@diagrammo/dgmo 0.6.1 → 0.6.2

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.
@@ -129,10 +129,352 @@ const REJECT_COUNT_MAX = 3;
129
129
  // Edge path generator
130
130
  // ============================================================
131
131
 
132
- const lineGenerator = d3Shape.line<{ x: number; y: number }>()
133
- .x((d) => d.x)
134
- .y((d) => d.y)
135
- .curve(d3Shape.curveBasis);
132
+ type Pt = { x: number; y: number };
133
+
134
+ /** Produce an SVG path string for a sequence of waypoints.
135
+ * 2-point paths use curveBumpX/Y (nice S-curve).
136
+ * Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
137
+ function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
138
+ const gen = d3Shape.line<Pt>().x((d) => d.x).y((d) => d.y);
139
+ if (pts.length <= 2) {
140
+ gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
141
+ } else {
142
+ gen.curve(d3Shape.curveCatmullRom.alpha(0.5));
143
+ }
144
+ return gen(pts) ?? '';
145
+ }
146
+
147
+ type Rect = { x: number; y: number; width: number; height: number };
148
+
149
+ /** Port-order exit and enter points for edges to eliminate fan-out crossings.
150
+ *
151
+ * For each node with ≥2 outgoing edges: sort edges by target perpendicular coord
152
+ * and assign source-border exit positions in the same order.
153
+ * For each node with ≥2 incoming edges: sort edges by source perpendicular coord
154
+ * and assign target-border enter positions in the same order.
155
+ *
156
+ * Returns two maps keyed by "sourceId:targetId":
157
+ * - srcPts: port-ordered exit point on the source border
158
+ * - tgtPts: port-ordered enter point on the target border */
159
+ function computePortPts(
160
+ edges: InfraLayoutEdge[],
161
+ nodeMap: Map<string, InfraLayoutNode>,
162
+ direction: 'LR' | 'TB',
163
+ ): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
164
+ const srcPts = new Map<string, Pt>();
165
+ const tgtPts = new Map<string, Pt>();
166
+ const PAD = 0.1; // keep ports 10% in from node edges
167
+
168
+ const activeEdges = edges.filter((e) => e.points.length > 0);
169
+
170
+ // ── Source (exit) port ordering ──────────────────────────────────────────
171
+ const bySource = new Map<string, InfraLayoutEdge[]>();
172
+ for (const e of activeEdges) {
173
+ if (!bySource.has(e.sourceId)) bySource.set(e.sourceId, []);
174
+ bySource.get(e.sourceId)!.push(e);
175
+ }
176
+ for (const [sourceId, es] of bySource) {
177
+ if (es.length < 2) continue;
178
+ const source = nodeMap.get(sourceId);
179
+ if (!source) continue;
180
+ const sorted = es
181
+ .map((e) => ({ e, t: nodeMap.get(e.targetId) }))
182
+ .filter((x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null)
183
+ .sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
184
+ const n = sorted.length;
185
+ for (let i = 0; i < n; i++) {
186
+ const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
187
+ const { e, t } = sorted[i];
188
+ const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
189
+ if (direction === 'LR') {
190
+ srcPts.set(`${e.sourceId}:${e.targetId}`, {
191
+ x: isBackward ? source.x - source.width / 2 : source.x + source.width / 2,
192
+ y: source.y - source.height / 2 + frac * source.height,
193
+ });
194
+ } else {
195
+ srcPts.set(`${e.sourceId}:${e.targetId}`, {
196
+ x: source.x - source.width / 2 + frac * source.width,
197
+ y: isBackward ? source.y - source.height / 2 : source.y + source.height / 2,
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ // ── Target (enter) port ordering ─────────────────────────────────────────
204
+ const byTarget = new Map<string, InfraLayoutEdge[]>();
205
+ for (const e of activeEdges) {
206
+ if (!byTarget.has(e.targetId)) byTarget.set(e.targetId, []);
207
+ byTarget.get(e.targetId)!.push(e);
208
+ }
209
+ for (const [targetId, es] of byTarget) {
210
+ if (es.length < 2) continue;
211
+ const target = nodeMap.get(targetId);
212
+ if (!target) continue;
213
+ const sorted = es
214
+ .map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
215
+ .filter((x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null)
216
+ .sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
217
+ const n = sorted.length;
218
+ for (let i = 0; i < n; i++) {
219
+ const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
220
+ const { e, s } = sorted[i];
221
+ const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
222
+ if (direction === 'LR') {
223
+ tgtPts.set(`${e.sourceId}:${e.targetId}`, {
224
+ x: isBackward ? target.x + target.width / 2 : target.x - target.width / 2,
225
+ y: target.y - target.height / 2 + frac * target.height,
226
+ });
227
+ } else {
228
+ tgtPts.set(`${e.sourceId}:${e.targetId}`, {
229
+ x: target.x - target.width / 2 + frac * target.width,
230
+ y: isBackward ? target.y + target.height / 2 : target.y - target.height / 2,
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ return { srcPts, tgtPts };
237
+ }
238
+
239
+ /** Find the Y lane for routing around blocking obstacles.
240
+ * Prefers threading through Y gaps between blocking rects over routing above/below all.
241
+ * Picks the candidate closest to targetY to minimise arc size.
242
+ *
243
+ * Uses a tight merge expansion (MERGE_SLOP) so narrow inter-group gaps are preserved
244
+ * and can be threaded, rather than being swallowed into one large merged interval. */
245
+ function findRoutingLane(
246
+ blocking: Rect[],
247
+ targetY: number,
248
+ margin: number,
249
+ ): number {
250
+ // Use a small slop for merging so closely-spaced (but distinct) groups
251
+ // stay as separate intervals and the gap between them can be threaded.
252
+ const MERGE_SLOP = 4;
253
+ const sorted = [...blocking].sort((a, b) => (a.y + a.height / 2) - (b.y + b.height / 2));
254
+ const merged: [number, number][] = [];
255
+ for (const r of sorted) {
256
+ const lo = r.y - MERGE_SLOP;
257
+ const hi = r.y + r.height + MERGE_SLOP;
258
+ if (merged.length && lo <= merged[merged.length - 1][1]) {
259
+ merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], hi);
260
+ } else {
261
+ merged.push([lo, hi]);
262
+ }
263
+ }
264
+ if (merged.length === 0) return targetY;
265
+
266
+ // Candidate lanes: above all, below all, mid-points of gaps wide enough to thread.
267
+ // MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
268
+ const MIN_GAP = 10;
269
+ const candidates: number[] = [
270
+ merged[0][0] - margin, // above all blocking rects
271
+ merged[merged.length - 1][1] + margin, // below all blocking rects
272
+ ];
273
+ for (let i = 0; i < merged.length - 1; i++) {
274
+ const gapLo = merged[i][1];
275
+ const gapHi = merged[i + 1][0];
276
+ if (gapHi - gapLo >= MIN_GAP) {
277
+ candidates.push((gapLo + gapHi) / 2); // thread through the gap
278
+ }
279
+ }
280
+
281
+ // Return the candidate closest to targetY (tightest arc)
282
+ return candidates.reduce((best, c) =>
283
+ Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best,
284
+ candidates[0]);
285
+ }
286
+
287
+ /** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
288
+ function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; width: number; height: number }): boolean {
289
+ const { x: rx, y: ry, width: rw, height: rh } = rect;
290
+ const rr = rx + rw;
291
+ const rb = ry + rh;
292
+ // Point-in-rect check
293
+ const inRect = (p: Pt) => p.x >= rx && p.x <= rr && p.y >= ry && p.y <= rb;
294
+ if (inRect(p1) || inRect(p2)) return true;
295
+ // Segment bounding box vs rect
296
+ if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
297
+ if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
298
+ // Cross product sign helper (z-component of cross product)
299
+ const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
300
+ // Does segment p1p2 cross segment a→b?
301
+ const crosses = (a: Pt, b: Pt) => {
302
+ const d1 = cross(a, b, p1);
303
+ const d2 = cross(a, b, p2);
304
+ const d3 = cross(p1, p2, a);
305
+ const d4 = cross(p1, p2, b);
306
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
307
+ ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
308
+ };
309
+ const tl: Pt = { x: rx, y: ry };
310
+ const tr: Pt = { x: rr, y: ry };
311
+ const br: Pt = { x: rr, y: rb };
312
+ const bl: Pt = { x: rx, y: rb };
313
+ return crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl);
314
+ }
315
+
316
+ /** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
317
+ *
318
+ * curveBumpX (LR) stays near sc.y for the left half of the edge, transitions
319
+ * vertically at the midpoint, then stays near tc.y for the right half.
320
+ * Approximated as three Manhattan segments:
321
+ * sc → (midX, sc.y) → (midX, tc.y) → tc
322
+ *
323
+ * curveBumpY (TB) mirrors this on the other axis. */
324
+ function curveIntersectsRect(sc: Pt, tc: Pt, rect: Rect, direction: 'LR' | 'TB'): boolean {
325
+ if (direction === 'LR') {
326
+ const midX = (sc.x + tc.x) / 2;
327
+ const m1: Pt = { x: midX, y: sc.y };
328
+ const m2: Pt = { x: midX, y: tc.y };
329
+ return segmentIntersectsRect(sc, m1, rect)
330
+ || segmentIntersectsRect(m1, m2, rect)
331
+ || segmentIntersectsRect(m2, tc, rect);
332
+ } else {
333
+ const midY = (sc.y + tc.y) / 2;
334
+ const m1: Pt = { x: sc.x, y: midY };
335
+ const m2: Pt = { x: tc.x, y: midY };
336
+ return segmentIntersectsRect(sc, m1, rect)
337
+ || segmentIntersectsRect(m1, m2, rect)
338
+ || segmentIntersectsRect(m2, tc, rect);
339
+ }
340
+ }
341
+
342
+ /** Compute waypoints for an edge.
343
+ *
344
+ * - Backward edges (going against the primary layout direction) arc above/left
345
+ * of all layout content to avoid crossing everything.
346
+ * - Forward edges that cross a group bounding box or an unrelated node arc
347
+ * above or below the collective blocking region (gap-aware Y routing).
348
+ * - Clear forward edges use a direct 2-point path (curveBumpX/Y S-curve). */
349
+ function edgeWaypoints(
350
+ source: InfraLayoutNode,
351
+ target: InfraLayoutNode,
352
+ groups: InfraLayoutGroup[],
353
+ nodes: InfraLayoutNode[],
354
+ direction: 'LR' | 'TB',
355
+ margin = 30,
356
+ srcExitPt?: Pt, // port-ordered exit point on source border
357
+ tgtEnterPt?: Pt, // port-ordered enter point on target border
358
+ ): Pt[] {
359
+ const sc: Pt = { x: source.x, y: source.y };
360
+ const tc: Pt = { x: target.x, y: target.y };
361
+
362
+ // ── Backward edge handling ───────────────────────────────────────────────
363
+ // curveBumpX/Y on a backward edge creates a loop. Route around obstacles
364
+ // in the backward path's axis band for a tight arc (not global min/max).
365
+ const isBackward = direction === 'LR' ? tc.x < sc.x : tc.y < sc.y;
366
+ if (isBackward) {
367
+ if (direction === 'LR') {
368
+ // Collect group/node rects that overlap the X band [tc.x, sc.x]
369
+ const xBandObs: Rect[] = [];
370
+ for (const g of groups) {
371
+ if (g.x + g.width < tc.x - margin || g.x > sc.x + margin) continue;
372
+ xBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
373
+ }
374
+ for (const n of nodes) {
375
+ if (n.id === source.id || n.id === target.id) continue;
376
+ const nLeft = n.x - n.width / 2;
377
+ const nRight = n.x + n.width / 2;
378
+ if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
379
+ xBandObs.push({ x: nLeft, y: n.y - n.height / 2, width: n.width, height: n.height });
380
+ }
381
+ const midY = (sc.y + tc.y) / 2;
382
+ const routeY = xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
383
+ const exitBorder: Pt = srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
384
+ const exitPt : Pt = { x: exitBorder.x, y: routeY };
385
+ const enterPt : Pt = { x: tc.x, y: routeY };
386
+ const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
387
+ return srcExitPt
388
+ ? [srcExitPt, exitPt, enterPt, tp]
389
+ : [exitBorder, exitPt, enterPt, tp];
390
+ } else {
391
+ // TB backward: collect obstacles in Y band [tc.y, sc.y]; find X lane
392
+ const yBandObs: Rect[] = [];
393
+ for (const g of groups) {
394
+ if (g.y + g.height < tc.y - margin || g.y > sc.y + margin) continue;
395
+ yBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
396
+ }
397
+ for (const n of nodes) {
398
+ if (n.id === source.id || n.id === target.id) continue;
399
+ const nTop = n.y - n.height / 2;
400
+ const nBot = n.y + n.height / 2;
401
+ if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
402
+ yBandObs.push({ x: n.x - n.width / 2, y: nTop, width: n.width, height: n.height });
403
+ }
404
+ // Rotate axes so findRoutingLane (which works in Y) resolves an X lane
405
+ const rotated = yBandObs.map((r) => ({ x: r.y, y: r.x, width: r.height, height: r.width }));
406
+ const midX = (sc.x + tc.x) / 2;
407
+ const routeX = rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
408
+ const exitPt : Pt = srcExitPt ?? { x: routeX, y: sc.y };
409
+ const enterPt : Pt = { x: routeX, y: tc.y };
410
+ return [
411
+ srcExitPt ?? nodeBorderPoint(source, exitPt),
412
+ exitPt,
413
+ enterPt,
414
+ tgtEnterPt ?? nodeBorderPoint(target, enterPt),
415
+ ];
416
+ }
417
+ }
418
+
419
+ // ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
420
+ const blocking: { x: number; y: number; width: number; height: number }[] = [];
421
+ const blockingGroupIds = new Set<string>();
422
+
423
+ // Use actual path endpoints (port-ordered) for more accurate blocking detection.
424
+ // Node centers underestimate midX/midY, causing nearby obstacles to go undetected.
425
+ const pathSrc: Pt = srcExitPt ?? sc;
426
+ const pathTgt: Pt = tgtEnterPt ?? tc;
427
+
428
+ // Groups (excluding source/target groups)
429
+ for (const g of groups) {
430
+ if (g.id === source.groupId || g.id === target.groupId) continue;
431
+ const gRect: Rect = { x: g.x, y: g.y, width: g.width, height: g.height };
432
+ if (curveIntersectsRect(pathSrc, pathTgt, gRect, direction)) {
433
+ blocking.push(gRect);
434
+ blockingGroupIds.add(g.id);
435
+ }
436
+ }
437
+
438
+ // Individual nodes not already covered by a blocking group rect
439
+ for (const n of nodes) {
440
+ if (n.id === source.id || n.id === target.id) continue;
441
+ // Skip nodes in source/target groups (routing around the group handles them)
442
+ if (n.groupId && (n.groupId === source.groupId || n.groupId === target.groupId)) continue;
443
+ // Skip nodes inside a group whose bounding box is already blocking
444
+ if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
445
+ const nodeRect: Rect = { x: n.x - n.width / 2, y: n.y - n.height / 2, width: n.width, height: n.height };
446
+ if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
447
+ blocking.push(nodeRect);
448
+ }
449
+ }
450
+
451
+ if (blocking.length === 0) {
452
+ // Direct 2-point path — use port-ordered entry/exit points when provided.
453
+ const sp = srcExitPt ?? nodeBorderPoint(source, tc);
454
+ const tp = tgtEnterPt ?? nodeBorderPoint(target, sp);
455
+ return [sp, tp];
456
+ }
457
+
458
+ const obsLeft = Math.min(...blocking.map((o) => o.x));
459
+ const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
460
+
461
+ const routeY = findRoutingLane(blocking, tc.y, margin);
462
+
463
+ // Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
464
+ // direction when an obstacle's bounding box extends past source or target.
465
+ const exitX = direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
466
+ const enterX = direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
467
+ const exitPt : Pt = { x: exitX, y: routeY };
468
+ const enterPt : Pt = { x: enterX, y: routeY };
469
+
470
+ const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
471
+
472
+ if (srcExitPt) {
473
+ return [srcExitPt, exitPt, enterPt, tp];
474
+ }
475
+
476
+ return [nodeBorderPoint(source, exitPt), exitPt, enterPt, tp];
477
+ }
136
478
 
137
479
  /** Compute the point on a node's border closest to an external target point. */
138
480
  function nodeBorderPoint(
@@ -597,12 +939,15 @@ function renderEdgePaths(
597
939
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
598
940
  edges: InfraLayoutEdge[],
599
941
  nodes: InfraLayoutNode[],
942
+ groups: InfraLayoutGroup[],
600
943
  palette: PaletteColors,
601
944
  isDark: boolean,
602
945
  animate: boolean,
946
+ direction: 'LR' | 'TB',
603
947
  ) {
604
948
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
605
949
  const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
950
+ const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
606
951
 
607
952
  for (const edge of edges) {
608
953
  if (edge.points.length === 0) continue;
@@ -612,29 +957,13 @@ function renderEdgePaths(
612
957
  const color = edgeColor(edge, palette);
613
958
  const strokeW = edgeWidth();
614
959
 
615
- // Ensure dagre waypoints are ordered source→target (not guaranteed by dagre)
616
- let pts = edge.points;
617
- if (sourceNode && targetNode && pts.length >= 2) {
618
- const first = pts[0];
619
- const distFirstToSource = (first.x - sourceNode.x) ** 2 + (first.y - sourceNode.y) ** 2;
620
- const distFirstToTarget = (first.x - targetNode.x) ** 2 + (first.y - targetNode.y) ** 2;
621
- if (distFirstToTarget < distFirstToSource) {
622
- pts = [...pts].reverse();
623
- }
624
- }
625
-
626
- // Prepend source border point and append target border point so edges
627
- // visually connect to node boundaries (dagre waypoints float between nodes)
628
- if (sourceNode && pts.length > 0) {
629
- const bp = nodeBorderPoint(sourceNode, pts[0]);
630
- pts = [bp, ...pts];
631
- }
632
- if (targetNode && pts.length > 0) {
633
- const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
634
- pts = [...pts, bp];
635
- }
636
-
637
- const pathD = lineGenerator(pts) ?? '';
960
+ if (!sourceNode || !targetNode) continue;
961
+ const key = `${edge.sourceId}:${edge.targetId}`;
962
+ const pts = edgeWaypoints(
963
+ sourceNode, targetNode, groups, nodes, direction, 30,
964
+ srcPts.get(key), tgtPts.get(key),
965
+ );
966
+ const pathD = buildPathD(pts, direction);
638
967
  const edgeG = svg.append('g')
639
968
  .attr('class', 'infra-edge')
640
969
  .attr('data-line-number', edge.lineNumber);
@@ -674,16 +1003,30 @@ function renderEdgePaths(
674
1003
  function renderEdgeLabels(
675
1004
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
676
1005
  edges: InfraLayoutEdge[],
1006
+ nodes: InfraLayoutNode[],
1007
+ groups: InfraLayoutGroup[],
677
1008
  palette: PaletteColors,
678
1009
  isDark: boolean,
679
1010
  animate: boolean,
1011
+ direction: 'LR' | 'TB',
680
1012
  ) {
1013
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
1014
+ const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
681
1015
  for (const edge of edges) {
682
1016
  if (edge.points.length === 0) continue;
683
1017
  if (!edge.label) continue;
684
1018
 
685
- const midIdx = Math.floor(edge.points.length / 2);
686
- const midPt = edge.points[midIdx];
1019
+ const sourceNode = nodeMap.get(edge.sourceId);
1020
+ const targetNode = nodeMap.get(edge.targetId);
1021
+ if (!sourceNode || !targetNode) continue;
1022
+
1023
+ const key = `${edge.sourceId}:${edge.targetId}`;
1024
+ const wps = edgeWaypoints(
1025
+ sourceNode, targetNode, groups, nodes, direction, 30,
1026
+ srcPts.get(key), tgtPts.get(key),
1027
+ );
1028
+ // Label midpoint: middle waypoint of the routed path
1029
+ const midPt = wps[Math.floor(wps.length / 2)];
687
1030
  const labelText = edge.label;
688
1031
 
689
1032
  const g = svg.append('g')
@@ -711,7 +1054,7 @@ function renderEdgeLabels(
711
1054
 
712
1055
  // When animated, add a wider invisible hover zone so labels appear on hover
713
1056
  if (animate) {
714
- const pathD = lineGenerator(edge.points) ?? '';
1057
+ const pathD = buildPathD(wps, direction);
715
1058
  g.insert('path', ':first-child')
716
1059
  .attr('d', pathD)
717
1060
  .attr('fill', 'none')
@@ -1541,7 +1884,7 @@ export function renderInfra(
1541
1884
 
1542
1885
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1543
1886
  renderGroups(svg, layout.groups, palette, isDark);
1544
- renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
1887
+ renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
1545
1888
  const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
1546
1889
  const scaledGroupIds = new Set<string>(
1547
1890
  layout.groups
@@ -1556,7 +1899,7 @@ export function renderInfra(
1556
1899
  if (shouldAnimate) {
1557
1900
  renderRejectParticles(svg, layout.nodes);
1558
1901
  }
1559
- renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
1902
+ renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
1560
1903
 
1561
1904
  // Legend at bottom
1562
1905
  if (hasLegend) {
@@ -28,8 +28,8 @@ export interface ISLayoutEdge {
28
28
  status: import('./types').InitiativeStatus;
29
29
  lineNumber: number;
30
30
  // Layout contract for points[]:
31
- // Back-edges: 3 points — [src.bottom/top_center, arc_control, tgt.bottom/top_center]
32
- // Y-displaced: 3 points — [src.bottom/top_center, diagonal_mid, tgt.left_center]
31
+ // Back-edges: 5 points — [src.top/bottom_center, depart_ctrl, arc_control, approach_ctrl, tgt.top/bottom_center]
32
+ // Top/bottom-exit: 4 points — [src.top/bottom_center, depart_ctrl, tgt_approach, tgt.left_center]
33
33
  // 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
34
34
  // fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
35
35
  points: { x: number; y: number }[];
@@ -81,6 +81,7 @@ const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom
81
81
  const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
82
82
  const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
83
83
  const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
84
+ const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical departure tangent for top/bottom-exit elbows
84
85
  const CHAR_WIDTH_RATIO = 0.6;
85
86
  const NODE_FONT_SIZE = 13;
86
87
  const NODE_TEXT_PADDING = 12;
@@ -221,15 +222,14 @@ export function layoutInitiativeStatus(
221
222
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
222
223
  const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
223
224
  const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
224
- const step = Math.min((enterX - exitX) * 0.15, 20);
225
+ const step = Math.max(0, Math.min((enterX - exitX) * 0.15, 20)); // clamped ≥0: guards overlapping nodes
225
226
 
226
- // 4-branch routing: isBackEdge → isYDisplaced → 4-point elbow → fixedDagrePoints
227
- const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
228
- const isYDisplaced = !isBackEdge
229
- && Math.abs(tgt.y - src.y) > NODESEP;
230
- // Note: hasIntermediateRank guard intentionally omitted from isYDisplaced the > NODESEP threshold
231
- // already filters normal adjacent-rank fans (which spread by ~NODESEP); the guard would block the
232
- // original use case (fan targets far below source in the same adjacent rank).
227
+ // 5-branch routing: isBackEdge → isTopExitisBottomExit → 4-point elbow → fixedDagrePoints
228
+ const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
229
+ // Guards: tgt.x > src.x (strict) keeps step positive; !hasIntermediateRank defers multi-rank
230
+ // displaced edges to fixedDagrePoints so dagre can route around intermediate nodes.
231
+ const isTopExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y < src.y - NODESEP;
232
+ const isBottomExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y > src.y + NODESEP;
233
233
 
234
234
  let points: { x: number; y: number }[];
235
235
 
@@ -248,36 +248,52 @@ export function layoutInitiativeStatus(
248
248
  ? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
249
249
  : rawMidX;
250
250
  const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
251
+ // Clamped departure/approach control points give near-orthogonal tangents at node edges.
252
+ // For narrow back-edges (|src.x - tgt.x| < 2*TOP_EXIT_STEP), clamps degrade to midX±1 — valid.
253
+ const srcDepart = Math.max(midX + 1, src.x - TOP_EXIT_STEP);
254
+ const tgtApproach = Math.min(midX - 1, tgt.x + TOP_EXIT_STEP);
251
255
  if (routeAbove) {
252
256
  const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
253
257
  points = [
254
- { x: src.x, y: src.y - srcHalfH },
255
- { x: midX, y: arcY },
256
- { x: tgt.x, y: tgt.y - tgtHalfH },
258
+ { x: src.x, y: src.y - srcHalfH },
259
+ { x: srcDepart, y: src.y - srcHalfH - TOP_EXIT_STEP },
260
+ { x: midX, y: arcY },
261
+ { x: tgtApproach, y: tgt.y - tgtHalfH - TOP_EXIT_STEP },
262
+ { x: tgt.x, y: tgt.y - tgtHalfH },
257
263
  ];
258
264
  } else {
259
265
  const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
260
266
  points = [
261
- { x: src.x, y: src.y + srcHalfH },
262
- { x: midX, y: arcY },
263
- { x: tgt.x, y: tgt.y + tgtHalfH },
267
+ { x: src.x, y: src.y + srcHalfH },
268
+ { x: srcDepart, y: src.y + srcHalfH + TOP_EXIT_STEP },
269
+ { x: midX, y: arcY },
270
+ { x: tgtApproach, y: tgt.y + tgtHalfH + TOP_EXIT_STEP },
271
+ { x: tgt.x, y: tgt.y + tgtHalfH },
264
272
  ];
265
273
  }
266
- } else if (isYDisplaced) {
267
- // 3-point diagonal: exit bottom/top-center of source, enter left-center of target.
268
- // yOffset applied as X-spread at exit AND Y-spread at entry so parallel edges maintain
269
- // a consistent visual gap along their entire length (not just at one end).
270
- const exitY = tgt.y > src.y + NODESEP
271
- ? src.y + src.height / 2 // target is below exit bottom
272
- : src.y - src.height / 2; // target is above — exit top
273
- const spreadExitX = src.x + yOffset;
274
- const spreadEntryY = tgt.y + yOffset;
275
- const midX = (spreadExitX + enterX) / 2; // always monotone ✓ (yOffset << node gap)
276
- const midY = (exitY + spreadEntryY) / 2;
274
+ } else if (isTopExit) {
275
+ // 4-point top-exit elbow: exits top of source ~vertically, arrives left of target horizontally.
276
+ // Top exit keeps this edge ABOVE the horizontal right-exit bundle avoids crossings.
277
+ // yOffset repurposed as X-spread for top/bottom-exit branches (same magnitude, different axis).
278
+ // p1x: floor at src.x prevents negative-yOffset edges from going left of origin (breaks monotone X);
279
+ // ceiling at midpoint-1 prevents overshooting for large positive yOffset (±32px for 5 parallel edges).
280
+ const exitY = src.y - src.height / 2;
281
+ const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
277
282
  points = [
278
- { x: spreadExitX, y: exitY },
279
- { x: midX, y: midY },
280
- { x: enterX, y: spreadEntryY },
283
+ { x: src.x, y: exitY },
284
+ { x: p1x, y: exitY - TOP_EXIT_STEP },
285
+ { x: enterX - step, y: tgt.y + yOffset },
286
+ { x: enterX, y: tgt.y },
287
+ ];
288
+ } else if (isBottomExit) {
289
+ // 4-point bottom-exit elbow: mirror of top-exit. Keeps edge BELOW the horizontal bundle.
290
+ const exitY = src.y + src.height / 2;
291
+ const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
292
+ points = [
293
+ { x: src.x, y: exitY },
294
+ { x: p1x, y: exitY + TOP_EXIT_STEP },
295
+ { x: enterX - step, y: tgt.y + yOffset },
296
+ { x: enterX, y: tgt.y },
281
297
  ];
282
298
  } else if (tgt.x > src.x && !hasIntermediateRank) {
283
299
  // 4-point elbow: adjacent-rank forward edges (unchanged)