@diagrammo/dgmo 0.8.3 → 0.8.4

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.
Files changed (112) hide show
  1. package/.claude/commands/dgmo-diagram-this.md +60 -0
  2. package/.claude/commands/dgmo-document-project.md +128 -0
  3. package/.claude/commands/dgmo.md +185 -50
  4. package/.cursorrules +32 -37
  5. package/.github/copilot-instructions.md +35 -44
  6. package/.windsurfrules +32 -37
  7. package/README.md +4 -4
  8. package/dist/cli.cjs +153 -153
  9. package/dist/editor.cjs +336 -0
  10. package/dist/editor.cjs.map +1 -0
  11. package/dist/editor.d.cts +27 -0
  12. package/dist/editor.d.ts +27 -0
  13. package/dist/editor.js +305 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/index.cjs +3336 -1055
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.js +3336 -1055
  18. package/dist/index.js.map +1 -1
  19. package/docs/language-reference.md +30 -29
  20. package/gallery/fixtures/arc.dgmo +18 -0
  21. package/gallery/fixtures/area.dgmo +19 -0
  22. package/gallery/fixtures/bar-stacked.dgmo +10 -0
  23. package/gallery/fixtures/bar.dgmo +10 -0
  24. package/gallery/fixtures/c4-full.dgmo +52 -0
  25. package/gallery/fixtures/c4.dgmo +17 -0
  26. package/gallery/fixtures/chord.dgmo +12 -0
  27. package/gallery/fixtures/class-basic.dgmo +14 -0
  28. package/gallery/fixtures/class-full.dgmo +43 -0
  29. package/gallery/fixtures/doughnut.dgmo +8 -0
  30. package/gallery/fixtures/flowchart-basic.dgmo +3 -0
  31. package/gallery/fixtures/flowchart-colors.dgmo +5 -0
  32. package/gallery/fixtures/flowchart-complex.dgmo +17 -0
  33. package/gallery/fixtures/flowchart-decision.dgmo +5 -0
  34. package/gallery/fixtures/flowchart-full.dgmo +13 -0
  35. package/gallery/fixtures/flowchart-groups.dgmo +10 -0
  36. package/gallery/fixtures/flowchart-loop.dgmo +7 -0
  37. package/gallery/fixtures/flowchart-nested.dgmo +7 -0
  38. package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
  39. package/gallery/fixtures/function.dgmo +8 -0
  40. package/gallery/fixtures/funnel.dgmo +7 -0
  41. package/gallery/fixtures/gantt-full.dgmo +49 -0
  42. package/gallery/fixtures/gantt.dgmo +42 -0
  43. package/gallery/fixtures/heatmap.dgmo +8 -0
  44. package/gallery/fixtures/infra-full.dgmo +78 -0
  45. package/gallery/fixtures/infra-overload.dgmo +25 -0
  46. package/gallery/fixtures/infra.dgmo +47 -0
  47. package/gallery/fixtures/initiative-status-full.dgmo +46 -0
  48. package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
  49. package/gallery/fixtures/initiative-status.dgmo +9 -0
  50. package/gallery/fixtures/line.dgmo +19 -0
  51. package/gallery/fixtures/multi-line.dgmo +11 -0
  52. package/gallery/fixtures/org-basic.dgmo +16 -0
  53. package/gallery/fixtures/org-full.dgmo +69 -0
  54. package/gallery/fixtures/org-teams.dgmo +25 -0
  55. package/gallery/fixtures/pie.dgmo +9 -0
  56. package/gallery/fixtures/polar-area.dgmo +8 -0
  57. package/gallery/fixtures/quadrant.dgmo +18 -0
  58. package/gallery/fixtures/radar.dgmo +8 -0
  59. package/gallery/fixtures/sankey.dgmo +31 -0
  60. package/gallery/fixtures/scatter.dgmo +21 -0
  61. package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
  62. package/gallery/fixtures/sequence-tags.dgmo +41 -0
  63. package/gallery/fixtures/sequence.dgmo +35 -0
  64. package/gallery/fixtures/sitemap-basic.dgmo +12 -0
  65. package/gallery/fixtures/sitemap-full.dgmo +156 -0
  66. package/gallery/fixtures/slope.dgmo +8 -0
  67. package/gallery/fixtures/spr-eras.dgmo +62 -0
  68. package/gallery/fixtures/state.dgmo +30 -0
  69. package/gallery/fixtures/timeline-intraday.dgmo +14 -0
  70. package/gallery/fixtures/timeline.dgmo +32 -0
  71. package/gallery/fixtures/venn.dgmo +10 -0
  72. package/gallery/fixtures/wordcloud.dgmo +24 -0
  73. package/package.json +51 -2
  74. package/src/c4/layout.ts +372 -90
  75. package/src/c4/parser.ts +100 -55
  76. package/src/chart.ts +91 -28
  77. package/src/class/parser.ts +41 -12
  78. package/src/cli.ts +168 -61
  79. package/src/completion.ts +378 -183
  80. package/src/d3.ts +887 -288
  81. package/src/dgmo-mermaid.ts +16 -13
  82. package/src/dgmo-router.ts +69 -23
  83. package/src/echarts.ts +646 -153
  84. package/src/editor/dgmo.grammar +69 -0
  85. package/src/editor/dgmo.grammar.d.ts +2 -0
  86. package/src/editor/dgmo.grammar.js +18 -0
  87. package/src/editor/dgmo.grammar.terms.d.ts +5 -0
  88. package/src/editor/dgmo.grammar.terms.js +35 -0
  89. package/src/editor/highlight.ts +36 -0
  90. package/src/editor/index.ts +28 -0
  91. package/src/editor/keywords.ts +220 -0
  92. package/src/editor/tokens.ts +30 -0
  93. package/src/er/parser.ts +48 -14
  94. package/src/er/renderer.ts +112 -53
  95. package/src/gantt/calculator.ts +91 -29
  96. package/src/gantt/parser.ts +197 -71
  97. package/src/gantt/renderer.ts +1120 -350
  98. package/src/graph/flowchart-parser.ts +46 -25
  99. package/src/graph/state-parser.ts +47 -17
  100. package/src/infra/parser.ts +157 -53
  101. package/src/infra/renderer.ts +723 -271
  102. package/src/initiative-status/parser.ts +138 -44
  103. package/src/kanban/parser.ts +25 -14
  104. package/src/org/layout.ts +111 -44
  105. package/src/org/parser.ts +69 -22
  106. package/src/palettes/index.ts +3 -2
  107. package/src/sequence/parser.ts +193 -61
  108. package/src/sitemap/parser.ts +65 -29
  109. package/src/utils/arrows.ts +2 -22
  110. package/src/utils/duration.ts +39 -21
  111. package/src/utils/legend-constants.ts +0 -2
  112. package/src/utils/parsing.ts +75 -31
@@ -9,8 +9,18 @@ import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import type { InfraTagGroup } from './types';
11
11
  import { resolveColor } from '../colors';
12
- import type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './layout';
13
- import { inferRoles, collectDiagramRoles, collectFanoutSourceIds, FANOUT_ROLE } from './roles';
12
+ import type {
13
+ InfraLayoutResult,
14
+ InfraLayoutNode,
15
+ InfraLayoutEdge,
16
+ InfraLayoutGroup,
17
+ } from './layout';
18
+ import {
19
+ inferRoles,
20
+ collectDiagramRoles,
21
+ collectFanoutSourceIds,
22
+ FANOUT_ROLE,
23
+ } from './roles';
14
24
  import { parseInfra } from './parser';
15
25
  import { computeInfra } from './compute';
16
26
  import { layoutInfra } from './layout';
@@ -26,7 +36,12 @@ import {
26
36
  LEGEND_GROUP_GAP,
27
37
  measureLegendText,
28
38
  } from '../utils/legend-constants';
29
- import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y, TITLE_OFFSET } from '../utils/title-constants';
39
+ import {
40
+ TITLE_FONT_SIZE,
41
+ TITLE_FONT_WEIGHT,
42
+ TITLE_Y,
43
+ TITLE_OFFSET,
44
+ } from '../utils/title-constants';
30
45
 
31
46
  // ============================================================
32
47
  // Constants
@@ -51,7 +66,7 @@ const COLLAPSE_BAR_INSET = 0;
51
66
  const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
52
67
  const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
53
68
  const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
54
- const SPEED_BADGE_GAP = 6; // gap between speed option slots
69
+ const SPEED_BADGE_GAP = 6; // gap between speed option slots
55
70
 
56
71
  // Health colors (from UX spec)
57
72
  const COLOR_HEALTHY = '#22c55e';
@@ -60,24 +75,39 @@ const COLOR_OVERLOADED = '#ef4444';
60
75
  /** SLO thresholds resolved for a single node (chart-level + per-node override). */
61
76
  interface NodeSlo {
62
77
  availThreshold: number | null; // fraction e.g. 0.999
63
- latencyP90: number | null; // ms e.g. 200
64
- warningMargin: number; // fraction e.g. 0.05
78
+ latencyP90: number | null; // ms e.g. 200
79
+ warningMargin: number; // fraction e.g. 0.05
65
80
  }
66
81
 
67
82
  /** Resolve effective SLO for a node: per-node properties take precedence over chart-level options.
68
83
  * Returns null if neither availThreshold nor latencyP90 is declared. */
