@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,328 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — edge bundling for parallel links
|
|
3
|
+
//
|
|
4
|
+
// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and
|
|
5
|
+
// groups them into bundles. Bundled edges share a visual junction point
|
|
6
|
+
// where they merge/split, creating cleaner diagrams.
|
|
7
|
+
//
|
|
8
|
+
// This module provides:
|
|
9
|
+
// - analyzeEdgeBundles(): Finds and creates bundles from graph edges
|
|
10
|
+
// - calculateJunctionPoint(): Computes optimal merge/split locations
|
|
11
|
+
// - routeBundledEdges(): Routes edges through junction points
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AsciiGraph, AsciiNode, AsciiEdge, EdgeBundle, GridCoord, Direction,
|
|
16
|
+
} from './types'
|
|
17
|
+
import { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types'
|
|
18
|
+
import { getPath, mergePath } from './pathfinder'
|
|
19
|
+
import { getNodeSubgraph } from './grid'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Bundle analysis
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Analyze graph edges and create bundles for parallel links.
|
|
27
|
+
*
|
|
28
|
+
* Groups edges by:
|
|
29
|
+
* - Fan-in: Multiple edges sharing the same target (A & B --> C)
|
|
30
|
+
* - Fan-out: Multiple edges sharing the same source (A --> B & C)
|
|
31
|
+
*
|
|
32
|
+
* Only creates bundles when:
|
|
33
|
+
* - Graph direction is TD (top-down) - LR routing handles merging naturally
|
|
34
|
+
* - 2+ edges share the endpoint
|
|
35
|
+
* - All edges have the same style (solid/dotted/thick)
|
|
36
|
+
* - None of the edges have labels (labels would overlap at junction)
|
|
37
|
+
* - Edges are not self-loops
|
|
38
|
+
*
|
|
39
|
+
* @returns Array of bundles. Each edge can belong to at most one bundle.
|
|
40
|
+
*/
|
|
41
|
+
export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] {
|
|
42
|
+
// Only bundle in TD direction - LR routing handles merging naturally at corners
|
|
43
|
+
if (graph.config.graphDirection !== 'TD') {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
const bundles: EdgeBundle[] = []
|
|
47
|
+
const bundledEdges = new Set<AsciiEdge>()
|
|
48
|
+
|
|
49
|
+
// Group edges by target (fan-in candidates)
|
|
50
|
+
const edgesByTarget = new Map<AsciiNode, AsciiEdge[]>()
|
|
51
|
+
for (const edge of graph.edges) {
|
|
52
|
+
// Skip self-loops
|
|
53
|
+
if (edge.from === edge.to) continue
|
|
54
|
+
|
|
55
|
+
const existing = edgesByTarget.get(edge.to) ?? []
|
|
56
|
+
existing.push(edge)
|
|
57
|
+
edgesByTarget.set(edge.to, existing)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create fan-in bundles
|
|
61
|
+
for (const [target, edges] of edgesByTarget) {
|
|
62
|
+
if (edges.length < 2) continue
|
|
63
|
+
if (!canBundle(edges, graph)) continue
|
|
64
|
+
|
|
65
|
+
// Check if all edges are already bundled
|
|
66
|
+
if (edges.some(e => bundledEdges.has(e))) continue
|
|
67
|
+
|
|
68
|
+
const bundle: EdgeBundle = {
|
|
69
|
+
type: 'fan-in',
|
|
70
|
+
edges: [...edges],
|
|
71
|
+
sharedNode: target,
|
|
72
|
+
otherNodes: edges.map(e => e.from),
|
|
73
|
+
junctionPoint: null,
|
|
74
|
+
sharedPath: [],
|
|
75
|
+
junctionDir: Middle,
|
|
76
|
+
sharedNodeDir: Middle,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Mark edges as bundled
|
|
80
|
+
for (const edge of edges) {
|
|
81
|
+
edge.bundle = bundle
|
|
82
|
+
bundledEdges.add(edge)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
bundles.push(bundle)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Group edges by source (fan-out candidates)
|
|
89
|
+
const edgesBySource = new Map<AsciiNode, AsciiEdge[]>()
|
|
90
|
+
for (const edge of graph.edges) {
|
|
91
|
+
// Skip self-loops and already bundled edges
|
|
92
|
+
if (edge.from === edge.to) continue
|
|
93
|
+
if (bundledEdges.has(edge)) continue
|
|
94
|
+
|
|
95
|
+
const existing = edgesBySource.get(edge.from) ?? []
|
|
96
|
+
existing.push(edge)
|
|
97
|
+
edgesBySource.set(edge.from, existing)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create fan-out bundles
|
|
101
|
+
for (const [source, edges] of edgesBySource) {
|
|
102
|
+
if (edges.length < 2) continue
|
|
103
|
+
if (!canBundle(edges, graph)) continue
|
|
104
|
+
|
|
105
|
+
const bundle: EdgeBundle = {
|
|
106
|
+
type: 'fan-out',
|
|
107
|
+
edges: [...edges],
|
|
108
|
+
sharedNode: source,
|
|
109
|
+
otherNodes: edges.map(e => e.to),
|
|
110
|
+
junctionPoint: null,
|
|
111
|
+
sharedPath: [],
|
|
112
|
+
junctionDir: Middle,
|
|
113
|
+
sharedNodeDir: Middle,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Mark edges as bundled
|
|
117
|
+
for (const edge of edges) {
|
|
118
|
+
edge.bundle = bundle
|
|
119
|
+
bundledEdges.add(edge)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
bundles.push(bundle)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return bundles
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a group of edges can be bundled together.
|
|
130
|
+
* Returns false if edges have different styles, any have labels,
|
|
131
|
+
* or if the edges span subgraph boundaries (which creates complex routing).
|
|
132
|
+
*/
|
|
133
|
+
function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean {
|
|
134
|
+
if (edges.length < 2) return false
|
|
135
|
+
|
|
136
|
+
const firstStyle = edges[0]!.style
|
|
137
|
+
const firstFromSg = getNodeSubgraph(graph, edges[0]!.from)
|
|
138
|
+
const firstToSg = getNodeSubgraph(graph, edges[0]!.to)
|
|
139
|
+
|
|
140
|
+
for (const edge of edges) {
|
|
141
|
+
// Different styles can't be bundled (would look confusing)
|
|
142
|
+
if (edge.style !== firstStyle) return false
|
|
143
|
+
|
|
144
|
+
// Edges with labels can't be bundled (labels would overlap at junction)
|
|
145
|
+
if (edge.text.length > 0) return false
|
|
146
|
+
|
|
147
|
+
// Don't bundle if edges span different subgraph boundaries
|
|
148
|
+
// (creates complex routing that doesn't look good)
|
|
149
|
+
const fromSg = getNodeSubgraph(graph, edge.from)
|
|
150
|
+
const toSg = getNodeSubgraph(graph, edge.to)
|
|
151
|
+
if (fromSg !== firstFromSg || toSg !== firstToSg) return false
|
|
152
|
+
|
|
153
|
+
// Don't bundle if source and target are in different subgraphs
|
|
154
|
+
// (cross-boundary edges have special routing needs)
|
|
155
|
+
if (fromSg !== toSg) return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Junction point calculation
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Calculate the optimal junction point for a bundle.
|
|
167
|
+
*
|
|
168
|
+
* For fan-in (A & B --> C):
|
|
169
|
+
* - Junction is placed between the sources and the target
|
|
170
|
+
* - In TD: above the target, horizontally centered between sources
|
|
171
|
+
* - In LR: left of the target, vertically centered between sources
|
|
172
|
+
*
|
|
173
|
+
* For fan-out (A --> B & C):
|
|
174
|
+
* - Junction is placed between the source and the targets
|
|
175
|
+
* - In TD: below the source, horizontally centered between targets
|
|
176
|
+
* - In LR: right of the source, vertically centered between targets
|
|
177
|
+
*/
|
|
178
|
+
export function calculateJunctionPoint(
|
|
179
|
+
graph: AsciiGraph,
|
|
180
|
+
bundle: EdgeBundle,
|
|
181
|
+
): GridCoord {
|
|
182
|
+
const dir = graph.config.graphDirection
|
|
183
|
+
const sharedCoord = bundle.sharedNode.gridCoord!
|
|
184
|
+
const otherCoords = bundle.otherNodes.map(n => n.gridCoord!)
|
|
185
|
+
|
|
186
|
+
if (bundle.type === 'fan-in') {
|
|
187
|
+
// Junction is BEFORE the shared target
|
|
188
|
+
// Calculate center of sources
|
|
189
|
+
const minX = Math.min(...otherCoords.map(c => c.x))
|
|
190
|
+
const maxX = Math.max(...otherCoords.map(c => c.x))
|
|
191
|
+
const minY = Math.min(...otherCoords.map(c => c.y))
|
|
192
|
+
const maxY = Math.max(...otherCoords.map(c => c.y))
|
|
193
|
+
|
|
194
|
+
if (dir === 'TD') {
|
|
195
|
+
// Junction above target, centered between sources
|
|
196
|
+
// Place it one row above the target's entry point
|
|
197
|
+
const junctionY = sharedCoord.y - 1
|
|
198
|
+
// X is centered between sources, but clamped to shared node's X for alignment
|
|
199
|
+
const centerX = Math.floor((minX + maxX) / 2) + 1 // +1 for center of 3x3 block
|
|
200
|
+
const junctionX = sharedCoord.x + 1 // Align with target's center
|
|
201
|
+
|
|
202
|
+
return { x: junctionX, y: junctionY }
|
|
203
|
+
} else {
|
|
204
|
+
// LR: Junction left of target, centered between sources
|
|
205
|
+
const junctionX = sharedCoord.x - 1
|
|
206
|
+
const junctionY = sharedCoord.y + 1 // Align with target's center
|
|
207
|
+
|
|
208
|
+
return { x: junctionX, y: junctionY }
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
// fan-out: Junction is AFTER the shared source
|
|
212
|
+
const minX = Math.min(...otherCoords.map(c => c.x))
|
|
213
|
+
const maxX = Math.max(...otherCoords.map(c => c.x))
|
|
214
|
+
const minY = Math.min(...otherCoords.map(c => c.y))
|
|
215
|
+
const maxY = Math.max(...otherCoords.map(c => c.y))
|
|
216
|
+
|
|
217
|
+
if (dir === 'TD') {
|
|
218
|
+
// Junction below source, will then split to targets
|
|
219
|
+
const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block
|
|
220
|
+
const junctionX = sharedCoord.x + 1 // Align with source's center
|
|
221
|
+
|
|
222
|
+
return { x: junctionX, y: junctionY }
|
|
223
|
+
} else {
|
|
224
|
+
// LR: Junction right of source
|
|
225
|
+
const junctionX = sharedCoord.x + 3
|
|
226
|
+
const junctionY = sharedCoord.y + 1
|
|
227
|
+
|
|
228
|
+
return { x: junctionX, y: junctionY }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// Bundled edge routing
|
|
235
|
+
// ============================================================================
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Route all edges in a bundle through the junction point.
|
|
239
|
+
*
|
|
240
|
+
* For fan-in bundles:
|
|
241
|
+
* 1. Route each source → junction (stored in edge.pathToJunction)
|
|
242
|
+
* 2. Route junction → target (stored in bundle.sharedPath)
|
|
243
|
+
*
|
|
244
|
+
* For fan-out bundles:
|
|
245
|
+
* 1. Route source → junction (stored in bundle.sharedPath)
|
|
246
|
+
* 2. Route junction → each target (stored in edge.pathToJunction)
|
|
247
|
+
*/
|
|
248
|
+
export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void {
|
|
249
|
+
const dir = graph.config.graphDirection
|
|
250
|
+
|
|
251
|
+
// Calculate and store junction point
|
|
252
|
+
bundle.junctionPoint = calculateJunctionPoint(graph, bundle)
|
|
253
|
+
const junction = bundle.junctionPoint
|
|
254
|
+
|
|
255
|
+
// Determine directions based on graph direction and bundle type
|
|
256
|
+
if (bundle.type === 'fan-in') {
|
|
257
|
+
// Sources converge to junction, then junction to target
|
|
258
|
+
bundle.junctionDir = dir === 'TD' ? Up : Left
|
|
259
|
+
bundle.sharedNodeDir = dir === 'TD' ? Down : Right
|
|
260
|
+
|
|
261
|
+
// Route junction → target (shared path)
|
|
262
|
+
const targetCoord = bundle.sharedNode.gridCoord!
|
|
263
|
+
const targetEntry = dir === 'TD'
|
|
264
|
+
? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target
|
|
265
|
+
: { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target
|
|
266
|
+
|
|
267
|
+
const sharedPath = getPath(graph.grid, junction, targetEntry)
|
|
268
|
+
bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [junction, targetEntry]
|
|
269
|
+
|
|
270
|
+
// Route each source → junction
|
|
271
|
+
for (const edge of bundle.edges) {
|
|
272
|
+
const sourceCoord = edge.from.gridCoord!
|
|
273
|
+
const sourceExit = dir === 'TD'
|
|
274
|
+
? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source
|
|
275
|
+
: { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source
|
|
276
|
+
|
|
277
|
+
const pathToJunction = getPath(graph.grid, sourceExit, junction)
|
|
278
|
+
edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [sourceExit, junction]
|
|
279
|
+
|
|
280
|
+
// Set edge directions for proper drawing
|
|
281
|
+
edge.startDir = dir === 'TD' ? Down : Right
|
|
282
|
+
edge.endDir = dir === 'TD' ? Up : Left
|
|
283
|
+
|
|
284
|
+
// Build full path for grid size calculation: source → junction → target
|
|
285
|
+
edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)]
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
// fan-out: Source to junction, then junction splits to targets
|
|
289
|
+
bundle.junctionDir = dir === 'TD' ? Down : Right
|
|
290
|
+
bundle.sharedNodeDir = dir === 'TD' ? Up : Left
|
|
291
|
+
|
|
292
|
+
// Route source → junction (shared path)
|
|
293
|
+
const sourceCoord = bundle.sharedNode.gridCoord!
|
|
294
|
+
const sourceExit = dir === 'TD'
|
|
295
|
+
? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source
|
|
296
|
+
: { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source
|
|
297
|
+
|
|
298
|
+
const sharedPath = getPath(graph.grid, sourceExit, junction)
|
|
299
|
+
bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [sourceExit, junction]
|
|
300
|
+
|
|
301
|
+
// Route junction → each target
|
|
302
|
+
for (const edge of bundle.edges) {
|
|
303
|
+
const targetCoord = edge.to.gridCoord!
|
|
304
|
+
const targetEntry = dir === 'TD'
|
|
305
|
+
? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target
|
|
306
|
+
: { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target
|
|
307
|
+
|
|
308
|
+
const pathToJunction = getPath(graph.grid, junction, targetEntry)
|
|
309
|
+
edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [junction, targetEntry]
|
|
310
|
+
|
|
311
|
+
// Set edge directions
|
|
312
|
+
edge.startDir = dir === 'TD' ? Down : Right
|
|
313
|
+
edge.endDir = dir === 'TD' ? Up : Left
|
|
314
|
+
|
|
315
|
+
// Build full path for grid size calculation: source → junction → target
|
|
316
|
+
edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)]
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Process all bundles in a graph: calculate junction points and route edges.
|
|
323
|
+
*/
|
|
324
|
+
export function processBundles(graph: AsciiGraph): void {
|
|
325
|
+
for (const bundle of graph.bundles) {
|
|
326
|
+
routeBundledEdges(graph, bundle)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — direction system and edge path determination
|
|
3
|
+
//
|
|
4
|
+
// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go.
|
|
5
|
+
// Handles direction constants, edge attachment point selection,
|
|
6
|
+
// and dual-path comparison for optimal edge routing.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import type { GridCoord, Direction, AsciiEdge, AsciiGraph } from './types'
|
|
10
|
+
import {
|
|
11
|
+
Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle,
|
|
12
|
+
gridCoordDirection,
|
|
13
|
+
} from './types'
|
|
14
|
+
import { getPath, mergePath } from './pathfinder'
|
|
15
|
+
import { getEffectiveDirection, getNodeSubgraph } from './grid'
|
|
16
|
+
import { displayWidth } from '../text-metrics'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Direction utilities
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export function getOpposite(d: Direction): Direction {
|
|
23
|
+
if (d === Up) return Down
|
|
24
|
+
if (d === Down) return Up
|
|
25
|
+
if (d === Left) return Right
|
|
26
|
+
if (d === Right) return Left
|
|
27
|
+
if (d === UpperRight) return LowerLeft
|
|
28
|
+
if (d === UpperLeft) return LowerRight
|
|
29
|
+
if (d === LowerRight) return UpperLeft
|
|
30
|
+
if (d === LowerLeft) return UpperRight
|
|
31
|
+
return Middle
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compare directions by value (not reference). */
|
|
35
|
+
export function dirEquals(a: Direction, b: Direction): boolean {
|
|
36
|
+
return a.x === b.x && a.y === b.y
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Determine 8-way direction from one coordinate to another.
|
|
41
|
+
* Uses the coordinate difference to pick one of 8 cardinal/ordinal directions.
|
|
42
|
+
*/
|
|
43
|
+
export function determineDirection(from: { x: number; y: number }, to: { x: number; y: number }): Direction {
|
|
44
|
+
if (from.x === to.x) {
|
|
45
|
+
return from.y < to.y ? Down : Up
|
|
46
|
+
} else if (from.y === to.y) {
|
|
47
|
+
return from.x < to.x ? Right : Left
|
|
48
|
+
} else if (from.x < to.x) {
|
|
49
|
+
return from.y < to.y ? LowerRight : UpperRight
|
|
50
|
+
} else {
|
|
51
|
+
return from.y < to.y ? LowerLeft : UpperLeft
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Start/end direction selection for edges
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/** Self-reference routing (node points to itself). */
|
|
60
|
+
function selfReferenceDirection(graphDirection: string): [Direction, Direction, Direction, Direction] {
|
|
61
|
+
if (graphDirection === 'LR') return [Right, Down, Down, Right]
|
|
62
|
+
return [Down, Right, Right, Down]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Determine preferred and alternative start/end directions for an edge.
|
|
67
|
+
* Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd].
|
|
68
|
+
*
|
|
69
|
+
* The edge routing tries both pairs and picks the shorter path.
|
|
70
|
+
* Direction selection depends on relative node positions and graph direction (LR vs TD).
|
|
71
|
+
*/
|
|
72
|
+
export function determineStartAndEndDir(
|
|
73
|
+
edge: AsciiEdge,
|
|
74
|
+
graphDirection: string,
|
|
75
|
+
): [Direction, Direction, Direction, Direction] {
|
|
76
|
+
if (edge.from === edge.to) return selfReferenceDirection(graphDirection)
|
|
77
|
+
|
|
78
|
+
const d = determineDirection(edge.from.gridCoord!, edge.to.gridCoord!)
|
|
79
|
+
|
|
80
|
+
let preferredDir: Direction
|
|
81
|
+
let preferredOppositeDir: Direction
|
|
82
|
+
let alternativeDir: Direction
|
|
83
|
+
let alternativeOppositeDir: Direction
|
|
84
|
+
|
|
85
|
+
const isBackwards = graphDirection === 'LR'
|
|
86
|
+
? (dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft))
|
|
87
|
+
: (dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight))
|
|
88
|
+
|
|
89
|
+
if (dirEquals(d, LowerRight)) {
|
|
90
|
+
if (graphDirection === 'LR') {
|
|
91
|
+
preferredDir = Down; preferredOppositeDir = Left
|
|
92
|
+
alternativeDir = Right; alternativeOppositeDir = Up
|
|
93
|
+
} else {
|
|
94
|
+
preferredDir = Right; preferredOppositeDir = Up
|
|
95
|
+
alternativeDir = Down; alternativeOppositeDir = Left
|
|
96
|
+
}
|
|
97
|
+
} else if (dirEquals(d, UpperRight)) {
|
|
98
|
+
if (graphDirection === 'LR') {
|
|
99
|
+
preferredDir = Up; preferredOppositeDir = Left
|
|
100
|
+
alternativeDir = Right; alternativeOppositeDir = Down
|
|
101
|
+
} else {
|
|
102
|
+
preferredDir = Right; preferredOppositeDir = Down
|
|
103
|
+
alternativeDir = Up; alternativeOppositeDir = Left
|
|
104
|
+
}
|
|
105
|
+
} else if (dirEquals(d, LowerLeft)) {
|
|
106
|
+
if (graphDirection === 'LR') {
|
|
107
|
+
preferredDir = Down; preferredOppositeDir = Down
|
|
108
|
+
alternativeDir = Left; alternativeOppositeDir = Up
|
|
109
|
+
} else {
|
|
110
|
+
preferredDir = Left; preferredOppositeDir = Up
|
|
111
|
+
alternativeDir = Down; alternativeOppositeDir = Right
|
|
112
|
+
}
|
|
113
|
+
} else if (dirEquals(d, UpperLeft)) {
|
|
114
|
+
if (graphDirection === 'LR') {
|
|
115
|
+
preferredDir = Down; preferredOppositeDir = Down
|
|
116
|
+
alternativeDir = Left; alternativeOppositeDir = Down
|
|
117
|
+
} else {
|
|
118
|
+
preferredDir = Right; preferredOppositeDir = Right
|
|
119
|
+
alternativeDir = Up; alternativeOppositeDir = Right
|
|
120
|
+
}
|
|
121
|
+
} else if (isBackwards) {
|
|
122
|
+
if (graphDirection === 'LR' && dirEquals(d, Left)) {
|
|
123
|
+
preferredDir = Down; preferredOppositeDir = Down
|
|
124
|
+
alternativeDir = Left; alternativeOppositeDir = Right
|
|
125
|
+
} else if (graphDirection === 'TD' && dirEquals(d, Up)) {
|
|
126
|
+
preferredDir = Right; preferredOppositeDir = Right
|
|
127
|
+
alternativeDir = Up; alternativeOppositeDir = Down
|
|
128
|
+
} else {
|
|
129
|
+
preferredDir = d; preferredOppositeDir = getOpposite(d)
|
|
130
|
+
alternativeDir = d; alternativeOppositeDir = getOpposite(d)
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
// Default: go in the natural direction
|
|
134
|
+
preferredDir = d; preferredOppositeDir = getOpposite(d)
|
|
135
|
+
alternativeDir = d; alternativeOppositeDir = getOpposite(d)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Edge path determination
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Determine the path for an edge by trying two candidate routes (preferred + alternative)
|
|
147
|
+
* and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir.
|
|
148
|
+
*
|
|
149
|
+
* When both A* paths fail (common for edges crossing subgraph boundaries), falls back
|
|
150
|
+
* to a direct path using the start/end points. This ensures edges always have a path
|
|
151
|
+
* for arrowhead rendering.
|
|
152
|
+
*
|
|
153
|
+
* Uses the effective direction for edge routing, respecting subgraph direction overrides
|
|
154
|
+
* when both source and target are in the same subgraph.
|
|
155
|
+
*/
|
|
156
|
+
export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void {
|
|
157
|
+
// Determine effective direction for this edge
|
|
158
|
+
// If both nodes are in the same subgraph with a direction override, use it
|
|
159
|
+
// Otherwise, use the graph's direction (not source's effective direction)
|
|
160
|
+
const sourceSg = getNodeSubgraph(graph, edge.from)
|
|
161
|
+
const targetSg = getNodeSubgraph(graph, edge.to)
|
|
162
|
+
const effectiveDir = (sourceSg && sourceSg === targetSg && sourceSg.direction)
|
|
163
|
+
? sourceSg.direction
|
|
164
|
+
: graph.config.graphDirection
|
|
165
|
+
|
|
166
|
+
const [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] =
|
|
167
|
+
determineStartAndEndDir(edge, effectiveDir)
|
|
168
|
+
|
|
169
|
+
// Try preferred path
|
|
170
|
+
const prefFrom = gridCoordDirection(edge.from.gridCoord!, preferredDir)
|
|
171
|
+
const prefTo = gridCoordDirection(edge.to.gridCoord!, preferredOppositeDir)
|
|
172
|
+
let preferredPath = getPath(graph.grid, prefFrom, prefTo)
|
|
173
|
+
|
|
174
|
+
// Try alternative path
|
|
175
|
+
const altFrom = gridCoordDirection(edge.from.gridCoord!, alternativeDir)
|
|
176
|
+
const altTo = gridCoordDirection(edge.to.gridCoord!, alternativeOppositeDir)
|
|
177
|
+
let alternativePath = getPath(graph.grid, altFrom, altTo)
|
|
178
|
+
|
|
179
|
+
// Case 1: Both paths found — pick the shorter one
|
|
180
|
+
if (preferredPath !== null && alternativePath !== null) {
|
|
181
|
+
preferredPath = mergePath(preferredPath)
|
|
182
|
+
alternativePath = mergePath(alternativePath)
|
|
183
|
+
|
|
184
|
+
if (preferredPath.length <= alternativePath.length) {
|
|
185
|
+
edge.startDir = preferredDir
|
|
186
|
+
edge.endDir = preferredOppositeDir
|
|
187
|
+
edge.path = preferredPath
|
|
188
|
+
} else {
|
|
189
|
+
edge.startDir = alternativeDir
|
|
190
|
+
edge.endDir = alternativeOppositeDir
|
|
191
|
+
edge.path = alternativePath
|
|
192
|
+
}
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Case 2: Only preferred path found
|
|
197
|
+
if (preferredPath !== null) {
|
|
198
|
+
edge.startDir = preferredDir
|
|
199
|
+
edge.endDir = preferredOppositeDir
|
|
200
|
+
edge.path = mergePath(preferredPath)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Case 3: Only alternative path found
|
|
205
|
+
if (alternativePath !== null) {
|
|
206
|
+
edge.startDir = alternativeDir
|
|
207
|
+
edge.endDir = alternativeOppositeDir
|
|
208
|
+
edge.path = mergePath(alternativePath)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Case 4: Both paths failed — create a direct fallback path
|
|
213
|
+
// This happens for edges crossing subgraph boundaries where A* can't find
|
|
214
|
+
// a clear route. We create a direct path from source to target exit points
|
|
215
|
+
// so arrowheads can still be rendered correctly.
|
|
216
|
+
edge.startDir = preferredDir
|
|
217
|
+
edge.endDir = preferredOppositeDir
|
|
218
|
+
edge.path = [prefFrom, prefTo]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Find the best line segment in an edge's path to place a label on.
|
|
223
|
+
* Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid
|
|
224
|
+
* label collisions when multiple edges share initial segments.
|
|
225
|
+
* Falls back to the widest segment if none are suitable.
|
|
226
|
+
* Also increases the column width at the label position to fit the text.
|
|
227
|
+
*/
|
|
228
|
+
export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void {
|
|
229
|
+
if (edge.text.length === 0) return
|
|
230
|
+
|
|
231
|
+
const lenLabel = displayWidth(edge.text)
|
|
232
|
+
const pathLen = edge.path.length
|
|
233
|
+
const isVerticalFlow = graph.config.graphDirection === 'TD'
|
|
234
|
+
|
|
235
|
+
// Collect all segments with their widths and orientation
|
|
236
|
+
const segments: {
|
|
237
|
+
line: [GridCoord, GridCoord]
|
|
238
|
+
width: number
|
|
239
|
+
index: number
|
|
240
|
+
isVertical: boolean
|
|
241
|
+
}[] = []
|
|
242
|
+
|
|
243
|
+
for (let i = 1; i < pathLen; i++) {
|
|
244
|
+
const p1 = edge.path[i - 1]!
|
|
245
|
+
const p2 = edge.path[i]!
|
|
246
|
+
const line: [GridCoord, GridCoord] = [p1, p2]
|
|
247
|
+
const width = calculateLineWidth(graph, line)
|
|
248
|
+
// A segment is vertical if X coords are same, horizontal if Y coords are same
|
|
249
|
+
const isVertical = p1.x === p2.x
|
|
250
|
+
segments.push({ line, width, index: i, isVertical })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Find segments wide enough for the label, excluding the first segment
|
|
254
|
+
// The first segment is often shared between edges from the same source node
|
|
255
|
+
const suitableSegments = segments.filter(s => s.width >= lenLabel && s.index > 1)
|
|
256
|
+
|
|
257
|
+
let largestLine: [GridCoord, GridCoord]
|
|
258
|
+
|
|
259
|
+
if (suitableSegments.length > 0) {
|
|
260
|
+
// Prefer segments near the end of the path (closer to target)
|
|
261
|
+
// This avoids the shared initial segments from source
|
|
262
|
+
suitableSegments.sort((a, b) => b.index - a.index)
|
|
263
|
+
largestLine = suitableSegments[0]!.line
|
|
264
|
+
} else {
|
|
265
|
+
// Fall back to any suitable segment including the first
|
|
266
|
+
const fallbackSegments = segments.filter(s => s.width >= lenLabel)
|
|
267
|
+
if (fallbackSegments.length > 0) {
|
|
268
|
+
fallbackSegments.sort((a, b) => b.index - a.index)
|
|
269
|
+
largestLine = fallbackSegments[0]!.line
|
|
270
|
+
} else {
|
|
271
|
+
// No segment wide enough — use the widest one
|
|
272
|
+
segments.sort((a, b) => b.width - a.width)
|
|
273
|
+
largestLine = segments[0]?.line ?? [edge.path[0]!, edge.path[1]!]
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Ensure column at midpoint is wide enough for the label
|
|
278
|
+
const minX = Math.min(largestLine[0].x, largestLine[1].x)
|
|
279
|
+
const maxX = Math.max(largestLine[0].x, largestLine[1].x)
|
|
280
|
+
const middleX = minX + Math.floor((maxX - minX) / 2)
|
|
281
|
+
|
|
282
|
+
const current = graph.columnWidth.get(middleX) ?? 0
|
|
283
|
+
graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2))
|
|
284
|
+
|
|
285
|
+
edge.labelLine = [largestLine[0], largestLine[1]]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Calculate the total character width of a line segment by summing column widths. */
|
|
289
|
+
function calculateLineWidth(graph: AsciiGraph, line: [GridCoord, GridCoord]): number {
|
|
290
|
+
let total = 0
|
|
291
|
+
const startX = Math.min(line[0].x, line[1].x)
|
|
292
|
+
const endX = Math.max(line[0].x, line[1].x)
|
|
293
|
+
for (let x = startX; x <= endX; x++) {
|
|
294
|
+
total += graph.columnWidth.get(x) ?? 0
|
|
295
|
+
}
|
|
296
|
+
return total
|
|
297
|
+
}
|