@oh-my-pi/pi-utils 16.0.6 → 16.0.8

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,460 @@
1
+ // ============================================================================
2
+ // ASCII renderer — sequence diagrams
3
+ //
4
+ // Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout.
5
+ // Each actor occupies a column with a vertical lifeline; messages are horizontal
6
+ // arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups.
7
+ //
8
+ // Layout is fundamentally different from flowcharts — no grid or A* pathfinding.
9
+ // Instead: actors → columns, messages → rows, all positioned linearly.
10
+ // ============================================================================
11
+
12
+ import { parseSequenceDiagram } from '../sequence/parser'
13
+ import type { SequenceDiagram, Block } from '../sequence/types'
14
+ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
15
+ import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
16
+ import { splitLines, maxLineWidth, lineCount } from './multiline-utils'
17
+ import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
18
+
19
+ /** Classify a box-drawing character as 'border' or 'text'. */
20
+ function classifyBoxChar(ch: string): CharRole {
21
+ if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border'
22
+ return 'text'
23
+ }
24
+
25
+ /**
26
+ * Render a Mermaid sequence diagram to ASCII/Unicode text.
27
+ *
28
+ * Pipeline: parse → layout (columns + rows) → draw onto canvas → string.
29
+ */
30
+ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {
31
+ const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))
32
+ const diagram = parseSequenceDiagram(lines)
33
+
34
+ if (diagram.actors.length === 0) return ''
35
+
36
+ const useAscii = config.useAscii
37
+
38
+ // Box-drawing characters
39
+ const H = useAscii ? '-' : '─'
40
+ const V = useAscii ? '|' : '│'
41
+ const TL = useAscii ? '+' : '┌'
42
+ const TR = useAscii ? '+' : '┐'
43
+ const BL = useAscii ? '+' : '└'
44
+ const BR = useAscii ? '+' : '┘'
45
+ const JT = useAscii ? '+' : '┬' // top junction on lifeline
46
+ const JB = useAscii ? '+' : '┴' // bottom junction on lifeline
47
+ const JL = useAscii ? '+' : '├' // left junction
48
+ const JR = useAscii ? '+' : '┤' // right junction
49
+
50
+ // ---- LAYOUT: compute lifeline X positions ----
51
+
52
+ const actorIdx = new Map<string, number>()
53
+ diagram.actors.forEach((a, i) => actorIdx.set(a.id, i))
54
+
55
+ const boxPad = 1
56
+ // Use max line width for multi-line actor labels
57
+ const actorBoxWidths = diagram.actors.map(a => maxLineWidth(a.label) + 2 * boxPad + 2)
58
+ const halfBox = actorBoxWidths.map(w => Math.ceil(w / 2))
59
+ // Calculate actor box heights based on number of lines in label
60
+ const actorBoxHeights = diagram.actors.map(a => lineCount(a.label) + 2) // lines + top/bottom border
61
+ const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning
62
+
63
+ // Compute minimum gap between adjacent lifelines based on message labels.
64
+ // For messages spanning multiple actors, distribute the required width across gaps.
65
+ const adjMaxWidth: number[] = new Array(Math.max(diagram.actors.length - 1, 0)).fill(0)
66
+
67
+ for (const msg of diagram.messages) {
68
+ const fi = actorIdx.get(msg.from)!
69
+ const ti = actorIdx.get(msg.to)!
70
+ if (fi === ti) continue // self-messages don't affect spacing
71
+ const lo = Math.min(fi, ti)
72
+ const hi = Math.max(fi, ti)
73
+ // Required gap per span = (max line width + arrow decorations) / number of gaps
74
+ const needed = maxLineWidth(msg.label) + 4
75
+ const numGaps = hi - lo
76
+ const perGap = Math.ceil(needed / numGaps)
77
+ for (let g = lo; g < hi; g++) {
78
+ adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap)
79
+ }
80
+ }
81
+
82
+ // Compute lifeline x-positions (greedy left-to-right)
83
+ const llX: number[] = [halfBox[0]!]
84
+ for (let i = 1; i < diagram.actors.length; i++) {
85
+ const gap = Math.max(
86
+ halfBox[i - 1]! + halfBox[i]! + 2,
87
+ adjMaxWidth[i - 1]! + 2,
88
+ 10,
89
+ )
90
+ llX[i] = llX[i - 1]! + gap
91
+ }
92
+
93
+ // ---- LAYOUT: compute vertical positions for messages ----
94
+
95
+ // For each message index, track the y where its arrow is drawn.
96
+ // Also track block start/end y positions and divider y positions.
97
+ const msgArrowY: number[] = []
98
+ const msgLabelY: number[] = []
99
+ const blockStartY = new Map<number, number>()
100
+ const blockEndY = new Map<number, number>()
101
+ const divYMap = new Map<string, number>() // "blockIdx:divIdx" → y
102
+ const notePositions: Array<{ x: number; y: number; width: number; height: number; lines: string[] }> = []
103
+
104
+ let curY = actorBoxH // start right below header boxes
105
+
106
+ for (let m = 0; m < diagram.messages.length; m++) {
107
+ // Block openings at this message
108
+ for (let b = 0; b < diagram.blocks.length; b++) {
109
+ if (diagram.blocks[b]!.startIndex === m) {
110
+ curY += 2 // 1 blank + 1 header row
111
+ blockStartY.set(b, curY - 1)
112
+ }
113
+ }
114
+
115
+ // Dividers at this message index
116
+ for (let b = 0; b < diagram.blocks.length; b++) {
117
+ for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) {
118
+ if (diagram.blocks[b]!.dividers[d]!.index === m) {
119
+ curY += 1
120
+ divYMap.set(`${b}:${d}`, curY)
121
+ curY += 1
122
+ }
123
+ }
124
+ }
125
+
126
+ curY += 1 // blank row before message
127
+
128
+ const msg = diagram.messages[m]!
129
+ const isSelf = msg.from === msg.to
130
+
131
+ // Calculate height needed for multi-line message labels
132
+ const msgLineCount = lineCount(msg.label)
133
+
134
+ if (isSelf) {
135
+ // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm
136
+ msgLabelY[m] = curY + 1
137
+ msgArrowY[m] = curY
138
+ curY += 2 + msgLineCount // top-arm + label lines + bottom-arm
139
+ } else {
140
+ // Normal message: label row(s) then arrow row
141
+ msgLabelY[m] = curY
142
+ msgArrowY[m] = curY + msgLineCount // arrow goes after all label lines
143
+ curY += msgLineCount + 1 // label lines + arrow row
144
+ }
145
+
146
+ // Notes after this message
147
+ for (let n = 0; n < diagram.notes.length; n++) {
148
+ if (diagram.notes[n]!.afterIndex === m) {
149
+ curY += 1
150
+ const note = diagram.notes[n]!
151
+ const nLines = splitLines(note.text)
152
+ const nWidth = Math.max(...nLines.map(l => displayWidth(l))) + 4
153
+ const nHeight = nLines.length + 2
154
+
155
+ // Determine x position based on note.position
156
+ const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
157
+ let nx: number
158
+ if (note.position === 'left') {
159
+ nx = llX[aIdx]! - nWidth - 1
160
+ } else if (note.position === 'right') {
161
+ nx = llX[aIdx]! + 2
162
+ } else {
163
+ // 'over' — center over actor(s)
164
+ if (note.actorIds.length >= 2) {
165
+ const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx
166
+ nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2)
167
+ } else {
168
+ nx = llX[aIdx]! - Math.floor(nWidth / 2)
169
+ }
170
+ }
171
+ nx = Math.max(0, nx)
172
+
173
+ notePositions.push({ x: nx, y: curY, width: nWidth, height: nHeight, lines: nLines })
174
+ curY += nHeight
175
+ }
176
+ }
177
+
178
+ // Block closings after this message
179
+ for (let b = 0; b < diagram.blocks.length; b++) {
180
+ if (diagram.blocks[b]!.endIndex === m) {
181
+ curY += 1
182
+ blockEndY.set(b, curY)
183
+ curY += 1
184
+ }
185
+ }
186
+ }
187
+
188
+ curY += 1 // gap before footer
189
+ const footerY = curY
190
+ const totalH = footerY + actorBoxH
191
+
192
+ // Total canvas width
193
+ const lastLL = llX[llX.length - 1] ?? 0
194
+ const lastHalf = halfBox[halfBox.length - 1] ?? 0
195
+ let totalW = lastLL + lastHalf + 2
196
+
197
+ // Ensure canvas is wide enough for self-message labels and notes
198
+ for (let m = 0; m < diagram.messages.length; m++) {
199
+ const msg = diagram.messages[m]!
200
+ if (msg.from === msg.to) {
201
+ const fi = actorIdx.get(msg.from)!
202
+ const selfRight = llX[fi]! + 6 + 2 + displayWidth(msg.label)
203
+ totalW = Math.max(totalW, selfRight + 1)
204
+ }
205
+ }
206
+ for (const np of notePositions) {
207
+ totalW = Math.max(totalW, np.x + np.width + 1)
208
+ }
209
+
210
+ const canvas = mkCanvas(totalW, totalH - 1)
211
+ const rc = mkRoleCanvas(totalW, totalH - 1)
212
+
213
+ /** Set a character on the canvas and track its role. */
214
+ function setC(x: number, y: number, ch: string, role: CharRole): void {
215
+ if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {
216
+ canvas[x]![y] = ch
217
+ setRole(rc, x, y, role)
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write label cells starting at x0, keeping wide-glyph pairs atomic:
223
+ * a glyph and its WIDE_PAD continuation land together or not at all,
224
+ * clamped to [minX, maxXExcl) and the canvas bounds.
225
+ */
226
+ function setCells(x0: number, y: number, cells: string[], role: CharRole, minX = 0, maxXExcl = canvas.length): void {
227
+ const limit = Math.min(maxXExcl, canvas.length)
228
+ for (let i = 0; i < cells.length; i++) {
229
+ const cell = cells[i]!
230
+ if (cell === WIDE_PAD) continue // written atomically with its lead
231
+ const x = x0 + i
232
+ const wide = cells[i + 1] === WIDE_PAD
233
+ if (x < minX || x + (wide ? 1 : 0) >= limit) continue
234
+ setC(x, y, cell, role)
235
+ if (wide) setC(x + 1, y, WIDE_PAD, role)
236
+ }
237
+ }
238
+
239
+ // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ----
240
+
241
+ function drawActorBox(cx: number, topY: number, label: string): void {
242
+ const lines = splitLines(label)
243
+ const maxW = maxLineWidth(label)
244
+ const w = maxW + 2 * boxPad + 2
245
+ const h = lines.length + 2 // lines + top/bottom border
246
+ const left = cx - Math.floor(w / 2)
247
+
248
+ // Top border
249
+ setC(left, topY, TL, 'border')
250
+ for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border')
251
+ setC(left + w - 1, topY, TR, 'border')
252
+
253
+ // Content lines (centered horizontally within the box)
254
+ for (let i = 0; i < lines.length; i++) {
255
+ const row = topY + 1 + i
256
+ setC(left, row, V, 'border')
257
+ setC(left + w - 1, row, V, 'border')
258
+ // Center this line within the box
259
+ const line = lines[i]!
260
+ const cells = toCells(line)
261
+ const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2)
262
+ setCells(ls, row, cells, 'text')
263
+ }
264
+
265
+ // Bottom border
266
+ const bottomY = topY + h - 1
267
+ setC(left, bottomY, BL, 'border')
268
+ for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border')
269
+ setC(left + w - 1, bottomY, BR, 'border')
270
+ }
271
+
272
+ // ---- DRAW: lifelines ----
273
+
274
+ for (let i = 0; i < diagram.actors.length; i++) {
275
+ const x = llX[i]!
276
+ for (let y = actorBoxH; y <= footerY; y++) {
277
+ setC(x, y, V, 'line')
278
+ }
279
+ }
280
+
281
+ // ---- DRAW: actor header + footer boxes (drawn over lifelines) ----
282
+
283
+ for (let i = 0; i < diagram.actors.length; i++) {
284
+ const actor = diagram.actors[i]!
285
+ drawActorBox(llX[i]!, 0, actor.label)
286
+ drawActorBox(llX[i]!, footerY, actor.label)
287
+
288
+ // Lifeline junctions on box borders (Unicode only)
289
+ if (!useAscii) {
290
+ setC(llX[i]!, actorBoxH - 1, JT, 'junction')
291
+ setC(llX[i]!, footerY, JB, 'junction')
292
+ }
293
+ }
294
+
295
+ // ---- DRAW: messages ----
296
+
297
+ for (let m = 0; m < diagram.messages.length; m++) {
298
+ const msg = diagram.messages[m]!
299
+ const fi = actorIdx.get(msg.from)!
300
+ const ti = actorIdx.get(msg.to)!
301
+ const fromX = llX[fi]!
302
+ const toX = llX[ti]!
303
+ const isSelf = fi === ti
304
+ const isDashed = msg.lineStyle === 'dashed'
305
+ const isFilled = msg.arrowHead === 'filled'
306
+
307
+ // Arrow line character (solid vs dashed)
308
+ const lineChar = isDashed ? (useAscii ? '.' : '╌') : H
309
+
310
+ if (isSelf) {
311
+ // Self-message: 3-row loop to the right of the lifeline
312
+ // ├──┐ (row 0 = msgArrowY)
313
+ // │ │ Label (row 1)
314
+ // │◄─┘ (row 2)
315
+ const y0 = msgArrowY[m]!
316
+ const loopW = Math.max(4, 4)
317
+
318
+ // Row 0: start junction + horizontal + top-right corner
319
+ setC(fromX, y0, JL, 'junction')
320
+ for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0, lineChar, 'line')
321
+ setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner')
322
+
323
+ // Row 1: vertical on right side + label
324
+ setC(fromX + loopW, y0 + 1, V, 'line')
325
+ const labelX = fromX + loopW + 2
326
+ const selfCells = toCells(msg.label)
327
+ setCells(labelX, y0 + 1, selfCells, 'text', 0, totalW)
328
+
329
+ // Row 2: arrow-back + horizontal + bottom-right corner
330
+ const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁')
331
+ setC(fromX, y0 + 2, arrowChar, 'arrow')
332
+ for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, 'line')
333
+ setC(fromX + loopW, y0 + 2, useAscii ? '+' : '┘', 'corner')
334
+ } else {
335
+ // Normal message: label on row above, arrow on row below
336
+ const labelY = msgLabelY[m]!
337
+ const arrowY = msgArrowY[m]!
338
+ const leftToRight = fromX < toX
339
+
340
+ // Draw label centered between the two lifelines (supports multi-line)
341
+ const midX = Math.floor((fromX + toX) / 2)
342
+ const msgLines = splitLines(msg.label)
343
+
344
+ for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {
345
+ const cells = toCells(msgLines[lineIdx]!)
346
+ const labelStart = midX - Math.floor(cells.length / 2)
347
+ const y = labelY + lineIdx
348
+ setCells(labelStart, y, cells, 'text', 0, totalW)
349
+ }
350
+
351
+ // Draw arrow line
352
+ if (leftToRight) {
353
+ for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, 'line')
354
+ // Arrowhead at destination
355
+ const ah = isFilled ? (useAscii ? '>' : '▶') : (useAscii ? '>' : '▷')
356
+ setC(toX, arrowY, ah, 'arrow')
357
+ } else {
358
+ for (let x = toX + 1; x < fromX; x++) setC(x, arrowY, lineChar, 'line')
359
+ const ah = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁')
360
+ setC(toX, arrowY, ah, 'arrow')
361
+ }
362
+ }
363
+ }
364
+
365
+ // ---- DRAW: blocks (loop, alt, opt, par, etc.) ----
366
+
367
+ for (let b = 0; b < diagram.blocks.length; b++) {
368
+ const block = diagram.blocks[b]!
369
+ const topY = blockStartY.get(b)
370
+ const botY = blockEndY.get(b)
371
+ if (topY === undefined || botY === undefined) continue
372
+
373
+ // Find the leftmost/rightmost lifelines involved in this block's messages
374
+ let minLX = totalW
375
+ let maxLX = 0
376
+ for (let m = block.startIndex; m <= block.endIndex; m++) {
377
+ if (m >= diagram.messages.length) break
378
+ const msg = diagram.messages[m]!
379
+ const f = actorIdx.get(msg.from) ?? 0
380
+ const t = actorIdx.get(msg.to) ?? 0
381
+ minLX = Math.min(minLX, llX[Math.min(f, t)]!)
382
+ maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)
383
+ }
384
+
385
+ const bLeft = Math.max(0, minLX - 4)
386
+ const bRight = Math.min(totalW - 1, maxLX + 4)
387
+
388
+ // Top border with block type label
389
+ setC(bLeft, topY, TL, 'border')
390
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border')
391
+ setC(bRight, topY, TR, 'border')
392
+ // Write block header label over the top border (supports multi-line)
393
+ const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type
394
+ const hdrLines = splitLines(hdrLabel)
395
+
396
+ for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) {
397
+ const cells = toCells(hdrLines[lineIdx]!)
398
+ setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight)
399
+ }
400
+
401
+ // Bottom border
402
+ setC(bLeft, botY, BL, 'border')
403
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border')
404
+ setC(bRight, botY, BR, 'border')
405
+
406
+ // Side borders
407
+ for (let y = topY + 1; y < botY; y++) {
408
+ setC(bLeft, y, V, 'border')
409
+ setC(bRight, y, V, 'border')
410
+ }
411
+
412
+ // Dividers
413
+ for (let d = 0; d < block.dividers.length; d++) {
414
+ const dY = divYMap.get(`${b}:${d}`)
415
+ if (dY === undefined) continue
416
+ const dashChar = isDashedH()
417
+ setC(bLeft, dY, JL, 'junction')
418
+ for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line')
419
+ setC(bRight, dY, JR, 'junction')
420
+ // Divider label
421
+ const dLabel = block.dividers[d]!.label
422
+ if (dLabel) {
423
+ const dCells = toCells(`[${dLabel}]`)
424
+ setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight)
425
+ }
426
+ }
427
+ }
428
+
429
+ // ---- DRAW: notes ----
430
+
431
+ for (const np of notePositions) {
432
+ // Ensure canvas is big enough
433
+ increaseSize(canvas, np.x + np.width, np.y + np.height)
434
+ increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height)
435
+ // Top border
436
+ setC(np.x, np.y, TL, 'border')
437
+ for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border')
438
+ setC(np.x + np.width - 1, np.y, TR, 'border')
439
+ // Content rows
440
+ for (let l = 0; l < np.lines.length; l++) {
441
+ const ly = np.y + 1 + l
442
+ setC(np.x, ly, V, 'border')
443
+ setC(np.x + np.width - 1, ly, V, 'border')
444
+ const cells = toCells(np.lines[l]!)
445
+ setCells(np.x + 2, ly, cells, 'text')
446
+ }
447
+ // Bottom border
448
+ const by = np.y + np.height - 1
449
+ setC(np.x, by, BL, 'border')
450
+ for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border')
451
+ setC(np.x + np.width - 1, by, BR, 'border')
452
+ }
453
+
454
+ return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
455
+
456
+ // ---- Helper: dashed horizontal character ----
457
+ function isDashedH(): string {
458
+ return useAscii ? '-' : '╌'
459
+ }
460
+ }
@@ -0,0 +1,27 @@
1
+ // ============================================================================
2
+ // Circle shape renderer — uses corner decorators instead of curves
3
+ // ============================================================================
4
+
5
+ import type { ShapeRenderer } from './types'
6
+ import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
7
+ import { getCorners } from './corners'
8
+
9
+ /**
10
+ * Circle shape renderer.
11
+ * Uses circle markers (◯) at corners to indicate circular shape semantics.
12
+ *
13
+ * Renders as:
14
+ * ◯─────────◯
15
+ * │ Label │
16
+ * ◯─────────◯
17
+ */
18
+ export const circleRenderer: ShapeRenderer = {
19
+ getDimensions: getBoxDimensions,
20
+
21
+ render(label, dimensions, options) {
22
+ const corners = getCorners('circle', options.useAscii)
23
+ return renderBox(label, dimensions, corners, options.useAscii)
24
+ },
25
+
26
+ getAttachmentPoint: getBoxAttachmentPoint,
27
+ }
@@ -0,0 +1,127 @@
1
+ // ============================================================================
2
+ // Corner character lookup table for shape rendering
3
+ // ============================================================================
4
+ //
5
+ // All shapes are rendered as rectangles with distinctive corner characters
6
+ // to indicate shape type. This eliminates diagonal characters while keeping
7
+ // shapes visually distinguishable.
8
+
9
+ import type { AsciiNodeShape } from '../types'
10
+
11
+ /**
12
+ * Corner characters for a shape in both Unicode and ASCII modes.
13
+ */
14
+ export interface CornerChars {
15
+ /** Top-left corner */
16
+ tl: string
17
+ /** Top-right corner */
18
+ tr: string
19
+ /** Bottom-left corner */
20
+ bl: string
21
+ /** Bottom-right corner */
22
+ br: string
23
+ }
24
+
25
+ /**
26
+ * Shape corner configuration with both Unicode and ASCII variants.
27
+ */
28
+ export interface ShapeCorners {
29
+ unicode: CornerChars
30
+ ascii: CornerChars
31
+ }
32
+
33
+ /**
34
+ * Corner character lookup table for all shape types.
35
+ *
36
+ * Design principles:
37
+ * - All shapes use orthogonal box structure (no diagonals)
38
+ * - Corner characters indicate shape semantics
39
+ * - ASCII fallbacks use available punctuation
40
+ */
41
+ export const SHAPE_CORNERS: Record<AsciiNodeShape, ShapeCorners> = {
42
+ // Standard rectangular shapes
43
+ rectangle: {
44
+ unicode: { tl: '┌', tr: '┐', bl: '└', br: '┘' },
45
+ ascii: { tl: '+', tr: '+', bl: '+', br: '+' },
46
+ },
47
+ rounded: {
48
+ unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' },
49
+ ascii: { tl: '.', tr: '.', bl: "'", br: "'" },
50
+ },
51
+
52
+ // Circular shapes - use circle markers at corners
53
+ circle: {
54
+ unicode: { tl: '◯', tr: '◯', bl: '◯', br: '◯' },
55
+ ascii: { tl: 'o', tr: 'o', bl: 'o', br: 'o' },
56
+ },
57
+ doublecircle: {
58
+ unicode: { tl: '◎', tr: '◎', bl: '◎', br: '◎' },
59
+ ascii: { tl: '@', tr: '@', bl: '@', br: '@' },
60
+ },
61
+
62
+ // Diamond - decision nodes
63
+ diamond: {
64
+ unicode: { tl: '◇', tr: '◇', bl: '◇', br: '◇' },
65
+ ascii: { tl: '<', tr: '>', bl: '<', br: '>' },
66
+ },
67
+
68
+ // Hexagon - process nodes (crop corners — monospace-safe, distinct from rectangle)
69
+ hexagon: {
70
+ unicode: { tl: '⌜', tr: '⌝', bl: '⌞', br: '⌟' },
71
+ ascii: { tl: '*', tr: '*', bl: '*', br: '*' },
72
+ },
73
+
74
+ // Stadium/pill shape
75
+ stadium: {
76
+ unicode: { tl: '(', tr: ')', bl: '(', br: ')' },
77
+ ascii: { tl: '(', tr: ')', bl: '(', br: ')' },
78
+ },
79
+
80
+ // Subroutine - double vertical bars
81
+ subroutine: {
82
+ unicode: { tl: '╟', tr: '╢', bl: '╟', br: '╢' },
83
+ ascii: { tl: '|', tr: '|', bl: '|', br: '|' },
84
+ },
85
+
86
+ // Cylinder/database
87
+ cylinder: {
88
+ unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' },
89
+ ascii: { tl: '.', tr: '.', bl: "'", br: "'" },
90
+ },
91
+
92
+ // Asymmetric/flag - pointer on left side
93
+ asymmetric: {
94
+ unicode: { tl: '▷', tr: '┐', bl: '▷', br: '┘' },
95
+ ascii: { tl: '>', tr: '+', bl: '>', br: '+' },
96
+ },
97
+
98
+ // Trapezoid - wider at bottom (top corners slope inward)
99
+ trapezoid: {
100
+ unicode: { tl: '/', tr: '\\', bl: '└', br: '┘' },
101
+ ascii: { tl: '/', tr: '\\', bl: '+', br: '+' },
102
+ },
103
+
104
+ // Trapezoid-alt - wider at top (bottom corners slope inward)
105
+ 'trapezoid-alt': {
106
+ unicode: { tl: '┌', tr: '┐', bl: '\\', br: '/' },
107
+ ascii: { tl: '+', tr: '+', bl: '\\', br: '/' },
108
+ },
109
+
110
+ // State diagram pseudostates (special handling, not corner-based)
111
+ 'state-start': {
112
+ unicode: { tl: '●', tr: '●', bl: '●', br: '●' },
113
+ ascii: { tl: '*', tr: '*', bl: '*', br: '*' },
114
+ },
115
+ 'state-end': {
116
+ unicode: { tl: '◉', tr: '◉', bl: '◉', br: '◉' },
117
+ ascii: { tl: '@', tr: '@', bl: '@', br: '@' },
118
+ },
119
+ }
120
+
121
+ /**
122
+ * Get corner characters for a shape type.
123
+ */
124
+ export function getCorners(shape: AsciiNodeShape, useAscii: boolean): CornerChars {
125
+ const corners = SHAPE_CORNERS[shape] ?? SHAPE_CORNERS.rectangle
126
+ return useAscii ? corners.ascii : corners.unicode
127
+ }
@@ -0,0 +1,27 @@
1
+ // ============================================================================
2
+ // Diamond shape renderer — uses corner decorators instead of diagonals
3
+ // ============================================================================
4
+
5
+ import type { ShapeRenderer } from './types'
6
+ import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
7
+ import { getCorners } from './corners'
8
+
9
+ /**
10
+ * Diamond shape renderer.
11
+ * Uses diamond markers (◇) at corners to indicate decision node semantics.
12
+ *
13
+ * Renders as:
14
+ * ◇─────────◇
15
+ * │ Label │
16
+ * ◇─────────◇
17
+ */
18
+ export const diamondRenderer: ShapeRenderer = {
19
+ getDimensions: getBoxDimensions,
20
+
21
+ render(label, dimensions, options) {
22
+ const corners = getCorners('diamond', options.useAscii)
23
+ return renderBox(label, dimensions, corners, options.useAscii)
24
+ },
25
+
26
+ getAttachmentPoint: getBoxAttachmentPoint,
27
+ }
@@ -0,0 +1,27 @@
1
+ // ============================================================================
2
+ // Hexagon shape renderer — uses corner decorators instead of diagonals
3
+ // ============================================================================
4
+
5
+ import type { ShapeRenderer } from './types'
6
+ import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
7
+ import { getCorners } from './corners'
8
+
9
+ /**
10
+ * Hexagon shape renderer.
11
+ * Uses hexagon markers (⬡) at corners to indicate process node semantics.
12
+ *
13
+ * Renders as:
14
+ * ⬡─────────⬡
15
+ * │ Label │
16
+ * ⬡─────────⬡
17
+ */
18
+ export const hexagonRenderer: ShapeRenderer = {
19
+ getDimensions: getBoxDimensions,
20
+
21
+ render(label, dimensions, options) {
22
+ const corners = getCorners('hexagon', options.useAscii)
23
+ return renderBox(label, dimensions, corners, options.useAscii)
24
+ },
25
+
26
+ getAttachmentPoint: getBoxAttachmentPoint,
27
+ }