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