@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,441 @@
1
+ // ============================================================================
2
+ // ASCII renderer — ER diagrams
3
+ //
4
+ // Renders erDiagram text to ASCII/Unicode art.
5
+ // Each entity is a 2-section box (header | attributes).
6
+ // Relationships are drawn as lines with crow's foot notation at endpoints.
7
+ //
8
+ // Layout: entities are placed in a grid pattern (multiple rows if needed).
9
+ // Relationship lines use Manhattan routing between entity boxes.
10
+ // ============================================================================
11
+
12
+ import { parseErDiagram } from '../er/parser'
13
+ import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types'
14
+ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
15
+ import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
16
+ import { drawMultiBox } from './draw'
17
+ import { splitLines } from './multiline-utils'
18
+ import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
19
+
20
+ /** Classify a character from a box drawing as 'border' or 'text'. */
21
+ function classifyBoxChar(ch: string): CharRole {
22
+ if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border'
23
+ return 'text'
24
+ }
25
+
26
+ // ============================================================================
27
+ // Entity box content
28
+ // ============================================================================
29
+
30
+ /** Format an attribute line: "PK type name" or "FK type name" etc. */
31
+ function formatAttribute(attr: ErAttribute): string {
32
+ const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' '
33
+ return `${keyStr}${attr.type} ${attr.name}`
34
+ }
35
+
36
+ /** Build sections for an entity box: [header], [attributes] */
37
+ function buildEntitySections(entity: ErEntity): string[][] {
38
+ // Support multi-line entity names
39
+ const header = splitLines(entity.label)
40
+ const attrs = entity.attributes.map(formatAttribute)
41
+ if (attrs.length === 0) return [header]
42
+ return [header, attrs]
43
+ }
44
+
45
+ // ============================================================================
46
+ // Crow's foot notation
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Returns the ASCII/Unicode characters for a crow's foot cardinality marker.
51
+ * Markers are drawn adjacent to entity boxes at relationship endpoints.
52
+ *
53
+ * Standard ER notation:
54
+ * one: ─┤├─ perpendicular line (exactly one)
55
+ * zero-one: ─○┤─ circle + perpendicular (zero or one)
56
+ * many: ─<>─ crow's foot (one or more)
57
+ * zero-many: ─○<─ circle + crow's foot (zero or more)
58
+ *
59
+ * @param card - The cardinality type
60
+ * @param useAscii - Use ASCII-only characters
61
+ * @param isRight - True if this marker is on the right side of the relationship
62
+ */
63
+ function getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string {
64
+ if (useAscii) {
65
+ switch (card) {
66
+ case 'one': return '|'
67
+ case 'zero-one': return 'o|'
68
+ case 'many': return isRight ? '<' : '>'
69
+ case 'zero-many': return isRight ? 'o<' : '>o'
70
+ }
71
+ } else {
72
+ // Use cleaner Unicode characters
73
+ switch (card) {
74
+ case 'one': return '│'
75
+ case 'zero-one': return '○│'
76
+ case 'many': return isRight ? '╟' : '╢'
77
+ case 'zero-many': return isRight ? '○╟' : '╢○'
78
+ }
79
+ }
80
+ }
81
+
82
+ // ============================================================================
83
+ // Positioned entity
84
+ // ============================================================================
85
+
86
+ interface PlacedEntity {
87
+ entity: ErEntity
88
+ sections: string[][]
89
+ x: number
90
+ y: number
91
+ width: number
92
+ height: number
93
+ }
94
+
95
+ // ============================================================================
96
+ // Connected Component Detection
97
+ // ============================================================================
98
+
99
+ /**
100
+ * Find connected components in the ER diagram using DFS.
101
+ * Treats relationships as undirected edges for connectivity.
102
+ *
103
+ * Returns an array of entity ID sets, one per connected component.
104
+ */
105
+ function findConnectedComponents(diagram: ErDiagram): Set<string>[] {
106
+ const visited = new Set<string>()
107
+ const components: Set<string>[] = []
108
+
109
+ // Build undirected adjacency list from relationships
110
+ const neighbors = new Map<string, Set<string>>()
111
+ for (const ent of diagram.entities) {
112
+ neighbors.set(ent.id, new Set())
113
+ }
114
+ for (const rel of diagram.relationships) {
115
+ neighbors.get(rel.entity1)?.add(rel.entity2)
116
+ neighbors.get(rel.entity2)?.add(rel.entity1)
117
+ }
118
+
119
+ // DFS to find each component
120
+ function dfs(startId: string, component: Set<string>): void {
121
+ const stack = [startId]
122
+ while (stack.length > 0) {
123
+ const nodeId = stack.pop()!
124
+ if (visited.has(nodeId)) continue
125
+
126
+ visited.add(nodeId)
127
+ component.add(nodeId)
128
+
129
+ for (const neighbor of neighbors.get(nodeId) ?? []) {
130
+ if (!visited.has(neighbor)) {
131
+ stack.push(neighbor)
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // Find all components
138
+ for (const ent of diagram.entities) {
139
+ if (!visited.has(ent.id)) {
140
+ const component = new Set<string>()
141
+ dfs(ent.id, component)
142
+ if (component.size > 0) {
143
+ components.push(component)
144
+ }
145
+ }
146
+ }
147
+
148
+ return components
149
+ }
150
+
151
+ // ============================================================================
152
+ // Layout and rendering
153
+ // ============================================================================
154
+
155
+ /**
156
+ * Render a Mermaid ER diagram to ASCII/Unicode text.
157
+ *
158
+ * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string.
159
+ */
160
+ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {
161
+ const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))
162
+ const diagram = parseErDiagram(lines)
163
+
164
+ if (diagram.entities.length === 0) return ''
165
+
166
+ const useAscii = config.useAscii
167
+ const hGap = 6 // horizontal gap between entity boxes
168
+ const vGap = 4 // vertical gap between rows (for relationship lines)
169
+ const componentGap = 6 // vertical gap between disconnected components
170
+
171
+ // --- Build entity box dimensions ---
172
+ const entitySections = new Map<string, string[][]>()
173
+ const entityBoxW = new Map<string, number>()
174
+ const entityBoxH = new Map<string, number>()
175
+ const entityById = new Map<string, ErEntity>()
176
+
177
+ for (const ent of diagram.entities) {
178
+ entityById.set(ent.id, ent)
179
+ const sections = buildEntitySections(ent)
180
+ entitySections.set(ent.id, sections)
181
+
182
+ let maxTextW = 0
183
+ for (const section of sections) {
184
+ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line))
185
+ }
186
+ const boxW = maxTextW + 4 // 2 border + 2 padding
187
+
188
+ let totalLines = 0
189
+ for (const section of sections) totalLines += Math.max(section.length, 1)
190
+ const boxH = totalLines + (sections.length - 1) + 2
191
+
192
+ entityBoxW.set(ent.id, boxW)
193
+ entityBoxH.set(ent.id, boxH)
194
+ }
195
+
196
+ // --- Find connected components ---
197
+ const components = findConnectedComponents(diagram)
198
+
199
+ // --- Layout: place each component, then stack components vertically ---
200
+ const placed = new Map<string, PlacedEntity>()
201
+ let currentY = 0
202
+
203
+ for (const component of components) {
204
+ // Get entities in this component (preserve original order for consistency)
205
+ const componentEntities = diagram.entities.filter(e => component.has(e.id))
206
+
207
+ // Layout entities within this component horizontally
208
+ // Use sqrt-based row limit for larger components
209
+ const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length)))
210
+
211
+ let currentX = 0
212
+ let maxRowH = 0
213
+ let colCount = 0
214
+ const componentStartY = currentY
215
+
216
+ for (const ent of componentEntities) {
217
+ const w = entityBoxW.get(ent.id)!
218
+ const h = entityBoxH.get(ent.id)!
219
+
220
+ if (colCount >= maxPerRow) {
221
+ // Wrap to next row within this component
222
+ currentY += maxRowH + vGap
223
+ currentX = 0
224
+ maxRowH = 0
225
+ colCount = 0
226
+ }
227
+
228
+ placed.set(ent.id, {
229
+ entity: ent,
230
+ sections: entitySections.get(ent.id)!,
231
+ x: currentX,
232
+ y: currentY,
233
+ width: w,
234
+ height: h,
235
+ })
236
+
237
+ currentX += w + hGap
238
+ maxRowH = Math.max(maxRowH, h)
239
+ colCount++
240
+ }
241
+
242
+ // Move to next component row (add gap between components)
243
+ currentY += maxRowH + componentGap
244
+ }
245
+
246
+ // --- Create canvas ---
247
+ let totalW = 0
248
+ let totalH = 0
249
+ for (const p of placed.values()) {
250
+ totalW = Math.max(totalW, p.x + p.width)
251
+ totalH = Math.max(totalH, p.y + p.height)
252
+ }
253
+ totalW += 4
254
+ totalH += 2
255
+
256
+ const canvas = mkCanvas(totalW - 1, totalH - 1)
257
+ const rc = mkRoleCanvas(totalW - 1, totalH - 1)
258
+
259
+ /** Set a character on the canvas and track its role. */
260
+ function setC(x: number, y: number, ch: string, role: CharRole): void {
261
+ if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {
262
+ canvas[x]![y] = ch
263
+ setRole(rc, x, y, role)
264
+ }
265
+ }
266
+
267
+ // --- Draw entity boxes ---
268
+ for (const p of placed.values()) {
269
+ const boxCanvas = drawMultiBox(p.sections, useAscii)
270
+ for (let bx = 0; bx < boxCanvas.length; bx++) {
271
+ for (let by = 0; by < boxCanvas[0]!.length; by++) {
272
+ const ch = boxCanvas[bx]![by]!
273
+ if (ch !== ' ') {
274
+ const cx = p.x + bx
275
+ const cy = p.y + by
276
+ if (cx < totalW && cy < totalH) {
277
+ setC(cx, cy, ch, classifyBoxChar(ch))
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // --- Draw relationships ---
285
+ const H = useAscii ? '-' : '─'
286
+ const V = useAscii ? '|' : '│'
287
+ const dashH = useAscii ? '.' : '╌'
288
+ const dashV = useAscii ? ':' : '┊'
289
+
290
+ for (const rel of diagram.relationships) {
291
+ const e1 = placed.get(rel.entity1)
292
+ const e2 = placed.get(rel.entity2)
293
+ if (!e1 || !e2) continue
294
+
295
+ const lineH = rel.identifying ? H : dashH
296
+ const lineV = rel.identifying ? V : dashV
297
+
298
+ // Determine connection direction based on relative position.
299
+ // Connect from right side of left entity to left side of right entity (horizontal),
300
+ // or from bottom of upper entity to top of lower entity (vertical).
301
+ const e1CX = e1.x + Math.floor(e1.width / 2)
302
+ const e1CY = e1.y + Math.floor(e1.height / 2)
303
+ const e2CX = e2.x + Math.floor(e2.width / 2)
304
+ const e2CY = e2.y + Math.floor(e2.height / 2)
305
+
306
+ // Check if entities are on the same row (horizontal connection)
307
+ const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
308
+
309
+ if (sameRow) {
310
+ // Horizontal connection: right side of left entity → left side of right entity
311
+ const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1]
312
+ const [leftCard, rightCard] = e1CX < e2CX
313
+ ? [rel.cardinality1, rel.cardinality2]
314
+ : [rel.cardinality2, rel.cardinality1]
315
+
316
+ const startX = left.x + left.width
317
+ const endX = right.x - 1
318
+ const lineY = left.y + Math.floor(left.height / 2)
319
+
320
+ // Draw horizontal line
321
+ for (let x = startX; x <= endX; x++) {
322
+ setC(x, lineY, lineH, 'line')
323
+ }
324
+
325
+ // Draw crow's foot markers at endpoints
326
+ // Left marker (at left entity's right edge) - isRight=false
327
+ const leftChars = getCrowsFootChars(leftCard, useAscii, false)
328
+ for (let i = 0; i < leftChars.length; i++) {
329
+ setC(startX + i, lineY, leftChars[i]!, 'arrow')
330
+ }
331
+
332
+ // Right marker (at right entity's left edge) - isRight=true
333
+ const rightChars = getCrowsFootChars(rightCard, useAscii, true)
334
+ for (let i = 0; i < rightChars.length; i++) {
335
+ setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow')
336
+ }
337
+
338
+ // Relationship label centered in the gap between the two entities, below the line.
339
+ // Clamp label to the gap region [startX, endX] to avoid overwriting box borders.
340
+ // Supports multi-line labels.
341
+ if (rel.label) {
342
+ const lines = splitLines(rel.label)
343
+ const gapMid = Math.floor((startX + endX) / 2)
344
+
345
+ // Place lines below the relationship line (lineY + 1, lineY + 2, ...)
346
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
347
+ const line = lines[lineIdx]!
348
+ const cells = toCells(line)
349
+ const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2))
350
+ const labelY = lineY + 1 + lineIdx
351
+ // Ensure canvas is tall enough
352
+ increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
353
+ increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
354
+ for (let i = 0; i < cells.length; i++) {
355
+ const cell = cells[i]!
356
+ if (cell === WIDE_PAD) continue // written atomically with its lead
357
+ const lx = labelStart + i
358
+ const wide = cells[i + 1] === WIDE_PAD
359
+ // Keep wide-glyph pairs atomic within the gap region
360
+ if (lx < startX || lx + (wide ? 1 : 0) > endX) continue
361
+ setC(lx, labelY, cell, 'text')
362
+ if (wide) setC(lx + 1, labelY, WIDE_PAD, 'text')
363
+ }
364
+ }
365
+ }
366
+ } else {
367
+ // Vertical connection: bottom of upper entity → top of lower entity
368
+ const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1]
369
+ const [upperCard, lowerCard] = e1CY < e2CY
370
+ ? [rel.cardinality1, rel.cardinality2]
371
+ : [rel.cardinality2, rel.cardinality1]
372
+
373
+ const startY = upper.y + upper.height
374
+ const endY = lower.y - 1
375
+ const lineX = upper.x + Math.floor(upper.width / 2)
376
+
377
+ // Vertical line
378
+ for (let y = startY; y <= endY; y++) {
379
+ setC(lineX, y, lineV, 'line')
380
+ }
381
+
382
+ // If horizontal offset needed, add a horizontal segment
383
+ const lowerCX = lower.x + Math.floor(lower.width / 2)
384
+ if (lineX !== lowerCX) {
385
+ const midY = Math.floor((startY + endY) / 2)
386
+ // Horizontal segment at midY
387
+ const lx = Math.min(lineX, lowerCX)
388
+ const rx = Math.max(lineX, lowerCX)
389
+ for (let x = lx; x <= rx; x++) {
390
+ setC(x, midY, lineH, 'line')
391
+ }
392
+ // Vertical from midY to lower entity
393
+ for (let y = midY + 1; y <= endY; y++) {
394
+ setC(lowerCX, y, lineV, 'line')
395
+ }
396
+ }
397
+
398
+ // Crow's foot markers (vertical direction)
399
+ // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false)
400
+ const upperChars = getCrowsFootChars(upperCard, useAscii, false)
401
+ for (let i = 0; i < upperChars.length; i++) {
402
+ setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow')
403
+ }
404
+
405
+ // Lower marker (at lower entity's top edge) - treat as target side (isRight=true)
406
+ const targetX = lineX !== lowerCX ? lowerCX : lineX
407
+ const lowerChars = getCrowsFootChars(lowerCard, useAscii, true)
408
+ for (let i = 0; i < lowerChars.length; i++) {
409
+ setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow')
410
+ }
411
+
412
+ // Relationship label — placed to the right of the vertical line at the midpoint.
413
+ // We expand the canvas as needed since labels can extend beyond the initial bounds.
414
+ // Supports multi-line labels.
415
+ if (rel.label) {
416
+ const lines = splitLines(rel.label)
417
+ const midY = Math.floor((startY + endY) / 2)
418
+ // Center lines vertically around midY
419
+ const startLabelY = midY - Math.floor((lines.length - 1) / 2)
420
+
421
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
422
+ const cells = toCells(lines[lineIdx]!)
423
+ const labelX = lineX + 2
424
+ const y = startLabelY + lineIdx
425
+ if (y >= 0) {
426
+ for (let i = 0; i < cells.length; i++) {
427
+ const lx = labelX + i
428
+ if (lx >= 0) {
429
+ increaseSize(canvas, lx + 1, y + 1)
430
+ increaseRoleCanvasSize(rc, lx + 1, y + 1)
431
+ setC(lx, y, cells[i]!, 'text')
432
+ }
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
441
+ }