@diagrammo/dgmo 0.4.2 → 0.4.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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -0,0 +1,1553 @@
1
+ // ============================================================
2
+ // Infra Chart SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import * as d3Shape from 'd3-shape';
7
+ import { FONT_FAMILY } from '../fonts';
8
+ import type { PaletteColors } from '../palettes';
9
+ import { mix } from '../palettes/color-utils';
10
+ import type { ParsedInfra, InfraTagGroup } from './types';
11
+ import { resolveColor } from '../colors';
12
+ import type { ComputedInfraModel } from './types';
13
+ import type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './layout';
14
+ import { inferRoles, collectDiagramRoles } from './roles';
15
+ import type { InfraRole } from './roles';
16
+ import { parseInfra } from './parser';
17
+ import { computeInfra } from './compute';
18
+ import { layoutInfra } from './layout';
19
+
20
+ // ============================================================
21
+ // Constants
22
+ // ============================================================
23
+
24
+ const DIAGRAM_PADDING = 20;
25
+ const NODE_FONT_SIZE = 13;
26
+ const META_FONT_SIZE = 10;
27
+ const META_LINE_HEIGHT = 14;
28
+ const RPS_FONT_SIZE = 11;
29
+ const EDGE_LABEL_FONT_SIZE = 11;
30
+ const GROUP_LABEL_FONT_SIZE = 14;
31
+ const NODE_BORDER_RADIUS = 8;
32
+ const EDGE_STROKE_WIDTH = 1.5;
33
+ const NODE_STROKE_WIDTH = 1.5;
34
+ const OVERLOAD_STROKE_WIDTH = 3;
35
+ const ROLE_DOT_RADIUS = 3;
36
+ const NODE_HEADER_HEIGHT = 28;
37
+ const NODE_SEPARATOR_GAP = 4;
38
+ const NODE_PAD_BOTTOM = 10;
39
+ const COLLAPSE_BAR_HEIGHT = 6;
40
+ const COLLAPSE_BAR_INSET = 0;
41
+
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
+
56
+ // Health colors (from UX spec)
57
+ const COLOR_HEALTHY = '#22c55e';
58
+ const COLOR_WARNING = '#eab308';
59
+ const COLOR_OVERLOADED = '#ef4444';
60
+ const COLOR_NEUTRAL = '#94a3b8';
61
+
62
+ /** A row in the node card body. `inverted` renders as a colored pill with light text. */
63
+ interface NodeRow {
64
+ key: string;
65
+ value: string;
66
+ valueFill: string;
67
+ fontWeight: string;
68
+ inverted?: boolean;
69
+ invertedBg?: string;
70
+ }
71
+
72
+ /** Computed metric row (latency percentiles, availability, CB state). */
73
+ interface ComputedRow {
74
+ key: string;
75
+ value: string;
76
+ color?: string;
77
+ inverted?: boolean;
78
+ }
79
+
80
+ // Animation constants
81
+ const FLOW_DASH = '8 6'; // dash-array for animated edges
82
+ const FLOW_DASH_LEN = 14; // sum of dash + gap (for offset keyframe)
83
+ const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
84
+ const FLOW_SPEED_MAX = 6; // seconds at min RPS
85
+ const OVERLOAD_PULSE_SPEED = 0.8; // seconds for overload pulse cycle
86
+ const PARTICLE_R = 5; // particle circle radius
87
+ const PARTICLE_COUNT_MIN = 1; // min particles per edge
88
+ const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
89
+ const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
90
+ const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
91
+
92
+ // Reject particle constants
93
+ const REJECT_PARTICLE_R = PARTICLE_R;
94
+ const REJECT_DROP_DISTANCE = 30; // px downward travel
95
+ const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
96
+ const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
97
+ const REJECT_COUNT_MIN = 1;
98
+ const REJECT_COUNT_MAX = 3;
99
+
100
+ // ============================================================
101
+ // Edge path generator
102
+ // ============================================================
103
+
104
+ const lineGenerator = d3Shape.line<{ x: number; y: number }>()
105
+ .x((d) => d.x)
106
+ .y((d) => d.y)
107
+ .curve(d3Shape.curveBasis);
108
+
109
+ /** Compute the point on a node's border closest to an external target point. */
110
+ function nodeBorderPoint(
111
+ node: InfraLayoutNode,
112
+ target: { x: number; y: number },
113
+ ): { x: number; y: number } {
114
+ const hw = node.width / 2;
115
+ const hh = node.height / 2;
116
+ const dx = target.x - node.x;
117
+ const dy = target.y - node.y;
118
+ if (dx === 0 && dy === 0) return { x: node.x + hw, y: node.y };
119
+
120
+ // Scale factor to reach the bounding box edge
121
+ const sx = hw / Math.abs(dx || 1);
122
+ const sy = hh / Math.abs(dy || 1);
123
+ const s = Math.min(sx, sy);
124
+
125
+ return { x: node.x + dx * s, y: node.y + dy * s };
126
+ }
127
+
128
+ /** Map RPS to animation duration — higher RPS = faster (shorter duration). */
129
+ function flowDuration(rps: number, maxRps: number): number {
130
+ if (maxRps <= 0) return FLOW_SPEED_MAX;
131
+ const t = Math.min(rps / maxRps, 1);
132
+ return FLOW_SPEED_MAX - t * (FLOW_SPEED_MAX - FLOW_SPEED_MIN);
133
+ }
134
+
135
+ /** Map RPS to particle count — more traffic = more particles. */
136
+ function particleCount(rps: number, maxRps: number): number {
137
+ if (maxRps <= 0) return PARTICLE_COUNT_MIN;
138
+ const t = Math.min(rps / maxRps, 1);
139
+ return Math.round(PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN));
140
+ }
141
+
142
+ /** Determine if a node is in warning state (>70% capacity but not overloaded). */
143
+ function isWarning(node: InfraLayoutNode): boolean {
144
+ if (node.overloaded || node.isEdge) return false;
145
+ // Serverless: concurrency-based capacity
146
+ const concurrencyProp = node.properties.find((p) => p.key === 'concurrency');
147
+ if (concurrencyProp) {
148
+ const concurrency = Number(concurrencyProp.value);
149
+ const durationProp = node.properties.find((p) => p.key === 'duration-ms');
150
+ const durationMs = durationProp ? Number(durationProp.value) : 100;
151
+ const cap = concurrency / (durationMs / 1000);
152
+ return cap > 0 && node.computedRps / cap > 0.7;
153
+ }
154
+ // Traditional: max-rps * instances
155
+ const maxRps = node.properties.find((p) => p.key === 'max-rps');
156
+ if (!maxRps) return false;
157
+ const cap = Number(maxRps.value) * node.computedInstances;
158
+ return cap > 0 && node.computedRps / cap > 0.7;
159
+ }
160
+
161
+ // ============================================================
162
+ // Helpers
163
+ // ============================================================
164
+
165
+ /** Display names for behavior property keys. */
166
+ const PROP_DISPLAY: Record<string, string> = {
167
+ 'cache-hit': 'cache hit',
168
+ 'firewall-block': 'fw block',
169
+ 'ratelimit-rps': 'rate limit',
170
+ 'latency-ms': 'latency',
171
+ 'uptime': 'uptime',
172
+ 'instances': 'instances',
173
+ 'max-rps': 'capacity',
174
+ 'cb-error-threshold': 'CB error',
175
+ 'cb-latency-threshold-ms': 'CB latency',
176
+ 'concurrency': 'concurrency',
177
+ 'duration-ms': 'duration',
178
+ 'cold-start-ms': 'cold start',
179
+ 'buffer': 'buffer',
180
+ 'drain-rate': 'drain',
181
+ 'retention-hours': 'retention',
182
+ 'partitions': 'partitions',
183
+ };
184
+
185
+ /** Keys whose values are RPS counts and should be formatted like RPS. */
186
+ const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
187
+
188
+ /** Keys whose values are milliseconds and should show the "ms" suffix. */
189
+ const MS_FORMAT_KEYS = new Set(['latency-ms', 'cb-latency-threshold-ms', 'duration-ms', 'cold-start-ms']);
190
+
191
+ /** Keys whose values are percentages and should show the "%" suffix. */
192
+ const PCT_FORMAT_KEYS = new Set(['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold']);
193
+
194
+ /** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
195
+ function getComputedRows(node: InfraLayoutNode, expanded: boolean): ComputedRow[] {
196
+ const rows: ComputedRow[] = [];
197
+
198
+ // Serverless instances: demand vs concurrency limit
199
+ if (node.computedConcurrentInvocations > 0) {
200
+ const concurrency = getNodeNumProp(node, 'concurrency', 0);
201
+ const demand = node.computedConcurrentInvocations;
202
+ const ratio = concurrency > 0 ? demand / concurrency : 0;
203
+ const color = ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
204
+ const value = concurrency > 0
205
+ ? `${formatCount(demand)} / ${formatCount(concurrency)}`
206
+ : `${formatCount(demand)}`;
207
+ rows.push({ key: 'instances', value, color, inverted: color != null });
208
+ }
209
+
210
+ const p = node.computedLatencyPercentiles;
211
+ if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) {
212
+ if (expanded) {
213
+ rows.push({ key: 'p50', value: formatMsShort(p.p50) });
214
+ rows.push({ key: 'p90', value: formatMsShort(p.p90) });
215
+ }
216
+ rows.push({ key: 'p99', value: formatMsShort(p.p99) });
217
+ }
218
+ // Computed (cumulative) uptime — only show when it differs from the declared node uptime.
219
+ // On the edge node this is the system-wide uptime; on other nodes it's the path product.
220
+ // Label as "eff. uptime" to distinguish from the declared "uptime" property row.
221
+ if (node.computedUptime < 1) {
222
+ const declaredUptime = node.properties.find((p) => p.key === 'uptime');
223
+ const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
224
+ const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
225
+ if (differs || node.isEdge) {
226
+ rows.push({ key: 'eff. uptime', value: formatUptimeShort(node.computedUptime) });
227
+ }
228
+ }
229
+ if (node.computedAvailability < 1) {
230
+ const color = node.computedAvailability < 0.95 ? COLOR_OVERLOADED
231
+ : node.computedAvailability < 0.99 ? COLOR_WARNING
232
+ : undefined;
233
+ rows.push({ key: 'availability', value: formatUptimeShort(node.computedAvailability), color, inverted: color != null });
234
+ }
235
+ // Circuit breaker state — show when a CB is configured and open
236
+ if (node.computedCbState === 'open') {
237
+ rows.push({ key: 'CB', value: 'OPEN', color: COLOR_OVERLOADED, inverted: true });
238
+ }
239
+ // Queue computed rows: lag and overflow
240
+ if (node.queueMetrics) {
241
+ const { fillRate, timeToOverflow } = node.queueMetrics;
242
+ if (fillRate > 0) {
243
+ rows.push({ key: 'lag', value: `${formatCount(Math.round(fillRate))} msg/s` });
244
+ }
245
+ if (fillRate > 0 && timeToOverflow < Infinity) {
246
+ const overflowColor = timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
247
+ rows.push({
248
+ key: 'overflow',
249
+ value: `~${Math.round(timeToOverflow)}s`,
250
+ color: overflowColor,
251
+ inverted: timeToOverflow < 60,
252
+ });
253
+ }
254
+ }
255
+ return rows;
256
+ }
257
+
258
+ function formatCount(n: number): string {
259
+ if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
260
+ if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
261
+ return String(n);
262
+ }
263
+
264
+ function formatMsShort(ms: number): string {
265
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
266
+ return `${Math.round(ms)}ms`;
267
+ }
268
+
269
+ function formatUptimeShort(fraction: number): string {
270
+ const pct = fraction * 100;
271
+ if (pct >= 99.99) return '99.99%';
272
+ if (pct >= 99.9) return `${pct.toFixed(2)}%`;
273
+ if (pct >= 99) return `${pct.toFixed(1)}%`;
274
+ return `${pct.toFixed(1)}%`;
275
+ }
276
+
277
+ /** Properties shown as key-value rows inside the node card. */
278
+ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOptions?: Record<string, string>): { key: string; displayKey: string; value: string }[] {
279
+ if (node.isEdge) return [];
280
+ const rows: { key: string; displayKey: string; value: string }[] = [];
281
+ for (const p of node.properties) {
282
+ const displayKey = PROP_DISPLAY[p.key];
283
+ if (!displayKey) continue;
284
+ // Rate limit and max-rps are already shown in the RPS denominator —
285
+ // only show the dedicated row when the node is expanded (selected).
286
+ if (p.key === 'ratelimit-rps' && !expanded) continue;
287
+ if (p.key === 'max-rps' && !expanded) continue;
288
+ if (p.key === 'concurrency' && !expanded) continue;
289
+ // Format values with appropriate units
290
+ if (RPS_FORMAT_KEYS.has(p.key)) {
291
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
292
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatRpsShort(num) });
293
+ } else if (MS_FORMAT_KEYS.has(p.key)) {
294
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
295
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatMsShort(num) });
296
+ } else if (PCT_FORMAT_KEYS.has(p.key)) {
297
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
298
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}%` });
299
+ } else if (p.key === 'buffer') {
300
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
301
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatCount(num) });
302
+ } else if (p.key === 'drain-rate') {
303
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
304
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s` });
305
+ } else if (p.key === 'retention-hours') {
306
+ const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
307
+ rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}h` });
308
+ } else {
309
+ rows.push({ key: p.key, displayKey, value: String(p.value) });
310
+ }
311
+ }
312
+ // Inject diagram-level defaults for properties the node doesn't explicitly declare
313
+ if (diagramOptions) {
314
+ const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
315
+ const hasUptime = node.properties.some((p) => p.key === 'uptime');
316
+ const isServerlessNode = node.properties.some((p) => p.key === 'concurrency');
317
+ const defaultLatency = parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
318
+ const defaultUptime = parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
319
+ if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
320
+ rows.push({ key: 'latency-ms', displayKey: 'latency', value: formatMsShort(defaultLatency) });
321
+ }
322
+ if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
323
+ rows.push({ key: 'uptime', displayKey: 'uptime', value: `${defaultUptime}%` });
324
+ }
325
+ }
326
+ return rows;
327
+ }
328
+
329
+ function formatRps(rps: number): string {
330
+ if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k rps`;
331
+ return `${Math.round(rps)} rps`;
332
+ }
333
+
334
+ /** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
335
+ function formatRpsShort(rps: number): string {
336
+ if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
337
+ return `${Math.round(rps)}`;
338
+ }
339
+
340
+ /** Compute the worst severity across all row-producing conditions for a node.
341
+ * Returns 'overloaded' (red), 'warning' (yellow), or 'normal'. */
342
+ function worstNodeSeverity(node: InfraLayoutNode): 'overloaded' | 'warning' | 'normal' {
343
+ let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
344
+ const upgrade = (s: 'overloaded' | 'warning') => {
345
+ if (s === 'overloaded') worst = 'overloaded';
346
+ else if (worst !== 'overloaded') worst = 'warning';
347
+ };
348
+
349
+ // Collapsed group nodes: incorporate child health state
350
+ if (node.childHealthState === 'overloaded') upgrade('overloaded');
351
+ else if (node.childHealthState === 'warning') upgrade('warning');
352
+
353
+ // RPS-based: overloaded, rate-limited, or >70% capacity
354
+ if (node.overloaded) upgrade('overloaded');
355
+ if (node.rateLimited) upgrade('warning');
356
+ if (isWarning(node)) upgrade('warning');
357
+
358
+ // Availability
359
+ if (node.computedAvailability < 0.95) upgrade('overloaded');
360
+ else if (node.computedAvailability < 0.99) upgrade('warning');
361
+
362
+ // Circuit breaker open
363
+ if (node.computedCbState === 'open') upgrade('overloaded');
364
+
365
+ // Queue state: filling → warning, imminent overflow → overloaded
366
+ if (node.queueMetrics && node.queueMetrics.fillRate > 0) {
367
+ if (node.queueMetrics.timeToOverflow < 60) upgrade('overloaded');
368
+ else upgrade('warning');
369
+ }
370
+
371
+ // Rate-limit row (pre-rate-limit RPS vs configured limit)
372
+ if (!node.isEdge) {
373
+ const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
374
+ if (nodeRateLimit > 0 && node.computedRps > 0) {
375
+ let preRl = node.computedRps;
376
+ const ch = getNodeNumProp(node, 'cache-hit', 0);
377
+ if (ch > 0) preRl *= (100 - ch) / 100;
378
+ const fw = getNodeNumProp(node, 'firewall-block', 0);
379
+ if (fw > 0) preRl *= (100 - fw) / 100;
380
+ if (preRl > nodeRateLimit) upgrade('overloaded');
381
+ else if (preRl > nodeRateLimit * 0.8) upgrade('warning');
382
+ }
383
+ }
384
+
385
+ return worst;
386
+ }
387
+
388
+ function nodeColor(node: InfraLayoutNode, palette: PaletteColors, isDark: boolean): {
389
+ fill: string;
390
+ stroke: string;
391
+ textFill: string;
392
+ } {
393
+ const severity = worstNodeSeverity(node);
394
+ if (severity === 'overloaded') {
395
+ return {
396
+ fill: mix(palette.bg, COLOR_OVERLOADED, isDark ? 80 : 92),
397
+ stroke: COLOR_OVERLOADED,
398
+ textFill: palette.text,
399
+ };
400
+ }
401
+ if (severity === 'warning') {
402
+ return {
403
+ fill: mix(palette.bg, COLOR_WARNING, isDark ? 85 : 92),
404
+ stroke: COLOR_WARNING,
405
+ textFill: palette.text,
406
+ };
407
+ }
408
+ return {
409
+ fill: isDark ? mix(palette.bg, palette.text, 90) : mix(palette.bg, palette.text, 95),
410
+ stroke: isDark ? mix(palette.text, palette.bg, 60) : mix(palette.text, palette.bg, 40),
411
+ textFill: palette.text,
412
+ };
413
+ }
414
+
415
+ function edgeColor(_edge: InfraLayoutEdge, palette: PaletteColors): string {
416
+ return palette.textMuted;
417
+ }
418
+
419
+ function edgeWidth(): number {
420
+ return EDGE_STROKE_WIDTH;
421
+ }
422
+
423
+ // ============================================================
424
+ // Render functions
425
+ // ============================================================
426
+
427
+ function renderGroups(
428
+ svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
429
+ groups: InfraLayoutGroup[],
430
+ palette: PaletteColors,
431
+ isDark: boolean,
432
+ ) {
433
+ for (const group of groups) {
434
+ const g = svg.append('g')
435
+ .attr('class', 'infra-group')
436
+ .attr('data-line-number', group.lineNumber)
437
+ .attr('data-node-toggle', group.id)
438
+ .style('cursor', 'pointer');
439
+
440
+ // Filled background (matching org chart container style)
441
+ const groupFill = mix(palette.surface, palette.bg, 40);
442
+ const groupStroke = palette.textMuted;
443
+ g.append('rect')
444
+ .attr('x', group.x)
445
+ .attr('y', group.y)
446
+ .attr('width', group.width)
447
+ .attr('height', group.height)
448
+ .attr('rx', 6)
449
+ .attr('fill', groupFill)
450
+ .attr('stroke', groupStroke)
451
+ .attr('stroke-opacity', 0.35)
452
+ .attr('stroke-width', 1);
453
+
454
+ // Group label (centered at top)
455
+ g.append('text')
456
+ .attr('x', group.x + group.width / 2)
457
+ .attr('y', group.y + 16)
458
+ .attr('text-anchor', 'middle')
459
+ .attr('font-family', FONT_FAMILY)
460
+ .attr('font-size', GROUP_LABEL_FONT_SIZE)
461
+ .attr('font-weight', '600')
462
+ .attr('fill', palette.text)
463
+ .text(group.label);
464
+
465
+ // Group instances badge (top-right, like node instance badges)
466
+ const gi = typeof group.instances === 'number' ? group.instances :
467
+ typeof group.instances === 'string' ? parseInt(String(group.instances), 10) || 0 : 0;
468
+ if (gi > 1) {
469
+ g.append('text')
470
+ .attr('x', group.x + group.width - 8)
471
+ .attr('y', group.y + 16)
472
+ .attr('text-anchor', 'end')
473
+ .attr('font-family', FONT_FAMILY)
474
+ .attr('font-size', META_FONT_SIZE)
475
+ .attr('fill', palette.textMuted)
476
+ .text(`${gi}x`);
477
+ }
478
+ }
479
+ }
480
+
481
+ function renderEdgePaths(
482
+ svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
483
+ edges: InfraLayoutEdge[],
484
+ nodes: InfraLayoutNode[],
485
+ palette: PaletteColors,
486
+ isDark: boolean,
487
+ animate: boolean,
488
+ ) {
489
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
490
+ const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
491
+
492
+ for (const edge of edges) {
493
+ if (edge.points.length === 0) continue;
494
+
495
+ const targetNode = nodeMap.get(edge.targetId);
496
+ const sourceNode = nodeMap.get(edge.sourceId);
497
+ const color = edgeColor(edge, palette);
498
+ const strokeW = edgeWidth();
499
+
500
+ // Ensure dagre waypoints are ordered source→target (not guaranteed by dagre)
501
+ let pts = edge.points;
502
+ if (sourceNode && targetNode && pts.length >= 2) {
503
+ const first = pts[0];
504
+ const distFirstToSource = (first.x - sourceNode.x) ** 2 + (first.y - sourceNode.y) ** 2;
505
+ const distFirstToTarget = (first.x - targetNode.x) ** 2 + (first.y - targetNode.y) ** 2;
506
+ if (distFirstToTarget < distFirstToSource) {
507
+ pts = [...pts].reverse();
508
+ }
509
+ }
510
+
511
+ // Prepend source border point and append target border point so edges
512
+ // visually connect to node boundaries (dagre waypoints float between nodes)
513
+ if (sourceNode && pts.length > 0) {
514
+ const bp = nodeBorderPoint(sourceNode, pts[0]);
515
+ pts = [bp, ...pts];
516
+ }
517
+ if (targetNode && pts.length > 0) {
518
+ const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
519
+ pts = [...pts, bp];
520
+ }
521
+
522
+ const pathD = lineGenerator(pts) ?? '';
523
+ const edgeG = svg.append('g')
524
+ .attr('class', 'infra-edge')
525
+ .attr('data-line-number', edge.lineNumber);
526
+
527
+ edgeG.append('path')
528
+ .attr('d', pathD)
529
+ .attr('fill', 'none')
530
+ .attr('stroke', color)
531
+ .attr('stroke-width', strokeW);
532
+
533
+ if (animate && edge.computedRps > 0) {
534
+ const dur = flowDuration(edge.computedRps, maxRps);
535
+
536
+ // Particles traveling along the path — always green (overloaded nodes
537
+ // already have red styling + reject particles to show the problem)
538
+ const count = particleCount(edge.computedRps, maxRps);
539
+ const particleColor = palette.colors.green;
540
+
541
+ for (let i = 0; i < count; i++) {
542
+ const delay = (dur / count) * i;
543
+ const circle = edgeG.append('circle')
544
+ .attr('r', PARTICLE_R)
545
+ .attr('fill', particleColor)
546
+ .attr('opacity', 0.85);
547
+
548
+ // Use SMIL <animateMotion> for path-following
549
+ circle.append('animateMotion')
550
+ .attr('dur', `${dur}s`)
551
+ .attr('repeatCount', 'indefinite')
552
+ .attr('begin', `${delay}s`)
553
+ .attr('path', pathD);
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ function renderEdgeLabels(
560
+ svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
561
+ edges: InfraLayoutEdge[],
562
+ palette: PaletteColors,
563
+ isDark: boolean,
564
+ animate: boolean,
565
+ ) {
566
+ for (const edge of edges) {
567
+ if (edge.points.length === 0) continue;
568
+ if (!edge.label) continue;
569
+
570
+ const midIdx = Math.floor(edge.points.length / 2);
571
+ const midPt = edge.points[midIdx];
572
+ const labelText = edge.label;
573
+
574
+ const g = svg.append('g')
575
+ .attr('class', animate ? 'infra-edge-label' : '');
576
+
577
+ // Background rect for readability
578
+ const textWidth = labelText.length * 6.5 + 8;
579
+ g.append('rect')
580
+ .attr('x', midPt.x - textWidth / 2)
581
+ .attr('y', midPt.y - 8)
582
+ .attr('width', textWidth)
583
+ .attr('height', 16)
584
+ .attr('rx', 3)
585
+ .attr('fill', palette.bg)
586
+ .attr('opacity', 0.9);
587
+
588
+ g.append('text')
589
+ .attr('x', midPt.x)
590
+ .attr('y', midPt.y + 4)
591
+ .attr('text-anchor', 'middle')
592
+ .attr('font-family', FONT_FAMILY)
593
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
594
+ .attr('fill', palette.textMuted)
595
+ .text(labelText);
596
+
597
+ // When animated, add a wider invisible hover zone so labels appear on hover
598
+ if (animate) {
599
+ const pathD = lineGenerator(edge.points) ?? '';
600
+ g.insert('path', ':first-child')
601
+ .attr('d', pathD)
602
+ .attr('fill', 'none')
603
+ .attr('stroke', 'transparent')
604
+ .attr('stroke-width', 20);
605
+ }
606
+ }
607
+ }
608
+
609
+ /** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
610
+ function resolveActiveTagStroke(
611
+ node: InfraLayoutNode,
612
+ activeGroup: string,
613
+ tagGroups: InfraTagGroup[],
614
+ palette: PaletteColors,
615
+ ): string | null {
616
+ const tg = tagGroups.find((t) => t.name.toLowerCase() === activeGroup.toLowerCase());
617
+ if (!tg) return null;
618
+ const tagKey = (tg.alias ?? tg.name).toLowerCase();
619
+ const tagVal = node.tags[tagKey];
620
+ if (!tagVal) return null;
621
+ const tv = tg.values.find((v) => v.name.toLowerCase() === tagVal.toLowerCase());
622
+ if (!tv?.color) return null;
623
+ return resolveColor(tv.color, palette);
624
+ }
625
+
626
+ function renderNodes(
627
+ svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
628
+ nodes: InfraLayoutNode[],
629
+ palette: PaletteColors,
630
+ isDark: boolean,
631
+ animate: boolean,
632
+ selectedNodeId?: string | null,
633
+ activeGroup?: string | null,
634
+ diagramOptions?: Record<string, string>,
635
+ collapsedNodes?: Set<string> | null,
636
+ tagGroups?: InfraTagGroup[],
637
+ ) {
638
+ const mutedColor = palette.textMuted;
639
+
640
+ for (const node of nodes) {
641
+ let { fill, stroke, textFill } = nodeColor(node, palette, isDark);
642
+
643
+ // When a tag legend is active, override border color with tag color
644
+ if (activeGroup && tagGroups && !node.isEdge) {
645
+ const tagStroke = resolveActiveTagStroke(node, activeGroup, tagGroups, palette);
646
+ if (tagStroke) {
647
+ stroke = tagStroke;
648
+ fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
649
+ }
650
+ }
651
+ let cls = 'infra-node';
652
+ if (animate && node.isEdge) {
653
+ cls += ' infra-node-edge-throb';
654
+ } else if (animate && !node.isEdge) {
655
+ const severity = worstNodeSeverity(node);
656
+ if (node.computedCbState === 'open') cls += ' infra-node-cb-open';
657
+ else if (severity === 'overloaded') cls += ' infra-node-overload';
658
+ else if (severity === 'warning') cls += ' infra-node-warning';
659
+ }
660
+ const g = svg.append('g')
661
+ .attr('class', cls)
662
+ .attr('data-line-number', node.lineNumber)
663
+ .attr('data-infra-node', node.id)
664
+ .attr('data-node-collapse', node.id)
665
+ .style('cursor', 'pointer');
666
+
667
+ // Collapsed group nodes: toggle attribute
668
+ if (node.id.startsWith('[')) {
669
+ g.attr('data-node-toggle', node.id);
670
+ }
671
+
672
+ // Expose tag values for legend hover dimming
673
+ for (const [tagKey, tagVal] of Object.entries(node.tags)) {
674
+ g.attr(`data-tag-${tagKey.toLowerCase()}`, tagVal.toLowerCase());
675
+ }
676
+
677
+ // Expose role names for role legend hover dimming
678
+ if (!node.isEdge) {
679
+ const roles = inferRoles(node.properties);
680
+ for (const role of roles) {
681
+ g.attr(`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`, 'true');
682
+ }
683
+ }
684
+
685
+ const x = node.x - node.width / 2;
686
+ const y = node.y - node.height / 2;
687
+ const isCollapsedGroup = node.id.startsWith('[');
688
+ const strokeWidth = worstNodeSeverity(node) !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
689
+
690
+ // Node rect
691
+ g.append('rect')
692
+ .attr('x', x)
693
+ .attr('y', y)
694
+ .attr('width', node.width)
695
+ .attr('height', node.height)
696
+ .attr('rx', NODE_BORDER_RADIUS)
697
+ .attr('fill', fill)
698
+ .attr('stroke', stroke)
699
+ .attr('stroke-width', strokeWidth);
700
+
701
+ // Node name — centered in header area
702
+ const headerCenterY = y + NODE_HEADER_HEIGHT / 2 + NODE_FONT_SIZE * 0.35;
703
+ g.append('text')
704
+ .attr('x', node.x)
705
+ .attr('y', headerCenterY)
706
+ .attr('text-anchor', 'middle')
707
+ .attr('font-family', FONT_FAMILY)
708
+ .attr('font-size', NODE_FONT_SIZE)
709
+ .attr('font-weight', '600')
710
+ .attr('fill', textFill)
711
+ .text(node.label);
712
+
713
+ // --- Key-value rows below header (skipped for collapsed nodes) ---
714
+ const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
715
+ if (isNodeCollapsed) {
716
+ // Collapsed: show a subtle chevron indicator at the bottom of the header
717
+ const chevronY = y + node.height - 6;
718
+ g.append('text')
719
+ .attr('x', node.x)
720
+ .attr('y', chevronY)
721
+ .attr('text-anchor', 'middle')
722
+ .attr('font-family', FONT_FAMILY)
723
+ .attr('font-size', 8)
724
+ .attr('fill', textFill)
725
+ .attr('opacity', 0.5)
726
+ .text('▼');
727
+ }
728
+ if (!isNodeCollapsed) {
729
+ const expanded = node.id === selectedNodeId;
730
+ // Declared properties only shown when node is selected (expanded)
731
+ const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
732
+ const computedRows = getComputedRows(node, expanded);
733
+ const hasContent = displayProps.length > 0 || computedRows.length > 0 || node.computedRps > 0;
734
+
735
+ if (hasContent) {
736
+ // Separator line between header and body
737
+ const sepY = y + NODE_HEADER_HEIGHT;
738
+ g.append('line')
739
+ .attr('x1', x)
740
+ .attr('y1', sepY)
741
+ .attr('x2', x + node.width)
742
+ .attr('y2', sepY)
743
+ .attr('stroke', stroke)
744
+ .attr('stroke-opacity', 0.3)
745
+ .attr('stroke-width', 1);
746
+
747
+ // Build rows in two sections: computed (dynamic) on top, declared (config) below.
748
+ // A second separator line divides them when both sections have content.
749
+ const computedSection: NodeRow[] = [];
750
+ const declaredSection: NodeRow[] = [];
751
+
752
+ // Determine effective throughput limit for the RPS row denominator
753
+ const nodeMaxRps = getNodeNumProp(node, 'max-rps', 0);
754
+ const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
755
+ const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
756
+ const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
757
+ const serverlessCap = nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
758
+ const effectiveCap = serverlessCap > 0 ? serverlessCap
759
+ : nodeMaxRps > 0 && nodeRateLimit > 0
760
+ ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
761
+ : nodeMaxRps > 0 ? nodeMaxRps * node.computedInstances
762
+ : nodeRateLimit > 0 ? nodeRateLimit
763
+ : 0;
764
+
765
+ // --- Computed section: RPS + computed metrics ---
766
+ if (node.computedRps > 0) {
767
+ // Check rate-limit proximity (pre-ratelimit RPS after cache/fw reductions)
768
+ let rlSeverity: 'overloaded' | 'warning' | 'normal' = 'normal';
769
+ if (nodeRateLimit > 0 && node.computedRps > 0 && !node.isEdge) {
770
+ let preRl = node.computedRps;
771
+ const ch = getNodeNumProp(node, 'cache-hit', 0);
772
+ if (ch > 0) preRl *= (100 - ch) / 100;
773
+ const fw = getNodeNumProp(node, 'firewall-block', 0);
774
+ if (fw > 0) preRl *= (100 - fw) / 100;
775
+ if (preRl > nodeRateLimit) rlSeverity = 'overloaded';
776
+ else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
777
+ }
778
+ const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
779
+ node.overloaded ? 'overloaded'
780
+ : rlSeverity === 'overloaded' ? 'overloaded'
781
+ : node.rateLimited ? 'warning'
782
+ : isWarning(node) ? 'warning'
783
+ : rlSeverity === 'warning' ? 'warning'
784
+ : 'normal';
785
+ const rpsColor = rpsSeverity === 'overloaded' ? COLOR_OVERLOADED : rpsSeverity === 'warning' ? COLOR_WARNING : mutedColor;
786
+ const rpsInverted = rpsSeverity !== 'normal';
787
+ const rpsText = effectiveCap > 0 && !node.isEdge
788
+ ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
789
+ : formatRpsShort(node.computedRps);
790
+ computedSection.push({
791
+ key: 'RPS', value: rpsText, valueFill: rpsColor, fontWeight: '500',
792
+ inverted: rpsInverted, invertedBg: rpsInverted ? rpsColor : undefined,
793
+ });
794
+ }
795
+ for (const cr of computedRows) {
796
+ computedSection.push({
797
+ key: cr.key, value: cr.value,
798
+ valueFill: cr.color ?? mutedColor,
799
+ fontWeight: 'normal',
800
+ inverted: cr.inverted,
801
+ invertedBg: cr.inverted ? cr.color : undefined,
802
+ });
803
+ }
804
+
805
+ // --- Declared section: user-defined properties (only when node is selected) ---
806
+ for (const prop of displayProps) {
807
+ let propColor = textFill;
808
+ let inverted = false;
809
+ let invertedBg: string | undefined;
810
+ if (prop.key === 'ratelimit-rps' && nodeRateLimit > 0 && node.computedRps > 0) {
811
+ let preRl = node.computedRps;
812
+ const ch = getNodeNumProp(node, 'cache-hit', 0);
813
+ if (ch > 0) preRl *= (100 - ch) / 100;
814
+ const fw = getNodeNumProp(node, 'firewall-block', 0);
815
+ if (fw > 0) preRl *= (100 - fw) / 100;
816
+ if (preRl > nodeRateLimit) { propColor = COLOR_OVERLOADED; inverted = true; invertedBg = COLOR_OVERLOADED; }
817
+ else if (preRl > nodeRateLimit * 0.8) { propColor = COLOR_WARNING; inverted = true; invertedBg = COLOR_WARNING; }
818
+ }
819
+ declaredSection.push({ key: prop.displayKey, value: prop.value, valueFill: propColor, fontWeight: 'normal', inverted, invertedBg });
820
+ }
821
+
822
+ const rows = [...computedSection, ...declaredSection];
823
+
824
+ // Compute max key width so values align vertically
825
+ const maxKeyLen = Math.max(...rows.map((r) => r.key.length));
826
+ const valueX = x + 10 + (maxKeyLen + 2) * (META_FONT_SIZE * 0.6);
827
+
828
+ let rowY = sepY + NODE_SEPARATOR_GAP + META_FONT_SIZE;
829
+ const needsSectionSep = computedSection.length > 0 && declaredSection.length > 0;
830
+ let rowIdx = 0;
831
+ for (const row of rows) {
832
+ // Draw separator line between computed and declared sections
833
+ if (needsSectionSep && rowIdx === computedSection.length) {
834
+ const sepLineY = rowY - META_FONT_SIZE + 1;
835
+ g.append('line')
836
+ .attr('x1', x)
837
+ .attr('y1', sepLineY)
838
+ .attr('x2', x + node.width)
839
+ .attr('y2', sepLineY)
840
+ .attr('stroke', stroke)
841
+ .attr('stroke-opacity', 0.3)
842
+ .attr('stroke-width', 1);
843
+ rowY += NODE_SEPARATOR_GAP;
844
+ }
845
+ if (row.inverted && row.invertedBg) {
846
+ // Inverted pill: colored background spanning full row width, white text, values still aligned
847
+ const pillH = META_LINE_HEIGHT - 1;
848
+ const pillPad = 4;
849
+ const pillX = x + pillPad;
850
+ const pillY = rowY - META_FONT_SIZE + 1;
851
+ const pillW = node.width - pillPad * 2;
852
+ g.append('rect')
853
+ .attr('x', pillX)
854
+ .attr('y', pillY)
855
+ .attr('width', pillW)
856
+ .attr('height', pillH)
857
+ .attr('rx', 3)
858
+ .attr('fill', row.invertedBg)
859
+ .attr('opacity', 0.9);
860
+ // Inverted text: use lightest available color for max contrast on colored pills
861
+ const pillTextColor = isDark ? '#ffffff' : palette.text;
862
+ // Key — same x as normal rows
863
+ g.append('text')
864
+ .attr('x', x + 10)
865
+ .attr('y', rowY)
866
+ .attr('font-family', FONT_FAMILY)
867
+ .attr('font-size', META_FONT_SIZE)
868
+ .attr('font-weight', '600')
869
+ .attr('fill', pillTextColor)
870
+ .text(`${row.key}: `);
871
+ // Value — aligned with other rows
872
+ g.append('text')
873
+ .attr('x', valueX)
874
+ .attr('y', rowY)
875
+ .attr('font-family', FONT_FAMILY)
876
+ .attr('font-size', META_FONT_SIZE)
877
+ .attr('font-weight', '600')
878
+ .attr('fill', pillTextColor)
879
+ .text(row.value);
880
+ } else {
881
+ // Normal row: muted key + colored value
882
+ g.append('text')
883
+ .attr('x', x + 10)
884
+ .attr('y', rowY)
885
+ .attr('font-family', FONT_FAMILY)
886
+ .attr('font-size', META_FONT_SIZE)
887
+ .attr('fill', mutedColor)
888
+ .text(`${row.key}: `);
889
+
890
+ g.append('text')
891
+ .attr('x', valueX)
892
+ .attr('y', rowY)
893
+ .attr('font-family', FONT_FAMILY)
894
+ .attr('font-size', META_FONT_SIZE)
895
+ .attr('font-weight', row.fontWeight)
896
+ .attr('fill', row.valueFill)
897
+ .text(row.value);
898
+ }
899
+
900
+ rowY += META_LINE_HEIGHT;
901
+ rowIdx++;
902
+ }
903
+ }
904
+
905
+ // Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
906
+ // Serverless nodes show instances in a computed row instead (demand / concurrency).
907
+ if (!node.isEdge && node.computedConcurrentInvocations === 0 && node.computedInstances > 1) {
908
+ const badgeText = `${node.computedInstances}x`;
909
+ g.append('text')
910
+ .attr('x', x + node.width - 6)
911
+ .attr('y', y + NODE_HEADER_HEIGHT / 2 + META_FONT_SIZE * 0.35)
912
+ .attr('text-anchor', 'end')
913
+ .attr('font-family', FONT_FAMILY)
914
+ .attr('font-size', META_FONT_SIZE)
915
+ .attr('fill', mutedColor)
916
+ .attr('data-instance-node', node.id)
917
+ .style('cursor', 'pointer')
918
+ .text(badgeText);
919
+ }
920
+
921
+ // Role badge dots — only shown when Capabilities legend is expanded
922
+ const showDots = activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
923
+ const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
924
+ if (roles.length > 0) {
925
+ // Move dots up above the collapse bar for collapsed groups
926
+ const dotY = isCollapsedGroup
927
+ ? y + node.height - COLLAPSE_BAR_HEIGHT - NODE_PAD_BOTTOM / 2
928
+ : y + node.height - NODE_PAD_BOTTOM / 2;
929
+ const totalDotsWidth = roles.length * (ROLE_DOT_RADIUS * 2 + 2) - 2;
930
+ const startX = node.x - totalDotsWidth / 2 + ROLE_DOT_RADIUS;
931
+ for (let i = 0; i < roles.length; i++) {
932
+ g.append('circle')
933
+ .attr('cx', startX + i * (ROLE_DOT_RADIUS * 2 + 2))
934
+ .attr('cy', dotY)
935
+ .attr('r', ROLE_DOT_RADIUS)
936
+ .attr('fill', roles[i].color);
937
+ }
938
+ }
939
+
940
+ // Collapse bar at bottom of collapsed group nodes (accent stripe, clipped to card)
941
+ if (isCollapsedGroup) {
942
+ const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
943
+ g.append('clipPath').attr('id', clipId)
944
+ .append('rect')
945
+ .attr('x', x).attr('y', y)
946
+ .attr('width', node.width).attr('height', node.height)
947
+ .attr('rx', NODE_BORDER_RADIUS);
948
+ g.append('rect')
949
+ .attr('x', x + COLLAPSE_BAR_INSET)
950
+ .attr('y', y + node.height - COLLAPSE_BAR_HEIGHT)
951
+ .attr('width', node.width - COLLAPSE_BAR_INSET * 2)
952
+ .attr('height', COLLAPSE_BAR_HEIGHT)
953
+ .attr('fill', stroke)
954
+ .attr('clip-path', `url(#${clipId})`)
955
+ .attr('class', 'infra-collapse-bar');
956
+ }
957
+ }
958
+ }
959
+ }
960
+
961
+ // ============================================================
962
+ // Reject Particles — red fading drops for traffic-shedding nodes
963
+ // ============================================================
964
+
965
+ /** Get a numeric property from an InfraLayoutNode. */
966
+ function getNodeNumProp(node: InfraLayoutNode, key: string, fallback: number): number {
967
+ const prop = node.properties.find((p) => p.key === key);
968
+ if (!prop) return fallback;
969
+ if (typeof prop.value === 'number') return prop.value;
970
+ const num = parseFloat(String(prop.value));
971
+ return isNaN(num) ? fallback : num;
972
+ }
973
+
974
+ /**
975
+ * Compute rejected RPS for a node — traffic that arrives but is dropped/blocked.
976
+ * This includes: firewall-block, rate-limit excess, and overload excess.
977
+ * Cache-hit is NOT a reject — the request is served from cache.
978
+ */
979
+ function computeRejectedRps(node: InfraLayoutNode): number {
980
+ if (node.isEdge || node.computedRps <= 0) return 0;
981
+ let rejected = 0;
982
+ const inbound = node.computedRps;
983
+
984
+ // Firewall block: N% of inbound is rejected
985
+ const fwBlock = getNodeNumProp(node, 'firewall-block', 0);
986
+ if (fwBlock > 0) rejected += inbound * (fwBlock / 100);
987
+
988
+ // Rate limit excess (applied after cache/fw reductions)
989
+ const rateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
990
+ if (rateLimit > 0) {
991
+ let preRateLimitRps = inbound;
992
+ const cacheHit = getNodeNumProp(node, 'cache-hit', 0);
993
+ if (cacheHit > 0) preRateLimitRps *= (100 - cacheHit) / 100;
994
+ if (fwBlock > 0) preRateLimitRps *= (100 - fwBlock) / 100;
995
+ if (preRateLimitRps > rateLimit) {
996
+ rejected += preRateLimitRps - rateLimit;
997
+ }
998
+ }
999
+
1000
+ // Overload excess
1001
+ if (node.overloaded || node.childHealthState === 'overloaded') {
1002
+ const maxRps = getNodeNumProp(node, 'max-rps', 0);
1003
+ if (maxRps > 0) {
1004
+ const capacity = maxRps * node.computedInstances;
1005
+ if (inbound > capacity) {
1006
+ rejected += inbound - capacity;
1007
+ }
1008
+ }
1009
+ // For collapsed groups: childHealthState signals child overload even when
1010
+ // the virtual node's aggregated capacity math doesn't trigger (due to
1011
+ // group instances being baked into both max-rps and computedInstances).
1012
+ // Ensure we always produce a reject signal.
1013
+ if (rejected === 0) {
1014
+ rejected = inbound * 0.1;
1015
+ }
1016
+ }
1017
+
1018
+ return rejected;
1019
+ }
1020
+
1021
+ function renderRejectParticles(
1022
+ svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1023
+ nodes: InfraLayoutNode[],
1024
+ ) {
1025
+ // Compute max rejected RPS across all nodes for scaling
1026
+ const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
1027
+ for (const node of nodes) {
1028
+ const rejected = computeRejectedRps(node);
1029
+ if (rejected > 0) rejectMap.push({ node, rejected });
1030
+ }
1031
+ if (rejectMap.length === 0) return;
1032
+
1033
+ const maxRejected = Math.max(...rejectMap.map((r) => r.rejected));
1034
+
1035
+ for (const { node, rejected } of rejectMap) {
1036
+ const t = Math.min(rejected / maxRejected, 1);
1037
+ const count = Math.round(REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN));
1038
+ const dur = REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1039
+
1040
+ const nodeBottom = node.y + node.height / 2;
1041
+
1042
+ for (let i = 0; i < count; i++) {
1043
+ const delay = (dur / count) * i;
1044
+ // Spread particles horizontally around the node center
1045
+ const spread = node.width * 0.3;
1046
+ const startX = node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
1047
+ const startY = nodeBottom;
1048
+ const endY = nodeBottom + REJECT_DROP_DISTANCE;
1049
+
1050
+ const rejectColor = node.overloaded || node.childHealthState === 'overloaded'
1051
+ ? COLOR_OVERLOADED : COLOR_WARNING;
1052
+ const circle = svg.append('circle')
1053
+ .attr('r', REJECT_PARTICLE_R)
1054
+ .attr('fill', rejectColor)
1055
+ .attr('opacity', 0);
1056
+
1057
+ // Drop straight down from node bottom
1058
+ circle.append('animate')
1059
+ .attr('attributeName', 'cy')
1060
+ .attr('from', startY)
1061
+ .attr('to', endY)
1062
+ .attr('dur', `${dur}s`)
1063
+ .attr('repeatCount', 'indefinite')
1064
+ .attr('begin', `${delay}s`);
1065
+
1066
+ circle.append('animate')
1067
+ .attr('attributeName', 'cx')
1068
+ .attr('from', startX)
1069
+ .attr('to', startX)
1070
+ .attr('dur', `${dur}s`)
1071
+ .attr('repeatCount', 'indefinite')
1072
+ .attr('begin', `${delay}s`);
1073
+
1074
+ // Fade: appear quickly, then fade out
1075
+ circle.append('animate')
1076
+ .attr('attributeName', 'opacity')
1077
+ .attr('values', '0;0.8;0.6;0')
1078
+ .attr('keyTimes', '0;0.1;0.5;1')
1079
+ .attr('dur', `${dur}s`)
1080
+ .attr('repeatCount', 'indefinite')
1081
+ .attr('begin', `${delay}s`);
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ // ============================================================
1087
+ // Legend — pill/capsule groups (matching org chart style)
1088
+ // ============================================================
1089
+
1090
+ interface InfraLegendEntry {
1091
+ value: string;
1092
+ color: string;
1093
+ /** For role: kebab-case role name. For tag: lowercase tag value. */
1094
+ key: string;
1095
+ }
1096
+
1097
+ export interface InfraLegendGroup {
1098
+ name: string;
1099
+ type: 'role' | 'tag';
1100
+ /** For tag groups: the key used in data-tag-* attributes (alias or name). */
1101
+ tagKey?: string;
1102
+ entries: InfraLegendEntry[];
1103
+ width: number;
1104
+ minifiedWidth: number;
1105
+ }
1106
+
1107
+ /** Build legend groups from roles + tags. */
1108
+ export function computeInfraLegendGroups(
1109
+ nodes: InfraLayoutNode[],
1110
+ tagGroups: InfraTagGroup[],
1111
+ palette: PaletteColors,
1112
+ ): InfraLegendGroup[] {
1113
+ const groups: InfraLegendGroup[] = [];
1114
+
1115
+ // Capabilities group (from inferred roles)
1116
+ const roles = collectDiagramRoles(nodes.filter((n) => !n.isEdge).map((n) => n.properties));
1117
+ if (roles.length > 0) {
1118
+ const entries = roles.map((r) => ({
1119
+ value: r.name,
1120
+ color: r.color,
1121
+ key: r.name.toLowerCase().replace(/\s+/g, '-'),
1122
+ }));
1123
+ const pillWidth = 'Capabilities'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1124
+ let entriesWidth = 0;
1125
+ for (const e of entries) {
1126
+ entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1127
+ }
1128
+ groups.push({
1129
+ name: 'Capabilities',
1130
+ type: 'role',
1131
+ entries,
1132
+ width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
1133
+ minifiedWidth: pillWidth,
1134
+ });
1135
+ }
1136
+
1137
+ // Tag groups
1138
+ for (const tg of tagGroups) {
1139
+ const entries: InfraLegendEntry[] = [];
1140
+ for (const tv of tg.values) {
1141
+ if (tv.color) {
1142
+ entries.push({
1143
+ value: tv.name,
1144
+ color: resolveColor(tv.color, palette),
1145
+ key: tv.name.toLowerCase(),
1146
+ });
1147
+ }
1148
+ }
1149
+ if (entries.length === 0) continue;
1150
+ const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1151
+ let entriesWidth = 0;
1152
+ for (const e of entries) {
1153
+ entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1154
+ }
1155
+ groups.push({
1156
+ name: tg.name,
1157
+ type: 'tag',
1158
+ tagKey: (tg.alias ?? tg.name).toLowerCase(),
1159
+ entries,
1160
+ width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
1161
+ minifiedWidth: pillWidth,
1162
+ });
1163
+ }
1164
+
1165
+ return groups;
1166
+ }
1167
+
1168
+ /** Compute total width for the playback pill (speed only). */
1169
+ function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
1170
+ if (!playback) return 0;
1171
+ const pillWidth = 'Playback'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1172
+ if (!playback.expanded) return pillWidth;
1173
+
1174
+ let entriesW = 8; // gap after pill
1175
+ entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1176
+ for (const s of playback.speedOptions) {
1177
+ entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + 6;
1178
+ }
1179
+ return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1180
+ }
1181
+
1182
+ /** Whether a separate Scenario pill should render. */
1183
+ function renderLegend(
1184
+ rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1185
+ legendGroups: InfraLegendGroup[],
1186
+ totalWidth: number,
1187
+ legendY: number,
1188
+ palette: PaletteColors,
1189
+ isDark: boolean,
1190
+ activeGroup: string | null,
1191
+ playback?: InfraPlaybackState,
1192
+ ) {
1193
+ if (legendGroups.length === 0 && !playback) return;
1194
+
1195
+ const legendG = rootSvg.append('g')
1196
+ .attr('transform', `translate(0, ${legendY})`);
1197
+
1198
+ // Compute centered positions
1199
+ const effectiveW = (g: InfraLegendGroup) =>
1200
+ activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
1201
+ const playbackW = computePlaybackWidth(playback);
1202
+ const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
1203
+ const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
1204
+ + (legendGroups.length - 1) * LEGEND_GROUP_GAP
1205
+ + trailingGaps + playbackW;
1206
+ let cursorX = (totalWidth - totalLegendW) / 2;
1207
+
1208
+ for (const group of legendGroups) {
1209
+ const isActive = activeGroup != null && group.name.toLowerCase() === activeGroup.toLowerCase();
1210
+
1211
+ const groupBg = isDark
1212
+ ? mix(palette.bg, palette.text, 85)
1213
+ : mix(palette.bg, palette.text, 92);
1214
+
1215
+ const pillLabel = group.name;
1216
+ const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1217
+
1218
+ const gEl = legendG
1219
+ .append('g')
1220
+ .attr('transform', `translate(${cursorX}, 0)`)
1221
+ .attr('class', 'infra-legend-group')
1222
+ .attr('data-legend-group', group.name.toLowerCase())
1223
+ .attr('data-legend-type', group.type)
1224
+ .style('cursor', 'pointer');
1225
+
1226
+ // Outer capsule background (active only)
1227
+ if (isActive) {
1228
+ gEl.append('rect')
1229
+ .attr('width', group.width)
1230
+ .attr('height', LEGEND_HEIGHT)
1231
+ .attr('rx', LEGEND_HEIGHT / 2)
1232
+ .attr('fill', groupBg);
1233
+ }
1234
+
1235
+ const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1236
+ const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1237
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
1238
+
1239
+ // Pill background
1240
+ gEl.append('rect')
1241
+ .attr('x', pillXOff)
1242
+ .attr('y', pillYOff)
1243
+ .attr('width', pillWidth)
1244
+ .attr('height', pillH)
1245
+ .attr('rx', pillH / 2)
1246
+ .attr('fill', isActive ? palette.bg : groupBg);
1247
+
1248
+ // Active pill border
1249
+ if (isActive) {
1250
+ gEl.append('rect')
1251
+ .attr('x', pillXOff)
1252
+ .attr('y', pillYOff)
1253
+ .attr('width', pillWidth)
1254
+ .attr('height', pillH)
1255
+ .attr('rx', pillH / 2)
1256
+ .attr('fill', 'none')
1257
+ .attr('stroke', isDark ? mix(palette.textMuted, palette.bg, 50) : mix(palette.textMuted, palette.bg, 50))
1258
+ .attr('stroke-width', 0.75);
1259
+ }
1260
+
1261
+ // Pill text
1262
+ gEl.append('text')
1263
+ .attr('x', pillXOff + pillWidth / 2)
1264
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1265
+ .attr('font-family', FONT_FAMILY)
1266
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1267
+ .attr('font-weight', '500')
1268
+ .attr('fill', isActive ? palette.text : palette.textMuted)
1269
+ .attr('text-anchor', 'middle')
1270
+ .text(pillLabel);
1271
+
1272
+ // Entries inside capsule (active only)
1273
+ if (isActive) {
1274
+ let entryX = pillXOff + pillWidth + 4;
1275
+ for (const entry of group.entries) {
1276
+ const entryG = gEl
1277
+ .append('g')
1278
+ .attr('class', 'infra-legend-entry')
1279
+ .attr('data-legend-entry', entry.key)
1280
+ .attr('data-legend-type', group.type)
1281
+ .attr('data-legend-color', entry.color)
1282
+ .style('cursor', 'pointer');
1283
+
1284
+ if (group.type === 'tag' && group.tagKey) {
1285
+ entryG.attr('data-legend-tag-group', group.tagKey);
1286
+ }
1287
+
1288
+ entryG.append('circle')
1289
+ .attr('cx', entryX + LEGEND_DOT_R)
1290
+ .attr('cy', LEGEND_HEIGHT / 2)
1291
+ .attr('r', LEGEND_DOT_R)
1292
+ .attr('fill', entry.color);
1293
+
1294
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
1295
+ entryG.append('text')
1296
+ .attr('x', textX)
1297
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
1298
+ .attr('font-family', FONT_FAMILY)
1299
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1300
+ .attr('fill', palette.textMuted)
1301
+ .text(entry.value);
1302
+
1303
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1304
+ }
1305
+ }
1306
+
1307
+ cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
1308
+ }
1309
+
1310
+ // Playback pill — speed + pause only
1311
+ if (playback) {
1312
+ const isExpanded = playback.expanded;
1313
+ const groupBg = isDark
1314
+ ? mix(palette.bg, palette.text, 85)
1315
+ : mix(palette.bg, palette.text, 92);
1316
+
1317
+ const pillLabel = 'Playback';
1318
+ const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1319
+ const fullW = computePlaybackWidth(playback);
1320
+
1321
+ const pbG = legendG
1322
+ .append('g')
1323
+ .attr('transform', `translate(${cursorX}, 0)`)
1324
+ .attr('class', 'infra-legend-group infra-playback-pill')
1325
+ .style('cursor', 'pointer');
1326
+
1327
+ if (isExpanded) {
1328
+ pbG.append('rect')
1329
+ .attr('width', fullW)
1330
+ .attr('height', LEGEND_HEIGHT)
1331
+ .attr('rx', LEGEND_HEIGHT / 2)
1332
+ .attr('fill', groupBg);
1333
+ }
1334
+
1335
+ const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1336
+ const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1337
+ const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
1338
+
1339
+ pbG.append('rect')
1340
+ .attr('x', pillXOff).attr('y', pillYOff)
1341
+ .attr('width', pillWidth).attr('height', pillH)
1342
+ .attr('rx', pillH / 2)
1343
+ .attr('fill', isExpanded ? palette.bg : groupBg);
1344
+
1345
+ if (isExpanded) {
1346
+ pbG.append('rect')
1347
+ .attr('x', pillXOff).attr('y', pillYOff)
1348
+ .attr('width', pillWidth).attr('height', pillH)
1349
+ .attr('rx', pillH / 2)
1350
+ .attr('fill', 'none')
1351
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1352
+ .attr('stroke-width', 0.75);
1353
+ }
1354
+
1355
+ pbG.append('text')
1356
+ .attr('x', pillXOff + pillWidth / 2)
1357
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1358
+ .attr('font-family', FONT_FAMILY)
1359
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1360
+ .attr('font-weight', '500')
1361
+ .attr('fill', isExpanded ? palette.text : palette.textMuted)
1362
+ .attr('text-anchor', 'middle')
1363
+ .text(pillLabel);
1364
+
1365
+ if (isExpanded) {
1366
+ let entryX = pillXOff + pillWidth + 8;
1367
+ const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
1368
+
1369
+ const ppLabel = playback.paused ? '▶' : '⏸';
1370
+ pbG.append('text')
1371
+ .attr('x', entryX).attr('y', entryY)
1372
+ .attr('font-family', FONT_FAMILY)
1373
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1374
+ .attr('fill', palette.textMuted)
1375
+ .attr('data-playback-action', 'toggle-pause')
1376
+ .style('cursor', 'pointer')
1377
+ .text(ppLabel);
1378
+ entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
1379
+
1380
+ for (const s of playback.speedOptions) {
1381
+ const label = `${s}x`;
1382
+ const isActive = playback.speed === s;
1383
+ pbG.append('text')
1384
+ .attr('x', entryX).attr('y', entryY)
1385
+ .attr('font-family', FONT_FAMILY)
1386
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1387
+ .attr('font-weight', isActive ? '600' : '400')
1388
+ .attr('fill', isActive ? palette.primary : palette.textMuted)
1389
+ .attr('data-playback-action', 'set-speed')
1390
+ .attr('data-playback-value', String(s))
1391
+ .style('cursor', 'pointer')
1392
+ .text(label);
1393
+ entryX += label.length * LEGEND_ENTRY_FONT_W + 6;
1394
+ }
1395
+ }
1396
+
1397
+ cursorX += fullW + LEGEND_GROUP_GAP;
1398
+ }
1399
+
1400
+ }
1401
+
1402
+ // ============================================================
1403
+ // Main render
1404
+ // ============================================================
1405
+
1406
+ export interface InfraPlaybackState {
1407
+ expanded: boolean;
1408
+ paused: boolean;
1409
+ speed: number;
1410
+ speedOptions: readonly number[];
1411
+ }
1412
+
1413
+ export function renderInfra(
1414
+ container: HTMLDivElement,
1415
+ layout: InfraLayoutResult,
1416
+ palette: PaletteColors,
1417
+ isDark: boolean,
1418
+ title: string | null,
1419
+ titleLineNumber: number | null,
1420
+ tagGroups?: InfraTagGroup[],
1421
+ activeGroup?: string | null,
1422
+ animate?: boolean,
1423
+ playback?: InfraPlaybackState | null,
1424
+ selectedNodeId?: string | null,
1425
+ exportMode?: boolean,
1426
+ collapsedNodes?: Set<string> | null,
1427
+ ) {
1428
+ // Clear previous render (preserve tooltips if any)
1429
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1430
+
1431
+ // Build legend groups
1432
+ const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette);
1433
+ const hasLegend = legendGroups.length > 0 || !!playback;
1434
+ // In app mode (not export), legend is rendered as a separate fixed-size SVG
1435
+ const fixedLegend = !exportMode && hasLegend;
1436
+ const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
1437
+
1438
+ const titleOffset = title ? 40 : 0;
1439
+ const totalWidth = layout.width;
1440
+ const totalHeight = layout.height + titleOffset + legendOffset;
1441
+
1442
+ const shouldAnimate = animate !== false;
1443
+
1444
+ const rootSvg = d3Selection.select(container)
1445
+ .append('svg')
1446
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
1447
+ .attr('width', '100%')
1448
+ .attr('height', fixedLegend ? `calc(100% - ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}px)` : '100%')
1449
+ .attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
1450
+ .attr('preserveAspectRatio', 'xMidYMid meet');
1451
+
1452
+ // Inject animation keyframes + edge label hover styles
1453
+ if (shouldAnimate) {
1454
+ rootSvg.append('style').text(`
1455
+ @keyframes infra-pulse-warning {
1456
+ 0%, 100% { opacity: 1; }
1457
+ 50% { opacity: 0.7; }
1458
+ }
1459
+ @keyframes infra-pulse-overload {
1460
+ 0%, 100% { stroke-width: ${OVERLOAD_STROKE_WIDTH}; }
1461
+ 50% { stroke-width: ${OVERLOAD_STROKE_WIDTH + 2}; }
1462
+ }
1463
+ @keyframes infra-pulse-cb {
1464
+ 0%, 49% { stroke-dasharray: none; }
1465
+ 50%, 100% { stroke-dasharray: 6 4; }
1466
+ }
1467
+ .infra-node-warning > rect:first-of-type {
1468
+ animation: infra-pulse-warning ${NODE_PULSE_SPEED}s ease-in-out infinite;
1469
+ }
1470
+ .infra-node-overload > rect:first-of-type {
1471
+ animation: infra-pulse-overload ${NODE_PULSE_OVERLOAD}s ease-in-out infinite;
1472
+ }
1473
+ .infra-node-cb-open > rect:first-of-type {
1474
+ stroke-dasharray: 6 4;
1475
+ animation: infra-pulse-cb 1s step-end infinite;
1476
+ }
1477
+ @keyframes infra-edge-throb {
1478
+ 0%, 100% { stroke-width: 1.5; }
1479
+ 50% { stroke-width: 3; }
1480
+ }
1481
+ .infra-node-edge-throb > rect:first-of-type {
1482
+ animation: infra-edge-throb 2s ease-in-out infinite;
1483
+ cursor: pointer;
1484
+ }
1485
+ .infra-edge-label {
1486
+ opacity: 0;
1487
+ transition: opacity 0.15s ease;
1488
+ pointer-events: all;
1489
+ }
1490
+ .infra-edge-label:hover {
1491
+ opacity: 1;
1492
+ }
1493
+ `);
1494
+ }
1495
+
1496
+ const svg = rootSvg.append('g')
1497
+ .attr('transform', `translate(0, ${titleOffset})`);
1498
+
1499
+ // Title
1500
+ if (title) {
1501
+ rootSvg.append('text')
1502
+ .attr('class', 'chart-title')
1503
+ .attr('x', totalWidth / 2)
1504
+ .attr('y', 28)
1505
+ .attr('text-anchor', 'middle')
1506
+ .attr('font-family', FONT_FAMILY)
1507
+ .attr('font-size', 18)
1508
+ .attr('font-weight', '700')
1509
+ .attr('fill', palette.text)
1510
+ .attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
1511
+ .text(title);
1512
+ }
1513
+
1514
+ // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1515
+ renderGroups(svg, layout.groups, palette, isDark);
1516
+ renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
1517
+ renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options, collapsedNodes, tagGroups ?? []);
1518
+ if (shouldAnimate) {
1519
+ renderRejectParticles(svg, layout.nodes);
1520
+ }
1521
+ renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
1522
+
1523
+ // Legend at bottom
1524
+ if (hasLegend) {
1525
+ if (fixedLegend) {
1526
+ // Render legend in a separate SVG that stays at fixed pixel size
1527
+ const containerWidth = container.clientWidth || totalWidth;
1528
+ const legendSvg = d3Selection.select(container)
1529
+ .append('svg')
1530
+ .attr('class', 'infra-legend-fixed')
1531
+ .attr('width', '100%')
1532
+ .attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
1533
+ .attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
1534
+ .attr('preserveAspectRatio', 'xMidYMid meet')
1535
+ .style('display', 'block');
1536
+ renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
1537
+ } else {
1538
+ renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null, playback ?? undefined);
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ // ============================================================
1544
+ // Export pipeline (for CLI / d3.ts integration)
1545
+ // ============================================================
1546
+
1547
+ export function parseAndLayoutInfra(content: string) {
1548
+ const parsed = parseInfra(content);
1549
+ if (parsed.error) return { parsed, computed: null, layout: null };
1550
+ const computed = computeInfra(parsed);
1551
+ const layout = layoutInfra(computed);
1552
+ return { parsed, computed, layout };
1553
+ }