@graph-artifact/core 0.1.13 → 0.1.15
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/dist/components/GraphCanvas.d.ts +17 -4
- package/dist/components/GraphCanvas.js +136 -37
- package/dist/components/edges/RoutedEdge.d.ts +3 -4
- package/dist/components/edges/RoutedEdge.js +14 -7
- package/dist/components/exportPng.d.ts +46 -0
- package/dist/components/exportPng.js +156 -0
- package/dist/components/nodes/ClassNode.js +9 -2
- package/dist/components/nodes/EntityNode.js +41 -17
- package/dist/components/nodes/FlowNode.js +22 -7
- package/dist/components/nodes/StateNode.js +13 -17
- package/dist/components/nodes/SubgraphNode.js +2 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +7 -7
- package/dist/layout/dagre/index.js +49 -0
- package/dist/layout/dagre/nodeSizing.js +8 -8
- package/dist/layout/edges/buildEdges.js +681 -25
- package/dist/layout/edges/paths.d.ts +4 -0
- package/dist/layout/edges/paths.js +137 -9
- package/dist/layout/index.js +19 -0
- package/dist/layout/sequenceLayout.js +5 -2
- package/dist/parsers/state.js +162 -23
- package/dist/theme/dark.js +11 -11
- package/dist/theme/light.js +10 -10
- package/dist/utils/boxShadow.d.ts +9 -0
- package/dist/utils/boxShadow.js +103 -0
- package/package.json +1 -1
|
@@ -13,11 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { MarkerType } from '@xyflow/react';
|
|
15
15
|
import { intersectNode } from '../intersect/index.js';
|
|
16
|
-
import { waypointsDeviate, shortenPathEnd, shortenPathStart,
|
|
16
|
+
import { waypointsDeviate, shortenPathEnd, shortenPathStart, generateBasisPath, generateRoundedPath, computeLabelPosition, } from './paths.js';
|
|
17
17
|
import { classifyEdge, edgeStyleForType } from './classify.js';
|
|
18
18
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
19
19
|
const MARKER_SHORTENING = 6; // shorten path end so arrowhead tip sits at the boundary
|
|
20
|
-
|
|
20
|
+
// ER edge strokes render below card nodes (default zIndex ordering).
|
|
21
|
+
// The cardinality symbols render in EdgeLabelRenderer (above nodes),
|
|
22
|
+
// so we just need a small gap so the line stops before the card border.
|
|
23
|
+
const ER_ENDPOINT_GAP = 4;
|
|
24
|
+
const DEBUG_ER = false;
|
|
25
|
+
const DEBUG_SUBGRAPH_EDGES = false;
|
|
21
26
|
function clamp255(v) {
|
|
22
27
|
return Math.max(0, Math.min(255, Math.round(v)));
|
|
23
28
|
}
|
|
@@ -78,6 +83,308 @@ function pointInsideNodeBounds(point, node, epsilon = 0.5) {
|
|
|
78
83
|
point.y >= top - epsilon &&
|
|
79
84
|
point.y <= bottom + epsilon);
|
|
80
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Check if a point is outside a rectangular boundary (top-left origin).
|
|
88
|
+
* Used for subgraph boundary detection.
|
|
89
|
+
*/
|
|
90
|
+
function pointOutsideRect(bounds, point) {
|
|
91
|
+
return (point.x < bounds.x ||
|
|
92
|
+
point.x > bounds.x + bounds.width ||
|
|
93
|
+
point.y < bounds.y ||
|
|
94
|
+
point.y > bounds.y + bounds.height);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compute intersection of a line segment with a rectangular boundary.
|
|
98
|
+
* Returns the point where the line crosses the rect edge.
|
|
99
|
+
*/
|
|
100
|
+
function lineRectIntersection(bounds, outside, inside) {
|
|
101
|
+
const left = bounds.x;
|
|
102
|
+
const right = bounds.x + bounds.width;
|
|
103
|
+
const top = bounds.y;
|
|
104
|
+
const bottom = bounds.y + bounds.height;
|
|
105
|
+
const dx = inside.x - outside.x;
|
|
106
|
+
const dy = inside.y - outside.y;
|
|
107
|
+
let t = 1;
|
|
108
|
+
// Check each edge
|
|
109
|
+
if (dx !== 0) {
|
|
110
|
+
if (dx > 0) {
|
|
111
|
+
// Entering from left
|
|
112
|
+
const tLeft = (left - outside.x) / dx;
|
|
113
|
+
if (tLeft > 0 && tLeft < t)
|
|
114
|
+
t = tLeft;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// Entering from right
|
|
118
|
+
const tRight = (right - outside.x) / dx;
|
|
119
|
+
if (tRight > 0 && tRight < t)
|
|
120
|
+
t = tRight;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (dy !== 0) {
|
|
124
|
+
if (dy > 0) {
|
|
125
|
+
// Entering from top
|
|
126
|
+
const tTop = (top - outside.y) / dy;
|
|
127
|
+
if (tTop > 0 && tTop < t)
|
|
128
|
+
t = tTop;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Entering from bottom
|
|
132
|
+
const tBottom = (bottom - outside.y) / dy;
|
|
133
|
+
if (tBottom > 0 && tBottom < t)
|
|
134
|
+
t = tBottom;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
x: outside.x + dx * t,
|
|
139
|
+
y: outside.y + dy * t,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Trim a path at a subgraph boundary, forcing exit from bottom or entry from top.
|
|
144
|
+
* This is an enhanced version of Mermaid's cutPathAtIntersect that ensures
|
|
145
|
+
* edges use the correct boundary edge (top for incoming, bottom for outgoing).
|
|
146
|
+
*
|
|
147
|
+
* @param points - The path waypoints
|
|
148
|
+
* @param subgraphBounds - The subgraph boundary rectangle (top-left origin)
|
|
149
|
+
* @param isOutgoing - true if edge exits FROM subgraph (use bottom), false if enters TO subgraph (use top)
|
|
150
|
+
*/
|
|
151
|
+
function cutPathAtSubgraphBoundary(points, subgraphBounds, isOutgoing, otherNodePos) {
|
|
152
|
+
if (points.length < 2)
|
|
153
|
+
return points;
|
|
154
|
+
const subgraphBottom = subgraphBounds.y + subgraphBounds.height;
|
|
155
|
+
const subgraphTop = subgraphBounds.y;
|
|
156
|
+
const subgraphCenterX = subgraphBounds.x + subgraphBounds.width / 2;
|
|
157
|
+
const subgraphLeft = subgraphBounds.x;
|
|
158
|
+
const subgraphRight = subgraphBounds.x + subgraphBounds.width;
|
|
159
|
+
// Use the actual node position if available, otherwise fall back to waypoint
|
|
160
|
+
const getDestination = () => {
|
|
161
|
+
if (otherNodePos) {
|
|
162
|
+
return {
|
|
163
|
+
x: otherNodePos.x + otherNodePos.width / 2,
|
|
164
|
+
y: otherNodePos.y + otherNodePos.height / 2,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return points[points.length - 1];
|
|
168
|
+
};
|
|
169
|
+
const getSource = () => {
|
|
170
|
+
if (otherNodePos) {
|
|
171
|
+
return {
|
|
172
|
+
x: otherNodePos.x + otherNodePos.width / 2,
|
|
173
|
+
y: otherNodePos.y + otherNodePos.height / 2,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return points[0];
|
|
177
|
+
};
|
|
178
|
+
if (isOutgoing) {
|
|
179
|
+
// Edge exits FROM subgraph - need to route around the subgraph
|
|
180
|
+
const exitPoint = { x: subgraphCenterX, y: subgraphBottom };
|
|
181
|
+
const destination = getDestination();
|
|
182
|
+
// Determine if target is below the subgraph or to the side
|
|
183
|
+
const targetBelowSubgraph = destination.y > subgraphBottom + 10;
|
|
184
|
+
const targetLeftOfSubgraph = destination.x < subgraphLeft - 10;
|
|
185
|
+
const targetRightOfSubgraph = destination.x > subgraphRight + 10;
|
|
186
|
+
if (DEBUG_SUBGRAPH_EDGES) {
|
|
187
|
+
console.log(` cutPath: exitPoint=(${exitPoint.x.toFixed(0)}, ${exitPoint.y.toFixed(0)})`);
|
|
188
|
+
console.log(` cutPath: destination=(${destination.x.toFixed(0)}, ${destination.y.toFixed(0)})`);
|
|
189
|
+
console.log(` cutPath: targetBelow=${targetBelowSubgraph}, left=${targetLeftOfSubgraph}, right=${targetRightOfSubgraph}`);
|
|
190
|
+
}
|
|
191
|
+
// Route down from bottom, then to target
|
|
192
|
+
const clearanceY = subgraphBottom + 40; // Go 40px below subgraph before turning
|
|
193
|
+
if (targetBelowSubgraph) {
|
|
194
|
+
// Target is below - go straight down then to target
|
|
195
|
+
if (Math.abs(destination.x - subgraphCenterX) < 30) {
|
|
196
|
+
// Target is roughly centered below - direct path down
|
|
197
|
+
return [exitPoint, { x: subgraphCenterX, y: clearanceY }, destination];
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// Target is offset - route down then over
|
|
201
|
+
const turnPoint = { x: destination.x, y: clearanceY };
|
|
202
|
+
return [exitPoint, { x: subgraphCenterX, y: clearanceY }, turnPoint, destination];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (targetLeftOfSubgraph || targetRightOfSubgraph) {
|
|
206
|
+
// Target is to the side and NOT below - need to route around and up/over
|
|
207
|
+
const sideX = targetLeftOfSubgraph
|
|
208
|
+
? subgraphLeft - 40 // Go left of subgraph
|
|
209
|
+
: subgraphRight + 40; // Go right of subgraph
|
|
210
|
+
// Route: exit bottom → drop down → go to side → go up/over to target
|
|
211
|
+
const dropPoint = { x: subgraphCenterX, y: clearanceY };
|
|
212
|
+
const cornerPoint = { x: sideX, y: clearanceY };
|
|
213
|
+
const approachPoint = { x: sideX, y: destination.y };
|
|
214
|
+
return [exitPoint, dropPoint, cornerPoint, approachPoint, destination];
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// Target is within subgraph horizontal bounds but not below (above or overlapping)
|
|
218
|
+
// Route down and around - go down, then out to the side, then to target
|
|
219
|
+
const dropPoint = { x: subgraphCenterX, y: clearanceY };
|
|
220
|
+
const sideX = destination.x < subgraphCenterX ? subgraphLeft - 40 : subgraphRight + 40;
|
|
221
|
+
const cornerPoint = { x: sideX, y: clearanceY };
|
|
222
|
+
const approachPoint = { x: sideX, y: destination.y };
|
|
223
|
+
return [exitPoint, dropPoint, cornerPoint, approachPoint, destination];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// Edge enters TO subgraph - route to top
|
|
228
|
+
const entryPoint = { x: subgraphCenterX, y: subgraphTop };
|
|
229
|
+
const source = getSource();
|
|
230
|
+
const sourceAboveSubgraph = source.y < subgraphTop - 10;
|
|
231
|
+
const sourceLeftOfSubgraph = source.x < subgraphLeft - 10;
|
|
232
|
+
const sourceRightOfSubgraph = source.x > subgraphRight + 10;
|
|
233
|
+
const approachY = subgraphTop - 40;
|
|
234
|
+
if (sourceAboveSubgraph) {
|
|
235
|
+
// Source is above - can come straight down
|
|
236
|
+
if (Math.abs(source.x - subgraphCenterX) < 30) {
|
|
237
|
+
return [source, { x: subgraphCenterX, y: approachY }, entryPoint];
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
// Source is offset above - route over then down
|
|
241
|
+
const turnPoint = { x: subgraphCenterX, y: approachY };
|
|
242
|
+
return [source, { x: source.x, y: approachY }, turnPoint, entryPoint];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (sourceLeftOfSubgraph || sourceRightOfSubgraph) {
|
|
246
|
+
// Source is to the side - route around to top
|
|
247
|
+
const sideX = sourceLeftOfSubgraph ? subgraphLeft - 40 : subgraphRight + 40;
|
|
248
|
+
return [source, { x: sideX, y: source.y }, { x: sideX, y: approachY }, { x: subgraphCenterX, y: approachY }, entryPoint];
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Source is within subgraph bounds - shouldn't happen but handle gracefully
|
|
252
|
+
return [source, { x: subgraphCenterX, y: approachY }, entryPoint];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Build a path from a subgraph boundary (outgoing) to a target node.
|
|
258
|
+
* - If target is clearly ABOVE subgraph (back-edge): exit from TOP
|
|
259
|
+
* - Otherwise: exit from BOTTOM and route to target
|
|
260
|
+
* @param edgeOffset - Edge index for spreading
|
|
261
|
+
* @param totalEdges - Total number of outgoing edges (for spreading calculation)
|
|
262
|
+
*/
|
|
263
|
+
function buildSubgraphOutgoingPath(subgraphBounds, targetCenter, targetShape, borderRadius, edgeOffset = 0, totalEdges = 1) {
|
|
264
|
+
const subgraphTop = subgraphBounds.y;
|
|
265
|
+
const subgraphBottom = subgraphBounds.y + subgraphBounds.height;
|
|
266
|
+
const subgraphCenterX = subgraphBounds.x + subgraphBounds.width / 2;
|
|
267
|
+
const subgraphLeft = subgraphBounds.x;
|
|
268
|
+
const subgraphRight = subgraphBounds.x + subgraphBounds.width;
|
|
269
|
+
// Only treat as "above" if target is significantly above the subgraph top
|
|
270
|
+
// This identifies true back-edges (like logout → Idle)
|
|
271
|
+
const targetIsAbove = targetCenter.y + targetCenter.height / 2 < subgraphTop - 20;
|
|
272
|
+
if (targetIsAbove) {
|
|
273
|
+
// Target is above - exit from top (back-edge case like logout → Idle)
|
|
274
|
+
// Use RIGHT portion of top to avoid incoming edges on left
|
|
275
|
+
const spreadWidth = subgraphBounds.width * 0.3;
|
|
276
|
+
const slotSpacing = totalEdges > 1 ? spreadWidth / (totalEdges - 1) : 0;
|
|
277
|
+
const startX = subgraphCenterX + subgraphBounds.width * 0.1;
|
|
278
|
+
const exitX = totalEdges > 1 ? startX + edgeOffset * slotSpacing : startX + spreadWidth / 2;
|
|
279
|
+
const exitPoint = { x: exitX, y: subgraphTop };
|
|
280
|
+
const targetIntersect = intersectNode(targetCenter, exitPoint, targetShape, borderRadius);
|
|
281
|
+
// Smooth curved path up to target
|
|
282
|
+
const totalDY = subgraphTop - targetIntersect.y;
|
|
283
|
+
const totalDX = targetIntersect.x - exitX;
|
|
284
|
+
const waypoints = [
|
|
285
|
+
exitPoint,
|
|
286
|
+
// First control: rise up vertically a bit
|
|
287
|
+
{ x: exitX, y: subgraphTop - totalDY * 0.3 },
|
|
288
|
+
// Second control: start curving toward target
|
|
289
|
+
{ x: exitX + totalDX * 0.3, y: subgraphTop - totalDY * 0.5 },
|
|
290
|
+
// Third control: continue curving
|
|
291
|
+
{ x: exitX + totalDX * 0.7, y: subgraphTop - totalDY * 0.7 },
|
|
292
|
+
targetIntersect,
|
|
293
|
+
];
|
|
294
|
+
return shortenPathEnd(waypoints, MARKER_SHORTENING);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
// Target is below OR beside - always exit from BOTTOM
|
|
298
|
+
// Exit from the position closest to target (clamped to subgraph bounds)
|
|
299
|
+
const margin = 20;
|
|
300
|
+
const minExitX = subgraphLeft + margin;
|
|
301
|
+
const maxExitX = subgraphRight - margin;
|
|
302
|
+
// Aim toward target X, but stay within subgraph bounds
|
|
303
|
+
let exitX = Math.max(minExitX, Math.min(maxExitX, targetCenter.x));
|
|
304
|
+
// Add small offset for multiple edges to prevent overlap
|
|
305
|
+
exitX += edgeOffset * 15;
|
|
306
|
+
exitX = Math.max(minExitX, Math.min(maxExitX, exitX));
|
|
307
|
+
const exitPoint = { x: exitX, y: subgraphBottom };
|
|
308
|
+
const targetIntersect = intersectNode(targetCenter, exitPoint, targetShape, borderRadius);
|
|
309
|
+
// Check if target is actually below or beside
|
|
310
|
+
const targetIsBelow = targetCenter.y - targetCenter.height / 2 > subgraphBottom - 10;
|
|
311
|
+
if (targetIsBelow) {
|
|
312
|
+
// Smooth curved path down to target
|
|
313
|
+
// Use gradual control points for soft Bezier-like curve
|
|
314
|
+
const totalDY = targetIntersect.y - subgraphBottom;
|
|
315
|
+
const totalDX = targetIntersect.x - exitX;
|
|
316
|
+
const waypoints = [
|
|
317
|
+
exitPoint,
|
|
318
|
+
// First control: drop down vertically a bit
|
|
319
|
+
{ x: exitX, y: subgraphBottom + totalDY * 0.3 },
|
|
320
|
+
// Second control: start moving toward target
|
|
321
|
+
{ x: exitX + totalDX * 0.3, y: subgraphBottom + totalDY * 0.5 },
|
|
322
|
+
// Third control: continue curving
|
|
323
|
+
{ x: exitX + totalDX * 0.7, y: subgraphBottom + totalDY * 0.7 },
|
|
324
|
+
targetIntersect,
|
|
325
|
+
];
|
|
326
|
+
return shortenPathEnd(waypoints, MARKER_SHORTENING);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// Target is beside - route down then around with smooth curves
|
|
330
|
+
const targetIsLeft = targetCenter.x < subgraphCenterX;
|
|
331
|
+
const routeX = targetIsLeft ? subgraphLeft - 40 - edgeOffset * 15 : subgraphRight + 40 + edgeOffset * 15;
|
|
332
|
+
const clearanceY = subgraphBottom + 25;
|
|
333
|
+
// Create smooth curve with gradual transitions
|
|
334
|
+
const waypoints = [
|
|
335
|
+
exitPoint,
|
|
336
|
+
// Gentle drop
|
|
337
|
+
{ x: exitX, y: subgraphBottom + 10 },
|
|
338
|
+
// Curve toward route X
|
|
339
|
+
{ x: (exitX + routeX) / 2, y: clearanceY },
|
|
340
|
+
{ x: routeX, y: clearanceY },
|
|
341
|
+
// Curve toward target Y
|
|
342
|
+
{ x: routeX, y: (clearanceY + targetCenter.y) / 2 },
|
|
343
|
+
{ x: routeX, y: targetCenter.y },
|
|
344
|
+
targetIntersect,
|
|
345
|
+
];
|
|
346
|
+
return shortenPathEnd(waypoints, MARKER_SHORTENING);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Build a path from a source node to a subgraph boundary (incoming).
|
|
352
|
+
* Always enters at TOP of subgraph (like a regular node in TB layout).
|
|
353
|
+
* Uses LEFT portion of top edge (outgoing edges use RIGHT portion).
|
|
354
|
+
* @param edgeOffset - Horizontal offset to spread multiple edges apart
|
|
355
|
+
* @param totalEdges - Total number of incoming edges (for spreading calculation)
|
|
356
|
+
*/
|
|
357
|
+
function buildSubgraphIncomingPath(sourceCenter, sourceShape, subgraphBounds, borderRadius, edgeOffset = 0, totalEdges = 1) {
|
|
358
|
+
const subgraphTop = subgraphBounds.y;
|
|
359
|
+
const subgraphCenterX = subgraphBounds.x + subgraphBounds.width / 2;
|
|
360
|
+
const subgraphCenterY = subgraphBounds.y + subgraphBounds.height / 2;
|
|
361
|
+
// Spread edges across LEFT portion of top (outgoing uses RIGHT portion)
|
|
362
|
+
const spreadWidth = subgraphBounds.width * 0.3;
|
|
363
|
+
const slotSpacing = totalEdges > 1 ? spreadWidth / (totalEdges - 1) : 0;
|
|
364
|
+
// Start from center-left of top edge
|
|
365
|
+
const startX = subgraphCenterX - subgraphBounds.width * 0.1 - spreadWidth;
|
|
366
|
+
const entryX = totalEdges > 1 ? startX + edgeOffset * slotSpacing : subgraphCenterX - subgraphBounds.width * 0.15;
|
|
367
|
+
// Entry point at top of subgraph
|
|
368
|
+
const entryPoint = { x: entryX, y: subgraphTop };
|
|
369
|
+
// Compute intersection with source node boundary by aiming toward subgraph center
|
|
370
|
+
// This ensures the edge exits from the correct side of the source node
|
|
371
|
+
const subgraphCenter = { x: subgraphCenterX, y: subgraphCenterY };
|
|
372
|
+
const sourceIntersect = intersectNode(sourceCenter, subgraphCenter, sourceShape, borderRadius);
|
|
373
|
+
// Smooth curved path from source to entry point
|
|
374
|
+
const totalDY = entryPoint.y - sourceIntersect.y;
|
|
375
|
+
const totalDX = entryPoint.x - sourceIntersect.x;
|
|
376
|
+
const waypoints = [
|
|
377
|
+
sourceIntersect,
|
|
378
|
+
// First control: drop down from source
|
|
379
|
+
{ x: sourceIntersect.x, y: sourceIntersect.y + totalDY * 0.3 },
|
|
380
|
+
// Second control: start curving toward entry
|
|
381
|
+
{ x: sourceIntersect.x + totalDX * 0.3, y: sourceIntersect.y + totalDY * 0.5 },
|
|
382
|
+
// Third control: continue curving
|
|
383
|
+
{ x: sourceIntersect.x + totalDX * 0.7, y: sourceIntersect.y + totalDY * 0.7 },
|
|
384
|
+
entryPoint,
|
|
385
|
+
];
|
|
386
|
+
return waypoints;
|
|
387
|
+
}
|
|
81
388
|
function chooseSourceAimPoint(center, points) {
|
|
82
389
|
const first = points[0];
|
|
83
390
|
if (!first || points.length < 2)
|
|
@@ -108,12 +415,156 @@ function chooseTargetAimPoint(center, points) {
|
|
|
108
415
|
Math.abs(turnDx) > Math.abs(turnDy) * 1.25;
|
|
109
416
|
return lateralThenVerticalNearTarget ? prev : last;
|
|
110
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* Spread multiple edges arriving at the same node around its perimeter.
|
|
420
|
+
* Returns a map of edgeIndex → offset angle (radians) to apply to the intersection.
|
|
421
|
+
*/
|
|
422
|
+
function computeEndpointSpread(edgesByTarget) {
|
|
423
|
+
const spreadOffsets = new Map();
|
|
424
|
+
const SPREAD_ANGLE = 0.15; // radians (~8.5 degrees) between adjacent edges
|
|
425
|
+
for (const [, edges] of edgesByTarget) {
|
|
426
|
+
if (edges.length <= 1)
|
|
427
|
+
continue;
|
|
428
|
+
// Sort edges by approach angle
|
|
429
|
+
edges.sort((a, b) => a.angle - b.angle);
|
|
430
|
+
// Group edges with similar angles (within 0.2 radians / ~11 degrees)
|
|
431
|
+
const groups = [];
|
|
432
|
+
let currentGroup = [edges[0]];
|
|
433
|
+
for (let i = 1; i < edges.length; i++) {
|
|
434
|
+
const angleDiff = edges[i].angle - edges[i - 1].angle;
|
|
435
|
+
if (angleDiff < 0.2) {
|
|
436
|
+
currentGroup.push(edges[i]);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
groups.push(currentGroup);
|
|
440
|
+
currentGroup = [edges[i]];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
groups.push(currentGroup);
|
|
444
|
+
// Spread edges within each group
|
|
445
|
+
for (const group of groups) {
|
|
446
|
+
if (group.length <= 1)
|
|
447
|
+
continue;
|
|
448
|
+
const centerIndex = (group.length - 1) / 2;
|
|
449
|
+
for (let i = 0; i < group.length; i++) {
|
|
450
|
+
const offset = (i - centerIndex) * SPREAD_ANGLE;
|
|
451
|
+
spreadOffsets.set(group[i].edgeIndex, offset);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return spreadOffsets;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Apply an angular offset to an intersection point, moving it along the node boundary.
|
|
459
|
+
*/
|
|
460
|
+
function offsetIntersectionByAngle(intersection, nodeCenter, angleOffset, nodeShape) {
|
|
461
|
+
if (Math.abs(angleOffset) < 0.001)
|
|
462
|
+
return intersection;
|
|
463
|
+
// Calculate current angle from node center to intersection
|
|
464
|
+
const dx = intersection.x - nodeCenter.x;
|
|
465
|
+
const dy = intersection.y - nodeCenter.y;
|
|
466
|
+
const currentAngle = Math.atan2(dy, dx);
|
|
467
|
+
// Apply offset
|
|
468
|
+
const newAngle = currentAngle + angleOffset;
|
|
469
|
+
// Project a point outward from center at new angle
|
|
470
|
+
const projectedPoint = {
|
|
471
|
+
x: nodeCenter.x + Math.cos(newAngle) * 1000,
|
|
472
|
+
y: nodeCenter.y + Math.sin(newAngle) * 1000,
|
|
473
|
+
};
|
|
474
|
+
// Recalculate intersection with node boundary at new angle
|
|
475
|
+
return intersectNode(nodeCenter, projectedPoint, nodeShape, 0);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Offset a label position perpendicular to the edge direction.
|
|
479
|
+
* This helps separate overlapping labels when multiple edges have similar paths.
|
|
480
|
+
*/
|
|
481
|
+
function offsetLabelPerpendicular(labelPos, edgeStart, edgeEnd, offset) {
|
|
482
|
+
if (Math.abs(offset) < 0.001)
|
|
483
|
+
return labelPos;
|
|
484
|
+
// Calculate edge direction
|
|
485
|
+
const dx = edgeEnd.x - edgeStart.x;
|
|
486
|
+
const dy = edgeEnd.y - edgeStart.y;
|
|
487
|
+
const len = Math.hypot(dx, dy);
|
|
488
|
+
if (len < 1)
|
|
489
|
+
return labelPos;
|
|
490
|
+
// Calculate perpendicular direction (rotate 90 degrees)
|
|
491
|
+
const perpX = -dy / len;
|
|
492
|
+
const perpY = dx / len;
|
|
493
|
+
// Apply offset (use offset * 12 to get visible separation)
|
|
494
|
+
return {
|
|
495
|
+
x: labelPos.x + perpX * offset * 12,
|
|
496
|
+
y: labelPos.y + perpY * offset * 12,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
111
499
|
// ─── Edge Builder ───────────────────────────────────────────────────────────
|
|
112
500
|
export function buildEdges(parsedEdges, positions, _handles, direction, theme, diamondEdgeHandles, edgeWaypoints, nodeShapes) {
|
|
113
501
|
const markerSize = theme.edgeDefaults.markerSize;
|
|
114
502
|
const isVertical = direction === 'TB' || direction === 'BT';
|
|
115
503
|
const cardBorderRadius = parseInt(theme.radius.lg, 10) || 8;
|
|
116
504
|
const branchPalette = buildBranchPalette(theme.color.orange1, theme.color.orange2, theme.color.orange3);
|
|
505
|
+
const erGap = ER_ENDPOINT_GAP;
|
|
506
|
+
// Pre-compute edge endpoint spreading for nodes with multiple incoming edges
|
|
507
|
+
const edgesByTarget = new Map();
|
|
508
|
+
const edgesBySource = new Map();
|
|
509
|
+
parsedEdges.forEach((edge, i) => {
|
|
510
|
+
const sourcePos = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
511
|
+
const targetPos = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
512
|
+
if (!sourcePos || !targetPos)
|
|
513
|
+
return;
|
|
514
|
+
// Skip subgraph edges - they use different logic
|
|
515
|
+
if (edge.sourceSubgraphId || edge.targetSubgraphId)
|
|
516
|
+
return;
|
|
517
|
+
const sourceCenter = {
|
|
518
|
+
x: sourcePos.x + sourcePos.width / 2,
|
|
519
|
+
y: sourcePos.y + sourcePos.height / 2,
|
|
520
|
+
width: sourcePos.width,
|
|
521
|
+
height: sourcePos.height,
|
|
522
|
+
};
|
|
523
|
+
const targetCenter = {
|
|
524
|
+
x: targetPos.x + targetPos.width / 2,
|
|
525
|
+
y: targetPos.y + targetPos.height / 2,
|
|
526
|
+
width: targetPos.width,
|
|
527
|
+
height: targetPos.height,
|
|
528
|
+
};
|
|
529
|
+
// Angle from target to source (approach angle)
|
|
530
|
+
const approachAngle = Math.atan2(sourceCenter.y - targetCenter.y, sourceCenter.x - targetCenter.x);
|
|
531
|
+
// Angle from source to target (departure angle)
|
|
532
|
+
const departureAngle = Math.atan2(targetCenter.y - sourceCenter.y, targetCenter.x - sourceCenter.x);
|
|
533
|
+
const targetKey = edge.targetSubgraphId ?? edge.target;
|
|
534
|
+
const targetEdges = edgesByTarget.get(targetKey) ?? [];
|
|
535
|
+
targetEdges.push({ edgeIndex: i, angle: approachAngle, sourceCenter });
|
|
536
|
+
edgesByTarget.set(targetKey, targetEdges);
|
|
537
|
+
const sourceKey = edge.sourceSubgraphId ?? edge.source;
|
|
538
|
+
const sourceEdges = edgesBySource.get(sourceKey) ?? [];
|
|
539
|
+
sourceEdges.push({ edgeIndex: i, angle: departureAngle, sourceCenter: targetCenter });
|
|
540
|
+
edgesBySource.set(sourceKey, sourceEdges);
|
|
541
|
+
});
|
|
542
|
+
const targetSpreadOffsets = computeEndpointSpread(edgesByTarget);
|
|
543
|
+
const sourceSpreadOffsets = computeEndpointSpread(edgesBySource);
|
|
544
|
+
// Detect bidirectional edge pairs (A→B and B→A) for label separation
|
|
545
|
+
// These edges have opposite directions so they won't be caught by the angle-based spreading
|
|
546
|
+
const bidirectionalLabelOffset = new Map();
|
|
547
|
+
const edgePairKey = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
548
|
+
const edgePairs = new Map(); // pairKey → [edgeIndex1, edgeIndex2, ...]
|
|
549
|
+
parsedEdges.forEach((edge, i) => {
|
|
550
|
+
if (edge.sourceSubgraphId || edge.targetSubgraphId)
|
|
551
|
+
return;
|
|
552
|
+
const key = edgePairKey(edge.source, edge.target);
|
|
553
|
+
const existing = edgePairs.get(key) ?? [];
|
|
554
|
+
existing.push(i);
|
|
555
|
+
edgePairs.set(key, existing);
|
|
556
|
+
});
|
|
557
|
+
// For each pair with multiple edges, assign alternating offsets
|
|
558
|
+
for (const [, indices] of edgePairs) {
|
|
559
|
+
if (indices.length <= 1)
|
|
560
|
+
continue;
|
|
561
|
+
const centerIndex = (indices.length - 1) / 2;
|
|
562
|
+
indices.forEach((edgeIdx, i) => {
|
|
563
|
+
// Offset alternates: -1, +1 for pair; -1.5, 0, +1.5 for triple, etc.
|
|
564
|
+
const offset = (i - centerIndex) * 1.5;
|
|
565
|
+
bidirectionalLabelOffset.set(edgeIdx, offset);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
117
568
|
// Build handle slot maps for forward edges
|
|
118
569
|
const forwardBySource = new Map();
|
|
119
570
|
const forwardByTarget = new Map();
|
|
@@ -148,6 +599,21 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
148
599
|
const SIDE_SLOTS = 5;
|
|
149
600
|
const backSourceCounters = new Map();
|
|
150
601
|
const backTargetCounters = new Map();
|
|
602
|
+
// Track subgraph edge counts for spreading
|
|
603
|
+
const subgraphOutgoingCounts = new Map();
|
|
604
|
+
const subgraphIncomingCounts = new Map();
|
|
605
|
+
// Pre-count edges per subgraph to compute offsets
|
|
606
|
+
for (const edge of parsedEdges) {
|
|
607
|
+
if (edge.sourceSubgraphId) {
|
|
608
|
+
subgraphOutgoingCounts.set(edge.sourceSubgraphId, (subgraphOutgoingCounts.get(edge.sourceSubgraphId) ?? 0) + 1);
|
|
609
|
+
}
|
|
610
|
+
if (edge.targetSubgraphId) {
|
|
611
|
+
subgraphIncomingCounts.set(edge.targetSubgraphId, (subgraphIncomingCounts.get(edge.targetSubgraphId) ?? 0) + 1);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Track current index for each subgraph to assign offsets
|
|
615
|
+
const subgraphOutgoingIndex = new Map();
|
|
616
|
+
const subgraphIncomingIndex = new Map();
|
|
151
617
|
return parsedEdges.map((edge, i) => {
|
|
152
618
|
const sourcePos = positions.get(edge.sourceSubgraphId ?? edge.source);
|
|
153
619
|
const targetPos = positions.get(edge.targetSubgraphId ?? edge.target);
|
|
@@ -164,15 +630,79 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
164
630
|
// so no path shortening needed.
|
|
165
631
|
const isErEdge = edge.cardSource || edge.cardTarget;
|
|
166
632
|
const isMindmapEdge = !!edge.noArrow;
|
|
167
|
-
const shortenEnd = isErEdge ?
|
|
168
|
-
const shortenStart = isErEdge ?
|
|
633
|
+
const shortenEnd = isErEdge ? erGap : (isMindmapEdge ? 0 : MARKER_SHORTENING);
|
|
634
|
+
const shortenStart = isErEdge ? erGap : 0;
|
|
169
635
|
// Track path endpoints + angles for ER cardinality symbols
|
|
170
636
|
let pathStart;
|
|
171
637
|
let pathEnd;
|
|
172
638
|
let startAngle = 0; // angle toward source node (radians)
|
|
173
639
|
let endAngle = 0; // angle toward target node (radians)
|
|
174
640
|
let routePointsForLive;
|
|
175
|
-
|
|
641
|
+
// Special handling for subgraph edges - build path from scratch
|
|
642
|
+
if (edge.sourceSubgraphId && sourcePos && targetPos) {
|
|
643
|
+
const subgraphBounds = positions.get(edge.sourceSubgraphId);
|
|
644
|
+
if (subgraphBounds) {
|
|
645
|
+
// Compute offset to spread multiple edges from same subgraph
|
|
646
|
+
const totalOutgoing = subgraphOutgoingCounts.get(edge.sourceSubgraphId) ?? 1;
|
|
647
|
+
const currentIndex = subgraphOutgoingIndex.get(edge.sourceSubgraphId) ?? 0;
|
|
648
|
+
subgraphOutgoingIndex.set(edge.sourceSubgraphId, currentIndex + 1);
|
|
649
|
+
// Center the offsets: for 3 edges, offsets are -1, 0, 1
|
|
650
|
+
const edgeOffset = currentIndex - Math.floor((totalOutgoing - 1) / 2);
|
|
651
|
+
if (DEBUG_SUBGRAPH_EDGES) {
|
|
652
|
+
console.log(`[SUBGRAPH OUTGOING] ${edge.source} → ${edge.target} (label: ${edge.label})`);
|
|
653
|
+
console.log(` subgraphBounds: x=${subgraphBounds.x.toFixed(0)}, y=${subgraphBounds.y.toFixed(0)}, w=${subgraphBounds.width.toFixed(0)}, h=${subgraphBounds.height.toFixed(0)}`);
|
|
654
|
+
console.log(` targetPos: x=${targetPos.x.toFixed(0)}, y=${targetPos.y.toFixed(0)}, w=${targetPos.width.toFixed(0)}, h=${targetPos.height.toFixed(0)}`);
|
|
655
|
+
console.log(` edgeOffset: ${edgeOffset} (${currentIndex + 1} of ${totalOutgoing})`);
|
|
656
|
+
}
|
|
657
|
+
const targetCenter = {
|
|
658
|
+
x: targetPos.x + targetPos.width / 2,
|
|
659
|
+
y: targetPos.y + targetPos.height / 2,
|
|
660
|
+
width: targetPos.width,
|
|
661
|
+
height: targetPos.height,
|
|
662
|
+
};
|
|
663
|
+
// Build path from subgraph boundary to target
|
|
664
|
+
const pathPoints = buildSubgraphOutgoingPath(subgraphBounds, targetCenter, targetShape, cardBorderRadius, currentIndex, totalOutgoing);
|
|
665
|
+
if (DEBUG_SUBGRAPH_EDGES) {
|
|
666
|
+
console.log(` pathPoints:`, JSON.stringify(pathPoints.map(p => `(${p.x.toFixed(0)},${p.y.toFixed(0)})`)));
|
|
667
|
+
}
|
|
668
|
+
labelPos = computeLabelPosition(pathPoints);
|
|
669
|
+
svgPath = generateBasisPath(pathPoints);
|
|
670
|
+
routePointsForLive = pathPoints;
|
|
671
|
+
pathStart = pathPoints[0];
|
|
672
|
+
pathEnd = pathPoints[pathPoints.length - 1];
|
|
673
|
+
startAngle = Math.atan2(pathPoints[0].y - pathPoints[1].y, pathPoints[0].x - pathPoints[1].x);
|
|
674
|
+
endAngle = Math.atan2(pathPoints[pathPoints.length - 1].y - pathPoints[pathPoints.length - 2].y, pathPoints[pathPoints.length - 1].x - pathPoints[pathPoints.length - 2].x);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else if (edge.targetSubgraphId && sourcePos && targetPos) {
|
|
678
|
+
const subgraphBounds = positions.get(edge.targetSubgraphId);
|
|
679
|
+
if (subgraphBounds) {
|
|
680
|
+
// Compute offset to spread multiple edges entering same subgraph
|
|
681
|
+
const totalIncoming = subgraphIncomingCounts.get(edge.targetSubgraphId) ?? 1;
|
|
682
|
+
const currentIndex = subgraphIncomingIndex.get(edge.targetSubgraphId) ?? 0;
|
|
683
|
+
subgraphIncomingIndex.set(edge.targetSubgraphId, currentIndex + 1);
|
|
684
|
+
if (DEBUG_SUBGRAPH_EDGES) {
|
|
685
|
+
console.log(`[SUBGRAPH INCOMING] ${edge.source} → ${edge.target} (label: ${edge.label})`);
|
|
686
|
+
console.log(` edgeIndex: ${currentIndex} of ${totalIncoming}`);
|
|
687
|
+
}
|
|
688
|
+
const sourceCenter = {
|
|
689
|
+
x: sourcePos.x + sourcePos.width / 2,
|
|
690
|
+
y: sourcePos.y + sourcePos.height / 2,
|
|
691
|
+
width: sourcePos.width,
|
|
692
|
+
height: sourcePos.height,
|
|
693
|
+
};
|
|
694
|
+
// Build path from source to subgraph boundary
|
|
695
|
+
const pathPoints = buildSubgraphIncomingPath(sourceCenter, sourceShape, subgraphBounds, cardBorderRadius, currentIndex, totalIncoming);
|
|
696
|
+
labelPos = computeLabelPosition(pathPoints);
|
|
697
|
+
svgPath = generateBasisPath(pathPoints);
|
|
698
|
+
routePointsForLive = pathPoints;
|
|
699
|
+
pathStart = pathPoints[0];
|
|
700
|
+
pathEnd = pathPoints[pathPoints.length - 1];
|
|
701
|
+
startAngle = Math.atan2(pathPoints[0].y - pathPoints[1].y, pathPoints[0].x - pathPoints[1].x);
|
|
702
|
+
endAngle = Math.atan2(pathPoints[pathPoints.length - 1].y - pathPoints[pathPoints.length - 2].y, pathPoints[pathPoints.length - 1].x - pathPoints[pathPoints.length - 2].x);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (waypoints && waypoints.points.length > 0 && sourcePos && targetPos) {
|
|
176
706
|
const sourceCenter = {
|
|
177
707
|
x: sourcePos.x + sourcePos.width / 2,
|
|
178
708
|
y: sourcePos.y + sourcePos.height / 2,
|
|
@@ -189,20 +719,47 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
189
719
|
// Determine if dagre's waypoints deviate from a straight source→target line.
|
|
190
720
|
// Back/lateral edges always use waypoints (they need routing to avoid overlap).
|
|
191
721
|
// Forward edges use waypoints only if they deviate significantly from straight.
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
722
|
+
// ER edges also use the simple path when waypoints are nearly straight — this
|
|
723
|
+
// ensures the path endpoints match the measured node boundaries rather than
|
|
724
|
+
// relying on dagre waypoints (which may be stale after relayout).
|
|
725
|
+
const isSimpleForward = !edge.noArrow && edgeClass === 'forward' && !waypointsDeviate(rawPoints, 20);
|
|
195
726
|
if (isSimpleForward) {
|
|
196
727
|
// Simple forward edge: straight line from source boundary to target boundary.
|
|
197
|
-
|
|
198
|
-
|
|
728
|
+
let startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
|
|
729
|
+
let endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
|
|
730
|
+
// Apply endpoint spreading for nodes with multiple edges
|
|
731
|
+
const sourceSpreadOffset = sourceSpreadOffsets.get(i);
|
|
732
|
+
const targetSpreadOffset = targetSpreadOffsets.get(i);
|
|
733
|
+
if (sourceSpreadOffset !== undefined) {
|
|
734
|
+
startIntersect = offsetIntersectionByAngle(startIntersect, sourceCenter, sourceSpreadOffset, sourceShape);
|
|
735
|
+
}
|
|
736
|
+
if (targetSpreadOffset !== undefined) {
|
|
737
|
+
endIntersect = offsetIntersectionByAngle(endIntersect, targetCenter, targetSpreadOffset, targetShape);
|
|
738
|
+
}
|
|
739
|
+
if (DEBUG_ER && isErEdge) {
|
|
740
|
+
console.log(`[ER SIMPLE] ${edge.source} → ${edge.target}`);
|
|
741
|
+
console.log(` sourcePos: top=${sourcePos.y}, height=${sourcePos.height}, bottom=${sourcePos.y + sourcePos.height}`);
|
|
742
|
+
console.log(` sourceCenter: (${sourceCenter.x}, ${sourceCenter.y}), w=${sourceCenter.width}, h=${sourceCenter.height}`);
|
|
743
|
+
console.log(` startIntersect: (${startIntersect.x}, ${startIntersect.y})`);
|
|
744
|
+
console.log(` targetPos: top=${targetPos.y}, height=${targetPos.height}`);
|
|
745
|
+
console.log(` endIntersect: (${endIntersect.x}, ${endIntersect.y})`);
|
|
746
|
+
console.log(` [DEBUG] positions map size: ${positions.size}, positions source entry:`, positions.get(edge.source));
|
|
747
|
+
console.log(` sourceSpreadOffset: ${sourceSpreadOffset}, targetSpreadOffset: ${targetSpreadOffset}`);
|
|
748
|
+
}
|
|
199
749
|
let finalPoints = [startIntersect, endIntersect];
|
|
200
750
|
finalPoints = shortenPathStart(finalPoints, shortenStart);
|
|
201
751
|
finalPoints = shortenPathEnd(finalPoints, shortenEnd);
|
|
202
752
|
if (isErEdge) {
|
|
203
|
-
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius,
|
|
753
|
+
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, erGap);
|
|
204
754
|
}
|
|
205
755
|
labelPos = computeLabelPosition(finalPoints);
|
|
756
|
+
// Offset label perpendicular to edge direction for overlapping edges
|
|
757
|
+
const combinedSpreadOffset = (sourceSpreadOffset ?? 0) + (targetSpreadOffset ?? 0);
|
|
758
|
+
const biDirOffset = bidirectionalLabelOffset.get(i) ?? 0;
|
|
759
|
+
const totalLabelOffset = combinedSpreadOffset + biDirOffset;
|
|
760
|
+
if (totalLabelOffset !== 0) {
|
|
761
|
+
labelPos = offsetLabelPerpendicular(labelPos, finalPoints[0], finalPoints[finalPoints.length - 1], totalLabelOffset);
|
|
762
|
+
}
|
|
206
763
|
svgPath = `M${finalPoints[0].x},${finalPoints[0].y}L${finalPoints[1].x},${finalPoints[1].y}`;
|
|
207
764
|
routePointsForLive = finalPoints;
|
|
208
765
|
// Compute endpoint angles for ER cardinality symbols
|
|
@@ -221,22 +778,44 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
221
778
|
// Dagre waypoints are based on estimated node sizes. After React Flow
|
|
222
779
|
// measurement, ER/Class nodes can be taller; trim any near-end waypoints
|
|
223
780
|
// that now fall inside source/target node bounds to prevent edge overlap.
|
|
224
|
-
|
|
781
|
+
const trimEps = isErEdge ? (erGap + 0.5) : 0.5;
|
|
782
|
+
while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[0], sourceCenter, trimEps)) {
|
|
225
783
|
innerPoints.shift();
|
|
226
784
|
}
|
|
227
|
-
while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[innerPoints.length - 1], targetCenter)) {
|
|
785
|
+
while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[innerPoints.length - 1], targetCenter, trimEps)) {
|
|
228
786
|
innerPoints.pop();
|
|
229
787
|
}
|
|
230
788
|
if (innerPoints.length === 0) {
|
|
231
|
-
|
|
232
|
-
|
|
789
|
+
let startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
|
|
790
|
+
let endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
|
|
791
|
+
// Apply endpoint spreading for nodes with multiple edges
|
|
792
|
+
const sourceSpreadOffset = sourceSpreadOffsets.get(i);
|
|
793
|
+
const targetSpreadOffset = targetSpreadOffsets.get(i);
|
|
794
|
+
if (sourceSpreadOffset !== undefined) {
|
|
795
|
+
startIntersect = offsetIntersectionByAngle(startIntersect, sourceCenter, sourceSpreadOffset, sourceShape);
|
|
796
|
+
}
|
|
797
|
+
if (targetSpreadOffset !== undefined) {
|
|
798
|
+
endIntersect = offsetIntersectionByAngle(endIntersect, targetCenter, targetSpreadOffset, targetShape);
|
|
799
|
+
}
|
|
800
|
+
if (DEBUG_ER && isErEdge) {
|
|
801
|
+
console.log(`[ER TRIMMED-TO-DIRECT] ${edge.source} → ${edge.target}`);
|
|
802
|
+
console.log(` sourcePos: top=${sourcePos.y}, height=${sourcePos.height}, bottom=${sourcePos.y + sourcePos.height}`);
|
|
803
|
+
console.log(` startIntersect: (${startIntersect.x}, ${startIntersect.y})`);
|
|
804
|
+
}
|
|
233
805
|
let finalPoints = [startIntersect, endIntersect];
|
|
234
806
|
finalPoints = shortenPathStart(finalPoints, shortenStart);
|
|
235
807
|
finalPoints = shortenPathEnd(finalPoints, shortenEnd);
|
|
236
808
|
if (isErEdge) {
|
|
237
|
-
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius,
|
|
809
|
+
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, erGap);
|
|
238
810
|
}
|
|
239
811
|
labelPos = computeLabelPosition(finalPoints);
|
|
812
|
+
// Offset label perpendicular to edge direction for overlapping edges
|
|
813
|
+
const combinedSpreadOffset = (sourceSpreadOffset ?? 0) + (targetSpreadOffset ?? 0);
|
|
814
|
+
const biDirOffset = bidirectionalLabelOffset.get(i) ?? 0;
|
|
815
|
+
const totalLabelOffset = combinedSpreadOffset + biDirOffset;
|
|
816
|
+
if (totalLabelOffset !== 0) {
|
|
817
|
+
labelPos = offsetLabelPerpendicular(labelPos, finalPoints[0], finalPoints[finalPoints.length - 1], totalLabelOffset);
|
|
818
|
+
}
|
|
240
819
|
svgPath = `M${finalPoints[0].x},${finalPoints[0].y}L${finalPoints[1].x},${finalPoints[1].y}`;
|
|
241
820
|
routePointsForLive = finalPoints;
|
|
242
821
|
pathStart = finalPoints[0];
|
|
@@ -245,21 +824,48 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
245
824
|
endAngle = Math.atan2(finalPoints[finalPoints.length - 1].y - finalPoints[finalPoints.length - 2].y, finalPoints[finalPoints.length - 1].x - finalPoints[finalPoints.length - 2].x);
|
|
246
825
|
}
|
|
247
826
|
else {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
const
|
|
827
|
+
// For non-simple routes, compute intersections using the actual waypoint
|
|
828
|
+
// directions, not a chosen "aim point" that may be far from the entry angle.
|
|
829
|
+
const firstInner = innerPoints[0];
|
|
830
|
+
const lastInner = innerPoints[innerPoints.length - 1];
|
|
831
|
+
let startIntersect = intersectNode(sourceCenter, firstInner, sourceShape, isErEdge ? cardBorderRadius : 0);
|
|
832
|
+
let endIntersect = intersectNode(targetCenter, lastInner, targetShape, isErEdge ? cardBorderRadius : 0);
|
|
833
|
+
// Apply endpoint spreading for nodes with multiple edges (smaller offset for routed edges)
|
|
834
|
+
const sourceSpreadOffset = sourceSpreadOffsets.get(i);
|
|
835
|
+
const targetSpreadOffset = targetSpreadOffsets.get(i);
|
|
836
|
+
if (sourceSpreadOffset !== undefined) {
|
|
837
|
+
startIntersect = offsetIntersectionByAngle(startIntersect, sourceCenter, sourceSpreadOffset * 0.5, sourceShape);
|
|
838
|
+
}
|
|
839
|
+
if (targetSpreadOffset !== undefined) {
|
|
840
|
+
endIntersect = offsetIntersectionByAngle(endIntersect, targetCenter, targetSpreadOffset * 0.5, targetShape);
|
|
841
|
+
}
|
|
842
|
+
if (DEBUG_ER && isErEdge) {
|
|
843
|
+
console.log(`[ER COMPLEX] ${edge.source} → ${edge.target}`);
|
|
844
|
+
console.log(` sourcePos: top=${sourcePos.y}, height=${sourcePos.height}, bottom=${sourcePos.y + sourcePos.height}`);
|
|
845
|
+
console.log(` sourceCenter: (${sourceCenter.x}, ${sourceCenter.y})`);
|
|
846
|
+
console.log(` firstInner waypoint: (${firstInner.x}, ${firstInner.y})`);
|
|
847
|
+
console.log(` startIntersect: (${startIntersect.x}, ${startIntersect.y})`);
|
|
848
|
+
console.log(` Is startIntersect.y inside node? ${startIntersect.y < sourcePos.y + sourcePos.height ? 'YES - BUG!' : 'no'}`);
|
|
849
|
+
}
|
|
252
850
|
let finalPoints = [startIntersect, ...innerPoints, endIntersect];
|
|
253
851
|
finalPoints = shortenPathStart(finalPoints, shortenStart);
|
|
254
852
|
finalPoints = shortenPathEnd(finalPoints, shortenEnd);
|
|
255
853
|
if (isErEdge) {
|
|
256
|
-
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius,
|
|
854
|
+
finalPoints = clampErEndpoints(finalPoints, sourceCenter, targetCenter, sourceShape, targetShape, cardBorderRadius, erGap);
|
|
257
855
|
}
|
|
258
856
|
labelPos = computeLabelPosition(finalPoints);
|
|
857
|
+
// Offset label perpendicular to edge direction for overlapping edges (smaller factor for routed)
|
|
858
|
+
const combinedSpreadOffset = ((sourceSpreadOffset ?? 0) + (targetSpreadOffset ?? 0)) * 0.5;
|
|
859
|
+
const biDirOffset = bidirectionalLabelOffset.get(i) ?? 0;
|
|
860
|
+
const totalLabelOffset = combinedSpreadOffset + biDirOffset;
|
|
861
|
+
if (totalLabelOffset !== 0) {
|
|
862
|
+
labelPos = offsetLabelPerpendicular(labelPos, finalPoints[0], finalPoints[finalPoints.length - 1], totalLabelOffset);
|
|
863
|
+
}
|
|
259
864
|
// For ER, keep middle smoothing but force straight start/end segments.
|
|
260
865
|
// This stabilizes endpoint tangent (for cardinality symbols) and
|
|
261
866
|
// prevents spline overshoot from visually re-entering node bounds.
|
|
262
867
|
if (edge.noArrow) {
|
|
868
|
+
// Mindmap edges: rounded corners for organic feel
|
|
263
869
|
if (finalPoints.length === 3) {
|
|
264
870
|
const [p0, c, p2] = finalPoints;
|
|
265
871
|
svgPath = `M${p0.x},${p0.y}Q${c.x},${c.y},${p2.x},${p2.y}`;
|
|
@@ -269,7 +875,11 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
269
875
|
}
|
|
270
876
|
}
|
|
271
877
|
else {
|
|
272
|
-
|
|
878
|
+
// All edges (including ER): B-spline for smooth, flowing curves.
|
|
879
|
+
// B-splines approximate control points rather than passing through them,
|
|
880
|
+
// which naturally smooths out dagre's sharp orthogonal waypoints.
|
|
881
|
+
// The function ensures exact start/end points for proper node connection.
|
|
882
|
+
svgPath = generateBasisPath(finalPoints);
|
|
273
883
|
}
|
|
274
884
|
routePointsForLive = finalPoints;
|
|
275
885
|
// Compute endpoint angles for ER cardinality symbols
|
|
@@ -280,10 +890,57 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
280
890
|
}
|
|
281
891
|
}
|
|
282
892
|
}
|
|
893
|
+
// Fallback: if no waypoints were provided or path wasn't computed,
|
|
894
|
+
// generate a simple direct path from source to target boundary.
|
|
895
|
+
if (svgPath === '' && sourcePos && targetPos) {
|
|
896
|
+
const sourceCenter = {
|
|
897
|
+
x: sourcePos.x + sourcePos.width / 2,
|
|
898
|
+
y: sourcePos.y + sourcePos.height / 2,
|
|
899
|
+
width: sourcePos.width,
|
|
900
|
+
height: sourcePos.height,
|
|
901
|
+
};
|
|
902
|
+
const targetCenter = {
|
|
903
|
+
x: targetPos.x + targetPos.width / 2,
|
|
904
|
+
y: targetPos.y + targetPos.height / 2,
|
|
905
|
+
width: targetPos.width,
|
|
906
|
+
height: targetPos.height,
|
|
907
|
+
};
|
|
908
|
+
let startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, 0);
|
|
909
|
+
let endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, 0);
|
|
910
|
+
// Apply endpoint spreading for nodes with multiple edges
|
|
911
|
+
const sourceSpreadOffset = sourceSpreadOffsets.get(i);
|
|
912
|
+
const targetSpreadOffset = targetSpreadOffsets.get(i);
|
|
913
|
+
if (sourceSpreadOffset !== undefined) {
|
|
914
|
+
startIntersect = offsetIntersectionByAngle(startIntersect, sourceCenter, sourceSpreadOffset, sourceShape);
|
|
915
|
+
}
|
|
916
|
+
if (targetSpreadOffset !== undefined) {
|
|
917
|
+
endIntersect = offsetIntersectionByAngle(endIntersect, targetCenter, targetSpreadOffset, targetShape);
|
|
918
|
+
}
|
|
919
|
+
let finalPoints = [startIntersect, endIntersect];
|
|
920
|
+
finalPoints = shortenPathEnd(finalPoints, MARKER_SHORTENING);
|
|
921
|
+
labelPos = computeLabelPosition(finalPoints);
|
|
922
|
+
// Offset label perpendicular to edge direction for overlapping edges
|
|
923
|
+
const combinedSpreadOffset = (sourceSpreadOffset ?? 0) + (targetSpreadOffset ?? 0);
|
|
924
|
+
const biDirOffset = bidirectionalLabelOffset.get(i) ?? 0;
|
|
925
|
+
const totalLabelOffset = combinedSpreadOffset + biDirOffset;
|
|
926
|
+
if (totalLabelOffset !== 0) {
|
|
927
|
+
labelPos = offsetLabelPerpendicular(labelPos, finalPoints[0], finalPoints[finalPoints.length - 1], totalLabelOffset);
|
|
928
|
+
}
|
|
929
|
+
svgPath = `M${finalPoints[0].x},${finalPoints[0].y}L${finalPoints[1].x},${finalPoints[1].y}`;
|
|
930
|
+
}
|
|
283
931
|
// ── Handle assignment ──
|
|
284
932
|
let sourceHandle;
|
|
285
933
|
let targetHandle;
|
|
286
|
-
|
|
934
|
+
// Subgraph edges: force top/bottom handles so edges enter/exit subgraphs vertically
|
|
935
|
+
const isSubgraphSource = !!edge.sourceSubgraphId;
|
|
936
|
+
const isSubgraphTarget = !!edge.targetSubgraphId;
|
|
937
|
+
if ((isSubgraphSource || isSubgraphTarget) && sourcePos && targetPos) {
|
|
938
|
+
// Edges to/from subgraphs should use vertical (forward-style) handles
|
|
939
|
+
// Source exits from bottom, target enters from top
|
|
940
|
+
sourceHandle = 'fw-source-0';
|
|
941
|
+
targetHandle = 'fw-target-0';
|
|
942
|
+
}
|
|
943
|
+
else if (isErEdge && sourcePos && targetPos) {
|
|
287
944
|
// ER-specific handle strategy:
|
|
288
945
|
// prefer side handles for lateral/diagonal links, top/bottom for vertical links.
|
|
289
946
|
const srcCX = sourcePos.x + sourcePos.width / 2;
|
|
@@ -371,8 +1028,7 @@ export function buildEdges(parsedEdges, positions, _handles, direction, theme, d
|
|
|
371
1028
|
labelBgBorderRadius: theme.edgeDefaults.labelBgBorderRadius,
|
|
372
1029
|
sourceHandle,
|
|
373
1030
|
targetHandle,
|
|
374
|
-
//
|
|
375
|
-
// (Regular nodes set zIndex=10; subgraphs set zIndex=0.)
|
|
1031
|
+
// Default: edges above subgraph backgrounds but below nodes.
|
|
376
1032
|
zIndex: 1,
|
|
377
1033
|
data: {
|
|
378
1034
|
svgPath,
|