@diagrammo/dgmo 0.47.0 → 0.48.0

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 (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -1,2534 +0,0 @@
1
- // ============================================================
2
- // Infra Chart SVG Renderer
3
- // ============================================================
4
-
5
- import { tagAttrKey } from '../utils/tag-groups';
6
- import * as d3Selection from 'd3-selection';
7
- import * as d3Shape from 'd3-shape';
8
- import { FONT_FAMILY } from '../fonts';
9
- import type { PaletteColors } from '../palettes';
10
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
11
- import type { InfraTagGroup } from './types';
12
- import { resolveColor } from '../colors';
13
- import { renderInlineText } from '../utils/inline-markdown';
14
- import { preprocessDescriptionLine } from '../utils/description-helpers';
15
- import {
16
- measureText,
17
- truncateText,
18
- CHAR_WIDTH_RATIO,
19
- } from '../utils/text-measure';
20
- import type {
21
- InfraLayoutResult,
22
- InfraLayoutNode,
23
- InfraLayoutEdge,
24
- InfraLayoutGroup,
25
- } from './layout';
26
- import {
27
- inferRoles,
28
- collectDiagramRoles,
29
- collectFanoutSourceIds,
30
- FANOUT_ROLE,
31
- } from './roles';
32
- import { parseInfra } from './parser';
33
- import { computeInfra } from './compute';
34
- import { layoutInfra } from './layout';
35
- import {
36
- LEGEND_HEIGHT,
37
- LEGEND_PILL_PAD,
38
- LEGEND_PILL_FONT_SIZE,
39
- LEGEND_CAPSULE_PAD,
40
- LEGEND_DOT_R,
41
- LEGEND_ENTRY_FONT_SIZE,
42
- LEGEND_ENTRY_DOT_GAP,
43
- LEGEND_ENTRY_TRAIL,
44
- measureLegendText,
45
- } from '../utils/legend-constants';
46
- import { renderIntegratedLegend } from '../utils/legend-integration';
47
- import { getMaxLegendReservedHeight } from '../utils/legend-layout';
48
- import {
49
- TITLE_FONT_SIZE,
50
- TITLE_FONT_WEIGHT,
51
- TITLE_Y,
52
- TITLE_OFFSET,
53
- } from '../utils/title-constants';
54
- import { ScaleContext } from '../utils/scaling';
55
-
56
- // ============================================================
57
- // Constants
58
- // ============================================================
59
-
60
- const NODE_FONT_SIZE = 13;
61
- // Intentional deviation (conventions §1): infra uses denser meta rows
62
- // (10px font / 14px line height) than the 11/16 default.
63
- const META_FONT_SIZE = 10;
64
- const META_LINE_HEIGHT = 14;
65
- const EDGE_LABEL_FONT_SIZE = 11;
66
- const GROUP_LABEL_FONT_SIZE = 14;
67
- const NODE_BORDER_RADIUS = 8;
68
- import {
69
- EDGE_STROKE_WIDTH,
70
- NODE_STROKE_WIDTH,
71
- COLLAPSE_BAR_HEIGHT,
72
- COLLAPSE_BAR_INSET,
73
- } from '../utils/visual-conventions'; // shared (Story 111.1)
74
- const OVERLOAD_STROKE_WIDTH = 3;
75
- const ROLE_DOT_RADIUS = 3;
76
- const NODE_HEADER_HEIGHT = 28;
77
- const NODE_SEPARATOR_GAP = 4;
78
- const NODE_PAD_BOTTOM = 10;
79
-
80
- const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
81
- const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
82
- const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
83
- const SPEED_BADGE_GAP = 6; // gap between speed option slots
84
-
85
- interface ScaledInfraConstants {
86
- sNodeFontSize: number;
87
- sMetaFontSize: number;
88
- sMetaLineHeight: number;
89
- sEdgeLabelFontSize: number;
90
- sGroupLabelFontSize: number;
91
- sNodeBorderRadius: number;
92
- sEdgeStrokeWidth: number;
93
- sNodeStrokeWidth: number;
94
- sOverloadStrokeWidth: number;
95
- sRoleDotRadius: number;
96
- sNodeSeparatorGap: number;
97
- sNodePadBottom: number;
98
- sCollapseBarHeight: number;
99
- sCollapseBarInset: number;
100
- sParticleR: number;
101
- sRejectParticleR: number;
102
- sRejectDropDistance: number;
103
- sTitleFontSize: number;
104
- sTitleY: number;
105
- sLegendFixedGap: number;
106
- }
107
-
108
- function buildScaledConstants(ctx: ScaleContext): ScaledInfraConstants {
109
- return {
110
- sNodeFontSize: ctx.text(NODE_FONT_SIZE),
111
- sMetaFontSize: ctx.text(META_FONT_SIZE),
112
- sMetaLineHeight: ctx.structural(META_LINE_HEIGHT),
113
- sEdgeLabelFontSize: ctx.text(EDGE_LABEL_FONT_SIZE),
114
- sGroupLabelFontSize: ctx.text(GROUP_LABEL_FONT_SIZE),
115
- sNodeBorderRadius: ctx.structural(NODE_BORDER_RADIUS),
116
- sEdgeStrokeWidth: ctx.structural(EDGE_STROKE_WIDTH),
117
- sNodeStrokeWidth: ctx.structural(NODE_STROKE_WIDTH),
118
- sOverloadStrokeWidth: ctx.structural(OVERLOAD_STROKE_WIDTH),
119
- sRoleDotRadius: ctx.structural(ROLE_DOT_RADIUS),
120
- sNodeSeparatorGap: ctx.structural(NODE_SEPARATOR_GAP),
121
- sNodePadBottom: ctx.structural(NODE_PAD_BOTTOM),
122
- sCollapseBarHeight: ctx.structural(COLLAPSE_BAR_HEIGHT),
123
- sCollapseBarInset: ctx.structural(COLLAPSE_BAR_INSET),
124
- sParticleR: ctx.structural(PARTICLE_R),
125
- sRejectParticleR: ctx.structural(PARTICLE_R),
126
- sRejectDropDistance: ctx.structural(REJECT_DROP_DISTANCE),
127
- sTitleFontSize: ctx.text(TITLE_FONT_SIZE),
128
- sTitleY: ctx.structural(TITLE_Y),
129
- sLegendFixedGap: ctx.aesthetic(LEGEND_FIXED_GAP),
130
- };
131
- }
132
-
133
- // Health colors (from UX spec)
134
- const COLOR_HEALTHY = '#22c55e';
135
- const COLOR_WARNING = '#eab308';
136
- const COLOR_OVERLOADED = '#ef4444';
137
- /** SLO thresholds resolved for a single node (chart-level + per-node override). */
138
- interface NodeSlo {
139
- availThreshold: number | null; // fraction e.g. 0.999
140
- latencyP90: number | null; // ms e.g. 200
141
- warningMargin: number; // fraction e.g. 0.05
142
- }
143
-
144
- /** Resolve effective SLO for a node: per-node properties take precedence over chart-level options.
145
- * Returns null if neither availThreshold nor latencyP90 is declared. */
146
- function resolveNodeSlo(
147
- node: InfraLayoutNode,
148
- diagramOptions: Record<string, string>
149
- ): NodeSlo | null {
150
- const nodeProp = (key: string) => node.properties.find((p) => p.key === key);
151
-
152
- const availRaw =
153
- nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
154
- const latencyRaw =
155
- nodeProp('slo-p90-latency-ms')?.value ??
156
- diagramOptions['slo-p90-latency-ms'];
157
- const marginRaw =
158
- nodeProp('slo-warning-margin')?.value ??
159
- diagramOptions['slo-warning-margin'];
160
-
161
- const availParsed =
162
- availRaw != null
163
- ? parseFloat(String(availRaw).replace('%', '')) / 100
164
- : NaN;
165
- const availThreshold = !isNaN(availParsed) ? availParsed : null;
166
- const latencyParsed =
167
- latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
168
- const latencyP90 = !isNaN(latencyParsed) ? latencyParsed : null;
169
- const marginParsed =
170
- marginRaw != null
171
- ? parseFloat(String(marginRaw).replace('%', '')) / 100
172
- : NaN;
173
- const warningMargin = !isNaN(marginParsed) ? marginParsed : 0.05;
174
-
175
- if (availThreshold == null && latencyP90 == null) return null;
176
- return { availThreshold, latencyP90, warningMargin };
177
- }
178
-
179
- /** A row in the node card body. `inverted` renders as a colored pill with light text. */
180
- interface NodeRow {
181
- key: string;
182
- value: string;
183
- valueFill: string;
184
- fontWeight: string;
185
- inverted?: boolean;
186
- invertedBg?: string;
187
- }
188
-
189
- /** Computed metric row (latency percentiles, availability, CB state). */
190
- interface ComputedRow {
191
- key: string;
192
- value: string;
193
- color?: string;
194
- inverted?: boolean;
195
- }
196
-
197
- // Animation constants
198
- const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
199
- const FLOW_SPEED_MAX = 6; // seconds at min RPS
200
- const PARTICLE_R = 5; // particle circle radius
201
- const PARTICLE_COUNT_MIN = 1; // min particles per edge
202
- const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
203
- const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
204
- const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
205
-
206
- // Reject particle constants
207
- const REJECT_DROP_DISTANCE = 30; // px downward travel
208
- const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
209
- const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
210
- const REJECT_COUNT_MIN = 1;
211
- const REJECT_COUNT_MAX = 3;
212
-
213
- // ============================================================
214
- // Edge path generator
215
- // ============================================================
216
-
217
- type Pt = { x: number; y: number };
218
-
219
- /** Produce an SVG path string for a sequence of waypoints.
220
- * 2-point paths use curveBumpX/Y (nice S-curve).
221
- * Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
222
- function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
223
- const gen = d3Shape
224
- .line<Pt>()
225
- .x((d) => d.x)
226
- .y((d) => d.y);
227
- if (pts.length <= 2) {
228
- gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
229
- } else {
230
- gen.curve(d3Shape.curveCatmullRom.alpha(0.5));
231
- }
232
- return gen(pts) ?? '';
233
- }
234
-
235
- type Rect = { x: number; y: number; width: number; height: number };
236
-
237
- /** Port-order exit and enter points for edges to eliminate fan-out crossings.
238
- *
239
- * For each node with ≥2 outgoing edges: sort edges by target perpendicular coord
240
- * and assign source-border exit positions in the same order.
241
- * For each node with ≥2 incoming edges: sort edges by source perpendicular coord
242
- * and assign target-border enter positions in the same order.
243
- *
244
- * Returns two maps keyed by "sourceId:targetId":
245
- * - srcPts: port-ordered exit point on the source border
246
- * - tgtPts: port-ordered enter point on the target border */
247
- function computePortPts(
248
- edges: readonly InfraLayoutEdge[],
249
- nodeMap: Map<string, InfraLayoutNode>,
250
- direction: 'LR' | 'TB'
251
- ): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
252
- const srcPts = new Map<string, Pt>();
253
- const tgtPts = new Map<string, Pt>();
254
- const PAD = 0.1; // keep ports 10% in from node edges
255
-
256
- const activeEdges = edges.filter((e) => e.points.length > 0);
257
-
258
- // ── Source (exit) port ordering ──────────────────────────────────────────
259
- const bySource = new Map<string, InfraLayoutEdge[]>();
260
- for (const e of activeEdges) {
261
- if (!bySource.has(e.sourceId)) bySource.set(e.sourceId, []);
262
- bySource.get(e.sourceId)!.push(e);
263
- }
264
- for (const [sourceId, es] of bySource) {
265
- if (es.length < 2) continue;
266
- const source = nodeMap.get(sourceId);
267
- if (!source) continue;
268
- const sorted = es
269
- .map((e) => ({ e, t: nodeMap.get(e.targetId) }))
270
- .filter(
271
- (x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null
272
- )
273
- .sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
274
- const n = sorted.length;
275
- for (let i = 0; i < n; i++) {
276
- const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
277
- // In-bounds by loop guard.
278
- const { e, t } = sorted[i]!;
279
- const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
280
- if (direction === 'LR') {
281
- srcPts.set(`${e.sourceId}:${e.targetId}`, {
282
- x: isBackward
283
- ? source.x - source.width / 2
284
- : source.x + source.width / 2,
285
- y: source.y - source.height / 2 + frac * source.height,
286
- });
287
- } else {
288
- srcPts.set(`${e.sourceId}:${e.targetId}`, {
289
- x: source.x - source.width / 2 + frac * source.width,
290
- y: isBackward
291
- ? source.y - source.height / 2
292
- : source.y + source.height / 2,
293
- });
294
- }
295
- }
296
- }
297
-
298
- // ── Target (enter) port ordering ─────────────────────────────────────────
299
- const byTarget = new Map<string, InfraLayoutEdge[]>();
300
- for (const e of activeEdges) {
301
- if (!byTarget.has(e.targetId)) byTarget.set(e.targetId, []);
302
- byTarget.get(e.targetId)!.push(e);
303
- }
304
- for (const [targetId, es] of byTarget) {
305
- if (es.length < 2) continue;
306
- const target = nodeMap.get(targetId);
307
- if (!target) continue;
308
- const sorted = es
309
- .map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
310
- .filter(
311
- (x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null
312
- )
313
- .sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
314
- const n = sorted.length;
315
- for (let i = 0; i < n; i++) {
316
- const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
317
- // In-bounds by loop guard.
318
- const { e, s } = sorted[i]!;
319
- const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
320
- if (direction === 'LR') {
321
- tgtPts.set(`${e.sourceId}:${e.targetId}`, {
322
- x: isBackward
323
- ? target.x + target.width / 2
324
- : target.x - target.width / 2,
325
- y: target.y - target.height / 2 + frac * target.height,
326
- });
327
- } else {
328
- tgtPts.set(`${e.sourceId}:${e.targetId}`, {
329
- x: target.x - target.width / 2 + frac * target.width,
330
- y: isBackward
331
- ? target.y + target.height / 2
332
- : target.y - target.height / 2,
333
- });
334
- }
335
- }
336
- }
337
-
338
- return { srcPts, tgtPts };
339
- }
340
-
341
- /** Find the Y lane for routing around blocking obstacles.
342
- * Prefers threading through Y gaps between blocking rects over routing above/below all.
343
- * Picks the candidate closest to targetY to minimise arc size.
344
- *
345
- * Uses a tight merge expansion (MERGE_SLOP) so narrow inter-group gaps are preserved
346
- * and can be threaded, rather than being swallowed into one large merged interval. */
347
- function findRoutingLane(
348
- blocking: Rect[],
349
- targetY: number,
350
- margin: number
351
- ): number {
352
- // Use a small slop for merging so closely-spaced (but distinct) groups
353
- // stay as separate intervals and the gap between them can be threaded.
354
- const MERGE_SLOP = 4;
355
- const sorted = [...blocking].sort(
356
- (a, b) => a.y + a.height / 2 - (b.y + b.height / 2)
357
- );
358
- const merged: [number, number][] = [];
359
- for (const r of sorted) {
360
- const lo = r.y - MERGE_SLOP;
361
- const hi = r.y + r.height + MERGE_SLOP;
362
- // In-bounds: merged.length > 0 guarded on this branch.
363
- if (merged.length && lo <= merged[merged.length - 1]![1]) {
364
- merged[merged.length - 1]![1] = Math.max(
365
- merged[merged.length - 1]![1],
366
- hi
367
- );
368
- } else {
369
- merged.push([lo, hi]);
370
- }
371
- }
372
- if (merged.length === 0) return targetY;
373
-
374
- // Candidate lanes: above all, below all, mid-points of gaps wide enough to thread.
375
- // MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
376
- const MIN_GAP = 10;
377
- const candidates: number[] = [
378
- // In-bounds: merged.length > 0 guarded above.
379
- merged[0]![0] - margin, // above all blocking rects
380
- merged[merged.length - 1]![1] + margin, // below all blocking rects
381
- ];
382
- for (let i = 0; i < merged.length - 1; i++) {
383
- // In-bounds by loop guard.
384
- const gapLo = merged[i]![1];
385
- const gapHi = merged[i + 1]![0];
386
- if (gapHi - gapLo >= MIN_GAP) {
387
- candidates.push((gapLo + gapHi) / 2); // thread through the gap
388
- }
389
- }
390
-
391
- // Return the candidate closest to targetY (tightest arc)
392
- return candidates.reduce(
393
- // candidates[0] is a non-empty array (two seed values pushed above).
394
- (best, c) => (Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best),
395
- candidates[0]!
396
- );
397
- }
398
-
399
- /** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
400
- function segmentIntersectsRect(
401
- p1: Pt,
402
- p2: Pt,
403
- rect: { x: number; y: number; width: number; height: number }
404
- ): boolean {
405
- const { x: rx, y: ry, width: rw, height: rh } = rect;
406
- const rr = rx + rw;
407
- const rb = ry + rh;
408
- // Point-in-rect check
409
- const inRect = (p: Pt) => p.x >= rx && p.x <= rr && p.y >= ry && p.y <= rb;
410
- if (inRect(p1) || inRect(p2)) return true;
411
- // Segment bounding box vs rect
412
- if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
413
- if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
414
- // Cross product sign helper (z-component of cross product)
415
- const cross = (o: Pt, a: Pt, b: Pt) =>
416
- (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
417
- // Does segment p1p2 cross segment a→b?
418
- const crosses = (a: Pt, b: Pt) => {
419
- const d1 = cross(a, b, p1);
420
- const d2 = cross(a, b, p2);
421
- const d3 = cross(p1, p2, a);
422
- const d4 = cross(p1, p2, b);
423
- return (
424
- ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
425
- ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
426
- );
427
- };
428
- const tl: Pt = { x: rx, y: ry };
429
- const tr: Pt = { x: rr, y: ry };
430
- const br: Pt = { x: rr, y: rb };
431
- const bl: Pt = { x: rx, y: rb };
432
- return (
433
- crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl)
434
- );
435
- }
436
-
437
- /** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
438
- *
439
- * curveBumpX (LR) stays near sc.y for the left half of the edge, transitions
440
- * vertically at the midpoint, then stays near tc.y for the right half.
441
- * Approximated as three Manhattan segments:
442
- * sc → (midX, sc.y) → (midX, tc.y) → tc
443
- *
444
- * curveBumpY (TB) mirrors this on the other axis. */
445
- function curveIntersectsRect(
446
- sc: Pt,
447
- tc: Pt,
448
- rect: Rect,
449
- direction: 'LR' | 'TB'
450
- ): boolean {
451
- if (direction === 'LR') {
452
- const midX = (sc.x + tc.x) / 2;
453
- const m1: Pt = { x: midX, y: sc.y };
454
- const m2: Pt = { x: midX, y: tc.y };
455
- return (
456
- segmentIntersectsRect(sc, m1, rect) ||
457
- segmentIntersectsRect(m1, m2, rect) ||
458
- segmentIntersectsRect(m2, tc, rect)
459
- );
460
- } else {
461
- const midY = (sc.y + tc.y) / 2;
462
- const m1: Pt = { x: sc.x, y: midY };
463
- const m2: Pt = { x: tc.x, y: midY };
464
- return (
465
- segmentIntersectsRect(sc, m1, rect) ||
466
- segmentIntersectsRect(m1, m2, rect) ||
467
- segmentIntersectsRect(m2, tc, rect)
468
- );
469
- }
470
- }
471
-
472
- /** Compute waypoints for an edge.
473
- *
474
- * - Backward edges (going against the primary layout direction) arc above/left
475
- * of all layout content to avoid crossing everything.
476
- * - Forward edges that cross a group bounding box or an unrelated node arc
477
- * above or below the collective blocking region (gap-aware Y routing).
478
- * - Clear forward edges use a direct 2-point path (curveBumpX/Y S-curve). */
479
- function edgeWaypoints(
480
- source: InfraLayoutNode,
481
- target: InfraLayoutNode,
482
- groups: readonly InfraLayoutGroup[],
483
- nodes: readonly InfraLayoutNode[],
484
- direction: 'LR' | 'TB',
485
- margin = 30,
486
- srcExitPt?: Pt, // port-ordered exit point on source border
487
- tgtEnterPt?: Pt // port-ordered enter point on target border
488
- ): Pt[] {
489
- const sc: Pt = { x: source.x, y: source.y };
490
- const tc: Pt = { x: target.x, y: target.y };
491
-
492
- // ── Backward edge handling ───────────────────────────────────────────────
493
- // curveBumpX/Y on a backward edge creates a loop. Route around obstacles
494
- // in the backward path's axis band for a tight arc (not global min/max).
495
- const isBackward = direction === 'LR' ? tc.x < sc.x : tc.y < sc.y;
496
- if (isBackward) {
497
- if (direction === 'LR') {
498
- // Collect group/node rects that overlap the X band [tc.x, sc.x]
499
- const xBandObs: Rect[] = [];
500
- for (const g of groups) {
501
- if (g.x + g.width < tc.x - margin || g.x > sc.x + margin) continue;
502
- xBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
503
- }
504
- for (const n of nodes) {
505
- if (n.id === source.id || n.id === target.id) continue;
506
- const nLeft = n.x - n.width / 2;
507
- const nRight = n.x + n.width / 2;
508
- if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
509
- xBandObs.push({
510
- x: nLeft,
511
- y: n.y - n.height / 2,
512
- width: n.width,
513
- height: n.height,
514
- });
515
- }
516
- const midY = (sc.y + tc.y) / 2;
517
- const routeY =
518
- xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
519
- const exitBorder: Pt =
520
- srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
521
- const exitPt: Pt = { x: exitBorder.x, y: routeY };
522
- const enterPt: Pt = { x: tc.x, y: routeY };
523
- const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
524
- return srcExitPt
525
- ? [srcExitPt, exitPt, enterPt, tp]
526
- : [exitBorder, exitPt, enterPt, tp];
527
- } else {
528
- // TB backward: collect obstacles in Y band [tc.y, sc.y]; find X lane
529
- const yBandObs: Rect[] = [];
530
- for (const g of groups) {
531
- if (g.y + g.height < tc.y - margin || g.y > sc.y + margin) continue;
532
- yBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
533
- }
534
- for (const n of nodes) {
535
- if (n.id === source.id || n.id === target.id) continue;
536
- const nTop = n.y - n.height / 2;
537
- const nBot = n.y + n.height / 2;
538
- if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
539
- yBandObs.push({
540
- x: n.x - n.width / 2,
541
- y: nTop,
542
- width: n.width,
543
- height: n.height,
544
- });
545
- }
546
- // Rotate axes so findRoutingLane (which works in Y) resolves an X lane
547
- const rotated = yBandObs.map((r) => ({
548
- x: r.y,
549
- y: r.x,
550
- width: r.height,
551
- height: r.width,
552
- }));
553
- const midX = (sc.x + tc.x) / 2;
554
- const routeX =
555
- rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
556
- const exitPt: Pt = srcExitPt ?? { x: routeX, y: sc.y };
557
- const enterPt: Pt = { x: routeX, y: tc.y };
558
- return [
559
- srcExitPt ?? nodeBorderPoint(source, exitPt),
560
- exitPt,
561
- enterPt,
562
- tgtEnterPt ?? nodeBorderPoint(target, enterPt),
563
- ];
564
- }
565
- }
566
-
567
- // ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
568
- const blocking: { x: number; y: number; width: number; height: number }[] =
569
- [];
570
- const blockingGroupIds = new Set<string>();
571
-
572
- // Use actual path endpoints (port-ordered) for more accurate blocking detection.
573
- // Node centers underestimate midX/midY, causing nearby obstacles to go undetected.
574
- const pathSrc: Pt = srcExitPt ?? sc;
575
- const pathTgt: Pt = tgtEnterPt ?? tc;
576
-
577
- // Groups (excluding source/target groups)
578
- for (const g of groups) {
579
- if (g.id === source.groupId || g.id === target.groupId) continue;
580
- const gRect: Rect = { x: g.x, y: g.y, width: g.width, height: g.height };
581
- if (curveIntersectsRect(pathSrc, pathTgt, gRect, direction)) {
582
- blocking.push(gRect);
583
- blockingGroupIds.add(g.id);
584
- }
585
- }
586
-
587
- // Individual nodes not already covered by a blocking group rect
588
- for (const n of nodes) {
589
- if (n.id === source.id || n.id === target.id) continue;
590
- // Skip nodes in source/target groups (routing around the group handles them)
591
- if (
592
- n.groupId &&
593
- (n.groupId === source.groupId || n.groupId === target.groupId)
594
- )
595
- continue;
596
- // Skip nodes inside a group whose bounding box is already blocking
597
- if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
598
- const nodeRect: Rect = {
599
- x: n.x - n.width / 2,
600
- y: n.y - n.height / 2,
601
- width: n.width,
602
- height: n.height,
603
- };
604
- if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
605
- blocking.push(nodeRect);
606
- }
607
- }
608
-
609
- if (blocking.length === 0) {
610
- // Direct 2-point path — use port-ordered entry/exit points when provided.
611
- const sp = srcExitPt ?? nodeBorderPoint(source, tc);
612
- const tp = tgtEnterPt ?? nodeBorderPoint(target, sp);
613
- return [sp, tp];
614
- }
615
-
616
- const obsLeft = Math.min(...blocking.map((o) => o.x));
617
- const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
618
-
619
- const routeY = findRoutingLane(blocking, tc.y, margin);
620
-
621
- // Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
622
- // direction when an obstacle's bounding box extends past source or target.
623
- const exitX =
624
- direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
625
- const enterX =
626
- direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
627
- const exitPt: Pt = { x: exitX, y: routeY };
628
- const enterPt: Pt = { x: enterX, y: routeY };
629
-
630
- const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
631
-
632
- if (srcExitPt) {
633
- return [srcExitPt, exitPt, enterPt, tp];
634
- }
635
-
636
- return [nodeBorderPoint(source, exitPt), exitPt, enterPt, tp];
637
- }
638
-
639
- /** Compute the point on a node's border closest to an external target point. */
640
- function nodeBorderPoint(
641
- node: InfraLayoutNode,
642
- target: { x: number; y: number }
643
- ): { x: number; y: number } {
644
- const hw = node.width / 2;
645
- const hh = node.height / 2;
646
- const dx = target.x - node.x;
647
- const dy = target.y - node.y;
648
- if (dx === 0 && dy === 0) return { x: node.x + hw, y: node.y };
649
-
650
- // Scale factor to reach the bounding box edge
651
- const sx = hw / Math.abs(dx || 1);
652
- const sy = hh / Math.abs(dy || 1);
653
- const s = Math.min(sx, sy);
654
-
655
- return { x: node.x + dx * s, y: node.y + dy * s };
656
- }
657
-
658
- /** Map RPS to animation duration — higher RPS = faster (shorter duration). */
659
- function flowDuration(rps: number, maxRps: number): number {
660
- if (maxRps <= 0) return FLOW_SPEED_MAX;
661
- const t = Math.min(rps / maxRps, 1);
662
- return FLOW_SPEED_MAX - t * (FLOW_SPEED_MAX - FLOW_SPEED_MIN);
663
- }
664
-
665
- /** Map RPS to particle count — more traffic = more particles. */
666
- function particleCount(rps: number, maxRps: number): number {
667
- if (maxRps <= 0) return PARTICLE_COUNT_MIN;
668
- const t = Math.min(rps / maxRps, 1);
669
- return Math.round(
670
- PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN)
671
- );
672
- }
673
-
674
- /** Determine if a node is in warning state (>70% capacity but not overloaded). */
675
- function isWarning(node: InfraLayoutNode): boolean {
676
- if (node.overloaded || node.isEdge) return false;
677
- // Serverless: concurrency-based capacity
678
- const concurrencyProp = node.properties.find((p) => p.key === 'concurrency');
679
- if (concurrencyProp) {
680
- const concurrency = Number(concurrencyProp.value);
681
- const durationProp = node.properties.find((p) => p.key === 'duration-ms');
682
- const durationMs = durationProp ? Number(durationProp.value) : 100;
683
- const cap = concurrency / (durationMs / 1000);
684
- return cap > 0 && node.computedRps / cap > 0.7;
685
- }
686
- // Traditional: max-rps * instances
687
- const maxRps = node.properties.find((p) => p.key === 'max-rps');
688
- if (!maxRps) return false;
689
- const cap = Number(maxRps.value) * node.computedInstances;
690
- return cap > 0 && node.computedRps / cap > 0.7;
691
- }
692
-
693
- // ============================================================
694
- // Helpers
695
- // ============================================================
696
-
697
- /** Display names for behavior property keys. Aligned with DSL property names. */
698
- const PROP_DISPLAY: Record<string, string> = {
699
- 'cache-hit': 'cache hit',
700
- 'firewall-block': 'firewall block',
701
- 'ratelimit-rps': 'rate limit RPS',
702
- 'latency-ms': 'latency',
703
- uptime: 'uptime',
704
- instances: 'instances',
705
- 'max-rps': 'max RPS',
706
- 'cb-error-threshold': 'CB error threshold',
707
- 'cb-latency-threshold-ms': 'CB latency threshold',
708
- concurrency: 'concurrency',
709
- 'duration-ms': 'duration',
710
- 'cold-start-ms': 'cold start',
711
- buffer: 'buffer',
712
- 'drain-rate': 'drain rate',
713
- 'retention-hours': 'retention',
714
- partitions: 'partitions',
715
- };
716
-
717
- const DESC_MAX_CHARS = 120;
718
- /** Pixel-width cap for description lines, derived from the legacy char cap at META_FONT_SIZE. */
719
- const DESC_MAX_WIDTH = DESC_MAX_CHARS * CHAR_WIDTH_RATIO * META_FONT_SIZE;
720
-
721
- /** Truncate description text to fit DESC_MAX_WIDTH at META_FONT_SIZE. */
722
- function truncateDesc(text: string): string {
723
- return truncateText(text, META_FONT_SIZE, DESC_MAX_WIDTH);
724
- }
725
-
726
- /** Keys whose values are RPS counts and should be formatted like RPS. */
727
- const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
728
-
729
- /** Keys whose values are milliseconds and should show the "ms" suffix. */
730
- const MS_FORMAT_KEYS = new Set([
731
- 'latency-ms',
732
- 'cb-latency-threshold-ms',
733
- 'duration-ms',
734
- 'cold-start-ms',
735
- ]);
736
-
737
- /** Keys whose values are percentages and should show the "%" suffix. */
738
- const PCT_FORMAT_KEYS = new Set([
739
- 'cache-hit',
740
- 'firewall-block',
741
- 'uptime',
742
- 'cb-error-threshold',
743
- ]);
744
-
745
- /** Compute SLO color for a p90 latency value against the configured threshold.
746
- * Callers must guard slo.latencyP90 != null before calling. */
747
- function sloLatencyColor(p90: number, slo: NodeSlo): string {
748
- const t = slo.latencyP90 ?? 0;
749
- if (t === 0) return COLOR_HEALTHY; // no meaningful threshold — treat as healthy
750
- const m = slo.warningMargin;
751
- return p90 > t
752
- ? COLOR_OVERLOADED
753
- : p90 > t * (1 - m)
754
- ? COLOR_WARNING
755
- : COLOR_HEALTHY;
756
- }
757
-
758
- /** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
759
- function getComputedRows(
760
- node: InfraLayoutNode,
761
- expanded: boolean,
762
- slo?: NodeSlo | null
763
- ): ComputedRow[] {
764
- const rows: ComputedRow[] = [];
765
-
766
- // Serverless instances: demand vs concurrency limit
767
- if (node.computedConcurrentInvocations > 0) {
768
- const concurrency = getNodeNumProp(node, 'concurrency', 0);
769
- const demand = node.computedConcurrentInvocations;
770
- const ratio = concurrency > 0 ? demand / concurrency : 0;
771
- const color =
772
- ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
773
- const value =
774
- concurrency > 0
775
- ? `${formatCount(demand)} / ${formatCount(concurrency)}`
776
- : `${formatCount(demand)}`;
777
- rows.push({
778
- key: 'instances',
779
- value,
780
- ...(color !== undefined && { color }),
781
- inverted: color != null,
782
- });
783
- }
784
-
785
- const p = node.computedLatencyPercentiles;
786
- if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) {
787
- if (expanded) {
788
- rows.push({ key: 'p50', value: formatMsShort(p.p50) });
789
- if (slo?.latencyP90 != null) {
790
- const color = sloLatencyColor(p.p90, slo);
791
- const p90Value =
792
- color !== COLOR_HEALTHY
793
- ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
794
- : formatMsShort(p.p90);
795
- rows.push({
796
- key: 'p90',
797
- value: p90Value,
798
- color,
799
- inverted: color !== COLOR_HEALTHY,
800
- });
801
- } else {
802
- rows.push({ key: 'p90', value: formatMsShort(p.p90) });
803
- }
804
- rows.push({ key: 'p99', value: formatMsShort(p.p99) });
805
- } else if (p.p90 > 0) {
806
- // Collapsed: show p90 (with SLO color if configured) instead of p99
807
- if (slo?.latencyP90 != null) {
808
- const color = sloLatencyColor(p.p90, slo);
809
- const p90Value =
810
- color !== COLOR_HEALTHY
811
- ? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
812
- : formatMsShort(p.p90);
813
- rows.push({
814
- key: 'p90',
815
- value: p90Value,
816
- color,
817
- inverted: color !== COLOR_HEALTHY,
818
- });
819
- } else {
820
- rows.push({ key: 'p90', value: formatMsShort(p.p90) });
821
- }
822
- }
823
- }
824
- // Computed (cumulative) uptime — only show when it differs from the declared node uptime.
825
- // On the edge node this is the system-wide uptime; on other nodes it's the path product.
826
- // Label as "eff. uptime" to distinguish from the declared "uptime" property row.
827
- if (node.computedUptime < 1) {
828
- const declaredUptime = node.properties.find((p) => p.key === 'uptime');
829
- const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
830
- const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
831
- if (differs || node.isEdge) {
832
- rows.push({
833
- key: 'eff. uptime',
834
- value: formatUptimeShort(node.computedUptime),
835
- });
836
- }
837
- }
838
- if (node.computedAvailability < 1) {
839
- let color: string | undefined;
840
- if (slo?.availThreshold != null) {
841
- const t = slo.availThreshold;
842
- const m = slo.warningMargin;
843
- if (node.computedAvailability < t) color = COLOR_OVERLOADED;
844
- else if (node.computedAvailability < Math.min(1, t + m))
845
- color = COLOR_WARNING;
846
- else color = COLOR_HEALTHY;
847
- } else {
848
- color =
849
- node.computedAvailability < 0.95
850
- ? COLOR_OVERLOADED
851
- : node.computedAvailability < 0.99
852
- ? COLOR_WARNING
853
- : undefined;
854
- }
855
- rows.push({
856
- key: 'availability',
857
- value: formatUptimeShort(node.computedAvailability),
858
- ...(color !== undefined && { color }),
859
- inverted: color != null && color !== COLOR_HEALTHY,
860
- });
861
- }
862
- // Circuit breaker state — show when a CB is configured and open
863
- if (node.computedCbState === 'open') {
864
- rows.push({
865
- key: 'CB',
866
- value: 'OPEN',
867
- color: COLOR_OVERLOADED,
868
- inverted: true,
869
- });
870
- }
871
- // Queue computed rows: lag and overflow
872
- if (node.queueMetrics) {
873
- const { fillRate, timeToOverflow } = node.queueMetrics;
874
- if (fillRate > 0) {
875
- rows.push({
876
- key: 'lag',
877
- value: `${formatCount(Math.round(fillRate))} msg/s`,
878
- });
879
- }
880
- if (fillRate > 0 && timeToOverflow < Infinity) {
881
- const overflowColor =
882
- timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
883
- rows.push({
884
- key: 'overflow',
885
- value: `~${Math.round(timeToOverflow)}s`,
886
- color: overflowColor,
887
- inverted: timeToOverflow < 60,
888
- });
889
- }
890
- }
891
- return rows;
892
- }
893
-
894
- function formatCount(n: number): string {
895
- if (n >= 1000000)
896
- return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
897
- if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
898
- return String(n);
899
- }
900
-
901
- function formatMsShort(ms: number): string {
902
- if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
903
- return `${Math.round(ms)}ms`;
904
- }
905
-
906
- function formatUptimeShort(fraction: number): string {
907
- const pct = fraction * 100;
908
- if (pct >= 99.99) return '99.99%';
909
- if (pct >= 99.9) return `${pct.toFixed(2)}%`;
910
- if (pct >= 99) return `${pct.toFixed(1)}%`;
911
- return `${pct.toFixed(1)}%`;
912
- }
913
-
914
- /** Properties shown as key-value rows inside the node card. */
915
- function getDisplayProps(
916
- node: InfraLayoutNode,
917
- expanded: boolean,
918
- diagramOptions?: Record<string, string>
919
- ): { key: string; displayKey: string; value: string }[] {
920
- if (node.isEdge) return [];
921
- const rows: { key: string; displayKey: string; value: string }[] = [];
922
- for (const p of node.properties) {
923
- const displayKey = PROP_DISPLAY[p.key];
924
- if (!displayKey) continue;
925
- // Rate limit and max-rps are already shown in the RPS denominator —
926
- // only show the dedicated row when the node is expanded (selected).
927
- if (p.key === 'ratelimit-rps' && !expanded) continue;
928
- if (p.key === 'max-rps' && !expanded) continue;
929
- if (p.key === 'concurrency' && !expanded) continue;
930
- // Format values with appropriate units
931
- if (RPS_FORMAT_KEYS.has(p.key)) {
932
- const num =
933
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
934
- rows.push({
935
- key: p.key,
936
- displayKey,
937
- value: isNaN(num) ? String(p.value) : formatRpsShort(num),
938
- });
939
- } else if (MS_FORMAT_KEYS.has(p.key)) {
940
- const num =
941
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
942
- rows.push({
943
- key: p.key,
944
- displayKey,
945
- value: isNaN(num) ? String(p.value) : formatMsShort(num),
946
- });
947
- } else if (PCT_FORMAT_KEYS.has(p.key)) {
948
- const num =
949
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
950
- rows.push({
951
- key: p.key,
952
- displayKey,
953
- value: isNaN(num) ? String(p.value) : `${num}%`,
954
- });
955
- } else if (p.key === 'buffer') {
956
- const num =
957
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
958
- rows.push({
959
- key: p.key,
960
- displayKey,
961
- value: isNaN(num) ? String(p.value) : formatCount(num),
962
- });
963
- } else if (p.key === 'drain-rate') {
964
- const num =
965
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
966
- rows.push({
967
- key: p.key,
968
- displayKey,
969
- value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s`,
970
- });
971
- } else if (p.key === 'retention-hours') {
972
- const num =
973
- typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
974
- rows.push({
975
- key: p.key,
976
- displayKey,
977
- value: isNaN(num) ? String(p.value) : `${num}h`,
978
- });
979
- } else {
980
- rows.push({ key: p.key, displayKey, value: String(p.value) });
981
- }
982
- }
983
- // Inject diagram-level defaults for properties the node doesn't explicitly declare
984
- if (diagramOptions) {
985
- const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
986
- const hasUptime = node.properties.some((p) => p.key === 'uptime');
987
- const isServerlessNode = node.properties.some(
988
- (p) => p.key === 'concurrency'
989
- );
990
- const defaultLatency =
991
- parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
992
- const defaultUptime =
993
- parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
994
- if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
995
- rows.push({
996
- key: 'latency-ms',
997
- displayKey: 'latency',
998
- value: formatMsShort(defaultLatency),
999
- });
1000
- }
1001
- if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
1002
- rows.push({
1003
- key: 'uptime',
1004
- displayKey: 'uptime',
1005
- value: `${defaultUptime}%`,
1006
- });
1007
- }
1008
- }
1009
- return rows;
1010
- }
1011
-
1012
- /** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
1013
- function formatRpsShort(rps: number): string {
1014
- if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
1015
- return `${Math.round(rps)}`;
1016
- }
1017
-
1018
- /** Compute the worst severity across all row-producing conditions for a node.
1019
- * Returns 'overloaded' (red), 'warning' (yellow), 'healthy' (green), or 'normal'. */
1020
- function worstNodeSeverity(
1021
- node: InfraLayoutNode,
1022
- slo?: NodeSlo | null
1023
- ): 'overloaded' | 'warning' | 'healthy' | 'normal' {
1024
- let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
1025
- const upgrade = (s: 'overloaded' | 'warning') => {
1026
- if (s === 'overloaded') worst = 'overloaded';
1027
- else if (worst !== 'overloaded') worst = 'warning';
1028
- };
1029
-
1030
- // Collapsed group nodes: incorporate child health state
1031
- if (node.childHealthState === 'overloaded') upgrade('overloaded');
1032
- else if (node.childHealthState === 'warning') upgrade('warning');
1033
-
1034
- // RPS-based: overloaded, rate-limited, or >70% capacity
1035
- if (node.overloaded) upgrade('overloaded');
1036
- if (node.rateLimited) upgrade('warning');
1037
- if (isWarning(node)) upgrade('warning');
1038
-
1039
- // Availability — SLO threshold if declared, otherwise hardcoded fallback
1040
- if (slo?.availThreshold != null) {
1041
- const t = slo.availThreshold;
1042
- const m = slo.warningMargin;
1043
- if (node.computedAvailability < t) upgrade('overloaded');
1044
- else if (node.computedAvailability < Math.min(1, t + m)) upgrade('warning');
1045
- // else: in green zone — handled after loop
1046
- } else {
1047
- if (node.computedAvailability < 0.95) upgrade('overloaded');
1048
- else if (node.computedAvailability < 0.99) upgrade('warning');
1049
- }
1050
-
1051
- // p90 Latency SLO
1052
- if (slo?.latencyP90 != null) {
1053
- const t = slo.latencyP90;
1054
- const m = slo.warningMargin;
1055
- const p90 = node.computedLatencyPercentiles.p90;
1056
- if (p90 > t) upgrade('overloaded');
1057
- else if (p90 > t * (1 - m)) upgrade('warning');
1058
- // else: in green zone
1059
- }
1060
-
1061
- // Circuit breaker open
1062
- if (node.computedCbState === 'open') upgrade('overloaded');
1063
-
1064
- // Queue state: filling → warning, imminent overflow → overloaded
1065
- if (node.queueMetrics && node.queueMetrics.fillRate > 0) {
1066
- if (node.queueMetrics.timeToOverflow < 60) upgrade('overloaded');
1067
- else upgrade('warning');
1068
- }
1069
-
1070
- // Rate-limit row (pre-rate-limit RPS vs configured limit)
1071
- if (!node.isEdge) {
1072
- const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
1073
- if (nodeRateLimit > 0 && node.computedRps > 0) {
1074
- let preRl = node.computedRps;
1075
- const ch = getNodeNumProp(node, 'cache-hit', 0);
1076
- if (ch > 0) preRl *= (100 - ch) / 100;
1077
- const fw = getNodeNumProp(node, 'firewall-block', 0);
1078
- if (fw > 0) preRl *= (100 - fw) / 100;
1079
- if (preRl > nodeRateLimit) upgrade('overloaded');
1080
- else if (preRl > nodeRateLimit * 0.8) upgrade('warning');
1081
- }
1082
- }
1083
-
1084
- // Healthy: SLO declared AND all checks are in the green zone
1085
- if (worst === 'normal' && slo != null) {
1086
- const availGreen =
1087
- slo.availThreshold == null ||
1088
- node.computedAvailability >=
1089
- Math.min(1, slo.availThreshold + slo.warningMargin);
1090
- const latencyGreen =
1091
- slo.latencyP90 == null ||
1092
- node.computedLatencyPercentiles.p90 <=
1093
- slo.latencyP90 * (1 - slo.warningMargin);
1094
- if (availGreen && latencyGreen) return 'healthy';
1095
- }
1096
-
1097
- return worst;
1098
- }
1099
-
1100
- function nodeColor(
1101
- _node: InfraLayoutNode,
1102
- palette: PaletteColors,
1103
- isDark: boolean,
1104
- severity: ReturnType<typeof worstNodeSeverity>,
1105
- solid?: boolean
1106
- ): {
1107
- fill: string;
1108
- stroke: string;
1109
- textFill: string;
1110
- } {
1111
- // Severity fills: canonical 25% tint via shapeFill() (TD-3).
1112
- // Fills are visibly LOUDER than pre-spec 8-20% color; combined with
1113
- // the 2x OVERLOAD_STROKE_WIDTH, severity reads cleanly without a badge.
1114
- if (severity === 'overloaded') {
1115
- const fill = shapeFill(palette, COLOR_OVERLOADED, isDark, {
1116
- ...(solid !== undefined && { solid }),
1117
- });
1118
- return {
1119
- fill,
1120
- stroke: COLOR_OVERLOADED,
1121
- textFill: contrastText(
1122
- fill,
1123
- palette.textOnFillLight,
1124
- palette.textOnFillDark
1125
- ),
1126
- };
1127
- }
1128
- if (severity === 'warning') {
1129
- const fill = shapeFill(palette, COLOR_WARNING, isDark, {
1130
- ...(solid !== undefined && { solid }),
1131
- });
1132
- return {
1133
- fill,
1134
- stroke: COLOR_WARNING,
1135
- textFill: contrastText(
1136
- fill,
1137
- palette.textOnFillLight,
1138
- palette.textOnFillDark
1139
- ),
1140
- };
1141
- }
1142
- if (severity === 'healthy') {
1143
- const fill = shapeFill(palette, COLOR_HEALTHY, isDark, {
1144
- ...(solid !== undefined && { solid }),
1145
- });
1146
- return {
1147
- fill,
1148
- stroke: COLOR_HEALTHY,
1149
- textFill: contrastText(
1150
- fill,
1151
- palette.textOnFillLight,
1152
- palette.textOnFillDark
1153
- ),
1154
- };
1155
- }
1156
- // Normal-state node: subtle-neutral 5-10% text tint — intentionally
1157
- // near-invisible so default nodes recede. Out of scope per TD-2 / F2;
1158
- // migrating to shapeFill() would 5x its saturation.
1159
- return {
1160
- fill: isDark
1161
- ? mix(palette.bg, palette.text, 90)
1162
- : mix(palette.bg, palette.text, 95),
1163
- stroke: isDark
1164
- ? mix(palette.text, palette.bg, 60)
1165
- : mix(palette.text, palette.bg, 40),
1166
- textFill: palette.text,
1167
- };
1168
- }
1169
-
1170
- function edgeColor(_edge: InfraLayoutEdge, palette: PaletteColors): string {
1171
- return palette.textMuted;
1172
- }
1173
-
1174
- // ============================================================
1175
- // Render functions
1176
- // ============================================================
1177
-
1178
- function renderGroups(
1179
- svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1180
- groups: readonly InfraLayoutGroup[],
1181
- palette: PaletteColors,
1182
- _isDark: boolean,
1183
- sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
1184
- ) {
1185
- for (const group of groups) {
1186
- const g = svg
1187
- .append('g')
1188
- .attr('class', 'infra-group')
1189
- .attr('data-line-number', group.lineNumber)
1190
- .attr('data-node-toggle', group.id)
1191
- .style('cursor', 'pointer');
1192
-
1193
- const groupFill = mix(palette.surface, palette.bg, 40);
1194
- const groupStroke = palette.textMuted;
1195
- g.append('rect')
1196
- .attr('x', group.x)
1197
- .attr('y', group.y)
1198
- .attr('width', group.width)
1199
- .attr('height', group.height)
1200
- .attr('rx', 6)
1201
- .attr('fill', groupFill)
1202
- .attr('stroke', groupStroke)
1203
- .attr('stroke-opacity', 0.35)
1204
- .attr('stroke-width', 1);
1205
-
1206
- g.append('text')
1207
- .attr('x', group.x + group.width / 2)
1208
- .attr('y', group.y + 16)
1209
- .attr('text-anchor', 'middle')
1210
- .attr('font-family', FONT_FAMILY)
1211
- .attr('font-size', sc.sGroupLabelFontSize)
1212
- .attr('font-weight', '600')
1213
- .attr('fill', palette.text)
1214
- .text(group.label);
1215
-
1216
- const gi =
1217
- typeof group.instances === 'number'
1218
- ? group.instances
1219
- : typeof group.instances === 'string'
1220
- ? parseInt(String(group.instances), 10) || 0
1221
- : 0;
1222
- if (gi > 1) {
1223
- g.append('text')
1224
- .attr('x', group.x + group.width - 8)
1225
- .attr('y', group.y + 16)
1226
- .attr('text-anchor', 'end')
1227
- .attr('font-family', FONT_FAMILY)
1228
- .attr('font-size', sc.sMetaFontSize)
1229
- .attr('fill', palette.textMuted)
1230
- .text(`${gi}x`);
1231
- }
1232
- }
1233
- }
1234
-
1235
- function renderEdgePaths(
1236
- svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1237
- edges: readonly InfraLayoutEdge[],
1238
- nodes: readonly InfraLayoutNode[],
1239
- groups: readonly InfraLayoutGroup[],
1240
- palette: PaletteColors,
1241
- _isDark: boolean,
1242
- animate: boolean,
1243
- direction: 'LR' | 'TB',
1244
- speedMultiplier: number = 1,
1245
- sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
1246
- ) {
1247
- const nodeMap = new Map(nodes.map((n) => [n.id, n]));
1248
- const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
1249
- const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
1250
-
1251
- for (const edge of edges) {
1252
- if (edge.points.length === 0) continue;
1253
-
1254
- const targetNode = nodeMap.get(edge.targetId);
1255
- const sourceNode = nodeMap.get(edge.sourceId);
1256
- const color = edgeColor(edge, palette);
1257
- const strokeW = sc.sEdgeStrokeWidth;
1258
-
1259
- if (!sourceNode || !targetNode) continue;
1260
- const key = `${edge.sourceId}:${edge.targetId}`;
1261
- const pts = edgeWaypoints(
1262
- sourceNode,
1263
- targetNode,
1264
- groups,
1265
- nodes,
1266
- direction,
1267
- 30,
1268
- srcPts.get(key),
1269
- tgtPts.get(key)
1270
- );
1271
- const pathD = buildPathD(pts, direction);
1272
- const edgeG = svg
1273
- .append('g')
1274
- .attr('class', 'infra-edge')
1275
- .attr('data-line-number', edge.lineNumber)
1276
- // Endpoint node ids for baked-CSS connection-highlight (hover-styles.ts).
1277
- .attr('data-from', edge.sourceId)
1278
- .attr('data-to', edge.targetId);
1279
-
1280
- const edgePath = edgeG
1281
- .append('path')
1282
- .attr('d', pathD)
1283
- .attr('fill', 'none')
1284
- .attr('stroke', color)
1285
- .attr('stroke-width', strokeW);
1286
- if (edge.async) {
1287
- edgePath.attr('stroke-dasharray', '6 4');
1288
- }
1289
-
1290
- if (animate && edge.computedRps > 0) {
1291
- const baseDur = flowDuration(edge.computedRps, maxRps);
1292
- const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
1293
-
1294
- const count = particleCount(edge.computedRps, maxRps);
1295
- const particleColor = palette.colors.green;
1296
-
1297
- for (let i = 0; i < count; i++) {
1298
- const delay = (dur / count) * i;
1299
- const circle = edgeG
1300
- .append('circle')
1301
- .attr('r', sc.sParticleR)
1302
- .attr('fill', particleColor)
1303
- .attr('opacity', 0.85);
1304
-
1305
- circle
1306
- .append('animateMotion')
1307
- .attr('dur', `${dur}s`)
1308
- .attr('repeatCount', 'indefinite')
1309
- .attr('begin', `${delay}s`)
1310
- .attr('path', pathD);
1311
- }
1312
- }
1313
- }
1314
- }
1315
-
1316
- function renderEdgeLabels(
1317
- svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1318
- edges: readonly InfraLayoutEdge[],
1319
- nodes: readonly InfraLayoutNode[],
1320
- groups: readonly InfraLayoutGroup[],
1321
- palette: PaletteColors,
1322
- _isDark: boolean,
1323
- animate: boolean,
1324
- direction: 'LR' | 'TB',
1325
- sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
1326
- ) {
1327
- const nodeMap = new Map(nodes.map((n) => [n.id, n]));
1328
- const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
1329
- for (const edge of edges) {
1330
- if (edge.points.length === 0) continue;
1331
- if (!edge.label) continue;
1332
-
1333
- const sourceNode = nodeMap.get(edge.sourceId);
1334
- const targetNode = nodeMap.get(edge.targetId);
1335
- if (!sourceNode || !targetNode) continue;
1336
-
1337
- const key = `${edge.sourceId}:${edge.targetId}`;
1338
- const wps = edgeWaypoints(
1339
- sourceNode,
1340
- targetNode,
1341
- groups,
1342
- nodes,
1343
- direction,
1344
- 30,
1345
- srcPts.get(key),
1346
- tgtPts.get(key)
1347
- );
1348
- const midPt = wps[Math.floor(wps.length / 2)];
1349
- if (!midPt) continue;
1350
- const labelText = edge.label;
1351
-
1352
- const g = svg.append('g').attr('class', animate ? 'infra-edge-label' : '');
1353
-
1354
- const textWidth = measureText(labelText, EDGE_LABEL_FONT_SIZE) + 8;
1355
- g.append('rect')
1356
- .attr('x', midPt.x - textWidth / 2)
1357
- .attr('y', midPt.y - 8)
1358
- .attr('width', textWidth)
1359
- .attr('height', 16)
1360
- .attr('rx', 3)
1361
- .attr('fill', palette.bg)
1362
- .attr('opacity', 0.9);
1363
-
1364
- g.append('text')
1365
- .attr('x', midPt.x)
1366
- .attr('y', midPt.y + 4)
1367
- .attr('text-anchor', 'middle')
1368
- .attr('font-family', FONT_FAMILY)
1369
- .attr('font-size', sc.sEdgeLabelFontSize)
1370
- .attr('fill', palette.textMuted)
1371
- .text(labelText);
1372
-
1373
- if (animate) {
1374
- const pathD = buildPathD(wps, direction);
1375
- g.insert('path', ':first-child')
1376
- .attr('d', pathD)
1377
- .attr('fill', 'none')
1378
- .attr('stroke', 'transparent')
1379
- .attr('stroke-width', 20);
1380
- }
1381
- }
1382
- }
1383
-
1384
- /** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
1385
- function resolveActiveTagStroke(
1386
- node: InfraLayoutNode,
1387
- activeGroup: string,
1388
- tagGroups: readonly InfraTagGroup[],
1389
- palette: PaletteColors
1390
- ): string | null {
1391
- const tg = tagGroups.find(
1392
- (t) => t.name.toLowerCase() === activeGroup.toLowerCase()
1393
- );
1394
- if (!tg) return null;
1395
- const tagKey = tagAttrKey(tg.alias ?? tg.name);
1396
- const tagVal = node.tags[tagKey];
1397
- if (!tagVal) return null;
1398
- const tv = tg.values.find(
1399
- (v) => v.name.toLowerCase() === tagVal.toLowerCase()
1400
- );
1401
- if (!tv?.color) return null;
1402
- return resolveColor(tv.color, palette);
1403
- }
1404
-
1405
- function renderNodes(
1406
- svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1407
- nodes: readonly InfraLayoutNode[],
1408
- palette: PaletteColors,
1409
- isDark: boolean,
1410
- animate: boolean,
1411
- expandedNodeIds?: Set<string> | null,
1412
- activeGroup?: string | null,
1413
- diagramOptions?: Record<string, string>,
1414
- collapsedNodes?: Set<string> | null,
1415
- tagGroups?: readonly InfraTagGroup[],
1416
- fanoutSourceIds?: Set<string>,
1417
- scaledGroupIds?: Set<string>,
1418
- sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
1419
- ) {
1420
- for (const node of nodes) {
1421
- const slo =
1422
- !node.isEdge && diagramOptions
1423
- ? resolveNodeSlo(node, diagramOptions)
1424
- : null;
1425
- const severity = worstNodeSeverity(node, slo);
1426
- // Infra INTENTIONALLY ignores `solid-fill` — the renderer relies on subtle
1427
- // tint gradations to communicate severity tiers (normal / warning /
1428
- // overloaded), instance counts, RPS load indicators, and the nested
1429
- // header/body/metrics panel structure. Solid fills collapse all of those
1430
- // signals into one saturated rectangle, making the diagram unreadable.
1431
- const nodeColors = nodeColor(node, palette, isDark, severity, false);
1432
- const textFill = nodeColors.textFill;
1433
- let { fill, stroke } = nodeColors;
1434
-
1435
- // When a tag legend is active, override border color with tag color
1436
- if (activeGroup && tagGroups && !node.isEdge) {
1437
- const tagStroke = resolveActiveTagStroke(
1438
- node,
1439
- activeGroup,
1440
- tagGroups,
1441
- palette
1442
- );
1443
- if (tagStroke) {
1444
- stroke = tagStroke;
1445
- // Canonical 25% tint (matches org/sitemap/c4 tag fills) — was a
1446
- // muted 6-12% wash that read inconsistently against other charts.
1447
- fill = shapeFill(palette, tagStroke, isDark);
1448
- }
1449
- }
1450
- let cls = 'infra-node';
1451
- if (animate && node.isEdge) {
1452
- cls += ' infra-node-edge-throb';
1453
- } else if (animate && !node.isEdge) {
1454
- if (node.computedCbState === 'open') cls += ' infra-node-cb-open';
1455
- else if (severity === 'overloaded') cls += ' infra-node-overload';
1456
- else if (severity === 'warning') cls += ' infra-node-warning';
1457
- }
1458
- const g = svg
1459
- .append('g')
1460
- .attr('class', cls)
1461
- .attr('data-line-number', node.lineNumber)
1462
- .attr('data-infra-node', node.id)
1463
- .attr('data-node-collapse', node.id)
1464
- .style('cursor', 'pointer');
1465
-
1466
- // Collapsed group nodes: toggle attribute
1467
- if (node.id.startsWith('[')) {
1468
- g.attr('data-node-toggle', node.id);
1469
- }
1470
-
1471
- // Expose tag values for legend hover dimming
1472
- for (const [tagKey, tagVal] of Object.entries(node.tags)) {
1473
- g.attr(`data-tag-${tagKey.toLowerCase()}`, tagVal.toLowerCase());
1474
- }
1475
-
1476
- // Expose role names for role legend hover dimming
1477
- if (!node.isEdge) {
1478
- const roles = inferRoles(node.properties);
1479
- for (const role of roles) {
1480
- g.attr(
1481
- `data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`,
1482
- 'true'
1483
- );
1484
- }
1485
- if (fanoutSourceIds?.has(node.id)) {
1486
- g.attr('data-role-fan-out', 'true');
1487
- }
1488
- }
1489
-
1490
- const x = node.x - node.width / 2;
1491
- const y = node.y - node.height / 2;
1492
- const isCollapsedGroup = node.id.startsWith('[');
1493
- const strokeWidth =
1494
- severity !== 'normal' ? sc.sOverloadStrokeWidth : sc.sNodeStrokeWidth;
1495
-
1496
- g.append('rect')
1497
- .attr('x', x)
1498
- .attr('y', y)
1499
- .attr('width', node.width)
1500
- .attr('height', node.height)
1501
- .attr('rx', sc.sNodeBorderRadius)
1502
- .attr('fill', fill)
1503
- .attr('stroke', stroke)
1504
- .attr('stroke-width', strokeWidth);
1505
-
1506
- const headerCenterY = y + NODE_HEADER_HEIGHT / 2 + sc.sNodeFontSize * 0.35;
1507
- g.append('text')
1508
- .attr('x', node.x)
1509
- .attr('y', headerCenterY)
1510
- .attr('text-anchor', 'middle')
1511
- .attr('font-family', FONT_FAMILY)
1512
- .attr('font-size', sc.sNodeFontSize)
1513
- .attr('font-weight', '600')
1514
- .attr('fill', textFill)
1515
- .text(node.label);
1516
-
1517
- // --- Key-value rows below header (skipped for collapsed nodes) ---
1518
- const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
1519
- if (isNodeCollapsed) {
1520
- // Collapsed: show a subtle chevron indicator at the bottom of the header
1521
- const chevronY = y + node.height - 6;
1522
- g.append('text')
1523
- .attr('x', node.x)
1524
- .attr('y', chevronY)
1525
- .attr('text-anchor', 'middle')
1526
- .attr('font-family', FONT_FAMILY)
1527
- .attr('font-size', 8)
1528
- .attr('fill', textFill)
1529
- .attr('opacity', 0.6)
1530
- .text('▶');
1531
- }
1532
- if (!isNodeCollapsed) {
1533
- const expanded = expandedNodeIds?.has(node.id) ?? false;
1534
-
1535
- // Description subtitle — shown below label only when node is selected
1536
- const descLines =
1537
- expanded && node.description && !node.isEdge ? node.description : [];
1538
- const descH = descLines.length * META_LINE_HEIGHT;
1539
- for (let di = 0; di < descLines.length; di++) {
1540
- const rawLine = descLines[di]!;
1541
- const processed = preprocessDescriptionLine(rawLine);
1542
- const descTruncated = truncateDesc(processed);
1543
- const isTruncated = descTruncated !== processed;
1544
- const textEl = g
1545
- .append('text')
1546
- .attr('x', node.x)
1547
- .attr(
1548
- 'y',
1549
- y +
1550
- NODE_HEADER_HEIGHT +
1551
- di * META_LINE_HEIGHT +
1552
- META_LINE_HEIGHT / 2 +
1553
- sc.sMetaFontSize * 0.35
1554
- )
1555
- .attr('text-anchor', 'middle')
1556
- .attr('font-family', FONT_FAMILY)
1557
- .attr('font-size', sc.sMetaFontSize)
1558
- .attr('fill', textFill);
1559
- renderInlineText(textEl, descTruncated, palette, sc.sMetaFontSize);
1560
- if (isTruncated) textEl.append('title').text(rawLine);
1561
- }
1562
-
1563
- // Declared properties only shown when node is selected (expanded)
1564
- const displayProps =
1565
- !node.isEdge && expanded
1566
- ? getDisplayProps(node, expanded, diagramOptions)
1567
- : [];
1568
- const computedRows = getComputedRows(node, expanded, slo);
1569
- const hasContent =
1570
- displayProps.length > 0 ||
1571
- computedRows.length > 0 ||
1572
- node.computedRps > 0;
1573
-
1574
- if (hasContent) {
1575
- // Separator line between header and body
1576
- const sepY = y + NODE_HEADER_HEIGHT + descH;
1577
- g.append('line')
1578
- .attr('x1', x)
1579
- .attr('y1', sepY)
1580
- .attr('x2', x + node.width)
1581
- .attr('y2', sepY)
1582
- .attr('stroke', stroke)
1583
- .attr('stroke-opacity', 0.3)
1584
- .attr('stroke-width', 1);
1585
-
1586
- // Build rows in two sections: computed (dynamic) on top, declared (config) below.
1587
- // A second separator line divides them when both sections have content.
1588
- const computedSection: NodeRow[] = [];
1589
- const declaredSection: NodeRow[] = [];
1590
-
1591
- // Determine effective throughput limit for the RPS row denominator
1592
- const nodeMaxRps = getNodeNumProp(node, 'max-rps', 0);
1593
- const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
1594
- const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
1595
- const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
1596
- const serverlessCap =
1597
- nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
1598
- const effectiveCap =
1599
- serverlessCap > 0
1600
- ? serverlessCap
1601
- : nodeMaxRps > 0 && nodeRateLimit > 0
1602
- ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
1603
- : nodeMaxRps > 0
1604
- ? nodeMaxRps * node.computedInstances
1605
- : nodeRateLimit > 0
1606
- ? nodeRateLimit
1607
- : 0;
1608
-
1609
- // --- Computed section: RPS + computed metrics ---
1610
- if (node.computedRps > 0) {
1611
- // Check rate-limit proximity (pre-ratelimit RPS after cache/fw reductions)
1612
- let rlSeverity: 'overloaded' | 'warning' | 'normal' = 'normal';
1613
- if (nodeRateLimit > 0 && node.computedRps > 0 && !node.isEdge) {
1614
- let preRl = node.computedRps;
1615
- const ch = getNodeNumProp(node, 'cache-hit', 0);
1616
- if (ch > 0) preRl *= (100 - ch) / 100;
1617
- const fw = getNodeNumProp(node, 'firewall-block', 0);
1618
- if (fw > 0) preRl *= (100 - fw) / 100;
1619
- if (preRl > nodeRateLimit) rlSeverity = 'overloaded';
1620
- else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
1621
- }
1622
- const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
1623
- node.overloaded
1624
- ? 'overloaded'
1625
- : rlSeverity === 'overloaded'
1626
- ? 'overloaded'
1627
- : node.rateLimited
1628
- ? 'warning'
1629
- : isWarning(node)
1630
- ? 'warning'
1631
- : rlSeverity === 'warning'
1632
- ? 'warning'
1633
- : 'normal';
1634
- const rpsColor =
1635
- rpsSeverity === 'overloaded'
1636
- ? COLOR_OVERLOADED
1637
- : rpsSeverity === 'warning'
1638
- ? COLOR_WARNING
1639
- : textFill;
1640
- const rpsInverted = rpsSeverity !== 'normal';
1641
- const rpsText =
1642
- effectiveCap > 0 && !node.isEdge
1643
- ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
1644
- : formatRpsShort(node.computedRps);
1645
- computedSection.push({
1646
- key: 'RPS',
1647
- value: rpsText,
1648
- valueFill: rpsColor,
1649
- fontWeight: '500',
1650
- inverted: rpsInverted,
1651
- ...(rpsInverted && { invertedBg: rpsColor }),
1652
- });
1653
- }
1654
- for (const cr of computedRows) {
1655
- computedSection.push({
1656
- key: cr.key,
1657
- value: cr.value,
1658
- valueFill: cr.color ?? textFill,
1659
- fontWeight: 'normal',
1660
- ...(cr.inverted !== undefined && { inverted: cr.inverted }),
1661
- ...(cr.inverted &&
1662
- cr.color !== undefined && { invertedBg: cr.color }),
1663
- });
1664
- }
1665
-
1666
- // --- Declared section: user-defined properties (only when node is selected) ---
1667
- for (const prop of displayProps) {
1668
- let propColor = textFill;
1669
- let inverted = false;
1670
- let invertedBg: string | undefined;
1671
- if (
1672
- prop.key === 'ratelimit-rps' &&
1673
- nodeRateLimit > 0 &&
1674
- node.computedRps > 0
1675
- ) {
1676
- let preRl = node.computedRps;
1677
- const ch = getNodeNumProp(node, 'cache-hit', 0);
1678
- if (ch > 0) preRl *= (100 - ch) / 100;
1679
- const fw = getNodeNumProp(node, 'firewall-block', 0);
1680
- if (fw > 0) preRl *= (100 - fw) / 100;
1681
- if (preRl > nodeRateLimit) {
1682
- propColor = COLOR_OVERLOADED;
1683
- inverted = true;
1684
- invertedBg = COLOR_OVERLOADED;
1685
- } else if (preRl > nodeRateLimit * 0.8) {
1686
- propColor = COLOR_WARNING;
1687
- inverted = true;
1688
- invertedBg = COLOR_WARNING;
1689
- }
1690
- }
1691
- declaredSection.push({
1692
- key: prop.displayKey,
1693
- value: prop.value,
1694
- valueFill: propColor,
1695
- fontWeight: 'normal',
1696
- inverted,
1697
- ...(invertedBg !== undefined && { invertedBg }),
1698
- });
1699
- }
1700
-
1701
- const rows = [...computedSection, ...declaredSection];
1702
-
1703
- // Compute max key width (pixels) so values align vertically.
1704
- // Keys are drawn as "${key}: ", so measure that exact string.
1705
- const maxKeyWidth = Math.max(
1706
- ...rows.map((r) => measureText(`${r.key}: `, sc.sMetaFontSize))
1707
- );
1708
- const valueX = x + 10 + maxKeyWidth;
1709
-
1710
- let rowY = sepY + NODE_SEPARATOR_GAP + sc.sMetaFontSize;
1711
- const needsSectionSep =
1712
- computedSection.length > 0 && declaredSection.length > 0;
1713
- let rowIdx = 0;
1714
- for (const row of rows) {
1715
- // Draw separator line between computed and declared sections
1716
- if (needsSectionSep && rowIdx === computedSection.length) {
1717
- const sepLineY = rowY - sc.sMetaFontSize + 1;
1718
- g.append('line')
1719
- .attr('x1', x)
1720
- .attr('y1', sepLineY)
1721
- .attr('x2', x + node.width)
1722
- .attr('y2', sepLineY)
1723
- .attr('stroke', stroke)
1724
- .attr('stroke-opacity', 0.3)
1725
- .attr('stroke-width', 1);
1726
- rowY += sc.sNodeSeparatorGap;
1727
- }
1728
- if (row.inverted && row.invertedBg) {
1729
- const pillH = sc.sMetaLineHeight - 1;
1730
- const pillPad = 4;
1731
- const pillX = x + pillPad;
1732
- const pillY = rowY - sc.sMetaFontSize + 1;
1733
- const pillW = node.width - pillPad * 2;
1734
- g.append('rect')
1735
- .attr('x', pillX)
1736
- .attr('y', pillY)
1737
- .attr('width', pillW)
1738
- .attr('height', pillH)
1739
- .attr('rx', 3)
1740
- .attr('fill', row.invertedBg)
1741
- .attr('opacity', 0.9);
1742
- // Inverted text: use lightest available color for max contrast on colored pills
1743
- const pillTextColor = isDark ? '#ffffff' : palette.text;
1744
- // Key — same x as normal rows
1745
- g.append('text')
1746
- .attr('x', x + 10)
1747
- .attr('y', rowY)
1748
- .attr('font-family', FONT_FAMILY)
1749
- .attr('font-size', sc.sMetaFontSize)
1750
- .attr('font-weight', '600')
1751
- .attr('fill', pillTextColor)
1752
- .text(`${row.key}: `);
1753
- g.append('text')
1754
- .attr('x', valueX)
1755
- .attr('y', rowY)
1756
- .attr('font-family', FONT_FAMILY)
1757
- .attr('font-size', sc.sMetaFontSize)
1758
- .attr('font-weight', '600')
1759
- .attr('fill', pillTextColor)
1760
- .text(row.value);
1761
- } else {
1762
- g.append('text')
1763
- .attr('x', x + 10)
1764
- .attr('y', rowY)
1765
- .attr('font-family', FONT_FAMILY)
1766
- .attr('font-size', sc.sMetaFontSize)
1767
- .attr('fill', textFill)
1768
- .text(`${row.key}: `);
1769
-
1770
- g.append('text')
1771
- .attr('x', valueX)
1772
- .attr('y', rowY)
1773
- .attr('font-family', FONT_FAMILY)
1774
- .attr('font-size', sc.sMetaFontSize)
1775
- .attr('font-weight', row.fontWeight)
1776
- .attr('fill', row.valueFill)
1777
- .text(row.value);
1778
- }
1779
-
1780
- rowY += sc.sMetaLineHeight;
1781
- rowIdx++;
1782
- }
1783
- }
1784
-
1785
- // Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
1786
- // Serverless nodes show instances in a computed row instead (demand / concurrency).
1787
- // Nodes inside a scaled group suppress their badge — the group header already shows Nx.
1788
- const inScaledGroup =
1789
- node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
1790
- if (
1791
- !node.isEdge &&
1792
- node.computedConcurrentInvocations === 0 &&
1793
- node.computedInstances > 1 &&
1794
- !inScaledGroup
1795
- ) {
1796
- const badgeText = `${node.computedInstances}x`;
1797
- g.append('text')
1798
- .attr('x', x + node.width - 6)
1799
- .attr('y', y + NODE_HEADER_HEIGHT / 2 + sc.sMetaFontSize * 0.35)
1800
- .attr('text-anchor', 'end')
1801
- .attr('font-family', FONT_FAMILY)
1802
- .attr('font-size', sc.sMetaFontSize)
1803
- .attr('fill', textFill)
1804
- .attr('data-instance-node', node.id)
1805
- .style('cursor', 'pointer')
1806
- .text(badgeText);
1807
- }
1808
-
1809
- const showDots = activeGroup?.toLowerCase() === 'capabilities';
1810
- const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
1811
- if (roles.length > 0) {
1812
- const dotY = isCollapsedGroup
1813
- ? y + node.height - sc.sCollapseBarHeight - sc.sNodePadBottom / 2
1814
- : y + node.height - sc.sNodePadBottom / 2;
1815
- const totalDotsWidth = roles.length * (sc.sRoleDotRadius * 2 + 2) - 2;
1816
- const startX = node.x - totalDotsWidth / 2 + sc.sRoleDotRadius;
1817
- for (let i = 0; i < roles.length; i++) {
1818
- g.append('circle')
1819
- .attr('cx', startX + i * (sc.sRoleDotRadius * 2 + 2))
1820
- .attr('cy', dotY)
1821
- .attr('r', sc.sRoleDotRadius)
1822
- .attr('fill', roles[i]!.color);
1823
- }
1824
- }
1825
-
1826
- if (isCollapsedGroup) {
1827
- const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
1828
- g.append('clipPath')
1829
- .attr('id', clipId)
1830
- .append('rect')
1831
- .attr('x', x)
1832
- .attr('y', y)
1833
- .attr('width', node.width)
1834
- .attr('height', node.height)
1835
- .attr('rx', sc.sNodeBorderRadius);
1836
- g.append('rect')
1837
- .attr('x', x + sc.sCollapseBarInset)
1838
- .attr('y', y + node.height - sc.sCollapseBarHeight)
1839
- .attr('width', node.width - sc.sCollapseBarInset * 2)
1840
- .attr('height', sc.sCollapseBarHeight)
1841
- .attr('fill', stroke)
1842
- .attr('clip-path', `url(#${clipId})`)
1843
- .attr('class', 'infra-collapse-bar');
1844
- }
1845
- }
1846
- }
1847
- }
1848
-
1849
- // ============================================================
1850
- // Reject Particles — red fading drops for traffic-shedding nodes
1851
- // ============================================================
1852
-
1853
- /** Get a numeric property from an InfraLayoutNode. */
1854
- function getNodeNumProp(
1855
- node: InfraLayoutNode,
1856
- key: string,
1857
- fallback: number
1858
- ): number {
1859
- const prop = node.properties.find((p) => p.key === key);
1860
- if (!prop) return fallback;
1861
- if (typeof prop.value === 'number') return prop.value;
1862
- const num = parseFloat(String(prop.value));
1863
- return isNaN(num) ? fallback : num;
1864
- }
1865
-
1866
- /**
1867
- * Compute rejected RPS for a node — traffic that arrives but is dropped/blocked.
1868
- * This includes: firewall-block, rate-limit excess, and overload excess.
1869
- * Cache-hit is NOT a reject — the request is served from cache.
1870
- */
1871
- function computeRejectedRps(node: InfraLayoutNode): number {
1872
- if (node.isEdge || node.computedRps <= 0) return 0;
1873
- let rejected = 0;
1874
- const inbound = node.computedRps;
1875
-
1876
- // Firewall block: N% of inbound is rejected
1877
- const fwBlock = getNodeNumProp(node, 'firewall-block', 0);
1878
- if (fwBlock > 0) rejected += inbound * (fwBlock / 100);
1879
-
1880
- // Rate limit excess (applied after cache/fw reductions)
1881
- const rateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
1882
- if (rateLimit > 0) {
1883
- let preRateLimitRps = inbound;
1884
- const cacheHit = getNodeNumProp(node, 'cache-hit', 0);
1885
- if (cacheHit > 0) preRateLimitRps *= (100 - cacheHit) / 100;
1886
- if (fwBlock > 0) preRateLimitRps *= (100 - fwBlock) / 100;
1887
- if (preRateLimitRps > rateLimit) {
1888
- rejected += preRateLimitRps - rateLimit;
1889
- }
1890
- }
1891
-
1892
- // Overload excess
1893
- if (node.overloaded || node.childHealthState === 'overloaded') {
1894
- const maxRps = getNodeNumProp(node, 'max-rps', 0);
1895
- if (maxRps > 0) {
1896
- const capacity = maxRps * node.computedInstances;
1897
- if (inbound > capacity) {
1898
- rejected += inbound - capacity;
1899
- }
1900
- }
1901
- // For collapsed groups: childHealthState signals child overload even when
1902
- // the virtual node's aggregated capacity math doesn't trigger (due to
1903
- // group instances being baked into both max-rps and computedInstances).
1904
- // Ensure we always produce a reject signal.
1905
- if (rejected === 0) {
1906
- rejected = inbound * 0.1;
1907
- }
1908
- }
1909
-
1910
- return rejected;
1911
- }
1912
-
1913
- function renderRejectParticles(
1914
- svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1915
- nodes: readonly InfraLayoutNode[],
1916
- speedMultiplier: number = 1,
1917
- sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
1918
- ) {
1919
- // Compute max rejected RPS across all nodes for scaling
1920
- const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
1921
- for (const node of nodes) {
1922
- const rejected = computeRejectedRps(node);
1923
- if (rejected > 0) rejectMap.push({ node, rejected });
1924
- }
1925
- if (rejectMap.length === 0) return;
1926
-
1927
- const maxRejected = Math.max(...rejectMap.map((r) => r.rejected));
1928
-
1929
- for (const { node, rejected } of rejectMap) {
1930
- const t = Math.min(rejected / maxRejected, 1);
1931
- const count = Math.round(
1932
- REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN)
1933
- );
1934
- const baseDur =
1935
- REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1936
- const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
1937
-
1938
- const nodeBottom = node.y + node.height / 2;
1939
-
1940
- for (let i = 0; i < count; i++) {
1941
- const delay = (dur / count) * i;
1942
- // Spread particles horizontally around the node center
1943
- const spread = node.width * 0.3;
1944
- const startX =
1945
- node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
1946
- const startY = nodeBottom;
1947
- const endY = nodeBottom + sc.sRejectDropDistance;
1948
-
1949
- const rejectColor =
1950
- node.overloaded || node.childHealthState === 'overloaded'
1951
- ? COLOR_OVERLOADED
1952
- : COLOR_WARNING;
1953
- const circle = svg
1954
- .append('circle')
1955
- .attr('r', sc.sRejectParticleR)
1956
- .attr('fill', rejectColor)
1957
- .attr('opacity', 0);
1958
-
1959
- // Drop straight down from node bottom
1960
- circle
1961
- .append('animate')
1962
- .attr('attributeName', 'cy')
1963
- .attr('from', startY)
1964
- .attr('to', endY)
1965
- .attr('dur', `${dur}s`)
1966
- .attr('repeatCount', 'indefinite')
1967
- .attr('begin', `${delay}s`);
1968
-
1969
- circle
1970
- .append('animate')
1971
- .attr('attributeName', 'cx')
1972
- .attr('from', startX)
1973
- .attr('to', startX)
1974
- .attr('dur', `${dur}s`)
1975
- .attr('repeatCount', 'indefinite')
1976
- .attr('begin', `${delay}s`);
1977
-
1978
- // Fade: appear quickly, then fade out
1979
- circle
1980
- .append('animate')
1981
- .attr('attributeName', 'opacity')
1982
- .attr('values', '0;0.8;0.6;0')
1983
- .attr('keyTimes', '0;0.1;0.5;1')
1984
- .attr('dur', `${dur}s`)
1985
- .attr('repeatCount', 'indefinite')
1986
- .attr('begin', `${delay}s`);
1987
- }
1988
- }
1989
- }
1990
-
1991
- // ============================================================
1992
- // Legend — pill/capsule groups (matching org chart style)
1993
- // ============================================================
1994
-
1995
- interface InfraLegendEntry {
1996
- value: string;
1997
- color: string;
1998
- /** For role: kebab-case role name. For tag: lowercase tag value. */
1999
- key: string;
2000
- }
2001
-
2002
- export interface InfraLegendGroup {
2003
- name: string;
2004
- type: 'role' | 'tag';
2005
- /** For tag groups: the key used in data-tag-* attributes (alias or name). */
2006
- tagKey?: string;
2007
- entries: InfraLegendEntry[];
2008
- width: number;
2009
- minifiedWidth: number;
2010
- }
2011
-
2012
- /** Build legend groups from roles + tags. */
2013
- export function computeInfraLegendGroups(
2014
- nodes: readonly InfraLayoutNode[],
2015
- tagGroups: readonly InfraTagGroup[],
2016
- palette: PaletteColors,
2017
- edges?: readonly InfraLayoutEdge[]
2018
- ): InfraLegendGroup[] {
2019
- const groups: InfraLegendGroup[] = [];
2020
-
2021
- // Capabilities group (from inferred roles + fanout edges)
2022
- const roles = collectDiagramRoles(
2023
- nodes.filter((n) => !n.isEdge).map((n) => n.properties)
2024
- );
2025
- if (edges && collectFanoutSourceIds([...edges]).size > 0) {
2026
- roles.push(FANOUT_ROLE);
2027
- }
2028
- if (roles.length > 0) {
2029
- const entries = roles.map((r) => ({
2030
- value: r.name,
2031
- color: r.color,
2032
- key: r.name.toLowerCase().replace(/\s+/g, '-'),
2033
- }));
2034
- const pillWidth =
2035
- measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) +
2036
- LEGEND_PILL_PAD;
2037
- let entriesWidth = 0;
2038
- for (const e of entries) {
2039
- entriesWidth +=
2040
- LEGEND_DOT_R * 2 +
2041
- LEGEND_ENTRY_DOT_GAP +
2042
- measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
2043
- LEGEND_ENTRY_TRAIL;
2044
- }
2045
- groups.push({
2046
- name: 'Capabilities',
2047
- type: 'role',
2048
- entries,
2049
- width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
2050
- minifiedWidth: pillWidth,
2051
- });
2052
- }
2053
-
2054
- // Tag groups
2055
- for (const tg of tagGroups) {
2056
- const entries: InfraLegendEntry[] = [];
2057
- for (const tv of tg.values) {
2058
- if (tv.color) {
2059
- entries.push({
2060
- value: tv.name,
2061
- color: resolveColor(tv.color, palette) ?? tv.color,
2062
- key: tv.name.toLowerCase(),
2063
- });
2064
- }
2065
- }
2066
- if (entries.length === 0) continue;
2067
- const pillWidth =
2068
- measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2069
- let entriesWidth = 0;
2070
- for (const e of entries) {
2071
- entriesWidth +=
2072
- LEGEND_DOT_R * 2 +
2073
- LEGEND_ENTRY_DOT_GAP +
2074
- measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
2075
- LEGEND_ENTRY_TRAIL;
2076
- }
2077
- groups.push({
2078
- name: tg.name,
2079
- type: 'tag',
2080
- tagKey: tagAttrKey(tg.alias ?? tg.name),
2081
- entries,
2082
- width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
2083
- minifiedWidth: pillWidth,
2084
- });
2085
- }
2086
-
2087
- return groups;
2088
- }
2089
-
2090
- function renderLegend(
2091
- rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2092
- legendGroups: InfraLegendGroup[],
2093
- totalWidth: number,
2094
- legendY: number,
2095
- palette: PaletteColors,
2096
- isDark: boolean,
2097
- activeGroup: string | null,
2098
- playback?: InfraPlaybackState,
2099
- exportMode = false,
2100
- controlsHost?: 'app' | 'inline'
2101
- ) {
2102
- if (legendGroups.length === 0 && !playback) return;
2103
- // App-hosted playback: the play/pause + speed UI lives in the app overlay
2104
- // strip, so suppress the in-SVG Playback pill and emit the controls anchor.
2105
- const appHostedPlayback = controlsHost === 'app' && !!playback;
2106
-
2107
- const legendG = rootSvg
2108
- .append('g')
2109
- .attr('transform', `translate(0, ${legendY})`);
2110
-
2111
- if (activeGroup) {
2112
- legendG.attr('data-legend-active', tagAttrKey(activeGroup));
2113
- }
2114
-
2115
- // Build all groups including Playback as a regular group for consistent rendering
2116
- const allGroups = legendGroups.map((g) => ({
2117
- name: g.name,
2118
- entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2119
- }));
2120
- // Add Playback as a group with empty entries (collapsed pill) or dummy entries
2121
- // (expanded) — unless the app hosts it, in which case it's suppressed.
2122
- if (playback && !appHostedPlayback) {
2123
- allGroups.push({ name: 'Playback', entries: [] });
2124
- }
2125
-
2126
- renderIntegratedLegend(legendG, {
2127
- groups: allGroups,
2128
- activeGroup,
2129
- mode: exportMode ? 'export' : 'preview',
2130
- showEmptyGroups: true,
2131
- ...(appHostedPlayback && {
2132
- controlsHost: 'app' as const,
2133
- controlsGroup: {
2134
- toggles: [
2135
- {
2136
- id: 'playback',
2137
- type: 'toggle' as const,
2138
- label: 'Playback',
2139
- active: true,
2140
- onToggle: () => {},
2141
- },
2142
- ],
2143
- },
2144
- }),
2145
- palette,
2146
- isDark,
2147
- width: totalWidth,
2148
- });
2149
-
2150
- // Add infra-specific classes and data attributes for app interactivity
2151
- legendG.selectAll('[data-legend-group]').classed('infra-legend-group', true);
2152
- for (const group of legendGroups) {
2153
- const groupKey = tagAttrKey(group.name);
2154
- for (const entry of group.entries) {
2155
- const entryEl = legendG.select(
2156
- `[data-legend-group="${groupKey}"] [data-legend-entry="${entry.value.toLowerCase()}"]`
2157
- );
2158
- if (!entryEl.empty()) {
2159
- entryEl
2160
- .attr('data-legend-entry', entry.key.toLowerCase())
2161
- .attr('data-legend-color', entry.color)
2162
- .attr('data-legend-type', group.type)
2163
- .attr(
2164
- 'data-legend-tag-group',
2165
- group.type === 'tag' ? (group.tagKey ?? '') : null
2166
- );
2167
- }
2168
- }
2169
- }
2170
-
2171
- // Mark the Playback pill with infra-playback-pill class for app interactivity
2172
- const playbackEl = legendG.select('[data-legend-group="playback"]');
2173
- if (!playbackEl.empty()) {
2174
- playbackEl.classed('infra-playback-pill', true);
2175
- }
2176
-
2177
- // Inject speed badges into the Playback pill when expanded
2178
- // Speed badges are injected into the Playback pill below
2179
-
2180
- // Inject speed badges into the Playback pill when expanded
2181
- if (playback && playback.expanded && !playbackEl.empty()) {
2182
- const pillWidth =
2183
- measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2184
-
2185
- let entryX = pillWidth + 8;
2186
- const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
2187
-
2188
- const ppLabel = playback.paused ? '▶' : '⏸';
2189
- playbackEl
2190
- .append('text')
2191
- .attr('x', entryX)
2192
- .attr('y', entryY)
2193
- .attr('font-family', FONT_FAMILY)
2194
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
2195
- .attr('fill', palette.textMuted)
2196
- .attr('data-playback-action', 'toggle-pause')
2197
- .style('cursor', 'pointer')
2198
- .text(ppLabel);
2199
- entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
2200
-
2201
- for (const s of playback.speedOptions) {
2202
- const label = `${s}x`;
2203
- const isSpeedActive = playback.speed === s;
2204
- const slotW =
2205
- measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) +
2206
- SPEED_BADGE_H_PAD * 2;
2207
- const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
2208
- const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
2209
-
2210
- const speedG = playbackEl
2211
- .append('g')
2212
- .attr('data-playback-action', 'set-speed')
2213
- .attr('data-playback-value', String(s))
2214
- .style('cursor', 'pointer');
2215
-
2216
- speedG
2217
- .append('rect')
2218
- .attr('x', entryX)
2219
- .attr('y', badgeY)
2220
- .attr('width', slotW)
2221
- .attr('height', badgeH)
2222
- .attr('rx', badgeH / 2)
2223
- .attr('fill', isSpeedActive ? palette.primary : 'transparent');
2224
-
2225
- speedG
2226
- .append('text')
2227
- .attr('x', entryX + slotW / 2)
2228
- .attr('y', entryY)
2229
- .attr('font-family', FONT_FAMILY)
2230
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
2231
- .attr('font-weight', isSpeedActive ? '600' : '400')
2232
- .attr('fill', isSpeedActive ? palette.bg : palette.textMuted)
2233
- .attr('text-anchor', 'middle')
2234
- .text(label);
2235
- entryX += slotW + SPEED_BADGE_GAP;
2236
- }
2237
- }
2238
- }
2239
-
2240
- // ============================================================
2241
- // Main render
2242
- // ============================================================
2243
-
2244
- export interface InfraPlaybackState {
2245
- expanded: boolean;
2246
- paused: boolean;
2247
- speed: number;
2248
- speedOptions: readonly number[];
2249
- }
2250
-
2251
- export function renderInfra(
2252
- container: HTMLDivElement,
2253
- layout: InfraLayoutResult,
2254
- palette: PaletteColors,
2255
- isDark: boolean,
2256
- title: string | null,
2257
- titleLineNumber: number | null,
2258
- tagGroups?: readonly InfraTagGroup[],
2259
- activeGroup?: string | null,
2260
- animate?: boolean,
2261
- playback?: InfraPlaybackState | null,
2262
- expandedNodeIds?: Set<string> | null,
2263
- exportMode?: boolean,
2264
- collapsedNodes?: Set<string> | null,
2265
- /** When 'app', the playback pill is suppressed and a controls row + anchor are
2266
- * reserved for the app overlay strip (play/pause + speed live there). */
2267
- controlsHost?: 'app' | 'inline'
2268
- ) {
2269
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2270
- const appHostedPlayback = controlsHost === 'app' && !!playback;
2271
-
2272
- const ctx = ScaleContext.identity();
2273
- const sc = buildScaledConstants(ctx);
2274
-
2275
- const legendGroups = computeInfraLegendGroups(
2276
- layout.nodes,
2277
- tagGroups ?? [],
2278
- palette,
2279
- layout.edges
2280
- );
2281
- // App-hosted: the playback pill moves to the app overlay, so a playback-only
2282
- // legend (no tag groups) has nothing left to render.
2283
- const hasLegend =
2284
- legendGroups.length > 0 || (!!playback && !appHostedPlayback);
2285
- const fixedLegend = !exportMode && hasLegend;
2286
- const legendDynamicH = hasLegend
2287
- ? getMaxLegendReservedHeight(
2288
- {
2289
- groups: legendGroups,
2290
- position: { placement: 'top-center', titleRelation: 'below-title' },
2291
- mode: exportMode ? 'export' : 'preview',
2292
- showEmptyGroups: true,
2293
- },
2294
- container.clientWidth || layout.width
2295
- )
2296
- : LEGEND_HEIGHT;
2297
- const legendOffset = hasLegend && !fixedLegend ? legendDynamicH : 0;
2298
-
2299
- const titleOffset = title ? TITLE_OFFSET : 0;
2300
- const totalWidth = layout.width;
2301
- const totalHeight = layout.height + titleOffset + legendOffset;
2302
-
2303
- const shouldAnimate = animate !== false;
2304
-
2305
- const fixedTitleH = fixedLegend && title ? TITLE_OFFSET : 0;
2306
- const diagramViewHeight = fixedLegend
2307
- ? layout.height + (title && !fixedTitleH ? titleOffset : 0) + legendOffset
2308
- : totalHeight;
2309
-
2310
- if (fixedTitleH) {
2311
- const titleContainerW = container.clientWidth || totalWidth;
2312
- const titleSvg = d3Selection
2313
- .select(container)
2314
- .append('svg')
2315
- .attr('class', 'infra-title-fixed')
2316
- .attr('width', '100%')
2317
- .attr('height', fixedTitleH)
2318
- .attr('viewBox', `0 0 ${titleContainerW} ${fixedTitleH}`)
2319
- .attr('preserveAspectRatio', 'xMidYMid meet')
2320
- .style('display', 'block');
2321
- titleSvg
2322
- .append('text')
2323
- .attr('class', 'chart-title')
2324
- .attr('x', titleContainerW / 2)
2325
- .attr('y', sc.sTitleY)
2326
- .attr('text-anchor', 'middle')
2327
- .attr('font-family', FONT_FAMILY)
2328
- .attr('font-size', sc.sTitleFontSize)
2329
- .attr('font-weight', TITLE_FONT_WEIGHT)
2330
- .attr('fill', palette.text)
2331
- .attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
2332
- .text(title!);
2333
- }
2334
-
2335
- const fixedOverheadH =
2336
- (fixedLegend ? legendDynamicH + sc.sLegendFixedGap : 0) + fixedTitleH;
2337
- const rootSvg = d3Selection
2338
- .select(container)
2339
- .append('svg')
2340
- .attr('xmlns', 'http://www.w3.org/2000/svg')
2341
- .attr('width', '100%')
2342
- .attr(
2343
- 'height',
2344
- fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%'
2345
- )
2346
- .attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
2347
- .attr('preserveAspectRatio', 'xMidYMid meet');
2348
-
2349
- if (ctx.isBelowFloor) {
2350
- rootSvg.attr('width', '100%');
2351
- }
2352
-
2353
- if (shouldAnimate) {
2354
- rootSvg.append('style').text(`
2355
- @keyframes infra-pulse-warning {
2356
- 0%, 100% { opacity: 1; }
2357
- 50% { opacity: 0.7; }
2358
- }
2359
- @keyframes infra-pulse-overload {
2360
- 0%, 100% { stroke-width: ${sc.sOverloadStrokeWidth}; }
2361
- 50% { stroke-width: ${sc.sOverloadStrokeWidth + 2}; }
2362
- }
2363
- @keyframes infra-pulse-cb {
2364
- 0%, 49% { stroke-dasharray: none; }
2365
- 50%, 100% { stroke-dasharray: 6 4; }
2366
- }
2367
- .infra-node-warning > rect:first-of-type {
2368
- animation: infra-pulse-warning ${NODE_PULSE_SPEED}s ease-in-out infinite;
2369
- }
2370
- .infra-node-overload > rect:first-of-type {
2371
- animation: infra-pulse-overload ${NODE_PULSE_OVERLOAD}s ease-in-out infinite;
2372
- }
2373
- .infra-node-cb-open > rect:first-of-type {
2374
- stroke-dasharray: 6 4;
2375
- animation: infra-pulse-cb 1s step-end infinite;
2376
- }
2377
- @keyframes infra-edge-throb {
2378
- 0%, 100% { stroke-width: ${sc.sEdgeStrokeWidth}; }
2379
- 50% { stroke-width: ${sc.sEdgeStrokeWidth * 2}; }
2380
- }
2381
- .infra-node-edge-throb > rect:first-of-type {
2382
- animation: infra-edge-throb 2s ease-in-out infinite;
2383
- cursor: pointer;
2384
- }
2385
- .infra-edge-label {
2386
- opacity: 0;
2387
- transition: opacity 0.15s ease;
2388
- pointer-events: all;
2389
- }
2390
- .infra-edge-label:hover {
2391
- opacity: 1;
2392
- }
2393
- `);
2394
- }
2395
-
2396
- const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
2397
- const svg = rootSvg
2398
- .append('g')
2399
- .attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
2400
-
2401
- if (title && !fixedTitleH) {
2402
- rootSvg
2403
- .append('text')
2404
- .attr('class', 'chart-title')
2405
- .attr('x', totalWidth / 2)
2406
- .attr('y', sc.sTitleY)
2407
- .attr('text-anchor', 'middle')
2408
- .attr('font-family', FONT_FAMILY)
2409
- .attr('font-size', sc.sTitleFontSize)
2410
- .attr('font-weight', TITLE_FONT_WEIGHT)
2411
- .attr('fill', palette.text)
2412
- .attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
2413
- .text(title);
2414
- }
2415
-
2416
- renderGroups(svg, layout.groups, palette, isDark, sc);
2417
- const speedMultiplier = playback?.speed ?? 1;
2418
- renderEdgePaths(
2419
- svg,
2420
- layout.edges,
2421
- layout.nodes,
2422
- layout.groups,
2423
- palette,
2424
- isDark,
2425
- shouldAnimate,
2426
- layout.direction,
2427
- speedMultiplier,
2428
- sc
2429
- );
2430
- const fanoutSourceIds = collectFanoutSourceIds([...layout.edges]);
2431
- const scaledGroupIds = new Set<string>(
2432
- layout.groups
2433
- .filter((g) => {
2434
- const gi =
2435
- typeof g.instances === 'number'
2436
- ? g.instances
2437
- : typeof g.instances === 'string'
2438
- ? parseInt(String(g.instances), 10) || 0
2439
- : 0;
2440
- return gi > 1;
2441
- })
2442
- .map((g) => g.id)
2443
- );
2444
- renderNodes(
2445
- svg,
2446
- layout.nodes,
2447
- palette,
2448
- isDark,
2449
- shouldAnimate,
2450
- expandedNodeIds,
2451
- activeGroup,
2452
- layout.options,
2453
- collapsedNodes,
2454
- tagGroups ?? [],
2455
- fanoutSourceIds,
2456
- scaledGroupIds,
2457
- sc
2458
- );
2459
- if (shouldAnimate) {
2460
- renderRejectParticles(svg, layout.nodes, speedMultiplier, sc);
2461
- }
2462
- renderEdgeLabels(
2463
- svg,
2464
- layout.edges,
2465
- layout.nodes,
2466
- layout.groups,
2467
- palette,
2468
- isDark,
2469
- shouldAnimate,
2470
- layout.direction,
2471
- sc
2472
- );
2473
-
2474
- if (hasLegend) {
2475
- if (fixedLegend) {
2476
- const legendContainerW = container.clientWidth || totalWidth;
2477
- const legendSvg = d3Selection
2478
- .select(container)
2479
- .insert('svg', 'svg:last-of-type')
2480
- .attr('class', 'infra-legend-fixed')
2481
- .attr('width', '100%')
2482
- .attr('height', legendDynamicH + sc.sLegendFixedGap)
2483
- .attr(
2484
- 'viewBox',
2485
- `0 0 ${legendContainerW} ${legendDynamicH + sc.sLegendFixedGap}`
2486
- )
2487
- .attr('preserveAspectRatio', 'xMidYMid meet')
2488
- .style('display', 'block')
2489
- .style('pointer-events', 'none');
2490
- renderLegend(
2491
- legendSvg,
2492
- legendGroups,
2493
- legendContainerW,
2494
- sc.sLegendFixedGap / 2,
2495
- palette,
2496
- isDark,
2497
- activeGroup ?? null,
2498
- playback ?? undefined,
2499
- exportMode,
2500
- controlsHost
2501
- );
2502
- // Re-enable pointer events on interactive legend elements
2503
- legendSvg
2504
- .selectAll('.infra-legend-group')
2505
- .style('pointer-events', 'auto');
2506
- } else {
2507
- // Export mode: render legend at top (below title)
2508
- renderLegend(
2509
- rootSvg,
2510
- legendGroups,
2511
- totalWidth,
2512
- titleOffset,
2513
- palette,
2514
- isDark,
2515
- activeGroup ?? null,
2516
- playback ?? undefined,
2517
- exportMode,
2518
- controlsHost
2519
- );
2520
- }
2521
- }
2522
- }
2523
-
2524
- // ============================================================
2525
- // Export pipeline (for CLI / d3.ts integration)
2526
- // ============================================================
2527
-
2528
- export function parseAndLayoutInfra(content: string) {
2529
- const parsed = parseInfra(content);
2530
- if (parsed.error) return { parsed, computed: null, layout: null };
2531
- const computed = computeInfra(parsed);
2532
- const layout = layoutInfra(computed);
2533
- return { parsed, computed, layout };
2534
- }