@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,2001 @@
1
+ // ============================================================================
2
+ // ASCII renderer — class diagrams
3
+ //
4
+ // Renders classDiagram text to ASCII/Unicode art.
5
+ // Each class is a multi-compartment box (header | attributes | methods).
6
+ // Relationships are drawn as lines between classes with UML markers.
7
+ //
8
+ // Layout: level-based top-down. "From" classes are placed above "to" classes
9
+ // for all relationship types, matching ELK/mermaid.com behavior.
10
+ // Relationship lines use simple Manhattan routing (vertical + horizontal).
11
+ // ============================================================================
12
+
13
+ import {
14
+ parseClassDiagram,
15
+ formatClassMember,
16
+ } from '@zombie-mermaid/mermaid-parser'
17
+ import type {
18
+ ClassDiagram,
19
+ ClassNode,
20
+ ClassNote,
21
+ RelationshipType,
22
+ } from '@zombie-mermaid/mermaid-parser'
23
+ import type {
24
+ AsciiConfig,
25
+ Canvas,
26
+ CharRole,
27
+ AsciiTheme,
28
+ ColorMode,
29
+ } from './types.ts'
30
+ import {
31
+ mkCanvas,
32
+ mkRoleCanvas,
33
+ canvasToString,
34
+ increaseSize,
35
+ increaseRoleCanvasSize,
36
+ write,
37
+ isJunctionChar,
38
+ mergeJunctions,
39
+ } from './canvas.ts'
40
+ import { drawMultiBox, measureMultiBox, classifyBoxChar } from './draw.ts'
41
+ import { allocateTerritory } from './territory.ts'
42
+ import type { Territory } from './territory.ts'
43
+ import { markBoxLabelLinks, mkLinkCanvas } from './hyperlinks.ts'
44
+ import type { LinkCanvas } from './hyperlinks.ts'
45
+ import { safeHref, splitStatements } from '@zombie-mermaid/core'
46
+ import { getCorners } from './shapes/corners.ts'
47
+ import { splitLines } from './multiline-utils.ts'
48
+ import { displayWidth, toDisplayCells } from './display-width.ts'
49
+ import { findFreeLane } from './lane-search.ts'
50
+ import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
51
+
52
+ /** Build the text sections for a class box: [header], [attributes], [methods] */
53
+ function buildClassSections(cls: ClassNode): string[][] {
54
+ // Header section: optional annotation + class name (may be multi-line)
55
+ const header: string[] = []
56
+ if (cls.annotation) header.push(`<<${cls.annotation}>>`)
57
+ // Support multi-line class names
58
+ const nameLines = splitLines(cls.label)
59
+ header.push(...nameLines)
60
+
61
+ // Attributes section
62
+ const attrs = cls.attributes.map(formatClassMember)
63
+
64
+ // Methods section
65
+ const methods = cls.methods.map(formatClassMember)
66
+
67
+ // Build sections from only the non-empty parts. Only attrs (2-section),
68
+ // only methods (2-section), both (3-section), or neither (1-section, header only).
69
+ const sections: string[][] = [header]
70
+ if (attrs.length > 0) sections.push(attrs)
71
+ if (methods.length > 0) sections.push(methods)
72
+ return sections
73
+ }
74
+
75
+ // ============================================================================
76
+ // Relationship marker characters
77
+ // ============================================================================
78
+
79
+ interface RelMarker {
80
+ /** Relationship type (determines marker shape) */
81
+ type: RelationshipType
82
+ /** Which end the marker is placed at */
83
+ markerAt: 'from' | 'to'
84
+ /** Whether the line is dashed */
85
+ dashed: boolean
86
+ }
87
+
88
+ /**
89
+ * Build the marker metadata for a relationship.
90
+ * The actual marker character will be determined at placement time based on line direction.
91
+ */
92
+ function getRelMarker(
93
+ type: RelationshipType,
94
+ markerAt: 'from' | 'to',
95
+ ): RelMarker {
96
+ const dashed = type === 'dependency' || type === 'realization'
97
+ return { type, markerAt, dashed }
98
+ }
99
+
100
+ /**
101
+ * Get the UML marker shape character for a relationship type.
102
+ * For directional arrows (association/dependency), the direction parameter
103
+ * specifies which way the arrow should point.
104
+ */
105
+ function getMarkerShape(
106
+ type: RelationshipType,
107
+ useAscii: boolean,
108
+ direction?: 'up' | 'down' | 'left' | 'right',
109
+ ): string {
110
+ switch (type) {
111
+ case 'inheritance':
112
+ case 'realization':
113
+ // Hollow triangle - rotate based on line direction
114
+ // Triangle points TOWARD the parent class
115
+ if (direction === 'down') {
116
+ // Line goes down (parent above, child below) - triangle points UP
117
+ return useAscii ? '^' : '△'
118
+ } else if (direction === 'up') {
119
+ // Line goes up (parent below, child above) - triangle points DOWN
120
+ return useAscii ? 'v' : '▽'
121
+ } else if (direction === 'left') {
122
+ // Line goes left - triangle points LEFT
123
+ return useAscii ? '>' : '◁'
124
+ } else {
125
+ // Default: line goes right - triangle points RIGHT
126
+ return useAscii ? '<' : '▷'
127
+ }
128
+ case 'composition':
129
+ // Filled diamond - omnidirectional shape
130
+ return useAscii ? '*' : '◆'
131
+ case 'aggregation':
132
+ // Hollow diamond - omnidirectional shape
133
+ return useAscii ? 'o' : '◇'
134
+ case 'association':
135
+ case 'dependency':
136
+ // Directional arrow - rotate based on line direction
137
+ if (direction === 'down') {
138
+ return useAscii ? 'v' : '▼'
139
+ } else if (direction === 'up') {
140
+ return useAscii ? '^' : '▲'
141
+ } else if (direction === 'left') {
142
+ return useAscii ? '<' : '◀'
143
+ } else {
144
+ // Default to right (or when direction not specified)
145
+ return useAscii ? '>' : '▶'
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Clip a relationship label's padded display cells to fit within
152
+ * [writableStart, writableEnd] — the room that's actually free on its row
153
+ * before it would collide with a *different* relationship's label that a
154
+ * previous iteration already drew there (see the call site in
155
+ * `renderClassAscii` for how that room is computed).
156
+ *
157
+ * Deliberately clips in place rather than re-centering into the available
158
+ * window: shifting a label's anchor to dodge a collision can push it
159
+ * further right than its natural centered position, which then collides
160
+ * with the *next* relationship's rightful space and cascades into
161
+ * dropping labels entirely — this happened while fixing issue #447 (a
162
+ * label pinned near the left canvas edge, itself already clamped rightward
163
+ * by `Math.max(0, idealLabelStart)`, swallowed its neighbor's whole ideal
164
+ * column). Truncated text gets an ellipsis on whichever side got clipped;
165
+ * the label's own padding spaces are dropped first since they're the least
166
+ * meaningful thing to lose.
167
+ */
168
+ function fitLabelToAvailableWidth(
169
+ naturalStart: number,
170
+ naturalCells: string[],
171
+ text: string,
172
+ writableStart: number,
173
+ writableEnd: number,
174
+ ): { start: number; cells: string[] } {
175
+ const naturalEnd = naturalStart + naturalCells.length - 1
176
+ const clippedLeft = writableStart > naturalStart
177
+ const clippedRight = writableEnd < naturalEnd
178
+ if (!clippedLeft && !clippedRight) {
179
+ return { start: naturalStart, cells: naturalCells }
180
+ }
181
+
182
+ // Only the side(s) that actually collided move inward — an unclipped
183
+ // side keeps its natural bound. Using the raw `writableStart`/
184
+ // `writableEnd` for *both* sides here (even the uncollided one) would
185
+ // center the shrunk label inside the entire remaining canvas rather
186
+ // than snug against its own natural extent, drifting it away from its
187
+ // own relationship and into a completely different one's territory.
188
+ const effectiveStart = clippedLeft ? writableStart : naturalStart
189
+ const effectiveEnd = clippedRight ? writableEnd : naturalEnd
190
+ const availableCols = Math.max(0, effectiveEnd - effectiveStart + 1)
191
+ const ellipsisCount = (clippedLeft ? 1 : 0) + (clippedRight ? 1 : 0)
192
+
193
+ if (availableCols === 0) return { start: effectiveStart, cells: [] }
194
+ if (availableCols < ellipsisCount) {
195
+ // Not even room for both ellipsis markers — show a single one rather
196
+ // than nothing, so a squeezed-out label still leaves a visible trace.
197
+ return { start: effectiveStart, cells: ['…'] }
198
+ }
199
+
200
+ const innerWidth = Math.max(0, availableCols - ellipsisCount)
201
+ const textCells = toDisplayCells(text)
202
+ const kept =
203
+ textCells.length <= innerWidth
204
+ ? textCells
205
+ : clippedLeft && !clippedRight
206
+ ? textCells.slice(textCells.length - innerWidth) // keep the tail
207
+ : textCells.slice(0, innerWidth) // keep the head
208
+
209
+ const cells = [
210
+ ...(clippedLeft ? ['…'] : []),
211
+ ...kept,
212
+ ...(clippedRight ? ['…'] : []),
213
+ ]
214
+ // Center the shrunk label within the writable window so it still reads
215
+ // near the relationship it belongs to, rather than hugging one edge.
216
+ const start =
217
+ effectiveStart + Math.max(0, Math.floor((availableCols - cells.length) / 2))
218
+ return { start, cells }
219
+ }
220
+
221
+ /**
222
+ * Display width of a relationship label as it is actually drawn: its widest
223
+ * line plus one padding space on each side. Every pass that needs to know
224
+ * how much horizontal room a label takes (column reservation, per-pair
225
+ * column spacing, territory precomputation, drawing) must measure it the
226
+ * same way, or the room reserved up front won't match what gets drawn.
227
+ */
228
+ function labelCellWidth(label: string): number {
229
+ return Math.max(...splitLines(label).map((l) => displayWidth(l))) + 2
230
+ }
231
+
232
+ // ============================================================================
233
+ // Layout and rendering
234
+ // ============================================================================
235
+
236
+ /** Positioned class node on the canvas */
237
+ interface PlacedClass {
238
+ cls: ClassNode
239
+ sections: string[][]
240
+ x: number
241
+ y: number
242
+ width: number
243
+ height: number
244
+ }
245
+
246
+ /** Per-render switches that aren't layout config (see `AsciiRenderOptions`). */
247
+ export interface ClassAsciiOptions {
248
+ /** Wrap each `click`-linked class's name in an OSC 8 hyperlink pair. */
249
+ hyperlinks?: boolean
250
+ }
251
+
252
+ /**
253
+ * Render a Mermaid class diagram to ASCII/Unicode text.
254
+ *
255
+ * Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string.
256
+ */
257
+ export function renderClassAscii(
258
+ text: string,
259
+ config: AsciiConfig,
260
+ colorMode?: ColorMode,
261
+ theme?: AsciiTheme,
262
+ options: ClassAsciiOptions = {},
263
+ ): string {
264
+ const lines = splitStatements(text)
265
+ // Notes ride through the layout as pseudo-classes — see withNotePseudoClasses.
266
+ const { diagram, notesById } = withNotePseudoClasses(parseClassDiagram(lines))
267
+
268
+ if (diagram.classes.length === 0) return ''
269
+
270
+ const useAscii = config.useAscii
271
+ // See paddingOffset's doc comment (types.ts) for why these are an offset
272
+ // from the padding defaults rather than the raw config values.
273
+ const hGap = paddingOffset(config.paddingX, DEFAULT_PADDING_X, 4, 1) // horizontal gap between class boxes
274
+ const vGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 3, 1) // vertical gap between levels (enough for relationship lines)
275
+
276
+ // --- Build box dimensions for each class ---
277
+ const classSections = new Map<string, string[][]>()
278
+ const classBoxW = new Map<string, number>()
279
+ const classBoxH = new Map<string, number>()
280
+
281
+ for (const cls of diagram.classes) {
282
+ const sections = buildClassSections(cls)
283
+ classSections.set(cls.id, sections)
284
+
285
+ // Reserve exactly what drawMultiBox will draw — measuring it here rather
286
+ // than re-deriving the arithmetic keeps layout and drawing in lockstep for
287
+ // wide-character (CJK/fullwidth) content.
288
+ const { width: boxW, height: boxH } = measureMultiBox(
289
+ sections,
290
+ config.boxBorderPadding,
291
+ )
292
+
293
+ classBoxW.set(cls.id, boxW)
294
+ classBoxH.set(cls.id, boxH)
295
+ }
296
+
297
+ // --- Assign levels: topological sort based on directed relationships ---
298
+ // All relationship types place "from" above "to" in the layout, matching
299
+ // ELK's layered algorithm and the official mermaid.com renderer behavior.
300
+ // For "Animal <|-- Dog": from="Animal", to="Dog" → Animal above Dog.
301
+ //
302
+ // Every relationship type (including association and dependency) forces nodes
303
+ // to different levels. Same-row routing for mixed diagrams causes collisions:
304
+ // detour lines overlap with cross-level routing, and labels overwrite box borders.
305
+
306
+ const classById = new Map<string, ClassNode>()
307
+ for (const cls of diagram.classes) classById.set(cls.id, cls)
308
+
309
+ const parents = new Map<string, Set<string>>() // child → set of parent IDs
310
+ const children = new Map<string, Set<string>>() // parent → set of child IDs
311
+
312
+ for (const rel of diagram.relationships) {
313
+ // Level assignment always places "from" above "to", for every relationship
314
+ // type — including inheritance and realization — matching real mermaid.js's
315
+ // layout. This is independent of which end carries the UML marker glyph
316
+ // (`rel.markerAt`, used only to orient the arrowhead when drawing the line
317
+ // below); e.g. `Bird ..|> Flyable` (markerAt='to') places Bird above
318
+ // Flyable even though the hollow-triangle marker touches Flyable. See
319
+ // issue #446 — this used to special-case inheritance/realization to put
320
+ // whichever end held the marker on top, which produced the correct order
321
+ // for `<|--` (where marker happens to be at 'from') but reversed it for
322
+ // `..|>` (where marker is at 'to').
323
+ const parentId = rel.from
324
+ const childId = rel.to
325
+
326
+ const parentSet = parents.get(childId) ?? new Set<string>()
327
+ parents.set(childId, parentSet)
328
+ parentSet.add(parentId)
329
+ const childSet = children.get(parentId) ?? new Set<string>()
330
+ children.set(parentId, childSet)
331
+ childSet.add(childId)
332
+ }
333
+
334
+ // BFS from roots (classes that have no parents) to assign levels.
335
+ // Cap at classes.length - 1 to prevent infinite loops on cyclic graphs
336
+ // (e.g. View --> Model and Model ..> View would otherwise push levels
337
+ // upward forever). In a DAG the longest path has at most N-1 edges.
338
+ const level = new Map<string, number>()
339
+ const roots = diagram.classes.filter(
340
+ (c) => !parents.has(c.id) || parents.get(c.id)!.size === 0,
341
+ )
342
+ const queue: string[] = roots.map((c) => c.id)
343
+ for (const id of queue) level.set(id, 0)
344
+
345
+ const levelCap = diagram.classes.length - 1
346
+ let qi = 0
347
+ while (qi < queue.length) {
348
+ const id = queue[qi++]!
349
+ const childSet = children.get(id)
350
+ if (!childSet) continue
351
+ for (const childId of childSet) {
352
+ const newLevel = (level.get(id) ?? 0) + 1
353
+ if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop
354
+ if (!level.has(childId) || level.get(childId)! < newLevel) {
355
+ level.set(childId, newLevel)
356
+ queue.push(childId)
357
+ }
358
+ }
359
+ }
360
+
361
+ // Assign remaining (unconnected) classes to level 0
362
+ for (const cls of diagram.classes) {
363
+ if (!level.has(cls.id)) level.set(cls.id, 0)
364
+ }
365
+
366
+ // A `note for X` note has no relationships, so it landed on level 0 above;
367
+ // move it onto its class's row so the row placement below puts it right
368
+ // beside that class.
369
+ pinNoteLevels(notesById, level)
370
+
371
+ // --- Position classes by level ---
372
+ // Group classes by level
373
+ const maxLevel = Math.max(...[...level.values()], 0)
374
+ const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => [])
375
+ for (const cls of diagram.classes) {
376
+ levelGroups[level.get(cls.id)!]!.push(cls.id)
377
+ }
378
+
379
+ // When more than one relationship connects the same pair of classes —
380
+ // most commonly a pair going in opposite directions, e.g. both
381
+ // `View --> Model` and `Model ..> View` — every relationship's connection
382
+ // point defaults to the exact same box-center column. Left alone, that
383
+ // means their lines, arrowheads, and labels all land on the same cells:
384
+ // whichever relationship draws last silently overwrites the other's, so
385
+ // it appears to vanish from the ASCII output entirely (issue #448). Give
386
+ // each relationship in such a group its own column, spread symmetrically
387
+ // around the box center, so their routes never start from the same point.
388
+ // Spacing is sized to the widest label in the group (not a fixed
389
+ // constant) so long labels still clear each other horizontally.
390
+ //
391
+ // Computed before positions are assigned (it depends only on the
392
+ // relationship list) because the column reservation below needs to know
393
+ // how far each group fans out from its boxes' centers.
394
+ const relColumnOffset = new Map<number, number>()
395
+ /** Distance between a group's outermost lanes, keyed by member — see `anchorOffset`. */
396
+ const relGroupSpread = new Map<number, number>()
397
+ {
398
+ const pairGroups = new Map<string, number[]>()
399
+ diagram.relationships.forEach((rel, i) => {
400
+ const pairKey = [rel.from, rel.to].sort().join('::')
401
+ const group = pairGroups.get(pairKey) ?? []
402
+ group.push(i)
403
+ pairGroups.set(pairKey, group)
404
+ })
405
+ for (const group of pairGroups.values()) {
406
+ if (group.length < 2) continue
407
+ const n = group.length
408
+ const widestLabel = Math.max(
409
+ ...group.map((i) => {
410
+ const label = diagram.relationships[i]!.label
411
+ if (!label) return 3 // room for just a line/arrow
412
+ return labelCellWidth(label)
413
+ }),
414
+ )
415
+ const step = widestLabel + 1 // +1 for a visual gap between labels
416
+ group.forEach((relIndex, pos) => {
417
+ relColumnOffset.set(relIndex, Math.round((pos - (n - 1) / 2) * step))
418
+ relGroupSpread.set(relIndex, (n - 1) * step)
419
+ })
420
+ }
421
+ }
422
+
423
+ // Fan relationships that converge on the same target (or diverge from the
424
+ // same source) even when they come from/go to otherwise-unrelated
425
+ // classes — e.g. `Teacher --> Course` and `Student --> Course`, two
426
+ // distinct pairs that both terminate at Course. `pairGroups` above only
427
+ // catches multiple relationships between the exact same two classes;
428
+ // nothing previously separated these, so both anchored at Course's exact
429
+ // center column. That collapsed their lines and arrowheads onto the same
430
+ // cells, and — since a relationship's only horizontal jog can land on a
431
+ // single shared row when the two source rows/box heights are equal —
432
+ // let one relationship's label (drawn in a later pass, unaware of the
433
+ // collision) blot out the entire visible portion of the *other*
434
+ // relationship's connector, leaving it looking like it stops short of
435
+ // its target instead of merely overlapping (issue #632).
436
+ //
437
+ // Scoped to the shared end only — the far end keeps its own center
438
+ // column — since, unlike a duplicate pair, the *other* end of each
439
+ // relationship is a different, unrelated class that needs no fanning of
440
+ // its own. A relationship already offset by `pairGroups` above keeps
441
+ // that symmetric (both-ends) offset unchanged; only relationships with
442
+ // no pair-based offset get fanned here.
443
+ //
444
+ // Further scoped to relationships whose target sits exactly one level
445
+ // below their source (`level.get(to) - level.get(from) === 1`) — a
446
+ // direct, adjacent-level convergence/divergence like this one. A
447
+ // relationship that instead skips a level (e.g. `A --> C` when `B` sits
448
+ // between them at `A --> B --> C`) already needs the existing
449
+ // box-collision detour routing below (`findClearColumn`) to route around
450
+ // the intervening class; fanning its anchor here too would fight that
451
+ // routing over the same geometry it depends on, corrupting it. Excluding
452
+ // skip-level relationships from the group entirely — rather than just
453
+ // from getting an offset — also keeps a same-level sibling's own offset
454
+ // arithmetic (position-in-group, spread) based only on the other
455
+ // relationships it could actually collide with.
456
+ const relFromOffset = new Map<number, number>()
457
+ const relFromSpread = new Map<number, number>()
458
+ const relToOffset = new Map<number, number>()
459
+ const relToSpread = new Map<number, number>()
460
+ {
461
+ const fanBySharedEndpoint = (
462
+ endpointOf: (rel: (typeof diagram.relationships)[number]) => string,
463
+ offsetOut: Map<number, number>,
464
+ spreadOut: Map<number, number>,
465
+ ): void => {
466
+ const groups = new Map<string, number[]>()
467
+ diagram.relationships.forEach((rel, i) => {
468
+ if (relColumnOffset.has(i)) return // already fanned as a duplicate pair
469
+ if (!classById.has(rel.from) || !classById.has(rel.to)) return
470
+ const fromLevel = level.get(rel.from) ?? 0
471
+ const toLevel = level.get(rel.to) ?? 0
472
+ if (toLevel - fromLevel !== 1) return // skip-level — leave to detour routing
473
+ const key = endpointOf(rel)
474
+ const group = groups.get(key) ?? []
475
+ group.push(i)
476
+ groups.set(key, group)
477
+ })
478
+ for (const group of groups.values()) {
479
+ if (group.length < 2) continue
480
+ const n = group.length
481
+ const widestLabel = Math.max(
482
+ ...group.map((i) => {
483
+ const label = diagram.relationships[i]!.label
484
+ if (!label) return 3 // room for just a line/arrow
485
+ return labelCellWidth(label)
486
+ }),
487
+ )
488
+ const step = widestLabel + 1 // +1 for a visual gap between labels
489
+ group.forEach((relIndex, pos) => {
490
+ offsetOut.set(relIndex, Math.round((pos - (n - 1) / 2) * step))
491
+ spreadOut.set(relIndex, (n - 1) * step)
492
+ })
493
+ }
494
+ }
495
+ fanBySharedEndpoint((rel) => rel.to, relToOffset, relToSpread)
496
+ fanBySharedEndpoint((rel) => rel.from, relFromOffset, relFromSpread)
497
+ }
498
+
499
+ // --- Reserve column room for relationship labels and fanned-out groups ---
500
+ // A class's horizontal slot used to be exactly its box's content width, so
501
+ // a narrow box (a single-letter class with no members) whose relationship
502
+ // carries a long label had nowhere to put that label: neighbouring labels
503
+ // fought over the same cells and the territory pass below truncated them
504
+ // all to `…` even though the diagram could simply have spread the columns
505
+ // further apart (issue #488). The same shortfall broke multi-relationship
506
+ // groups: the per-pair offsets above can fan out well past a narrow box's
507
+ // edges, and clamping them back inside the box collapsed distinct
508
+ // relationships onto one connection point (issue #489).
509
+ //
510
+ // So each class's column *slot* is its box plus whatever its
511
+ // relationships overhang past the box's edges: for every relationship,
512
+ // the label (or bare line) centered on that relationship's lane reaches
513
+ // some distance left and right of the box's center column, and the slot
514
+ // is padded by however much of that reach falls outside the box. The box
515
+ // itself keeps its content width; only the gap around it grows, and only
516
+ // on the side and by the amount a label or group actually needs — a
517
+ // right-side overhang on the last class of a level, say, moves nothing.
518
+ // Every relationship reserves at *both* of its endpoints so a vertical
519
+ // pair (A above B) is padded identically and B stays directly under A.
520
+ const columnReach = new Map<string, { left: number; right: number }>()
521
+ for (const cls of diagram.classes) {
522
+ columnReach.set(cls.id, { left: 0, right: 0 })
523
+ }
524
+ diagram.relationships.forEach((rel, relIndex) => {
525
+ if (!classById.has(rel.from) || !classById.has(rel.to)) return
526
+ // Mirror the draw pass: a label of `cellW` cells centered on its lane
527
+ // starts `floor(cellW / 2)` cells left of the lane; a bare line/arrow
528
+ // is a single cell on the lane itself. Each endpoint reserves room
529
+ // using its own offset — a duplicate-pair member's offset is the same
530
+ // at both ends, but a relationship fanned only at its shared target (or
531
+ // source) — see `relFromOffset`/`relToOffset` above — must reserve
532
+ // differently at each end, matching where its lane actually sits there.
533
+ const cellW = rel.label ? labelCellWidth(rel.label) : 1
534
+ const fromOffset =
535
+ relColumnOffset.get(relIndex) ?? relFromOffset.get(relIndex) ?? 0
536
+ const toOffset =
537
+ relColumnOffset.get(relIndex) ?? relToOffset.get(relIndex) ?? 0
538
+ const fromReach = columnReach.get(rel.from)!
539
+ fromReach.left = Math.max(
540
+ fromReach.left,
541
+ Math.floor(cellW / 2) - fromOffset,
542
+ )
543
+ fromReach.right = Math.max(
544
+ fromReach.right,
545
+ fromOffset + cellW - 1 - Math.floor(cellW / 2),
546
+ )
547
+ const toReach = columnReach.get(rel.to)!
548
+ toReach.left = Math.max(toReach.left, Math.floor(cellW / 2) - toOffset)
549
+ toReach.right = Math.max(
550
+ toReach.right,
551
+ toOffset + cellW - 1 - Math.floor(cellW / 2),
552
+ )
553
+ })
554
+
555
+ // Compute positions: each level is a row, classes in a row are spaced horizontally
556
+ const placed = new Map<string, PlacedClass>()
557
+ let currentY = 0
558
+ // Right edge of the widest level's last slot — a slot can be wider than
559
+ // its box, so the canvas has to cover the slot, not just the box.
560
+ let maxSlotEnd = 0
561
+
562
+ for (let lv = 0; lv <= maxLevel; lv++) {
563
+ const group = levelGroups[lv]!
564
+ if (group.length === 0) continue
565
+
566
+ // --- #970's block-based x-assignment (issues #971, #972) ---
567
+ // docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md.
568
+ // Alignment blocks group classes at this level sharing the exact same
569
+ // parent-id set, processed as a unit. A block's *qualifying* parents
570
+ // are the subset strictly shallower than this level — a parent at the
571
+ // same level (a rootless relationship cycle, whose members still
572
+ // record each other as "parents" in `parents` even though nothing
573
+ // above them ever resolves) never qualifies. A block with zero
574
+ // qualifying parents has "no resolvable parents" and every member
575
+ // keeps its plain left-to-right baseline position, computed
576
+ // individually (not as a joint unit — #971's exact per-class
577
+ // behavior, preserved verbatim for this shape, including when a
578
+ // no-resolvable-parents block's members happen not to be declaration-
579
+ // adjacent). A block with at least one qualifying parent computes a
580
+ // real desired center: the mean of its qualifying parents' own,
581
+ // already-placed box centers (a single qualifying parent's mean is
582
+ // just that parent's center — the #964 repro exactly, #971's case; a
583
+ // block of more than one class sharing that parent set spreads evenly
584
+ // by centering the block's *total* width on that shared center, #972's
585
+ // "multiple children of one parent" case; more than one qualifying
586
+ // parent averages their centers, #972's "converging edges" case).
587
+ // Either way, the block's desired *left* edge (its slot-space left,
588
+ // matching `currentX`'s role below) is `desiredCenter -
589
+ // floor(totalWidth / 2)` — floor chosen only for determinism. A single
590
+ // left-to-right compaction pass then resolves any collision between
591
+ // *any* two blocks' desired positions — universal across every block
592
+ // shape, not just multi-parent ones, since two independently-aligned
593
+ // singleton blocks can still want overlapping positions (#971) — by
594
+ // only ever pushing a block right of its desired position, never left,
595
+ // and never reordering blocks relative to each other.
596
+ //
597
+ // A class with no parents at all is always its own singleton block —
598
+ // two unrelated roots must never be merged into one block just because
599
+ // both happen to have an empty parent set; there is nothing shared to
600
+ // align them around.
601
+ const blockKeyOf = (id: string): string => {
602
+ const pset = parents.get(id)
603
+ return pset && pset.size > 0 ? [...pset].sort().join(',') : `root:${id}`
604
+ }
605
+ // Every member sharing a given block key, regardless of adjacency in
606
+ // `group` — looked up once a block is actually confirmed to have a
607
+ // shared center to align around (see the main loop below). Building
608
+ // this eagerly for every id up front, and processing every block's
609
+ // *entire* membership together the moment its first (in declaration
610
+ // order) member is reached — even for a block with no resolvable
611
+ // parents — used to silently reorder an unrelated class that was
612
+ // declared between two same-parent-set siblings ahead of it, moving it
613
+ // visually later than its own declaration position (caught in review
614
+ // on #972). The main loop below only merges members into one placement
615
+ // unit for a block that actually resolves to a shared center; a
616
+ // no-resolvable-parents "block" is never merged, so it can't reorder
617
+ // anything.
618
+ const blockMembers = new Map<string, string[]>()
619
+ for (const id of group) {
620
+ const key = blockKeyOf(id)
621
+ let members = blockMembers.get(key)
622
+ if (!members) {
623
+ members = []
624
+ blockMembers.set(key, members)
625
+ }
626
+ members.push(id)
627
+ }
628
+
629
+ // Baseline: today's plain left-to-right slot position for every class
630
+ // in this level, computed exactly as the pre-#971 algorithm did — the
631
+ // fallback position for a no-resolvable-parents block's members, and
632
+ // the starting point the compaction step below can only ever push
633
+ // right of, never left.
634
+ const baselineSlotLeft = new Map<string, number>()
635
+ const slotWidthOf = new Map<string, number>()
636
+ const leftPadOf = new Map<string, number>()
637
+ {
638
+ let x = 0
639
+ for (const id of group) {
640
+ const w = classBoxW.get(id)!
641
+ // The box's center column sits `floor(w / 2)` cells from its left
642
+ // edge (the same arithmetic `connectionColumns` uses), so anything
643
+ // reaching further than that past the center overhangs the box.
644
+ const reach = columnReach.get(id)!
645
+ const leftPad = Math.max(0, reach.left - Math.floor(w / 2))
646
+ const rightPad = Math.max(0, reach.right - (w - 1 - Math.floor(w / 2)))
647
+ baselineSlotLeft.set(id, x)
648
+ slotWidthOf.set(id, leftPad + w + rightPad)
649
+ leftPadOf.set(id, leftPad)
650
+ x += leftPad + w + rightPad + hGap
651
+ }
652
+ }
653
+
654
+ let currentX = 0
655
+ let maxH = 0
656
+
657
+ /** Places one class's box at slot-left `slotLeft`, advancing `currentX`/`maxH`/`maxSlotEnd`. */
658
+ const placeAt = (id: string, slotLeft: number): void => {
659
+ const cls = classById.get(id)!
660
+ const w = classBoxW.get(id)!
661
+ const h = classBoxH.get(id)!
662
+ const leftPad = leftPadOf.get(id)!
663
+ const slotWidth = slotWidthOf.get(id)!
664
+ placed.set(id, {
665
+ cls,
666
+ sections: classSections.get(id)!,
667
+ // Shifted right only by the left overhang, so a box with nothing
668
+ // reaching past its left edge lands exactly where its slot does.
669
+ x: slotLeft + leftPad,
670
+ y: currentY,
671
+ width: w,
672
+ height: h,
673
+ })
674
+ maxSlotEnd = Math.max(maxSlotEnd, slotLeft + slotWidth)
675
+ currentX = slotLeft + slotWidth + hGap
676
+ maxH = Math.max(maxH, h)
677
+ }
678
+
679
+ const placedThisLevel = new Set<string>()
680
+ for (const id of group) {
681
+ // Already placed as part of an earlier qualifying block's joint
682
+ // placement below (never true for a no-resolvable-parents id, which
683
+ // is always placed the moment the loop reaches it, in its own true
684
+ // declaration-order turn).
685
+ if (placedThisLevel.has(id)) continue
686
+
687
+ const key = blockKeyOf(id)
688
+ const pset = parents.get(id)
689
+ // `level.get(pid)!`: every relationship endpoint is guaranteed a
690
+ // `classById`/`level` entry (the parser's `ensureClass` auto-creates
691
+ // an implicit class for any id used only as a relationship endpoint,
692
+ // never just a dangling reference — see `classById.has(...)`'s own
693
+ // guards elsewhere in this file, which protect a *different* case),
694
+ // so this is never actually missing.
695
+ const qualifying = pset
696
+ ? [...pset].filter((pid) => level.get(pid)! < lv)
697
+ : []
698
+
699
+ if (qualifying.length === 0) {
700
+ // No resolvable parents: this id has no shared center to align
701
+ // around, so it is never merged into a joint block with the other
702
+ // members `blockKeyOf` groups it with — only *this* id is placed
703
+ // here, in its own true position in `group`'s declaration order,
704
+ // exactly matching #971's per-class behavior for this shape. Any
705
+ // other same-key member is placed independently, on its own later
706
+ // turn through this same loop — merging them together here would
707
+ // reorder whatever was declared between them ahead of its own
708
+ // declaration position (issue caught in #972's review).
709
+ const slotLeft = Math.max(baselineSlotLeft.get(id)!, currentX, 0)
710
+ placeAt(id, slotLeft)
711
+ placedThisLevel.add(id)
712
+ continue
713
+ }
714
+
715
+ // A block with a real shared center to align around *is* placed as
716
+ // a joint unit, gathering every member `blockKeyOf` groups with it
717
+ // regardless of adjacency in `group` — the whole reason to group
718
+ // same-parent-set siblings together in the first place.
719
+ const members = blockMembers.get(key)!
720
+
721
+ // `placed.get(...)!`: every id in `qualifying` has a strictly-
722
+ // shallower level than `lv`, and this loop places every class of a
723
+ // level before moving to the next, so a strictly-shallower class is
724
+ // always already placed by the time this level is processed.
725
+ // "Center column" is always a box's own rendered center, never the
726
+ // reach-padded slot's — aligning to the slot center instead would
727
+ // visually misalign the boxes whenever a relationship's label
728
+ // overhang is asymmetric.
729
+ const parentCenters = qualifying.map((pid) => {
730
+ const p = placed.get(pid)!
731
+ return p.x + Math.floor(p.width / 2)
732
+ })
733
+ const desiredCenter = Math.round(
734
+ parentCenters.reduce((sum, c) => sum + c, 0) / parentCenters.length,
735
+ )
736
+
737
+ let totalWidth = 0
738
+ for (let i = 0; i < members.length; i++) {
739
+ totalWidth += slotWidthOf.get(members[i]!)!
740
+ if (i < members.length - 1) totalWidth += hGap
741
+ }
742
+ // `totalWidth` is the *slot* span (every member's own reach padding
743
+ // included at both ends), but the thing that must visually center on
744
+ // `desiredCenter` is the *rendered box* span — from the first
745
+ // member's own box left edge to the last member's own box right
746
+ // edge, which excludes the outermost reach padding at each end (that
747
+ // padding only ever separates a box from a *neighbor*; at the
748
+ // block's own two ends there is no neighbor to separate from, so it
749
+ // just becomes asymmetric dead space that would otherwise skew the
750
+ // visible boxes off `desiredCenter` whenever the first member's
751
+ // leftPad differs from the last member's rightPad — e.g. one child
752
+ // has a long incoming label reserving room on its own left, the
753
+ // other has none). Subtracting them back out here, once, is cheaper
754
+ // than threading a separate "visible width" accumulator through the
755
+ // loop above (caught in #972's review).
756
+ const firstLeftPad = leftPadOf.get(members[0]!)!
757
+ const lastMember = members[members.length - 1]!
758
+ const lastRightPad =
759
+ slotWidthOf.get(lastMember)! -
760
+ leftPadOf.get(lastMember)! -
761
+ classBoxW.get(lastMember)!
762
+ const visibleWidth = totalWidth - firstLeftPad - lastRightPad
763
+ const blockDesiredLeft =
764
+ desiredCenter - firstLeftPad - Math.floor(visibleWidth / 2)
765
+ const blockActualLeft = Math.max(blockDesiredLeft, currentX, 0)
766
+
767
+ let memberX = blockActualLeft
768
+ for (const memberId of members) {
769
+ placeAt(memberId, memberX)
770
+ placedThisLevel.add(memberId)
771
+ memberX = currentX
772
+ }
773
+ }
774
+
775
+ currentY += maxH + vGap
776
+ }
777
+
778
+ // --- Create canvas ---
779
+ let totalW = maxSlotEnd
780
+ let totalH = 0
781
+ for (const p of placed.values()) {
782
+ totalW = Math.max(totalW, p.x + p.width)
783
+ totalH = Math.max(totalH, p.y + p.height)
784
+ }
785
+
786
+ // Extra space for relationship lines that may go below/beside
787
+ totalW += 4
788
+ totalH += 2
789
+
790
+ const canvas = mkCanvas(totalW - 1, totalH - 1)
791
+ const rc = mkRoleCanvas(totalW - 1, totalH - 1)
792
+
793
+ /**
794
+ * Set a character on the canvas and track its role.
795
+ * Delegates bounds-checking to the shared `write()` primitive
796
+ * (src/ascii/canvas.ts) instead of duplicating the guard here — see
797
+ * issue #171.
798
+ */
799
+ function setC(x: number, y: number, ch: string, role: CharRole): void {
800
+ write(canvas, x, y, ch, { role, roleCanvas: rc })
801
+ }
802
+
803
+ // Opt-in OSC 8 hyperlinks (see hyperlinks.ts): the link canvas is sized to
804
+ // the main canvas, so a cell clipped by the `cx < totalW` guard below is
805
+ // clipped here too.
806
+ const linkCanvas: LinkCanvas | undefined = options.hyperlinks
807
+ ? mkLinkCanvas(totalW - 1, totalH - 1)
808
+ : undefined
809
+
810
+ // --- Draw class boxes ---
811
+ for (const p of placed.values()) {
812
+ const boxCanvas = drawMultiBox(
813
+ p.sections,
814
+ useAscii,
815
+ config.boxBorderPadding,
816
+ )
817
+ // Copy box onto main canvas at (p.x, p.y) with role tracking
818
+ for (let bx = 0; bx < boxCanvas.length; bx++) {
819
+ for (let by = 0; by < boxCanvas[0]!.length; by++) {
820
+ const ch = boxCanvas[bx]![by]!
821
+ if (ch !== ' ') {
822
+ const cx = p.x + bx
823
+ const cy = p.y + by
824
+ if (cx < totalW && cy < totalH) {
825
+ setC(cx, cy, ch, classifyBoxChar(ch))
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ // A `click` href links the class-name line(s) of the header section —
832
+ // box row 1 is the first header line, which is the `<<annotation>>`
833
+ // when there is one, so the name starts on the row after it. The SVG
834
+ // renderer wraps the whole class box in an <a>; in a terminal, the name
835
+ // is the natural analog of "the label".
836
+ if (linkCanvas) {
837
+ const href = safeHref(diagram.interactions.get(p.cls.id)?.href)
838
+ const header = p.sections[0]
839
+ if (href !== undefined && header !== undefined) {
840
+ const nameRowStart = 1 + (p.cls.annotation ? 1 : 0)
841
+ markBoxLabelLinks(linkCanvas, boxCanvas, { x: p.x, y: p.y }, href, {
842
+ from: nameRowStart,
843
+ to: header.length,
844
+ })
845
+ }
846
+ }
847
+ }
848
+
849
+ // --- Snapshot cells occupied by class boxes ---
850
+ // Taken once, right after boxes are drawn and before any relationship
851
+ // line, corner, marker, or label is drawn — the class-diagram analog of
852
+ // the `boxCells`/`setCGuarded` guard er-diagram.ts gained for issue #350.
853
+ // Two independent routing branches rely on it:
854
+ // - The cross-level ("target below"/"target above") branches, whose
855
+ // routing can jog a long way horizontally with no prior occupancy
856
+ // check — e.g. connecting a level-1 child to a level-0 parent whose
857
+ // column sits far from an intervening, taller same-level sibling's
858
+ // box, which the jog then cuts straight through.
859
+ // - The same-level branch's own detour (this file's final `else`), which
860
+ // primarily avoids a same-row obstruction by computing its detour row
861
+ // from this same box geometry (see `obstructionBottom` there); this
862
+ // snapshot is the last-resort backstop for whatever that routing
863
+ // doesn't anticipate.
864
+ const boxCells = new Set<string>()
865
+ for (const p of placed.values()) {
866
+ for (let by = 0; by < p.height; by++) {
867
+ for (let bx = 0; bx < p.width; bx++) {
868
+ boxCells.add(`${p.x + bx},${p.y + by}`)
869
+ }
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Like setC, but refuses to draw into a cell already occupied by a class
875
+ * box (see boxCells above). Used by both the cross-level relationship
876
+ * branches (where a mis-routed jog can cut straight through an
877
+ * unrelated, taller same-level box) and the same-level branch's own
878
+ * detour (a last-resort backstop for whatever its obstruction-aware
879
+ * routing doesn't anticipate — see `obstructionBottom` below), so either
880
+ * kind of mis-routed segment degrades to a gap in the line rather than
881
+ * corrupting a box's border or attribute/method text.
882
+ */
883
+ function setCGuarded(x: number, y: number, ch: string, role: CharRole): void {
884
+ if (boxCells.has(`${x},${y}`)) return
885
+ setC(x, y, ch, role)
886
+ }
887
+
888
+ /** Check if a point (x, y) is inside any class box */
889
+ function isInsideBox(
890
+ x: number,
891
+ y: number,
892
+ excludeIds?: Set<string>,
893
+ ): boolean {
894
+ for (const [id, p] of placed.entries()) {
895
+ if (excludeIds?.has(id)) continue
896
+ if (
897
+ x >= p.x &&
898
+ x <= p.x + p.width - 1 &&
899
+ y >= p.y &&
900
+ y <= p.y + p.height - 1
901
+ ) {
902
+ return true
903
+ }
904
+ }
905
+ return false
906
+ }
907
+
908
+ /** Find a clear vertical column for routing that doesn't pass through any boxes */
909
+ function findClearColumn(
910
+ startX: number,
911
+ y1: number,
912
+ y2: number,
913
+ excludeIds: Set<string>,
914
+ ): number {
915
+ const columnIsClear = (x: number): boolean => {
916
+ for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
917
+ if (isInsideBox(x, y, excludeIds)) return false
918
+ }
919
+ return true
920
+ }
921
+
922
+ // Scan outward from `startX`, right before left at each distance, never
923
+ // off the left edge of the canvas. The right bound reaches far enough
924
+ // past the widest box that a column clear of every box is always found
925
+ // in practice — but the caller-side fallback below stays as a backstop
926
+ // rather than being asserted away.
927
+ return (
928
+ findFreeLane(startX, 0, startX + totalW + 9, columnIsClear) ?? totalW + 2
929
+ )
930
+ }
931
+
932
+ // --- Draw relationship lines ---
933
+ const H = useAscii ? '-' : '─'
934
+ const V = useAscii ? '|' : '│'
935
+ const dashH = useAscii ? '.' : '╌'
936
+ const dashV = useAscii ? ':' : '┊'
937
+
938
+ /**
939
+ * Offset from a box's center column to the *anchor* where one
940
+ * relationship's line touches that box's border — always within the
941
+ * box's own width, so a line visibly leaves from (or arrives at) the box
942
+ * rather than from empty space beside it. This is the only place the
943
+ * per-pair offset is reined in: the *lane* the line then runs along
944
+ * keeps the full offset (see `connectionColumns`), which the column
945
+ * reservation in the layout pass guarantees room for.
946
+ *
947
+ * A group whose lanes fan out wider than the box has its anchors spread
948
+ * evenly across the box's full width (corner columns included) by
949
+ * scaling the whole group down by one factor, so as many relationships
950
+ * as the box has columns keep a distinct border cell — and so a distinct
951
+ * arrowhead. Clamping each lane on its own instead collapsed every outer
952
+ * lane onto the same edge column, which is exactly how #489's four
953
+ * relationships ended up sharing two connection points. Only a group
954
+ * with more members than the box has columns still has to share.
955
+ */
956
+ function anchorOffset(
957
+ offset: number,
958
+ spread: number,
959
+ boxWidth: number,
960
+ ): number {
961
+ // Columns available on each side of the center — the center itself is
962
+ // `floor(boxWidth / 2)` in from the left edge, matching `connectionColumns`.
963
+ const left = Math.floor(boxWidth / 2)
964
+ const right = boxWidth - 1 - left
965
+ const scale = spread > left + right ? (left + right) / spread : 1
966
+ return Math.max(-left, Math.min(right, Math.round(offset * scale)))
967
+ }
968
+
969
+ /**
970
+ * Connection columns for one relationship, shifted by its per-pair offset.
971
+ *
972
+ * `fromCX`/`toCX` are the *lanes*: the columns the line's vertical run,
973
+ * arrowhead-side jog, and label all sit on. They carry the full offset,
974
+ * so relationships in a group never share a lane. Every pass that
975
+ * positions something relative to a relationship's route — the
976
+ * line-drawing loop, the label-territory precompute, and the
977
+ * label-drawing pass — must go through this one helper and use these
978
+ * lanes, so the label ends up on the same column the line actually runs
979
+ * along. When only the line pass applied the offset (an earlier version
980
+ * of this fix), a reciprocal pair's labels were still measured and drawn
981
+ * against the shared box center: the territory pass saw two labels with
982
+ * identical midpoints and split the column between them, truncating both
983
+ * to a single mashed `rea……ies` while their lines sat on separate columns
984
+ * (issue #448).
985
+ *
986
+ * `fromAnchorX`/`toAnchorX` are where the line meets each box border —
987
+ * the lane pulled back inside the box (see `anchorOffset`). When a group
988
+ * fans out wider than a narrow box (issue #489: four relationships
989
+ * between two single-letter classes), a lane can sit well outside the
990
+ * box; the line then jogs horizontally along the row adjacent to the box
991
+ * from the anchor out to its lane (see `drawJog`) instead of the lanes
992
+ * being collapsed onto the few columns the box has, which made distinct
993
+ * relationships overwrite one another. Relationships may end up sharing
994
+ * an anchor when a group outnumbers the box's columns — that's fine,
995
+ * their jogs merge into a small trunk at the border — but never a lane.
996
+ * Labels always use the lane, never the anchor: only the line's
997
+ * box-border touchpoint needs pulling back inside the box.
998
+ *
999
+ * The offset applied at each end is resolved independently: a
1000
+ * relationship in a duplicate-pair group (`relColumnOffset`) uses that
1001
+ * same offset at both ends, as before, but one that only shares a single
1002
+ * endpoint with other relationships (`relFromOffset`/`relToOffset` — see
1003
+ * their doc comment above) is fanned at that shared end only, leaving its
1004
+ * other, unrelated end anchored on its own box center (issue #632).
1005
+ */
1006
+ function connectionColumns(
1007
+ relIndex: number,
1008
+ fromP: PlacedClass,
1009
+ toP: PlacedClass,
1010
+ ): {
1011
+ fromCX: number
1012
+ toCX: number
1013
+ fromAnchorX: number
1014
+ toAnchorX: number
1015
+ } {
1016
+ const fromOffset =
1017
+ relColumnOffset.get(relIndex) ?? relFromOffset.get(relIndex) ?? 0
1018
+ const toOffset =
1019
+ relColumnOffset.get(relIndex) ?? relToOffset.get(relIndex) ?? 0
1020
+ const fromSpread =
1021
+ relGroupSpread.get(relIndex) ?? relFromSpread.get(relIndex) ?? 0
1022
+ const toSpread =
1023
+ relGroupSpread.get(relIndex) ?? relToSpread.get(relIndex) ?? 0
1024
+ const fromCenter = fromP.x + Math.floor(fromP.width / 2)
1025
+ const toCenter = toP.x + Math.floor(toP.width / 2)
1026
+ return {
1027
+ fromCX: fromCenter + fromOffset,
1028
+ toCX: toCenter + toOffset,
1029
+ fromAnchorX:
1030
+ fromCenter + anchorOffset(fromOffset, fromSpread, fromP.width),
1031
+ toAnchorX: toCenter + anchorOffset(toOffset, toSpread, toP.width),
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Write one cell of a jog, merging with whatever another relationship's
1037
+ * jog already drew there. Jogs of one group all run along the same row
1038
+ * next to the box, so they routinely overlap: two relationships sharing
1039
+ * an anchor stack their corners on one cell, and an outer relationship's
1040
+ * jog passes straight through an inner one's lane corner. Compositing
1041
+ * box-drawing glyphs (`─` over `┌` → `┬`, `┘` over `┘` → `┘`) turns those
1042
+ * overlaps into a readable trunk instead of the last write winning. A
1043
+ * marker glyph already placed by an earlier relationship is left alone —
1044
+ * an arrowhead at a shared anchor doubles as the junction.
1045
+ */
1046
+ function setJogCell(x: number, y: number, ch: string, role: CharRole): void {
1047
+ if (boxCells.has(`${x},${y}`)) return
1048
+ if (rc[x]?.[y] === 'arrow') return
1049
+ const existing = canvas[x]?.[y]
1050
+ if (
1051
+ !useAscii &&
1052
+ existing !== undefined &&
1053
+ isJunctionChar(existing) &&
1054
+ isJunctionChar(ch)
1055
+ ) {
1056
+ const merged = mergeJunctions(existing, ch)
1057
+ setC(x, y, merged, merged === ch ? role : 'junction')
1058
+ return
1059
+ }
1060
+ setC(x, y, ch, role)
1061
+ }
1062
+
1063
+ /**
1064
+ * Draw the short horizontal jog that joins a relationship's anchor on a
1065
+ * box border to the lane its line runs along, on the row directly
1066
+ * adjacent to that border. `boxIsAbove` says which side of the row the
1067
+ * box sits on: the anchor's corner opens toward the box, the lane's
1068
+ * corner opens the other way, toward the vertical run. A relationship
1069
+ * whose lane already sits inside its box needs no jog at all, which keeps
1070
+ * every pre-existing (unfanned) layout byte-identical.
1071
+ */
1072
+ function drawJog(
1073
+ row: number,
1074
+ anchorX: number,
1075
+ laneX: number,
1076
+ lineH: string,
1077
+ boxIsAbove: boolean,
1078
+ ): void {
1079
+ if (anchorX === laneX) return
1080
+ const lx = Math.min(anchorX, laneX)
1081
+ const rx = Math.max(anchorX, laneX)
1082
+ // Interior cells only — the two ends are corners (or, in plain-ASCII
1083
+ // mode, the same line glyph), written separately so a second jog
1084
+ // sharing this anchor merges with the corner instead of flattening it.
1085
+ for (let x = lx + 1; x < rx; x++) {
1086
+ setJogCell(x, row, lineH, 'line')
1087
+ }
1088
+ if (useAscii) {
1089
+ setJogCell(anchorX, row, lineH, 'line')
1090
+ setJogCell(laneX, row, lineH, 'line')
1091
+ return
1092
+ }
1093
+ const laneIsRight = laneX > anchorX
1094
+ const anchorCorner = boxIsAbove
1095
+ ? laneIsRight
1096
+ ? '└'
1097
+ : '┘'
1098
+ : laneIsRight
1099
+ ? '┌'
1100
+ : '┐'
1101
+ const laneCorner = boxIsAbove
1102
+ ? laneIsRight
1103
+ ? '┐'
1104
+ : '┌'
1105
+ : laneIsRight
1106
+ ? '┘'
1107
+ : '└'
1108
+ setJogCell(anchorX, row, anchorCorner, 'corner')
1109
+ setJogCell(laneX, row, laneCorner, 'corner')
1110
+ }
1111
+
1112
+ // A detoured relationship's actual routed path — the far column its
1113
+ // vertical trunk runs along, and the rows its horizontal jogs sit on —
1114
+ // keyed by relationship index. The line-drawing pass below is the only
1115
+ // place that decides whether (and where) a relationship detours, but the
1116
+ // label-territory precompute and label-drawing passes further down need
1117
+ // that same decision too, so they can anchor a detoured relationship's
1118
+ // label on its real path instead of the straight-line source/target
1119
+ // midpoint (issue #487). Populated only for the "target below source,
1120
+ // needs a detour around an intermediate box" case — the only routing
1121
+ // branch that currently detours at all.
1122
+ const detourRoutes = new Map<
1123
+ number,
1124
+ {
1125
+ routeX: number
1126
+ exitY: number
1127
+ entryY: number
1128
+ fromAnchorX: number
1129
+ toAnchorX: number
1130
+ clearSide: 'left' | 'right'
1131
+ }
1132
+ >()
1133
+
1134
+ // Same-level relationships' obstruction-aware detour row (issue #953),
1135
+ // keyed by relationship index — `computeLabelAnchor`'s own same-level
1136
+ // branch reads this so a labeled same-level relationship's label anchors
1137
+ // on the same corrected row the connector itself routes through, instead
1138
+ // of recomputing the naive (obstruction-unaware) `Math.max(fromBY, toBY)
1139
+ // + 2` and landing back inside whatever box the connector fix was
1140
+ // routing around.
1141
+ const sameLevelDetourY = new Map<number, number>()
1142
+
1143
+ diagram.relationships.forEach((rel, relIndex) => {
1144
+ const fromP = placed.get(rel.from)
1145
+ const toP = placed.get(rel.to)
1146
+ if (!fromP || !toP) return
1147
+
1148
+ const marker = getRelMarker(rel.type, rel.markerAt)
1149
+ const lineH = marker.dashed ? dashH : H
1150
+ const lineV = marker.dashed ? dashV : V
1151
+
1152
+ // Exclude source and target boxes from collision detection
1153
+ const excludeIds = new Set([rel.from, rel.to])
1154
+
1155
+ // Connection points: the lanes the line runs along (`fromCX`/`toCX`)
1156
+ // and the anchors where it touches each box border — see
1157
+ // `connectionColumns` for why those can differ.
1158
+ const { fromCX, toCX, fromAnchorX, toAnchorX } = connectionColumns(
1159
+ relIndex,
1160
+ fromP,
1161
+ toP,
1162
+ )
1163
+ const fromBY = fromP.y + fromP.height - 1
1164
+ const toTY = toP.y
1165
+
1166
+ // Route: Manhattan routing with collision avoidance
1167
+ // If target is below source: vertical down from source, horizontal if needed, vertical down to target
1168
+ // If same row: horizontal line with a small vertical detour above or below
1169
+ if (fromBY < toTY) {
1170
+ // Target is below source — routing with collision avoidance
1171
+ // Find a clear vertical column for the ENTIRE path from source to target
1172
+ const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds)
1173
+ const needsDetour = routeX !== fromCX
1174
+
1175
+ // Expand canvas if needed to accommodate routing column
1176
+ if (routeX >= totalW) {
1177
+ increaseSize(canvas, routeX + 2, totalH)
1178
+ }
1179
+
1180
+ if (needsDetour) {
1181
+ // COLLISION CASE: Route around intermediate boxes
1182
+ // Path: source anchor → horizontal to routeX → vertical to entry → horizontal to target anchor
1183
+ // The horizontals start/end at the *anchors* (on the box borders),
1184
+ // so a fanned-out lane needs no separate jog here — the detour's
1185
+ // own horizontal already runs along the jog row.
1186
+
1187
+ const exitY = fromBY + 1
1188
+ const entryY = toTY - 1
1189
+ detourRoutes.set(relIndex, {
1190
+ routeX,
1191
+ exitY,
1192
+ entryY,
1193
+ fromAnchorX,
1194
+ toAnchorX,
1195
+ // `findClearColumn` starts its search at `fromCX` and only
1196
+ // returns a different column when that one collided with a box
1197
+ // over the route's full y-range — so whichever side it moved
1198
+ // toward is the side with clearance, and the box it moved away
1199
+ // from sits on the other side of `routeX`. The label-anchor pass
1200
+ // uses this to keep a detour label's whole width clear of that
1201
+ // box, not just the single column `routeX` itself.
1202
+ clearSide: routeX > fromCX ? 'right' : 'left',
1203
+ })
1204
+
1205
+ // 1. Horizontal from source anchor to route column
1206
+ const lx1 = Math.min(fromAnchorX, routeX)
1207
+ const rx1 = Math.max(fromAnchorX, routeX)
1208
+ for (let x = lx1; x <= rx1; x++) {
1209
+ setCGuarded(x, exitY, lineH, 'line')
1210
+ }
1211
+ if (!useAscii && exitY < (canvas[0]?.length ?? 0)) {
1212
+ if (fromAnchorX < routeX) {
1213
+ setCGuarded(fromAnchorX, exitY, '└', 'corner')
1214
+ setCGuarded(routeX, exitY, '┐', 'corner')
1215
+ } else {
1216
+ setCGuarded(fromAnchorX, exitY, '┘', 'corner')
1217
+ setCGuarded(routeX, exitY, '┌', 'corner')
1218
+ }
1219
+ }
1220
+
1221
+ // 2. Vertical at routeX from exit to entry
1222
+ for (let y = exitY + 1; y <= entryY; y++) {
1223
+ setCGuarded(routeX, y, lineV, 'line')
1224
+ }
1225
+
1226
+ // 3. Horizontal from routeX to target anchor at entry
1227
+ if (routeX !== toAnchorX) {
1228
+ const lx2 = Math.min(routeX, toAnchorX)
1229
+ const rx2 = Math.max(routeX, toAnchorX)
1230
+ for (let x = lx2; x <= rx2; x++) {
1231
+ setCGuarded(x, entryY, lineH, 'line')
1232
+ }
1233
+ if (!useAscii && entryY < (canvas[0]?.length ?? 0)) {
1234
+ if (routeX < toAnchorX) {
1235
+ setCGuarded(routeX, entryY, '└', 'corner')
1236
+ setCGuarded(toAnchorX, entryY, '┐', 'corner')
1237
+ } else {
1238
+ setCGuarded(routeX, entryY, '┘', 'corner')
1239
+ setCGuarded(toAnchorX, entryY, '┌', 'corner')
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ // Markers for detour case
1245
+ if (marker.markerAt === 'to') {
1246
+ // Target sits below this point — the arrowhead must point down
1247
+ // into it. Hierarchical markers (inheritance/realization) rotate
1248
+ // opposite to directional ones (association/dependency): passing
1249
+ // 'up' yields a hierarchical marker's down-pointing glyph, while
1250
+ // 'down' yields a directional marker's down-pointing glyph. See
1251
+ // the matching compensation in the "target is above source"
1252
+ // branch below, which handles the mirrored case.
1253
+ const isHierarchical =
1254
+ marker.type === 'inheritance' || marker.type === 'realization'
1255
+ const markerChar = getMarkerShape(
1256
+ marker.type,
1257
+ useAscii,
1258
+ isHierarchical ? 'up' : 'down',
1259
+ )
1260
+ setCGuarded(toAnchorX, entryY, markerChar, 'arrow')
1261
+ }
1262
+ if (marker.markerAt === 'from') {
1263
+ const markerChar = getMarkerShape(marker.type, useAscii, 'down')
1264
+ setCGuarded(fromAnchorX, fromBY + 1, markerChar, 'arrow')
1265
+ }
1266
+ } else {
1267
+ // NO COLLISION CASE: Use original midpoint-based routing
1268
+ // Path: source anchor → (jog to lane) → vertical to midY → horizontal at midY → vertical → (jog to anchor) → target
1269
+ //
1270
+ // The jogs only exist when a lane sits outside its box; otherwise
1271
+ // anchor and lane coincide and the path is the plain three-segment
1272
+ // route. With the default vertical gap the from-jog row, midY, and
1273
+ // the to-jog row are the three rows between the boxes, so a fanned
1274
+ // group reads as a trunk under the source, labels on the lanes,
1275
+ // and a trunk gathering back into the target.
1276
+
1277
+ const midY = fromBY + Math.floor((toTY - fromBY) / 2)
1278
+ const fromJogs = fromCX !== fromAnchorX
1279
+ const toJogs = toCX !== toAnchorX
1280
+
1281
+ // 1. Jog from the source anchor out to the lane, then vertical from
1282
+ // the source bottom (or from below the jog) to midY
1283
+ drawJog(fromBY + 1, fromAnchorX, fromCX, lineH, true)
1284
+ for (let y = fromBY + (fromJogs ? 2 : 1); y <= midY; y++) {
1285
+ setCGuarded(fromCX, y, lineV, 'line')
1286
+ }
1287
+
1288
+ // 2. Horizontal from fromCX to toCX at midY (if needed)
1289
+ //
1290
+ // This segment matters most for the boxCells guard: it can travel
1291
+ // a long way horizontally when the target's column sits far from
1292
+ // the source's (e.g. a level-1 child positioned under a completely
1293
+ // different level-0 sibling than its own parent). Nothing about
1294
+ // `findClearColumn` finding `fromCX` itself clear (the only check
1295
+ // that decided this is the no-collision branch) says the *row*
1296
+ // this horizontal run lands on is clear all the way over to
1297
+ // `toCX` too — a taller same-level sibling sitting between them,
1298
+ // extending below its own row's shortest box, is exactly the case
1299
+ // that check misses.
1300
+ if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) {
1301
+ const lx = Math.min(fromCX, toCX)
1302
+ const rx = Math.max(fromCX, toCX)
1303
+ for (let x = lx; x <= rx; x++) {
1304
+ setCGuarded(x, midY, lineH, 'line')
1305
+ }
1306
+ if (!useAscii) {
1307
+ setCGuarded(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner')
1308
+ setCGuarded(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner')
1309
+ }
1310
+ }
1311
+
1312
+ // 3. Vertical from midY to the target top (or to above the jog),
1313
+ // then jog from the lane back in to the target anchor
1314
+ for (let y = midY + 1; y < toTY - (toJogs ? 1 : 0); y++) {
1315
+ setCGuarded(toCX, y, lineV, 'line')
1316
+ }
1317
+ drawJog(toTY - 1, toAnchorX, toCX, lineH, false)
1318
+
1319
+ // Markers for no-collision case
1320
+ if (marker.markerAt === 'to') {
1321
+ // Same rotation compensation as the detour case above — target is
1322
+ // below this point, so hierarchical markers need 'up' to point
1323
+ // down into it.
1324
+ const isHierarchical =
1325
+ marker.type === 'inheritance' || marker.type === 'realization'
1326
+ setCGuarded(
1327
+ toAnchorX,
1328
+ toTY - 1,
1329
+ getMarkerShape(
1330
+ marker.type,
1331
+ useAscii,
1332
+ isHierarchical ? 'up' : 'down',
1333
+ ),
1334
+ 'arrow',
1335
+ )
1336
+ }
1337
+ if (marker.markerAt === 'from') {
1338
+ setCGuarded(
1339
+ fromAnchorX,
1340
+ fromBY + 1,
1341
+ getMarkerShape(marker.type, useAscii, 'down'),
1342
+ 'arrow',
1343
+ )
1344
+ }
1345
+ }
1346
+ } else if (toP.y + toP.height - 1 < fromP.y) {
1347
+ // Target is ABOVE source — draw upward from source top to target bottom
1348
+ const fromTY = fromP.y
1349
+ const toBY = toP.y + toP.height - 1
1350
+ const midY = toBY + Math.floor((fromTY - toBY) / 2)
1351
+ const fromJogs = fromCX !== fromAnchorX
1352
+ const toJogs = toCX !== toAnchorX
1353
+
1354
+ // Jog along the row above the source from its anchor out to the lane
1355
+ // (mirror of the downward case), then vertical up to midY
1356
+ drawJog(fromTY - 1, fromAnchorX, fromCX, lineH, false)
1357
+ for (let y = fromTY - (fromJogs ? 2 : 1); y >= midY; y--) {
1358
+ setCGuarded(fromCX, y, lineV, 'line')
1359
+ }
1360
+
1361
+ // This branch has no collision-avoidance routing at all (unlike the
1362
+ // "target below source" branch above, which at least tries
1363
+ // `findClearColumn` first) — it always draws the straight
1364
+ // jog/vertical/horizontal/vertical/jog path. The boxCells guard is
1365
+ // this path's only protection against a horizontal run landing on
1366
+ // an unrelated box; see the equivalent midY comment in the "target
1367
+ // below source" branch above for why that's a real, not merely
1368
+ // theoretical, case.
1369
+ if (fromCX !== toCX) {
1370
+ const lx = Math.min(fromCX, toCX)
1371
+ const rx = Math.max(fromCX, toCX)
1372
+ for (let x = lx; x <= rx; x++) {
1373
+ setCGuarded(x, midY, lineH, 'line')
1374
+ }
1375
+ if (!useAscii && midY >= 0 && midY < totalH) {
1376
+ setCGuarded(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner')
1377
+ setCGuarded(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner')
1378
+ }
1379
+ }
1380
+
1381
+ for (let y = midY - 1; y > toBY + (toJogs ? 1 : 0); y--) {
1382
+ setCGuarded(toCX, y, lineV, 'line')
1383
+ }
1384
+ drawJog(toBY + 1, toAnchorX, toCX, lineH, true)
1385
+
1386
+ // Draw markers - arrows point in the direction of the vertical segment (upward)
1387
+ if (marker.markerAt === 'from') {
1388
+ const markerChar = getMarkerShape(marker.type, useAscii, 'up')
1389
+ const my = fromTY - 1
1390
+ for (let i = 0; i < markerChar.length; i++) {
1391
+ setCGuarded(
1392
+ fromAnchorX - Math.floor(markerChar.length / 2) + i,
1393
+ my,
1394
+ markerChar[i]!,
1395
+ 'arrow',
1396
+ )
1397
+ }
1398
+ }
1399
+ if (marker.markerAt === 'to') {
1400
+ const isHierarchical =
1401
+ marker.type === 'inheritance' || marker.type === 'realization'
1402
+ const markerDir = isHierarchical ? 'down' : 'up'
1403
+ const markerChar = getMarkerShape(marker.type, useAscii, markerDir)
1404
+ const my = toBY + 1
1405
+ for (let i = 0; i < markerChar.length; i++) {
1406
+ setCGuarded(
1407
+ toAnchorX - Math.floor(markerChar.length / 2) + i,
1408
+ my,
1409
+ markerChar[i]!,
1410
+ 'arrow',
1411
+ )
1412
+ }
1413
+ }
1414
+ } else {
1415
+ // Same level — draw horizontal line with a detour below both boxes.
1416
+ const toBY = toP.y + toP.height - 1
1417
+ const lx = Math.min(fromCX, toCX)
1418
+ const rx = Math.max(fromCX, toCX)
1419
+
1420
+ // The naive detour row — max(fromBY, toBY) + 2 — is derived only from
1421
+ // this relationship's own two endpoints. A same-level layout puts
1422
+ // every class in the level on one shared row (see the placement loop
1423
+ // above), so a relationship cycle that can't be linearly leveled
1424
+ // (e.g. A->B->C->A) lands a third, unrelated class between `fromP`
1425
+ // and `toP` in that same row. When that in-between class is taller
1426
+ // than both endpoints (more attributes/methods), the naive detour row
1427
+ // sits *above* its bottom edge, and the horizontal segment below
1428
+ // (spanning [lx, rx], which crosses straight through that class's
1429
+ // x-range) cuts through its box instead of running beneath it.
1430
+ // Search for any such same-row obstruction and route the detour below
1431
+ // its bottom edge too — generalizing er-diagram.ts's obstructionBottom
1432
+ // search (issue #350) to class diagrams' same-level detour.
1433
+ let obstructionBottom = Math.max(fromBY, toBY)
1434
+ for (const [id, other] of placed.entries()) {
1435
+ if (id === rel.from || id === rel.to) continue
1436
+ if (other.y !== fromP.y) continue
1437
+ const overlapsGap = other.x < rx + 1 && other.x + other.width > lx
1438
+ if (overlapsGap) {
1439
+ obstructionBottom = Math.max(
1440
+ obstructionBottom,
1441
+ other.y + other.height - 1,
1442
+ )
1443
+ }
1444
+ }
1445
+ const detourY = obstructionBottom + 2
1446
+ sameLevelDetourY.set(relIndex, detourY)
1447
+ increaseSize(canvas, totalW, detourY + 1)
1448
+ increaseRoleCanvasSize(rc, totalW, detourY + 1)
1449
+ const fromJogs = fromCX !== fromAnchorX
1450
+ const toJogs = toCX !== toAnchorX
1451
+
1452
+ // Jog out to the lane under the source, then vertical down from it
1453
+ drawJog(fromBY + 1, fromAnchorX, fromCX, lineH, true)
1454
+ for (let y = fromBY + (fromJogs ? 2 : 1); y <= detourY; y++) {
1455
+ setCGuarded(fromCX, y, lineV, 'line')
1456
+ }
1457
+ // Horizontal
1458
+ for (let x = lx; x <= rx; x++) {
1459
+ setCGuarded(x, detourY, lineH, 'line')
1460
+ }
1461
+ // Vertical up to target, then jog back in to its anchor
1462
+ for (let y = detourY - 1; y >= toBY + (toJogs ? 2 : 1); y--) {
1463
+ setCGuarded(toCX, y, lineV, 'line')
1464
+ }
1465
+ drawJog(toBY + 1, toAnchorX, toCX, lineH, true)
1466
+
1467
+ // Draw markers - same-level routing uses vertical segments at both ends
1468
+ if (marker.markerAt === 'from') {
1469
+ const markerChar = getMarkerShape(marker.type, useAscii, 'down')
1470
+ const my = fromBY + 1
1471
+ for (let i = 0; i < markerChar.length; i++) {
1472
+ setCGuarded(
1473
+ fromAnchorX - Math.floor(markerChar.length / 2) + i,
1474
+ my,
1475
+ markerChar[i]!,
1476
+ 'arrow',
1477
+ )
1478
+ }
1479
+ }
1480
+ if (marker.markerAt === 'to') {
1481
+ // Target sits above this point (line detours below both boxes then
1482
+ // comes back up) — mirrors the "target is above source" branch's
1483
+ // compensation above.
1484
+ const isHierarchical =
1485
+ marker.type === 'inheritance' || marker.type === 'realization'
1486
+ const markerChar = getMarkerShape(
1487
+ marker.type,
1488
+ useAscii,
1489
+ isHierarchical ? 'down' : 'up',
1490
+ )
1491
+ const my = toP.y + toP.height
1492
+ for (let i = 0; i < markerChar.length; i++) {
1493
+ setCGuarded(
1494
+ toAnchorX - Math.floor(markerChar.length / 2) + i,
1495
+ my,
1496
+ markerChar[i]!,
1497
+ 'arrow',
1498
+ )
1499
+ }
1500
+ }
1501
+ }
1502
+ })
1503
+
1504
+ /**
1505
+ * Where a relationship's label should ideally center — the column and row
1506
+ * the territory precompute and label-drawing passes below both anchor on.
1507
+ *
1508
+ * For the common case (no detour), this is the straight-line midpoint
1509
+ * between the source and target connection columns/rows, same as before.
1510
+ *
1511
+ * For a relationship whose line detours around an intermediate box (see
1512
+ * `detourRoutes` above), the label instead anchors on the *actual routed
1513
+ * path*: beside the midpoint of the detour's vertical trunk (the longest
1514
+ * segment in the common case), or — when the boxes are close enough
1515
+ * together that the trunk has no vertical room at all — the midpoint of
1516
+ * whichever horizontal jog is longer. Anchoring at the straight-line
1517
+ * midpoint ignored the detour entirely, so a detoured relationship's
1518
+ * label could land in the same column (and even the same row) as an
1519
+ * unrelated relationship's straight-line label, reading as though both
1520
+ * terminated at the same box (issue #487).
1521
+ *
1522
+ * `labelWidth` (the padded display width the caller is about to draw)
1523
+ * matters for the vertical-trunk case specifically: `findClearColumn`
1524
+ * only guarantees the single column `routeX` is clear of boxes over the
1525
+ * trunk's row range, not a whole label-width window centered on it. A
1526
+ * label centered directly on `routeX` routinely re-overlapped the very
1527
+ * box the trunk was routed around to avoid — visible as the label
1528
+ * appearing to collide with (or get shoved off) the box the trunk hugs.
1529
+ * `detour.clearSide` says which side of `routeX` is the side
1530
+ * `findClearColumn` actually found clear (see its call site), so the
1531
+ * label is anchored flush against the trunk on that side instead of
1532
+ * straddling it.
1533
+ */
1534
+ function computeLabelAnchor(
1535
+ relIndex: number,
1536
+ fromP: PlacedClass,
1537
+ toP: PlacedClass,
1538
+ labelWidth: number,
1539
+ ): { idealMidX: number; baseMidY: number } {
1540
+ const { fromCX, toCX } = connectionColumns(relIndex, fromP, toP)
1541
+ const fromBY = fromP.y + fromP.height - 1
1542
+ const toTY = toP.y
1543
+
1544
+ if (fromBY < toTY) {
1545
+ const detour = detourRoutes.get(relIndex)
1546
+ if (detour) {
1547
+ const trunkTop = detour.exitY + 1
1548
+ const trunkBottom = detour.entryY
1549
+ if (trunkBottom >= trunkTop) {
1550
+ // Vertical trunk has room — it's the longest segment of the
1551
+ // route in the common case, so anchor there. The label sits
1552
+ // flush against the trunk on its clear side (see doc comment)
1553
+ // rather than centered on it, with a 1-column gap for legibility.
1554
+ const gap = 1
1555
+ const idealMidX =
1556
+ detour.clearSide === 'right'
1557
+ ? detour.routeX + gap + Math.floor(labelWidth / 2)
1558
+ : detour.routeX - gap - Math.ceil(labelWidth / 2)
1559
+ return {
1560
+ idealMidX,
1561
+ baseMidY: Math.floor((trunkTop + trunkBottom) / 2),
1562
+ }
1563
+ }
1564
+ // Boxes are close enough together that the trunk has no vertical
1565
+ // room (exit and entry jogs sit on the same or adjacent rows) —
1566
+ // anchor on whichever horizontal jog is longer instead.
1567
+ const exitWidth = Math.abs(detour.routeX - detour.fromAnchorX)
1568
+ const entryWidth = Math.abs(detour.toAnchorX - detour.routeX)
1569
+ return entryWidth >= exitWidth
1570
+ ? {
1571
+ idealMidX: Math.floor((detour.routeX + detour.toAnchorX) / 2),
1572
+ baseMidY: detour.entryY,
1573
+ }
1574
+ : {
1575
+ idealMidX: Math.floor((detour.fromAnchorX + detour.routeX) / 2),
1576
+ baseMidY: detour.exitY,
1577
+ }
1578
+ }
1579
+ return {
1580
+ idealMidX: Math.floor((fromCX + toCX) / 2),
1581
+ baseMidY: Math.floor((fromBY + 1 + toTY - 1) / 2),
1582
+ }
1583
+ }
1584
+ if (toP.y + toP.height - 1 < fromP.y) {
1585
+ const toBY = toP.y + toP.height - 1
1586
+ return {
1587
+ idealMidX: Math.floor((fromCX + toCX) / 2),
1588
+ baseMidY: Math.floor((toBY + 1 + fromP.y - 1) / 2),
1589
+ }
1590
+ }
1591
+ // Same level — anchor on the obstruction-aware detour row the connector
1592
+ // itself was routed through (see `sameLevelDetourY` above), not the
1593
+ // naive `Math.max(fromBY, toBY) + 2` recomputation, which ignores any
1594
+ // taller same-row box between `fromP`/`toP` and would place the label
1595
+ // right back inside it (issue #953).
1596
+ return {
1597
+ idealMidX: Math.floor((fromCX + toCX) / 2),
1598
+ // The `??` fallback is unreachable in practice: the main
1599
+ // relationship-drawing pass above runs for every relationship before
1600
+ // this label pass does, and it sets `sameLevelDetourY` for every
1601
+ // relIndex that takes this same "same level" branch — the exact
1602
+ // condition this function just evaluated to reach here. Kept only
1603
+ // because `Map.get` is typed `T | undefined`, not because a real
1604
+ // diagram can actually hit it.
1605
+ /* v8 ignore next */
1606
+ baseMidY:
1607
+ sameLevelDetourY.get(relIndex) ??
1608
+ Math.max(fromBY, toP.y + toP.height - 1) + 2,
1609
+ }
1610
+ }
1611
+
1612
+ /**
1613
+ * Resolve a label's actual drawn row, starting from `computeLabelAnchor`'s
1614
+ * `baseMidY` and nudging it to the nearest box-free row in the from/to gap
1615
+ * when the ideal row would land inside an intervening box. Shared by the
1616
+ * territory precompute below and the draw pass further down so both agree
1617
+ * on the *same* final row for a given relationship — see the doc comment
1618
+ * on the territory precompute for why that agreement matters (issue #531).
1619
+ */
1620
+ function resolveLabelFinalY(
1621
+ fromP: PlacedClass,
1622
+ toP: PlacedClass,
1623
+ idealMidX: number,
1624
+ baseMidY: number,
1625
+ maxLabelWidth: number,
1626
+ lineCount: number,
1627
+ excludeIds: Set<string>,
1628
+ ): number {
1629
+ let labelY = baseMidY
1630
+ const halfHeight = Math.floor(lineCount / 2)
1631
+ const fromBY = fromP.y + fromP.height - 1
1632
+ const toTY = toP.y
1633
+
1634
+ // Check if any label line would be inside a box
1635
+ let labelInBox = false
1636
+ for (let i = 0; i < lineCount; i++) {
1637
+ const y = labelY - halfHeight + i
1638
+ const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)
1639
+ const labelStart = Math.max(0, idealLabelStart)
1640
+ for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {
1641
+ if (isInsideBox(x, y, excludeIds)) {
1642
+ labelInBox = true
1643
+ break
1644
+ }
1645
+ }
1646
+ if (labelInBox) break
1647
+ }
1648
+
1649
+ // If label is inside a box, find the gap between boxes
1650
+ if (labelInBox) {
1651
+ const gapTop = fromBY + 1
1652
+ const gapBottom = toTY - 1
1653
+
1654
+ // Place label in the middle of the gap, outside any intermediate box
1655
+ for (let y = gapTop; y <= gapBottom; y++) {
1656
+ let clearRow = true
1657
+ const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)
1658
+ const labelStart = Math.max(0, idealLabelStart)
1659
+ for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {
1660
+ if (isInsideBox(x, y, excludeIds)) {
1661
+ clearRow = false
1662
+ break
1663
+ }
1664
+ }
1665
+ if (clearRow) {
1666
+ labelY = y
1667
+ break
1668
+ }
1669
+ }
1670
+ }
1671
+
1672
+ return labelY
1673
+ }
1674
+
1675
+ // --- Precompute each label's horizontal territory ---
1676
+ // A label's left/right bound is derived purely from connection-point
1677
+ // geometry — never from draw order or from what another relationship's
1678
+ // label happened to render as. Two labels whose natural (fully centered,
1679
+ // unclamped) spans would actually overlap split the contested space
1680
+ // evenly at the midpoint between their connection columns; labels that
1681
+ // don't naturally overlap are left unconstrained by each other.
1682
+ //
1683
+ // This matters because a *reactive* approach — only checking cells an
1684
+ // earlier iteration already drew text into — cascades: a label pinned
1685
+ // near the canvas's left edge (idealLabelStart < 0) used to be *shifted*
1686
+ // fully into view via `Math.max(0, idealLabelStart)` rather than
1687
+ // *clipped*, which silently ate into its neighbor's rightful column; that
1688
+ // neighbor then had nothing left to reactively claim and vanished
1689
+ // entirely, while labels further along drifted based on whatever was
1690
+ // left over. Precomputing fixed, mutually exclusive territories up front
1691
+ // — before any label is drawn — means every label's placement is
1692
+ // consistent regardless of relationship order, and a genuinely
1693
+ // insufficient column width (e.g. six same-row relationships spaced
1694
+ // closer together than their labels are wide) truncates *all* of them
1695
+ // consistently instead of destroying an arbitrary subset. See issue #447.
1696
+ //
1697
+ // rowStart/rowEnd are derived from `resolveLabelFinalY`'s result, *not*
1698
+ // `computeLabelAnchor`'s raw `baseMidY` — the same "clear box gap" runtime
1699
+ // fallback the draw pass applies can move a label to a row far from its
1700
+ // ideal one (e.g. a detoured relationship whose ideal row lands inside the
1701
+ // box it detoured around). Territories computed from the pre-fallback row
1702
+ // used to miss exactly the overlaps that fallback creates: two
1703
+ // relationships whose *ideal* rows didn't overlap could still both
1704
+ // resolve to the *same actual* row, and with no territory split between
1705
+ // them the later one's unconditional draw silently overwrote the earlier
1706
+ // one's label (issue #531). Resolving here, once, up front — and reusing
1707
+ // the result in the draw pass below via `finalLabelYByRel` — guarantees
1708
+ // territory and drawing always agree on the same row per relationship.
1709
+ //
1710
+ // The allocation itself — splitting contested columns at the midpoint
1711
+ // between two competing labels' idealMidX values — lives in
1712
+ // `allocateTerritory` (`territory.ts`), which is storage-agnostic and
1713
+ // knows nothing about relationships; this pass only supplies the geometry
1714
+ // (issue #618). Its doc comments carry the full rationale summarised
1715
+ // above.
1716
+ interface LabelGeometry {
1717
+ rel: (typeof diagram.relationships)[number]
1718
+ idealMidX: number
1719
+ naturalStart: number
1720
+ naturalEnd: number
1721
+ rowStart: number
1722
+ rowEnd: number
1723
+ }
1724
+ const finalLabelYByRel = new Map<
1725
+ (typeof diagram.relationships)[number],
1726
+ number
1727
+ >()
1728
+ const labelGeometry: LabelGeometry[] = []
1729
+ for (const [relIndex, rel] of diagram.relationships.entries()) {
1730
+ if (!rel.label) continue
1731
+ const fromP = placed.get(rel.from)
1732
+ const toP = placed.get(rel.to)
1733
+ if (!fromP || !toP) continue
1734
+
1735
+ // Same anchor the draw pass below uses — needed so territory splitting
1736
+ // only ever kicks in between labels that could actually land on
1737
+ // overlapping rows. Two relationships can share a similar idealMidX
1738
+ // while being drawn many rows apart (e.g. one class's two separate
1739
+ // outgoing edges to two different targets at different heights) —
1740
+ // splitting their X territory in that case truncates both for no
1741
+ // reason, since they never actually collide.
1742
+ const lines = splitLines(rel.label)
1743
+ const halfHeight = Math.floor(lines.length / 2)
1744
+
1745
+ const width = Math.max(...lines.map(displayWidth)) + 2 // +2 for padding
1746
+ const { idealMidX, baseMidY } = computeLabelAnchor(
1747
+ relIndex,
1748
+ fromP,
1749
+ toP,
1750
+ width,
1751
+ )
1752
+ const excludeIds = new Set([rel.from, rel.to])
1753
+ const labelY = resolveLabelFinalY(
1754
+ fromP,
1755
+ toP,
1756
+ idealMidX,
1757
+ baseMidY,
1758
+ width,
1759
+ lines.length,
1760
+ excludeIds,
1761
+ )
1762
+ finalLabelYByRel.set(rel, labelY)
1763
+ // Clamped the same way the draw loop below clamps it (never negative —
1764
+ // a label can't render left of the canvas edge) so this overlap check
1765
+ // reflects what will actually be drawn. Using the *unclamped* value
1766
+ // here would miss overlaps a left-edge label creates once it's shifted
1767
+ // into view: it would compute this label's territory as if it stayed
1768
+ // off-canvas at its raw, negative position instead of at column 0,
1769
+ // under-detecting a real collision with its neighbor (issue #447).
1770
+ const naturalStart = Math.max(0, idealMidX - Math.floor(width / 2))
1771
+ labelGeometry.push({
1772
+ rel,
1773
+ idealMidX,
1774
+ naturalStart,
1775
+ naturalEnd: naturalStart + width - 1,
1776
+ rowStart: labelY - halfHeight,
1777
+ rowEnd: labelY + halfHeight,
1778
+ })
1779
+ }
1780
+ const territoryByGeometry = allocateTerritory(labelGeometry, (g) => ({
1781
+ idealMid: g.idealMidX,
1782
+ start: g.naturalStart,
1783
+ end: g.naturalEnd,
1784
+ rowStart: g.rowStart,
1785
+ rowEnd: g.rowEnd,
1786
+ }))
1787
+ const territoryByRel = new Map<
1788
+ (typeof diagram.relationships)[number],
1789
+ Territory
1790
+ >()
1791
+ for (const g of labelGeometry) {
1792
+ const territory = territoryByGeometry.get(g)
1793
+ if (territory) territoryByRel.set(g.rel, territory)
1794
+ }
1795
+
1796
+ // --- Draw relationship labels ---
1797
+ // Deliberately a separate pass over *all* relationships, run only after
1798
+ // every relationship's lines are drawn above. Labels used to be drawn
1799
+ // inline with each relationship's own lines, which let a *later*
1800
+ // relationship's connector line silently overwrite an *earlier*
1801
+ // relationship's already-drawn label whenever both routes crossed the
1802
+ // same row — e.g. two edges converging on one target box both jog through
1803
+ // the same midpoint row (issue #447, "teaches" truncated to "tea" and
1804
+ // merged into a box-drawing corner). Running all lines first means no
1805
+ // line write can ever land on top of label text again.
1806
+ for (const [relIndex, rel] of diagram.relationships.entries()) {
1807
+ const fromP = placed.get(rel.from)
1808
+ const toP = placed.get(rel.to)
1809
+ if (!fromP || !toP) continue
1810
+ if (!rel.label) continue
1811
+
1812
+ // Draw relationship label at midpoint (supports multi-line)
1813
+ // Add padding around the label for readability
1814
+ {
1815
+ const lines = splitLines(rel.label)
1816
+ const maxLabelWidth = Math.max(...lines.map((l) => displayWidth(l))) + 2 // +2 for padding
1817
+
1818
+ // Calculate ideal label position based on routing direction (and, for
1819
+ // a detoured relationship, its actual routed path — see
1820
+ // `computeLabelAnchor`).
1821
+ const { idealMidX } = computeLabelAnchor(
1822
+ relIndex,
1823
+ fromP,
1824
+ toP,
1825
+ maxLabelWidth,
1826
+ )
1827
+
1828
+ // The row to draw on: resolved once, up front, by the territory
1829
+ // precompute above (via `resolveLabelFinalY`) — reused here rather
1830
+ // than re-resolved so this pass and the territory it reads from
1831
+ // (`territoryByRel`, just below) always agree on the same row for
1832
+ // this relationship. See the precompute's doc comment (issue #531).
1833
+ const labelY = finalLabelYByRel.get(rel)!
1834
+ const halfHeight = Math.floor(lines.length / 2)
1835
+
1836
+ // Center lines vertically around labelY
1837
+ const startY = labelY - halfHeight
1838
+
1839
+ const territory = territoryByRel.get(rel)!
1840
+
1841
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1842
+ const y = startY + lineIdx
1843
+ const text = lines[lineIdx]!
1844
+
1845
+ // Grid cells, not code units: a wide glyph occupies two columns, so
1846
+ // measuring/writing by code unit would centre the label wrong and
1847
+ // overrun the space cleared for it.
1848
+ const naturalCells = toDisplayCells(` ${text} `) // space padding on both sides
1849
+ // Clamped to never go negative — matches the clamp the territory
1850
+ // precomputation above already applied when deciding whether this
1851
+ // label and its neighbors' natural spans overlap, so the two stay
1852
+ // consistent. `fitLabelToAvailableWidth` still clips in place
1853
+ // rather than shifting for whatever collisions the territory
1854
+ // bounds encode, including one this very clamp creates against a
1855
+ // left neighbor (see the territory precomputation's comment).
1856
+ const naturalStart = Math.max(
1857
+ 0,
1858
+ idealMidX - Math.floor(naturalCells.length / 2),
1859
+ )
1860
+
1861
+ const { start: labelStart, cells } = fitLabelToAvailableWidth(
1862
+ naturalStart,
1863
+ naturalCells,
1864
+ text,
1865
+ Math.max(0, territory.left),
1866
+ territory.right,
1867
+ )
1868
+
1869
+ // Ensure canvas is wide enough for the label
1870
+ const labelEnd = labelStart + cells.length
1871
+ if (labelEnd > 0 && y >= 0) {
1872
+ increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1))
1873
+ increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1))
1874
+ }
1875
+ // Clear the area first (overwrite line characters) then draw the padded label
1876
+ for (let i = 0; i < cells.length; i++) {
1877
+ const lx = labelStart + i
1878
+ if (lx >= 0 && y >= 0) {
1879
+ setC(lx, y, cells[i]!, 'text')
1880
+ }
1881
+ }
1882
+ }
1883
+ }
1884
+ }
1885
+
1886
+ // Notes were drawn as plain boxes above; round their corners and join
1887
+ // each attached note to its class. Last, so a connector never lands on
1888
+ // top of a relationship line or label.
1889
+ decorateAsciiNotes(notesById, placed, canvas, useAscii, setC)
1890
+
1891
+ return canvasToString(canvas, {
1892
+ roleCanvas: rc,
1893
+ colorMode,
1894
+ theme,
1895
+ linkCanvas,
1896
+ })
1897
+ }
1898
+
1899
+ // ============================================================================
1900
+ // Notes — `note "text"` / `note for X "text"` (issue #420)
1901
+ //
1902
+ // A note is laid out as a pseudo-class: it gets a box like any class (a
1903
+ // single header-only section holding the note text, via the same
1904
+ // buildClassSections/drawMultiBox path), and is spliced into
1905
+ // `diagram.classes` immediately after the class it annotates so the row
1906
+ // placement puts it directly to that class's right. A free note, or one
1907
+ // whose class doesn't exist, is appended to the end and sits on the top row.
1908
+ // After all boxes and relationships are drawn, decorateAsciiNotes() swaps
1909
+ // the note's corners for rounded ones — the same rectangle-vs-rounded
1910
+ // distinction the flowchart ASCII renderer draws — and joins an attached
1911
+ // note to its class with a short dashed connector, the ASCII counterpart of
1912
+ // the dotted note→class link Mermaid draws. Class ids can't contain
1913
+ // whitespace, so a pseudo id with a space in it can never collide with one.
1914
+ // ============================================================================
1915
+
1916
+ /**
1917
+ * Return a copy of `parsed` whose `classes` list also contains one
1918
+ * pseudo-class per note, positioned as described above, plus a map from each
1919
+ * pseudo id to its note.
1920
+ */
1921
+ function withNotePseudoClasses(parsed: ClassDiagram): {
1922
+ diagram: ClassDiagram
1923
+ notesById: Map<string, ClassNote>
1924
+ } {
1925
+ const notesById = new Map<string, ClassNote>()
1926
+ if (parsed.notes.length === 0) return { diagram: parsed, notesById }
1927
+
1928
+ const pseudo = (index: number, note: ClassNote): ClassNode => {
1929
+ const id = `note ${index}`
1930
+ notesById.set(id, note)
1931
+ return { id, label: note.text, attributes: [], methods: [] }
1932
+ }
1933
+
1934
+ const classIds = new Set(parsed.classes.map((c) => c.id))
1935
+ const classes: ClassNode[] = []
1936
+ for (const cls of parsed.classes) {
1937
+ classes.push(cls)
1938
+ for (const [i, note] of parsed.notes.entries()) {
1939
+ if (note.forClass === cls.id) classes.push(pseudo(i, note))
1940
+ }
1941
+ }
1942
+ for (const [i, note] of parsed.notes.entries()) {
1943
+ if (note.forClass === undefined || !classIds.has(note.forClass)) {
1944
+ classes.push(pseudo(i, note))
1945
+ }
1946
+ }
1947
+
1948
+ return { diagram: { ...parsed, classes }, notesById }
1949
+ }
1950
+
1951
+ /** Put each attached note on the same level as its class. */
1952
+ function pinNoteLevels(
1953
+ notesById: Map<string, ClassNote>,
1954
+ level: Map<string, number>,
1955
+ ): void {
1956
+ for (const [id, note] of notesById) {
1957
+ if (note.forClass === undefined) continue
1958
+ const classLevel = level.get(note.forClass)
1959
+ if (classLevel !== undefined) level.set(id, classLevel)
1960
+ }
1961
+ }
1962
+
1963
+ /**
1964
+ * Round each note box's corners and draw the dashed connector from an
1965
+ * attached note's class to the note. The connector only fills cells that
1966
+ * are still blank, so it can never break a relationship line that happens
1967
+ * to route through the gap.
1968
+ */
1969
+ function decorateAsciiNotes(
1970
+ notesById: Map<string, ClassNote>,
1971
+ placed: Map<string, PlacedClass>,
1972
+ canvas: Canvas,
1973
+ useAscii: boolean,
1974
+ setC: (x: number, y: number, ch: string, role: CharRole) => void,
1975
+ ): void {
1976
+ if (notesById.size === 0) return
1977
+ const corners = getCorners('rounded', useAscii)
1978
+ const dash = useAscii ? '.' : '╌'
1979
+
1980
+ for (const [id, note] of notesById) {
1981
+ const p = placed.get(id)
1982
+ if (!p) continue
1983
+ const right = p.x + p.width - 1
1984
+ const bottom = p.y + p.height - 1
1985
+ setC(p.x, p.y, corners.tl, 'border')
1986
+ setC(right, p.y, corners.tr, 'border')
1987
+ setC(p.x, bottom, corners.bl, 'border')
1988
+ setC(right, bottom, corners.br, 'border')
1989
+
1990
+ if (note.forClass === undefined) continue
1991
+ const cls = placed.get(note.forClass)
1992
+ // By construction the note sits directly right of its class on the same
1993
+ // row; anything else means the layout changed under us — skip the line
1994
+ // rather than draw one through the middle of the diagram.
1995
+ if (!cls || cls.x + cls.width > p.x || cls.y !== p.y) continue
1996
+ const y = cls.y + 1
1997
+ for (let x = cls.x + cls.width; x < p.x; x++) {
1998
+ if (canvas[x]?.[y] === ' ') setC(x, y, dash, 'line')
1999
+ }
2000
+ }
2001
+ }