@diagrammo/dgmo 0.6.0 → 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.
@@ -16,6 +16,19 @@ import type { InfraRole } from './roles';
16
16
  import { parseInfra } from './parser';
17
17
  import { computeInfra } from './compute';
18
18
  import { layoutInfra } from './layout';
19
+ import {
20
+ LEGEND_HEIGHT,
21
+ LEGEND_PILL_PAD,
22
+ LEGEND_PILL_FONT_SIZE,
23
+ LEGEND_PILL_FONT_W,
24
+ LEGEND_CAPSULE_PAD,
25
+ LEGEND_DOT_R,
26
+ LEGEND_ENTRY_FONT_SIZE,
27
+ LEGEND_ENTRY_FONT_W,
28
+ LEGEND_ENTRY_DOT_GAP,
29
+ LEGEND_ENTRY_TRAIL,
30
+ LEGEND_GROUP_GAP,
31
+ } from '../utils/legend-constants';
19
32
 
20
33
  // ============================================================
21
34
  // Constants
@@ -39,22 +52,7 @@ const NODE_PAD_BOTTOM = 10;
39
52
  const COLLAPSE_BAR_HEIGHT = 6;
40
53
  const COLLAPSE_BAR_INSET = 0;
41
54
 
42
- // Legend pill/capsule constants (matching org chart style)
43
- const LEGEND_HEIGHT = 28;
44
- const LEGEND_PILL_PAD = 16;
45
- const LEGEND_PILL_FONT_SIZE = 11;
46
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
47
- const LEGEND_CAPSULE_PAD = 4;
48
- const LEGEND_DOT_R = 4;
49
- const LEGEND_ENTRY_FONT_SIZE = 10;
50
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
51
- const LEGEND_ENTRY_DOT_GAP = 4;
52
- const LEGEND_ENTRY_TRAIL = 8;
53
- const LEGEND_GROUP_GAP = 12;
54
- const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram
55
- const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
56
- const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
57
- const SPEED_BADGE_GAP = 6; // gap between speed option slots
55
+ const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
58
56
 
59
57
  // Health colors (from UX spec)
60
58
  const COLOR_HEALTHY = '#22c55e';
@@ -131,10 +129,352 @@ const REJECT_COUNT_MAX = 3;
131
129
  // Edge path generator
132
130
  // ============================================================
133
131
 
134
- const lineGenerator = d3Shape.line<{ x: number; y: number }>()
135
- .x((d) => d.x)
136
- .y((d) => d.y)
137
- .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
+ }
138
478
 
139
479
  /** Compute the point on a node's border closest to an external target point. */
