@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,435 @@
1
+ // ============================================================================
2
+ // ASCII renderer — edge bundling for parallel links
3
+ //
4
+ // Analyzes edges to find parallel links (A & B --> C or A --> B & C) and
5
+ // groups them into bundles. Bundled edges share a visual junction point
6
+ // where they merge/split, creating cleaner diagrams.
7
+ //
8
+ // This module provides:
9
+ // - analyzeEdgeBundles(): Finds and creates bundles from graph edges
10
+ // - calculateJunctionPoint(): Computes optimal merge/split locations
11
+ // - routeBundledEdges(): Routes edges through junction points
12
+ // ============================================================================
13
+
14
+ import type {
15
+ AsciiGraph,
16
+ AsciiNode,
17
+ AsciiEdge,
18
+ EdgeBundle,
19
+ GridCoord,
20
+ CardinalDirection,
21
+ } from './types.ts'
22
+ import {
23
+ Up,
24
+ Down,
25
+ Left,
26
+ Right,
27
+ Middle,
28
+ gridCoordDirection,
29
+ requireCardinalDirection,
30
+ } from './types.ts'
31
+ import { getNodeSubgraph, requireGridCoord } from './grid.ts'
32
+ import { routeEdge } from './pathfinder.ts'
33
+
34
+ // ============================================================================
35
+ // Bundle analysis
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Analyze graph edges and create bundles for parallel links.
40
+ *
41
+ * Groups edges by:
42
+ * - Fan-in: Multiple edges sharing the same target (A & B --> C)
43
+ * - Fan-out: Multiple edges sharing the same source (A --> B & C)
44
+ *
45
+ * Only creates bundles when:
46
+ * - Graph direction is TD (top-down) - LR routing handles merging naturally
47
+ * - 2+ edges share the endpoint
48
+ * - All edges have the same style (solid/dotted/thick)
49
+ * - None of the edges have labels (labels would overlap at junction)
50
+ * - Edges are not self-loops
51
+ *
52
+ * @returns Array of bundles. Each edge can belong to at most one bundle.
53
+ */
54
+ export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] {
55
+ // Only bundle in TD direction - LR routing handles merging naturally at corners
56
+ if (graph.config.graphDirection !== 'TD') {
57
+ return []
58
+ }
59
+ const bundles: EdgeBundle[] = []
60
+ const bundledEdges = new Set<AsciiEdge>()
61
+
62
+ // Group edges by target (fan-in candidates)
63
+ const edgesByTarget = new Map<AsciiNode, AsciiEdge[]>()
64
+ for (const edge of graph.edges) {
65
+ // Skip self-loops
66
+ if (edge.from === edge.to) continue
67
+
68
+ const existing = edgesByTarget.get(edge.to) ?? []
69
+ existing.push(edge)
70
+ edgesByTarget.set(edge.to, existing)
71
+ }
72
+
73
+ // Create fan-in bundles
74
+ for (const [target, edges] of edgesByTarget) {
75
+ if (edges.length < 2) continue
76
+ if (!canBundle(edges, graph)) continue
77
+
78
+ // Check if all edges are already bundled
79
+ if (edges.some((e) => bundledEdges.has(e))) continue
80
+
81
+ const bundle: EdgeBundle = {
82
+ type: 'fan-in',
83
+ edges: [...edges],
84
+ sharedNode: target,
85
+ otherNodes: edges.map((e) => e.from),
86
+ junctionPoint: null,
87
+ sharedPath: [],
88
+ junctionDir: Middle,
89
+ sharedNodeDir: Middle,
90
+ }
91
+
92
+ // Mark edges as bundled
93
+ for (const edge of edges) {
94
+ edge.bundle = bundle
95
+ bundledEdges.add(edge)
96
+ }
97
+
98
+ bundles.push(bundle)
99
+ }
100
+
101
+ // Group edges by source (fan-out candidates)
102
+ const edgesBySource = new Map<AsciiNode, AsciiEdge[]>()
103
+ for (const edge of graph.edges) {
104
+ // Skip self-loops and already bundled edges
105
+ if (edge.from === edge.to) continue
106
+ if (bundledEdges.has(edge)) continue
107
+
108
+ const existing = edgesBySource.get(edge.from) ?? []
109
+ existing.push(edge)
110
+ edgesBySource.set(edge.from, existing)
111
+ }
112
+
113
+ // Create fan-out bundles
114
+ for (const [source, edges] of edgesBySource) {
115
+ if (edges.length < 2) continue
116
+ if (!canBundle(edges, graph)) continue
117
+
118
+ const bundle: EdgeBundle = {
119
+ type: 'fan-out',
120
+ edges: [...edges],
121
+ sharedNode: source,
122
+ otherNodes: edges.map((e) => e.to),
123
+ junctionPoint: null,
124
+ sharedPath: [],
125
+ junctionDir: Middle,
126
+ sharedNodeDir: Middle,
127
+ }
128
+
129
+ // Mark edges as bundled
130
+ for (const edge of edges) {
131
+ edge.bundle = bundle
132
+ bundledEdges.add(edge)
133
+ }
134
+
135
+ bundles.push(bundle)
136
+ }
137
+
138
+ return bundles
139
+ }
140
+
141
+ /**
142
+ * Check if a group of edges can be bundled together.
143
+ * Returns false if edges have different styles, any have labels,
144
+ * or if the edges span subgraph boundaries (which creates complex routing).
145
+ */
146
+ function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean {
147
+ if (edges.length < 2) return false
148
+
149
+ const firstStyle = edges[0]!.style
150
+ const firstFromSg = getNodeSubgraph(graph, edges[0]!.from)
151
+ const firstToSg = getNodeSubgraph(graph, edges[0]!.to)
152
+
153
+ for (const edge of edges) {
154
+ // Different styles can't be bundled (would look confusing)
155
+ if (edge.style !== firstStyle) return false
156
+
157
+ // Edges with labels can't be bundled (labels would overlap at junction)
158
+ if (edge.text.length > 0) return false
159
+
160
+ // Don't bundle if edges span different subgraph boundaries
161
+ // (creates complex routing that doesn't look good)
162
+ const fromSg = getNodeSubgraph(graph, edge.from)
163
+ const toSg = getNodeSubgraph(graph, edge.to)
164
+ if (fromSg !== firstFromSg || toSg !== firstToSg) return false
165
+
166
+ // Don't bundle if source and target are in different subgraphs
167
+ // (cross-boundary edges have special routing needs)
168
+ if (fromSg !== toSg) return false
169
+ }
170
+
171
+ // A group formed by shared-target (fan-in) or shared-source (fan-out)
172
+ // grouping can still contain two edges that ALSO share the other
173
+ // endpoint — true parallel/multi-edges (e.g. two unlabeled `A --> B`
174
+ // edges), not a genuine fan-in/fan-out. Folding those into one shared
175
+ // trunk+junction would visually merge them into a single line (the same
176
+ // "second edge silently wins" defect this bundling feature is supposed
177
+ // to avoid — see #329), rather than the distinct offset lanes
178
+ // assignParallelEdgeLanes (edge-routing.ts) already gives them. Detect it
179
+ // generically: the group is fan-in (shared `.to`) or fan-out (shared
180
+ // `.from`) by construction (see analyzeEdgeBundles' two call sites), so
181
+ // checking whichever endpoint *isn't* uniformly shared for duplicates
182
+ // catches both cases without the caller having to say which one it is.
183
+ const sameTarget = edges.every((e) => e.to === edges[0]!.to)
184
+ const otherEndpoints = sameTarget
185
+ ? edges.map((e) => e.from)
186
+ : edges.map((e) => e.to)
187
+ if (new Set(otherEndpoints).size !== otherEndpoints.length) return false
188
+
189
+ // The duplicate check above only catches an *ordered*-pair duplicate. A
190
+ // reverse-direction sibling (`B --> A` alongside `A --> B`, #629) reaches
191
+ // a fan-in/fan-out group with a *distinct* other endpoint and slips past
192
+ // it, even though assignParallelEdgeLanes has already committed that pair
193
+ // to two distinct lanes — and a lane is a fixed offset from the pair's
194
+ // own center path, not from wherever a trunk would have moved its sibling
195
+ // to. So no edge assigned to a lane group ever gets bundled.
196
+ //
197
+ // Defensive: currently unreachable, and deliberately kept anyway. A
198
+ // reverse-direction lane is only assigned when the pair sits side by side
199
+ // on one grid row (edge-routing.ts's sharesHorizontalChannel), which is
200
+ // exactly what the rank-ordering check below already rejects — every
201
+ // source must be strictly above the shared target (or every target
202
+ // strictly below the shared source). Those two conditions happen to be
203
+ // complementary today; nothing structural keeps them that way.
204
+ /* v8 ignore next */
205
+ if (edges.some((e) => e.parallelLane)) return false
206
+
207
+ // Bundling assumes every source sits strictly before the shared target
208
+ // along the graph-direction axis for fan-in (TD: above it; LR: left of
209
+ // it), or every target sits strictly after the shared source for
210
+ // fan-out — calculateJunctionPoint places the junction on that side of
211
+ // the shared node and routeBundledEdges exits/enters it accordingly. The
212
+ // grid layout can violate that: a node's rank gets pinned by whichever
213
+ // incoming edge's source is placed first (placeReachableChildren in
214
+ // grid.ts never revisits an already-placed node), so a "diamond" like
215
+ // `Queue --> Worker` plus `Queue --> Retry --> Worker` can leave Worker
216
+ // at the SAME rank as Retry instead of one rank after it (#454).
217
+ // Bundling that pair would route Retry's edge through the same
218
+ // trunk+arrowhead as Queue's, silently swallowing Retry's own arrowhead
219
+ // into a line that looks like it belongs solely to Queue. Refuse to
220
+ // bundle whenever any edge doesn't actually satisfy the "before the
221
+ // target" / "after the source" assumption; routed independently instead,
222
+ // it gets its own distinct, visible arrowhead into/out of the shared node.
223
+ // `analyzeEdgeBundles` (this function's only caller) already gates on
224
+ // `graphDirection === 'TD'` before ever calling `canBundle`, so the 'LR'
225
+ // arm is unreachable today — kept for whenever LR bundling support
226
+ // lands, matching this comment block's own "TD: above it; LR: left of
227
+ // it" framing above.
228
+ /* v8 ignore next */
229
+ const axis: 'x' | 'y' = graph.config.graphDirection === 'LR' ? 'x' : 'y'
230
+ const sharedNode = sameTarget ? edges[0]!.to : edges[0]!.from
231
+ const sharedCoord = sharedNode.gridCoord
232
+ // Grid layout always runs before bundling — every node reaching this
233
+ // point has a gridCoord (see `requireGridCoord`'s own use lower in this
234
+ // file for the same assumption). Defensive only; not reachable from any
235
+ // real diagram.
236
+ /* v8 ignore next */
237
+ if (!sharedCoord) return false
238
+ for (const edge of edges) {
239
+ const otherNode = sameTarget ? edge.from : edge.to
240
+ const otherCoord = otherNode.gridCoord
241
+ /* v8 ignore next */
242
+ if (!otherCoord) return false
243
+ if (sameTarget) {
244
+ // fan-in: source must be strictly before the shared target
245
+ if (otherCoord[axis] >= sharedCoord[axis]) return false
246
+ } else {
247
+ // fan-out: target must be strictly after the shared source. The
248
+ // fan-in violation above is reachable via a plain diamond (a shared
249
+ // target reached both directly and via a detour — see #454's own
250
+ // repro and this file's regression test) because rank assignment
251
+ // can shortchange whichever of two *competing parents* gets visited
252
+ // second. The mirror-image fan-out violation would need a target's
253
+ // rank to land at or before its own source's — topologically only
254
+ // possible with an actual cycle, not exercised by any sample in
255
+ // this repo. Kept for symmetry with the fan-in case above (and
256
+ // because a cycle isn't inherently invalid mermaid syntax) rather
257
+ // than assumed impossible.
258
+ /* v8 ignore next */
259
+ if (otherCoord[axis] <= sharedCoord[axis]) return false
260
+ }
261
+ }
262
+
263
+ return true
264
+ }
265
+
266
+ // ============================================================================
267
+ // Junction point calculation
268
+ // ============================================================================
269
+
270
+ /**
271
+ * Calculate the optimal junction point for a bundle.
272
+ *
273
+ * For fan-in (A & B --> C):
274
+ * - Junction is placed between the sources and the target
275
+ * - In TD: above the target, horizontally centered between sources
276
+ * - In LR: left of the target, vertically centered between sources
277
+ *
278
+ * For fan-out (A --> B & C):
279
+ * - Junction is placed between the source and the targets
280
+ * - In TD: below the source, horizontally centered between targets
281
+ * - In LR: right of the source, vertically centered between targets
282
+ */
283
+ export function calculateJunctionPoint(
284
+ graph: AsciiGraph,
285
+ bundle: EdgeBundle,
286
+ ): GridCoord {
287
+ const dir = graph.config.graphDirection
288
+ const sharedCoord = requireGridCoord(bundle.sharedNode)
289
+
290
+ if (bundle.type === 'fan-in') {
291
+ // Junction is BEFORE the shared target
292
+ // Calculate center of sources
293
+ if (dir === 'TD') {
294
+ // Junction above target, centered between sources
295
+ // Place it one row above the target's entry point
296
+ const junctionY = sharedCoord.y - 1
297
+ // X is centered between sources, but clamped to shared node's X for alignment
298
+ const junctionX = sharedCoord.x + 1 // Align with target's center
299
+
300
+ return { x: junctionX, y: junctionY }
301
+ } else {
302
+ // LR: Junction left of target, centered between sources
303
+ const junctionX = sharedCoord.x - 1
304
+ const junctionY = sharedCoord.y + 1 // Align with target's center
305
+
306
+ return { x: junctionX, y: junctionY }
307
+ }
308
+ } else {
309
+ // fan-out: Junction is AFTER the shared source
310
+ if (dir === 'TD') {
311
+ // Junction below source, will then split to targets
312
+ const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block
313
+ const junctionX = sharedCoord.x + 1 // Align with source's center
314
+
315
+ return { x: junctionX, y: junctionY }
316
+ } else {
317
+ // LR: Junction right of source
318
+ const junctionX = sharedCoord.x + 3
319
+ const junctionY = sharedCoord.y + 1
320
+
321
+ return { x: junctionX, y: junctionY }
322
+ }
323
+ }
324
+ }
325
+
326
+ // ============================================================================
327
+ // Bundled edge routing
328
+ // ============================================================================
329
+
330
+ /**
331
+ * Route all edges in a bundle through the junction point.
332
+ *
333
+ * For fan-in bundles:
334
+ * 1. Route each source → junction (stored in edge.pathToJunction)
335
+ * 2. Route junction → target (stored in bundle.sharedPath)
336
+ *
337
+ * For fan-out bundles:
338
+ * 1. Route source → junction (stored in bundle.sharedPath)
339
+ * 2. Route junction → each target (stored in edge.pathToJunction)
340
+ */
341
+ export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void {
342
+ const dir = graph.config.graphDirection
343
+
344
+ // Calculate and store junction point
345
+ bundle.junctionPoint = calculateJunctionPoint(graph, bundle)
346
+ const junction = bundle.junctionPoint
347
+
348
+ // Determine directions based on graph direction and bundle type
349
+ if (bundle.type === 'fan-in') {
350
+ // Sources converge to junction, then junction to target
351
+ bundle.junctionDir = dir === 'TD' ? Up : Left
352
+ bundle.sharedNodeDir = dir === 'TD' ? Down : Right
353
+
354
+ // Route junction → target (shared path). Anchor offsets (top/left
355
+ // center of target, bottom/right center of source below) are computed
356
+ // via gridCoordDirection — the same per-direction offset edge-routing.ts
357
+ // uses — instead of each bundling call reimplementing its own
358
+ // arithmetic. routeEdge (pathfinder.ts, also used by edge-routing.ts)
359
+ // tries an unobstructed direct path before falling back to A*. Note
360
+ // that `targetDir` here is the *arrival* anchor at the target, not
361
+ // necessarily the true departure direction from `junction` — routeEdge
362
+ // handles that by trying both L-corner orientations internally (see
363
+ // tryDirectPath in pathfinder.ts) rather than trusting this value as a
364
+ // routing axis.
365
+ const targetCoord = requireGridCoord(bundle.sharedNode)
366
+ const targetDir: CardinalDirection = requireCardinalDirection(
367
+ dir === 'TD' ? Up : Left,
368
+ )
369
+ const targetEntry = gridCoordDirection(targetCoord, targetDir)
370
+
371
+ const sharedPath = routeEdge(graph, junction, targetEntry, targetDir)
372
+ bundle.sharedPath = sharedPath ?? [junction, targetEntry]
373
+
374
+ // Route each source → junction
375
+ for (const edge of bundle.edges) {
376
+ const sourceCoord = requireGridCoord(edge.from)
377
+ const sourceDir: CardinalDirection = requireCardinalDirection(
378
+ dir === 'TD' ? Down : Right,
379
+ )
380
+ const sourceExit = gridCoordDirection(sourceCoord, sourceDir)
381
+
382
+ const pathToJunction = routeEdge(graph, sourceExit, junction, sourceDir)
383
+ edge.pathToJunction = pathToJunction ?? [sourceExit, junction]
384
+
385
+ // Set edge directions for proper drawing
386
+ edge.startDir = sourceDir
387
+ edge.endDir = targetDir
388
+
389
+ // Build full path for grid size calculation: source → junction → target
390
+ edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)]
391
+ }
392
+ } else {
393
+ // fan-out: Source to junction, then junction splits to targets
394
+ bundle.junctionDir = dir === 'TD' ? Down : Right
395
+ bundle.sharedNodeDir = dir === 'TD' ? Up : Left
396
+
397
+ // Route source → junction (shared path)
398
+ const sourceCoord = requireGridCoord(bundle.sharedNode)
399
+ const sourceDir: CardinalDirection = requireCardinalDirection(
400
+ dir === 'TD' ? Down : Right,
401
+ )
402
+ const sourceExit = gridCoordDirection(sourceCoord, sourceDir)
403
+
404
+ const sharedPath = routeEdge(graph, sourceExit, junction, sourceDir)
405
+ bundle.sharedPath = sharedPath ?? [sourceExit, junction]
406
+
407
+ // Route junction → each target
408
+ for (const edge of bundle.edges) {
409
+ const targetCoord = requireGridCoord(edge.to)
410
+ const targetDir: CardinalDirection = requireCardinalDirection(
411
+ dir === 'TD' ? Up : Left,
412
+ )
413
+ const targetEntry = gridCoordDirection(targetCoord, targetDir)
414
+
415
+ const pathToJunction = routeEdge(graph, junction, targetEntry, targetDir)
416
+ edge.pathToJunction = pathToJunction ?? [junction, targetEntry]
417
+
418
+ // Set edge directions
419
+ edge.startDir = sourceDir
420
+ edge.endDir = targetDir
421
+
422
+ // Build full path for grid size calculation: source → junction → target
423
+ edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)]
424
+ }
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Process all bundles in a graph: calculate junction points and route edges.
430
+ */
431
+ export function processBundles(graph: AsciiGraph): void {
432
+ for (const bundle of graph.bundles) {
433
+ routeBundledEdges(graph, bundle)
434
+ }
435
+ }
@@ -0,0 +1,209 @@
1
+ // ============================================================================
2
+ // ASCII renderer — cross-style edge overlap detection
3
+ //
4
+ // `graph.grid` (grid-occupancy.ts) only tracks *node* occupancy, so two
5
+ // unrelated edges (different source and target — no reason for
6
+ // `analyzeEdgeBundles` to group them, and A* itself only avoids node cells)
7
+ // can independently route through the same empty cell. When both edges have
8
+ // the *same* line style that's harmless — it just looks like one clean
9
+ // shared line, which `analyzeEdgeBundles`'s "LR routing handles merging
10
+ // naturally at corners" comment and its own trunk-sharing tests rely on
11
+ // intentionally. It's only a real defect when the styles *differ*: a solid
12
+ // cell and a dotted cell can't both be drawn at the same grid position, so
13
+ // one silently overwrites the other and the reader can't tell two distinct
14
+ // connections are there at all.
15
+ //
16
+ // This module tracks, per *open, non-node* cell, which single style has
17
+ // claimed it (first claim wins) so grid.ts can detect a genuine cross-style
18
+ // collision after routing an edge and reroute just that edge around it —
19
+ // see `rerouteAroundStyleConflicts` in grid.ts.
20
+ //
21
+ // Node-occupied cells are deliberately excluded from tracking: an edge's
22
+ // path always includes its own source/target node's border cell (that's
23
+ // how it connects to the box at all), so two edges sharing a node — one
24
+ // incoming, one outgoing, as in a retry loop's `B -->|No| D; D -.-> A` —
25
+ // legitimately share that exact port cell. The character actually drawn
26
+ // there comes from the node's own box-drawing code, not either edge's line
27
+ // style, so it was never a real conflict. Blocking it wouldn't even help:
28
+ // an edge always returns to its own node's fixed attachment point
29
+ // regardless of grid occupancy, so re-routing around a node-owned "conflict"
30
+ // just finds the identical cell again on every retry.
31
+ // ============================================================================
32
+
33
+ import type { AsciiEdge, AsciiEdgeStyle, GridCoord } from './types.ts'
34
+ import { gridKey } from './types.ts'
35
+ import { isOccupied, pathCells, type Grid } from './grid-occupancy.ts'
36
+
37
+ /** Cells already claimed by a drawn edge, keyed by "x,y", storing which
38
+ * single line style is drawn there. */
39
+ export type EdgeCellStyles = Map<string, AsciiEdgeStyle>
40
+
41
+ export function createEdgeCellStyles(): EdgeCellStyles {
42
+ return new Map()
43
+ }
44
+
45
+ /**
46
+ * The first open (non-node-occupied) cell in `path` already claimed by a
47
+ * *different* style than `style`. Same-style overlap is not reported — see
48
+ * module doc.
49
+ */
50
+ export function findStyleConflict(
51
+ grid: Grid,
52
+ cellStyles: EdgeCellStyles,
53
+ path: readonly GridCoord[],
54
+ style: AsciiEdgeStyle,
55
+ ): GridCoord | null {
56
+ for (const cell of pathCells(path)) {
57
+ if (isOccupied(grid, cell)) continue
58
+ const existing = cellStyles.get(gridKey(cell))
59
+ if (existing !== undefined && existing !== style) return cell
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Record every open (non-node-occupied) cell in `path` as claimed by
66
+ * `style`. First claim per cell wins — a later same-style edge through the
67
+ * same cell is a harmless no-op, and a later different-style edge is
68
+ * caught by `findStyleConflict` before this ever runs for it.
69
+ */
70
+ export function claimPathCells(
71
+ grid: Grid,
72
+ cellStyles: EdgeCellStyles,
73
+ path: readonly GridCoord[],
74
+ style: AsciiEdgeStyle,
75
+ ): void {
76
+ for (const cell of pathCells(path)) {
77
+ if (isOccupied(grid, cell)) continue
78
+ const key = gridKey(cell)
79
+ if (!cellStyles.has(key)) cellStyles.set(key, style)
80
+ }
81
+ }
82
+
83
+ // ============================================================================
84
+ // Chain-edge overlap detection (independent of style)
85
+ // ============================================================================
86
+ //
87
+ // The cross-*style* conflict above only fires when two overlapping edges
88
+ // draw different characters — a same-style overlap silently draws the same
89
+ // glyph twice, which this module's own doc calls harmless, and it usually
90
+ // is: `analyzeEdgeBundles`'s own fan-in/fan-out trunk sharing is same-style
91
+ // by construction, and a "complete bipartite" crossing like `A & B --> C &
92
+ // D` (see ascii.test.ts's `ampersand_lhs_and_rhs` golden file) *deliberately*
93
+ // routes two edges that share neither endpoint (`A --> D` and `B --> C`)
94
+ // through the same shared crossbar — that is the intended, faithful
95
+ // rendering of that shape, not a defect.
96
+ //
97
+ // It stops being harmless in one specific shape a plain "shares an
98
+ // endpoint" check can't distinguish from that crossing pattern: a *chain*
99
+ // through a shared intermediate node, `A --> B` then `B --> C`, each routed
100
+ // independently. When their corners happen to coincide, `A`'s incoming leg
101
+ // and `C`'s outgoing leg trace the exact same column/row beyond the shared,
102
+ // node-owned port cell and read as one continuous `A --> C` connector
103
+ // (#1067, "System Architecture": `Mobile App --> API Gateway` and
104
+ // `API Gateway --> User Service` sharing one on-screen row with no visual
105
+ // break, even though there is no `Mobile App --> User Service` edge in the
106
+ // source). Unlike the crossing pattern above, a chain's two edges have no
107
+ // reason to share open-space cells at all beyond that one port — so this
108
+ // check is scoped *only* to chain pairs (`isChainPair` below), not to every
109
+ // pair of edges that merely shares neither endpoint; widening it to that
110
+ // general case is exactly what broke the ampersand golden file during this
111
+ // fix's own development.
112
+
113
+ /** Cells already claimed by drawn edges, keyed by "x,y", storing every edge
114
+ * (by reference) that drew there — independent of `EdgeCellStyles` above,
115
+ * which tracks *style* rather than edge identity.
116
+ *
117
+ * Stores a `Set`, not a single edge: `graph.edges` order means an edge
118
+ * unrelated to any chain can claim a cell before a real chain pair's own
119
+ * edges do, and a single-owner map would let that unrelated edge's claim
120
+ * hide the chain pair's overlap from `findUnrelatedOverlap` entirely. See
121
+ * that function's own doc.
122
+ */
123
+ export type EdgeCellOwners = Map<string, Set<AsciiEdge>>
124
+
125
+ export function createEdgeCellOwners(): EdgeCellOwners {
126
+ return new Map()
127
+ }
128
+
129
+ /**
130
+ * Whether `a` and `b` form a chain through a shared intermediate node: one
131
+ * edge's target is the other's source. Deliberately narrower than "shares
132
+ * any endpoint" — a fan-in/fan-out pair (shared `from` or shared `to`) is
133
+ * excluded on purpose, since that is exactly the shape a legitimate shared
134
+ * trunk (bundled or not — see module doc) already routes through common
135
+ * cells for.
136
+ */
137
+ function isChainPair(a: AsciiEdge, b: AsciiEdge): boolean {
138
+ return a.to === b.from || b.to === a.from
139
+ }
140
+
141
+ /**
142
+ * Minimum number of open, non-node cells a chain pair must share before it
143
+ * counts as a real conflict. A single shared cell is an ordinary crossing
144
+ * (two independent lines passing through the same point, which still reads
145
+ * as two lines) — it takes a *run* of shared cells, long enough to read as
146
+ * one continuous connector, to actually mislead a reader. Chosen to be the
147
+ * smallest value that catches a shared corner-to-corner leg (at least 2
148
+ * collinear cells) while still letting a lone crossing through.
149
+ */
150
+ const MIN_CHAIN_OVERLAP = 2
151
+
152
+ /**
153
+ * The first open (non-node-occupied) cell in `path` already claimed by a
154
+ * different edge that forms a chain with `edge` (`isChainPair`) — but only
155
+ * once *that specific chain partner's* total overlap with `edge` reaches
156
+ * `MIN_CHAIN_OVERLAP` cells (see that constant's doc). Each distinct owner
157
+ * of a cell is checked and counted independently — a cell can hold several
158
+ * edges' claims (see `EdgeCellOwners`'s own doc), and an unrelated same-style
159
+ * edge sharing a cell must not hide a real chain partner's overlap that also
160
+ * claimed it. Returns `null` when no chain partner's overlap reaches the
161
+ * threshold.
162
+ */
163
+ export function findUnrelatedOverlap(
164
+ grid: Grid,
165
+ owners: EdgeCellOwners,
166
+ path: readonly GridCoord[],
167
+ edge: AsciiEdge,
168
+ ): GridCoord | null {
169
+ const overlapCounts = new Map<AsciiEdge, number>()
170
+ const firstConflicts = new Map<AsciiEdge, GridCoord>()
171
+ for (const cell of pathCells(path)) {
172
+ if (isOccupied(grid, cell)) continue
173
+ const cellOwners = owners.get(gridKey(cell))
174
+ if (cellOwners === undefined) continue
175
+ for (const owner of cellOwners) {
176
+ if (owner === edge) continue
177
+ if (!isChainPair(owner, edge)) continue
178
+ overlapCounts.set(owner, (overlapCounts.get(owner) ?? 0) + 1)
179
+ if (!firstConflicts.has(owner)) firstConflicts.set(owner, cell)
180
+ }
181
+ }
182
+ for (const [owner, count] of overlapCounts) {
183
+ if (count >= MIN_CHAIN_OVERLAP) return firstConflicts.get(owner)!
184
+ }
185
+ return null
186
+ }
187
+
188
+ /**
189
+ * Record every open (non-node-occupied) cell in `path` as claimed by
190
+ * `edge`, alongside any edge(s) that already claimed it — see
191
+ * `EdgeCellOwners`'s own doc for why a cell can have more than one owner.
192
+ */
193
+ export function claimPathOwners(
194
+ grid: Grid,
195
+ owners: EdgeCellOwners,
196
+ path: readonly GridCoord[],
197
+ edge: AsciiEdge,
198
+ ): void {
199
+ for (const cell of pathCells(path)) {
200
+ if (isOccupied(grid, cell)) continue
201
+ const key = gridKey(cell)
202
+ let cellOwners = owners.get(key)
203
+ if (cellOwners === undefined) {
204
+ cellOwners = new Set()
205
+ owners.set(key, cellOwners)
206
+ }
207
+ cellOwners.add(edge)
208
+ }
209
+ }