@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,1318 @@
1
+ // ============================================================================
2
+ // ASCII renderer — sequence diagrams
3
+ //
4
+ // Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout.
5
+ // Each actor occupies a column with a vertical lifeline; messages are horizontal
6
+ // arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups.
7
+ //
8
+ // Layout is fundamentally different from flowcharts — no grid or A* pathfinding.
9
+ // Instead: actors → columns, messages → rows, all positioned linearly.
10
+ // ============================================================================
11
+
12
+ import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
13
+ import type { Block } from '@zombie-mermaid/mermaid-parser'
14
+ import type { AsciiConfig, CharRole, AsciiTheme, ColorMode } from './types.ts'
15
+ import {
16
+ mkCanvas,
17
+ mkRoleCanvas,
18
+ canvasToString,
19
+ getCanvasSize,
20
+ increaseSize,
21
+ increaseRoleCanvasSize,
22
+ write,
23
+ } from './canvas.ts'
24
+ import { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts'
25
+ import { splitStatements } from '@zombie-mermaid/core'
26
+ import {
27
+ displayWidth,
28
+ toDisplayCells,
29
+ WIDE_CHAR_PLACEHOLDER,
30
+ } from './display-width.ts'
31
+ import type { Message } from '@zombie-mermaid/mermaid-parser'
32
+ import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
33
+
34
+ // Width of a self-message's loop glyphs (├──┐ / ◀──┘), excluding the label.
35
+ // Shared between the drawing pass and the block-wall extent calculation so
36
+ // self-arrows inside alt/loop/opt blocks don't get clipped by the wall.
37
+ const SELF_LOOP_WIDTH = 4
38
+
39
+ // Small stick-figure glyph drawn above an `actor`-kind participant's label
40
+ // (inside the same bordered box a `participant` gets), so mermaid's two
41
+ // declaration keywords stay visually distinct in ASCII output the way real
42
+ // mermaid.js's SVG renderer distinguishes them (a circle-person icon vs. a
43
+ // plain box) — see issue #449. Plain ASCII already, so — unlike H/V/TL/etc.
44
+ // above — there's no separate useAscii/unicode variant to pick between.
45
+ const ACTOR_GLYPH_LINES = ['O', '/|\\', '/ \\']
46
+ const ACTOR_GLYPH_WIDTH = 3
47
+
48
+ // Horizontal clearance between a block's (loop/alt/opt/par/etc.) side wall
49
+ // and the lifelines its own messages touch. Shared by every block type —
50
+ // there is no per-type wall calculation, so this constant is the single
51
+ // source of truth for that spacing (see BLOCK_WALL_MARGIN's use below for
52
+ // why a *second*, independent use of it also guards against an untouched
53
+ // lifeline).
54
+ const BLOCK_WALL_MARGIN = 4
55
+
56
+ // Effective width of a self-message's loop, including room for an
57
+ // autonumber badge drawn at the start of the top arm when active. The badge
58
+ // digits replace the leading dashes rather than adding a second arrowhead,
59
+ // so the loop only needs to widen by the badge's own digit count — one
60
+ // extra column per digit — to keep the corner glyphs (┐/┘) intact.
61
+ // Shared by the drawing pass, the canvas-width pass, and the block-wall
62
+ // extent calculation so all three agree on how much room a numbered
63
+ // self-message actually needs.
64
+ function selfLoopWidth(msg: Message): number {
65
+ return msg.seqNumber === undefined
66
+ ? SELF_LOOP_WIDTH
67
+ : SELF_LOOP_WIDTH + String(msg.seqNumber).length
68
+ }
69
+
70
+ /**
71
+ * Render a Mermaid sequence diagram to ASCII/Unicode text.
72
+ *
73
+ * Pipeline: parse → layout (columns + rows) → draw onto canvas → string.
74
+ */
75
+ export function renderSequenceAscii(
76
+ text: string,
77
+ config: AsciiConfig,
78
+ colorMode?: ColorMode,
79
+ theme?: AsciiTheme,
80
+ ): string {
81
+ const lines = splitStatements(text)
82
+ const diagram = parseSequenceDiagram(lines)
83
+
84
+ if (diagram.actors.length === 0) return ''
85
+
86
+ const useAscii = config.useAscii
87
+
88
+ // Box-drawing characters
89
+ const H = useAscii ? '-' : '─'
90
+ const V = useAscii ? '|' : '│'
91
+ const TL = useAscii ? '+' : '┌'
92
+ const TR = useAscii ? '+' : '┐'
93
+ const BL = useAscii ? '+' : '└'
94
+ const BR = useAscii ? '+' : '┘'
95
+ const JT = useAscii ? '+' : '┬' // top junction on lifeline
96
+ const JB = useAscii ? '+' : '┴' // bottom junction on lifeline
97
+ const JL = useAscii ? '+' : '├' // left junction
98
+ const JR = useAscii ? '+' : '┤' // right junction
99
+
100
+ // ---- LAYOUT: compute lifeline X positions ----
101
+
102
+ const actorIdx = new Map<string, number>()
103
+ diagram.actors.forEach((a, i) => actorIdx.set(a.id, i))
104
+
105
+ /**
106
+ * Look up an actor's column index. The parser's `ensureActor` (see
107
+ * sequence/parser.ts) guarantees every message endpoint has a
108
+ * corresponding actor entry, so this is always found for real parsed
109
+ * input — but that guarantee lives in a different module/function, so
110
+ * narrow it here explicitly rather than trusting it silently across the
111
+ * boundary.
112
+ */
113
+ function actorIndexOf(id: string): number {
114
+ const idx = actorIdx.get(id)
115
+ if (idx === undefined) {
116
+ /* v8 ignore next */
117
+ throw new Error(`Sequence diagram: unknown actor "${id}"`)
118
+ }
119
+ return idx
120
+ }
121
+
122
+ /**
123
+ * Widest line among a block's header ("alt [label]") and every divider
124
+ * ("[else label]") — the minimum wall width the block's own text needs,
125
+ * independent of how far its messages' lifelines happen to span.
126
+ */
127
+ function maxBlockLabelWidth(block: Block): number {
128
+ const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type
129
+ let width = maxLineWidth(hdrLabel)
130
+ for (const divider of block.dividers) {
131
+ if (divider.label) {
132
+ width = Math.max(width, maxLineWidth(`[${divider.label}]`))
133
+ }
134
+ }
135
+ return width
136
+ }
137
+
138
+ // Clamped: a negative boxBorderPadding would otherwise produce a negative
139
+ // actor/note box width for a short label (see issue #343's CodeRabbit
140
+ // review — the same class of bug fixed in draw-boxes.ts's
141
+ // measureMultiBox/drawMultiBox for class/ER boxes).
142
+ const boxPad = Math.max(0, config.boxBorderPadding)
143
+ // `maxLineWidth`/`lineCount` account for the label only; add the
144
+ // stick-figure glyph's own footprint (see ACTOR_GLYPH_LINES below) for
145
+ // actor-kind participants so box sizing matches what drawActorBox draws.
146
+ const actorContentWidth = (a: {
147
+ label: string
148
+ type: 'participant' | 'actor'
149
+ }) =>
150
+ Math.max(maxLineWidth(a.label), a.type === 'actor' ? ACTOR_GLYPH_WIDTH : 0)
151
+ const actorContentHeight = (a: {
152
+ label: string
153
+ type: 'participant' | 'actor'
154
+ }) => lineCount(a.label) + (a.type === 'actor' ? ACTOR_GLYPH_LINES.length : 0)
155
+ // Use max line width for multi-line actor labels
156
+ const actorBoxWidths = diagram.actors.map(
157
+ (a) => actorContentWidth(a) + 2 * boxPad + 2,
158
+ )
159
+ const halfBox = actorBoxWidths.map((w) => Math.ceil(w / 2))
160
+ // Calculate actor box heights based on number of lines in label
161
+ const actorBoxHeights = diagram.actors.map((a) => actorContentHeight(a) + 2) // content lines + top/bottom border
162
+ const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning
163
+ // Left border column of each actor's box — drawActorBox derives the same
164
+ // `left` from the lifeline centre, so a creating arrow can stop just short
165
+ // of it (see "DRAW: messages" below).
166
+ const actorBoxLeft = (i: number, cx: number) =>
167
+ cx - Math.floor(actorBoxWidths[i]! / 2)
168
+
169
+ // ---- Participant lifecycle (create / destroy) ----
170
+ //
171
+ // A `create participant X` box is drawn centred on the row of the message
172
+ // that creates it, in place of a header box; a `destroy X` lifeline stops
173
+ // one row under the destroying arrow with a cross glyph, and gets no
174
+ // footer box. Mermaid draws the created box centred on the message line
175
+ // and ends the destroyed lifeline with a cross at that line; the ASCII
176
+ // form keeps the arrowhead intact by putting the cross on the row below
177
+ // — a cross *on* the arrow row would replace the arrowhead and read as a
178
+ // lost message (`-x`) instead.
179
+ //
180
+ // Rows above the arrow taken by the created box, and rows below it. For
181
+ // the default 3-row box that's one row each side of the arrow row.
182
+ const createdBoxAbove = Math.floor((actorBoxH - 1) / 2)
183
+ const createdBoxBelow = actorBoxH - 1 - createdBoxAbove
184
+ // Message index → index of the actor it creates (recipient) / destroys.
185
+ const createdByMsg = new Map<number, number>()
186
+ const destroyedByMsg = new Map<number, number>()
187
+ diagram.actors.forEach((a, i) => {
188
+ if (a.createdAt !== undefined) createdByMsg.set(a.createdAt, i)
189
+ if (a.destroyedAt !== undefined) destroyedByMsg.set(a.destroyedAt, i)
190
+ })
191
+ // Filled in by the vertical layout pass: actor index → top row of its
192
+ // created box / row of its destroy cross.
193
+ const createdBoxTop = new Map<number, number>()
194
+ const destroyRow = new Map<number, number>()
195
+
196
+ // ---- Participant groups (box … end) ----
197
+ //
198
+ // Each non-empty box becomes a labelled bracket around its members' header
199
+ // boxes (and an unlabelled one around their footer boxes), one column
200
+ // outside the outermost member's box on each side and one row above and
201
+ // below the actor-box rows:
202
+ //
203
+ // ┌─ Label ───────────────────┐
204
+ // │ ┌───────┐ ┌──────┐ │
205
+ // │ │ Alice │ │ John │ │
206
+ // │ └───┬───┘ └───┬──┘ │
207
+ // └─────┼───────────────┼─────┘
208
+ // │ │
209
+ //
210
+ // The bracket spans from the leftmost member to the rightmost, so a
211
+ // participant declared between two members sits visually inside, as in
212
+ // Mermaid. Colour is not representable here and is ignored. Running the
213
+ // side walls the full height of the diagram (as Mermaid's background
214
+ // rect does) would cut through every message crossing a group boundary,
215
+ // so the group is shown at the header and footer only.
216
+ const boxSpans = diagram.boxes
217
+ .filter((b) => b.actorIds.length > 0)
218
+ .map((b) => {
219
+ const idxs = b.actorIds.map(actorIndexOf)
220
+ return { label: b.label, lo: Math.min(...idxs), hi: Math.max(...idxs) }
221
+ })
222
+ const hasBoxes = boxSpans.length > 0
223
+ // Actor index → index into boxSpans whose span contains it, or -1.
224
+ const boxSpanOf = diagram.actors.map((_, i) =>
225
+ boxSpans.findIndex((s) => s.lo <= i && i <= s.hi),
226
+ )
227
+ // Rows the header bracket adds above the actor boxes (its top border) —
228
+ // the same count is added below them for its bottom border, and the
229
+ // footer bracket mirrors both.
230
+ const bracketRows = hasBoxes ? 1 : 0
231
+ // Columns between a member box's border and the bracket wall.
232
+ const BRACKET_GAP = 1
233
+ // Minimum bracket width for a label: `┌─ Label ─┐` needs the corner, a
234
+ // dash, a space, the label, a space, a dash, and the closing corner.
235
+ const bracketLabelWidth = (label: string) =>
236
+ label === '' ? 0 : maxLineWidth(label) + 6
237
+
238
+ // Compute minimum gap between adjacent lifelines based on message labels.
239
+ // For messages spanning multiple actors, distribute the required width across gaps.
240
+ const adjMaxWidth: number[] = new Array(
241
+ Math.max(diagram.actors.length - 1, 0),
242
+ ).fill(0)
243
+
244
+ for (const msg of diagram.messages) {
245
+ const fi = actorIndexOf(msg.from)
246
+ const ti = actorIndexOf(msg.to)
247
+ if (fi === ti) continue // self-messages don't affect spacing
248
+ const lo = Math.min(fi, ti)
249
+ const hi = Math.max(fi, ti)
250
+ // Required gap per span = (max line width + arrow decorations) / number of gaps
251
+ const needed = maxLineWidth(msg.label) + 4
252
+ const numGaps = hi - lo
253
+ const perGap = Math.ceil(needed / numGaps)
254
+ for (let g = lo; g < hi; g++) {
255
+ adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap)
256
+ }
257
+ }
258
+
259
+ // Width (including border + padding, matching the box drawn in the
260
+ // vertical-layout pass below) of a note anchored to an actor. Computed up
261
+ // front, before lifeline x-positions are finalized, so `left`/`right`
262
+ // notes can participate in gap sizing the same way message labels already
263
+ // do (issue #953 case C) instead of only being checked against a layout
264
+ // that's already fixed.
265
+ function noteBoxWidth(note: (typeof diagram.notes)[number]): number {
266
+ const nLines = splitLines(note.text)
267
+ return Math.max(...nLines.map((l) => displayWidth(l))) + 2 + 2 * boxPad
268
+ }
269
+
270
+ // Per-actor widest 'left'/'right' note anchored to it. A `left` note on
271
+ // actor i draws into the gap to its left (between actor i-1 and i, or —
272
+ // for the leftmost actor — the canvas's own left margin); a `right` note
273
+ // draws into the gap to its right (between actor i and i+1). Both need to
274
+ // be reserved as part of layout, not clamped after the fact (issue #953
275
+ // cases A-C: notes previously only ever collided with whatever gap
276
+ // message-label sizing happened to leave).
277
+ const leftNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
278
+ const rightNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
279
+ // A single-actor `over` note draws centered on that actor's own lifeline
280
+ // (see the note-x calculation below): `Math.floor(w / 2)` columns land to
281
+ // its left, the rest to its right. Neither half was reserved during gap
282
+ // sizing before this fix, so a note wider than the actor's own column
283
+ // silently spilled into whichever neighbour was closer (issue #992) — the
284
+ // same problem left/rightNoteWidth above already solved for `left`/
285
+ // `right` notes, just for the two halves of a centered one instead. A
286
+ // multi-actor `over` note (`Note over Alice,Bob`) is excluded on purpose:
287
+ // spanning both actors' columns is its intended shape, not a collision.
288
+ const overNoteHalfLeft: number[] = new Array(diagram.actors.length).fill(0)
289
+ const overNoteHalfRight: number[] = new Array(diagram.actors.length).fill(0)
290
+ for (const note of diagram.notes) {
291
+ if (note.position === 'left' || note.position === 'right') {
292
+ const aIdx = actorIndexOf(note.actorIds[0]!)
293
+ const w = noteBoxWidth(note)
294
+ if (note.position === 'left') {
295
+ leftNoteWidth[aIdx] = Math.max(leftNoteWidth[aIdx]!, w)
296
+ } else {
297
+ rightNoteWidth[aIdx] = Math.max(rightNoteWidth[aIdx]!, w)
298
+ }
299
+ } else if (note.position === 'over' && note.actorIds.length === 1) {
300
+ const aIdx = actorIndexOf(note.actorIds[0]!)
301
+ const w = noteBoxWidth(note)
302
+ const halfLeft = Math.floor(w / 2)
303
+ const halfRight = w - halfLeft
304
+ overNoteHalfLeft[aIdx] = Math.max(overNoteHalfLeft[aIdx]!, halfLeft)
305
+ overNoteHalfRight[aIdx] = Math.max(overNoteHalfRight[aIdx]!, halfRight)
306
+ }
307
+ }
308
+
309
+ // Compute lifeline x-positions (greedy left-to-right).
310
+ // See paddingOffset's doc comment (types.ts) for why this is an offset
311
+ // from the paddingX default rather than the raw config value.
312
+ const minLifelineGap = paddingOffset(
313
+ config.paddingX,
314
+ DEFAULT_PADDING_X,
315
+ 10,
316
+ 4,
317
+ )
318
+ // A bracketed first participant needs room for the wall to its left; two
319
+ // adjacent participants in different groups (or one grouped, one not)
320
+ // need room for the wall(s) between their boxes, plus a blank column on
321
+ // each side of every wall. Same-group neighbours have no wall between.
322
+ //
323
+ // A `left` note on the leftmost actor has no left neighbour to reserve a
324
+ // gap against — it draws into the canvas's own left margin instead, so
325
+ // that margin (the initial lifeline position itself) must be at least
326
+ // wide enough for it: `nx = llX[0] - nWidth - 1 >= 0` (see the note-x
327
+ // calculation below) requires `llX[0] >= nWidth + 1` (issue #953 case A —
328
+ // previously `nx` was clamped to 0 instead, forcing the note onto the
329
+ // lifeline it's supposed to sit beside).
330
+ const llX: number[] = [
331
+ Math.max(
332
+ halfBox[0]! + (boxSpanOf[0] === -1 ? 0 : BRACKET_GAP + 1),
333
+ leftNoteWidth[0]! + 1,
334
+ // A single-actor `over` note on the leftmost actor has no left
335
+ // neighbour either — same reasoning as the `left`-note case just
336
+ // above, but for the note's own left half (`nx = llX[0] -
337
+ // overNoteHalfLeft[0]` must stay >= 0; issue #992).
338
+ overNoteHalfLeft[0]!,
339
+ ),
340
+ ]
341
+ for (let i = 1; i < diagram.actors.length; i++) {
342
+ const prevSpan = boxSpanOf[i - 1]!
343
+ const thisSpan = boxSpanOf[i]!
344
+ const walls =
345
+ prevSpan === thisSpan
346
+ ? 0
347
+ : (prevSpan === -1 ? 0 : 1) + (thisSpan === -1 ? 0 : 1)
348
+ // The base constraint already leaves two blank columns between boxes;
349
+ // each wall needs one column of its own plus one more blank beside it.
350
+ const wallExtra = walls === 0 ? 0 : walls * (BRACKET_GAP + 1) - 1
351
+ // A `right` note on actor i-1, or a `left` note on actor i, draws into
352
+ // this gap — reserve room for whichever is wider (issue #953 cases B/C)
353
+ // so a wide note doesn't overflow into the neighbouring lifeline. The
354
+ // `+2` mirrors adjMaxWidth's own margin below and matches the 1-column
355
+ // clearance the note-x calculation already leaves on the lifeline side.
356
+ // A single-actor `over` note on either actor reaches into this same gap
357
+ // from its own side (its right half from i-1, its left half from i) —
358
+ // issue #992, folded into the same max() rather than summed, matching
359
+ // how the left/right pair above is already handled.
360
+ const noteGapNeed =
361
+ Math.max(
362
+ rightNoteWidth[i - 1]!,
363
+ leftNoteWidth[i]!,
364
+ overNoteHalfRight[i - 1]!,
365
+ overNoteHalfLeft[i]!,
366
+ ) + 2
367
+ const gap = Math.max(
368
+ halfBox[i - 1]! + halfBox[i]! + 2 + wallExtra,
369
+ adjMaxWidth[i - 1]! + 2,
370
+ noteGapNeed,
371
+ minLifelineGap,
372
+ )
373
+ llX[i] = llX[i - 1]! + gap
374
+ }
375
+
376
+ // Bracket wall columns, from live lifeline positions (later passes only
377
+ // ever shift lifelines right, so these stay valid if read at draw time).
378
+ const bracketLeft = (span: { lo: number }) =>
379
+ actorBoxLeft(span.lo, llX[span.lo]!) - BRACKET_GAP - 1
380
+ const bracketRight = (span: { label: string; lo: number; hi: number }) =>
381
+ Math.max(
382
+ actorBoxLeft(span.hi, llX[span.hi]!) +
383
+ actorBoxWidths[span.hi]! -
384
+ 1 +
385
+ BRACKET_GAP +
386
+ 1,
387
+ bracketLeft(span) + bracketLabelWidth(span.label) - 1,
388
+ )
389
+
390
+ // A label wider than its members' span widens the bracket to the right;
391
+ // push everything after the group out of the way, the same way the block-
392
+ // label pass below does for loop/alt headers.
393
+ for (const span of boxSpans) {
394
+ if (span.hi + 1 >= diagram.actors.length) continue
395
+ const nextLeft = actorBoxLeft(span.hi + 1, llX[span.hi + 1]!)
396
+ const nextWall = boxSpanOf[span.hi + 1] === -1 ? 0 : BRACKET_GAP + 1
397
+ const needed = bracketRight(span) + BRACKET_GAP + 1 + nextWall
398
+ const shift = needed - nextLeft
399
+ if (shift > 0) {
400
+ const shifted = llX.slice(span.hi + 1).map((x) => x + shift)
401
+ llX.splice(span.hi + 1, shifted.length, ...shifted)
402
+ }
403
+ }
404
+
405
+ // A block's wall only needs to reach as far right as the lifelines its
406
+ // own messages touch (see the identical minLX/maxLX calc in the block
407
+ // drawing pass below) — but the header/divider LABEL can need more room
408
+ // than that. If an uninvolved participant's lifeline already sits at or
409
+ // past that label-driven width, drawing the wall there lands on a
410
+ // different column than the lifeline (which was already placed) and
411
+ // just leaves that lifeline's "│" sitting inside the block, right next
412
+ // to the block's own wall — visually indistinguishable from the block
413
+ // enclosing a participant it has nothing to do with (#387, on top of
414
+ // #352's original fix). Push every lifeline after the block's rightmost
415
+ // participant out of the way before anything downstream depends on
416
+ // these positions.
417
+ //
418
+ // This runs independently of, and before, the draw-time pull-back added
419
+ // for #353 (below, in the "DRAW: blocks" pass): that pull-back only ever
420
+ // shrinks a block's *natural* (message-driven) extent away from an
421
+ // untouched lifeline it would otherwise land on — it has no notion of
422
+ // the label-driven width computed here. Without this pass shifting the
423
+ // lifeline out of the way first, a long label would push bRight straight
424
+ // back past whatever #353's pull-back had just cleared, re-enclosing it.
425
+ // This pass's own estimate below (naturalRight/labelRight, using
426
+ // BLOCK_WALL_MARGIN unclamped by any pull-back) is always >= whatever
427
+ // the draw-time pass can ultimately produce, so it shifts at least far
428
+ // enough — never more precisely, but never insufficiently either.
429
+ for (const block of diagram.blocks) {
430
+ let loIdx = -1
431
+ let hiIdx = -1
432
+ let minLX = Number.POSITIVE_INFINITY
433
+ let maxLX = -1
434
+ for (let m = block.startIndex; m <= block.endIndex; m++) {
435
+ if (m >= diagram.messages.length) break
436
+ const msg = diagram.messages[m]!
437
+ const f = actorIndexOf(msg.from)
438
+ const t = actorIndexOf(msg.to)
439
+ loIdx = loIdx === -1 ? Math.min(f, t) : Math.min(loIdx, f, t)
440
+ hiIdx = Math.max(hiIdx, f, t)
441
+ minLX = Math.min(minLX, llX[Math.min(f, t)]!)
442
+ maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)
443
+ if (f === t) {
444
+ const selfRight =
445
+ llX[f]! + SELF_LOOP_WIDTH + 2 + maxLineWidth(msg.label)
446
+ maxLX = Math.max(maxLX, selfRight)
447
+ }
448
+ }
449
+ // An empty block, or one whose rightmost participant is already the
450
+ // last actor, has nothing after it that could be swallowed.
451
+ if (hiIdx === -1 || hiIdx + 1 >= diagram.actors.length) continue
452
+
453
+ const bLeft = Math.max(0, minLX - BLOCK_WALL_MARGIN)
454
+ const naturalRight = maxLX + BLOCK_WALL_MARGIN
455
+ const labelRight = bLeft + 1 + maxBlockLabelWidth(block)
456
+ const bRight = Math.max(naturalRight, labelRight)
457
+
458
+ const nextLL = llX[hiIdx + 1]!
459
+ const shift = bRight + 2 - nextLL
460
+ if (shift > 0) {
461
+ // Shift every lifeline from hiIdx+1 onward by `shift`, in place —
462
+ // llX's identity has to survive this pass (later blocks in this same
463
+ // loop, and everything downstream, read positions back out of it) so
464
+ // this can't rebuild the array under a new binding. Deriving the
465
+ // shifted values with .map() and writing them back via splice (same
466
+ // start/length, so it's a pure overwrite) avoids the index-reassignment
467
+ // loop without changing what ends up in llX.
468
+ const shifted = llX.slice(hiIdx + 1).map((x) => x + shift)
469
+ llX.splice(hiIdx + 1, shifted.length, ...shifted)
470
+ }
471
+ }
472
+
473
+ // ---- LAYOUT: compute vertical positions for messages ----
474
+
475
+ // For each message index, track the y where its arrow is drawn.
476
+ // Also track block start/end y positions and divider y positions.
477
+ const msgArrowY: number[] = []
478
+ const msgLabelY: number[] = []
479
+ const blockStartY = new Map<number, number>()
480
+ const blockEndY = new Map<number, number>()
481
+ const divYMap = new Map<string, number>() // "blockIdx:divIdx" → y
482
+ const notePositions: Array<{
483
+ x: number
484
+ y: number
485
+ width: number
486
+ height: number
487
+ lines: string[]
488
+ }> = []
489
+
490
+ // Start right below the header boxes — and below the group bracket's
491
+ // bottom border when there is one (its top border row sits above them).
492
+ const headerBoxTop = bracketRows
493
+ const headerBottom = headerBoxTop + actorBoxH // first row after the boxes
494
+ let curY = headerBottom + bracketRows
495
+
496
+ // rowGap: the blank rows around messages, notes, and blocks. See
497
+ // paddingOffset's doc comment (types.ts) for why this is an offset from
498
+ // the paddingY default rather than the raw config value. Floored at 0 (not
499
+ // 1) since these are single-row gaps, not a whole box — collapsing a gap
500
+ // to 0 rows is still a valid, readable layout, just a tight one.
501
+ const rowGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 1, 0)
502
+
503
+ // Below rowGap's own floor of 0, three specific gaps still need at least
504
+ // one row: the row right after a divider (or the message row drawn there
505
+ // would land on the divider's own row and overwrite it), the row right
506
+ // after a block's closing border (same reasoning against whatever comes
507
+ // next), and the row before the footer (the footer's top border is drawn
508
+ // *before* messages/arrows in the draw pass — see "DRAW: actor header +
509
+ // footer boxes" below — so a message landing on the same row as the
510
+ // footer would draw its arrow through the footer's border). Everywhere
511
+ // else (blank row before a message, gap before a note, blank row before a
512
+ // block header) is genuinely optional spacing with no such collision risk,
513
+ // so those keep using rowGap directly. See issue #343's CodeRabbit review.
514
+ const minSeparatorGap = Math.max(rowGap, 1)
515
+
516
+ // Pre-message notes: afterIndex === -1 — position before message loop
517
+ for (const note of diagram.notes) {
518
+ if (note.afterIndex !== -1) continue
519
+ curY += rowGap // gap before note
520
+ const nLines = splitLines(note.text)
521
+ const nWidth = noteBoxWidth(note)
522
+ const nHeight = nLines.length + 2
523
+
524
+ const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
525
+ let nx: number
526
+ if (note.position === 'left') {
527
+ nx = llX[aIdx]! - nWidth - 1
528
+ } else if (note.position === 'right') {
529
+ nx = llX[aIdx]! + 2
530
+ } else {
531
+ // 'over'
532
+ if (note.actorIds.length >= 2) {
533
+ const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx
534
+ nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2)
535
+ } else {
536
+ nx = llX[aIdx]! - Math.floor(nWidth / 2)
537
+ }
538
+ }
539
+ nx = Math.max(0, nx)
540
+
541
+ notePositions.push({
542
+ x: nx,
543
+ y: curY,
544
+ width: nWidth,
545
+ height: nHeight,
546
+ lines: nLines,
547
+ })
548
+ curY += nHeight
549
+ }
550
+
551
+ for (let m = 0; m < diagram.messages.length; m++) {
552
+ // Block openings at this message
553
+ for (let b = 0; b < diagram.blocks.length; b++) {
554
+ if (diagram.blocks[b]!.startIndex === m) {
555
+ curY += rowGap + 1 // blank rows + 1 fixed header row
556
+ blockStartY.set(b, curY - 1)
557
+ }
558
+ }
559
+
560
+ // Dividers at this message index
561
+ for (let b = 0; b < diagram.blocks.length; b++) {
562
+ for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) {
563
+ if (diagram.blocks[b]!.dividers[d]!.index === m) {
564
+ curY += rowGap
565
+ divYMap.set(`${b}:${d}`, curY)
566
+ curY += minSeparatorGap
567
+ }
568
+ }
569
+ }
570
+
571
+ curY += rowGap // blank row before message
572
+
573
+ const msg = diagram.messages[m]!
574
+ const isSelf = msg.from === msg.to
575
+
576
+ // Calculate height needed for multi-line message labels
577
+ const msgLineCount = lineCount(msg.label)
578
+
579
+ // A self-message can't sensibly create its own recipient (the loop
580
+ // glyphs would sit where the box goes), so that degenerate case keeps
581
+ // the header box instead.
582
+ const createdIdx = isSelf ? undefined : createdByMsg.get(m)
583
+ const destroyedIdx = destroyedByMsg.get(m)
584
+
585
+ if (isSelf) {
586
+ // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm
587
+ msgLabelY[m] = curY + 1
588
+ msgArrowY[m] = curY
589
+ curY += 2 + msgLineCount // top-arm + label lines + bottom-arm
590
+ } else {
591
+ // Normal message: label row(s), then — for a creating message — the
592
+ // rows of the created box above the arrow, then the arrow row, then
593
+ // the created box's rows below it.
594
+ const boxAbove = createdIdx === undefined ? 0 : createdBoxAbove
595
+ const boxBelow = createdIdx === undefined ? 0 : createdBoxBelow
596
+ msgLabelY[m] = curY
597
+ msgArrowY[m] = curY + msgLineCount + boxAbove
598
+ curY += msgLineCount + boxAbove + 1 + boxBelow
599
+ if (createdIdx !== undefined) {
600
+ createdBoxTop.set(createdIdx, msgArrowY[m]! - boxAbove)
601
+ }
602
+ }
603
+
604
+ // One reserved row under the arrow (under the bottom arm, for a
605
+ // self-message) for the destroy cross, so it never lands on whatever
606
+ // comes next when rowGap is 0.
607
+ if (destroyedIdx !== undefined) {
608
+ destroyRow.set(destroyedIdx, curY)
609
+ curY += 1
610
+ }
611
+
612
+ // Notes after this message
613
+ for (let n = 0; n < diagram.notes.length; n++) {
614
+ if (diagram.notes[n]!.afterIndex === m) {
615
+ curY += rowGap
616
+ const note = diagram.notes[n]!
617
+ const nLines = splitLines(note.text)
618
+ const nWidth = noteBoxWidth(note)
619
+ const nHeight = nLines.length + 2
620
+
621
+ // Determine x position based on note.position
622
+ const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
623
+ let nx: number
624
+ if (note.position === 'left') {
625
+ nx = llX[aIdx]! - nWidth - 1
626
+ } else if (note.position === 'right') {
627
+ nx = llX[aIdx]! + 2
628
+ } else {
629
+ // 'over' — center over actor(s)
630
+ if (note.actorIds.length >= 2) {
631
+ const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx
632
+ nx =
633
+ Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) -
634
+ Math.floor(nWidth / 2)
635
+ } else {
636
+ nx = llX[aIdx]! - Math.floor(nWidth / 2)
637
+ }
638
+ }
639
+ nx = Math.max(0, nx)
640
+
641
+ notePositions.push({
642
+ x: nx,
643
+ y: curY,
644
+ width: nWidth,
645
+ height: nHeight,
646
+ lines: nLines,
647
+ })
648
+ curY += nHeight
649
+ }
650
+ }
651
+
652
+ // Block closings after this message
653
+ for (let b = 0; b < diagram.blocks.length; b++) {
654
+ if (diagram.blocks[b]!.endIndex === m) {
655
+ curY += rowGap
656
+ blockEndY.set(b, curY)
657
+ curY += minSeparatorGap
658
+ }
659
+ }
660
+ }
661
+
662
+ curY += minSeparatorGap // gap before footer (mandatory — see minSeparatorGap)
663
+ // With brackets, footerY is the footer bracket's top border row and the
664
+ // footer boxes start one row under it; otherwise it is the boxes' own top.
665
+ const footerY = curY
666
+ const footerBoxTop = footerY + bracketRows
667
+ const totalH = footerBoxTop + actorBoxH + bracketRows
668
+
669
+ // Total canvas width
670
+ const lastLL = llX[llX.length - 1] ?? 0
671
+ const lastHalf = halfBox[halfBox.length - 1] ?? 0
672
+ let totalW = lastLL + lastHalf + 2
673
+ for (const span of boxSpans) {
674
+ totalW = Math.max(totalW, bracketRight(span) + 2)
675
+ }
676
+
677
+ // Ensure canvas is wide enough for self-message labels and notes
678
+ for (let m = 0; m < diagram.messages.length; m++) {
679
+ const msg = diagram.messages[m]!
680
+ if (msg.from === msg.to) {
681
+ const fi = actorIndexOf(msg.from)
682
+ const selfRight =
683
+ llX[fi]! + selfLoopWidth(msg) + 2 + 2 + maxLineWidth(msg.label)
684
+ totalW = Math.max(totalW, selfRight + 1)
685
+ }
686
+ }
687
+ for (const np of notePositions) {
688
+ totalW = Math.max(totalW, np.x + np.width + 1)
689
+ }
690
+
691
+ const canvas = mkCanvas(totalW, totalH - 1)
692
+ const rc = mkRoleCanvas(totalW, totalH - 1)
693
+
694
+ /** Set a character on the canvas and track its role. */
695
+ function setC(x: number, y: number, ch: string, role: CharRole): void {
696
+ write(canvas, x, y, ch, { role, roleCanvas: rc })
697
+ }
698
+
699
+ /**
700
+ * Write a line of text starting at grid cell (x, y), one grid cell per
701
+ * terminal column rather than one grid cell per JS code point.
702
+ *
703
+ * A CJK/kana/hangul/fullwidth-form/emoji grapheme renders as TWO terminal
704
+ * columns but is a single JS character — writing it into a single grid
705
+ * cell (as a naive `for (let i = 0; i < line.length; i++)` loop does)
706
+ * under-reserves a column for every wide character, so unrelated content
707
+ * (box borders, adjacent lifelines) drawn later at a fixed grid index no
708
+ * longer lines up with what a real terminal actually renders for this
709
+ * row (issue #334). `toDisplayCells` (display-width.ts) splits `text`
710
+ * into one entry per terminal column — a wide grapheme followed by an
711
+ * empty placeholder entry — so writing one cell per entry keeps this
712
+ * row's grid indices in step with its rendered terminal columns, the
713
+ * same approach `drawText` (canvas.ts) already uses for flowchart/class/
714
+ * ER diagram boxes.
715
+ *
716
+ * `exclusiveMaxX`, when given, additionally skips any cell at or past
717
+ * that grid index — matching call sites that previously bounded their
718
+ * own manual write loop with `x < totalW` (a one-column margin short of
719
+ * `setC`'s own canvas-edge clipping). `setC` still clips to the actual
720
+ * canvas bounds regardless, so omitting it just falls back to that.
721
+ */
722
+ function writeTextCells(
723
+ x: number,
724
+ y: number,
725
+ text: string,
726
+ role: CharRole,
727
+ exclusiveMaxX?: number,
728
+ ): void {
729
+ const cells = toDisplayCells(text)
730
+ for (let i = 0; i < cells.length; i++) {
731
+ const cx = x + i
732
+ if (exclusiveMaxX !== undefined && cx >= exclusiveMaxX) break
733
+ // A wide grapheme's glyph cell is always immediately followed by its
734
+ // placeholder cell (toDisplayCells' pairing). Writing the glyph
735
+ // without room for that placeholder would leave its second terminal
736
+ // column unreserved even though the glyph still renders across two
737
+ // columns — reintroducing this file's own under-reservation bug
738
+ // right at the clip boundary instead of over the whole string. Stop
739
+ // one cell earlier instead of splitting the pair.
740
+ const isWideGlyphStart = cells[i + 1] === WIDE_CHAR_PLACEHOLDER
741
+ if (
742
+ isWideGlyphStart &&
743
+ exclusiveMaxX !== undefined &&
744
+ cx + 1 >= exclusiveMaxX
745
+ ) {
746
+ break
747
+ }
748
+ setC(cx, y, cells[i]!, role)
749
+ }
750
+ }
751
+
752
+ // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ----
753
+
754
+ /**
755
+ * Draws a bordered, centered actor box directly onto the shared sequence
756
+ * canvas. This intentionally does NOT go through the shared `drawMultiBox`
757
+ * primitive (src/ascii/draw-boxes.ts), for two concrete reasons:
758
+ *
759
+ * 1. Coordinate system: `drawMultiBox` returns a standalone canvas rooted
760
+ * at (0, 0), meant to be measured and then copied onto a caller's
761
+ * canvas (as class-diagram.ts and er-diagram.ts do). Actor boxes are
762
+ * positioned by a lifeline's center x-coordinate (`cx`), and are drawn
763
+ * twice per actor (header at y=0, footer at y=footerY) directly via the
764
+ * closure-captured `setC`, alongside unrelated lifeline/junction
765
+ * drawing that shares the same canvas and role-tracking.
766
+ * 2. Sizing/alignment semantics differ, not just coordinates: `drawMultiBox`
767
+ * sizes each section by raw `.length` and left-aligns its content with
768
+ * fixed padding. Actor labels are centered and sized via
769
+ * `maxLineWidth` (multiline-utils.ts), which is deliberately
770
+ * display-width-aware (not `.length`) so wide CJK/emoji labels get a
771
+ * correctly-sized box. Routing actor boxes through `drawMultiBox` as it
772
+ * stands would silently narrow boxes for wide-character actor labels —
773
+ * reintroducing the exact bug `maxLineWidth` exists to avoid — and
774
+ * changing `drawMultiBox` itself to be display-width-aware and
775
+ * center-aligned would alter class/ER diagram box sizing, which must
776
+ * stay untouched.
777
+ *
778
+ * For an `actor`-kind participant, `ACTOR_GLYPH_LINES` (a small stick
779
+ * figure) is prepended to the label lines, inside the same box — the
780
+ * box's width/height precompute above (`actorContentWidth`/
781
+ * `actorContentHeight`) already reserves room for it so this stays a
782
+ * pure drawing step with no additional sizing math.
783
+ *
784
+ * `boxH` is always the diagram-wide `actorBoxH` (the tallest box among
785
+ * *all* actors), not this actor's own content height — every box must
786
+ * bottom out at the same row so the lifeline/junction drawn below it
787
+ * (which unconditionally starts at row `actorBoxH`, see the "DRAW:
788
+ * lifelines" section below) connects directly to the box's bottom
789
+ * border instead of leaving a gap. A participant box shorter than an
790
+ * adjacent actor's stick-figure box gets blank rows appended above its
791
+ * bottom border to make up the difference.
792
+ */
793
+ function drawActorBox(
794
+ cx: number,
795
+ topY: number,
796
+ label: string,
797
+ actorType: 'participant' | 'actor',
798
+ boxH: number,
799
+ ): void {
800
+ const glyphLines = actorType === 'actor' ? ACTOR_GLYPH_LINES : []
801
+ const lines = [...glyphLines, ...splitLines(label)]
802
+ const maxW = Math.max(
803
+ maxLineWidth(label),
804
+ glyphLines.length > 0 ? ACTOR_GLYPH_WIDTH : 0,
805
+ )
806
+ const w = maxW + 2 * boxPad + 2
807
+ const h = boxH
808
+ const contentRows = h - 2
809
+ const left = cx - Math.floor(w / 2)
810
+
811
+ // Top border
812
+ setC(left, topY, TL, 'border')
813
+ for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border')
814
+ setC(left + w - 1, topY, TR, 'border')
815
+
816
+ // Content rows (top-aligned; horizontally centered). Any rows beyond
817
+ // `lines.length` (up to `contentRows`) stay blank padding — see the
818
+ // `boxH` doc note above.
819
+ for (let i = 0; i < contentRows; i++) {
820
+ const row = topY + 1 + i
821
+ setC(left, row, V, 'border')
822
+ setC(left + w - 1, row, V, 'border')
823
+ const line = lines[i]
824
+ if (line === undefined) continue
825
+ // Center this line within the box. Centering offset and cell-writing
826
+ // both use display width (terminal columns), not `.length` (JS
827
+ // code units) — a code-unit-based offset would under-center CJK
828
+ // labels, and writing one grid cell per code unit (rather than per
829
+ // terminal column) would under-reserve columns for wide glyphs,
830
+ // both contributing to issue #334's border/content misalignment.
831
+ const ls = left + 1 + boxPad + Math.floor((maxW - displayWidth(line)) / 2)
832
+ writeTextCells(ls, row, line, 'text')
833
+ }
834
+
835
+ // Bottom border
836
+ const bottomY = topY + h - 1
837
+ setC(left, bottomY, BL, 'border')
838
+ for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border')
839
+ setC(left + w - 1, bottomY, BR, 'border')
840
+ }
841
+
842
+ // ---- DRAW: lifelines ----
843
+
844
+ // A created participant's lifeline starts under its mid-diagram box; a
845
+ // destroyed one's ends at its cross row (drawn in the messages pass, so
846
+ // the cross wins over the `│` painted here).
847
+ const lifelineTop = (i: number) => {
848
+ const boxTop = createdBoxTop.get(i)
849
+ return boxTop === undefined ? headerBottom : boxTop + actorBoxH
850
+ }
851
+ const lifelineBottom = (i: number) => destroyRow.get(i) ?? footerY
852
+
853
+ for (let i = 0; i < diagram.actors.length; i++) {
854
+ const x = llX[i]!
855
+ for (let y = lifelineTop(i); y <= lifelineBottom(i); y++) {
856
+ setC(x, y, V, 'line')
857
+ }
858
+ }
859
+
860
+ // ---- DRAW: participant-group brackets (box … end) ----
861
+
862
+ const CROSS = useAscii ? '+' : '┼'
863
+ /**
864
+ * One bracket: top border (with the label, if any), side walls down the
865
+ * actor-box rows, bottom border. Lifelines that pass through a border row
866
+ * get a crossing glyph; a created (header) or destroyed (footer) member's
867
+ * lifeline doesn't reach that row, so its column keeps the plain border.
868
+ */
869
+ function drawBracket(
870
+ span: { label: string; lo: number; hi: number },
871
+ topRow: number,
872
+ label: string,
873
+ ): void {
874
+ const left = bracketLeft(span)
875
+ const right = bracketRight(span)
876
+ const bottomRow = topRow + actorBoxH + 1
877
+ setC(left, topRow, TL, 'border')
878
+ setC(right, topRow, TR, 'border')
879
+ setC(left, bottomRow, BL, 'border')
880
+ setC(right, bottomRow, BR, 'border')
881
+ for (let x = left + 1; x < right; x++) {
882
+ setC(x, topRow, H, 'border')
883
+ setC(x, bottomRow, H, 'border')
884
+ }
885
+ for (let y = topRow + 1; y < bottomRow; y++) {
886
+ setC(left, y, V, 'border')
887
+ setC(right, y, V, 'border')
888
+ }
889
+ if (label !== '') {
890
+ // `┌─ Label ─…`: keep the first dash, then the label with a space on
891
+ // each side. Multi-line labels take the first line only — the border
892
+ // is a single row.
893
+ writeTextCells(left + 2, topRow, ` ${splitLines(label)[0]!} `, 'text')
894
+ }
895
+ for (let i = span.lo; i <= span.hi; i++) {
896
+ const x = llX[i]!
897
+ if (lifelineTop(i) <= topRow && topRow <= lifelineBottom(i)) {
898
+ setC(x, topRow, CROSS, 'junction')
899
+ }
900
+ if (lifelineTop(i) <= bottomRow && bottomRow <= lifelineBottom(i)) {
901
+ setC(x, bottomRow, CROSS, 'junction')
902
+ }
903
+ }
904
+ }
905
+ for (const span of boxSpans) {
906
+ drawBracket(span, 0, span.label)
907
+ drawBracket(span, footerY, '')
908
+ }
909
+
910
+ // ---- DRAW: actor header + footer boxes (drawn over lifelines) ----
911
+
912
+ for (let i = 0; i < diagram.actors.length; i++) {
913
+ const actor = diagram.actors[i]!
914
+ // Created: the box sits on its creating message's row, not in the
915
+ // header. Destroyed: no footer box — the lifeline already ended.
916
+ const headerTop = createdBoxTop.get(i) ?? headerBoxTop
917
+ const destroyed = destroyRow.has(i)
918
+ drawActorBox(llX[i]!, headerTop, actor.label, actor.type, actorBoxH)
919
+ if (!destroyed) {
920
+ drawActorBox(llX[i]!, footerBoxTop, actor.label, actor.type, actorBoxH)
921
+ }
922
+
923
+ // Lifeline junctions on box borders (Unicode only)
924
+ if (!useAscii) {
925
+ setC(llX[i]!, headerTop + actorBoxH - 1, JT, 'junction')
926
+ if (!destroyed) setC(llX[i]!, footerBoxTop, JB, 'junction')
927
+ }
928
+ }
929
+
930
+ // ---- DRAW: messages ----
931
+
932
+ for (let m = 0; m < diagram.messages.length; m++) {
933
+ const msg = diagram.messages[m]!
934
+ const fi = actorIndexOf(msg.from)
935
+ const ti = actorIndexOf(msg.to)
936
+ const fromX = llX[fi]!
937
+ const toX = llX[ti]!
938
+ const isSelf = fi === ti
939
+ const isDashed = msg.lineStyle === 'dashed'
940
+ const isFilled = msg.arrowHead === 'filled'
941
+ // "lost message" (-x/--x): a distinct cross terminator, not the plain
942
+ // filled arrowhead it otherwise shares with ->>/-->>. Direction-
943
+ // independent, unlike the arrow glyphs below — see issue #330.
944
+ const isLost = msg.isLost === true
945
+ const lostChar = useAscii ? 'x' : '✕'
946
+
947
+ // Arrow line character (solid vs dashed)
948
+ const lineChar = isDashed ? (useAscii ? '.' : '╌') : H
949
+
950
+ if (isSelf) {
951
+ // Self-message: 3-row loop to the right of the lifeline
952
+ // ├──┐ (row 0 = msgArrowY)
953
+ // │ │ Label (row 1)
954
+ // │◄─┘ (row 2)
955
+ //
956
+ // The loop is only SELF_LOOP_WIDTH (4) columns wide by default, with no
957
+ // spare room for a second arrowhead without corrupting the loop's
958
+ // corner glyphs — so a bidirectional self-message still parses and
959
+ // draws a single arrowhead only. An autonumbered self-message widens
960
+ // the loop (see selfLoopWidth) so the badge digits fit at the start
961
+ // of the top arm without touching the corner.
962
+ const y0 = msgArrowY[m]!
963
+ const loopW = selfLoopWidth(msg)
964
+ const numStr =
965
+ msg.seqNumber === undefined ? undefined : String(msg.seqNumber)
966
+ // Split the label on <br/>-normalized newlines so multi-line self-arrow
967
+ // labels get one row each instead of dumping a literal \n mid-row.
968
+ const msgLines = splitLines(msg.label)
969
+
970
+ // Row 0: start junction + [autonumber badge] + horizontal + top-right
971
+ // corner. The badge, when present, replaces the leading dashes rather
972
+ // than sitting alongside them — mirroring how the normal-message
973
+ // badge overwrites the start of its arrow line.
974
+ setC(fromX, y0, JL, 'junction')
975
+ let dashStart = fromX + 1
976
+ if (numStr !== undefined) {
977
+ for (let i = 0; i < numStr.length; i++)
978
+ setC(fromX + 1 + i, y0, numStr[i]!, 'text')
979
+ dashStart = fromX + 1 + numStr.length
980
+ }
981
+ for (let x = dashStart; x < fromX + loopW; x++)
982
+ setC(x, y0, lineChar, 'line')
983
+ setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner')
984
+
985
+ // Label rows: vertical on right side + one line of label text each
986
+ const labelX = fromX + loopW + 2
987
+ for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {
988
+ const rowY = y0 + 1 + lineIdx
989
+ setC(fromX + loopW, rowY, V, 'line')
990
+ writeTextCells(labelX, rowY, msgLines[lineIdx]!, 'text', totalW)
991
+ }
992
+
993
+ // Bottom row: arrow-back + horizontal + bottom-right corner
994
+ const bottomY = y0 + 1 + msgLines.length
995
+ const arrowChar = isLost
996
+ ? lostChar
997
+ : isFilled
998
+ ? useAscii
999
+ ? '<'
1000
+ : '◀'
1001
+ : useAscii
1002
+ ? '<'
1003
+ : '◁'
1004
+ setC(fromX, bottomY, arrowChar, 'arrow')
1005
+ for (let x = fromX + 1; x < fromX + loopW; x++)
1006
+ setC(x, bottomY, lineChar, 'line')
1007
+ setC(fromX + loopW, bottomY, useAscii ? '+' : '┘', 'corner')
1008
+ } else {
1009
+ // Normal message: label on row above, arrow on row below
1010
+ const labelY = msgLabelY[m]!
1011
+ const arrowY = msgArrowY[m]!
1012
+ const leftToRight = fromX < toX
1013
+
1014
+ // A creating message's arrow stops at the created box's near border
1015
+ // instead of the lifeline centre (the box occupies this row — see the
1016
+ // lifecycle comment near createdBoxAbove). `headX` is where the
1017
+ // arrowhead goes; the line runs from the sender up to it.
1018
+ const createsRecipient = createdByMsg.get(m) === ti
1019
+ let headX = toX
1020
+ if (createsRecipient) {
1021
+ const boxLeft = actorBoxLeft(ti, toX)
1022
+ const boxRight = boxLeft + actorBoxWidths[ti]! - 1
1023
+ headX = leftToRight ? boxLeft - 1 : boxRight + 1
1024
+ }
1025
+
1026
+ // Draw label centered between the two lifelines (supports multi-line)
1027
+ const midX = Math.floor((fromX + toX) / 2)
1028
+ const msgLines = splitLines(msg.label)
1029
+
1030
+ for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {
1031
+ const line = msgLines[lineIdx]!
1032
+ // Center on display width, not `.length` — see writeTextCells' doc
1033
+ // comment for why a code-unit-based offset under-centers CJK labels.
1034
+ const labelStart = midX - Math.floor(displayWidth(line) / 2)
1035
+ const y = labelY + lineIdx
1036
+ writeTextCells(labelStart, y, line, 'text', totalW)
1037
+ }
1038
+
1039
+ // Draw arrow line
1040
+ if (leftToRight) {
1041
+ for (let x = fromX + 1; x < headX; x++)
1042
+ setC(x, arrowY, lineChar, 'line')
1043
+ // Arrowhead at destination
1044
+ const ah = isLost
1045
+ ? lostChar
1046
+ : isFilled
1047
+ ? useAscii
1048
+ ? '>'
1049
+ : '▶'
1050
+ : useAscii
1051
+ ? '>'
1052
+ : '▷'
1053
+ setC(headX, arrowY, ah, 'arrow')
1054
+ // Bidirectional (`<<->>` / `<<-->>`): mirror the arrowhead at the
1055
+ // departure end too. Both bidirectional tokens end in ">>" (see
1056
+ // parser.ts), so isFilled is always true here — no open-head
1057
+ // variant to branch on, unlike the one-way `ah` glyph above.
1058
+ if (msg.bidirectional) {
1059
+ const ahStart = useAscii ? '<' : '◀'
1060
+ setC(fromX, arrowY, ahStart, 'arrow')
1061
+ }
1062
+ } else {
1063
+ for (let x = headX + 1; x < fromX; x++)
1064
+ setC(x, arrowY, lineChar, 'line')
1065
+ const ah = isLost
1066
+ ? lostChar
1067
+ : isFilled
1068
+ ? useAscii
1069
+ ? '<'
1070
+ : '◀'
1071
+ : useAscii
1072
+ ? '<'
1073
+ : '◁'
1074
+ setC(headX, arrowY, ah, 'arrow')
1075
+ if (msg.bidirectional) {
1076
+ const ahStart = useAscii ? '>' : '▶'
1077
+ setC(fromX, arrowY, ahStart, 'arrow')
1078
+ }
1079
+ }
1080
+
1081
+ // autonumber badge: overwrite the start of the arrow line with the
1082
+ // sequence number, mirroring the small circled number the SVG
1083
+ // renderer draws over the start of the arrow (see renderer.ts's
1084
+ // renderSeqNumberBadge). Skipped if it wouldn't fit before the
1085
+ // opposite arrowhead — the minimum lifeline gap (10 cols) comfortably
1086
+ // fits typical 1-3 digit sequence numbers alongside short labels.
1087
+ if (msg.seqNumber !== undefined) {
1088
+ const numStr = String(msg.seqNumber)
1089
+ if (leftToRight) {
1090
+ const start = fromX + 1
1091
+ if (start + numStr.length < toX) {
1092
+ for (let i = 0; i < numStr.length; i++)
1093
+ setC(start + i, arrowY, numStr[i]!, 'text')
1094
+ }
1095
+ } else {
1096
+ const start = fromX - numStr.length
1097
+ if (start > headX) {
1098
+ for (let i = 0; i < numStr.length; i++)
1099
+ setC(start + i, arrowY, numStr[i]!, 'text')
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ // ---- DRAW: destroy crosses ----
1107
+
1108
+ // Same glyph as a lost message's terminator, on the destroyed actor's
1109
+ // lifeline one row under the destroying arrow (see the lifecycle comment
1110
+ // near createdBoxAbove for why not on the arrow row itself).
1111
+ for (const [i, y] of destroyRow) {
1112
+ setC(llX[i]!, y, useAscii ? 'x' : '✕', 'arrow')
1113
+ }
1114
+
1115
+ // ---- DRAW: blocks (loop, alt, opt, par, etc.) ----
1116
+
1117
+ // Largest column index it's currently safe to write a block wall into.
1118
+ // Starts at the canvas's own right margin (mirrors the historical
1119
+ // `totalW - 1` clamp) and is pushed out via increaseSize/
1120
+ // increaseRoleCanvasSize below whenever a wall needs to grow past it.
1121
+ let blockCanvasMaxX = totalW - 1
1122
+
1123
+ for (let b = 0; b < diagram.blocks.length; b++) {
1124
+ const block = diagram.blocks[b]!
1125
+ const topY = blockStartY.get(b)
1126
+ const botY = blockEndY.get(b)
1127
+ if (topY === undefined || botY === undefined) continue
1128
+
1129
+ // Find the leftmost/rightmost lifelines involved in this block's messages
1130
+ let minLX = totalW
1131
+ let maxLX = 0
1132
+ for (let m = block.startIndex; m <= block.endIndex; m++) {
1133
+ if (m >= diagram.messages.length) break
1134
+ const msg = diagram.messages[m]!
1135
+ const f = actorIdx.get(msg.from) ?? 0
1136
+ const t = actorIdx.get(msg.to) ?? 0
1137
+ minLX = Math.min(minLX, llX[Math.min(f, t)]!)
1138
+ maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)
1139
+ // Self-arrows draw their loop glyphs (├──┐ … ◀──┘) and label further
1140
+ // right than the lifeline itself — account for that extent too, or a
1141
+ // long self-arrow label gets clipped by / drawn outside the wall.
1142
+ if (f === t) {
1143
+ const selfRight =
1144
+ llX[f]! + selfLoopWidth(msg) + 2 + maxLineWidth(msg.label)
1145
+ maxLX = Math.max(maxLX, selfRight)
1146
+ }
1147
+ }
1148
+
1149
+ let bLeft = minLX - BLOCK_WALL_MARGIN
1150
+ let bRight = maxLX + BLOCK_WALL_MARGIN
1151
+
1152
+ // minLX/maxLX (and therefore bLeft/bRight) only account for lifelines
1153
+ // *this block's own messages* touch. That leaves a gap: the fixed
1154
+ // margin above can coincidentally place a wall exactly on — or past —
1155
+ // a different, untouched lifeline's column (#353).
1156
+ //
1157
+ // The fix is to PULL the wall back short of that lifeline, not push it
1158
+ // past. Verified against real mermaid.js's own SVG output for this
1159
+ // exact diagram (see scripts/lib/real-mermaid.ts, the same engine
1160
+ // behind GitHub's own mermaid preview): `loop`/`opt` enclose `Database`
1161
+ // there because their own messages touch it directly, but `alt` —
1162
+ // which never messages `Database` — stops well short of it (124px of
1163
+ // real clearance, not a few px of overshoot), even though `loop`/`opt`
1164
+ // in the same diagram extend ~11px *past* Database's lifeline to
1165
+ // enclose it. Real mermaid never widens a block's wall to enclose a
1166
+ // lifeline its own messages don't touch; it only ever clears one it
1167
+ // was already going to reach. Applies to every block type alike, both
1168
+ // walls.
1169
+ let nextRightLL = Number.POSITIVE_INFINITY
1170
+ let nextLeftLL = Number.NEGATIVE_INFINITY
1171
+ for (const x of llX) {
1172
+ if (x > maxLX && x < nextRightLL) nextRightLL = x
1173
+ if (x < minLX && x > nextLeftLL) nextLeftLL = x
1174
+ }
1175
+ // Never pull back past maxLX/minLX themselves — those already include
1176
+ // the self-arrow extent computed above, and an untouched lifeline
1177
+ // sitting close enough behind one can otherwise pull bRight below the
1178
+ // self-arrow's own label, which the later block-border draw then
1179
+ // overwrites (the label silently loses characters — CodeRabbit caught
1180
+ // this on this exact fix). Clearing the untouched lifeline yields to
1181
+ // not clipping this block's own content when the two can't both fit.
1182
+ if (bRight >= nextRightLL)
1183
+ bRight = Math.max(maxLX, nextRightLL - BLOCK_WALL_MARGIN)
1184
+ if (bLeft <= nextLeftLL)
1185
+ bLeft = Math.min(minLX, nextLeftLL + BLOCK_WALL_MARGIN)
1186
+
1187
+ bLeft = Math.max(0, bLeft)
1188
+ if (bRight > blockCanvasMaxX) {
1189
+ increaseSize(canvas, bRight + 1, totalH - 1)
1190
+ increaseRoleCanvasSize(rc, bRight + 1, totalH - 1)
1191
+ blockCanvasMaxX = bRight
1192
+ }
1193
+
1194
+ // Header ("alt [label]") and divider ("[else label]") text is drawn
1195
+ // starting at bLeft + 1 (see below), clipped to whatever bRight the
1196
+ // pull-back above produced. A wall sized purely from message spans
1197
+ // (even after that untouched-lifeline pull-back) has no relationship
1198
+ // to label length, so a long condition label was silently cut off
1199
+ // mid-word instead of the block widening to fit it (#352). Measure the
1200
+ // longest label among the header and every divider up front and widen
1201
+ // the wall — and the canvas itself, if the extra room isn't already
1202
+ // there — to fit it before any drawing happens.
1203
+ //
1204
+ // `bLeft` here MUST be the fully-resolved value above (post pull-back,
1205
+ // post clamp) — computing `neededRight` from an intermediate bLeft
1206
+ // would silently under-widen whenever minLX/the pull-back puts bLeft
1207
+ // below its margin (this repo's own #352 repro hits exactly that case:
1208
+ // `A` is actor 0, so minLX - BLOCK_WALL_MARGIN is negative and bLeft
1209
+ // only becomes 0 via the clamp above). Any participant this widening
1210
+ // would otherwise swallow was already pushed further right by the
1211
+ // lifeline-shifting layout pass earlier in this function, so growing
1212
+ // bRight here doesn't re-collide with whatever the pull-back above
1213
+ // just cleared.
1214
+ const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type
1215
+ const neededRight = bLeft + 1 + maxBlockLabelWidth(block)
1216
+ if (neededRight > bRight) {
1217
+ bRight = neededRight
1218
+ const [canvasMaxX] = getCanvasSize(canvas)
1219
+ if (bRight > canvasMaxX) {
1220
+ increaseSize(canvas, bRight, totalH - 1)
1221
+ increaseRoleCanvasSize(rc, bRight, totalH - 1)
1222
+ }
1223
+ totalW = Math.max(totalW, bRight + 1)
1224
+ blockCanvasMaxX = Math.max(blockCanvasMaxX, bRight)
1225
+ }
1226
+
1227
+ // Top border with block type label
1228
+ setC(bLeft, topY, TL, 'border')
1229
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border')
1230
+ setC(bRight, topY, TR, 'border')
1231
+ // Write block header label over the top border (supports multi-line)
1232
+ const hdrLines = splitLines(hdrLabel)
1233
+
1234
+ for (
1235
+ let lineIdx = 0;
1236
+ lineIdx < hdrLines.length && topY + lineIdx < botY;
1237
+ lineIdx++
1238
+ ) {
1239
+ writeTextCells(
1240
+ bLeft + 1,
1241
+ topY + lineIdx,
1242
+ hdrLines[lineIdx]!,
1243
+ 'text',
1244
+ bRight,
1245
+ )
1246
+ }
1247
+
1248
+ // Bottom border
1249
+ setC(bLeft, botY, BL, 'border')
1250
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border')
1251
+ setC(bRight, botY, BR, 'border')
1252
+
1253
+ // Side borders
1254
+ for (let y = topY + 1; y < botY; y++) {
1255
+ setC(bLeft, y, V, 'border')
1256
+ setC(bRight, y, V, 'border')
1257
+ }
1258
+
1259
+ // Dividers
1260
+ for (let d = 0; d < block.dividers.length; d++) {
1261
+ const dY = divYMap.get(`${b}:${d}`)
1262
+ if (dY === undefined) continue
1263
+ const dashChar = isDashedH()
1264
+ setC(bLeft, dY, JL, 'junction')
1265
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line')
1266
+ setC(bRight, dY, JR, 'junction')
1267
+ // Divider label
1268
+ const dLabel = block.dividers[d]!.label
1269
+ if (dLabel) {
1270
+ const dStr = `[${dLabel}]`
1271
+ writeTextCells(bLeft + 1, dY, dStr, 'text', bRight)
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ // ---- DRAW: notes ----
1277
+
1278
+ for (const np of notePositions) {
1279
+ // Ensure canvas is big enough
1280
+ increaseSize(canvas, np.x + np.width, np.y + np.height)
1281
+ increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height)
1282
+ // Top border
1283
+ setC(np.x, np.y, TL, 'border')
1284
+ for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border')
1285
+ setC(np.x + np.width - 1, np.y, TR, 'border')
1286
+ // Content rows
1287
+ for (let l = 0; l < np.lines.length; l++) {
1288
+ const ly = np.y + 1 + l
1289
+ setC(np.x, ly, V, 'border')
1290
+ setC(np.x + np.width - 1, ly, V, 'border')
1291
+ // Blank the full interior (content + padding columns) first — the
1292
+ // note is drawn over lifelines that were already painted down every
1293
+ // row in this span, and writeTextCells below only touches the exact
1294
+ // cells the text occupies. Whenever a note's computed width happens
1295
+ // to put its own padding column on top of a lifeline's x position
1296
+ // (e.g. "Note over A,B" wide enough to reach B's lifeline), that
1297
+ // untouched padding column lets the stale lifeline character leak
1298
+ // through as a doubled border glyph right next to the note's own
1299
+ // border.
1300
+ for (let x = np.x + 1; x < np.x + np.width - 1; x++) {
1301
+ setC(x, ly, ' ', 'text')
1302
+ }
1303
+ writeTextCells(np.x + 1 + boxPad, ly, np.lines[l]!, 'text')
1304
+ }
1305
+ // Bottom border
1306
+ const by = np.y + np.height - 1
1307
+ setC(np.x, by, BL, 'border')
1308
+ for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border')
1309
+ setC(np.x + np.width - 1, by, BR, 'border')
1310
+ }
1311
+
1312
+ return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
1313
+
1314
+ // ---- Helper: dashed horizontal character ----
1315
+ function isDashedH(): string {
1316
+ return useAscii ? '-' : '╌'
1317
+ }
1318
+ }