@oh-my-pi/pi-utils 18.2.5 → 18.2.7

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