@diagrammo/dgmo 0.47.0 → 0.48.1

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 (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. 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
- }