140
480
  function nodeBorderPoint(
@@ -599,12 +939,15 @@ function renderEdgePaths(
599
939
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
600
940
  edges: InfraLayoutEdge[],
601
941
  nodes: InfraLayoutNode[],
942
+ groups: InfraLayoutGroup[],
602
943
  palette: PaletteColors,
603
944
  isDark: boolean,
604
945
  animate: boolean,
946
+ direction: 'LR' | 'TB',
605
947
  ) {
606
948
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
607
949
  const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
950
+ const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
608
951
 
609
952
  for (const edge of edges) {
610
953
  if (edge.points.length === 0) continue;
@@ -614,29 +957,13 @@ function renderEdgePaths(
614
957
  const color = edgeColor(edge, palette);
615
958
  const strokeW = edgeWidth();
616
959
 
617
- // Ensure dagre waypoints are ordered source→target (not guaranteed by dagre)
618
- let pts = edge.points;
619
- if (sourceNode && targetNode && pts.length >= 2) {
620
- const first = pts[0];
621
- const distFirstToSource = (first.x - sourceNode.x) ** 2 + (first.y - sourceNode.y) ** 2;
622
- const distFirstToTarget = (first.x - targetNode.x) ** 2 + (first.y - targetNode.y) ** 2;
623
- if (distFirstToTarget < distFirstToSource) {
624
- pts = [...pts].reverse();
625
- }
626
- }
627
-
628
- // Prepend source border point and append target border point so edges
629
- // visually connect to node boundaries (dagre waypoints float between nodes)
630
- if (sourceNode && pts.length > 0) {
631
- const bp = nodeBorderPoint(sourceNode, pts[0]);
632
- pts = [bp, ...pts];
633
- }
634
- if (targetNode && pts.length > 0) {
635
- const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
636
- pts = [...pts, bp];
637
- }
638
-
639
- 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);
640
967
  const edgeG = svg.append('g')
641
968
  .attr('class', 'infra-edge')
642
969
  .attr('data-line-number', edge.lineNumber);
@@ -676,16 +1003,30 @@ function renderEdgePaths(
676
1003
  function renderEdgeLabels(
677
1004
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
678
1005
  edges: InfraLayoutEdge[],
1006
+ nodes: InfraLayoutNode[],
1007
+ groups: InfraLayoutGroup[],
679
1008
  palette: PaletteColors,
680
1009
  isDark: boolean,
681
1010
  animate: boolean,
1011
+ direction: 'LR' | 'TB',
682
1012
  ) {
1013
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
1014
+ const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
683
1015
  for (const edge of edges) {
684
1016
  if (edge.points.length === 0) continue;
685
1017
  if (!edge.label) continue;
686
1018
 
687
- const midIdx = Math.floor(edge.points.length / 2);
688
- 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)];
689
1030
  const labelText = edge.label;
690
1031
 
691
1032
  const g = svg.append('g')
@@ -713,7 +1054,7 @@ function renderEdgeLabels(
713
1054
 
714
1055
  // When animated, add a wider invisible hover zone so labels appear on hover
715
1056
  if (animate) {
716
- const pathD = lineGenerator(edge.points) ?? '';
1057
+ const pathD = buildPathD(wps, direction);
717
1058
  g.insert('path', ':first-child')
718
1059
  .attr('d', pathD)
719
1060
  .attr('fill', 'none')
@@ -1311,21 +1652,6 @@ export function computeInfraLegendGroups(
1311
1652
  return groups;
1312
1653
  }
1313
1654
 
1314
- /** Compute total width for the playback pill (speed only). */
1315
- function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
1316
- if (!playback) return 0;
1317
- const pillWidth = 'Playback'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1318
- if (!playback.expanded) return pillWidth;
1319
-
1320
- let entriesW = 8; // gap after pill
1321
- entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1322
- for (const s of playback.speedOptions) {
1323
- entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
1324
- }
1325
- return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1326
- }
1327
-
1328
- /** Whether a separate Scenario pill should render. */
1329
1655
  function renderLegend(
1330
1656
  rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1331
1657
  legendGroups: InfraLegendGroup[],
@@ -1334,21 +1660,21 @@ function renderLegend(
1334
1660
  palette: PaletteColors,
1335
1661
  isDark: boolean,
1336
1662
  activeGroup: string | null,
1337
- playback?: InfraPlaybackState,
1338
1663
  ) {
1339
- if (legendGroups.length === 0 && !playback) return;
1664
+ if (legendGroups.length === 0) return;
1340
1665
 
1341
1666
  const legendG = rootSvg.append('g')
1342
1667
  .attr('transform', `translate(0, ${legendY})`);
1343
1668
 
1669
+ if (activeGroup) {
1670
+ legendG.attr('data-legend-active', activeGroup.toLowerCase());
1671
+ }
1672
+
1344
1673
  // Compute centered positions
1345
1674
  const effectiveW = (g: InfraLegendGroup) =>
1346
1675
  activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
1347
- const playbackW = computePlaybackWidth(playback);
1348
- const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
1349
1676
  const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
1350
- + (legendGroups.length - 1) * LEGEND_GROUP_GAP
1351
- + trailingGaps + playbackW;
1677
+ + (legendGroups.length - 1) * LEGEND_GROUP_GAP;
1352
1678
  let cursorX = (totalWidth - totalLegendW) / 2;
1353
1679
 
1354
1680
  for (const group of legendGroups) {
@@ -1366,7 +1692,6 @@ function renderLegend(
1366
1692
  .attr('transform', `translate(${cursorX}, 0)`)
1367
1693
  .attr('class', 'infra-legend-group')
1368
1694
  .attr('data-legend-group', group.name.toLowerCase())
1369
- .attr('data-legend-type', group.type)
1370
1695
  .style('cursor', 'pointer');
1371
1696
 
1372
1697
  // Outer capsule background (active only)
@@ -1400,7 +1725,7 @@ function renderLegend(
1400
1725
  .attr('height', pillH)
1401
1726
  .attr('rx', pillH / 2)
1402
1727
  .attr('fill', 'none')
1403
- .attr('stroke', isDark ? mix(palette.textMuted, palette.bg, 50) : mix(palette.textMuted, palette.bg, 50))
1728
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1404
1729
  .attr('stroke-width', 0.75);
1405
1730
  }
1406
1731
 
@@ -1422,15 +1747,12 @@ function renderLegend(
1422
1747
  const entryG = gEl
1423
1748
  .append('g')
1424
1749
  .attr('class', 'infra-legend-entry')
1425
- .attr('data-legend-entry', entry.key)
1426
- .attr('data-legend-type', group.type)
1750
+ .attr('data-legend-entry', entry.key.toLowerCase())
1427
1751
  .attr('data-legend-color', entry.color)
1752
+ .attr('data-legend-type', group.type)
1753
+ .attr('data-legend-tag-group', group.type === 'tag' ? (group.tagKey ?? '') : null)
1428
1754
  .style('cursor', 'pointer');
1429
1755
 
1430
- if (group.type === 'tag' && group.tagKey) {
1431
- entryG.attr('data-legend-tag-group', group.tagKey);
1432
- }
1433
-
1434
1756
  entryG.append('circle')
1435
1757
  .attr('cx', entryX + LEGEND_DOT_R)
1436
1758
  .attr('cy', LEGEND_HEIGHT / 2)
@@ -1453,127 +1775,12 @@ function renderLegend(
1453
1775
  cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
1454
1776
  }
1455
1777
 
1456
- // Playback pill — speed + pause only
1457
- if (playback) {
1458
- const isExpanded = playback.expanded;
1459
- const groupBg = isDark
1460
- ? mix(palette.bg, palette.text, 85)
1461
- : mix(palette.bg, palette.text, 92);
1462
-
1463
- const pillLabel = 'Playback';
1464
- const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1465
- const fullW = computePlaybackWidth(playback);
1466
-
1467
- const pbG = legendG
1468
- .append('g')
1469
- .attr('transform', `translate(${cursorX}, 0)`)
1470
- .attr('class', 'infra-legend-group infra-playback-pill')
1471
- .style('cursor', 'pointer');
1472
-
1473
- if (isExpanded) {
1474
- pbG.append('rect')
1475
- .attr('width', fullW)
1476
- .attr('height', LEGEND_HEIGHT)
1477
- .attr('rx', LEGEND_HEIGHT / 2)
1478
- .attr('fill', groupBg);
1479
- }
1480
-
1481
- const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1482
- const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1483
- const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
1484
-
1485
- pbG.append('rect')
1486
- .attr('x', pillXOff).attr('y', pillYOff)
1487
- .attr('width', pillWidth).attr('height', pillH)
1488
- .attr('rx', pillH / 2)
1489
- .attr('fill', isExpanded ? palette.bg : groupBg);
1490
-
1491
- if (isExpanded) {
1492
- pbG.append('rect')
1493
- .attr('x', pillXOff).attr('y', pillYOff)
1494
- .attr('width', pillWidth).attr('height', pillH)
1495
- .attr('rx', pillH / 2)
1496
- .attr('fill', 'none')
1497
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1498
- .attr('stroke-width', 0.75);
1499
- }
1500
-
1501
- pbG.append('text')
1502
- .attr('x', pillXOff + pillWidth / 2)
1503
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1504
- .attr('font-family', FONT_FAMILY)
1505
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
1506
- .attr('font-weight', '500')
1507
- .attr('fill', isExpanded ? palette.text : palette.textMuted)
1508
- .attr('text-anchor', 'middle')
1509
- .text(pillLabel);
1510
-
1511
- if (isExpanded) {
1512
- let entryX = pillXOff + pillWidth + 8;
1513
- const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
1514
-
1515
- const ppLabel = playback.paused ? '▶' : '⏸';
1516
- pbG.append('text')
1517
- .attr('x', entryX).attr('y', entryY)
1518
- .attr('font-family', FONT_FAMILY)
1519
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
1520
- .attr('fill', palette.textMuted)
1521
- .attr('data-playback-action', 'toggle-pause')
1522
- .style('cursor', 'pointer')
1523
- .text(ppLabel);
1524
- entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
1525
-
1526
- for (const s of playback.speedOptions) {
1527
- const label = `${s}x`;
1528
- const isActive = playback.speed === s;
1529
- const slotW = label.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2;
1530
- const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
1531
- const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
1532
-
1533
- // Wrap in <g> with data attrs so a single element carries the action,
1534
- // and both rect and text inherit the hit target cleanly.
1535
- const speedG = pbG.append('g')
1536
- .attr('data-playback-action', 'set-speed')
1537
- .attr('data-playback-value', String(s))
1538
- .style('cursor', 'pointer');
1539
-
1540
- // Badge rect: filled for active, transparent hit-target for inactive
1541
- speedG.append('rect')
1542
- .attr('x', entryX)
1543
- .attr('y', badgeY)
1544
- .attr('width', slotW)
1545
- .attr('height', badgeH)
1546
- .attr('rx', badgeH / 2)
1547
- .attr('fill', isActive ? palette.primary : 'transparent');
1548
-
1549
- speedG.append('text')
1550
- .attr('x', entryX + slotW / 2).attr('y', entryY)
1551
- .attr('font-family', FONT_FAMILY)
1552
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1553
- .attr('font-weight', isActive ? '600' : '400')
1554
- .attr('fill', isActive ? palette.bg : palette.textMuted)
1555
- .attr('text-anchor', 'middle')
1556
- .text(label);
1557
- entryX += slotW + SPEED_BADGE_GAP;
1558
- }
1559
- }
1560
-
1561
- cursorX += fullW + LEGEND_GROUP_GAP;
1562
- }
1563
-
1564
1778
  }
1565
1779
 
1566
1780
  // ============================================================
1567
1781
  // Main render
1568
1782
  // ============================================================
1569
1783
 
1570
- export interface InfraPlaybackState {
1571
- expanded: boolean;
1572
- paused: boolean;
1573
- speed: number;
1574
- speedOptions: readonly number[];
1575
- }
1576
-
1577
1784
  export function renderInfra(
1578
1785
  container: HTMLDivElement,
1579
1786
  layout: InfraLayoutResult,
@@ -1584,7 +1791,7 @@ export function renderInfra(
1584
1791
  tagGroups?: InfraTagGroup[],
1585
1792
  activeGroup?: string | null,
1586
1793
  animate?: boolean,
1587
- playback?: InfraPlaybackState | null,
1794
+ _playback?: unknown,
1588
1795
  expandedNodeIds?: Set<string> | null,
1589
1796
  exportMode?: boolean,
1590
1797
  collapsedNodes?: Set<string> | null,
@@ -1594,7 +1801,7 @@ export function renderInfra(
1594
1801
 
1595
1802
  // Build legend groups
1596
1803
  const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
1597
- const hasLegend = legendGroups.length > 0 || !!playback;
1804
+ const hasLegend = legendGroups.length > 0;
1598
1805
  // In app mode (not export), legend is rendered as a separate fixed-size SVG
1599
1806
  const fixedLegend = !exportMode && hasLegend;
1600
1807
  const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
@@ -1677,7 +1884,7 @@ export function renderInfra(
1677
1884
 
1678
1885
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1679
1886
  renderGroups(svg, layout.groups, palette, isDark);
1680
- renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
1887
+ renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
1681
1888
  const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
1682
1889
  const scaledGroupIds = new Set<string>(
1683
1890
  layout.groups
@@ -1692,7 +1899,7 @@ export function renderInfra(
1692
1899
  if (shouldAnimate) {
1693
1900
  renderRejectParticles(svg, layout.nodes);
1694
1901
  }
1695
- renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
1902
+ renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
1696
1903
 
1697
1904
  // Legend at bottom
1698
1905
  if (hasLegend) {
@@ -1707,9 +1914,9 @@ export function renderInfra(
1707
1914
  .attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
1708
1915
  .attr('preserveAspectRatio', 'xMidYMid meet')
1709
1916
  .style('display', 'block');
1710
- renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
1917
+ renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null);
1711
1918
  } else {
1712
- renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null, playback ?? undefined);
1919
+ renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null);
1713
1920
  }
1714
1921
  }
1715
1922
  }