69
- function resolveNodeSlo(node: InfraLayoutNode, diagramOptions: Record<string, string>): NodeSlo | null {
84
+ function resolveNodeSlo(
85
+ node: InfraLayoutNode,
86
+ diagramOptions: Record<string, string>
87
+ ): NodeSlo | null {
70
88
  const nodeProp = (key: string) => node.properties.find((p) => p.key === key);
71
89
 
72
- const availRaw = nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
73
- const latencyRaw = nodeProp('slo-p90-latency-ms')?.value ?? diagramOptions['slo-p90-latency-ms'];
74
- const marginRaw = nodeProp('slo-warning-margin')?.value ?? diagramOptions['slo-warning-margin'];
75
-
76
- const availParsed = availRaw != null ? parseFloat(String(availRaw).replace('%', '')) / 100 : NaN;
90
+ const availRaw =
91
+ nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
92
+ const latencyRaw =
93
+ nodeProp('slo-p90-latency-ms')?.value ??
94
+ diagramOptions['slo-p90-latency-ms'];
95
+ const marginRaw =
96
+ nodeProp('slo-warning-margin')?.value ??
97
+ diagramOptions['slo-warning-margin'];
98
+
99
+ const availParsed =
100
+ availRaw != null
101
+ ? parseFloat(String(availRaw).replace('%', '')) / 100
102
+ : NaN;
77
103
  const availThreshold = !isNaN(availParsed) ? availParsed : null;
78
- const latencyParsed = latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
104
+ const latencyParsed =
105
+ latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
79
106
  const latencyP90 = !isNaN(latencyParsed) ? latencyParsed : null;
80
- const marginParsed = marginRaw != null ? parseFloat(String(marginRaw).replace('%', '')) / 100 : NaN;
107
+ const marginParsed =
108
+ marginRaw != null
109
+ ? parseFloat(String(marginRaw).replace('%', '')) / 100
110
+ : NaN;
81
111
  const warningMargin = !isNaN(marginParsed) ? marginParsed : 0.05;
82
112
 
83
113
  if (availThreshold == null && latencyP90 == null) return null;
@@ -103,19 +133,19 @@ interface ComputedRow {
103
133
  }
104
134
 
105
135
  // Animation constants
106
- const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
107
- const FLOW_SPEED_MAX = 6; // seconds at min RPS
108
- const PARTICLE_R = 5; // particle circle radius
109
- const PARTICLE_COUNT_MIN = 1; // min particles per edge
110
- const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
111
- const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
112
- const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
136
+ const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
137
+ const FLOW_SPEED_MAX = 6; // seconds at min RPS
138
+ const PARTICLE_R = 5; // particle circle radius
139
+ const PARTICLE_COUNT_MIN = 1; // min particles per edge
140
+ const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
141
+ const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
142
+ const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
113
143
 
114
144
  // Reject particle constants
115
145
  const REJECT_PARTICLE_R = PARTICLE_R;
116
- const REJECT_DROP_DISTANCE = 30; // px downward travel
117
- const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
118
- const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
146
+ const REJECT_DROP_DISTANCE = 30; // px downward travel
147
+ const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
148
+ const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
119
149
  const REJECT_COUNT_MIN = 1;
120
150
  const REJECT_COUNT_MAX = 3;
121
151
 
@@ -129,7 +159,10 @@ type Pt = { x: number; y: number };
129
159
  * 2-point paths use curveBumpX/Y (nice S-curve).
130
160
  * Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
131
161
  function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
132
- const gen = d3Shape.line<Pt>().x((d) => d.x).y((d) => d.y);
162
+ const gen = d3Shape
163
+ .line<Pt>()
164
+ .x((d) => d.x)
165
+ .y((d) => d.y);
133
166
  if (pts.length <= 2) {
134
167
  gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
135
168
  } else {
@@ -153,7 +186,7 @@ type Rect = { x: number; y: number; width: number; height: number };
153
186
  function computePortPts(
154
187
  edges: InfraLayoutEdge[],
155
188
  nodeMap: Map<string, InfraLayoutNode>,
156
- direction: 'LR' | 'TB',
189
+ direction: 'LR' | 'TB'
157
190
  ): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
158
191
  const srcPts = new Map<string, Pt>();
159
192
  const tgtPts = new Map<string, Pt>();
@@ -173,22 +206,28 @@ function computePortPts(
173
206
  if (!source) continue;
174
207
  const sorted = es
175
208
  .map((e) => ({ e, t: nodeMap.get(e.targetId) }))
176
- .filter((x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null)
209
+ .filter(
210
+ (x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null
211
+ )
177
212
  .sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
178
213
  const n = sorted.length;
179
214
  for (let i = 0; i < n; i++) {
180
- const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
215
+ const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
181
216
  const { e, t } = sorted[i];
182
217
  const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
183
218
  if (direction === 'LR') {
184
219
  srcPts.set(`${e.sourceId}:${e.targetId}`, {
185
- x: isBackward ? source.x - source.width / 2 : source.x + source.width / 2,
220
+ x: isBackward
221
+ ? source.x - source.width / 2
222
+ : source.x + source.width / 2,
186
223
  y: source.y - source.height / 2 + frac * source.height,
187
224
  });
188
225
  } else {
189
226
  srcPts.set(`${e.sourceId}:${e.targetId}`, {
190
227
  x: source.x - source.width / 2 + frac * source.width,
191
- y: isBackward ? source.y - source.height / 2 : source.y + source.height / 2,
228
+ y: isBackward
229
+ ? source.y - source.height / 2
230
+ : source.y + source.height / 2,
192
231
  });
193
232
  }
194
233
  }
@@ -206,22 +245,28 @@ function computePortPts(
206
245
  if (!target) continue;
207
246
  const sorted = es
208
247
  .map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
209
- .filter((x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null)
248
+ .filter(
249
+ (x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null
250
+ )
210
251
  .sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
211
252
  const n = sorted.length;
212
253
  for (let i = 0; i < n; i++) {
213
- const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
254
+ const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
214
255
  const { e, s } = sorted[i];
215
256
  const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
216
257
  if (direction === 'LR') {
217
258
  tgtPts.set(`${e.sourceId}:${e.targetId}`, {
218
- x: isBackward ? target.x + target.width / 2 : target.x - target.width / 2,
259
+ x: isBackward
260
+ ? target.x + target.width / 2
261
+ : target.x - target.width / 2,
219
262
  y: target.y - target.height / 2 + frac * target.height,
220
263
  });
221
264
  } else {
222
265
  tgtPts.set(`${e.sourceId}:${e.targetId}`, {
223
266
  x: target.x - target.width / 2 + frac * target.width,
224
- y: isBackward ? target.y + target.height / 2 : target.y - target.height / 2,
267
+ y: isBackward
268
+ ? target.y + target.height / 2
269
+ : target.y - target.height / 2,
225
270
  });
226
271
  }
227
272
  }
@@ -239,12 +284,14 @@ function computePortPts(
239
284
  function findRoutingLane(
240
285
  blocking: Rect[],
241
286
  targetY: number,
242
- margin: number,
287
+ margin: number
243
288
  ): number {
244
289
  // Use a small slop for merging so closely-spaced (but distinct) groups
245
290
  // stay as separate intervals and the gap between them can be threaded.
246
291
  const MERGE_SLOP = 4;
247
- const sorted = [...blocking].sort((a, b) => (a.y + a.height / 2) - (b.y + b.height / 2));
292
+ const sorted = [...blocking].sort(
293
+ (a, b) => a.y + a.height / 2 - (b.y + b.height / 2)
294
+ );
248
295
  const merged: [number, number][] = [];
249
296
  for (const r of sorted) {
250
297
  const lo = r.y - MERGE_SLOP;
@@ -261,25 +308,30 @@ function findRoutingLane(
261
308
  // MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
262
309
  const MIN_GAP = 10;
263
310
  const candidates: number[] = [
264
- merged[0][0] - margin, // above all blocking rects
265
- merged[merged.length - 1][1] + margin, // below all blocking rects
311
+ merged[0][0] - margin, // above all blocking rects
312
+ merged[merged.length - 1][1] + margin, // below all blocking rects
266
313
  ];
267
314
  for (let i = 0; i < merged.length - 1; i++) {
268
315
  const gapLo = merged[i][1];
269
316
  const gapHi = merged[i + 1][0];
270
317
  if (gapHi - gapLo >= MIN_GAP) {
271
- candidates.push((gapLo + gapHi) / 2); // thread through the gap
318
+ candidates.push((gapLo + gapHi) / 2); // thread through the gap
272
319
  }
273
320
  }
274
321
 
275
322
  // Return the candidate closest to targetY (tightest arc)
276
- return candidates.reduce((best, c) =>
277
- Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best,
278
- candidates[0]);
323
+ return candidates.reduce(
324
+ (best, c) => (Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best),
325
+ candidates[0]
326
+ );
279
327
  }
280
328
 
281
329
  /** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
282
- function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; width: number; height: number }): boolean {
330
+ function segmentIntersectsRect(
331
+ p1: Pt,
332
+ p2: Pt,
333
+ rect: { x: number; y: number; width: number; height: number }
334
+ ): boolean {
283
335
  const { x: rx, y: ry, width: rw, height: rh } = rect;
284
336
  const rr = rx + rw;
285
337
  const rb = ry + rh;
@@ -290,21 +342,26 @@ function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; wid
290
342
  if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
291
343
  if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
292
344
  // Cross product sign helper (z-component of cross product)
293
- const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
345
+ const cross = (o: Pt, a: Pt, b: Pt) =>
346
+ (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
294
347
  // Does segment p1p2 cross segment a→b?
295
348
  const crosses = (a: Pt, b: Pt) => {
296
349
  const d1 = cross(a, b, p1);
297
350
  const d2 = cross(a, b, p2);
298
351
  const d3 = cross(p1, p2, a);
299
352
  const d4 = cross(p1, p2, b);
300
- return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
301
- ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
353
+ return (
354
+ ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
355
+ ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
356
+ );
302
357
  };
303
358
  const tl: Pt = { x: rx, y: ry };
304
359
  const tr: Pt = { x: rr, y: ry };
305
360
  const br: Pt = { x: rr, y: rb };
306
361
  const bl: Pt = { x: rx, y: rb };
307
- return crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl);
362
+ return (
363
+ crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl)
364
+ );
308
365
  }
309
366
 
310
367
  /** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
@@ -315,21 +372,30 @@ function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; wid
315
372
  * sc → (midX, sc.y) → (midX, tc.y) → tc
316
373
  *
317
374
  * curveBumpY (TB) mirrors this on the other axis. */
318
- function curveIntersectsRect(sc: Pt, tc: Pt, rect: Rect, direction: 'LR' | 'TB'): boolean {
375
+ function curveIntersectsRect(
376
+ sc: Pt,
377
+ tc: Pt,
378
+ rect: Rect,
379
+ direction: 'LR' | 'TB'
380
+ ): boolean {
319
381
  if (direction === 'LR') {
320
382
  const midX = (sc.x + tc.x) / 2;
321
383
  const m1: Pt = { x: midX, y: sc.y };
322
384
  const m2: Pt = { x: midX, y: tc.y };
323
- return segmentIntersectsRect(sc, m1, rect)
324
- || segmentIntersectsRect(m1, m2, rect)
325
- || segmentIntersectsRect(m2, tc, rect);
385
+ return (
386
+ segmentIntersectsRect(sc, m1, rect) ||
387
+ segmentIntersectsRect(m1, m2, rect) ||
388
+ segmentIntersectsRect(m2, tc, rect)
389
+ );
326
390
  } else {
327
391
  const midY = (sc.y + tc.y) / 2;
328
392
  const m1: Pt = { x: sc.x, y: midY };
329
393
  const m2: Pt = { x: tc.x, y: midY };
330
- return segmentIntersectsRect(sc, m1, rect)
331
- || segmentIntersectsRect(m1, m2, rect)
332
- || segmentIntersectsRect(m2, tc, rect);
394
+ return (
395
+ segmentIntersectsRect(sc, m1, rect) ||
396
+ segmentIntersectsRect(m1, m2, rect) ||
397
+ segmentIntersectsRect(m2, tc, rect)
398
+ );
333
399
  }
334
400
  }
335
401
 
@@ -348,7 +414,7 @@ function edgeWaypoints(
348
414
  direction: 'LR' | 'TB',
349
415
  margin = 30,
350
416
  srcExitPt?: Pt, // port-ordered exit point on source border
351
- tgtEnterPt?: Pt, // port-ordered enter point on target border
417
+ tgtEnterPt?: Pt // port-ordered enter point on target border
352
418
  ): Pt[] {
353
419
  const sc: Pt = { x: source.x, y: source.y };
354
420
  const tc: Pt = { x: target.x, y: target.y };
@@ -370,13 +436,20 @@ function edgeWaypoints(
370
436
  const nLeft = n.x - n.width / 2;
371
437
  const nRight = n.x + n.width / 2;
372
438
  if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
373
- xBandObs.push({ x: nLeft, y: n.y - n.height / 2, width: n.width, height: n.height });
439
+ xBandObs.push({
440
+ x: nLeft,
441
+ y: n.y - n.height / 2,
442
+ width: n.width,
443
+ height: n.height,
444
+ });
374
445
  }
375
- const midY = (sc.y + tc.y) / 2;
376
- const routeY = xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
377
- const exitBorder: Pt = srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
378
- const exitPt : Pt = { x: exitBorder.x, y: routeY };
379
- const enterPt : Pt = { x: tc.x, y: routeY };
446
+ const midY = (sc.y + tc.y) / 2;
447
+ const routeY =
448
+ xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
449
+ const exitBorder: Pt =
450
+ srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
451
+ const exitPt: Pt = { x: exitBorder.x, y: routeY };
452
+ const enterPt: Pt = { x: tc.x, y: routeY };
380
453
  const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
381
454
  return srcExitPt
382
455
  ? [srcExitPt, exitPt, enterPt, tp]
@@ -393,14 +466,25 @@ function edgeWaypoints(
393
466
  const nTop = n.y - n.height / 2;
394
467
  const nBot = n.y + n.height / 2;
395
468
  if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
396
- yBandObs.push({ x: n.x - n.width / 2, y: nTop, width: n.width, height: n.height });
469
+ yBandObs.push({
470
+ x: n.x - n.width / 2,
471
+ y: nTop,
472
+ width: n.width,
473
+ height: n.height,
474
+ });
397
475
  }
398
476
  // Rotate axes so findRoutingLane (which works in Y) resolves an X lane
399
- const rotated = yBandObs.map((r) => ({ x: r.y, y: r.x, width: r.height, height: r.width }));
400
- const midX = (sc.x + tc.x) / 2;
401
- const routeX = rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
402
- const exitPt : Pt = srcExitPt ?? { x: routeX, y: sc.y };
403
- const enterPt : Pt = { x: routeX, y: tc.y };
477
+ const rotated = yBandObs.map((r) => ({
478
+ x: r.y,
479
+ y: r.x,
480
+ width: r.height,
481
+ height: r.width,
482
+ }));
483
+ const midX = (sc.x + tc.x) / 2;
484
+ const routeX =
485
+ rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
486
+ const exitPt: Pt = srcExitPt ?? { x: routeX, y: sc.y };
487
+ const enterPt: Pt = { x: routeX, y: tc.y };
404
488
  return [
405
489
  srcExitPt ?? nodeBorderPoint(source, exitPt),
406
490
  exitPt,
@@ -411,7 +495,8 @@ function edgeWaypoints(
411
495
  }
412
496
 
413
497
  // ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
414
- const blocking: { x: number; y: number; width: number; height: number }[] = [];
498
+ const blocking: { x: number; y: number; width: number; height: number }[] =
499
+ [];
415
500
  const blockingGroupIds = new Set<string>();
416
501
 
417
502
  // Use actual path endpoints (port-ordered) for more accurate blocking detection.
@@ -433,10 +518,19 @@ function edgeWaypoints(
433
518
  for (const n of nodes) {
434
519
  if (n.id === source.id || n.id === target.id) continue;
435
520
  // Skip nodes in source/target groups (routing around the group handles them)
436
- if (n.groupId && (n.groupId === source.groupId || n.groupId === target.groupId)) continue;
521
+ if (
522
+ n.groupId &&
523
+ (n.groupId === source.groupId || n.groupId === target.groupId)
524
+ )
525
+ continue;
437
526
  // Skip nodes inside a group whose bounding box is already blocking
438
527
  if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
439
- const nodeRect: Rect = { x: n.x - n.width / 2, y: n.y - n.height / 2, width: n.width, height: n.height };
528
+ const nodeRect: Rect = {
529
+ x: n.x - n.width / 2,
530
+ y: n.y - n.height / 2,
531
+ width: n.width,
532
+ height: n.height,
533
+ };
440
534
  if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
441
535
  blocking.push(nodeRect);
442
536
  }
@@ -449,17 +543,19 @@ function edgeWaypoints(
449
543
  return [sp, tp];
450
544
  }
451
545
 
452
- const obsLeft = Math.min(...blocking.map((o) => o.x));
546
+ const obsLeft = Math.min(...blocking.map((o) => o.x));
453
547
  const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
454
548
 
455
549
  const routeY = findRoutingLane(blocking, tc.y, margin);
456
550
 
457
551
  // Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
458
552
  // direction when an obstacle's bounding box extends past source or target.
459
- const exitX = direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
460
- const enterX = direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
461
- const exitPt : Pt = { x: exitX, y: routeY };
462
- const enterPt : Pt = { x: enterX, y: routeY };
553
+ const exitX =
554
+ direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
555
+ const enterX =
556
+ direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
557
+ const exitPt: Pt = { x: exitX, y: routeY };
558
+ const enterPt: Pt = { x: enterX, y: routeY };
463
559
 
464
560
  const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
465
561
 
@@ -473,7 +569,7 @@ function edgeWaypoints(
473
569
  /** Compute the point on a node's border closest to an external target point. */
474
570
  function nodeBorderPoint(
475
571
  node: InfraLayoutNode,
476
- target: { x: number; y: number },
572
+ target: { x: number; y: number }
477
573
  ): { x: number; y: number } {
478
574
  const hw = node.width / 2;
479
575
  const hh = node.height / 2;
@@ -500,7 +596,9 @@ function flowDuration(rps: number, maxRps: number): number {
500
596
  function particleCount(rps: number, maxRps: number): number {
501
597
  if (maxRps <= 0) return PARTICLE_COUNT_MIN;
502
598
  const t = Math.min(rps / maxRps, 1);
503
- return Math.round(PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN));
599
+ return Math.round(
600
+ PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN)
601
+ );
504
602
  }
505
603
 
506
604
  /** Determine if a node is in warning state (>70% capacity but not overloaded). */
@@ -532,18 +630,18 @@ const PROP_DISPLAY: Record<string, string> = {
532
630
  'firewall-block': 'firewall block',
533
631
  'ratelimit-rps': 'rate limit RPS',
534
632
  'latency-ms': 'latency',
535
- 'uptime': 'uptime',
536
- 'instances': 'instances',
633
+ uptime: 'uptime',
634
+ instances: 'instances',
537
635
  'max-rps': 'max RPS',
538
636
  'cb-error-threshold': 'CB error threshold',
539
637
  'cb-latency-threshold-ms': 'CB latency threshold',
540
- 'concurrency': 'concurrency',
638
+ concurrency: 'concurrency',
541
639
  'duration-ms': 'duration',
542
640
  'cold-start-ms': 'cold start',
543
- 'buffer': 'buffer',
641
+ buffer: 'buffer',
544
642
  'drain-rate': 'drain rate',
545
643
  'retention-hours': 'retention',
546
- 'partitions': 'partitions',
644
+ partitions: 'partitions',
547
645
  };
548
646
 
549
647
  const DESC_MAX_CHARS = 120;
@@ -558,10 +656,20 @@ function truncateDesc(text: string): string {
558
656
  const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
559
657
 
560
658
  /** Keys whose values are milliseconds and should show the "ms" suffix. */
561
- const MS_FORMAT_KEYS = new Set(['latency-ms', 'cb-latency-threshold-ms', 'duration-ms', 'cold-start-ms']);
659
+ const MS_FORMAT_KEYS = new Set([
660
+ 'latency-ms',
661
+ 'cb-latency-threshold-ms',
662
+ 'duration-ms',
663
+ 'cold-start-ms',
664
+ ]);
562
665
 
563
666
  /** Keys whose values are percentages and should show the "%" suffix. */
564
- const PCT_FORMAT_KEYS = new Set(['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold']);
667
+ const PCT_FORMAT_KEYS = new Set([
668
+ 'cache-hit',
669
+ 'firewall-block',
670
+ 'uptime',
671
+ 'cb-error-threshold',
672
+ ]);
565
673
 
566
674
  /** Compute SLO color for a p90 latency value against the configured threshold.
567
675
  * Callers must guard slo.latencyP90 != null before calling. */
@@ -569,11 +677,19 @@ function sloLatencyColor(p90: number, slo: NodeSlo): string {
569
677
  const t = slo.latencyP90 ?? 0;
570
678
  if (t === 0) return COLOR_HEALTHY; // no meaningful threshold — treat as healthy
571
679
  const m = slo.warningMargin;
572
- return p90 > t ? COLOR_OVERLOADED : p90 > t * (1 - m) ? COLOR_WARNING : COLOR_HEALTHY;
680
+ return p90 > t
681
+ ? COLOR_OVERLOADED
682
+ : p90 > t * (1 - m)
683
+ ? COLOR_WARNING
684
+ : COLOR_HEALTHY;
573
685
  }
574
686
 
575
687
  /** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
576
- function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo | null): ComputedRow[] {
688
+ function getComputedRows(
689
+ node: InfraLayoutNode,
690
+ expanded: boolean,
691
+ slo?: NodeSlo | null
692
+ ): ComputedRow[] {
577
693
  const rows: ComputedRow[] = [];
578
694
 
579
695
  // Serverless instances: demand vs concurrency limit
@@ -581,10 +697,12 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
581
697
  const concurrency = getNodeNumProp(node, 'concurrency', 0);
582
698
  const demand = node.computedConcurrentInvocations;
583
699
  const ratio = concurrency > 0 ? demand / concurrency : 0;
584
- const color = ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
585
- const value = concurrency > 0
586
- ? `${formatCount(demand)} / ${formatCount(concurrency)}`
587
- : `${formatCount(demand)}`;
700
+ const color =
701
+ ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
702
+ const value =
703
+ concurrency > 0
704
+ ? `${formatCount(demand)} / ${formatCount(concurrency)}`
705
+ : `${formatCount(demand)}`;
588
706
  rows.push({ key: 'instances', value, color, inverted: color != null });
589
707
  }
590
708
 
@@ -594,10 +712,16 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
594
712
  rows.push({ key: 'p50', value: formatMsShort(p.p50) });
595
713
  if (slo?.latencyP90 != null) {
596
714
  const color = sloLatencyColor(p.p90, slo);
597
- const p90Value = color !== COLOR_HEALTHY
598
- ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
599
- : formatMsShort(p.p90);
600
- rows.push({ key: 'p90', value: p90Value, color, inverted: color !== COLOR_HEALTHY });
715
+ const p90Value =
716
+ color !== COLOR_HEALTHY
717
+ ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
718
+ : formatMsShort(p.p90);
719
+ rows.push({
720
+ key: 'p90',
721
+ value: p90Value,
722
+ color,
723
+ inverted: color !== COLOR_HEALTHY,
724
+ });
601
725
  } else {
602
726
  rows.push({ key: 'p90', value: formatMsShort(p.p90) });
603
727
  }
@@ -606,10 +730,16 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
606
730
  // Collapsed: show p90 (with SLO color if configured) instead of p99
607
731
  if (slo?.latencyP90 != null) {
608
732
  const color = sloLatencyColor(p.p90, slo);
609
- const p90Value = color !== COLOR_HEALTHY
610
- ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
611
- : formatMsShort(p.p90);
612
- rows.push({ key: 'p90', value: p90Value, color, inverted: color !== COLOR_HEALTHY });
733
+ const p90Value =
734
+ color !== COLOR_HEALTHY
735
+ ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
736
+ : formatMsShort(p.p90);
737
+ rows.push({
738
+ key: 'p90',
739
+ value: p90Value,
740
+ color,
741
+ inverted: color !== COLOR_HEALTHY,
742
+ });
613
743
  } else {
614
744
  rows.push({ key: 'p90', value: formatMsShort(p.p90) });
615
745
  }
@@ -623,7 +753,10 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
623
753
  const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
624
754
  const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
625
755
  if (differs || node.isEdge) {
626
- rows.push({ key: 'eff. uptime', value: formatUptimeShort(node.computedUptime) });
756
+ rows.push({
757
+ key: 'eff. uptime',
758
+ value: formatUptimeShort(node.computedUptime),
759
+ });
627
760
  }
628
761
  }
629
762
  if (node.computedAvailability < 1) {
@@ -632,27 +765,45 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
632
765
  const t = slo.availThreshold;
633
766
  const m = slo.warningMargin;
634
767
  if (node.computedAvailability < t) color = COLOR_OVERLOADED;
635
- else if (node.computedAvailability < Math.min(1, t + m)) color = COLOR_WARNING;
768
+ else if (node.computedAvailability < Math.min(1, t + m))
769
+ color = COLOR_WARNING;
636
770
  else color = COLOR_HEALTHY;
637
771
  } else {
638
- color = node.computedAvailability < 0.95 ? COLOR_OVERLOADED
639
- : node.computedAvailability < 0.99 ? COLOR_WARNING
640
- : undefined;
772
+ color =
773
+ node.computedAvailability < 0.95
774
+ ? COLOR_OVERLOADED
775
+ : node.computedAvailability < 0.99
776
+ ? COLOR_WARNING
777
+ : undefined;
641
778
  }
642
- rows.push({ key: 'availability', value: formatUptimeShort(node.computedAvailability), color, inverted: color != null && color !== COLOR_HEALTHY });
779
+ rows.push({
780
+ key: 'availability',
781
+ value: formatUptimeShort(node.computedAvailability),
782
+ color,
783
+ inverted: color != null && color !== COLOR_HEALTHY,
784
+ });
643
785
  }
644
786
  // Circuit breaker state — show when a CB is configured and open
645
787
  if (node.computedCbState === 'open') {
646
- rows.push({ key: 'CB', value: 'OPEN', color: COLOR_OVERLOADED, inverted: true });
788
+ rows.push({
789
+ key: 'CB',
790
+ value: 'OPEN',
791
+ color: COLOR_OVERLOADED,
792
+ inverted: true,
793
+ });
647
794
  }
648
795
  // Queue computed rows: lag and overflow
649
796
  if (node.queueMetrics) {
650
797
  const { fillRate, timeToOverflow } = node.queueMetrics;
651
798
  if (fillRate > 0) {
652
- rows.push({ key: 'lag', value: `${formatCount(Math.round(fillRate))} msg/s` });
799
+ rows.push({
800
+ key: 'lag',
801
+ value: `${formatCount(Math.round(fillRate))} msg/s`,
802
+ });
653
803
  }
654
804
  if (fillRate > 0 && timeToOverflow < Infinity) {
655
- const overflowColor = timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
805
+ const overflowColor =
806
+ timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
656
807
  rows.push({
657
808
  key: 'overflow',
658
809
  value: `~${Math.round(timeToOverflow)}s`,
@@ -665,7 +816,8 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
665
816
  }
666
817
 
667
818
  function formatCount(n: number): string {
668
- if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
819
+ if (n >= 1000000)
820
+ return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
669
821
  if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
670
822
  return String(n);
671
823
  }
@@ -684,7 +836,11 @@ function formatUptimeShort(fraction: number): string {
684
836
  }
685
837
 
686
838
  /** Properties shown as key-value rows inside the node card. */
687
- function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOptions?: Record<string, string>): { key: string; displayKey: string; value: string }[] {
839
+ function getDisplayProps(
840
+ node: InfraLayoutNode,
841
+ expanded: boolean,
842
+ diagramOptions?: Record<string, string>
843
+ ): { key: string; displayKey: string; value: string }[] {
688
844
  if (node.isEdge) return [];
689
845
  const rows: { key: string; displayKey: string; value: string }[] = [];
690
846
  for (const p of node.properties) {
@@ -697,23 +853,53 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
697
853
  if (p.key === 'concurrency' && !expanded) continue;
698
854
  // Format values with appropriate units
699
855
  if (RPS_FORMAT_KEYS.has(p.key)) {
700
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
701
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatRpsShort(num) });
856
+ const num =
857
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
858
+ rows.push({
859
+ key: p.key,
860
+ displayKey,
861
+ value: isNaN(num) ? String(p.value) : formatRpsShort(num),
862
+ });
702
863
  } else if (MS_FORMAT_KEYS.has(p.key)) {
703
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
704
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatMsShort(num) });
864
+ const num =
865
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
866
+ rows.push({
867
+ key: p.key,
868
+ displayKey,
869
+ value: isNaN(num) ? String(p.value) : formatMsShort(num),
870
+ });
705
871
  } else if (PCT_FORMAT_KEYS.has(p.key)) {
706
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
707
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}%` });
872
+ const num =
873
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
874
+ rows.push({
875
+ key: p.key,
876
+ displayKey,
877
+ value: isNaN(num) ? String(p.value) : `${num}%`,
878
+ });
708
879
  } else if (p.key === 'buffer') {
709
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
710
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatCount(num) });
880
+ const num =
881
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
882
+ rows.push({
883
+ key: p.key,
884
+ displayKey,
885
+ value: isNaN(num) ? String(p.value) : formatCount(num),
886
+ });
711
887
  } else if (p.key === 'drain-rate') {
712
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
713
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s` });
888
+ const num =
889
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
890
+ rows.push({
891
+ key: p.key,
892
+ displayKey,
893
+ value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s`,
894
+ });
714
895
  } else if (p.key === 'retention-hours') {
715
- const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
716
- rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}h` });
896
+ const num =
897
+ typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
898
+ rows.push({
899
+ key: p.key,
900
+ displayKey,
901
+ value: isNaN(num) ? String(p.value) : `${num}h`,
902
+ });
717
903
  } else {
718
904
  rows.push({ key: p.key, displayKey, value: String(p.value) });
719
905
  }
@@ -722,20 +908,31 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
722
908
  if (diagramOptions) {
723
909
  const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
724
910
  const hasUptime = node.properties.some((p) => p.key === 'uptime');
725
- const isServerlessNode = node.properties.some((p) => p.key === 'concurrency');
726
- const defaultLatency = parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
727
- const defaultUptime = parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
911
+ const isServerlessNode = node.properties.some(
912
+ (p) => p.key === 'concurrency'
913
+ );
914
+ const defaultLatency =
915
+ parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
916
+ const defaultUptime =
917
+ parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
728
918
  if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
729
- rows.push({ key: 'latency-ms', displayKey: 'latency', value: formatMsShort(defaultLatency) });
919
+ rows.push({
920
+ key: 'latency-ms',
921
+ displayKey: 'latency',
922
+ value: formatMsShort(defaultLatency),
923
+ });
730
924
  }
731
925
  if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
732
- rows.push({ key: 'uptime', displayKey: 'uptime', value: `${defaultUptime}%` });
926
+ rows.push({
927
+ key: 'uptime',
928
+ displayKey: 'uptime',
929
+ value: `${defaultUptime}%`,
930
+ });
733
931
  }
734
932
  }
735
933
  return rows;
736
934
  }
737
935
 
738
-
739
936
  /** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
740
937
  function formatRpsShort(rps: number): string {
741
938
  if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
@@ -746,7 +943,7 @@ function formatRpsShort(rps: number): string {
746
943
  * Returns 'overloaded' (red), 'warning' (yellow), 'healthy' (green), or 'normal'. */
747
944
  function worstNodeSeverity(
748
945
  node: InfraLayoutNode,
749
- slo?: NodeSlo | null,
946
+ slo?: NodeSlo | null
750
947
  ): 'overloaded' | 'warning' | 'healthy' | 'normal' {
751
948
  let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
752
949
  const upgrade = (s: 'overloaded' | 'warning') => {
@@ -810,10 +1007,14 @@ function worstNodeSeverity(
810
1007
 
811
1008
  // Healthy: SLO declared AND all checks are in the green zone
812
1009
  if (worst === 'normal' && slo != null) {
813
- const availGreen = slo.availThreshold == null ||
814
- node.computedAvailability >= Math.min(1, slo.availThreshold + slo.warningMargin);
815
- const latencyGreen = slo.latencyP90 == null ||
816
- node.computedLatencyPercentiles.p90 <= slo.latencyP90 * (1 - slo.warningMargin);
1010
+ const availGreen =
1011
+ slo.availThreshold == null ||
1012
+ node.computedAvailability >=
1013
+ Math.min(1, slo.availThreshold + slo.warningMargin);
1014
+ const latencyGreen =
1015
+ slo.latencyP90 == null ||
1016
+ node.computedLatencyPercentiles.p90 <=
1017
+ slo.latencyP90 * (1 - slo.warningMargin);
817
1018
  if (availGreen && latencyGreen) return 'healthy';
818
1019
  }
819
1020
 
@@ -824,7 +1025,7 @@ function nodeColor(
824
1025
  _node: InfraLayoutNode,
825
1026
  palette: PaletteColors,
826
1027
  isDark: boolean,
827
- severity: ReturnType<typeof worstNodeSeverity>,
1028
+ severity: ReturnType<typeof worstNodeSeverity>
828
1029
  ): {
829
1030
  fill: string;
830
1031
  stroke: string;
@@ -852,8 +1053,12 @@ function nodeColor(
852
1053
  };
853
1054
  }
854
1055
  return {
855
- fill: isDark ? mix(palette.bg, palette.text, 90) : mix(palette.bg, palette.text, 95),
856
- stroke: isDark ? mix(palette.text, palette.bg, 60) : mix(palette.text, palette.bg, 40),
1056
+ fill: isDark
1057
+ ? mix(palette.bg, palette.text, 90)
1058
+ : mix(palette.bg, palette.text, 95),
1059
+ stroke: isDark
1060
+ ? mix(palette.text, palette.bg, 60)
1061
+ : mix(palette.text, palette.bg, 40),
857
1062
  textFill: palette.text,
858
1063
  };
859
1064
  }
@@ -874,10 +1079,11 @@ function renderGroups(
874
1079
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
875
1080
  groups: InfraLayoutGroup[],
876
1081
  palette: PaletteColors,
877
- isDark: boolean,
1082
+ _isDark: boolean
878
1083
  ) {
879
1084
  for (const group of groups) {
880
- const g = svg.append('g')
1085
+ const g = svg
1086
+ .append('g')
881
1087
  .attr('class', 'infra-group')
882
1088
  .attr('data-line-number', group.lineNumber)
883
1089
  .attr('data-node-toggle', group.id)
@@ -909,8 +1115,12 @@ function renderGroups(
909
1115
  .text(group.label);
910
1116
 
911
1117
  // Group instances badge (top-right, like node instance badges)
912
- const gi = typeof group.instances === 'number' ? group.instances :
913
- typeof group.instances === 'string' ? parseInt(String(group.instances), 10) || 0 : 0;
1118
+ const gi =
1119
+ typeof group.instances === 'number'
1120
+ ? group.instances
1121
+ : typeof group.instances === 'string'
1122
+ ? parseInt(String(group.instances), 10) || 0
1123
+ : 0;
914
1124
  if (gi > 1) {
915
1125
  g.append('text')
916
1126
  .attr('x', group.x + group.width - 8)
@@ -933,7 +1143,7 @@ function renderEdgePaths(
933
1143
  isDark: boolean,
934
1144
  animate: boolean,
935
1145
  direction: 'LR' | 'TB',
936
- speedMultiplier: number = 1,
1146
+ speedMultiplier: number = 1
937
1147
  ) {
938
1148
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
939
1149
  const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
@@ -950,15 +1160,23 @@ function renderEdgePaths(
950
1160
  if (!sourceNode || !targetNode) continue;
951
1161
  const key = `${edge.sourceId}:${edge.targetId}`;
952
1162
  const pts = edgeWaypoints(
953
- sourceNode, targetNode, groups, nodes, direction, 30,
954
- srcPts.get(key), tgtPts.get(key),
1163
+ sourceNode,
1164
+ targetNode,
1165
+ groups,
1166
+ nodes,
1167
+ direction,
1168
+ 30,
1169
+ srcPts.get(key),
1170
+ tgtPts.get(key)
955
1171
  );
956
1172
  const pathD = buildPathD(pts, direction);
957
- const edgeG = svg.append('g')
1173
+ const edgeG = svg
1174
+ .append('g')
958
1175
  .attr('class', 'infra-edge')
959
1176
  .attr('data-line-number', edge.lineNumber);
960
1177
 
961
- const edgePath = edgeG.append('path')
1178
+ const edgePath = edgeG
1179
+ .append('path')
962
1180
  .attr('d', pathD)
963
1181
  .attr('fill', 'none')
964
1182
  .attr('stroke', color)
@@ -978,13 +1196,15 @@ function renderEdgePaths(
978
1196
 
979
1197
  for (let i = 0; i < count; i++) {
980
1198
  const delay = (dur / count) * i;
981
- const circle = edgeG.append('circle')
1199
+ const circle = edgeG
1200
+ .append('circle')
982
1201
  .attr('r', PARTICLE_R)
983
1202
  .attr('fill', particleColor)
984
1203
  .attr('opacity', 0.85);
985
1204
 
986
1205
  // Use SMIL <animateMotion> for path-following
987
- circle.append('animateMotion')
1206
+ circle
1207
+ .append('animateMotion')
988
1208
  .attr('dur', `${dur}s`)
989
1209
  .attr('repeatCount', 'indefinite')
990
1210
  .attr('begin', `${delay}s`)
@@ -1002,7 +1222,7 @@ function renderEdgeLabels(
1002
1222
  palette: PaletteColors,
1003
1223
  isDark: boolean,
1004
1224
  animate: boolean,
1005
- direction: 'LR' | 'TB',
1225
+ direction: 'LR' | 'TB'
1006
1226
  ) {
1007
1227
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
1008
1228
  const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
@@ -1016,15 +1236,20 @@ function renderEdgeLabels(
1016
1236
 
1017
1237
  const key = `${edge.sourceId}:${edge.targetId}`;
1018
1238
  const wps = edgeWaypoints(
1019
- sourceNode, targetNode, groups, nodes, direction, 30,
1020
- srcPts.get(key), tgtPts.get(key),
1239
+ sourceNode,
1240
+ targetNode,
1241
+ groups,
1242
+ nodes,
1243
+ direction,
1244
+ 30,
1245
+ srcPts.get(key),
1246
+ tgtPts.get(key)
1021
1247
  );
1022
1248
  // Label midpoint: middle waypoint of the routed path
1023
1249
  const midPt = wps[Math.floor(wps.length / 2)];
1024
1250
  const labelText = edge.label;
1025
1251
 
1026
- const g = svg.append('g')
1027
- .attr('class', animate ? 'infra-edge-label' : '');
1252
+ const g = svg.append('g').attr('class', animate ? 'infra-edge-label' : '');
1028
1253
 
1029
1254
  // Background rect for readability
1030
1255
  const textWidth = labelText.length * 6.5 + 8;
@@ -1063,14 +1288,18 @@ function resolveActiveTagStroke(
1063
1288
  node: InfraLayoutNode,
1064
1289
  activeGroup: string,
1065
1290
  tagGroups: InfraTagGroup[],
1066
- palette: PaletteColors,
1291
+ palette: PaletteColors
1067
1292
  ): string | null {
1068
- const tg = tagGroups.find((t) => t.name.toLowerCase() === activeGroup.toLowerCase());
1293
+ const tg = tagGroups.find(
1294
+ (t) => t.name.toLowerCase() === activeGroup.toLowerCase()
1295
+ );
1069
1296
  if (!tg) return null;
1070
1297
  const tagKey = (tg.alias ?? tg.name).toLowerCase();
1071
1298
  const tagVal = node.tags[tagKey];
1072
1299
  if (!tagVal) return null;
1073
- const tv = tg.values.find((v) => v.name.toLowerCase() === tagVal.toLowerCase());
1300
+ const tv = tg.values.find(
1301
+ (v) => v.name.toLowerCase() === tagVal.toLowerCase()
1302
+ );
1074
1303
  if (!tv?.color) return null;
1075
1304
  return resolveColor(tv.color, palette);
1076
1305
  }
@@ -1087,18 +1316,28 @@ function renderNodes(
1087
1316
  collapsedNodes?: Set<string> | null,
1088
1317
  tagGroups?: InfraTagGroup[],
1089
1318
  fanoutSourceIds?: Set<string>,
1090
- scaledGroupIds?: Set<string>,
1319
+ scaledGroupIds?: Set<string>
1091
1320
  ) {
1092
1321
  const mutedColor = palette.textMuted;
1093
1322
 
1094
1323
  for (const node of nodes) {
1095
- const slo = (!node.isEdge && diagramOptions) ? resolveNodeSlo(node, diagramOptions) : null;
1324
+ const slo =
1325
+ !node.isEdge && diagramOptions
1326
+ ? resolveNodeSlo(node, diagramOptions)
1327
+ : null;
1096
1328
  const severity = worstNodeSeverity(node, slo);
1097
- let { fill, stroke, textFill } = nodeColor(node, palette, isDark, severity);
1329
+ const nodeColors = nodeColor(node, palette, isDark, severity);
1330
+ const textFill = nodeColors.textFill;
1331
+ let { fill, stroke } = nodeColors;
1098
1332
 
1099
1333
  // When a tag legend is active, override border color with tag color
1100
1334
  if (activeGroup && tagGroups && !node.isEdge) {
1101
- const tagStroke = resolveActiveTagStroke(node, activeGroup, tagGroups, palette);
1335
+ const tagStroke = resolveActiveTagStroke(
1336
+ node,
1337
+ activeGroup,
1338
+ tagGroups,
1339
+ palette
1340
+ );
1102
1341
  if (tagStroke) {
1103
1342
  stroke = tagStroke;
1104
1343
  fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
@@ -1112,7 +1351,8 @@ function renderNodes(
1112
1351
  else if (severity === 'overloaded') cls += ' infra-node-overload';
1113
1352
  else if (severity === 'warning') cls += ' infra-node-warning';
1114
1353
  }
1115
- const g = svg.append('g')
1354
+ const g = svg
1355
+ .append('g')
1116
1356
  .attr('class', cls)
1117
1357
  .attr('data-line-number', node.lineNumber)
1118
1358
  .attr('data-infra-node', node.id)
@@ -1133,7 +1373,10 @@ function renderNodes(
1133
1373
  if (!node.isEdge) {
1134
1374
  const roles = inferRoles(node.properties);
1135
1375
  for (const role of roles) {
1136
- g.attr(`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`, 'true');
1376
+ g.attr(
1377
+ `data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`,
1378
+ 'true'
1379
+ );
1137
1380
  }
1138
1381
  if (fanoutSourceIds?.has(node.id)) {
1139
1382
  g.attr('data-role-fan-out', 'true');
@@ -1143,7 +1386,8 @@ function renderNodes(
1143
1386
  const x = node.x - node.width / 2;
1144
1387
  const y = node.y - node.height / 2;
1145
1388
  const isCollapsedGroup = node.id.startsWith('[');
1146
- const strokeWidth = severity !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
1389
+ const strokeWidth =
1390
+ severity !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
1147
1391
 
1148
1392
  // Node rect
1149
1393
  g.append('rect')
@@ -1187,13 +1431,21 @@ function renderNodes(
1187
1431
  const expanded = expandedNodeIds?.has(node.id) ?? false;
1188
1432
 
1189
1433
  // Description subtitle — shown below label only when node is selected
1190
- const descH = (expanded && node.description && !node.isEdge) ? META_LINE_HEIGHT : 0;
1434
+ const descH =
1435
+ expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
1191
1436
  if (descH > 0 && node.description) {
1192
1437
  const descTruncated = truncateDesc(node.description);
1193
1438
  const isTruncated = descTruncated !== node.description;
1194
- const textEl = g.append('text')
1439
+ const textEl = g
1440
+ .append('text')
1195
1441
  .attr('x', node.x)
1196
- .attr('y', y + NODE_HEADER_HEIGHT + META_LINE_HEIGHT / 2 + META_FONT_SIZE * 0.35)
1442
+ .attr(
1443
+ 'y',
1444
+ y +
1445
+ NODE_HEADER_HEIGHT +
1446
+ META_LINE_HEIGHT / 2 +
1447
+ META_FONT_SIZE * 0.35
1448
+ )
1197
1449
  .attr('text-anchor', 'middle')
1198
1450
  .attr('font-family', FONT_FAMILY)
1199
1451
  .attr('font-size', META_FONT_SIZE)
@@ -1203,9 +1455,15 @@ function renderNodes(
1203
1455
  }
1204
1456
 
1205
1457
  // Declared properties only shown when node is selected (expanded)
1206
- const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
1458
+ const displayProps =
1459
+ !node.isEdge && expanded
1460
+ ? getDisplayProps(node, expanded, diagramOptions)
1461
+ : [];
1207
1462
  const computedRows = getComputedRows(node, expanded, slo);
1208
- const hasContent = displayProps.length > 0 || computedRows.length > 0 || node.computedRps > 0;
1463
+ const hasContent =
1464
+ displayProps.length > 0 ||
1465
+ computedRows.length > 0 ||
1466
+ node.computedRps > 0;
1209
1467
 
1210
1468
  if (hasContent) {
1211
1469
  // Separator line between header and body
@@ -1229,13 +1487,18 @@ function renderNodes(
1229
1487
  const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
1230
1488
  const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
1231
1489
  const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
1232
- const serverlessCap = nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
1233
- const effectiveCap = serverlessCap > 0 ? serverlessCap
1234
- : nodeMaxRps > 0 && nodeRateLimit > 0
1235
- ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
1236
- : nodeMaxRps > 0 ? nodeMaxRps * node.computedInstances
1237
- : nodeRateLimit > 0 ? nodeRateLimit
1238
- : 0;
1490
+ const serverlessCap =
1491
+ nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
1492
+ const effectiveCap =
1493
+ serverlessCap > 0
1494
+ ? serverlessCap
1495
+ : nodeMaxRps > 0 && nodeRateLimit > 0
1496
+ ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
1497
+ : nodeMaxRps > 0
1498
+ ? nodeMaxRps * node.computedInstances
1499
+ : nodeRateLimit > 0
1500
+ ? nodeRateLimit
1501
+ : 0;
1239
1502
 
1240
1503
  // --- Computed section: RPS + computed metrics ---
1241
1504
  if (node.computedRps > 0) {
@@ -1251,25 +1514,41 @@ function renderNodes(
1251
1514
  else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
1252
1515
  }
1253
1516
  const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
1254
- node.overloaded ? 'overloaded'
1255
- : rlSeverity === 'overloaded' ? 'overloaded'
1256
- : node.rateLimited ? 'warning'
1257
- : isWarning(node) ? 'warning'
1258
- : rlSeverity === 'warning' ? 'warning'
1259
- : 'normal';
1260
- const rpsColor = rpsSeverity === 'overloaded' ? COLOR_OVERLOADED : rpsSeverity === 'warning' ? COLOR_WARNING : mutedColor;
1517
+ node.overloaded
1518
+ ? 'overloaded'
1519
+ : rlSeverity === 'overloaded'
1520
+ ? 'overloaded'
1521
+ : node.rateLimited
1522
+ ? 'warning'
1523
+ : isWarning(node)
1524
+ ? 'warning'
1525
+ : rlSeverity === 'warning'
1526
+ ? 'warning'
1527
+ : 'normal';
1528
+ const rpsColor =
1529
+ rpsSeverity === 'overloaded'
1530
+ ? COLOR_OVERLOADED
1531
+ : rpsSeverity === 'warning'
1532
+ ? COLOR_WARNING
1533
+ : mutedColor;
1261
1534
  const rpsInverted = rpsSeverity !== 'normal';
1262
- const rpsText = effectiveCap > 0 && !node.isEdge
1263
- ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
1264
- : formatRpsShort(node.computedRps);
1535
+ const rpsText =
1536
+ effectiveCap > 0 && !node.isEdge
1537
+ ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
1538
+ : formatRpsShort(node.computedRps);
1265
1539
  computedSection.push({
1266
- key: 'RPS', value: rpsText, valueFill: rpsColor, fontWeight: '500',
1267
- inverted: rpsInverted, invertedBg: rpsInverted ? rpsColor : undefined,
1540
+ key: 'RPS',
1541
+ value: rpsText,
1542
+ valueFill: rpsColor,
1543
+ fontWeight: '500',
1544
+ inverted: rpsInverted,
1545
+ invertedBg: rpsInverted ? rpsColor : undefined,
1268
1546
  });
1269
1547
  }
1270
1548
  for (const cr of computedRows) {
1271
1549
  computedSection.push({
1272
- key: cr.key, value: cr.value,
1550
+ key: cr.key,
1551
+ value: cr.value,
1273
1552
  valueFill: cr.color ?? mutedColor,
1274
1553
  fontWeight: 'normal',
1275
1554
  inverted: cr.inverted,
@@ -1282,16 +1561,34 @@ function renderNodes(
1282
1561
  let propColor = textFill;
1283
1562
  let inverted = false;
1284
1563
  let invertedBg: string | undefined;
1285
- if (prop.key === 'ratelimit-rps' && nodeRateLimit > 0 && node.computedRps > 0) {
1564
+ if (
1565
+ prop.key === 'ratelimit-rps' &&
1566
+ nodeRateLimit > 0 &&
1567
+ node.computedRps > 0
1568
+ ) {
1286
1569
  let preRl = node.computedRps;
1287
1570
  const ch = getNodeNumProp(node, 'cache-hit', 0);
1288
1571
  if (ch > 0) preRl *= (100 - ch) / 100;
1289
1572
  const fw = getNodeNumProp(node, 'firewall-block', 0);
1290
1573
  if (fw > 0) preRl *= (100 - fw) / 100;
1291
- if (preRl > nodeRateLimit) { propColor = COLOR_OVERLOADED; inverted = true; invertedBg = COLOR_OVERLOADED; }
1292
- else if (preRl > nodeRateLimit * 0.8) { propColor = COLOR_WARNING; inverted = true; invertedBg = COLOR_WARNING; }
1574
+ if (preRl > nodeRateLimit) {
1575
+ propColor = COLOR_OVERLOADED;
1576
+ inverted = true;
1577
+ invertedBg = COLOR_OVERLOADED;
1578
+ } else if (preRl > nodeRateLimit * 0.8) {
1579
+ propColor = COLOR_WARNING;
1580
+ inverted = true;
1581
+ invertedBg = COLOR_WARNING;
1582
+ }
1293
1583
  }
1294
- declaredSection.push({ key: prop.displayKey, value: prop.value, valueFill: propColor, fontWeight: 'normal', inverted, invertedBg });
1584
+ declaredSection.push({
1585
+ key: prop.displayKey,
1586
+ value: prop.value,
1587
+ valueFill: propColor,
1588
+ fontWeight: 'normal',
1589
+ inverted,
1590
+ invertedBg,
1591
+ });
1295
1592
  }
1296
1593
 
1297
1594
  const rows = [...computedSection, ...declaredSection];
@@ -1301,7 +1598,8 @@ function renderNodes(
1301
1598
  const valueX = x + 10 + (maxKeyLen + 2) * (META_FONT_SIZE * 0.6);
1302
1599
 
1303
1600
  let rowY = sepY + NODE_SEPARATOR_GAP + META_FONT_SIZE;
1304
- const needsSectionSep = computedSection.length > 0 && declaredSection.length > 0;
1601
+ const needsSectionSep =
1602
+ computedSection.length > 0 && declaredSection.length > 0;
1305
1603
  let rowIdx = 0;
1306
1604
  for (const row of rows) {
1307
1605
  // Draw separator line between computed and declared sections
@@ -1380,8 +1678,14 @@ function renderNodes(
1380
1678
  // Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
1381
1679
  // Serverless nodes show instances in a computed row instead (demand / concurrency).
1382
1680
  // Nodes inside a scaled group suppress their badge — the group header already shows Nx.
1383
- const inScaledGroup = node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
1384
- if (!node.isEdge && node.computedConcurrentInvocations === 0 && node.computedInstances > 1 && !inScaledGroup) {
1681
+ const inScaledGroup =
1682
+ node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
1683
+ if (
1684
+ !node.isEdge &&
1685
+ node.computedConcurrentInvocations === 0 &&
1686
+ node.computedInstances > 1 &&
1687
+ !inScaledGroup
1688
+ ) {
1385
1689
  const badgeText = `${node.computedInstances}x`;
1386
1690
  g.append('text')
1387
1691
  .attr('x', x + node.width - 6)
@@ -1396,7 +1700,8 @@ function renderNodes(
1396
1700
  }
1397
1701
 
1398
1702
  // Role badge dots — only shown when Capabilities legend is expanded
1399
- const showDots = activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
1703
+ const showDots =
1704
+ activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
1400
1705
  const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
1401
1706
  if (roles.length > 0) {
1402
1707
  // Move dots up above the collapse bar for collapsed groups
@@ -1417,10 +1722,13 @@ function renderNodes(
1417
1722
  // Collapse bar at bottom of collapsed group nodes (accent stripe, clipped to card)
1418
1723
  if (isCollapsedGroup) {
1419
1724
  const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
1420
- g.append('clipPath').attr('id', clipId)
1725
+ g.append('clipPath')
1726
+ .attr('id', clipId)
1421
1727
  .append('rect')
1422
- .attr('x', x).attr('y', y)
1423
- .attr('width', node.width).attr('height', node.height)
1728
+ .attr('x', x)
1729
+ .attr('y', y)
1730
+ .attr('width', node.width)
1731
+ .attr('height', node.height)
1424
1732
  .attr('rx', NODE_BORDER_RADIUS);
1425
1733
  g.append('rect')
1426
1734
  .attr('x', x + COLLAPSE_BAR_INSET)
@@ -1440,7 +1748,11 @@ function renderNodes(
1440
1748
  // ============================================================
1441
1749
 
1442
1750
  /** Get a numeric property from an InfraLayoutNode. */
1443
- function getNodeNumProp(node: InfraLayoutNode, key: string, fallback: number): number {
1751
+ function getNodeNumProp(
1752
+ node: InfraLayoutNode,
1753
+ key: string,
1754
+ fallback: number
1755
+ ): number {
1444
1756
  const prop = node.properties.find((p) => p.key === key);
1445
1757
  if (!prop) return fallback;
1446
1758
  if (typeof prop.value === 'number') return prop.value;
@@ -1498,7 +1810,7 @@ function computeRejectedRps(node: InfraLayoutNode): number {
1498
1810
  function renderRejectParticles(
1499
1811
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1500
1812
  nodes: InfraLayoutNode[],
1501
- speedMultiplier: number = 1,
1813
+ speedMultiplier: number = 1
1502
1814
  ) {
1503
1815
  // Compute max rejected RPS across all nodes for scaling
1504
1816
  const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
@@ -1512,8 +1824,11 @@ function renderRejectParticles(
1512
1824
 
1513
1825
  for (const { node, rejected } of rejectMap) {
1514
1826
  const t = Math.min(rejected / maxRejected, 1);
1515
- const count = Math.round(REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN));
1516
- const baseDur = REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1827
+ const count = Math.round(
1828
+ REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN)
1829
+ );
1830
+ const baseDur =
1831
+ REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1517
1832
  const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
1518
1833
 
1519
1834
  const nodeBottom = node.y + node.height / 2;
@@ -1522,19 +1837,24 @@ function renderRejectParticles(
1522
1837
  const delay = (dur / count) * i;
1523
1838
  // Spread particles horizontally around the node center
1524
1839
  const spread = node.width * 0.3;
1525
- const startX = node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
1840
+ const startX =
1841
+ node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
1526
1842
  const startY = nodeBottom;
1527
1843
  const endY = nodeBottom + REJECT_DROP_DISTANCE;
1528
1844
 
1529
- const rejectColor = node.overloaded || node.childHealthState === 'overloaded'
1530
- ? COLOR_OVERLOADED : COLOR_WARNING;
1531
- const circle = svg.append('circle')
1845
+ const rejectColor =
1846
+ node.overloaded || node.childHealthState === 'overloaded'
1847
+ ? COLOR_OVERLOADED
1848
+ : COLOR_WARNING;
1849
+ const circle = svg
1850
+ .append('circle')
1532
1851
  .attr('r', REJECT_PARTICLE_R)
1533
1852
  .attr('fill', rejectColor)
1534
1853
  .attr('opacity', 0);
1535
1854
 
1536
1855
  // Drop straight down from node bottom
1537
- circle.append('animate')
1856
+ circle
1857
+ .append('animate')
1538
1858
  .attr('attributeName', 'cy')
1539
1859
  .attr('from', startY)
1540
1860
  .attr('to', endY)
@@ -1542,7 +1862,8 @@ function renderRejectParticles(
1542
1862
  .attr('repeatCount', 'indefinite')
1543
1863
  .attr('begin', `${delay}s`);
1544
1864
 
1545
- circle.append('animate')
1865
+ circle
1866
+ .append('animate')
1546
1867
  .attr('attributeName', 'cx')
1547
1868
  .attr('from', startX)
1548
1869
  .attr('to', startX)
@@ -1551,7 +1872,8 @@ function renderRejectParticles(
1551
1872
  .attr('begin', `${delay}s`);
1552
1873
 
1553
1874
  // Fade: appear quickly, then fade out
1554
- circle.append('animate')
1875
+ circle
1876
+ .append('animate')
1555
1877
  .attr('attributeName', 'opacity')
1556
1878
  .attr('values', '0;0.8;0.6;0')
1557
1879
  .attr('keyTimes', '0;0.1;0.5;1')
@@ -1588,12 +1910,14 @@ export function computeInfraLegendGroups(
1588
1910
  nodes: InfraLayoutNode[],
1589
1911
  tagGroups: InfraTagGroup[],
1590
1912
  palette: PaletteColors,
1591
- edges?: InfraLayoutEdge[],
1913
+ edges?: InfraLayoutEdge[]
1592
1914
  ): InfraLegendGroup[] {
1593
1915
  const groups: InfraLegendGroup[] = [];
1594
1916
 
1595
1917
  // Capabilities group (from inferred roles + fanout edges)
1596
- const roles = collectDiagramRoles(nodes.filter((n) => !n.isEdge).map((n) => n.properties));
1918
+ const roles = collectDiagramRoles(
1919
+ nodes.filter((n) => !n.isEdge).map((n) => n.properties)
1920
+ );
1597
1921
  if (edges && collectFanoutSourceIds(edges).size > 0) {
1598
1922
  roles.push(FANOUT_ROLE);
1599
1923
  }
@@ -1603,10 +1927,16 @@ export function computeInfraLegendGroups(
1603
1927
  color: r.color,
1604
1928
  key: r.name.toLowerCase().replace(/\s+/g, '-'),
1605
1929
  }));
1606
- const pillWidth = measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1930
+ const pillWidth =
1931
+ measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) +
1932
+ LEGEND_PILL_PAD;
1607
1933
  let entriesWidth = 0;
1608
1934
  for (const e of entries) {
1609
- entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1935
+ entriesWidth +=
1936
+ LEGEND_DOT_R * 2 +
1937
+ LEGEND_ENTRY_DOT_GAP +
1938
+ measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
1939
+ LEGEND_ENTRY_TRAIL;
1610
1940
  }
1611
1941
  groups.push({
1612
1942
  name: 'Capabilities',
@@ -1630,10 +1960,15 @@ export function computeInfraLegendGroups(
1630
1960
  }
1631
1961
  }
1632
1962
  if (entries.length === 0) continue;
1633
- const pillWidth = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1963
+ const pillWidth =
1964
+ measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1634
1965
  let entriesWidth = 0;
1635
1966
  for (const e of entries) {
1636
- entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1967
+ entriesWidth +=
1968
+ LEGEND_DOT_R * 2 +
1969
+ LEGEND_ENTRY_DOT_GAP +
1970
+ measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
1971
+ LEGEND_ENTRY_TRAIL;
1637
1972
  }
1638
1973
  groups.push({
1639
1974
  name: tg.name,
@@ -1649,15 +1984,21 @@ export function computeInfraLegendGroups(
1649
1984
  }
1650
1985
 
1651
1986
  /** Compute total width for the playback pill (speed only). */
1652
- function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
1987
+ function computePlaybackWidth(
1988
+ playback: InfraPlaybackState | undefined
1989
+ ): number {
1653
1990
  if (!playback) return 0;
1654
- const pillWidth = measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1991
+ const pillWidth =
1992
+ measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1655
1993
  if (!playback.expanded) return pillWidth;
1656
1994
 
1657
1995
  let entriesW = 8; // gap after pill
1658
1996
  entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1659
1997
  for (const s of playback.speedOptions) {
1660
- entriesW += measureLegendText(`${s}x`, LEGEND_ENTRY_FONT_SIZE) + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
1998
+ entriesW +=
1999
+ measureLegendText(`${s}x`, LEGEND_ENTRY_FONT_SIZE) +
2000
+ SPEED_BADGE_H_PAD * 2 +
2001
+ SPEED_BADGE_GAP;
1661
2002
  }
1662
2003
  return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1663
2004
  }
@@ -1670,11 +2011,12 @@ function renderLegend(
1670
2011
  palette: PaletteColors,
1671
2012
  isDark: boolean,
1672
2013
  activeGroup: string | null,
1673
- playback?: InfraPlaybackState,
2014
+ playback?: InfraPlaybackState
1674
2015
  ) {
1675
2016
  if (legendGroups.length === 0 && !playback) return;
1676
2017
 
1677
- const legendG = rootSvg.append('g')
2018
+ const legendG = rootSvg
2019
+ .append('g')
1678
2020
  .attr('transform', `translate(0, ${legendY})`);
1679
2021
 
1680
2022
  if (activeGroup) {
@@ -1683,23 +2025,31 @@ function renderLegend(
1683
2025
 
1684
2026
  // Compute centered positions
1685
2027
  const effectiveW = (g: InfraLegendGroup) =>
1686
- activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
2028
+ activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase()
2029
+ ? g.width
2030
+ : g.minifiedWidth;
1687
2031
  const playbackW = computePlaybackWidth(playback);
1688
- const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
1689
- const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
1690
- + (legendGroups.length - 1) * LEGEND_GROUP_GAP
1691
- + trailingGaps + playbackW;
2032
+ const trailingGaps =
2033
+ legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
2034
+ const totalLegendW =
2035
+ legendGroups.reduce((s, g) => s + effectiveW(g), 0) +
2036
+ (legendGroups.length - 1) * LEGEND_GROUP_GAP +
2037
+ trailingGaps +
2038
+ playbackW;
1692
2039
  let cursorX = (totalWidth - totalLegendW) / 2;
1693
2040
 
1694
2041
  for (const group of legendGroups) {
1695
- const isActive = activeGroup != null && group.name.toLowerCase() === activeGroup.toLowerCase();
2042
+ const isActive =
2043
+ activeGroup != null &&
2044
+ group.name.toLowerCase() === activeGroup.toLowerCase();
1696
2045
 
1697
2046
  const groupBg = isDark
1698
2047
  ? mix(palette.surface, palette.bg, 50)
1699
2048
  : mix(palette.surface, palette.bg, 30);
1700
2049
 
1701
2050
  const pillLabel = group.name;
1702
- const pillWidth = measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2051
+ const pillWidth =
2052
+ measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1703
2053
 
1704
2054
  const gEl = legendG
1705
2055
  .append('g')
@@ -1710,7 +2060,8 @@ function renderLegend(
1710
2060
 
1711
2061
  // Outer capsule background (active only)
1712
2062
  if (isActive) {
1713
- gEl.append('rect')
2063
+ gEl
2064
+ .append('rect')
1714
2065
  .attr('width', group.width)
1715
2066
  .attr('height', LEGEND_HEIGHT)
1716
2067
  .attr('rx', LEGEND_HEIGHT / 2)
@@ -1722,7 +2073,8 @@ function renderLegend(
1722
2073
  const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
1723
2074
 
1724
2075
  // Pill background
1725
- gEl.append('rect')
2076
+ gEl
2077
+ .append('rect')
1726
2078
  .attr('x', pillXOff)
1727
2079
  .attr('y', pillYOff)
1728
2080
  .attr('width', pillWidth)
@@ -1732,7 +2084,8 @@ function renderLegend(
1732
2084
 
1733
2085
  // Active pill border
1734
2086
  if (isActive) {
1735
- gEl.append('rect')
2087
+ gEl
2088
+ .append('rect')
1736
2089
  .attr('x', pillXOff)
1737
2090
  .attr('y', pillYOff)
1738
2091
  .attr('width', pillWidth)
@@ -1744,7 +2097,8 @@ function renderLegend(
1744
2097
  }
1745
2098
 
1746
2099
  // Pill text
1747
- gEl.append('text')
2100
+ gEl
2101
+ .append('text')
1748
2102
  .attr('x', pillXOff + pillWidth / 2)
1749
2103
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1750
2104
  .attr('font-family', FONT_FAMILY)
@@ -1764,17 +2118,22 @@ function renderLegend(
1764
2118
  .attr('data-legend-entry', entry.key.toLowerCase())
1765
2119
  .attr('data-legend-color', entry.color)
1766
2120
  .attr('data-legend-type', group.type)
1767
- .attr('data-legend-tag-group', group.type === 'tag' ? (group.tagKey ?? '') : null)
2121
+ .attr(
2122
+ 'data-legend-tag-group',
2123
+ group.type === 'tag' ? (group.tagKey ?? '') : null
2124
+ )
1768
2125
  .style('cursor', 'pointer');
1769
2126
 
1770
- entryG.append('circle')
2127
+ entryG
2128
+ .append('circle')
1771
2129
  .attr('cx', entryX + LEGEND_DOT_R)
1772
2130
  .attr('cy', LEGEND_HEIGHT / 2)
1773
2131
  .attr('r', LEGEND_DOT_R)
1774
2132
  .attr('fill', entry.color);
1775
2133
 
1776
2134
  const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
1777
- entryG.append('text')
2135
+ entryG
2136
+ .append('text')
1778
2137
  .attr('x', textX)
1779
2138
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
1780
2139
  .attr('font-family', FONT_FAMILY)
@@ -1782,7 +2141,10 @@ function renderLegend(
1782
2141
  .attr('fill', palette.textMuted)
1783
2142
  .text(entry.value);
1784
2143
 
1785
- entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
2144
+ entryX =
2145
+ textX +
2146
+ measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
2147
+ LEGEND_ENTRY_TRAIL;
1786
2148
  }
1787
2149
  }
1788
2150
 
@@ -1797,7 +2159,8 @@ function renderLegend(
1797
2159
  : mix(palette.bg, palette.text, 92);
1798
2160
 
1799
2161
  const pillLabel = 'Playback';
1800
- const pillWidth = measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2162
+ const pillWidth =
2163
+ measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1801
2164
  const fullW = computePlaybackWidth(playback);
1802
2165
 
1803
2166
  const pbG = legendG
@@ -1807,7 +2170,8 @@ function renderLegend(
1807
2170
  .style('cursor', 'pointer');
1808
2171
 
1809
2172
  if (isExpanded) {
1810
- pbG.append('rect')
2173
+ pbG
2174
+ .append('rect')
1811
2175
  .attr('width', fullW)
1812
2176
  .attr('height', LEGEND_HEIGHT)
1813
2177
  .attr('rx', LEGEND_HEIGHT / 2)
@@ -1818,23 +2182,30 @@ function renderLegend(
1818
2182
  const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1819
2183
  const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
1820
2184
 
1821
- pbG.append('rect')
1822
- .attr('x', pillXOff).attr('y', pillYOff)
1823
- .attr('width', pillWidth).attr('height', pillH)
2185
+ pbG
2186
+ .append('rect')
2187
+ .attr('x', pillXOff)
2188
+ .attr('y', pillYOff)
2189
+ .attr('width', pillWidth)
2190
+ .attr('height', pillH)
1824
2191
  .attr('rx', pillH / 2)
1825
2192
  .attr('fill', isExpanded ? palette.bg : groupBg);
1826
2193
 
1827
2194
  if (isExpanded) {
1828
- pbG.append('rect')
1829
- .attr('x', pillXOff).attr('y', pillYOff)
1830
- .attr('width', pillWidth).attr('height', pillH)
2195
+ pbG
2196
+ .append('rect')
2197
+ .attr('x', pillXOff)
2198
+ .attr('y', pillYOff)
2199
+ .attr('width', pillWidth)
2200
+ .attr('height', pillH)
1831
2201
  .attr('rx', pillH / 2)
1832
2202
  .attr('fill', 'none')
1833
2203
  .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1834
2204
  .attr('stroke-width', 0.75);
1835
2205
  }
1836
2206
 
1837
- pbG.append('text')
2207
+ pbG
2208
+ .append('text')
1838
2209
  .attr('x', pillXOff + pillWidth / 2)
1839
2210
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1840
2211
  .attr('font-family', FONT_FAMILY)
@@ -1849,8 +2220,10 @@ function renderLegend(
1849
2220
  const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
1850
2221
 
1851
2222
  const ppLabel = playback.paused ? '▶' : '⏸';
1852
- pbG.append('text')
1853
- .attr('x', entryX).attr('y', entryY)
2223
+ pbG
2224
+ .append('text')
2225
+ .attr('x', entryX)
2226
+ .attr('y', entryY)
1854
2227
  .attr('font-family', FONT_FAMILY)
1855
2228
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
1856
2229
  .attr('fill', palette.textMuted)
@@ -1862,16 +2235,20 @@ function renderLegend(
1862
2235
  for (const s of playback.speedOptions) {
1863
2236
  const label = `${s}x`;
1864
2237
  const isActive = playback.speed === s;
1865
- const slotW = measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) + SPEED_BADGE_H_PAD * 2;
2238
+ const slotW =
2239
+ measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) +
2240
+ SPEED_BADGE_H_PAD * 2;
1866
2241
  const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
1867
2242
  const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
1868
2243
 
1869
- const speedG = pbG.append('g')
2244
+ const speedG = pbG
2245
+ .append('g')
1870
2246
  .attr('data-playback-action', 'set-speed')
1871
2247
  .attr('data-playback-value', String(s))
1872
2248
  .style('cursor', 'pointer');
1873
2249
 
1874
- speedG.append('rect')
2250
+ speedG
2251
+ .append('rect')
1875
2252
  .attr('x', entryX)
1876
2253
  .attr('y', badgeY)
1877
2254
  .attr('width', slotW)
@@ -1879,8 +2256,10 @@ function renderLegend(
1879
2256
  .attr('rx', badgeH / 2)
1880
2257
  .attr('fill', isActive ? palette.primary : 'transparent');
1881
2258
 
1882
- speedG.append('text')
1883
- .attr('x', entryX + slotW / 2).attr('y', entryY)
2259
+ speedG
2260
+ .append('text')
2261
+ .attr('x', entryX + slotW / 2)
2262
+ .attr('y', entryY)
1884
2263
  .attr('font-family', FONT_FAMILY)
1885
2264
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1886
2265
  .attr('font-weight', isActive ? '600' : '400')
@@ -1891,9 +2270,8 @@ function renderLegend(
1891
2270
  }
1892
2271
  }
1893
2272
 
1894
- cursorX += fullW + LEGEND_GROUP_GAP;
2273
+ cursorX += fullW + LEGEND_GROUP_GAP; // eslint-disable-line no-useless-assignment
1895
2274
  }
1896
-
1897
2275
  }
1898
2276
 
1899
2277
  // ============================================================
@@ -1920,13 +2298,18 @@ export function renderInfra(
1920
2298
  playback?: InfraPlaybackState | null,
1921
2299
  expandedNodeIds?: Set<string> | null,
1922
2300
  exportMode?: boolean,
1923
- collapsedNodes?: Set<string> | null,
2301
+ collapsedNodes?: Set<string> | null
1924
2302
  ) {
1925
2303
  // Clear previous render (preserve tooltips if any)
1926
2304
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1927
2305
 
1928
2306
  // Build legend groups
1929
- const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
2307
+ const legendGroups = computeInfraLegendGroups(
2308
+ layout.nodes,
2309
+ tagGroups ?? [],
2310
+ palette,
2311
+ layout.edges
2312
+ );
1930
2313
  const hasLegend = legendGroups.length > 0 || !!playback;
1931
2314
  // In app mode (not export), legend is rendered as a separate fixed-size SVG
1932
2315
  const fixedLegend = !exportMode && hasLegend;
@@ -1947,7 +2330,8 @@ export function renderInfra(
1947
2330
 
1948
2331
  if (fixedTitleH) {
1949
2332
  const titleContainerW = container.clientWidth || totalWidth;
1950
- const titleSvg = d3Selection.select(container)
2333
+ const titleSvg = d3Selection
2334
+ .select(container)
1951
2335
  .append('svg')
1952
2336
  .attr('class', 'infra-title-fixed')
1953
2337
  .attr('width', '100%')
@@ -1955,7 +2339,8 @@ export function renderInfra(
1955
2339
  .attr('viewBox', `0 0 ${titleContainerW} ${fixedTitleH}`)
1956
2340
  .attr('preserveAspectRatio', 'xMidYMid meet')
1957
2341
  .style('display', 'block');
1958
- titleSvg.append('text')
2342
+ titleSvg
2343
+ .append('text')
1959
2344
  .attr('class', 'chart-title')
1960
2345
  .attr('x', titleContainerW / 2)
1961
2346
  .attr('y', TITLE_Y)
@@ -1968,12 +2353,17 @@ export function renderInfra(
1968
2353
  .text(title!);
1969
2354
  }
1970
2355
 
1971
- const fixedOverheadH = (fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0) + fixedTitleH;
1972
- const rootSvg = d3Selection.select(container)
2356
+ const fixedOverheadH =
2357
+ (fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0) + fixedTitleH;
2358
+ const rootSvg = d3Selection
2359
+ .select(container)
1973
2360
  .append('svg')
1974
2361
  .attr('xmlns', 'http://www.w3.org/2000/svg')
1975
2362
  .attr('width', '100%')
1976
- .attr('height', fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%')
2363
+ .attr(
2364
+ 'height',
2365
+ fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%'
2366
+ )
1977
2367
  .attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
1978
2368
  .attr('preserveAspectRatio', 'xMidYMid meet');
1979
2369
 
@@ -2023,12 +2413,14 @@ export function renderInfra(
2023
2413
 
2024
2414
  // Content group offset: skip title space (unless title was extracted to fixed SVG)
2025
2415
  const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
2026
- const svg = rootSvg.append('g')
2416
+ const svg = rootSvg
2417
+ .append('g')
2027
2418
  .attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
2028
2419
 
2029
2420
  // Title (inside rootSvg when not using fixed title)
2030
2421
  if (title && !fixedTitleH) {
2031
- rootSvg.append('text')
2422
+ rootSvg
2423
+ .append('text')
2032
2424
  .attr('class', 'chart-title')
2033
2425
  .attr('x', totalWidth / 2)
2034
2426
  .attr('y', TITLE_Y)
@@ -2044,43 +2436,103 @@ export function renderInfra(
2044
2436
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
2045
2437
  renderGroups(svg, layout.groups, palette, isDark);
2046
2438
  const speedMultiplier = playback?.speed ?? 1;
2047
- renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction, speedMultiplier);
2439
+ renderEdgePaths(
2440
+ svg,
2441
+ layout.edges,
2442
+ layout.nodes,
2443
+ layout.groups,
2444
+ palette,
2445
+ isDark,
2446
+ shouldAnimate,
2447
+ layout.direction,
2448
+ speedMultiplier
2449
+ );
2048
2450
  const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
2049
2451
  const scaledGroupIds = new Set<string>(
2050
2452
  layout.groups
2051
2453
  .filter((g) => {
2052
- const gi = typeof g.instances === 'number' ? g.instances
2053
- : typeof g.instances === 'string' ? parseInt(String(g.instances), 10) || 0 : 0;
2454
+ const gi =
2455
+ typeof g.instances === 'number'
2456
+ ? g.instances
2457
+ : typeof g.instances === 'string'
2458
+ ? parseInt(String(g.instances), 10) || 0
2459
+ : 0;
2054
2460
  return gi > 1;
2055
2461
  })
2056
2462
  .map((g) => g.id)
2057
2463
  );
2058
- renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, expandedNodeIds, activeGroup, layout.options, collapsedNodes, tagGroups ?? [], fanoutSourceIds, scaledGroupIds);
2464
+ renderNodes(
2465
+ svg,
2466
+ layout.nodes,
2467
+ palette,
2468
+ isDark,
2469
+ shouldAnimate,
2470
+ expandedNodeIds,
2471
+ activeGroup,
2472
+ layout.options,
2473
+ collapsedNodes,
2474
+ tagGroups ?? [],
2475
+ fanoutSourceIds,
2476
+ scaledGroupIds
2477
+ );
2059
2478
  if (shouldAnimate) {
2060
2479
  renderRejectParticles(svg, layout.nodes, speedMultiplier);
2061
2480
  }
2062
- renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
2481
+ renderEdgeLabels(
2482
+ svg,
2483
+ layout.edges,
2484
+ layout.nodes,
2485
+ layout.groups,
2486
+ palette,
2487
+ isDark,
2488
+ shouldAnimate,
2489
+ layout.direction
2490
+ );
2063
2491
 
2064
2492
  // Legend at top
2065
2493
  if (hasLegend) {
2066
2494
  if (fixedLegend) {
2067
2495
  // Render legend in a separate SVG that stays at fixed pixel size, inserted between title and diagram
2068
2496
  const containerWidth = container.clientWidth || totalWidth;
2069
- const legendSvg = d3Selection.select(container)
2497
+ const legendSvg = d3Selection
2498
+ .select(container)
2070
2499
  .insert('svg', 'svg:last-of-type')
2071
2500
  .attr('class', 'infra-legend-fixed')
2072
2501
  .attr('width', '100%')
2073
2502
  .attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
2074
- .attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
2503
+ .attr(
2504
+ 'viewBox',
2505
+ `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`
2506
+ )
2075
2507
  .attr('preserveAspectRatio', 'xMidYMid meet')
2076
2508
  .style('display', 'block')
2077
2509
  .style('pointer-events', 'none');
2078
- renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
2510
+ renderLegend(
2511
+ legendSvg,
2512
+ legendGroups,
2513
+ containerWidth,
2514
+ LEGEND_FIXED_GAP / 2,
2515
+ palette,
2516
+ isDark,
2517
+ activeGroup ?? null,
2518
+ playback ?? undefined
2519
+ );
2079
2520
  // Re-enable pointer events on interactive legend elements
2080
- legendSvg.selectAll('.infra-legend-group').style('pointer-events', 'auto');
2521
+ legendSvg
2522
+ .selectAll('.infra-legend-group')
2523
+ .style('pointer-events', 'auto');
2081
2524
  } else {
2082
2525
  // Export mode: render legend at top (below title)
2083
- renderLegend(rootSvg, legendGroups, totalWidth, titleOffset, palette, isDark, activeGroup ?? null, playback ?? undefined);
2526
+ renderLegend(
2527
+ rootSvg,
2528
+ legendGroups,
2529
+ totalWidth,
2530
+ titleOffset,
2531
+ palette,
2532
+ isDark,
2533
+ activeGroup ?? null,
2534
+ playback ?? undefined
2535
+ );
2084
2536
  }
2085
2537
  }
2086
2538
  }