@oh-my-pi/pi-utils 16.0.7 → 16.0.9

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