@chengchenccc/tui 0.1.1-rc.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 (226) hide show
  1. package/dist/autocomplete.d.ts +56 -0
  2. package/dist/autocomplete.d.ts.map +1 -0
  3. package/dist/autocomplete.js +647 -0
  4. package/dist/components/box.d.ts +22 -0
  5. package/dist/components/box.d.ts.map +1 -0
  6. package/dist/components/box.js +103 -0
  7. package/dist/components/card.d.ts +33 -0
  8. package/dist/components/card.d.ts.map +1 -0
  9. package/dist/components/card.js +49 -0
  10. package/dist/components/editor.d.ts +257 -0
  11. package/dist/components/editor.d.ts.map +1 -0
  12. package/dist/components/editor.js +1980 -0
  13. package/dist/components/input.d.ts +37 -0
  14. package/dist/components/input.d.ts.map +1 -0
  15. package/dist/components/input.js +389 -0
  16. package/dist/components/loader.d.ts +33 -0
  17. package/dist/components/loader.d.ts.map +1 -0
  18. package/dist/components/loader.js +100 -0
  19. package/dist/components/markdown.d.ts +108 -0
  20. package/dist/components/markdown.d.ts.map +1 -0
  21. package/dist/components/markdown.js +847 -0
  22. package/dist/components/markdown.test.d.ts +2 -0
  23. package/dist/components/markdown.test.d.ts.map +1 -0
  24. package/dist/components/markdown.test.js +81 -0
  25. package/dist/components/output-block.d.ts +44 -0
  26. package/dist/components/output-block.d.ts.map +1 -0
  27. package/dist/components/output-block.js +131 -0
  28. package/dist/components/select-list.d.ts +50 -0
  29. package/dist/components/select-list.d.ts.map +1 -0
  30. package/dist/components/select-list.js +166 -0
  31. package/dist/components/spacer.d.ts +12 -0
  32. package/dist/components/spacer.d.ts.map +1 -0
  33. package/dist/components/spacer.js +22 -0
  34. package/dist/components/statusline.d.ts +30 -0
  35. package/dist/components/statusline.d.ts.map +1 -0
  36. package/dist/components/statusline.js +46 -0
  37. package/dist/components/text.d.ts +19 -0
  38. package/dist/components/text.d.ts.map +1 -0
  39. package/dist/components/text.js +90 -0
  40. package/dist/components/truncated-text.d.ts +13 -0
  41. package/dist/components/truncated-text.d.ts.map +1 -0
  42. package/dist/components/truncated-text.js +50 -0
  43. package/dist/editor-component.d.ts +39 -0
  44. package/dist/editor-component.d.ts.map +1 -0
  45. package/dist/editor-component.js +1 -0
  46. package/dist/fuzzy.d.ts +16 -0
  47. package/dist/fuzzy.d.ts.map +1 -0
  48. package/dist/fuzzy.js +109 -0
  49. package/dist/index.d.ts +25 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +36 -0
  52. package/dist/keybindings.d.ts +193 -0
  53. package/dist/keybindings.d.ts.map +1 -0
  54. package/dist/keybindings.js +191 -0
  55. package/dist/keys.d.ts +184 -0
  56. package/dist/keys.d.ts.map +1 -0
  57. package/dist/keys.js +1191 -0
  58. package/dist/kill-ring.d.ts +28 -0
  59. package/dist/kill-ring.d.ts.map +1 -0
  60. package/dist/kill-ring.js +43 -0
  61. package/dist/mermaid.d.ts +23 -0
  62. package/dist/mermaid.d.ts.map +1 -0
  63. package/dist/mermaid.js +77 -0
  64. package/dist/mouse.d.ts +40 -0
  65. package/dist/mouse.d.ts.map +1 -0
  66. package/dist/mouse.js +39 -0
  67. package/dist/mouse.test.d.ts +2 -0
  68. package/dist/mouse.test.d.ts.map +1 -0
  69. package/dist/mouse.test.js +36 -0
  70. package/dist/native-modifiers.d.ts +3 -0
  71. package/dist/native-modifiers.d.ts.map +1 -0
  72. package/dist/native-modifiers.js +52 -0
  73. package/dist/stdin-buffer.d.ts +50 -0
  74. package/dist/stdin-buffer.d.ts.map +1 -0
  75. package/dist/stdin-buffer.js +360 -0
  76. package/dist/terminal-colors.d.ts +10 -0
  77. package/dist/terminal-colors.d.ts.map +1 -0
  78. package/dist/terminal-colors.js +58 -0
  79. package/dist/terminal-image.d.ts +90 -0
  80. package/dist/terminal-image.d.ts.map +1 -0
  81. package/dist/terminal-image.js +375 -0
  82. package/dist/terminal.d.ts +110 -0
  83. package/dist/terminal.d.ts.map +1 -0
  84. package/dist/terminal.js +432 -0
  85. package/dist/tui-scrollback.test.d.ts +2 -0
  86. package/dist/tui-scrollback.test.d.ts.map +1 -0
  87. package/dist/tui-scrollback.test.js +51 -0
  88. package/dist/tui.d.ts +330 -0
  89. package/dist/tui.d.ts.map +1 -0
  90. package/dist/tui.js +1568 -0
  91. package/dist/undo-stack.d.ts +17 -0
  92. package/dist/undo-stack.d.ts.map +1 -0
  93. package/dist/undo-stack.js +24 -0
  94. package/dist/utils.d.ts +85 -0
  95. package/dist/utils.d.ts.map +1 -0
  96. package/dist/utils.js +1066 -0
  97. package/dist/vendor/mermaid-ascii/ascii/ansi.d.ts +42 -0
  98. package/dist/vendor/mermaid-ascii/ascii/ansi.d.ts.map +1 -0
  99. package/dist/vendor/mermaid-ascii/ascii/ansi.js +361 -0
  100. package/dist/vendor/mermaid-ascii/ascii/canvas.d.ts +90 -0
  101. package/dist/vendor/mermaid-ascii/ascii/canvas.d.ts.map +1 -0
  102. package/dist/vendor/mermaid-ascii/ascii/canvas.js +414 -0
  103. package/dist/vendor/mermaid-ascii/ascii/class-diagram.d.ts +8 -0
  104. package/dist/vendor/mermaid-ascii/ascii/class-diagram.d.ts.map +1 -0
  105. package/dist/vendor/mermaid-ascii/ascii/class-diagram.js +623 -0
  106. package/dist/vendor/mermaid-ascii/ascii/converter.d.ts +13 -0
  107. package/dist/vendor/mermaid-ascii/ascii/converter.d.ts.map +1 -0
  108. package/dist/vendor/mermaid-ascii/ascii/converter.js +233 -0
  109. package/dist/vendor/mermaid-ascii/ascii/draw.d.ts +67 -0
  110. package/dist/vendor/mermaid-ascii/ascii/draw.d.ts.map +1 -0
  111. package/dist/vendor/mermaid-ascii/ascii/draw.js +1313 -0
  112. package/dist/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +49 -0
  113. package/dist/vendor/mermaid-ascii/ascii/edge-bundling.d.ts.map +1 -0
  114. package/dist/vendor/mermaid-ascii/ascii/edge-bundling.js +289 -0
  115. package/dist/vendor/mermaid-ascii/ascii/edge-routing.d.ts +44 -0
  116. package/dist/vendor/mermaid-ascii/ascii/edge-routing.d.ts.map +1 -0
  117. package/dist/vendor/mermaid-ascii/ascii/edge-routing.js +302 -0
  118. package/dist/vendor/mermaid-ascii/ascii/er-diagram.d.ts +8 -0
  119. package/dist/vendor/mermaid-ascii/ascii/er-diagram.d.ts.map +1 -0
  120. package/dist/vendor/mermaid-ascii/ascii/er-diagram.js +380 -0
  121. package/dist/vendor/mermaid-ascii/ascii/grid.d.ts +57 -0
  122. package/dist/vendor/mermaid-ascii/ascii/grid.d.ts.map +1 -0
  123. package/dist/vendor/mermaid-ascii/ascii/grid.js +516 -0
  124. package/dist/vendor/mermaid-ascii/ascii/index.d.ts +66 -0
  125. package/dist/vendor/mermaid-ascii/ascii/index.d.ts.map +1 -0
  126. package/dist/vendor/mermaid-ascii/ascii/index.js +133 -0
  127. package/dist/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +28 -0
  128. package/dist/vendor/mermaid-ascii/ascii/multiline-utils.d.ts.map +1 -0
  129. package/dist/vendor/mermaid-ascii/ascii/multiline-utils.js +60 -0
  130. package/dist/vendor/mermaid-ascii/ascii/pathfinder.d.ts +18 -0
  131. package/dist/vendor/mermaid-ascii/ascii/pathfinder.d.ts.map +1 -0
  132. package/dist/vendor/mermaid-ascii/ascii/pathfinder.js +224 -0
  133. package/dist/vendor/mermaid-ascii/ascii/sequence.d.ts +8 -0
  134. package/dist/vendor/mermaid-ascii/ascii/sequence.d.ts.map +1 -0
  135. package/dist/vendor/mermaid-ascii/ascii/sequence.js +416 -0
  136. package/dist/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +12 -0
  137. package/dist/vendor/mermaid-ascii/ascii/shapes/circle.d.ts.map +1 -0
  138. package/dist/vendor/mermaid-ascii/ascii/shapes/circle.js +22 -0
  139. package/dist/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +35 -0
  140. package/dist/vendor/mermaid-ascii/ascii/shapes/corners.d.ts.map +1 -0
  141. package/dist/vendor/mermaid-ascii/ascii/shapes/corners.js +91 -0
  142. package/dist/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +12 -0
  143. package/dist/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts.map +1 -0
  144. package/dist/vendor/mermaid-ascii/ascii/shapes/diamond.js +22 -0
  145. package/dist/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +12 -0
  146. package/dist/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts.map +1 -0
  147. package/dist/vendor/mermaid-ascii/ascii/shapes/hexagon.js +22 -0
  148. package/dist/vendor/mermaid-ascii/ascii/shapes/index.d.ts +27 -0
  149. package/dist/vendor/mermaid-ascii/ascii/shapes/index.d.ts.map +1 -0
  150. package/dist/vendor/mermaid-ascii/ascii/shapes/index.js +66 -0
  151. package/dist/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +32 -0
  152. package/dist/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts.map +1 -0
  153. package/dist/vendor/mermaid-ascii/ascii/shapes/rectangle.js +149 -0
  154. package/dist/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +12 -0
  155. package/dist/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts.map +1 -0
  156. package/dist/vendor/mermaid-ascii/ascii/shapes/rounded.js +22 -0
  157. package/dist/vendor/mermaid-ascii/ascii/shapes/special.d.ts +60 -0
  158. package/dist/vendor/mermaid-ascii/ascii/shapes/special.d.ts.map +1 -0
  159. package/dist/vendor/mermaid-ascii/ascii/shapes/special.js +253 -0
  160. package/dist/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +18 -0
  161. package/dist/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts.map +1 -0
  162. package/dist/vendor/mermaid-ascii/ascii/shapes/stadium.js +103 -0
  163. package/dist/vendor/mermaid-ascii/ascii/shapes/state.d.ts +31 -0
  164. package/dist/vendor/mermaid-ascii/ascii/shapes/state.d.ts.map +1 -0
  165. package/dist/vendor/mermaid-ascii/ascii/shapes/state.js +167 -0
  166. package/dist/vendor/mermaid-ascii/ascii/shapes/types.d.ts +56 -0
  167. package/dist/vendor/mermaid-ascii/ascii/shapes/types.d.ts.map +1 -0
  168. package/dist/vendor/mermaid-ascii/ascii/shapes/types.js +4 -0
  169. package/dist/vendor/mermaid-ascii/ascii/types.d.ts +207 -0
  170. package/dist/vendor/mermaid-ascii/ascii/types.d.ts.map +1 -0
  171. package/dist/vendor/mermaid-ascii/ascii/types.js +39 -0
  172. package/dist/vendor/mermaid-ascii/ascii/validate.d.ts +52 -0
  173. package/dist/vendor/mermaid-ascii/ascii/validate.d.ts.map +1 -0
  174. package/dist/vendor/mermaid-ascii/ascii/validate.js +97 -0
  175. package/dist/vendor/mermaid-ascii/ascii/xychart.d.ts +3 -0
  176. package/dist/vendor/mermaid-ascii/ascii/xychart.d.ts.map +1 -0
  177. package/dist/vendor/mermaid-ascii/ascii/xychart.js +719 -0
  178. package/dist/vendor/mermaid-ascii/class/parser.d.ts +7 -0
  179. package/dist/vendor/mermaid-ascii/class/parser.d.ts.map +1 -0
  180. package/dist/vendor/mermaid-ascii/class/parser.js +265 -0
  181. package/dist/vendor/mermaid-ascii/class/types.d.ts +103 -0
  182. package/dist/vendor/mermaid-ascii/class/types.d.ts.map +1 -0
  183. package/dist/vendor/mermaid-ascii/class/types.js +7 -0
  184. package/dist/vendor/mermaid-ascii/er/parser.d.ts +7 -0
  185. package/dist/vendor/mermaid-ascii/er/parser.d.ts.map +1 -0
  186. package/dist/vendor/mermaid-ascii/er/parser.js +162 -0
  187. package/dist/vendor/mermaid-ascii/er/types.d.ts +77 -0
  188. package/dist/vendor/mermaid-ascii/er/types.d.ts.map +1 -0
  189. package/dist/vendor/mermaid-ascii/er/types.js +7 -0
  190. package/dist/vendor/mermaid-ascii/index.d.ts +2 -0
  191. package/dist/vendor/mermaid-ascii/index.d.ts.map +1 -0
  192. package/dist/vendor/mermaid-ascii/index.js +13 -0
  193. package/dist/vendor/mermaid-ascii/multiline-utils.d.ts +10 -0
  194. package/dist/vendor/mermaid-ascii/multiline-utils.d.ts.map +1 -0
  195. package/dist/vendor/mermaid-ascii/multiline-utils.js +29 -0
  196. package/dist/vendor/mermaid-ascii/parser.d.ts +8 -0
  197. package/dist/vendor/mermaid-ascii/parser.d.ts.map +1 -0
  198. package/dist/vendor/mermaid-ascii/parser.js +558 -0
  199. package/dist/vendor/mermaid-ascii/sequence/parser.d.ts +7 -0
  200. package/dist/vendor/mermaid-ascii/sequence/parser.d.ts.map +1 -0
  201. package/dist/vendor/mermaid-ascii/sequence/parser.js +182 -0
  202. package/dist/vendor/mermaid-ascii/sequence/types.d.ts +131 -0
  203. package/dist/vendor/mermaid-ascii/sequence/types.d.ts.map +1 -0
  204. package/dist/vendor/mermaid-ascii/sequence/types.js +7 -0
  205. package/dist/vendor/mermaid-ascii/text-metrics.d.ts +24 -0
  206. package/dist/vendor/mermaid-ascii/text-metrics.d.ts.map +1 -0
  207. package/dist/vendor/mermaid-ascii/text-metrics.js +69 -0
  208. package/dist/vendor/mermaid-ascii/types.d.ts +115 -0
  209. package/dist/vendor/mermaid-ascii/types.d.ts.map +1 -0
  210. package/dist/vendor/mermaid-ascii/types.js +4 -0
  211. package/dist/vendor/mermaid-ascii/xychart/colors.d.ts +26 -0
  212. package/dist/vendor/mermaid-ascii/xychart/colors.d.ts.map +1 -0
  213. package/dist/vendor/mermaid-ascii/xychart/colors.js +145 -0
  214. package/dist/vendor/mermaid-ascii/xychart/parser.d.ts +7 -0
  215. package/dist/vendor/mermaid-ascii/xychart/parser.d.ts.map +1 -0
  216. package/dist/vendor/mermaid-ascii/xychart/parser.js +105 -0
  217. package/dist/vendor/mermaid-ascii/xychart/types.d.ts +146 -0
  218. package/dist/vendor/mermaid-ascii/xychart/types.d.ts.map +1 -0
  219. package/dist/vendor/mermaid-ascii/xychart/types.js +8 -0
  220. package/dist/virtual-terminal.d.ts +80 -0
  221. package/dist/virtual-terminal.d.ts.map +1 -0
  222. package/dist/virtual-terminal.js +284 -0
  223. package/dist/word-navigation.d.ts +25 -0
  224. package/dist/word-navigation.d.ts.map +1 -0
  225. package/dist/word-navigation.js +97 -0
  226. package/package.json +38 -0
