@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.
@@ -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, generateSmoothPath, generateRoundedPath, computeLabelPosition, } from './paths.js';
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
- const ER_PATH_GAP = 8; // hard visual clearance from ER node boundary
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 ? ER_PATH_GAP : (isMindmapEdge ? 0 : MARKER_SHORTENING);
168
- const shortenStart = isErEdge ? ER_PATH_GAP : 0;
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
- if (waypoints && waypoints.points.length > 0 && sourcePos && targetPos) {
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
- // For ER, always keep dagre waypoint routing so curves stay natural.
193
- // The straight-forward shortcut makes ER edges look too angular.
194
- const isSimpleForward = !isErEdge && !edge.noArrow && edgeClass === 'forward' && !waypointsDeviate(rawPoints, 20);
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
- const startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
198
- const endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
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, ER_PATH_GAP);
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
- while (innerPoints.length > 0 && pointInsideNodeBounds(innerPoints[0], sourceCenter)) {
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
- const startIntersect = intersectNode(sourceCenter, targetCenter, sourceShape, isErEdge ? cardBorderRadius : 0);
232
- const endIntersect = intersectNode(targetCenter, sourceCenter, targetShape, isErEdge ? cardBorderRadius : 0);
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, ER_PATH_GAP);
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
- const sourceAim = isErEdge ? chooseSourceAimPoint(sourceCenter, innerPoints) : innerPoints[0];
249
- const targetAim = isErEdge ? chooseTargetAimPoint(targetCenter, innerPoints) : innerPoints[innerPoints.length - 1];
250
- const startIntersect = intersectNode(sourceCenter, sourceAim, sourceShape, isErEdge ? cardBorderRadius : 0);
251
- const endIntersect = intersectNode(targetCenter, targetAim, targetShape, isErEdge ? cardBorderRadius : 0);
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, ER_PATH_GAP);
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
- svgPath = generateSmoothPath(finalPoints, !!isErEdge);
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
- if (isErEdge && sourcePos && targetPos) {
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
- // Keep edges above subgraph backgrounds but below regular nodes.
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,