@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.
- package/CHANGELOG.md +10 -0
- package/dist/types/mermaid-ascii.d.ts +1 -1
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
- package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
- package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
- package/package.json +2 -3
- package/src/mermaid-ascii.ts +1 -1
- package/src/vendor/mermaid-ascii/NOTICE +33 -0
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
- package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
- package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
- package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
- package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
- package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
- package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
- package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
- package/src/vendor/mermaid-ascii/class/types.ts +121 -0
- package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
- package/src/vendor/mermaid-ascii/er/types.ts +91 -0
- package/src/vendor/mermaid-ascii/index.ts +14 -0
- package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
- package/src/vendor/mermaid-ascii/parser.ts +645 -0
- package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
- package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
- package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
- package/src/vendor/mermaid-ascii/types.ts +164 -0
- package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
- package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
- 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
|
+
}
|