@@ -0,0 +1,1313 @@
1
+ // ============================================================================
2
+ // ASCII renderer — drawing operations
3
+ //
4
+ // Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go + cmd/arrow.go.
5
+ // Contains all visual rendering: boxes, lines, arrows, corners,
6
+ // subgraphs, labels, and the top-level draw orchestrator.
7
+ // ============================================================================
8
+ import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, drawingCoordEquals, } from "./types.js";
9
+ import { mkCanvas, copyCanvas, mergeCanvases, drawText, setRole } from "./canvas.js";
10
+ import { determineDirection, dirEquals } from "./edge-routing.js";
11
+ import { gridToDrawingCoord, lineToDrawing } from "./grid.js";
12
+ import { splitLines } from "./multiline-utils.js";
13
+ import { getCorners } from "./shapes/corners.js";
14
+ import { getShapeAttachmentPoint } from "./shapes/index.js";
15
+ import { displayWidth, LABEL_SPACE, toCells, WIDE_PAD } from "../text-metrics.js";
16
+ // ============================================================================
17
+ // Node drawing — renders a node using shape-aware rendering
18
+ // ============================================================================
19
+ /**
20
+ * Draw a node using its shape type.
21
+ * Returns a standalone canvas containing the rendered shape.
22
+ *
23
+ * For basic shapes (rectangle, rounded), uses grid-determined dimensions
24
+ * to ensure consistent sizing across nodes in the same column.
25
+ * For special shapes (diamond, circle, state pseudo-states, etc.),
26
+ * uses shape-specific dimension calculation but centers the content
27
+ * within the grid cell dimensions to ensure proper vertical alignment.
28
+ */
29
+ export function drawNode(node, graph) {
30
+ // All shapes use grid-determined dimensions to fill their allocated space.
31
+ // This ensures consistent sizing across nodes and eliminates gaps between
32
+ // nodes and subgraph borders. All shapes are rectangles with distinctive
33
+ // corner characters (defined in corners.ts) to indicate shape type.
34
+ return drawBoxWithGridDimensions(node, graph);
35
+ }
36
+ /**
37
+ * Draw a box shape using grid-determined dimensions.
38
+ * This ensures consistent sizing when multiple nodes share a column,
39
+ * and eliminates gaps between nodes and subgraph borders by filling
40
+ * the entire allocated grid space.
41
+ *
42
+ * All shapes are rendered as rectangles with distinctive corner characters
43
+ * (defined in corners.ts) to indicate shape type.
44
+ */
45
+ function drawBoxWithGridDimensions(node, graph) {
46
+ const gc = node.gridCoord;
47
+ const useAscii = graph.config.useAscii;
48
+ // Width spans 2 columns (border + content) - matching original behavior
49
+ let w = 0;
50
+ for (let i = 0; i < 2; i++) {
51
+ w += graph.columnWidth.get(gc.x + i) ?? 0;
52
+ }
53
+ // Height spans 2 rows (border + content)
54
+ let h = 0;
55
+ for (let i = 0; i < 2; i++) {
56
+ h += graph.rowHeight.get(gc.y + i) ?? 0;
57
+ }
58
+ const from = { x: 0, y: 0 };
59
+ const to = { x: w, y: h };
60
+ const box = mkCanvas(Math.max(from.x, to.x), Math.max(from.y, to.y));
61
+ // Get corner characters for this shape type
62
+ const corners = getCorners(node.shape, useAscii);
63
+ const isStateStart = node.shape === 'state-start';
64
+ const isStateEnd = node.shape === 'state-end';
65
+ const hChar = useAscii ? (isStateEnd ? '=' : '-') : (isStateEnd ? '═' : '─');
66
+ const vChar = useAscii ? (isStateEnd ? '‖' : '|') : (isStateEnd ? '║' : '│');
67
+ const stateStartCorners = useAscii
68
+ ? { tl: '+', tr: '+', bl: '+', br: '+' }
69
+ : { tl: '╭', tr: '╮', bl: '╰', br: '╯' };
70
+ const stateEndCorners = useAscii
71
+ ? { tl: '#', tr: '#', bl: '#', br: '#' }
72
+ : { tl: '╔', tr: '╗', bl: '╚', br: '╝' };
73
+ const effectiveCorners = isStateEnd ? stateEndCorners : isStateStart ? stateStartCorners : corners;
74
+ // Draw box border with shape-specific corners
75
+ for (let x = from.x + 1; x < to.x; x++)
76
+ box[x][from.y] = hChar;
77
+ for (let x = from.x + 1; x < to.x; x++)
78
+ box[x][to.y] = hChar;
79
+ for (let y = from.y + 1; y < to.y; y++)
80
+ box[from.x][y] = vChar;
81
+ for (let y = from.y + 1; y < to.y; y++)
82
+ box[to.x][y] = vChar;
83
+ box[from.x][from.y] = effectiveCorners.tl;
84
+ box[to.x][from.y] = effectiveCorners.tr;
85
+ box[from.x][to.y] = effectiveCorners.bl;
86
+ box[to.x][to.y] = effectiveCorners.br;
87
+ // Pseudostates have no source label; restore their UML marker explicitly.
88
+ const label = node.displayLabel || (isStateStart ? (useAscii ? '*' : '●') : isStateEnd ? (useAscii ? '*' : '◎') : '');
89
+ const lines = splitLines(label);
90
+ const textCenterY = from.y + Math.floor(h / 2);
91
+ const startY = textCenterY - Math.floor((lines.length - 1) / 2);
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i];
94
+ const cells = toCells(line);
95
+ const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1;
96
+ for (let j = 0; j < cells.length; j++) {
97
+ if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0].length) {
98
+ box[textX + j][startY + i] = cells[j];
99
+ }
100
+ }
101
+ }
102
+ return box;
103
+ }
104
+ /**
105
+ * Draw a node box with centered label text.
106
+ * Returns a standalone canvas containing just the box.
107
+ * Box size is determined by the grid column/row sizes for the node's position.
108
+ */
109
+ export function drawBox(node, graph) {
110
+ return drawNode(node, graph);
111
+ }
112
+ // ============================================================================
113
+ // Multi-section box drawing — for class and ER diagram nodes
114
+ // ============================================================================
115
+ /**
116
+ * Draw a multi-section box with horizontal dividers between sections.
117
+ * Used by class diagrams (header | attributes | methods) and ER diagrams (header | attributes).
118
+ * Each section is an array of text lines to render left-aligned with padding.
119
+ *
120
+ * @param sections - Array of sections, each section is an array of text lines
121
+ * @param useAscii - true for ASCII chars, false for Unicode box-drawing
122
+ * @param padding - horizontal padding inside the box (default 1)
123
+ * @returns A standalone Canvas containing the multi-section box
124
+ */
125
+ export function drawMultiBox(sections, useAscii, padding = 1) {
126
+ // Compute width: widest line across all sections + 2*padding + 2 border chars
127
+ let maxTextWidth = 0;
128
+ for (const section of sections) {
129
+ for (const line of section) {
130
+ maxTextWidth = Math.max(maxTextWidth, displayWidth(line));
131
+ }
132
+ }
133
+ const innerWidth = maxTextWidth + 2 * padding;
134
+ const boxWidth = innerWidth + 2; // +2 for left/right border
135
+ // Compute height: sum of all section line counts + dividers + 2 border rows
136
+ let totalLines = 0;
137
+ for (const section of sections) {
138
+ totalLines += Math.max(section.length, 1); // at least 1 row per section
139
+ }
140
+ const numDividers = sections.length - 1;
141
+ const boxHeight = totalLines + numDividers + 2; // +2 for top/bottom border
142
+ // Box-drawing characters
143
+ const hLine = useAscii ? '-' : '─';
144
+ const vLine = useAscii ? '|' : '│';
145
+ const tl = useAscii ? '+' : '┌';
146
+ const tr = useAscii ? '+' : '┐';
147
+ const bl = useAscii ? '+' : '└';
148
+ const br = useAscii ? '+' : '┘';
149
+ const divL = useAscii ? '+' : '├';
150
+ const divR = useAscii ? '+' : '┤';
151
+ const canvas = mkCanvas(boxWidth - 1, boxHeight - 1);
152
+ // Top border
153
+ canvas[0][0] = tl;
154
+ for (let x = 1; x < boxWidth - 1; x++)
155
+ canvas[x][0] = hLine;
156
+ canvas[boxWidth - 1][0] = tr;
157
+ // Bottom border
158
+ canvas[0][boxHeight - 1] = bl;
159
+ for (let x = 1; x < boxWidth - 1; x++)
160
+ canvas[x][boxHeight - 1] = hLine;
161
+ canvas[boxWidth - 1][boxHeight - 1] = br;
162
+ // Left and right borders (full height)
163
+ for (let y = 1; y < boxHeight - 1; y++) {
164
+ canvas[0][y] = vLine;
165
+ canvas[boxWidth - 1][y] = vLine;
166
+ }
167
+ // Render sections with dividers
168
+ let row = 1; // current y position (starts after top border)
169
+ for (let s = 0; s < sections.length; s++) {
170
+ const section = sections[s];
171
+ const lines = section.length > 0 ? section : [''];
172
+ // Draw section text lines
173
+ for (const line of lines) {
174
+ const startX = 1 + padding;
175
+ const cells = toCells(line);
176
+ for (let i = 0; i < cells.length; i++) {
177
+ canvas[startX + i][row] = cells[i];
178
+ }
179
+ row++;
180
+ }
181
+ // Draw divider after each section except the last
182
+ if (s < sections.length - 1) {
183
+ canvas[0][row] = divL;
184
+ for (let x = 1; x < boxWidth - 1; x++)
185
+ canvas[x][row] = hLine;
186
+ canvas[boxWidth - 1][row] = divR;
187
+ row++;
188
+ }
189
+ }
190
+ return canvas;
191
+ }
192
+ // ============================================================================
193
+ // Line drawing — 8-directional lines on the canvas
194
+ // ============================================================================
195
+ /**
196
+ * Line character sets for different edge styles.
197
+ * Each style has horizontal, vertical, and diagonal characters for both
198
+ * Unicode (box-drawing) and ASCII (basic punctuation) modes.
199
+ *
200
+ * Unicode dotted: ┄ (horizontal), ┆ (vertical) — U+2504, U+2506
201
+ * Unicode thick: ━ (horizontal), ┃ (vertical) — U+2501, U+2503
202
+ */
203
+ /**
204
+ * Line character sets for different edge styles.
205
+ * Only horizontal and vertical characters - no diagonals.
206
+ * All edges use orthogonal Manhattan routing (90° bends only).
207
+ */
208
+ const LINE_CHARS = {
209
+ solid: {
210
+ h: { unicode: '─', ascii: '-' },
211
+ v: { unicode: '│', ascii: '|' },
212
+ },
213
+ dotted: {
214
+ h: { unicode: '┄', ascii: '.' },
215
+ v: { unicode: '┆', ascii: ':' },
216
+ },
217
+ thick: {
218
+ h: { unicode: '━', ascii: '=' },
219
+ v: { unicode: '┃', ascii: '‖' },
220
+ },
221
+ };
222
+ /**
223
+ * Draw a line between two drawing coordinates using orthogonal Manhattan routing.
224
+ * Returns the list of coordinates that were drawn on.
225
+ * offsetFrom/offsetTo control how many cells to skip at the start/end.
226
+ *
227
+ * All lines use 90° bends only - no diagonal lines are produced.
228
+ * For diagonal directions, uses horizontal-first routing (draws horizontal
229
+ * segment, then vertical segment).
230
+ */
231
+ export function drawLine(canvas, from, to, offsetFrom, offsetTo, useAscii, style = 'solid') {
232
+ const dir = determineDirection(from, to);
233
+ const drawnCoords = [];
234
+ // Select character set based on style (horizontal and vertical only)
235
+ const chars = LINE_CHARS[style];
236
+ const hChar = useAscii ? chars.h.ascii : chars.h.unicode;
237
+ const vChar = useAscii ? chars.v.ascii : chars.v.unicode;
238
+ // Pure vertical directions
239
+ if (dirEquals(dir, Up)) {
240
+ for (let y = from.y - offsetFrom; y >= to.y - offsetTo; y--) {
241
+ drawnCoords.push({ x: from.x, y });
242
+ canvas[from.x][y] = vChar;
243
+ }
244
+ }
245
+ else if (dirEquals(dir, Down)) {
246
+ for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) {
247
+ drawnCoords.push({ x: from.x, y });
248
+ canvas[from.x][y] = vChar;
249
+ }
250
+ }
251
+ // Pure horizontal directions
252
+ else if (dirEquals(dir, Left)) {
253
+ for (let x = from.x - offsetFrom; x >= to.x - offsetTo; x--) {
254
+ drawnCoords.push({ x, y: from.y });
255
+ canvas[x][from.y] = hChar;
256
+ }
257
+ }
258
+ else if (dirEquals(dir, Right)) {
259
+ for (let x = from.x + offsetFrom; x <= to.x + offsetTo; x++) {
260
+ drawnCoords.push({ x, y: from.y });
261
+ canvas[x][from.y] = hChar;
262
+ }
263
+ }
264
+ // Diagonal directions: use Manhattan routing (horizontal-first, then vertical)
265
+ // UpperLeft: go left first, then up
266
+ else if (dirEquals(dir, UpperLeft)) {
267
+ // Horizontal segment: from.x -> to.x (going left)
268
+ for (let x = from.x - offsetFrom; x >= to.x; x--) {
269
+ drawnCoords.push({ x, y: from.y });
270
+ canvas[x][from.y] = hChar;
271
+ }
272
+ // Vertical segment: from.y -> to.y (going up)
273
+ for (let y = from.y - 1; y >= to.y - offsetTo; y--) {
274
+ drawnCoords.push({ x: to.x, y });
275
+ canvas[to.x][y] = vChar;
276
+ }
277
+ }
278
+ // UpperRight: go right first, then up
279
+ else if (dirEquals(dir, UpperRight)) {
280
+ // Horizontal segment: from.x -> to.x (going right)
281
+ for (let x = from.x + offsetFrom; x <= to.x; x++) {
282
+ drawnCoords.push({ x, y: from.y });
283
+ canvas[x][from.y] = hChar;
284
+ }
285
+ // Vertical segment: from.y -> to.y (going up)
286
+ for (let y = from.y - 1; y >= to.y - offsetTo; y--) {
287
+ drawnCoords.push({ x: to.x, y });
288
+ canvas[to.x][y] = vChar;
289
+ }
290
+ }
291
+ // LowerLeft: go left first, then down
292
+ else if (dirEquals(dir, LowerLeft)) {
293
+ // Horizontal segment: from.x -> to.x (going left)
294
+ for (let x = from.x - offsetFrom; x >= to.x; x--) {
295
+ drawnCoords.push({ x, y: from.y });
296
+ canvas[x][from.y] = hChar;
297
+ }
298
+ // Vertical segment: from.y -> to.y (going down)
299
+ for (let y = from.y + 1; y <= to.y + offsetTo; y++) {
300
+ drawnCoords.push({ x: to.x, y });
301
+ canvas[to.x][y] = vChar;
302
+ }
303
+ }
304
+ // LowerRight: go right first, then down
305
+ // Special case: if x difference is small (1), draw straight vertical at from.x
306
+ // This keeps edges visually aligned with the source node
307
+ else if (dirEquals(dir, LowerRight)) {
308
+ const dx = to.x - from.x;
309
+ if (dx <= 1) {
310
+ // Draw vertical line at from.x (source's x-coordinate)
311
+ for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) {
312
+ drawnCoords.push({ x: from.x, y });
313
+ canvas[from.x][y] = vChar;
314
+ }
315
+ }
316
+ else {
317
+ // Horizontal segment: from.x -> to.x (going right)
318
+ for (let x = from.x + offsetFrom; x <= to.x; x++) {
319
+ drawnCoords.push({ x, y: from.y });
320
+ canvas[x][from.y] = hChar;
321
+ }
322
+ // Vertical segment: from.y -> to.y (going down)
323
+ for (let y = from.y + 1; y <= to.y + offsetTo; y++) {
324
+ drawnCoords.push({ x: to.x, y });
325
+ canvas[to.x][y] = vChar;
326
+ }
327
+ }
328
+ }
329
+ return drawnCoords;
330
+ }
331
+ // ============================================================================
332
+ // Arrow drawing — path, corners, arrowheads, box-start junctions, labels
333
+ // ============================================================================
334
+ /**
335
+ * Draw a complete arrow (edge) between two nodes.
336
+ * Returns 6 separate canvases for layered compositing:
337
+ * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label]
338
+ *
339
+ * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd.
340
+ */
341
+ export function drawArrow(graph, edge) {
342
+ if (edge.path.length === 0) {
343
+ const empty = copyCanvas(graph.canvas);
344
+ return [empty, empty, empty, empty, empty, empty];
345
+ }
346
+ const labelCanvas = drawArrowLabel(graph, edge);
347
+ const [pathCanvas, linesDrawn, lineDirs] = drawPath(graph, edge.path, edge.style);
348
+ const boxStartCanvas = drawBoxStart(graph, edge.path, linesDrawn[0], edge.from.shape);
349
+ // Draw end arrowhead only if hasArrowEnd is true (default behavior)
350
+ let arrowHeadEndCanvas;
351
+ if (edge.hasArrowEnd) {
352
+ arrowHeadEndCanvas = drawArrowHead(graph, linesDrawn[linesDrawn.length - 1], lineDirs[lineDirs.length - 1]);
353
+ }
354
+ else {
355
+ arrowHeadEndCanvas = copyCanvas(graph.canvas);
356
+ }
357
+ // Draw start arrowhead for bidirectional edges
358
+ // The start arrowhead needs to be at the box connector position (one step back
359
+ // from the first line point), pointing into the source node.
360
+ let arrowHeadStartCanvas;
361
+ if (edge.hasArrowStart && linesDrawn.length > 0) {
362
+ const firstLine = linesDrawn[0];
363
+ const firstPoint = firstLine[0];
364
+ const startDir = reverseDirection(lineDirs[0]);
365
+ // Calculate the box connector position (one step back from first point)
366
+ const arrowPos = { x: firstPoint.x, y: firstPoint.y };
367
+ if (dirEquals(lineDirs[0], Right))
368
+ arrowPos.x = firstPoint.x - 1;
369
+ else if (dirEquals(lineDirs[0], Left))
370
+ arrowPos.x = firstPoint.x + 1;
371
+ else if (dirEquals(lineDirs[0], Down))
372
+ arrowPos.y = firstPoint.y - 1;
373
+ else if (dirEquals(lineDirs[0], Up))
374
+ arrowPos.y = firstPoint.y + 1;
375
+ // Create a synthetic line ending at the arrow position for drawArrowHead
376
+ const syntheticLine = [firstPoint, arrowPos];
377
+ arrowHeadStartCanvas = drawArrowHead(graph, syntheticLine, startDir);
378
+ }
379
+ else {
380
+ arrowHeadStartCanvas = copyCanvas(graph.canvas);
381
+ }
382
+ const cornersCanvas = drawCorners(graph, edge.path);
383
+ return [pathCanvas, boxStartCanvas, arrowHeadEndCanvas, arrowHeadStartCanvas, cornersCanvas, labelCanvas];
384
+ }
385
+ /**
386
+ * Reverse a direction (for bidirectional arrow start heads).
387
+ */
388
+ function reverseDirection(dir) {
389
+ if (dirEquals(dir, Up))
390
+ return Down;
391
+ if (dirEquals(dir, Down))
392
+ return Up;
393
+ if (dirEquals(dir, Left))
394
+ return Right;
395
+ if (dirEquals(dir, Right))
396
+ return Left;
397
+ if (dirEquals(dir, UpperLeft))
398
+ return LowerRight;
399
+ if (dirEquals(dir, UpperRight))
400
+ return LowerLeft;
401
+ if (dirEquals(dir, LowerLeft))
402
+ return UpperRight;
403
+ if (dirEquals(dir, LowerRight))
404
+ return UpperLeft;
405
+ return Middle;
406
+ }
407
+ /**
408
+ * Draw the path lines for an edge.
409
+ * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment.
410
+ */
411
+ function drawPath(graph, path, style = 'solid') {
412
+ const canvas = copyCanvas(graph.canvas);
413
+ let previousCoord = path[0];
414
+ const linesDrawn = [];
415
+ const lineDirs = [];
416
+ for (let i = 1; i < path.length; i++) {
417
+ const nextCoord = path[i];
418
+ const prevDC = gridToDrawingCoord(graph, previousCoord);
419
+ const nextDC = gridToDrawingCoord(graph, nextCoord);
420
+ if (drawingCoordEquals(prevDC, nextDC)) {
421
+ previousCoord = nextCoord;
422
+ continue;
423
+ }
424
+ const dir = determineDirection(previousCoord, nextCoord);
425
+ const segment = drawLine(canvas, prevDC, nextDC, 1, -1, graph.config.useAscii, style);
426
+ if (segment.length === 0)
427
+ segment.push(prevDC);
428
+ linesDrawn.push(segment);
429
+ lineDirs.push(dir);
430
+ previousCoord = nextCoord;
431
+ }
432
+ return [canvas, linesDrawn, lineDirs];
433
+ }
434
+ /**
435
+ * Draw the junction character where an edge exits the source node's box.
436
+ * Only applies to Unicode mode (ASCII mode just uses the line characters).
437
+ * Skips drawing for state pseudo-states which have their own visual borders.
438
+ */
439
+ function drawBoxStart(graph, path, firstLine, sourceShape) {
440
+ const canvas = copyCanvas(graph.canvas);
441
+ if (graph.config.useAscii)
442
+ return canvas;
443
+ // Skip box start connectors for state pseudo-states (they have their own bordered design)
444
+ if (sourceShape === 'state-start' || sourceShape === 'state-end') {
445
+ return canvas;
446
+ }
447
+ const from = firstLine[0];
448
+ const dir = determineDirection(path[0], path[1]);
449
+ if (dirEquals(dir, Up))
450
+ canvas[from.x][from.y + 1] = '┴';
451
+ else if (dirEquals(dir, Down))
452
+ canvas[from.x][from.y - 1] = '┬';
453
+ else if (dirEquals(dir, Left))
454
+ canvas[from.x + 1][from.y] = '┤';
455
+ else if (dirEquals(dir, Right))
456
+ canvas[from.x - 1][from.y] = '├';
457
+ return canvas;
458
+ }
459
+ /**
460
+ * Draw the arrowhead at the end of an edge path.
461
+ * Uses triangular Unicode symbols (▲▼◄►) or ASCII symbols (^v<>).
462
+ */
463
+ function drawArrowHead(graph, lastLine, fallbackDir) {
464
+ const canvas = copyCanvas(graph.canvas);
465
+ if (lastLine.length === 0)
466
+ return canvas;
467
+ const from = lastLine[0];
468
+ const lastPos = lastLine[lastLine.length - 1];
469
+ let dir = determineDirection(from, lastPos);
470
+ if (lastLine.length === 1 || dirEquals(dir, Middle))
471
+ dir = fallbackDir;
472
+ let char;
473
+ if (!graph.config.useAscii) {
474
+ if (dirEquals(dir, Up))
475
+ char = '▲';
476
+ else if (dirEquals(dir, Down))
477
+ char = '▼';
478
+ else if (dirEquals(dir, Left))
479
+ char = '◄';
480
+ else if (dirEquals(dir, Right))
481
+ char = '►';
482
+ else if (dirEquals(dir, UpperRight))
483
+ char = '◥';
484
+ else if (dirEquals(dir, UpperLeft))
485
+ char = '◤';
486
+ else if (dirEquals(dir, LowerRight))
487
+ char = '◢';
488
+ else if (dirEquals(dir, LowerLeft))
489
+ char = '◣';
490
+ else {
491
+ // Fallback
492
+ if (dirEquals(fallbackDir, Up))
493
+ char = '▲';
494
+ else if (dirEquals(fallbackDir, Down))
495
+ char = '▼';
496
+ else if (dirEquals(fallbackDir, Left))
497
+ char = '◄';
498
+ else if (dirEquals(fallbackDir, Right))
499
+ char = '►';
500
+ else if (dirEquals(fallbackDir, UpperRight))
501
+ char = '◥';
502
+ else if (dirEquals(fallbackDir, UpperLeft))
503
+ char = '◤';
504
+ else if (dirEquals(fallbackDir, LowerRight))
505
+ char = '◢';
506
+ else if (dirEquals(fallbackDir, LowerLeft))
507
+ char = '◣';
508
+ else
509
+ char = '●';
510
+ }
511
+ }
512
+ else {
513
+ if (dirEquals(dir, Up))
514
+ char = '^';
515
+ else if (dirEquals(dir, Down))
516
+ char = 'v';
517
+ else if (dirEquals(dir, Left))
518
+ char = '<';
519
+ else if (dirEquals(dir, Right))
520
+ char = '>';
521
+ else {
522
+ if (dirEquals(fallbackDir, Up))
523
+ char = '^';
524
+ else if (dirEquals(fallbackDir, Down))
525
+ char = 'v';
526
+ else if (dirEquals(fallbackDir, Left))
527
+ char = '<';
528
+ else if (dirEquals(fallbackDir, Right))
529
+ char = '>';
530
+ else
531
+ char = '*';
532
+ }
533
+ }
534
+ canvas[lastPos.x][lastPos.y] = char;
535
+ return canvas;
536
+ }
537
+ /**
538
+ * Draw corner characters at path bends (where the direction changes).
539
+ * Uses ┌┐└┘ in Unicode mode, + in ASCII mode.
540
+ */
541
+ function drawCorners(graph, path) {
542
+ const canvas = copyCanvas(graph.canvas);
543
+ for (let idx = 1; idx < path.length - 1; idx++) {
544
+ const coord = path[idx];
545
+ const dc = gridToDrawingCoord(graph, coord);
546
+ const prevDir = determineDirection(path[idx - 1], coord);
547
+ const nextDir = determineDirection(coord, path[idx + 1]);
548
+ let corner;
549
+ if (!graph.config.useAscii) {
550
+ if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
551
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {
552
+ corner = '┐';
553
+ }
554
+ else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
555
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {
556
+ corner = '┘';
557
+ }
558
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
559
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {
560
+ corner = '┌';
561
+ }
562
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
563
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {
564
+ corner = '└';
565
+ }
566
+ else {
567
+ corner = '+';
568
+ }
569
+ }
570
+ else {
571
+ corner = '+';
572
+ }
573
+ canvas[dc.x][dc.y] = corner;
574
+ }
575
+ return canvas;
576
+ }
577
+ /** Draw edge label text centered on the widest path segment. */
578
+ function drawArrowLabel(graph, edge) {
579
+ const canvas = copyCanvas(graph.canvas);
580
+ if (edge.text.length === 0)
581
+ return canvas;
582
+ const drawingLine = lineToDrawing(graph, edge.labelLine);
583
+ // Determine if this is an upward edge (target is above source in the path)
584
+ // This is used to offset labels on bidirectional edges to prevent overlap
585
+ let isUpwardEdge;
586
+ if (edge.path.length >= 2) {
587
+ const startY = edge.path[0].y;
588
+ const endY = edge.path[edge.path.length - 1].y;
589
+ // Edge goes up if end Y is less than start Y (smaller Y = higher on screen)
590
+ if (endY < startY) {
591
+ isUpwardEdge = true;
592
+ }
593
+ else if (endY > startY) {
594
+ isUpwardEdge = false;
595
+ }
596
+ // If endY === startY, it's horizontal, leave isUpwardEdge undefined
597
+ }
598
+ drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge);
599
+ return canvas;
600
+ }
601
+ /**
602
+ * Draw text centered on a line segment defined by two drawing coordinates.
603
+ * Supports multi-line labels.
604
+ *
605
+ * When isUpwardEdge is provided, offsets the label vertically to prevent
606
+ * overlapping with labels from edges going the opposite direction:
607
+ * - Upward edges: label placed in lower portion of segment
608
+ * - Downward edges (isUpwardEdge=false): label placed in upper portion
609
+ * - No direction (isUpwardEdge=undefined): label centered (default)
610
+ */
611
+ function drawTextOnLine(canvas, line, label, isUpwardEdge) {
612
+ if (line.length < 2)
613
+ return;
614
+ const minX = Math.min(line[0].x, line[1].x);
615
+ const maxX = Math.max(line[0].x, line[1].x);
616
+ const minY = Math.min(line[0].y, line[1].y);
617
+ const maxY = Math.max(line[0].y, line[1].y);
618
+ const middleX = minX + Math.floor((maxX - minX) / 2);
619
+ let middleY = minY + Math.floor((maxY - minY) / 2);
620
+ // Offset label vertically to prevent overlap on bidirectional edges
621
+ // For vertical segments (same X), shift based on edge direction
622
+ if (isUpwardEdge !== undefined && minX === maxX) {
623
+ const segmentHeight = maxY - minY;
624
+ const offset = Math.max(1, Math.floor(segmentHeight / 4));
625
+ if (isUpwardEdge) {
626
+ // Upward edge: place label in lower portion
627
+ middleY = middleY + offset;
628
+ }
629
+ else {
630
+ // Downward edge: place label in upper portion
631
+ middleY = middleY - offset;
632
+ }
633
+ }
634
+ // Support multi-line labels
635
+ const lines = splitLines(label);
636
+ const startY = middleY - Math.floor((lines.length - 1) / 2);
637
+ for (let i = 0; i < lines.length; i++) {
638
+ const lineText = lines[i];
639
+ const startX = middleX - Math.floor(displayWidth(lineText) / 2);
640
+ drawText(canvas, { x: startX, y: startY + i }, lineText.replaceAll(' ', LABEL_SPACE));
641
+ }
642
+ }
643
+ // ============================================================================
644
+ // Node attachment point helper
645
+ // ============================================================================
646
+ /**
647
+ * Get the drawing coordinate where an edge attaches to a node's border.
648
+ * Uses grid-allocated dimensions so attachment points align with the actual
649
+ * drawn box (which may be wider/taller than the intrinsic shape dimensions
650
+ * when sharing a column/row with a larger node).
651
+ */
652
+ function getNodeAttachmentPoint(graph, node, dir) {
653
+ const gc = node.gridCoord;
654
+ // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions)
655
+ let w = 0;
656
+ for (let i = 0; i < 2; i++) {
657
+ w += graph.columnWidth.get(gc.x + i) ?? 0;
658
+ }
659
+ let h = 0;
660
+ for (let i = 0; i < 2; i++) {
661
+ h += graph.rowHeight.get(gc.y + i) ?? 0;
662
+ }
663
+ // Build dimensions matching the actual drawn box size
664
+ const gridDimensions = {
665
+ width: w + 1,
666
+ height: h + 1,
667
+ labelArea: { x: 0, y: 0, width: 0, height: 0 },
668
+ gridColumns: [0, 0, 0],
669
+ gridRows: [0, 0, 0],
670
+ };
671
+ const baseCoord = node.drawingCoord;
672
+ return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord);
673
+ }
674
+ // ============================================================================
675
+ // Bundled edge drawing — for parallel links (A & B --> C)
676
+ // ============================================================================
677
+ /**
678
+ * Draw a single edge's segment in a bundle (source → junction for fan-in,
679
+ * junction → target for fan-out).
680
+ *
681
+ * Returns the same tuple format as drawArrow for consistency.
682
+ */
683
+ function drawBundledEdgeSegment(graph, edge, bundle) {
684
+ const empty = copyCanvas(graph.canvas);
685
+ if (!edge.pathToJunction || edge.pathToJunction.length === 0) {
686
+ return [empty, empty, empty, empty, empty, empty];
687
+ }
688
+ // Draw the path segment (pathToJunction)
689
+ const pathCanvas = copyCanvas(graph.canvas);
690
+ const useAscii = graph.config.useAscii;
691
+ // Convert grid coords to drawing coords
692
+ // For fan-in: first point is at source node border (use attachment point)
693
+ // For fan-out: last point is at target node border (use attachment point)
694
+ const drawingPath = edge.pathToJunction.map((gc, idx) => {
695
+ if (bundle.type === 'fan-in' && idx === 0) {
696
+ // First point: use source node's actual border position
697
+ return getNodeAttachmentPoint(graph, edge.from, edge.startDir);
698
+ }
699
+ if (bundle.type === 'fan-out' && idx === edge.pathToJunction.length - 1) {
700
+ // Last point: use target node's actual border position
701
+ return getNodeAttachmentPoint(graph, edge.to, edge.endDir);
702
+ }
703
+ return gridToDrawingCoord(graph, gc);
704
+ });
705
+ // Draw line segments
706
+ for (let i = 1; i < drawingPath.length; i++) {
707
+ const from = drawingPath[i - 1];
708
+ const to = drawingPath[i];
709
+ if (!drawingCoordEquals(from, to)) {
710
+ // Always skip both endpoints of every segment (offset 1, -1),
711
+ // matching non-bundled drawPath behavior. This leaves endpoint
712
+ // characters to corner/junction/boxStart canvases, preventing
713
+ // line characters from corrupting them via mergeJunctions.
714
+ drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style);
715
+ }
716
+ }
717
+ // Draw corners at path bends
718
+ const cornersCanvas = copyCanvas(graph.canvas);
719
+ for (let idx = 1; idx < edge.pathToJunction.length - 1; idx++) {
720
+ const coord = edge.pathToJunction[idx];
721
+ const dc = gridToDrawingCoord(graph, coord);
722
+ const prevDir = determineDirection(edge.pathToJunction[idx - 1], coord);
723
+ const nextDir = determineDirection(coord, edge.pathToJunction[idx + 1]);
724
+ let corner;
725
+ if (!useAscii) {
726
+ if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
727
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {
728
+ corner = '┐';
729
+ }
730
+ else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
731
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {
732
+ corner = '┘';
733
+ }
734
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
735
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {
736
+ corner = '┌';
737
+ }
738
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
739
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {
740
+ corner = '└';
741
+ }
742
+ else {
743
+ corner = '+';
744
+ }
745
+ }
746
+ else {
747
+ corner = '+';
748
+ }
749
+ cornersCanvas[dc.x][dc.y] = corner;
750
+ }
751
+ // Draw box start connector (for fan-in, from source node)
752
+ // The connector is placed at the first point coordinate (box border position)
753
+ // since we use offsets 1,-1 for drawLine, the line starts one step past this point
754
+ const boxStartCanvas = copyCanvas(graph.canvas);
755
+ if (bundle.type === 'fan-in' && edge.pathToJunction.length >= 2) {
756
+ const firstPoint = drawingPath[0];
757
+ const dir = determineDirection(edge.pathToJunction[0], edge.pathToJunction[1]);
758
+ if (!useAscii) {
759
+ if (dirEquals(dir, Up))
760
+ boxStartCanvas[firstPoint.x][firstPoint.y] = '┴';
761
+ else if (dirEquals(dir, Down))
762
+ boxStartCanvas[firstPoint.x][firstPoint.y] = '┬';
763
+ else if (dirEquals(dir, Left))
764
+ boxStartCanvas[firstPoint.x][firstPoint.y] = '┤';
765
+ else if (dirEquals(dir, Right))
766
+ boxStartCanvas[firstPoint.x][firstPoint.y] = '├';
767
+ }
768
+ }
769
+ // Label canvas (bundled edges typically don't have labels, but handle it)
770
+ const labelCanvas = copyCanvas(graph.canvas);
771
+ return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas];
772
+ }
773
+ /**
774
+ * Draw the shared path segment of a bundle (junction → target for fan-in,
775
+ * source → junction for fan-out).
776
+ */
777
+ function drawBundleSharedPath(graph, bundle) {
778
+ const pathCanvas = copyCanvas(graph.canvas);
779
+ const cornersCanvas = copyCanvas(graph.canvas);
780
+ if (bundle.sharedPath.length < 2) {
781
+ return [pathCanvas, cornersCanvas];
782
+ }
783
+ const useAscii = graph.config.useAscii;
784
+ const style = bundle.edges[0]?.style ?? 'solid';
785
+ const graphDir = graph.config.graphDirection;
786
+ // Convert grid coords to drawing coords
787
+ // For fan-in: last point is at target node border
788
+ // For fan-out: first point is at source node border
789
+ const drawingPath = bundle.sharedPath.map((gc, idx) => {
790
+ if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) {
791
+ // Last point: use target node's actual border position (entry from above/left)
792
+ const entryDir = graphDir === 'TD' ? Up : Left;
793
+ return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir);
794
+ }
795
+ if (bundle.type === 'fan-out' && idx === 0) {
796
+ // First point: use source node's actual border position (exit going down/right)
797
+ const exitDir = graphDir === 'TD' ? Down : Right;
798
+ return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir);
799
+ }
800
+ return gridToDrawingCoord(graph, gc);
801
+ });
802
+ // Draw line segments with appropriate offsets
803
+ for (let i = 1; i < drawingPath.length; i++) {
804
+ const from = drawingPath[i - 1];
805
+ const to = drawingPath[i];
806
+ if (!drawingCoordEquals(from, to)) {
807
+ // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath.
808
+ drawLine(pathCanvas, from, to, 1, -1, useAscii, style);
809
+ }
810
+ }
811
+ // Draw corners at path bends
812
+ for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) {
813
+ const coord = bundle.sharedPath[idx];
814
+ const dc = gridToDrawingCoord(graph, coord);
815
+ const prevDir = determineDirection(bundle.sharedPath[idx - 1], coord);
816
+ const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]);
817
+ let corner;
818
+ if (!useAscii) {
819
+ if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||
820
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {
821
+ corner = '┐';
822
+ }
823
+ else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||
824
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {
825
+ corner = '┘';
826
+ }
827
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||
828
+ (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {
829
+ corner = '┌';
830
+ }
831
+ else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||
832
+ (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {
833
+ corner = '└';
834
+ }
835
+ else {
836
+ corner = '+';
837
+ }
838
+ }
839
+ else {
840
+ corner = '+';
841
+ }
842
+ cornersCanvas[dc.x][dc.y] = corner;
843
+ }
844
+ return [pathCanvas, cornersCanvas];
845
+ }
846
+ /**
847
+ * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target).
848
+ */
849
+ function drawBundleArrowhead(graph, bundle) {
850
+ const canvas = copyCanvas(graph.canvas);
851
+ if (bundle.sharedPath.length < 2)
852
+ return canvas;
853
+ // Get the last segment direction
854
+ const lastIdx = bundle.sharedPath.length - 1;
855
+ const secondLast = bundle.sharedPath[lastIdx - 1];
856
+ const last = bundle.sharedPath[lastIdx];
857
+ const dir = determineDirection(secondLast, last);
858
+ // Get drawing coord 1 char outside the target node's border (not on the border itself).
859
+ // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead
860
+ // sits at the last drawn point (1 char before the border).
861
+ const graphDir = graph.config.graphDirection;
862
+ const entryDir = graphDir === 'TD' ? Up : Left;
863
+ const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir);
864
+ // Offset 1 char away from the box border so arrowhead sits outside the box
865
+ if (graphDir === 'TD')
866
+ dc.y -= 1;
867
+ else
868
+ dc.x -= 1;
869
+ // Draw arrowhead
870
+ let char;
871
+ if (!graph.config.useAscii) {
872
+ if (dirEquals(dir, Up))
873
+ char = '▲';
874
+ else if (dirEquals(dir, Down))
875
+ char = '▼';
876
+ else if (dirEquals(dir, Left))
877
+ char = '◄';
878
+ else if (dirEquals(dir, Right))
879
+ char = '►';
880
+ else
881
+ char = '▼'; // default
882
+ }
883
+ else {
884
+ if (dirEquals(dir, Up))
885
+ char = '^';
886
+ else if (dirEquals(dir, Down))
887
+ char = 'v';
888
+ else if (dirEquals(dir, Left))
889
+ char = '<';
890
+ else if (dirEquals(dir, Right))
891
+ char = '>';
892
+ else
893
+ char = 'v'; // default
894
+ }
895
+ canvas[dc.x][dc.y] = char;
896
+ return canvas;
897
+ }
898
+ /**
899
+ * Draw the arrowhead for a single edge in a fan-out bundle.
900
+ */
901
+ function drawBundledEdgeArrowhead(graph, edge) {
902
+ const canvas = copyCanvas(graph.canvas);
903
+ if (!edge.pathToJunction || edge.pathToJunction.length < 2)
904
+ return canvas;
905
+ // Get the last segment direction
906
+ const lastIdx = edge.pathToJunction.length - 1;
907
+ const secondLast = edge.pathToJunction[lastIdx - 1];
908
+ const last = edge.pathToJunction[lastIdx];
909
+ const dir = determineDirection(secondLast, last);
910
+ // Get drawing coord 1 char outside the target node's border
911
+ const graphDir = graph.config.graphDirection;
912
+ const entryDir = graphDir === 'TD' ? Up : Left;
913
+ const dc = getNodeAttachmentPoint(graph, edge.to, entryDir);
914
+ // Offset 1 char away from the box border so arrowhead sits outside the box
915
+ if (graphDir === 'TD')
916
+ dc.y -= 1;
917
+ else
918
+ dc.x -= 1;
919
+ // Draw arrowhead
920
+ let char;
921
+ if (!graph.config.useAscii) {
922
+ if (dirEquals(dir, Up))
923
+ char = '▲';
924
+ else if (dirEquals(dir, Down))
925
+ char = '▼';
926
+ else if (dirEquals(dir, Left))
927
+ char = '◄';
928
+ else if (dirEquals(dir, Right))
929
+ char = '►';
930
+ else
931
+ char = '▼'; // default
932
+ }
933
+ else {
934
+ if (dirEquals(dir, Up))
935
+ char = '^';
936
+ else if (dirEquals(dir, Down))
937
+ char = 'v';
938
+ else if (dirEquals(dir, Left))
939
+ char = '<';
940
+ else if (dirEquals(dir, Right))
941
+ char = '>';
942
+ else
943
+ char = 'v'; // default
944
+ }
945
+ canvas[dc.x][dc.y] = char;
946
+ return canvas;
947
+ }
948
+ /**
949
+ * Draw the junction character where bundled edges merge/split.
950
+ *
951
+ * Analyzes actual connecting directions to choose the correct character:
952
+ * - ┼ (cross): lines from all 4 directions
953
+ * - ┬ (T down): lines from left, right, and down
954
+ * - ┴ (T up): lines from left, right, and up
955
+ * - ├ (T right): lines from up, down, and right
956
+ * - ┤ (T left): lines from up, down, and left
957
+ */
958
+ function drawJunctionCharacter(graph, bundle) {
959
+ const canvas = copyCanvas(graph.canvas);
960
+ if (!bundle.junctionPoint)
961
+ return canvas;
962
+ const dc = gridToDrawingCoord(graph, bundle.junctionPoint);
963
+ const useAscii = graph.config.useAscii;
964
+ // Analyze what directions actually connect to the junction
965
+ let hasUp = false;
966
+ let hasDown = false;
967
+ let hasLeft = false;
968
+ let hasRight = false;
969
+ // Check shared path direction (where the line continues to/from the shared node)
970
+ if (bundle.sharedPath.length >= 2) {
971
+ // For fan-in: shared path goes FROM junction TO target (index 0 is junction)
972
+ // For fan-out: shared path goes FROM source TO junction (last index is junction)
973
+ const junctionIdx = bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1;
974
+ const adjacentIdx = bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2;
975
+ const sharedDir = determineDirection(bundle.sharedPath[junctionIdx], bundle.sharedPath[adjacentIdx]);
976
+ // This is the direction the shared path GOES from junction
977
+ if (dirEquals(sharedDir, Down))
978
+ hasDown = true;
979
+ else if (dirEquals(sharedDir, Up))
980
+ hasUp = true;
981
+ else if (dirEquals(sharedDir, Right))
982
+ hasRight = true;
983
+ else if (dirEquals(sharedDir, Left))
984
+ hasLeft = true;
985
+ }
986
+ // Check each edge's path direction at the junction
987
+ for (const edge of bundle.edges) {
988
+ if (edge.pathToJunction && edge.pathToJunction.length >= 2) {
989
+ // For fan-in: pathToJunction goes FROM source TO junction (last is junction)
990
+ // For fan-out: pathToJunction goes FROM junction TO target (first is junction)
991
+ const junctionIdx = bundle.type === 'fan-in'
992
+ ? edge.pathToJunction.length - 1
993
+ : 0;
994
+ const adjacentIdx = bundle.type === 'fan-in'
995
+ ? edge.pathToJunction.length - 2
996
+ : 1;
997
+ const arrivalDir = determineDirection(edge.pathToJunction[adjacentIdx], edge.pathToJunction[junctionIdx]);
998
+ // This is the direction the edge ARRIVES at junction from
999
+ // e.g., if arrivalDir is Right, the line comes FROM the left
1000
+ if (dirEquals(arrivalDir, Down))
1001
+ hasUp = true; // arrived going down = came from up
1002
+ else if (dirEquals(arrivalDir, Up))
1003
+ hasDown = true;
1004
+ else if (dirEquals(arrivalDir, Right))
1005
+ hasLeft = true;
1006
+ else if (dirEquals(arrivalDir, Left))
1007
+ hasRight = true;
1008
+ }
1009
+ }
1010
+ // Select character based on connected directions
1011
+ let char;
1012
+ if (!useAscii) {
1013
+ if (hasUp && hasDown && hasLeft && hasRight) {
1014
+ char = '┼'; // cross - all 4 directions
1015
+ }
1016
+ else if (hasDown && hasLeft && hasRight && !hasUp) {
1017
+ char = '┬'; // T pointing down
1018
+ }
1019
+ else if (hasUp && hasLeft && hasRight && !hasDown) {
1020
+ char = '┴'; // T pointing up
1021
+ }
1022
+ else if (hasUp && hasDown && hasRight && !hasLeft) {
1023
+ char = '├'; // T pointing right
1024
+ }
1025
+ else if (hasUp && hasDown && hasLeft && !hasRight) {
1026
+ char = '┤'; // T pointing left
1027
+ }
1028
+ else if (hasLeft && hasRight) {
1029
+ char = '─'; // horizontal only
1030
+ }
1031
+ else if (hasUp && hasDown) {
1032
+ char = '│'; // vertical only
1033
+ }
1034
+ else if (hasDown && hasRight) {
1035
+ char = '┌'; // corner
1036
+ }
1037
+ else if (hasDown && hasLeft) {
1038
+ char = '┐';
1039
+ }
1040
+ else if (hasUp && hasRight) {
1041
+ char = '└';
1042
+ }
1043
+ else if (hasUp && hasLeft) {
1044
+ char = '┘';
1045
+ }
1046
+ else {
1047
+ char = '┼'; // fallback
1048
+ }
1049
+ }
1050
+ else {
1051
+ char = '+';
1052
+ }
1053
+ canvas[dc.x][dc.y] = char;
1054
+ return canvas;
1055
+ }
1056
+ // ============================================================================
1057
+ // Subgraph drawing
1058
+ // ============================================================================
1059
+ /** Draw a subgraph border rectangle. */
1060
+ export function drawSubgraphBox(sg, graph) {
1061
+ const width = sg.maxX - sg.minX;
1062
+ const height = sg.maxY - sg.minY;
1063
+ if (width <= 0 || height <= 0)
1064
+ return mkCanvas(0, 0);
1065
+ const from = { x: 0, y: 0 };
1066
+ const to = { x: width, y: height };
1067
+ const canvas = mkCanvas(width, height);
1068
+ if (!graph.config.useAscii) {
1069
+ for (let x = from.x + 1; x < to.x; x++)
1070
+ canvas[x][from.y] = '─';
1071
+ for (let x = from.x + 1; x < to.x; x++)
1072
+ canvas[x][to.y] = '─';
1073
+ for (let y = from.y + 1; y < to.y; y++)
1074
+ canvas[from.x][y] = '│';
1075
+ for (let y = from.y + 1; y < to.y; y++)
1076
+ canvas[to.x][y] = '│';
1077
+ canvas[from.x][from.y] = '┌';
1078
+ canvas[to.x][from.y] = '┐';
1079
+ canvas[from.x][to.y] = '└';
1080
+ canvas[to.x][to.y] = '┘';
1081
+ }
1082
+ else {
1083
+ for (let x = from.x + 1; x < to.x; x++)
1084
+ canvas[x][from.y] = '-';
1085
+ for (let x = from.x + 1; x < to.x; x++)
1086
+ canvas[x][to.y] = '-';
1087
+ for (let y = from.y + 1; y < to.y; y++)
1088
+ canvas[from.x][y] = '|';
1089
+ for (let y = from.y + 1; y < to.y; y++)
1090
+ canvas[to.x][y] = '|';
1091
+ canvas[from.x][from.y] = '+';
1092
+ canvas[to.x][from.y] = '+';
1093
+ canvas[from.x][to.y] = '+';
1094
+ canvas[to.x][to.y] = '+';
1095
+ }
1096
+ return canvas;
1097
+ }
1098
+ /** Draw a subgraph label centered in its header area. Supports multi-line labels. */
1099
+ export function drawSubgraphLabel(sg, graph) {
1100
+ const width = sg.maxX - sg.minX;
1101
+ const height = sg.maxY - sg.minY;
1102
+ if (width <= 0 || height <= 0)
1103
+ return [mkCanvas(0, 0), { x: 0, y: 0 }];
1104
+ const canvas = mkCanvas(width, height);
1105
+ // Support multi-line subgraph labels
1106
+ const lines = splitLines(sg.name);
1107
+ // Start at row 1 inside subgraph, expand downward for multiple lines
1108
+ for (let i = 0; i < lines.length; i++) {
1109
+ const line = lines[i];
1110
+ const labelY = 1 + i;
1111
+ let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2);
1112
+ if (labelX < 1)
1113
+ labelX = 1;
1114
+ const cells = toCells(line);
1115
+ for (let j = 0; j < cells.length; j++) {
1116
+ const cell = cells[j];
1117
+ if (cell === WIDE_PAD)
1118
+ continue; // written atomically with its lead
1119
+ const cx = labelX + j;
1120
+ const wide = cells[j + 1] === WIDE_PAD;
1121
+ // Keep wide-glyph pairs atomic at the subgraph edge
1122
+ if (cx + (wide ? 1 : 0) >= width || labelY >= height)
1123
+ continue;
1124
+ canvas[cx][labelY] = cell;
1125
+ if (wide)
1126
+ canvas[cx + 1][labelY] = WIDE_PAD;
1127
+ }
1128
+ }
1129
+ return [canvas, { x: sg.minX, y: sg.minY }];
1130
+ }
1131
+ // ============================================================================
1132
+ // Top-level draw orchestrator
1133
+ // ============================================================================
1134
+ /** Sort subgraphs by nesting depth (shallowest first) for correct layered rendering. */
1135
+ function sortSubgraphsByDepth(subgraphs) {
1136
+ function getDepth(sg) {
1137
+ return sg.parent === null ? 0 : 1 + getDepth(sg.parent);
1138
+ }
1139
+ const sorted = [...subgraphs];
1140
+ sorted.sort((a, b) => getDepth(a) - getDepth(b));
1141
+ return sorted;
1142
+ }
1143
+ // ============================================================================
1144
+ // Role tracking helpers for colored output
1145
+ // ============================================================================
1146
+ /**
1147
+ * Fill roles for all non-space characters in a canvas region.
1148
+ * Used after drawing a layer to record what role those characters have.
1149
+ */
1150
+ function fillRolesFromCanvas(roleCanvas, canvas, offset, role) {
1151
+ for (let x = 0; x < canvas.length; x++) {
1152
+ for (let y = 0; y < (canvas[0]?.length ?? 0); y++) {
1153
+ const char = canvas[x]?.[y];
1154
+ if (char && char !== ' ') {
1155
+ const rx = x + offset.x;
1156
+ const ry = y + offset.y;
1157
+ // Use setRole which auto-expands the role canvas if needed
1158
+ if (rx >= 0 && ry >= 0) {
1159
+ setRole(roleCanvas, rx, ry, role);
1160
+ }
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ /**
1166
+ * Fill roles for multiple canvases with the same role.
1167
+ */
1168
+ function fillRolesFromCanvases(roleCanvas, canvases, offset, role) {
1169
+ for (const canvas of canvases) {
1170
+ fillRolesFromCanvas(roleCanvas, canvas, offset, role);
1171
+ }
1172
+ }
1173
+ /**
1174
+ * Special handling for node boxes: border chars get 'border' role, text gets 'text' role.
1175
+ * Common final-state border characters (`#` and `=`) count only on the outer edge,
1176
+ * so identical characters inside ordinary node labels retain the text role.
1177
+ */
1178
+ function fillRolesForNodeBox(roleCanvas, canvas, offset) {
1179
+ const isBorderChar = (c) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c);
1180
+ const isStateEndBorderChar = (c) => /^[╔╗╚╝═║#=‖]$/.test(c);
1181
+ const maxX = canvas.length - 1;
1182
+ const maxY = (canvas[0]?.length ?? 1) - 1;
1183
+ for (let x = 0; x < canvas.length; x++) {
1184
+ for (let y = 0; y < (canvas[0]?.length ?? 0); y++) {
1185
+ const char = canvas[x]?.[y];
1186
+ if (char && char !== ' ') {
1187
+ const rx = x + offset.x;
1188
+ const ry = y + offset.y;
1189
+ // Use setRole which auto-expands the role canvas if needed
1190
+ if (rx >= 0 && ry >= 0) {
1191
+ const isOuterEdge = x === 0 || x === maxX || y === 0 || y === maxY;
1192
+ const role = isBorderChar(char) || (isOuterEdge && isStateEndBorderChar(char)) ? 'border' : 'text';
1193
+ setRole(roleCanvas, rx, ry, role);
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ /**
1200
+ * Main draw function — renders the entire graph onto the canvas.
1201
+ * Drawing order matters for correct layering:
1202
+ * 1. Subgraph borders (bottom layer)
1203
+ * 2. Node boxes
1204
+ * 3. Edge paths (lines)
1205
+ * 4. Edge corners
1206
+ * 5. Arrowheads
1207
+ * 6. Box-start junctions
1208
+ * 7. Edge labels
1209
+ * 8. Subgraph labels (top layer)
1210
+ *
1211
+ * Also fills the roleCanvas with character roles for colored output.
1212
+ */
1213
+ export function drawGraph(graph) {
1214
+ const useAscii = graph.config.useAscii;
1215
+ const zero = { x: 0, y: 0 };
1216
+ // Draw subgraph borders
1217
+ const sortedSgs = sortSubgraphsByDepth(graph.subgraphs);
1218
+ for (const sg of sortedSgs) {
1219
+ const sgCanvas = drawSubgraphBox(sg, graph);
1220
+ const offset = { x: sg.minX, y: sg.minY };
1221
+ graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, sgCanvas);
1222
+ // Subgraph borders get 'border' role
1223
+ fillRolesFromCanvas(graph.roleCanvas, sgCanvas, offset, 'border');
1224
+ }
1225
+ // Draw node boxes
1226
+ for (const node of graph.nodes) {
1227
+ if (!node.drawn && node.drawingCoord && node.drawing) {
1228
+ graph.canvas = mergeCanvases(graph.canvas, node.drawingCoord, useAscii, node.drawing);
1229
+ // Node boxes: detect border vs text characters
1230
+ fillRolesForNodeBox(graph.roleCanvas, node.drawing, node.drawingCoord);
1231
+ node.drawn = true;
1232
+ }
1233
+ }
1234
+ // Collect all edge drawing layers
1235
+ const lineCanvases = [];
1236
+ const cornerCanvases = [];
1237
+ const arrowHeadEndCanvases = [];
1238
+ const arrowHeadStartCanvases = [];
1239
+ const boxStartCanvases = [];
1240
+ const labelCanvases = [];
1241
+ const junctionCanvases = [];
1242
+ // Track which bundles have been processed (to draw shared paths only once)
1243
+ const processedBundles = new Set();
1244
+ for (const edge of graph.edges) {
1245
+ // Handle bundled edges specially
1246
+ if (edge.bundle && edge.pathToJunction) {
1247
+ const bundle = edge.bundle;
1248
+ // Draw this edge's individual path (source → junction for fan-in, junction → target for fan-out)
1249
+ const [pathC, boxStartC, , , cornersC, labelC] = drawBundledEdgeSegment(graph, edge, bundle);
1250
+ lineCanvases.push(pathC);
1251
+ cornerCanvases.push(cornersC);
1252
+ boxStartCanvases.push(boxStartC);
1253
+ labelCanvases.push(labelC);
1254
+ // Draw the bundle's shared path and arrowhead only once
1255
+ if (!processedBundles.has(bundle)) {
1256
+ processedBundles.add(bundle);
1257
+ // Draw shared path (junction → target for fan-in, source → junction for fan-out)
1258
+ const [sharedPathC, sharedCornersC] = drawBundleSharedPath(graph, bundle);
1259
+ lineCanvases.push(sharedPathC);
1260
+ cornerCanvases.push(sharedCornersC);
1261
+ // Draw arrowhead at target for fan-in (once for all edges in bundle)
1262
+ if (bundle.type === 'fan-in') {
1263
+ const arrowHeadC = drawBundleArrowhead(graph, bundle);
1264
+ arrowHeadEndCanvases.push(arrowHeadC);
1265
+ }
1266
+ // Draw junction character
1267
+ const junctionC = drawJunctionCharacter(graph, bundle);
1268
+ junctionCanvases.push(junctionC);
1269
+ }
1270
+ // For fan-out bundles, draw arrowhead at each target
1271
+ if (bundle.type === 'fan-out' && edge.hasArrowEnd) {
1272
+ const arrowHeadC = drawBundledEdgeArrowhead(graph, edge);
1273
+ arrowHeadEndCanvases.push(arrowHeadC);
1274
+ }
1275
+ }
1276
+ else {
1277
+ // Non-bundled edge: use standard drawing
1278
+ const [pathC, boxStartC, arrowHeadEndC, arrowHeadStartC, cornersC, labelC] = drawArrow(graph, edge);
1279
+ lineCanvases.push(pathC);
1280
+ cornerCanvases.push(cornersC);
1281
+ arrowHeadEndCanvases.push(arrowHeadEndC);
1282
+ arrowHeadStartCanvases.push(arrowHeadStartC);
1283
+ boxStartCanvases.push(boxStartC);
1284
+ labelCanvases.push(labelC);
1285
+ }
1286
+ }
1287
+ // Merge edge layers in order and track roles
1288
+ // Note: arrowHeadStart is merged AFTER boxStart so bidirectional arrows
1289
+ // properly overwrite the box connector at the source end
1290
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...lineCanvases);
1291
+ fillRolesFromCanvases(graph.roleCanvas, lineCanvases, zero, 'line');
1292
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...cornerCanvases);
1293
+ fillRolesFromCanvases(graph.roleCanvas, cornerCanvases, zero, 'corner');
1294
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...junctionCanvases);
1295
+ fillRolesFromCanvases(graph.roleCanvas, junctionCanvases, zero, 'junction');
1296
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadEndCanvases);
1297
+ fillRolesFromCanvases(graph.roleCanvas, arrowHeadEndCanvases, zero, 'arrow');
1298
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...boxStartCanvases);
1299
+ fillRolesFromCanvases(graph.roleCanvas, boxStartCanvases, zero, 'junction');
1300
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadStartCanvases);
1301
+ fillRolesFromCanvases(graph.roleCanvas, arrowHeadStartCanvases, zero, 'arrow');
1302
+ graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...labelCanvases);
1303
+ fillRolesFromCanvases(graph.roleCanvas, labelCanvases, zero, 'text');
1304
+ // Draw subgraph labels last (on top)
1305
+ for (const sg of graph.subgraphs) {
1306
+ if (sg.nodes.length === 0)
1307
+ continue;
1308
+ const [labelCanvas, offset] = drawSubgraphLabel(sg, graph);
1309
+ graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, labelCanvas);
1310
+ fillRolesFromCanvas(graph.roleCanvas, labelCanvas, offset, 'text');
1311
+ }
1312
+ return graph.canvas;
1313
+ }