@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,1488 @@
1
+ // ============================================================================
2
+ // ASCII renderer — ER diagrams
3
+ //
4
+ // Renders erDiagram text to ASCII/Unicode art.
5
+ // Each entity is a 2-section box (header | attributes).
6
+ // Relationships are drawn as lines with crow's foot notation at endpoints.
7
+ //
8
+ // Layout: entities are placed in a grid pattern (multiple rows if needed).
9
+ // Relationship lines use Manhattan routing between entity boxes.
10
+ // ============================================================================
11
+
12
+ import { parseErDiagram } from '@zombie-mermaid/mermaid-parser'
13
+ import type {
14
+ ErDiagram,
15
+ ErEntity,
16
+ ErAttribute,
17
+ Cardinality,
18
+ } from '@zombie-mermaid/mermaid-parser'
19
+ import type {
20
+ AsciiConfig,
21
+ CharRole,
22
+ AsciiTheme,
23
+ ColorMode,
24
+ Canvas,
25
+ } from './types.ts'
26
+ import {
27
+ mkCanvas,
28
+ mkRoleCanvas,
29
+ canvasToString,
30
+ increaseSize,
31
+ increaseRoleCanvasSize,
32
+ write,
33
+ } from './canvas.ts'
34
+ import { drawMultiBox, measureMultiBox, classifyBoxChar } from './draw.ts'
35
+ import { splitLines, maxLineWidth } from './multiline-utils.ts'
36
+ import { splitStatements } from '@zombie-mermaid/core'
37
+ import { toDisplayCells } from './display-width.ts'
38
+ import { findFreeLane } from './lane-search.ts'
39
+ import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
40
+
41
+ // ============================================================================
42
+ // Entity box content
43
+ // ============================================================================
44
+
45
+ /** Format an attribute line: "PK type name" or "FK type name" etc. */
46
+ function formatAttribute(attr: ErAttribute): string {
47
+ const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' '
48
+ return `${keyStr}${attr.type} ${attr.name}`
49
+ }
50
+
51
+ /** Build sections for an entity box: [header], [attributes] */
52
+ function buildEntitySections(entity: ErEntity): string[][] {
53
+ // Support multi-line entity names
54
+ const header = splitLines(entity.label)
55
+ const attrs = entity.attributes.map(formatAttribute)
56
+ if (attrs.length === 0) return [header]
57
+ return [header, attrs]
58
+ }
59
+
60
+ // ============================================================================
61
+ // Crow's foot notation
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Returns the ASCII/Unicode characters for a crow's foot cardinality marker.
66
+ * Markers are drawn adjacent to entity boxes at relationship endpoints.
67
+ *
68
+ * Standard ER notation:
69
+ * one: ─┤├─ perpendicular line (exactly one)
70
+ * zero-one: ─○┤─ circle + perpendicular (zero or one)
71
+ * many: ─<>─ crow's foot (one or more)
72
+ * zero-many: ─○<─ circle + crow's foot (zero or more)
73
+ *
74
+ * @param card - The cardinality type
75
+ * @param useAscii - Use ASCII-only characters
76
+ * @param isRight - True if this marker is on the right side of the relationship
77
+ */
78
+ function getCrowsFootChars(
79
+ card: Cardinality,
80
+ useAscii: boolean,
81
+ isRight = false,
82
+ vertical = false,
83
+ ): string {
84
+ if (useAscii) {
85
+ switch (card) {
86
+ case 'one':
87
+ // A bare '-' has no vertical extent, so it reads as a gap in the
88
+ // vertical line rather than a tick crossing it (issue: PR #442's
89
+ // fix made the marker visible but visually severed the line). '+'
90
+ // carries both strokes, matching how '|' crosses the horizontal
91
+ // line in the non-vertical case below.
92
+ return vertical ? '+' : '|'
93
+ case 'zero-one':
94
+ return isRight ? 'o|' : '|o'
95
+ case 'many':
96
+ return isRight ? '<' : '>'
97
+ case 'zero-many':
98
+ return isRight ? 'o<' : '>o'
99
+ }
100
+ } else {
101
+ // Use cleaner Unicode characters
102
+ switch (card) {
103
+ case 'one':
104
+ // Same reasoning as the ASCII '+' above: '┼' keeps the vertical
105
+ // stroke (line stays visually continuous) while adding the
106
+ // horizontal stroke that marks the "one" cardinality.
107
+ return vertical ? '┼' : '│'
108
+ case 'zero-one':
109
+ return isRight ? '○│' : '│○'
110
+ case 'many':
111
+ return isRight ? '╟' : '╢'
112
+ case 'zero-many':
113
+ return isRight ? '○╟' : '╢○'
114
+ }
115
+ }
116
+ }
117
+
118
+ // ============================================================================
119
+ // Positioned entity
120
+ // ============================================================================
121
+
122
+ interface PlacedEntity {
123
+ entity: ErEntity
124
+ sections: string[][]
125
+ x: number
126
+ y: number
127
+ width: number
128
+ height: number
129
+ }
130
+
131
+ // ============================================================================
132
+ // Connected Component Detection
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Find connected components in the ER diagram using DFS.
137
+ * Treats relationships as undirected edges for connectivity.
138
+ *
139
+ * Returns an array of entity ID sets, one per connected component.
140
+ */
141
+ function findConnectedComponents(diagram: ErDiagram): Set<string>[] {
142
+ const visited = new Set<string>()
143
+ const components: Set<string>[] = []
144
+
145
+ // Build undirected adjacency list from relationships
146
+ const neighbors = new Map<string, Set<string>>()
147
+ for (const ent of diagram.entities) {
148
+ neighbors.set(ent.id, new Set())
149
+ }
150
+ for (const rel of diagram.relationships) {
151
+ neighbors.get(rel.entity1)?.add(rel.entity2)
152
+ neighbors.get(rel.entity2)?.add(rel.entity1)
153
+ }
154
+
155
+ // DFS to find each component
156
+ function dfs(startId: string, component: Set<string>): void {
157
+ const stack = [startId]
158
+ while (stack.length > 0) {
159
+ const nodeId = stack.pop()!
160
+ if (visited.has(nodeId)) continue
161
+
162
+ visited.add(nodeId)
163
+ component.add(nodeId)
164
+
165
+ for (const neighbor of neighbors.get(nodeId) ?? []) {
166
+ if (!visited.has(neighbor)) {
167
+ stack.push(neighbor)
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // Find all components
174
+ for (const ent of diagram.entities) {
175
+ if (!visited.has(ent.id)) {
176
+ const component = new Set<string>()
177
+ dfs(ent.id, component)
178
+ if (component.size > 0) {
179
+ components.push(component)
180
+ }
181
+ }
182
+ }
183
+
184
+ return components
185
+ }
186
+
187
+ // ============================================================================
188
+ // Layout and rendering
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Look up a per-entity value computed by renderErAscii's box-sizing pass.
193
+ * Every entity in `diagram.entities` gets an entry in `entityBoxW`,
194
+ * `entityBoxH`, and `entitySections` before layout runs, so this always
195
+ * succeeds for a real diagram entity — but that guarantee is established by
196
+ * a separate imperative loop the compiler can't connect back to this
197
+ * lookup, so it's checked explicitly rather than trusted via `!`.
198
+ */
199
+ function mustGetEntityValue<T>(
200
+ map: Map<string, T>,
201
+ entityId: string,
202
+ what: string,
203
+ ): T {
204
+ const value = map.get(entityId)
205
+ if (value === undefined) {
206
+ /* v8 ignore next */
207
+ throw new Error(
208
+ `ER diagram layout: missing ${what} for entity "${entityId}"`,
209
+ )
210
+ }
211
+ return value
212
+ }
213
+
214
+ /**
215
+ * Check whether every cell in row `y` across [xStart, xEnd] is still blank
216
+ * on `canvas`, ignoring column `skipX` (the relationship's own vertical
217
+ * stem, which legitimately already occupies that column across the whole
218
+ * gap before a jog row is chosen — see `chooseFreeRow`).
219
+ *
220
+ * A column past the canvas's current width isn't drawn yet — `canvas[x]` is
221
+ * `undefined` there — but it isn't *occupied* either: relationship labels
222
+ * routinely land past the initial bounds and grow the canvas on write (see
223
+ * `increaseSize` at the label-drawing call sites below). Treating that as
224
+ * "not free" made `chooseFreeRow` reject perfectly good candidate rows near
225
+ * the canvas edge and fall back to the plain midpoint instead, undermining
226
+ * the whole point of the search.
227
+ */
228
+ // Exported for direct unit testing (see check-diff-coverage.ts's own
229
+ // exports for the precedent this repo already uses) — renderErAscii's fixed
230
+ // vGap never produces a gap wide enough to exercise every branch of
231
+ // chooseFreeRow's search order through the full render pipeline alone, so
232
+ // the row-selection logic itself is tested directly against constructed
233
+ // inputs instead.
234
+ export function isRowFree(
235
+ canvas: Canvas,
236
+ y: number,
237
+ xStart: number,
238
+ xEnd: number,
239
+ skipX: number,
240
+ ): boolean {
241
+ for (let x = xStart; x <= xEnd; x++) {
242
+ if (x === skipX) continue
243
+ const col = canvas[x]
244
+ if (col === undefined) continue // not drawn yet — not occupied
245
+ if (col[y] !== ' ' && col[y] !== undefined) return false
246
+ }
247
+ return true
248
+ }
249
+
250
+ /**
251
+ * Choose a row for a vertical relationship's horizontal jog segment,
252
+ * preferring the geometric midpoint between `startY` and `endY` but
253
+ * scanning outward (alternating below/above) for the nearest row across
254
+ * [xStart, xEnd] that isn't already occupied — by an entity box the naive
255
+ * midpoint would otherwise cut through, or by another relationship's
256
+ * already-drawn jog (see issue #351: every vertical relationship between
257
+ * the same two component rows previously computed the *same* midpoint,
258
+ * so their jogs and labels landed on one shared row and overwrote each
259
+ * other). Candidates are restricted to the open interval (startY, endY) so
260
+ * the chosen row never collides with the crow's-foot markers flush against
261
+ * either entity border.
262
+ *
263
+ * Falls back to the plain midpoint — the only row every caller used before
264
+ * this fix — when the gap is too small to have any candidate row at all, or
265
+ * when every candidate in the gap is occupied (a dense diagram where
266
+ * avoiding collisions entirely isn't possible with this renderer's
267
+ * straight-jog routing).
268
+ *
269
+ * Exported for direct unit testing — see the comment on `isRowFree` above.
270
+ */
271
+ export function chooseFreeRow(
272
+ canvas: Canvas,
273
+ startY: number,
274
+ endY: number,
275
+ xStart: number,
276
+ xEnd: number,
277
+ skipX: number,
278
+ ): number {
279
+ const preferred = Math.floor((startY + endY) / 2)
280
+ // Candidates are the open interval (startY, endY) — `findFreeLane`'s
281
+ // bounds are inclusive, so they're the endpoints stepped one row inward.
282
+ // `preferred` itself is exempt from those bounds by design (see
283
+ // `findFreeLane`), which is what keeps the plain-midpoint fallback below
284
+ // reachable for a gap too narrow to hold any candidate at all.
285
+ return (
286
+ findFreeLane(preferred, startY + 1, endY - 1, (row) =>
287
+ isRowFree(canvas, row, xStart, xEnd, skipX),
288
+ ) ?? preferred
289
+ )
290
+ }
291
+
292
+ /**
293
+ * Render a Mermaid ER diagram to ASCII/Unicode text.
294
+ *
295
+ * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string.
296
+ */
297
+ export function renderErAscii(
298
+ text: string,
299
+ config: AsciiConfig,
300
+ colorMode?: ColorMode,
301
+ theme?: AsciiTheme,
302
+ ): string {
303
+ const lines = splitStatements(text)
304
+ const diagram = parseErDiagram(lines)
305
+
306
+ if (diagram.entities.length === 0) return ''
307
+
308
+ const useAscii = config.useAscii
309
+ // See paddingOffset's doc comment (types.ts) for why these are an offset
310
+ // from the padding defaults rather than the raw config values.
311
+ //
312
+ // hGap/vGap floors are higher than the generic "don't collapse to
313
+ // nothing" minimum of 1: crow's-foot markers are up to 2 cells wide, so a
314
+ // same-row relationship's two markers need room not to collide (see the
315
+ // horizontal-marker section below). A vertical relationship needs at
316
+ // least 2 rows so the upper and lower markers don't land on the same
317
+ // row. (See issue #343's CodeRabbit review.)
318
+ const hGap = paddingOffset(config.paddingX, DEFAULT_PADDING_X, 6, 6) // horizontal gap between entity boxes
319
+ const vGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 4, 2) // vertical gap between rows (for relationship lines)
320
+ // Vertical gap between disconnected components. Unlike vGap, this gap
321
+ // never needs to fit a relationship line, a jog, or a label — disconnected
322
+ // components have no edges between them by definition — so it only needs
323
+ // enough room to read as a visual break between unrelated entities, not
324
+ // the same routing headroom a same-component row wrap needs. A gap this
325
+ // large previously left most of the output blank for diagrams with a few
326
+ // small unrelated components (issue #351) — base lowered from 6 to 2
327
+ // accordingly; still offset from config.paddingY like the others.
328
+ const componentGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 2, 1)
329
+
330
+ // Widest crow's-foot marker glyph across every cardinality, in the
331
+ // current mode (ASCII/Unicode) — the same "up to 2 cells wide" fact the
332
+ // hGap/vGap comment above states, but computed here instead of restated
333
+ // as a raw literal, so anything sized off it (labelInset, labelX below)
334
+ // automatically tracks a future change to the marker glyph set (issue
335
+ // #415, following #384's precedent for hGap/vGap/componentGap).
336
+ const maxMarkerWidth = Math.max(
337
+ ...(['one', 'zero-one', 'many', 'zero-many'] as const).flatMap((card) => [
338
+ getCrowsFootChars(card, useAscii, false).length,
339
+ getCrowsFootChars(card, useAscii, true).length,
340
+ ]),
341
+ )
342
+
343
+ // --- Build entity box dimensions ---
344
+ const entitySections = new Map<string, string[][]>()
345
+ const entityBoxW = new Map<string, number>()
346
+ const entityBoxH = new Map<string, number>()
347
+ const entityById = new Map<string, ErEntity>()
348
+
349
+ for (const ent of diagram.entities) {
350
+ entityById.set(ent.id, ent)
351
+ const sections = buildEntitySections(ent)
352
+ entitySections.set(ent.id, sections)
353
+
354
+ // Reserve exactly what drawMultiBox will draw — measuring it here rather
355
+ // than re-deriving the arithmetic keeps layout and drawing in lockstep for
356
+ // wide-character (CJK/fullwidth) content.
357
+ const { width: boxW, height: boxH } = measureMultiBox(
358
+ sections,
359
+ config.boxBorderPadding,
360
+ )
361
+
362
+ entityBoxW.set(ent.id, boxW)
363
+ entityBoxH.set(ent.id, boxH)
364
+ }
365
+
366
+ // Widest relationship label between each unordered pair of entities.
367
+ // Used to widen the horizontal gap between entities so labels aren't
368
+ // truncated and keep at least 1 char of padding from both entity boxes
369
+ // (see issue #67 — labels like "ordered in" were clamped to the fixed
370
+ // 6-char gap and truncated to "ordere").
371
+ const pairLabelWidth = new Map<string, number>()
372
+ for (const rel of diagram.relationships) {
373
+ if (!rel.label) continue
374
+ const key = [rel.entity1, rel.entity2].sort().join('|')
375
+ const w = maxLineWidth(rel.label)
376
+ pairLabelWidth.set(key, Math.max(pairLabelWidth.get(key) ?? 0, w))
377
+ }
378
+
379
+ // --- Find connected components ---
380
+ const components = findConnectedComponents(diagram)
381
+
382
+ // --- Layout: place each component, then stack components vertically ---
383
+ const placed = new Map<string, PlacedEntity>()
384
+ let currentY = 0
385
+
386
+ for (const component of components) {
387
+ // Get entities in this component (preserve original order for consistency)
388
+ const componentEntities = diagram.entities.filter((e) =>
389
+ component.has(e.id),
390
+ )
391
+
392
+ // Layout entities within this component horizontally
393
+ // Use sqrt-based row limit for larger components
394
+ const maxPerRow = Math.max(
395
+ 2,
396
+ Math.ceil(Math.sqrt(componentEntities.length)),
397
+ )
398
+
399
+ let currentX = 0
400
+ let maxRowH = 0
401
+ let colCount = 0
402
+
403
+ for (let idx = 0; idx < componentEntities.length; idx++) {
404
+ const ent = componentEntities[idx]!
405
+ const w = mustGetEntityValue(entityBoxW, ent.id, 'box width')
406
+ const h = mustGetEntityValue(entityBoxH, ent.id, 'box height')
407
+
408
+ if (colCount >= maxPerRow) {
409
+ // Wrap to next row within this component
410
+ currentY += maxRowH + vGap
411
+ currentX = 0
412
+ maxRowH = 0
413
+ colCount = 0
414
+ }
415
+
416
+ placed.set(ent.id, {
417
+ entity: ent,
418
+ sections: mustGetEntityValue(entitySections, ent.id, 'sections'),
419
+ x: currentX,
420
+ y: currentY,
421
+ width: w,
422
+ height: h,
423
+ })
424
+
425
+ // Widen the gap to the next entity in this row so a connecting
426
+ // relationship's label fits with 1 char of padding on each side
427
+ // instead of being truncated or crammed against a box border.
428
+ let gap = hGap
429
+ const willWrapNext = colCount + 1 >= maxPerRow
430
+ const nextEnt = componentEntities[idx + 1]
431
+ if (!willWrapNext && nextEnt) {
432
+ const key = [ent.id, nextEnt.id].sort().join('|')
433
+ const labelW = pairLabelWidth.get(key) ?? 0
434
+ if (labelW > 0) gap = Math.max(gap, labelW + 2)
435
+ }
436
+
437
+ currentX += w + gap
438
+ maxRowH = Math.max(maxRowH, h)
439
+ colCount++
440
+ }
441
+
442
+ // Move to next component row (add gap between components)
443
+ currentY += maxRowH + componentGap
444
+ }
445
+
446
+ // --- Create canvas ---
447
+ let totalW = 0
448
+ let totalH = 0
449
+ for (const p of placed.values()) {
450
+ totalW = Math.max(totalW, p.x + p.width)
451
+ totalH = Math.max(totalH, p.y + p.height)
452
+ }
453
+ totalW += 4
454
+ totalH += 2
455
+
456
+ const canvas = mkCanvas(totalW - 1, totalH - 1)
457
+ const rc = mkRoleCanvas(totalW - 1, totalH - 1)
458
+
459
+ /**
460
+ * Set a character on the canvas and track its role.
461
+ * Delegates bounds-checking to the shared `write()` primitive
462
+ * (src/ascii/canvas.ts) instead of duplicating the guard here — see
463
+ * issue #171.
464
+ */
465
+ function setC(x: number, y: number, ch: string, role: CharRole): void {
466
+ write(canvas, x, y, ch, { role, roleCanvas: rc })
467
+ }
468
+
469
+ /**
470
+ * True for a cell a relationship draw must not silently overwrite: an
471
+ * entity's own box border, or 'text' (a label or an entity's own
472
+ * header/attribute text). Relationships are drawn in declaration order, so
473
+ * a later relationship's line, crow's-foot marker, or label can otherwise
474
+ * land on the exact cell an earlier one (or a plain entity box) already
475
+ * wrote, corrupting it — a stray line glyph mid-word, one label's
476
+ * characters spliced into another's, or (for a same-row relationship whose
477
+ * straight line runs across an unrelated entity sitting between its two
478
+ * endpoints) that entity's border erased outright (issue #392). This
479
+ * doesn't fix the underlying routing gap — the line still crosses straight
480
+ * through the box, an out-of-scope defect tracked in #351/#390's "known
481
+ * limitation" — it only stops that crossing from destroying content.
482
+ */
483
+ function isProtected(x: number, y: number): boolean {
484
+ const role = rc[x]?.[y]
485
+ return role === 'text' || role === 'border'
486
+ }
487
+
488
+ /**
489
+ * True when every cell a label's line would occupy (after clamping to
490
+ * [minX, maxX]) is free of protected content (see isProtected). Checked as
491
+ * a whole line rather than character-by-character: a per-character skip on
492
+ * a *label* write (unlike a line or marker) would let two overlapping
493
+ * labels' letters splice together into a new word that isn't either
494
+ * original label — e.g. "has" + the tail of "tagged-with" reading as
495
+ * "hasged-with" — which is more misleading than either label winning
496
+ * outright or neither appearing. A label either renders intact or is
497
+ * skipped entirely.
498
+ */
499
+ function canPlaceLabelLine(
500
+ cells: string[],
501
+ startX: number,
502
+ y: number,
503
+ minX: number,
504
+ maxX: number,
505
+ ): boolean {
506
+ for (let i = 0; i < cells.length; i++) {
507
+ const x = startX + i
508
+ if (x < minX || x > maxX) continue
509
+ if (isProtected(x, y)) return false
510
+ }
511
+ return true
512
+ }
513
+
514
+ /**
515
+ * Like canPlaceLabelLine, but also refuses a cell already holding a
516
+ * crow's-foot marker ('arrow'), or reserved by an entity box (see
517
+ * boxCells, defined below — referenced here by closure, since this
518
+ * function isn't actually called until after boxCells exists). Used only
519
+ * when searching for an alternate row for a vertical relationship's
520
+ * label (see below): a label's own natural row is allowed to sit on top
521
+ * of that same relationship's freshly-drawn line (expected — the label
522
+ * always wins over the line beneath it), but a *different* row picked
523
+ * specifically to dodge a collision shouldn't destroy a marker it
524
+ * happens to land on instead. The boxCells check matters most for a
525
+ * bypass-routed relationship (#350): a box's own blank interior padding
526
+ * never gets a role written to it, so isProtected alone would treat it
527
+ * as free even though it's inside another entity's box.
528
+ */
529
+ function canPlaceLabelLineAvoidingMarkers(
530
+ cells: string[],
531
+ startX: number,
532
+ y: number,
533
+ minX: number,
534
+ maxX: number,
535
+ ): boolean {
536
+ for (let i = 0; i < cells.length; i++) {
537
+ const x = startX + i
538
+ if (x < minX || x > maxX) continue
539
+ if (isProtected(x, y) || rc[x]?.[y] === 'arrow') return false
540
+ if (boxCells.has(`${x},${y}`)) return false
541
+ }
542
+ return true
543
+ }
544
+
545
+ // --- Draw entity boxes ---
546
+ for (const p of placed.values()) {
547
+ const boxCanvas = drawMultiBox(
548
+ p.sections,
549
+ useAscii,
550
+ config.boxBorderPadding,
551
+ )
552
+ for (let bx = 0; bx < boxCanvas.length; bx++) {
553
+ for (let by = 0; by < boxCanvas[0]!.length; by++) {
554
+ const ch = boxCanvas[bx]![by]!
555
+ if (ch !== ' ') {
556
+ const cx = p.x + bx
557
+ const cy = p.y + by
558
+ if (cx < totalW && cy < totalH) {
559
+ setC(cx, cy, ch, classifyBoxChar(ch))
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ // --- Snapshot cells occupied by entity boxes ---
567
+ // Taken once, right after boxes are drawn and before any relationship
568
+ // line, crow's-foot marker, or label is drawn, so relationship rendering
569
+ // can never silently overwrite a box border or attribute text (issue
570
+ // #350). The obstruction-aware routing below (see `obstructionBottom` and
571
+ // the vertical row-band clamp) avoids these cells in the common cases;
572
+ // this snapshot is the last-resort guarantee for whatever routing doesn't
573
+ // anticipate.
574
+ const boxCells = new Set<string>()
575
+ for (const p of placed.values()) {
576
+ for (let by = 0; by < p.height; by++) {
577
+ for (let bx = 0; bx < p.width; bx++) {
578
+ boxCells.add(`${p.x + bx},${p.y + by}`)
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Like setC, but refuses to draw into a cell reserved by an entity box
585
+ * (see boxCells above). Used for every relationship line, crow's-foot
586
+ * marker, and label write below, so a mis-routed segment degrades to a
587
+ * gap in the line rather than corrupting a box's border or attribute
588
+ * text.
589
+ */
590
+ function setCGuarded(x: number, y: number, ch: string, role: CharRole): void {
591
+ if (boxCells.has(`${x},${y}`)) return
592
+ // Two relationship lines are allowed to cross (a normal, expected part
593
+ // of ER layout), but a later relationship's line/marker must not punch
594
+ // through an earlier relationship's already-placed label text — that's
595
+ // the same silent-corruption shape as issue #350, just between two
596
+ // relationships instead of a relationship and a box.
597
+ if (role !== 'text' && rc[x]?.[y] === 'text') return
598
+ setC(x, y, ch, role)
599
+ }
600
+
601
+ /**
602
+ * Like setCGuarded, but for a horizontal line/dash fill: also backs off
603
+ * one cell when either neighbor already holds 'text' (carried over from
604
+ * the #392 fix's setRelHChar). isProtected/boxCells alone guard a
605
+ * label's own cells, but a *different*, later-processed relationship's
606
+ * horizontal jog — including the obstruction-detour and band-clamped
607
+ * routing added for #350 — can still run its dashes right up against an
608
+ * earlier one's label with zero visual gap (e.g. "────authors────").
609
+ * Only used for horizontal fills (same-row connection line, detour
610
+ * bottom, vertical connection's jog): a vertical '│' passing a label's
611
+ * row doesn't read as visually cramped the same way, so it isn't padded.
612
+ */
613
+ function setCGuardedH(
614
+ x: number,
615
+ y: number,
616
+ ch: string,
617
+ role: CharRole,
618
+ ): void {
619
+ if (rc[x - 1]?.[y] === 'text' || rc[x + 1]?.[y] === 'text') return
620
+ setCGuarded(x, y, ch, role)
621
+ }
622
+
623
+ /**
624
+ * True when every cell in the rectangle [xStart, xEnd] x [yStart, yEnd] is
625
+ * still blank — i.e. neither an entity box (see boxCells) nor a
626
+ * previously-drawn relationship line/marker/label occupies it. Used to
627
+ * pick a detour row for one relationship that doesn't land on top of
628
+ * another relationship's already-drawn line or label (both routed through
629
+ * the same row-gap band can otherwise silently overwrite each other).
630
+ * Reads directly from `canvas` rather than boxCells, since it must also
631
+ * see ink from relationships drawn earlier in this same loop.
632
+ */
633
+ function regionClear(
634
+ xStart: number,
635
+ xEnd: number,
636
+ yStart: number,
637
+ yEnd: number,
638
+ ): boolean {
639
+ for (let y = yStart; y <= yEnd; y++) {
640
+ for (let x = xStart; x <= xEnd; x++) {
641
+ const ch = canvas[x]?.[y]
642
+ if (ch !== undefined && ch !== ' ') return false
643
+ }
644
+ }
645
+ return true
646
+ }
647
+
648
+ /**
649
+ * Vertical relationship "lanes" already drawn — one entry per straight
650
+ * vertical run, added as each relationship's connector is finalized.
651
+ * Checked before drawing each subsequent vertical connector so that two
652
+ * *unrelated* relationships' stems never land on immediately-adjacent
653
+ * (but not identical) columns purely by coincidence: with nothing in the
654
+ * rendered output to distinguish them, that reads as a single connector
655
+ * that inexplicably shifts sideways partway down, rather than as two
656
+ * independent lines (issue #411). A relationship's own two segments
657
+ * (either side of its own jog) are never checked against this registry
658
+ * as they're computed — only against lanes registered by *earlier*
659
+ * relationships in this same loop — since a single line legitimately
660
+ * bending at its own jog is not the ambiguity this guards against.
661
+ */
662
+ const verticalLanes: { x: number; yStart: number; yEnd: number }[] = []
663
+
664
+ /**
665
+ * True when [yStart, yEnd] at column x sits one column from an existing
666
+ * lane whose row range overlaps it. Every call site passes a range
667
+ * derived from startY/endY (upper's bottom to lower's top, or a row
668
+ * within that span), which the layout always establishes as
669
+ * startY <= endY — a degenerate/inverted range never reaches here.
670
+ */
671
+ function verticalLaneConflict(
672
+ x: number,
673
+ yStart: number,
674
+ yEnd: number,
675
+ ): boolean {
676
+ for (const lane of verticalLanes) {
677
+ if (Math.abs(lane.x - x) !== 1) continue
678
+ if (yStart <= lane.yEnd && yEnd >= lane.yStart) return true
679
+ }
680
+ return false
681
+ }
682
+
683
+ /**
684
+ * Like verticalLaneConflict, but also rejects the *same* column as an
685
+ * existing lane (diff 0), not just an adjacent one (diff 1). Used only
686
+ * when searching for a via/bypass column (see the multiRowObstruction
687
+ * branch below): landing the bypass on another relationship's own
688
+ * column wouldn't just be visually adjacent, it would run straight
689
+ * through — and setCGuarded doesn't protect a 'line' cell from being
690
+ * overwritten by another 'line' write, so the two relationships' glyphs
691
+ * (including corner turns) would silently blend into one path, which is
692
+ * worse than the adjacent-column aliasing this fix targets in the first
693
+ * place.
694
+ */
695
+ function viaColumnBlocked(x: number, yStart: number, yEnd: number): boolean {
696
+ for (const lane of verticalLanes) {
697
+ if (Math.abs(lane.x - x) > 1) continue
698
+ if (yStart <= lane.yEnd && yEnd >= lane.yStart) return true
699
+ }
700
+ return false
701
+ }
702
+
703
+ function registerVerticalLane(x: number, yStart: number, yEnd: number): void {
704
+ verticalLanes.push({ x, yStart, yEnd })
705
+ }
706
+
707
+ // --- Draw relationships ---
708
+ const H = useAscii ? '-' : '─'
709
+ const V = useAscii ? '|' : '│'
710
+ const dashH = useAscii ? '.' : '╌'
711
+ const dashV = useAscii ? ':' : '┊'
712
+
713
+ /**
714
+ * Character for the single cell where a routed relationship's path turns
715
+ * — a vertical segment and a horizontal segment meeting at a right angle
716
+ * (issue #414). `vertDir` is the direction the vertical segment extends
717
+ * away from this corner cell ('up' toward smaller y, 'down' toward
718
+ * larger y); `horizDir` is the direction the horizontal segment extends
719
+ * away from this same cell. Collapses to '+' in ASCII mode, matching
720
+ * getCrowsFootChars' own useAscii branching.
721
+ */
722
+ function getCornerChar(
723
+ vertDir: 'up' | 'down',
724
+ horizDir: 'left' | 'right',
725
+ ): string {
726
+ if (useAscii) return '+'
727
+ if (vertDir === 'down') return horizDir === 'right' ? '┌' : '┐'
728
+ return horizDir === 'right' ? '└' : '┘'
729
+ }
730
+
731
+ /**
732
+ * Draw a corner glyph at a routed relationship's turn point, through the
733
+ * same setCGuarded occupancy guard as every other relationship write (see
734
+ * setCGuarded's doc comment above), so a corner can never overwrite an
735
+ * entity box border or another relationship's already-placed label text
736
+ * — the same corruption shape #391 fixed for plain line/marker writes.
737
+ * Called after the plain line segments (and, in the multi-row-bypass
738
+ * case, before the crow's-foot markers) are drawn, so the corner glyph
739
+ * replaces whichever line/dash character would otherwise occupy that one
740
+ * cell where the path actually changes direction.
741
+ */
742
+ function drawCorner(
743
+ x: number,
744
+ y: number,
745
+ vertDir: 'up' | 'down',
746
+ horizDir: 'left' | 'right',
747
+ ): void {
748
+ setCGuarded(x, y, getCornerChar(vertDir, horizDir), 'line')
749
+ }
750
+
751
+ /**
752
+ * Attachment columns for vertical (different-row) relationships, keyed by
753
+ * entity id, one bucket per edge the relationship attaches to. Without
754
+ * this, every vertical relationship's marker independently defaults to
755
+ * its entity's exact horizontal center — so when two or more
756
+ * relationships converge on the *same* entity's top edge (or leave from
757
+ * the same bottom edge) — e.g. USER→COMMENT and POST→COMMENT both ending
758
+ * at COMMENT — their markers land on the identical cell and read as one
759
+ * merged crow's-foot instead of two distinguishable relationships (issue
760
+ * #453). Real mermaid.js avoids this by staggering each edge's
761
+ * attachment point along the entity's border based on where the other
762
+ * endpoint sits; `attachmentX` below mirrors that by spreading multiple
763
+ * attachments left-to-right across the entity's own width, ordered by the
764
+ * other entity's center X (so the visual left-to-right order of the
765
+ * relationships matches the left-to-right order of their sources).
766
+ * Entities with only one attachment on a given side are unaffected — they
767
+ * still resolve to dead center, exactly as before this change.
768
+ */
769
+ interface VerticalAttachment {
770
+ rel: (typeof diagram.relationships)[number]
771
+ otherCenterX: number
772
+ }
773
+ const topAttachments = new Map<string, VerticalAttachment[]>()
774
+ const bottomAttachments = new Map<string, VerticalAttachment[]>()
775
+ for (const rel of diagram.relationships) {
776
+ const e1 = placed.get(rel.entity1)
777
+ const e2 = placed.get(rel.entity2)
778
+ if (!e1 || !e2) continue
779
+ const e1CY = e1.y + Math.floor(e1.height / 2)
780
+ const e2CY = e2.y + Math.floor(e2.height / 2)
781
+ const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
782
+ if (sameRow) continue
783
+ const [upperE, lowerE] = e1CY < e2CY ? [e1, e2] : [e2, e1]
784
+ const upperCX = upperE.x + Math.floor(upperE.width / 2)
785
+ const lowerCX = lowerE.x + Math.floor(lowerE.width / 2)
786
+ let bottomList = bottomAttachments.get(upperE.entity.id)
787
+ if (!bottomList) {
788
+ bottomList = []
789
+ bottomAttachments.set(upperE.entity.id, bottomList)
790
+ }
791
+ bottomList.push({ rel, otherCenterX: lowerCX })
792
+ let topList = topAttachments.get(lowerE.entity.id)
793
+ if (!topList) {
794
+ topList = []
795
+ topAttachments.set(lowerE.entity.id, topList)
796
+ }
797
+ topList.push({ rel, otherCenterX: upperCX })
798
+ }
799
+
800
+ /**
801
+ * Resolve the attachment column for `rel` on `entity`'s given side. Falls
802
+ * back to dead center when that side has zero or one attachment (the
803
+ * common case, and the pre-#453 behavior). With two or more, spreads them
804
+ * evenly across the entity's own width — inset by 1 cell from each edge
805
+ * so a 2-cell-wide crow's-foot marker (e.g. "zero-many") doesn't sit
806
+ * flush against a box corner — ordered by the other entity's center X.
807
+ */
808
+ function attachmentX(
809
+ entity: PlacedEntity,
810
+ rel: (typeof diagram.relationships)[number],
811
+ attachments: Map<string, VerticalAttachment[]>,
812
+ ): number {
813
+ const list = attachments.get(entity.entity.id)
814
+ const centerX = entity.x + Math.floor(entity.width / 2)
815
+ if (!list || list.length <= 1) return centerX
816
+ const sorted = [...list].sort((a, b) => a.otherCenterX - b.otherCenterX)
817
+ const idx = sorted.findIndex((a) => a.rel === rel)
818
+ if (idx === -1) return centerX
819
+ const margin = Math.min(1, Math.floor((entity.width - 1) / 2))
820
+ const usableWidth = Math.max(0, entity.width - 1 - margin * 2)
821
+ const step = sorted.length > 1 ? usableWidth / (sorted.length - 1) : 0
822
+ return entity.x + margin + Math.round(idx * step)
823
+ }
824
+
825
+ for (const rel of diagram.relationships) {
826
+ const e1 = placed.get(rel.entity1)
827
+ const e2 = placed.get(rel.entity2)
828
+ if (!e1 || !e2) continue
829
+
830
+ const lineH = rel.identifying ? H : dashH
831
+ const lineV = rel.identifying ? V : dashV
832
+
833
+ // Determine connection direction based on relative position.
834
+ // Connect from right side of left entity to left side of right entity (horizontal),
835
+ // or from bottom of upper entity to top of lower entity (vertical).
836
+ const e1CX = e1.x + Math.floor(e1.width / 2)
837
+ const e1CY = e1.y + Math.floor(e1.height / 2)
838
+ const e2CX = e2.x + Math.floor(e2.width / 2)
839
+ const e2CY = e2.y + Math.floor(e2.height / 2)
840
+
841
+ // Check if entities are on the same row (horizontal connection)
842
+ const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
843
+
844
+ if (sameRow) {
845
+ // Horizontal connection: right side of left entity → left side of right entity
846
+ const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1]
847
+ const [leftCard, rightCard] =
848
+ e1CX < e2CX
849
+ ? [rel.cardinality1, rel.cardinality2]
850
+ : [rel.cardinality2, rel.cardinality1]
851
+
852
+ const startX = left.x + left.width
853
+ const endX = right.x - 1
854
+ const lineY = left.y + Math.floor(left.height / 2)
855
+
856
+ // A straight line at lineY only stays clear of other boxes when left
857
+ // and right are actually adjacent in the row. When some other entity
858
+ // in the same row sits between them (e.g. ORDER↔SHIPMENT with
859
+ // LINE_ITEM placed in between — issue #350), the direct path runs
860
+ // straight through that entity's box. Detect that case by checking
861
+ // for any other row-mate whose x-range overlaps the gap.
862
+ let obstructionBottom: number | undefined
863
+ for (const other of placed.values()) {
864
+ if (other === left || other === right) continue
865
+ if (other.y !== left.y) continue
866
+ const overlapsGap = other.x < endX + 1 && other.x + other.width > startX
867
+ if (overlapsGap) {
868
+ obstructionBottom = Math.max(
869
+ obstructionBottom ?? 0,
870
+ other.y + other.height,
871
+ )
872
+ }
873
+ }
874
+
875
+ // Horizontal crow's-foot markers: flush against the border by default
876
+ // (standard ER notation, and #390's original intent), except where a
877
+ // marker's own border-adjacent glyph is character-identical to the
878
+ // border glyph itself ('one'/'zero-one' use '│'/'|', the same as the
879
+ // vertical border) — there, flush reads as a doubled border rather
880
+ // than a marker touching one, so that side gets the same inset as
881
+ // the label (issue #67). 'many'/'zero-many' markers ('╢'/'╟',
882
+ // '○╢'/'○╟') never collide with the border glyph and stay flush,
883
+ // matching #390's original intent for them.
884
+ //
885
+ // This was briefly a blanket inset for every horizontal marker
886
+ // (matching the review comment at
887
+ // https://github.com/dfadler/zombie-mermaid/issues/351#issuecomment-5497209031,
888
+ // which found flush unreadable but tested against a raw text
889
+ // comparison, not a render) — rendered in a real terminal, adjacent
890
+ // monospace box-drawing glyphs each sit centered in their own cell
891
+ // and don't visually fuse regardless of spacing, so a blanket inset
892
+ // wasn't fixing a defect, it was just adding unrequested space to
893
+ // markers that already read fine flush (e.g. `CUSTOMER }o--o{ ORDER`
894
+ // — no collision on either side, no reason for either to move).
895
+ // Narrowed to per-side glyph-identity detection instead, so only the
896
+ // colliding side ever moves — see issue #413.
897
+ const gapWidth = endX - startX + 1
898
+ // Only worth insetting the label off the border when the gap could
899
+ // fit the widest possible marker plus at least one cell of actual
900
+ // label content (maxMarkerWidth + 1) — below that, insetting both
901
+ // sides would just shrink an already-tight label region for no
902
+ // readability benefit. Was a flat `>= 3` (issue #415); derived here
903
+ // so it stays correct if maxMarkerWidth ever changes. The false
904
+ // branch is unreachable today — hGap's own floor (paddingOffset's
905
+ // hard-coded 6, independent of config.paddingX) keeps gapWidth well
906
+ // above maxMarkerWidth + 1 for the current glyph set — but is kept
907
+ // (not simplified to a bare `1`) so a future wider marker glyph set
908
+ // is still handled correctly without revisiting this line.
909
+ /* v8 ignore next */
910
+ const labelInset = gapWidth >= maxMarkerWidth + 1 ? 1 : 0
911
+ const leftChars = getCrowsFootChars(leftCard, useAscii, false)
912
+ const rightChars = getCrowsFootChars(rightCard, useAscii, true)
913
+ const leftInset = gapWidth >= 3 && leftChars[0] === V ? 1 : 0
914
+ const rightInset =
915
+ gapWidth >= 3 && rightChars[rightChars.length - 1] === V ? 1 : 0
916
+ const markerStartX = startX + leftInset
917
+ const markerEndX = endX - rightInset
918
+
919
+ let labelBaseY: number
920
+
921
+ if (obstructionBottom === undefined) {
922
+ // Direct path: nothing sits between left and right in this row.
923
+ for (let x = startX; x <= endX; x++) {
924
+ setCGuardedH(x, lineY, lineH, 'line')
925
+ }
926
+ labelBaseY = lineY + 1
927
+
928
+ // Two relationships between the exact same adjacent pair (a
929
+ // parallel/multi edge) both compute this identical row — without
930
+ // this search, the second relationship's label would silently
931
+ // overwrite the first's, since setCGuarded only guards a
932
+ // non-text write against existing text, not text-over-text.
933
+ // Search downward, within the row-gap band, for a row where the
934
+ // label is still on blank canvas.
935
+ if (rel.label) {
936
+ const labelMinX = startX + labelInset
937
+ const labelMaxX = endX - labelInset
938
+ const labelRows = splitLines(rel.label).length
939
+ const maxLabelY = lineY + 1 + Math.max(vGap - 1, 1)
940
+ while (
941
+ labelBaseY < maxLabelY &&
942
+ !regionClear(
943
+ labelMinX,
944
+ labelMaxX,
945
+ labelBaseY,
946
+ labelBaseY + labelRows - 1,
947
+ )
948
+ ) {
949
+ labelBaseY++
950
+ }
951
+ }
952
+ } else {
953
+ // Detour beneath the obstructing entity, through the free row-gap
954
+ // band (vGap) that layout already reserves below every row, then
955
+ // back up to the right entity's edge. Nothing else is ever placed
956
+ // in that band, so it's guaranteed clear regardless of how much
957
+ // taller the obstruction is than left/right themselves.
958
+ const rowBottom = Math.max(
959
+ left.y + left.height,
960
+ right.y + right.height,
961
+ obstructionBottom,
962
+ )
963
+ // Reserve room for the label too (it goes one row below the detour
964
+ // line), and search downward for a row where both the detour line
965
+ // and the label are still on blank canvas — another relationship
966
+ // detoured through the same row-gap band would otherwise land on
967
+ // the exact same row and silently overwrite this one's label.
968
+ const labelRows = rel.label ? splitLines(rel.label).length : 0
969
+ let detourY = rowBottom + 1
970
+ const maxDetourY = rowBottom + Math.max(vGap * 3, 4)
971
+ while (
972
+ detourY < maxDetourY &&
973
+ !regionClear(startX, endX, detourY, detourY + labelRows)
974
+ ) {
975
+ detourY++
976
+ }
977
+ // Grow the canvas before drawing — a detour can reach further down
978
+ // than the initial component-based sizing anticipated.
979
+ increaseSize(canvas, endX + 1, detourY + labelRows + 1)
980
+ increaseRoleCanvasSize(rc, endX + 1, detourY + labelRows + 1)
981
+ for (let y = lineY; y <= detourY; y++) {
982
+ setCGuarded(startX, y, lineV, 'line')
983
+ setCGuarded(endX, y, lineV, 'line')
984
+ }
985
+ for (let x = startX; x <= endX; x++) {
986
+ setCGuardedH(x, detourY, lineH, 'line')
987
+ }
988
+ // Mark the two points where the detour actually turns — straight
989
+ // down from the row, then straight across, then straight back up —
990
+ // so the turn reads as one continuous line rather than a line
991
+ // ending flush against an unrelated mark (issue #414).
992
+ if (startX !== endX) {
993
+ drawCorner(startX, detourY, 'up', 'right')
994
+ drawCorner(endX, detourY, 'up', 'left')
995
+ }
996
+ labelBaseY = detourY + 1
997
+ }
998
+
999
+ // Draw crow's foot markers at endpoints
1000
+ // Left marker (at left entity's right edge) - isRight=false
1001
+ for (let i = 0; i < leftChars.length; i++) {
1002
+ setCGuarded(markerStartX + i, lineY, leftChars[i]!, 'arrow')
1003
+ }
1004
+
1005
+ // Right marker (at right entity's left edge) - isRight=true
1006
+ for (let i = 0; i < rightChars.length; i++) {
1007
+ setCGuarded(
1008
+ markerEndX - rightChars.length + 1 + i,
1009
+ lineY,
1010
+ rightChars[i]!,
1011
+ 'arrow',
1012
+ )
1013
+ }
1014
+
1015
+ // Relationship label centered in the gap between the two entities,
1016
+ // below the line (or below the detour, when one was needed). Clamp
1017
+ // label to the padded gap region [startX + inset, endX - inset] so it
1018
+ // never touches a box border. Supports multi-line labels. The gap
1019
+ // itself is widened during layout (see pairLabelWidth) so the full
1020
+ // label always fits when the path is direct.
1021
+ if (rel.label) {
1022
+ const lines = splitLines(rel.label)
1023
+ const gapMid = Math.floor((startX + endX) / 2)
1024
+ const labelMinX = startX + labelInset
1025
+ const labelMaxX = endX - labelInset
1026
+
1027
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1028
+ const line = lines[lineIdx]!
1029
+ // Grid cells, not code units — see toDisplayCells.
1030
+ const cells = toDisplayCells(line)
1031
+ const labelStart = Math.max(
1032
+ labelMinX,
1033
+ gapMid - Math.floor(cells.length / 2),
1034
+ )
1035
+ const labelY = labelBaseY + lineIdx
1036
+ // Ensure canvas is tall enough
1037
+ increaseSize(
1038
+ canvas,
1039
+ Math.max(labelStart + cells.length, 1),
1040
+ Math.max(labelY + 1, 1),
1041
+ )
1042
+ increaseRoleCanvasSize(
1043
+ rc,
1044
+ Math.max(labelStart + cells.length, 1),
1045
+ Math.max(labelY + 1, 1),
1046
+ )
1047
+ if (
1048
+ !canPlaceLabelLine(cells, labelStart, labelY, labelMinX, labelMaxX)
1049
+ ) {
1050
+ continue
1051
+ }
1052
+ for (let i = 0; i < cells.length; i++) {
1053
+ const lx = labelStart + i
1054
+ if (lx >= labelMinX && lx <= labelMaxX) {
1055
+ setCGuarded(lx, labelY, cells[i]!, 'text')
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ } else {
1061
+ // Vertical connection: bottom of upper entity → top of lower entity
1062
+ const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1]
1063
+ const [upperCard, lowerCard] =
1064
+ e1CY < e2CY
1065
+ ? [rel.cardinality1, rel.cardinality2]
1066
+ : [rel.cardinality2, rel.cardinality1]
1067
+
1068
+ const startY = upper.y + upper.height
1069
+ const endY = lower.y - 1
1070
+ // Dead center when this entity has only one relationship attaching
1071
+ // to this side; otherwise staggered so it doesn't collide with a
1072
+ // sibling relationship's marker on the same edge (issue #453).
1073
+ const lineX = attachmentX(upper, rel, bottomAttachments)
1074
+ const lowerCX = attachmentX(lower, rel, topAttachments)
1075
+ // Jog-row selection superseded by the multi-row-obstruction routing
1076
+ // below (issue #350's regionClear/pickBandY-based band search), which
1077
+ // subsumes this block's same-row-mate collision avoidance (issue
1078
+ // #351) as a special case. See chooseFreeRow's removal — no caller
1079
+ // remains.
1080
+
1081
+ /** True when column x is free of every entity box across [yStart, yEnd]. */
1082
+ function columnClearOfBoxes(
1083
+ x: number,
1084
+ yStart: number,
1085
+ yEnd: number,
1086
+ ): boolean {
1087
+ for (let y = yStart; y <= yEnd; y++) {
1088
+ if (boxCells.has(`${x},${y}`)) return false
1089
+ }
1090
+ return true
1091
+ }
1092
+
1093
+ // Crow's-foot markers sit flush against each entity border, matching
1094
+ // the horizontal-connection markers above — see issue #351. (No
1095
+ // separate label inset is needed here: the vertical label, below,
1096
+ // never clamped to an inset range in the first place.) Computed up
1097
+ // front (rather than only just before drawing the markers) because
1098
+ // the multi-row-obstruction routing below needs to jog *after* these
1099
+ // rows, not immediately at startY/endY — otherwise the marker ends up
1100
+ // sitting past where the line already turned away, disconnected from
1101
+ // it.
1102
+ const markerStartY = startY
1103
+ const markerEndY = endY
1104
+
1105
+ // The naive vertical midpoint can still sit inside a row-mate of
1106
+ // `upper` that's taller than `upper` itself, so a horizontal jog (or
1107
+ // the label, placed near the same band) at that Y would run straight
1108
+ // through that entity's box (issue #350 — e.g. `dispatches`, where
1109
+ // SHIPMENT is shorter than its row-mate LINE_ITEM). Clamp to the
1110
+ // row-gap band that's actually free of every entity: below the
1111
+ // tallest box in upper's row, and above the top of lower's row.
1112
+ const upperRowBottom = Math.max(
1113
+ ...[...placed.values()]
1114
+ .filter((p) => p.y === upper.y)
1115
+ .map((p) => p.y + p.height),
1116
+ )
1117
+ const lowerRowTop = lower.y
1118
+ const bandTop = Math.max(startY, upperRowBottom + 1)
1119
+ const bandBottom = Math.min(endY, lowerRowTop - 1)
1120
+
1121
+ /**
1122
+ * Pick a row within the free row-gap band for a horizontal run across
1123
+ * [xStart, xEnd] (plus `extraRows` below it, for a multi-line label).
1124
+ * Prefers a row that's still entirely blank — not already used by
1125
+ * another relationship's line or label routed through the same band
1126
+ * (issue #350) — falling back to the midpoint of the band (or of the
1127
+ * full startY..endY span, if the band is degenerate) when nothing is
1128
+ * fully clear.
1129
+ */
1130
+ function pickBandY(
1131
+ xStart: number,
1132
+ xEnd: number,
1133
+ extraRows: number,
1134
+ ): number {
1135
+ const fallback =
1136
+ bandTop <= bandBottom
1137
+ ? Math.floor((bandTop + bandBottom) / 2)
1138
+ : Math.floor((startY + endY) / 2)
1139
+ if (bandTop > bandBottom) return fallback
1140
+ for (let y = bandTop; y <= bandBottom; y++) {
1141
+ if (regionClear(xStart, xEnd, y, y + extraRows)) return y
1142
+ }
1143
+ return fallback
1144
+ }
1145
+
1146
+ // Natural (unescalated) routing: a single straight column when the
1147
+ // two entities share a center column, otherwise a two-segment jog
1148
+ // through a row picked by pickBandY. Computed up front — even though
1149
+ // it ends up unused when multiRowObstruction below escalates to the
1150
+ // via-column bypass instead — because deciding *whether* to escalate
1151
+ // also depends on whether this natural routing's own columns would
1152
+ // land adjacent to another relationship's already-drawn vertical run
1153
+ // (see verticalLaneConflict, issue #411).
1154
+ const needsJog = lineX !== lowerCX
1155
+ const lx = Math.min(lineX, lowerCX)
1156
+ const rx = Math.max(lineX, lowerCX)
1157
+ const midY = needsJog ? pickBandY(lx, rx, 0) : endY
1158
+ const initialVertEnd = needsJog ? midY - 1 : endY
1159
+
1160
+ // [bandTop, bandBottom] — not the wider [startY, endY] — is what the
1161
+ // single-row clamp above already guarantees is clear of a row-mate of
1162
+ // `upper` (or the top of `lower`'s own row). Checking the wider range
1163
+ // here would misfire on exactly that already-handled case (a row-mate
1164
+ // taller than `upper`, e.g. STUDENT next to a shorter TEACHER) and
1165
+ // route it through the free-column bypass unnecessarily. A genuine
1166
+ // multi-row obstruction — some entity in a row strictly between
1167
+ // `upper`'s and `lower`'s own rows (e.g. A→G below, where D sits in
1168
+ // the same column as A, two rows down) — still shows up here, since
1169
+ // bandTop/bandBottom span every row in between, not just one.
1170
+ //
1171
+ // Also escalates to the via-column bypass when the *natural* routing
1172
+ // computed above would put lineX's or lowerCX's own straight segment
1173
+ // column-adjacent to a different relationship's already-registered
1174
+ // vertical lane over an overlapping row range (issue #411) — bumping
1175
+ // this relationship onto its own distinct column keeps the two
1176
+ // readable as separate lines instead of one that appears to bend
1177
+ // sideways where they happen to sit closest.
1178
+ // The registered/checked range for each column includes the jog row
1179
+ // (midY) itself, not just the pure-vertical run up to it — the corner
1180
+ // glyph at (lineX, midY) or (lowerCX, midY) is where the bend
1181
+ // actually happens, and it's exactly as visually adjacent to a
1182
+ // neighboring column as the straight run above/below it.
1183
+ const multiRowObstruction =
1184
+ !columnClearOfBoxes(lineX, bandTop, bandBottom) ||
1185
+ !columnClearOfBoxes(lowerCX, bandTop, bandBottom) ||
1186
+ verticalLaneConflict(lineX, startY, needsJog ? midY : endY) ||
1187
+ (needsJog && verticalLaneConflict(lowerCX, midY, endY))
1188
+
1189
+ // Where the visible line actually runs — lineX in the common case,
1190
+ // or the free bypass column found below when a third entity sits
1191
+ // directly in the way. The label (further down) anchors to whichever
1192
+ // one is real, instead of always assuming lineX.
1193
+ let routingX = lineX
1194
+
1195
+ if (multiRowObstruction) {
1196
+ // Route around the intervening entity through a column that's
1197
+ // completely free of every box across the whole vertical span,
1198
+ // searched outward from lineX. Two short horizontal jogs — right
1199
+ // after leaving upper, right before reaching lower — connect
1200
+ // lineX/lowerCX to that column; the long middle run is a plain
1201
+ // vertical line, guaranteed not to touch any box.
1202
+ // Also skips a column that would collide with (viaColumnBlocked
1203
+ // rejects both identical and lane-adjacent columns) a different
1204
+ // relationship's already-registered vertical run (issue #411) — a
1205
+ // via column must be safe on both fronts, and reusing another
1206
+ // relationship's own column outright would silently blend the two
1207
+ // paths together (setCGuarded lets one 'line' write overwrite
1208
+ // another), which is worse than the adjacency this escalation is
1209
+ // meant to fix.
1210
+ const canvasWidth = canvas.length
1211
+ let viaX: number | undefined
1212
+ for (let offset = 0; offset <= canvasWidth; offset++) {
1213
+ const right = lineX + offset
1214
+ if (
1215
+ columnClearOfBoxes(right, startY, endY) &&
1216
+ !viaColumnBlocked(right, startY, endY)
1217
+ ) {
1218
+ viaX = right
1219
+ break
1220
+ }
1221
+ const left = lineX - offset
1222
+ if (
1223
+ offset > 0 &&
1224
+ columnClearOfBoxes(left, startY, endY) &&
1225
+ !viaColumnBlocked(left, startY, endY)
1226
+ ) {
1227
+ viaX = left
1228
+ break
1229
+ }
1230
+ }
1231
+ // No column anywhere on the canvas is fully clear (a very dense
1232
+ // diagram) — fall back to lineX. setCGuarded still guarantees no
1233
+ // box gets corrupted; the line just keeps the gap it already had.
1234
+ routingX = viaX ?? lineX
1235
+
1236
+ // Stay on lineX/lowerCX through the marker rows (so each crow's
1237
+ // foot marker still sits on a connected line, same as the
1238
+ // non-obstructed case below), then jog over to routingX for the
1239
+ // long middle run.
1240
+ for (let y = startY; y <= markerStartY; y++) {
1241
+ setCGuarded(lineX, y, lineV, 'line')
1242
+ }
1243
+ for (
1244
+ let x = Math.min(lineX, routingX);
1245
+ x <= Math.max(lineX, routingX);
1246
+ x++
1247
+ ) {
1248
+ setCGuardedH(x, markerStartY, lineH, 'line')
1249
+ }
1250
+ for (let y = markerStartY; y <= markerEndY; y++) {
1251
+ setCGuarded(routingX, y, lineV, 'line')
1252
+ }
1253
+ for (
1254
+ let x = Math.min(routingX, lowerCX);
1255
+ x <= Math.max(routingX, lowerCX);
1256
+ x++
1257
+ ) {
1258
+ setCGuardedH(x, markerEndY, lineH, 'line')
1259
+ }
1260
+ for (let y = markerEndY; y <= endY; y++) {
1261
+ setCGuarded(lowerCX, y, lineV, 'line')
1262
+ }
1263
+ // Mark the bypass's four turns (issue #414). Drawn before the
1264
+ // crow's-foot markers below, so a marker centered on the same
1265
+ // column/row still wins where the two coincide — same precedence
1266
+ // as before this change, when the marker overwrote a plain line
1267
+ // character there instead of a corner.
1268
+ if (lineX !== routingX) {
1269
+ const horizDirAtLine = routingX > lineX ? 'right' : 'left'
1270
+ const horizDirAtRouting =
1271
+ horizDirAtLine === 'right' ? 'left' : 'right'
1272
+ drawCorner(lineX, markerStartY, 'up', horizDirAtLine)
1273
+ drawCorner(routingX, markerStartY, 'down', horizDirAtRouting)
1274
+ }
1275
+ if (routingX !== lowerCX) {
1276
+ const horizDirAtRouting2 = lowerCX > routingX ? 'right' : 'left'
1277
+ const horizDirAtLower =
1278
+ horizDirAtRouting2 === 'right' ? 'left' : 'right'
1279
+ drawCorner(routingX, markerEndY, 'up', horizDirAtRouting2)
1280
+ drawCorner(lowerCX, markerEndY, 'down', horizDirAtLower)
1281
+ }
1282
+ // Register the long middle run so a later relationship's own
1283
+ // vertical connector won't land adjacent to it either (#411). The
1284
+ // short lineX/lowerCX stubs at the marker rows are only 1 row deep
1285
+ // (markerStartY..markerStartY, markerEndY..markerEndY) — negligible
1286
+ // for this check, so they're not registered separately.
1287
+ registerVerticalLane(routingX, markerStartY, markerEndY)
1288
+ } else {
1289
+ // Vertical line. Column lineX stays within upper's own x-range,
1290
+ // which by layout construction never overlaps a row-mate's box, so
1291
+ // this straight run is safe regardless of how far it descends. When
1292
+ // a horizontal jog is needed below, though, the path redirects to
1293
+ // lowerCX at the jog row — so this initial fill must stop *before*
1294
+ // that row rather than always running the full height. Otherwise a
1295
+ // leftover remnant keeps drawing all the way to endY at the
1296
+ // original lineX, a stray parallel line beside the actual jogged
1297
+ // path that connects to nothing (issue #392).
1298
+ //
1299
+ // needsJog/lx/rx/midY/initialVertEnd were already computed above,
1300
+ // alongside the multiRowObstruction check itself (issue #411) —
1301
+ // reused here rather than recomputed.
1302
+ for (let y = startY; y <= initialVertEnd; y++) {
1303
+ setCGuarded(lineX, y, lineV, 'line')
1304
+ }
1305
+ // Registered range includes the jog row itself (midY) when jogging
1306
+ // — see the multiRowObstruction comment above for why.
1307
+ registerVerticalLane(lineX, startY, needsJog ? midY : initialVertEnd)
1308
+
1309
+ // If horizontal offset needed, add a horizontal segment
1310
+ if (needsJog) {
1311
+ // Horizontal segment at midY
1312
+ for (let x = lx; x <= rx; x++) {
1313
+ setCGuardedH(x, midY, lineH, 'line')
1314
+ }
1315
+ // Vertical from midY to lower entity
1316
+ for (let y = midY + 1; y <= endY; y++) {
1317
+ setCGuarded(lowerCX, y, lineV, 'line')
1318
+ }
1319
+ registerVerticalLane(lowerCX, midY, endY)
1320
+ // Mark the jog's two turns (issue #414): the vertical run from
1321
+ // upper arrives from above and turns toward lowerCX; the
1322
+ // vertical run into lower departs downward, having turned away
1323
+ // from lineX.
1324
+ const horizDirAtLine = lowerCX > lineX ? 'right' : 'left'
1325
+ const horizDirAtLower = horizDirAtLine === 'right' ? 'left' : 'right'
1326
+ drawCorner(lineX, midY, 'up', horizDirAtLine)
1327
+ drawCorner(lowerCX, midY, 'down', horizDirAtLower)
1328
+ // The path now ends at lowerCX, not lineX — the lower marker and
1329
+ // label (below) must anchor there too, or they render visually
1330
+ // disconnected from the line that actually reaches them (#392).
1331
+ routingX = lowerCX
1332
+ }
1333
+ }
1334
+
1335
+ // Crow's foot markers (vertical direction) — markerStartY/markerEndY
1336
+ // computed up front, above.
1337
+ // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false)
1338
+ const upperChars = getCrowsFootChars(upperCard, useAscii, false, true)
1339
+ for (let i = 0; i < upperChars.length; i++) {
1340
+ setCGuarded(
1341
+ lineX - Math.floor(upperChars.length / 2) + i,
1342
+ markerStartY,
1343
+ upperChars[i]!,
1344
+ 'arrow',
1345
+ )
1346
+ }
1347
+
1348
+ // Lower marker (at lower entity's top edge) - treat as target side (isRight=true)
1349
+ const targetX = lineX !== lowerCX ? lowerCX : lineX
1350
+ const lowerChars = getCrowsFootChars(lowerCard, useAscii, true, true)
1351
+ for (let i = 0; i < lowerChars.length; i++) {
1352
+ setCGuarded(
1353
+ targetX - Math.floor(lowerChars.length / 2) + i,
1354
+ markerEndY,
1355
+ lowerChars[i]!,
1356
+ 'arrow',
1357
+ )
1358
+ }
1359
+
1360
+ // Relationship label — placed to the right of the vertical line.
1361
+ // Anchored to routingX, not lineX/targetX: when the connection was
1362
+ // detoured around an obstructing entity (#350), or jogged over to
1363
+ // the lower entity's own column, the label needs to sit next to
1364
+ // wherever the line actually ends up, not next to the relationship's
1365
+ // original upper-entity column — otherwise it renders visually
1366
+ // disconnected, floating in the gap between the two. We expand the
1367
+ // canvas as needed since labels can extend beyond the initial
1368
+ // bounds. Supports multi-line labels.
1369
+ if (rel.label) {
1370
+ const lines = splitLines(rel.label)
1371
+ // How far a centered vertical marker's own rightmost glyph column
1372
+ // extends past its own center column — see upperChars/lowerChars
1373
+ // above, drawn via `center - Math.floor(chars.length / 2) + i`. For
1374
+ // every current 1- or 2-cell marker this is 0 (centering always
1375
+ // leans the extra cell left, never right), so labelX below reduces
1376
+ // to routingX + 2, matching the flat constant this replaces — but
1377
+ // it stays correct if a future marker's glyph or centering ever
1378
+ // extended past the line column (issue #415).
1379
+ const markerRightExtent = Math.max(
1380
+ upperChars.length - 1 - Math.floor(upperChars.length / 2),
1381
+ lowerChars.length - 1 - Math.floor(lowerChars.length / 2),
1382
+ )
1383
+ // 1 cell for the line column itself + 1 cell of breathing room
1384
+ // before the label starts. Was a flat `+ 2` (issue #415).
1385
+ const labelX = routingX + markerRightExtent + 2
1386
+ const cellsPerLine = lines.map((line) => toDisplayCells(line))
1387
+ const maxCells = Math.max(...cellsPerLine.map((cells) => cells.length))
1388
+ const lastLx = labelX + maxCells - 1
1389
+ const blockHeight = lines.length
1390
+ // Geometric midpoint, used only as a starting point for the
1391
+ // collision search below — the jog itself no longer shares this
1392
+ // value (chooseFreeRow was removed; multiRowObstruction routing
1393
+ // picks its own row independently), so this is a fresh, local
1394
+ // fallback rather than a value reused from the line/jog above.
1395
+ const midY = Math.floor((startY + endY) / 2)
1396
+ // the search for the label's block there rather than at a freshly
1397
+ // recomputed geometric midpoint — otherwise the label could still
1398
+ // drift onto a different row than its own horizontal segment.
1399
+ const naturalStartY = midY - Math.floor((blockHeight - 1) / 2)
1400
+
1401
+ if (lastLx >= 0) {
1402
+ increaseSize(canvas, lastLx + 1, endY + 1)
1403
+ increaseRoleCanvasSize(rc, lastLx + 1, endY + 1)
1404
+ }
1405
+
1406
+ // Multiple vertical relationships sharing the same upper/lower
1407
+ // entity "rows" in the grid layout end up with the identical
1408
+ // startY/endY — and so the identical natural midY — for their own
1409
+ // labels. Without ever considering another row, only the first of
1410
+ // them to draw would keep its label; the rest would find their
1411
+ // natural row already taken and drop out entirely, even though
1412
+ // there's room a row or two away. Try the natural row first, then
1413
+ // scan outward (alternating below/above) within the relationship's
1414
+ // own vertical run for the nearest row where the whole label fits
1415
+ // cleanly. "Fits" means clear of every entity box (see boxCells) —
1416
+ // not just text/border content already drawn — since a box's own
1417
+ // blank interior padding never gets a role written to it and would
1418
+ // otherwise look free; a bypass-routed relationship (routingX) can
1419
+ // legitimately run its label past a box that never touched
1420
+ // startY/endY's naive band (#350). It also means clear of markers,
1421
+ // not just text/borders (#392) — before giving up.
1422
+ let placedAtY: number | null = null
1423
+ for (
1424
+ let offset = 0;
1425
+ offset <= endY - startY && placedAtY === null;
1426
+ offset++
1427
+ ) {
1428
+ const candidates =
1429
+ offset === 0
1430
+ ? [naturalStartY]
1431
+ : [naturalStartY + offset, naturalStartY - offset]
1432
+ for (const candidateStart of candidates) {
1433
+ if (
1434
+ candidateStart < startY ||
1435
+ candidateStart + blockHeight - 1 > endY
1436
+ ) {
1437
+ continue
1438
+ }
1439
+ const fits = cellsPerLine.every((cells, lineIdx) =>
1440
+ canPlaceLabelLineAvoidingMarkers(
1441
+ cells,
1442
+ labelX,
1443
+ candidateStart + lineIdx,
1444
+ 0,
1445
+ lastLx,
1446
+ ),
1447
+ )
1448
+ if (fits) {
1449
+ placedAtY = candidateStart
1450
+ break
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ if (placedAtY !== null) {
1456
+ for (let lineIdx = 0; lineIdx < blockHeight; lineIdx++) {
1457
+ const cells = cellsPerLine[lineIdx]!
1458
+ const y = placedAtY + lineIdx
1459
+ for (let i = 0; i < cells.length; i++) {
1460
+ const lx = labelX + i
1461
+ if (lx >= 0) {
1462
+ setCGuarded(lx, y, cells[i]!, 'text')
1463
+ }
1464
+ }
1465
+ // A relationship's own jog can leave a line character
1466
+ // immediately beside where its own label starts —
1467
+ // setCGuardedH only stops a *different*, later write from
1468
+ // crowding an already-placed label; it can't retroactively
1469
+ // clean up a dash this same relationship left right there
1470
+ // moments earlier, before the label existed to protect
1471
+ // against it. Clear one cell of breathing room on each side
1472
+ // whenever a line glyph (from any relationship) is sitting
1473
+ // there.
1474
+ if (rc[labelX - 1]?.[y] === 'line') {
1475
+ setC(labelX - 1, y, ' ', 'line')
1476
+ }
1477
+ const afterX = labelX + cells.length
1478
+ if (rc[afterX]?.[y] === 'line') {
1479
+ setC(afterX, y, ' ', 'line')
1480
+ }
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
1488
+ }