@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
package/src/c4/layout.ts DELETED
@@ -1,2441 +0,0 @@
1
- // ============================================================
2
- // C4 Context Diagram Layout Engine (dagre)
3
- // ============================================================
4
-
5
- import { tagAttrKey } from '../utils/tag-groups';
6
- import dagre from '@dagrejs/dagre';
7
- import type {
8
- ParsedC4,
9
- C4Element,
10
- C4Relationship,
11
- C4ArrowType,
12
- C4Shape,
13
- C4DeploymentNode,
14
- } from './types';
15
- import type { TagGroup } from '../utils/tag-groups';
16
- import type { Writable } from '../utils/brand';
17
-
18
- /** Mutable edge variant for in-place position shifts during layout. */
19
- type MutableC4LayoutEdge = Writable<C4LayoutEdge> & {
20
- points: { x: number; y: number }[];
21
- };
22
- import {
23
- LEGEND_PILL_FONT_SIZE,
24
- LEGEND_ENTRY_FONT_SIZE,
25
- LEGEND_HEIGHT,
26
- LEGEND_PILL_PAD,
27
- LEGEND_DOT_R,
28
- LEGEND_ENTRY_DOT_GAP,
29
- LEGEND_ENTRY_TRAIL,
30
- LEGEND_CAPSULE_PAD,
31
- measureLegendText,
32
- } from '../utils/legend-constants';
33
- import { measureText, wrapTextToWidth } from '../utils/text-measure';
34
-
35
- /** dagre node label shape after layout(). */
36
- interface DagreNodeLabel {
37
- x: number;
38
- y: number;
39
- width: number;
40
- height: number;
41
- [key: string]: unknown;
42
- }
43
-
44
- /** dagre edge label shape after layout(). */
45
- interface DagreEdgeLabel {
46
- points: { x: number; y: number }[];
47
- [key: string]: unknown;
48
- }
49
-
50
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
- const gNode = (g: any, name: string): DagreNodeLabel => g.node(name);
52
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- const gEdge = (g: any, v: string, w: string): DagreEdgeLabel | undefined =>
54
- g.edge(v, w);
55
-
56
- // ============================================================
57
- // Types
58
- // ============================================================
59
-
60
- export interface C4LayoutNode {
61
- readonly id: string;
62
- readonly name: string;
63
- readonly type: 'person' | 'system' | 'container' | 'component';
64
- readonly description?: string;
65
- readonly metadata: Readonly<Record<string, string>>;
66
- readonly lineNumber: number;
67
- readonly color?: string;
68
- readonly shape?: C4Shape;
69
- readonly technology?: string;
70
- readonly drillable?: boolean;
71
- readonly importPath?: string;
72
- readonly x: number;
73
- readonly y: number;
74
- readonly width: number;
75
- readonly height: number;
76
- }
77
-
78
- export interface C4LayoutEdge {
79
- readonly source: string;
80
- readonly target: string;
81
- readonly arrowType: C4ArrowType;
82
- readonly label?: string;
83
- readonly technology?: string;
84
- readonly lineNumber: number;
85
- readonly points: ReadonlyArray<{ readonly x: number; readonly y: number }>;
86
- }
87
-
88
- export interface C4LegendEntry {
89
- readonly value: string;
90
- readonly color: string;
91
- }
92
-
93
- export interface C4LegendGroup {
94
- readonly name: string;
95
- readonly entries: readonly C4LegendEntry[];
96
- readonly x: number;
97
- readonly y: number;
98
- readonly width: number;
99
- readonly height: number;
100
- }
101
-
102
- export interface C4LayoutBoundary {
103
- readonly label: string;
104
- readonly typeLabel: string;
105
- readonly lineNumber: number;
106
- readonly x: number;
107
- readonly y: number;
108
- readonly width: number;
109
- readonly height: number;
110
- }
111
-
112
- export interface C4LayoutResult {
113
- readonly nodes: readonly C4LayoutNode[];
114
- readonly edges: readonly C4LayoutEdge[];
115
- readonly legend: readonly C4LegendGroup[];
116
- readonly boundary?: C4LayoutBoundary;
117
- readonly groupBoundaries: readonly C4LayoutBoundary[];
118
- readonly width: number;
119
- readonly height: number;
120
- }
121
-
122
- // ============================================================
123
- // Constants
124
- // ============================================================
125
-
126
- const MIN_NODE_WIDTH = 160;
127
- const MAX_NODE_WIDTH = 260;
128
- const TYPE_LABEL_HEIGHT = 18;
129
- const DIVIDER_GAP = 6;
130
- const NAME_HEIGHT = 20;
131
- const NAME_FONT_SIZE = 14;
132
- const DESC_LINE_HEIGHT = 16;
133
- const DESC_FONT_SIZE = 11;
134
- const CARD_V_PAD = 14;
135
- const CARD_H_PAD = 20;
136
- const META_LINE_HEIGHT = 16;
137
- const META_FONT_SIZE = 11;
138
- const MARGIN = 40;
139
- const BOUNDARY_PAD = 40;
140
- const GROUP_BOUNDARY_PAD = 24;
141
-
142
- // Legend constants (match org)
143
-
144
- // ============================================================
145
- // Post-Layout Crossing Reduction
146
- // ============================================================
147
-
148
- interface NodeGeometry {
149
- y: number;
150
- width: number;
151
- height: number;
152
- }
153
-
154
- // Large penalty per edge-node collision — dominates the distance term so the
155
- // sifter strongly prefers orderings where edges don't pass through other nodes.
156
- const EDGE_NODE_COLLISION_WEIGHT = 5000;
157
-
158
- /**
159
- * Compute penalty for an edge ordering. Uses degree-weighted edge distance:
160
- * long edges to high-degree nodes are penalized more than to low-degree nodes.
161
- * This places shared/important nodes closer to their neighbors, reducing
162
- * visual edge congestion.
163
- *
164
- * When nodeGeometry is provided, also adds a heavy penalty for each case where
165
- * a straight-line edge bounding box overlaps another node — driving the sifter
166
- * to prefer orderings that avoid edge-node collisions.
167
- */
168
- function computeEdgePenalty(
169
- edgeList: { source: string; target: string }[],
170
- nodePositions: Map<string, number>,
171
- degrees: Map<string, number>,
172
- nodeGeometry?: Map<string, NodeGeometry>
173
- ): number {
174
- let penalty = 0;
175
-
176
- // Degree-weighted edge distance: longer edges to higher-degree nodes
177
- // are penalized more, pulling "important" (shared) nodes closer to
178
- // their neighbors.
179
- for (const edge of edgeList) {
180
- const sx = nodePositions.get(edge.source);
181
- const tx = nodePositions.get(edge.target);
182
- if (sx == null || tx == null) continue;
183
- const dist = Math.abs(sx - tx);
184
- const weight = Math.min(
185
- degrees.get(edge.source) ?? 1,
186
- degrees.get(edge.target) ?? 1
187
- );
188
- penalty += dist * weight;
189
- }
190
-
191
- // Edge-node collision penalty: for each edge A→B, check if any other node C
192
- // has its bounding box inside the straight-line bounding box of the edge.
193
- // Uses x from nodePositions (updated per permutation) and y/size from nodeGeometry.
194
- if (nodeGeometry) {
195
- for (const edge of edgeList) {
196
- const geomA = nodeGeometry.get(edge.source);
197
- const geomB = nodeGeometry.get(edge.target);
198
- if (!geomA || !geomB) continue;
199
-
200
- const ax = nodePositions.get(edge.source) ?? 0;
201
- const bx = nodePositions.get(edge.target) ?? 0;
202
- const ay = geomA.y;
203
- const by = geomB.y;
204
-
205
- // Skip edges within the same rank — they have no vertical span to check
206
- if (ay === by) continue;
207
-
208
- const edgeMinX = Math.min(ax, bx);
209
- const edgeMaxX = Math.max(ax, bx);
210
- const edgeMinY = Math.min(ay, by);
211
- const edgeMaxY = Math.max(ay, by);
212
-
213
- for (const [name, geomC] of nodeGeometry) {
214
- if (name === edge.source || name === edge.target) continue;
215
- const cx = nodePositions.get(name) ?? 0;
216
- const cy = geomC.y;
217
- const hw = geomC.width / 2;
218
- const hh = geomC.height / 2;
219
-
220
- // AABB overlap: node C's box intersects the edge's straight-line bounding box
221
- if (
222
- cx + hw > edgeMinX &&
223
- cx - hw < edgeMaxX &&
224
- cy + hh > edgeMinY &&
225
- cy - hh < edgeMaxY
226
- ) {
227
- penalty += EDGE_NODE_COLLISION_WEIGHT;
228
- }
229
- }
230
- }
231
- }
232
-
233
- return penalty;
234
- }
235
-
236
- /**
237
- * Post-dagre crossing reduction via permutation search.
238
- *
239
- * For each rank, tries all permutations (up to rank size 8) to find the
240
- * node ordering that minimizes degree-weighted edge distance. Nodes with
241
- * more connections (like a database shared by multiple services) get placed
242
- * closer to their neighbors, producing cleaner visual layouts.
243
- */
244
- function reduceCrossings(
245
- g: InstanceType<typeof dagre.graphlib.Graph>,
246
- edgeList: { source: string; target: string }[],
247
- nodeGroupMap?: Map<string, string>
248
- ): void {
249
- if (edgeList.length < 2) return;
250
-
251
- // Compute degree (number of edges) for each node
252
- const degrees = new Map<string, number>();
253
- for (const edge of edgeList) {
254
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1);
255
- degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1);
256
- }
257
-
258
- // Build geometry map for edge-node collision scoring
259
- const nodeGeometry = new Map<string, NodeGeometry>();
260
- for (const name of g.nodes()) {
261
- const pos = gNode(g, name);
262
- if (pos)
263
- nodeGeometry.set(name, {
264
- y: pos.y,
265
- width: pos.width,
266
- height: pos.height,
267
- });
268
- }
269
-
270
- // Group nodes by rank
271
- const rankMap = new Map<number, string[]>();
272
- for (const name of g.nodes()) {
273
- const pos = gNode(g, name);
274
- if (!pos) continue;
275
- const rankY = Math.round(pos.y);
276
- if (!rankMap.has(rankY)) rankMap.set(rankY, []);
277
- rankMap.get(rankY)!.push(name);
278
- }
279
-
280
- // Sort each rank by current x position
281
- for (const [, rankNodes] of rankMap) {
282
- rankNodes.sort((a, b) => gNode(g, a).x - gNode(g, b).x);
283
- }
284
-
285
- let anyMoved = false;
286
-
287
- for (const [, rankNodes] of rankMap) {
288
- if (rankNodes.length < 2) continue;
289
-
290
- // When groups exist, partition rank nodes by group and only permute within groups
291
- const partitions: string[][] = [];
292
- if (nodeGroupMap && nodeGroupMap.size > 0) {
293
- const groupBuckets = new Map<string, string[]>();
294
- const ungrouped: string[] = [];
295
- for (const name of rankNodes) {
296
- const grp = nodeGroupMap.get(name);
297
- if (grp) {
298
- if (!groupBuckets.has(grp)) groupBuckets.set(grp, []);
299
- groupBuckets.get(grp)!.push(name);
300
- } else {
301
- ungrouped.push(name);
302
- }
303
- }
304
- for (const bucket of groupBuckets.values()) {
305
- if (bucket.length >= 2) partitions.push(bucket);
306
- }
307
- if (ungrouped.length >= 2) partitions.push(ungrouped);
308
- } else {
309
- partitions.push(rankNodes);
310
- }
311
-
312
- for (const partition of partitions) {
313
- if (partition.length < 2) continue;
314
-
315
- // Collect the x-slots for this partition (sorted)
316
- const xSlots = partition
317
- .map((name) => gNode(g, name).x)
318
- .sort((a, b) => a - b);
319
-
320
- // Build position map snapshot
321
- const basePositions = new Map<string, number>();
322
- for (const name of g.nodes()) {
323
- const pos = gNode(g, name);
324
- if (pos) basePositions.set(name, pos.x);
325
- }
326
-
327
- // Current penalty
328
- const currentPenalty = computeEdgePenalty(
329
- edgeList,
330
- basePositions,
331
- degrees,
332
- nodeGeometry
333
- );
334
-
335
- // Try permutations (feasible for partition sizes ≤ 8)
336
- let bestPerm = [...partition];
337
- let bestPenalty = currentPenalty;
338
-
339
- if (partition.length <= 8) {
340
- const perms = permutations(partition);
341
- for (const perm of perms) {
342
- const testPositions = new Map(basePositions);
343
- for (let i = 0; i < perm.length; i++) {
344
- testPositions.set(perm[i]!, xSlots[i]!);
345
- }
346
- const penalty = computeEdgePenalty(
347
- edgeList,
348
- testPositions,
349
- degrees,
350
- nodeGeometry
351
- );
352
- if (penalty < bestPenalty) {
353
- bestPenalty = penalty;
354
- bestPerm = [...perm];
355
- }
356
- }
357
- } else {
358
- // For large partitions, use adjacent swap
359
- const workingOrder = [...partition];
360
- let improved = true;
361
- let passes = 0;
362
- while (improved && passes < 10) {
363
- improved = false;
364
- passes++;
365
- for (let i = 0; i < workingOrder.length - 1; i++) {
366
- const testPositions = new Map(basePositions);
367
- for (let k = 0; k < workingOrder.length; k++) {
368
- testPositions.set(workingOrder[k]!, xSlots[k]!);
369
- }
370
- const before = computeEdgePenalty(
371
- edgeList,
372
- testPositions,
373
- degrees,
374
- nodeGeometry
375
- );
376
-
377
- [workingOrder[i], workingOrder[i + 1]] = [
378
- workingOrder[i + 1]!,
379
- workingOrder[i]!,
380
- ];
381
- const testPositions2 = new Map(basePositions);
382
- for (let k = 0; k < workingOrder.length; k++) {
383
- testPositions2.set(workingOrder[k]!, xSlots[k]!);
384
- }
385
- const after = computeEdgePenalty(
386
- edgeList,
387
- testPositions2,
388
- degrees,
389
- nodeGeometry
390
- );
391
-
392
- if (after < before) {
393
- improved = true;
394
- if (after < bestPenalty) {
395
- bestPenalty = after;
396
- bestPerm = [...workingOrder];
397
- }
398
- } else {
399
- [workingOrder[i], workingOrder[i + 1]] = [
400
- workingOrder[i + 1]!,
401
- workingOrder[i]!,
402
- ];
403
- }
404
- }
405
- }
406
- }
407
-
408
- // Apply best permutation if it differs from current
409
- if (bestPerm.some((name, i) => name !== partition[i])) {
410
- for (let i = 0; i < bestPerm.length; i++) {
411
- gNode(g, bestPerm[i]!).x = xSlots[i]!;
412
- // Update in the original rankNodes too
413
- const rankIdx = rankNodes.indexOf(partition[i]!);
414
- if (rankIdx >= 0) rankNodes[rankIdx] = bestPerm[i]!;
415
- }
416
- anyMoved = true;
417
- }
418
- }
419
- }
420
-
421
- // Recompute edge waypoints if any positions changed
422
- if (anyMoved) {
423
- for (const edge of edgeList) {
424
- const edgeData = gEdge(g, edge.source, edge.target);
425
- if (!edgeData) continue;
426
- const srcPos = gNode(g, edge.source);
427
- const tgtPos = gNode(g, edge.target);
428
- if (!srcPos || !tgtPos) continue;
429
-
430
- const srcBottom = { x: srcPos.x, y: srcPos.y + srcPos.height / 2 };
431
- const tgtTop = { x: tgtPos.x, y: tgtPos.y - tgtPos.height / 2 };
432
- const midY = (srcBottom.y + tgtTop.y) / 2;
433
-
434
- edgeData.points = [
435
- srcBottom,
436
- { x: srcBottom.x, y: midY },
437
- { x: tgtTop.x, y: midY },
438
- tgtTop,
439
- ];
440
- }
441
- }
442
- }
443
-
444
- /** Generate all permutations of an array (Heap's algorithm). */
445
- function permutations<T>(arr: T[]): T[][] {
446
- const result: T[][] = [];
447
- const a = [...arr];
448
- const n = a.length;
449
- const c = new Array(n).fill(0) as number[];
450
-
451
- result.push([...a]);
452
-
453
- let i = 0;
454
- while (i < n) {
455
- if (c[i]! < i) {
456
- if (i % 2 === 0) {
457
- [a[0], a[i]] = [a[i]!, a[0]!];
458
- } else {
459
- [a[c[i]!], a[i]] = [a[i]!, a[c[i]!]!];
460
- }
461
- result.push([...a]);
462
- c[i]!++;
463
- i = 0;
464
- } else {
465
- c[i] = 0;
466
- i++;
467
- }
468
- }
469
-
470
- return result;
471
- }
472
-
473
- // ============================================================
474
- // Roll-Up Logic
475
- // ============================================================
476
-
477
- export interface ContextRelationship {
478
- sourceName: string;
479
- targetName: string;
480
- label?: string;
481
- technology?: string;
482
- arrowType: C4ArrowType;
483
- lineNumber: number;
484
- }
485
-
486
- /**
487
- * Build a map from element name → top-level ancestor name.
488
- * Top-level elements map to themselves.
489
- */
490
- function buildOwnershipMap(
491
- elements: readonly C4Element[]
492
- ): Map<string, string> {
493
- const map = new Map<string, string>();
494
-
495
- function walk(el: C4Element, ancestor: string): void {
496
- map.set(el.name, ancestor);
497
- for (const child of el.children) {
498
- walk(child, ancestor);
499
- }
500
- for (const group of el.groups) {
501
- for (const child of group.children) {
502
- walk(child, ancestor);
503
- }
504
- }
505
- }
506
-
507
- for (const el of elements) {
508
- walk(el, el.name);
509
- }
510
-
511
- return map;
512
- }
513
-
514
- /**
515
- * Collect all relationships from the entire element tree.
516
- */
517
- function collectAllRelationships(
518
- elements: readonly C4Element[],
519
- _ownerMap: Map<string, string>
520
- ): { sourceName: string; rel: C4Relationship }[] {
521
- const result: { sourceName: string; rel: C4Relationship }[] = [];
522
-
523
- function walk(el: C4Element): void {
524
- for (const rel of el.relationships) {
525
- result.push({ sourceName: el.name, rel });
526
- }
527
- for (const child of el.children) {
528
- walk(child);
529
- }
530
- for (const group of el.groups) {
531
- for (const child of group.children) {
532
- walk(child);
533
- }
534
- }
535
- }
536
-
537
- for (const el of elements) {
538
- walk(el);
539
- }
540
-
541
- return result;
542
- }
543
-
544
- /**
545
- * Roll up container/component-level relationships to system-to-system edges.
546
- * - Skips internal relationships (same top-level ancestor).
547
- * - Deduplicates: same source→target pair keeps only one (first seen).
548
- * - Explicit system-level relationships override rolled-up ones.
549
- */
550
- export function rollUpContextRelationships(
551
- parsed: ParsedC4
552
- ): ContextRelationship[] {
553
- const ownerMap = buildOwnershipMap(parsed.elements);
554
- const allRels = collectAllRelationships(parsed.elements, ownerMap);
555
-
556
- // Also include orphan relationships
557
- // Separate system-level (explicit) from nested (rolled-up)
558
- const topLevelNames = new Set(parsed.elements.map((e) => e.name));
559
- const explicitKeys = new Set<string>();
560
- const explicit: ContextRelationship[] = [];
561
- const nested: ContextRelationship[] = [];
562
-
563
- for (const { sourceName, rel } of allRels) {
564
- const sourceAncestor = ownerMap.get(sourceName) ?? sourceName;
565
- const targetAncestor = ownerMap.get(rel.target) ?? rel.target;
566
-
567
- // Skip internal relationships (both in same system)
568
- if (sourceAncestor === targetAncestor) continue;
569
-
570
- const entry: ContextRelationship = {
571
- sourceName: sourceAncestor,
572
- targetName: targetAncestor,
573
- ...(rel.label !== undefined && { label: rel.label }),
574
- ...(rel.technology !== undefined && { technology: rel.technology }),
575
- arrowType: rel.arrowType,
576
- lineNumber: rel.lineNumber,
577
- };
578
-
579
- // Check if source is a top-level element (explicit system-level rel)
580
- if (topLevelNames.has(sourceName) && sourceName === sourceAncestor) {
581
- const key = `${sourceAncestor}→${targetAncestor}`;
582
- explicitKeys.add(key);
583
- explicit.push(entry);
584
- } else {
585
- nested.push(entry);
586
- }
587
- }
588
-
589
- // Deduplicate: explicit overrides rolled-up
590
- const result = [...explicit];
591
- const seenKeys = new Set(explicitKeys);
592
-
593
- for (const rel of nested) {
594
- const key = `${rel.sourceName}→${rel.targetName}`;
595
- if (!seenKeys.has(key)) {
596
- seenKeys.add(key);
597
- result.push(rel);
598
- }
599
- }
600
-
601
- return result;
602
- }
603
-
604
- // ============================================================
605
- // Tag Group Color Resolution
606
- // ============================================================
607
-
608
- function resolveNodeColor(
609
- el: C4Element,
610
- tagGroups: readonly TagGroup[],
611
- activeGroupName: string | null,
612
- ancestors?: readonly C4Element[]
613
- ): string | undefined {
614
- // Check metadata for explicit color
615
- const colorMeta = el.metadata['color'];
616
- if (colorMeta) return colorMeta;
617
- if (!activeGroupName) return undefined;
618
-
619
- const group = tagGroups.find(
620
- (g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
621
- );
622
- if (!group) return undefined;
623
- const key = tagAttrKey(group.name);
624
- // Walk inheritance chain: element → ancestors (container → system)
625
- let metaValue: string | undefined = el.metadata[key];
626
- if (!metaValue && ancestors) {
627
- for (const ancestor of ancestors) {
628
- metaValue = ancestor.metadata[key];
629
- if (metaValue) break;
630
- }
631
- }
632
- const resolvedValue = metaValue ?? group.defaultValue;
633
- if (!resolvedValue) return '#999999';
634
- return (
635
- group.entries.find(
636
- (e) => e.value.toLowerCase() === resolvedValue.toLowerCase()
637
- )?.color ?? '#999999'
638
- );
639
- }
640
-
641
- // ============================================================
642
- // Node Sizing
643
- // ============================================================
644
-
645
- /** Keys to exclude from the below-divider metadata display. */
646
- const META_EXCLUDE_KEYS = new Set([
647
- 'description',
648
- 'tech',
649
- 'technology',
650
- 'is a',
651
- ]);
652
-
653
- /** Collect displayable metadata entries for a container card. */
654
- export function collectCardMetadata(
655
- metadata: Record<string, string>
656
- ): { key: string; value: string }[] {
657
- const entries: { key: string; value: string }[] = [];
658
- // Technology first
659
- const tech = metadata['tech'] ?? metadata['technology'];
660
- if (tech) entries.push({ key: 'Technology', value: tech });
661
- // Then other metadata (tag groups, etc.)
662
- for (const [k, v] of Object.entries(metadata)) {
663
- if (META_EXCLUDE_KEYS.has(k.toLowerCase())) continue;
664
- // Capitalize key for display
665
- const displayKey = k.charAt(0).toUpperCase() + k.slice(1);
666
- entries.push({ key: displayKey, value: v });
667
- }
668
- return entries;
669
- }
670
-
671
- export function computeC4NodeDimensions(
672
- el: C4Element,
673
- options?: { showTechnology?: boolean }
674
- ): { width: number; height: number } {
675
- // Width: based on name length, clamped
676
- const nameWidth = measureText(el.name, NAME_FONT_SIZE) + CARD_H_PAD * 2;
677
- let width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, nameWidth));
678
-
679
- if (options?.showTechnology) {
680
- // Container card layout: name + description | divider | metadata rows
681
- // (no type label — containers are the default in container view)
682
- let height = CARD_V_PAD + NAME_HEIGHT;
683
-
684
- const desc = el.description?.join('\n');
685
- if (desc) {
686
- const contentWidth = width - CARD_H_PAD * 2;
687
- const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
688
- height += lines.length * DESC_LINE_HEIGHT;
689
- }
690
-
691
- // Metadata rows below divider
692
- const metaEntries = collectCardMetadata(el.metadata);
693
- if (metaEntries.length > 0) {
694
- height += DIVIDER_GAP; // divider
695
- height += metaEntries.length * META_LINE_HEIGHT;
696
- // Widen card if metadata rows need more space
697
- const maxMetaWidth = Math.max(
698
- ...metaEntries.map(
699
- (e) =>
700
- measureText(`${e.key}: ${e.value}`, META_FONT_SIZE) + CARD_H_PAD * 2
701
- )
702
- );
703
- if (maxMetaWidth > width) width = Math.min(MAX_NODE_WIDTH, maxMetaWidth);
704
- }
705
-
706
- height += CARD_V_PAD;
707
- return { width, height };
708
- }
709
-
710
- // Context card layout: type + name | divider | description
711
- let height = CARD_V_PAD + TYPE_LABEL_HEIGHT + DIVIDER_GAP + NAME_HEIGHT;
712
-
713
- const desc = el.description?.join('\n');
714
- if (desc) {
715
- const contentWidth = width - CARD_H_PAD * 2;
716
- const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
717
- height += lines.length * DESC_LINE_HEIGHT;
718
- }
719
-
720
- height += CARD_V_PAD;
721
-
722
- return { width, height };
723
- }
724
-
725
- // ============================================================
726
- // Legend Helpers
727
- // ============================================================
728
-
729
- function computeLegendGroups(
730
- tagGroups: readonly TagGroup[]
731
- ): Writable<C4LegendGroup>[] {
732
- const result: Writable<C4LegendGroup>[] = [];
733
-
734
- for (const group of tagGroups) {
735
- const entries: C4LegendEntry[] = [];
736
- for (const entry of group.entries) {
737
- entries.push({ value: entry.value, color: entry.color });
738
- }
739
- if (entries.length === 0) continue;
740
-
741
- // Compute pill width: group name + entries
742
- const nameW =
743
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
744
- LEGEND_PILL_PAD * 2;
745
- let capsuleW = LEGEND_CAPSULE_PAD;
746
- for (const e of entries) {
747
- capsuleW +=
748
- LEGEND_DOT_R * 2 +
749
- LEGEND_ENTRY_DOT_GAP +
750
- measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
751
- LEGEND_ENTRY_TRAIL;
752
- }
753
- capsuleW += LEGEND_CAPSULE_PAD;
754
-
755
- result.push({
756
- name: group.name,
757
- entries,
758
- x: 0,
759
- y: 0,
760
- width: nameW + capsuleW,
761
- height: LEGEND_HEIGHT,
762
- });
763
- }
764
-
765
- return result;
766
- }
767
-
768
- // ============================================================
769
- // Adaptive Spacing
770
- // ============================================================
771
-
772
- /**
773
- * Compute dagre graph spacing based on edge density.
774
- * Nodes with high fan-out (many labeled edges) need more inter-rank
775
- * space so labels don't overlap. Returns ranksep and edgesep.
776
- */
777
- function computeAdaptiveSpacing(
778
- edges: { sourceName: string; label?: string; technology?: string }[]
779
- ): { nodesep: number; ranksep: number; edgesep: number } {
780
- // Count max labeled out-degree for any single source node
781
- const outDegree = new Map<string, number>();
782
- for (const edge of edges) {
783
- if (edge.label || edge.technology) {
784
- outDegree.set(edge.sourceName, (outDegree.get(edge.sourceName) ?? 0) + 1);
785
- }
786
- }
787
- const maxFanOut = Math.max(0, ...outDegree.values());
788
-
789
- // Scale spacing: each additional fan-out edge needs more room for its label.
790
- // nodesep: wider horizontal gaps give fan-out edges distinct angles,
791
- // making it clear which label belongs to which line.
792
- const nodesep = Math.max(80, 60 + maxFanOut * 20);
793
- const ranksep = Math.max(140, 100 + maxFanOut * 35);
794
- const edgesep = Math.max(30, 20 + maxFanOut * 8);
795
-
796
- return { nodesep, ranksep, edgesep };
797
- }
798
-
799
- // ============================================================
800
- // Main Layout
801
- // ============================================================
802
-
803
- export function layoutC4Context(
804
- parsed: ParsedC4,
805
- activeTagGroup?: string | null
806
- ): C4LayoutResult {
807
- // Filter to person + system elements only
808
- const contextElements = parsed.elements.filter(
809
- (el) => el.type === 'person' || el.type === 'system'
810
- );
811
-
812
- if (contextElements.length === 0) {
813
- return {
814
- nodes: [],
815
- edges: [],
816
- legend: [],
817
- groupBoundaries: [],
818
- width: 0,
819
- height: 0,
820
- };
821
- }
822
-
823
- // Roll up relationships
824
- const contextRels = rollUpContextRelationships(parsed);
825
-
826
- // Compute adaptive spacing based on edge density
827
- const spacing = computeAdaptiveSpacing(contextRels);
828
-
829
- // Create dagre graph
830
- const g = new dagre.graphlib.Graph();
831
- g.setGraph({
832
- rankdir: 'TB',
833
- nodesep: spacing.nodesep,
834
- ranksep: spacing.ranksep,
835
- edgesep: spacing.edgesep,
836
- });
837
- g.setDefaultEdgeLabel(() => ({}));
838
-
839
- // Add nodes
840
- const nameToElement = new Map<string, C4Element>();
841
- for (const el of contextElements) {
842
- nameToElement.set(el.name, el);
843
- const dims = computeC4NodeDimensions(el);
844
- g.setNode(el.name, { width: dims.width, height: dims.height });
845
- }
846
-
847
- // Add edges — only between known nodes
848
- const validRels: ContextRelationship[] = [];
849
- for (const rel of contextRels) {
850
- if (
851
- nameToElement.has(rel.sourceName) &&
852
- nameToElement.has(rel.targetName)
853
- ) {
854
- validRels.push(rel);
855
- g.setEdge(rel.sourceName, rel.targetName, { label: rel.label ?? '' });
856
- }
857
- }
858
-
859
- // Run layout
860
- dagre.layout(g);
861
-
862
- // Post-dagre crossing reduction
863
- reduceCrossings(
864
- g,
865
- validRels.map((r) => ({ source: r.sourceName, target: r.targetName }))
866
- );
867
-
868
- // Extract positioned nodes
869
- const nodes: Writable<C4LayoutNode>[] = contextElements.map(
870
- (el): Writable<C4LayoutNode> => {
871
- const pos = g.node(el.name);
872
- const color = resolveNodeColor(
873
- el,
874
- parsed.tagGroups,
875
- activeTagGroup ?? null
876
- );
877
- const hasContainers =
878
- el.children.some((c) => c.type === 'container') ||
879
- el.groups.some((g) => g.children.some((c) => c.type === 'container'));
880
- const description = el.description?.join('\n');
881
- const drillable = hasContainers || el.importPath ? true : undefined;
882
- return {
883
- id: el.name,
884
- name: el.name,
885
- type: el.type as 'person' | 'system',
886
- ...(description !== undefined && { description }),
887
- metadata: el.metadata,
888
- lineNumber: el.lineNumber,
889
- ...(color !== undefined && { color }),
890
- ...(drillable !== undefined && { drillable }),
891
- ...(el.importPath !== undefined && { importPath: el.importPath }),
892
- x: pos.x,
893
- y: pos.y,
894
- width: pos.width,
895
- height: pos.height,
896
- };
897
- }
898
- );
899
-
900
- // Extract edges with waypoints
901
- const edges: MutableC4LayoutEdge[] = validRels.map(
902
- (rel): MutableC4LayoutEdge => {
903
- const edgeData = g.edge(rel.sourceName, rel.targetName);
904
- return {
905
- source: rel.sourceName,
906
- target: rel.targetName,
907
- arrowType: rel.arrowType,
908
- ...(rel.label !== undefined && { label: rel.label }),
909
- ...(rel.technology !== undefined && { technology: rel.technology }),
910
- lineNumber: rel.lineNumber,
911
- points: edgeData?.points ?? [],
912
- };
913
- }
914
- );
915
-
916
- // Compute bounding box of all content (nodes + edge points)
917
- let minX = Infinity,
918
- minY = Infinity,
919
- maxX = -Infinity,
920
- maxY = -Infinity;
921
- for (const node of nodes) {
922
- const left = node.x - node.width / 2;
923
- const top = node.y - node.height / 2;
924
- const right = node.x + node.width / 2;
925
- const bottom = node.y + node.height / 2;
926
- if (left < minX) minX = left;
927
- if (top < minY) minY = top;
928
- if (right > maxX) maxX = right;
929
- if (bottom > maxY) maxY = bottom;
930
- }
931
- for (const edge of edges) {
932
- for (const pt of edge.points) {
933
- if (pt.x < minX) minX = pt.x;
934
- if (pt.y < minY) minY = pt.y;
935
- if (pt.x > maxX) maxX = pt.x;
936
- if (pt.y > maxY) maxY = pt.y;
937
- }
938
- }
939
-
940
- // Shift everything so content starts at (MARGIN, MARGIN)
941
- if (nodes.length > 0) {
942
- const shiftX = MARGIN - minX;
943
- const shiftY = MARGIN - minY;
944
- for (const node of nodes) {
945
- node.x += shiftX;
946
- node.y += shiftY;
947
- }
948
- for (const edge of edges) {
949
- for (const pt of edge.points) {
950
- pt.x += shiftX;
951
- pt.y += shiftY;
952
- }
953
- }
954
- }
955
-
956
- let totalWidth = nodes.length > 0 ? maxX - minX + MARGIN * 2 : 0;
957
- let totalHeight = nodes.length > 0 ? maxY - minY + MARGIN * 2 : 0;
958
-
959
- // Legend: show all defined tag groups and entries so users see the full
960
- // tag vocabulary regardless of which elements are visible at this view level.
961
- const legendGroups = computeLegendGroups(parsed.tagGroups);
962
-
963
- // Position legend below diagram
964
- if (legendGroups.length > 0) {
965
- const legendY = totalHeight + MARGIN;
966
- let legendX = MARGIN;
967
- for (const lg of legendGroups) {
968
- lg.x = legendX;
969
- lg.y = legendY;
970
- legendX += lg.width + 12;
971
- }
972
- const legendRight = legendX;
973
- const legendBottom = legendY + LEGEND_HEIGHT;
974
- if (legendRight > totalWidth) totalWidth = legendRight;
975
- if (legendBottom > totalHeight) totalHeight = legendBottom;
976
- }
977
-
978
- return {
979
- nodes,
980
- edges,
981
- legend: legendGroups,
982
- groupBoundaries: [],
983
- width: totalWidth,
984
- height: totalHeight,
985
- };
986
- }
987
-
988
- // ============================================================
989
- // Container-Level Layout
990
- // ============================================================
991
-
992
- /**
993
- * Layout containers within a specific system, plus external elements
994
- * that have relationships with those containers.
995
- */
996
- export function layoutC4Containers(
997
- parsed: ParsedC4,
998
- systemName: string,
999
- activeTagGroup?: string | null
1000
- ): C4LayoutResult {
1001
- // Find the system element by name
1002
- const system = parsed.elements.find(
1003
- (el) => el.name.toLowerCase() === systemName.toLowerCase()
1004
- );
1005
- if (!system) {
1006
- return {
1007
- nodes: [],
1008
- edges: [],
1009
- legend: [],
1010
- groupBoundaries: [],
1011
- width: 0,
1012
- height: 0,
1013
- };
1014
- }
1015
-
1016
- // Collect all containers: direct children + group children
1017
- const containers: C4Element[] = [];
1018
- for (const child of system.children) {
1019
- if (child.type === 'container') containers.push(child);
1020
- }
1021
- for (const group of system.groups) {
1022
- for (const child of group.children) {
1023
- if (child.type === 'container') containers.push(child);
1024
- }
1025
- }
1026
-
1027
- if (containers.length === 0) {
1028
- return {
1029
- nodes: [],
1030
- edges: [],
1031
- legend: [],
1032
- groupBoundaries: [],
1033
- width: 0,
1034
- height: 0,
1035
- };
1036
- }
1037
-
1038
- const containerNames = new Set(containers.map((c) => c.name.toLowerCase()));
1039
-
1040
- // Build name → element map for all top-level elements
1041
- const topElementsByName = new Map<string, C4Element>();
1042
- for (const el of parsed.elements) {
1043
- topElementsByName.set(el.name.toLowerCase(), el);
1044
- }
1045
-
1046
- // Identify external elements: targets of container relationships that aren't
1047
- // in this system, OR top-level elements that target containers in this system
1048
- const externalNames = new Set<string>();
1049
-
1050
- // Forward: container → target outside this system
1051
- for (const container of containers) {
1052
- for (const rel of container.relationships) {
1053
- const targetLower = rel.target.toLowerCase();
1054
- if (!containerNames.has(targetLower)) {
1055
- externalNames.add(targetLower);
1056
- }
1057
- }
1058
- }
1059
-
1060
- // Reverse: top-level elements (and their children) that target containers in this system
1061
- const ownerMap = buildOwnershipMap(parsed.elements);
1062
- const allRels = collectAllRelationships(parsed.elements, ownerMap);
1063
- for (const { sourceName, rel } of allRels) {
1064
- const sourceAncestor = ownerMap.get(sourceName) ?? sourceName;
1065
- // Skip relationships from within this system
1066
- if (sourceAncestor.toLowerCase() === system.name.toLowerCase()) continue;
1067
- // Check if target is a container in this system
1068
- if (containerNames.has(rel.target.toLowerCase())) {
1069
- externalNames.add(sourceAncestor.toLowerCase());
1070
- }
1071
- }
1072
-
1073
- // Resolve external elements
1074
- const externals: C4Element[] = [];
1075
- for (const name of externalNames) {
1076
- const el = topElementsByName.get(name);
1077
- if (el) externals.push(el);
1078
- }
1079
-
1080
- // Build element-to-group mapping for compound graph
1081
- const elementToGroup = new Map<
1082
- string,
1083
- { name: string; lineNumber: number }
1084
- >();
1085
- for (const group of system.groups) {
1086
- for (const child of group.children) {
1087
- if (child.type === 'container') {
1088
- elementToGroup.set(child.name, {
1089
- name: group.name,
1090
- lineNumber: group.lineNumber,
1091
- });
1092
- }
1093
- }
1094
- }
1095
- const hasGroups = elementToGroup.size > 0;
1096
-
1097
- // Create dagre graph — use compound when groups exist
1098
- const g = hasGroups
1099
- ? new dagre.graphlib.Graph({ compound: true })
1100
- : new dagre.graphlib.Graph();
1101
- g.setDefaultEdgeLabel(() => ({}));
1102
-
1103
- // Add virtual group parent nodes
1104
- if (hasGroups) {
1105
- const seenGroups = new Set<string>();
1106
- for (const { name } of elementToGroup.values()) {
1107
- if (!seenGroups.has(name)) {
1108
- seenGroups.add(name);
1109
- g.setNode('__group_' + name, {});
1110
- }
1111
- }
1112
- }
1113
-
1114
- // Add container nodes
1115
- const nameToElement = new Map<string, C4Element>();
1116
- for (const el of containers) {
1117
- nameToElement.set(el.name, el);
1118
- const dims = computeC4NodeDimensions(el, { showTechnology: true });
1119
- g.setNode(el.name, { width: dims.width, height: dims.height });
1120
- // Set group parent
1121
- const grp = elementToGroup.get(el.name);
1122
- if (grp) {
1123
- g.setParent(el.name, '__group_' + grp.name);
1124
- }
1125
- }
1126
-
1127
- // Add external nodes
1128
- for (const el of externals) {
1129
- nameToElement.set(el.name, el);
1130
- const dims = computeC4NodeDimensions(el);
1131
- g.setNode(el.name, { width: dims.width, height: dims.height });
1132
- }
1133
-
1134
- // Collect container-level relationships (not rolled up)
1135
- interface ContainerRel {
1136
- sourceName: string;
1137
- targetName: string;
1138
- label?: string;
1139
- technology?: string;
1140
- arrowType: C4ArrowType;
1141
- lineNumber: number;
1142
- }
1143
-
1144
- const containerRels: ContainerRel[] = [];
1145
- const seenEdgeKeys = new Set<string>();
1146
-
1147
- // Forward: container → target
1148
- for (const container of containers) {
1149
- for (const rel of container.relationships) {
1150
- const targetEl = nameToElement.get(rel.target);
1151
- if (!targetEl) continue;
1152
- const key = `${container.name}→${rel.target}`;
1153
- if (!seenEdgeKeys.has(key)) {
1154
- seenEdgeKeys.add(key);
1155
- containerRels.push({
1156
- sourceName: container.name,
1157
- targetName: rel.target,
1158
- ...(rel.label !== undefined && { label: rel.label }),
1159
- ...(rel.technology !== undefined && { technology: rel.technology }),
1160
- arrowType: rel.arrowType,
1161
- lineNumber: rel.lineNumber,
1162
- });
1163
- }
1164
- }
1165
- }
1166
-
1167
- // Reverse: external → container (find specific container-level relationships)
1168
- for (const { sourceName, rel } of allRels) {
1169
- const sourceAncestor = ownerMap.get(sourceName) ?? sourceName;
1170
- if (sourceAncestor.toLowerCase() === system.name.toLowerCase()) continue;
1171
- if (containerNames.has(rel.target.toLowerCase())) {
1172
- const externalEl = nameToElement.get(sourceAncestor);
1173
- if (!externalEl) continue;
1174
- const key = `${sourceAncestor}→${rel.target}`;
1175
- if (!seenEdgeKeys.has(key)) {
1176
- seenEdgeKeys.add(key);
1177
- containerRels.push({
1178
- sourceName: sourceAncestor,
1179
- targetName: rel.target,
1180
- ...(rel.label !== undefined && { label: rel.label }),
1181
- ...(rel.technology !== undefined && { technology: rel.technology }),
1182
- arrowType: rel.arrowType,
1183
- lineNumber: rel.lineNumber,
1184
- });
1185
- }
1186
- }
1187
- }
1188
-
1189
- // Compute adaptive spacing based on edge fan-out
1190
- const spacing = computeAdaptiveSpacing(containerRels);
1191
- g.setGraph({
1192
- rankdir: 'TB',
1193
- nodesep: spacing.nodesep,
1194
- ranksep: spacing.ranksep,
1195
- edgesep: spacing.edgesep,
1196
- });
1197
-
1198
- // Add edges to dagre
1199
- for (const rel of containerRels) {
1200
- if (
1201
- nameToElement.has(rel.sourceName) &&
1202
- nameToElement.has(rel.targetName)
1203
- ) {
1204
- g.setEdge(rel.sourceName, rel.targetName, { label: rel.label ?? '' });
1205
- }
1206
- }
1207
-
1208
- // Run layout
1209
- dagre.layout(g);
1210
-
1211
- // Post-dagre crossing reduction (with group-aware partitioning)
1212
- const nodeGroupMap = hasGroups
1213
- ? new Map([...elementToGroup.entries()].map(([k, v]) => [k, v.name]))
1214
- : undefined;
1215
- reduceCrossings(
1216
- g,
1217
- containerRels
1218
- .filter(
1219
- (r) =>
1220
- nameToElement.has(r.sourceName) && nameToElement.has(r.targetName)
1221
- )
1222
- .map((r) => ({ source: r.sourceName, target: r.targetName })),
1223
- nodeGroupMap
1224
- );
1225
-
1226
- // Extract positioned nodes
1227
- const nodes: Writable<C4LayoutNode>[] = [];
1228
- for (const el of containers) {
1229
- const pos = g.node(el.name);
1230
- const color = resolveNodeColor(
1231
- el,
1232
- parsed.tagGroups,
1233
- activeTagGroup ?? null
1234
- );
1235
- const tech = el.metadata['tech'] ?? el.metadata['technology'];
1236
- const hasComponents =
1237
- el.children.some((c) => c.type === 'component') ||
1238
- el.groups.some((grp) => grp.children.some((c) => c.type === 'component'));
1239
- const description = el.description?.join('\n');
1240
- const drillable = hasComponents || el.importPath ? true : undefined;
1241
- nodes.push({
1242
- id: el.name,
1243
- name: el.name,
1244
- type: 'container',
1245
- ...(description !== undefined && { description }),
1246
- metadata: el.metadata,
1247
- lineNumber: el.lineNumber,
1248
- ...(color !== undefined && { color }),
1249
- shape: el.shape,
1250
- ...(tech !== undefined && { technology: tech }),
1251
- ...(drillable !== undefined && { drillable }),
1252
- ...(el.importPath !== undefined && { importPath: el.importPath }),
1253
- x: pos.x,
1254
- y: pos.y,
1255
- width: pos.width,
1256
- height: pos.height,
1257
- });
1258
- }
1259
-
1260
- for (const el of externals) {
1261
- const pos = g.node(el.name);
1262
- const color = resolveNodeColor(
1263
- el,
1264
- parsed.tagGroups,
1265
- activeTagGroup ?? null
1266
- );
1267
- const description = el.description?.join('\n');
1268
- nodes.push({
1269
- id: el.name,
1270
- name: el.name,
1271
- type: el.type as 'person' | 'system',
1272
- ...(description !== undefined && { description }),
1273
- metadata: el.metadata,
1274
- lineNumber: el.lineNumber,
1275
- ...(color !== undefined && { color }),
1276
- x: pos.x,
1277
- y: pos.y,
1278
- width: pos.width,
1279
- height: pos.height,
1280
- });
1281
- }
1282
-
1283
- // Extract edges
1284
- const edges: MutableC4LayoutEdge[] = containerRels
1285
- .filter(
1286
- (rel) =>
1287
- nameToElement.has(rel.sourceName) && nameToElement.has(rel.targetName)
1288
- )
1289
- .map((rel): MutableC4LayoutEdge => {
1290
- const edgeData = g.edge(rel.sourceName, rel.targetName);
1291
- return {
1292
- source: rel.sourceName,
1293
- target: rel.targetName,
1294
- arrowType: rel.arrowType,
1295
- ...(rel.label !== undefined && { label: rel.label }),
1296
- ...(rel.technology !== undefined && { technology: rel.technology }),
1297
- lineNumber: rel.lineNumber,
1298
- points: edgeData?.points ?? [],
1299
- };
1300
- });
1301
-
1302
- // Compute boundary box from container nodes only
1303
- const containerNodes = nodes.filter((n) => n.type === 'container');
1304
- let bMinX = Infinity,
1305
- bMinY = Infinity,
1306
- bMaxX = -Infinity,
1307
- bMaxY = -Infinity;
1308
- for (const n of containerNodes) {
1309
- const left = n.x - n.width / 2;
1310
- const top = n.y - n.height / 2;
1311
- const right = n.x + n.width / 2;
1312
- const bottom = n.y + n.height / 2;
1313
- if (left < bMinX) bMinX = left;
1314
- if (top < bMinY) bMinY = top;
1315
- if (right > bMaxX) bMaxX = right;
1316
- if (bottom > bMaxY) bMaxY = bottom;
1317
- }
1318
-
1319
- const boundary: Writable<C4LayoutBoundary> = {
1320
- label: system.name,
1321
- typeLabel: 'system',
1322
- lineNumber: system.lineNumber,
1323
- x: bMinX - BOUNDARY_PAD,
1324
- y: bMinY - BOUNDARY_PAD,
1325
- width: bMaxX - bMinX + BOUNDARY_PAD * 2,
1326
- height: bMaxY - bMinY + BOUNDARY_PAD * 2,
1327
- };
1328
-
1329
- // Compute group boundaries from member node positions
1330
- const groupBoundaries: Writable<C4LayoutBoundary>[] = [];
1331
- if (hasGroups) {
1332
- const nodeMap = new Map(containerNodes.map((n) => [n.name, n]));
1333
- const seenGroups = new Map<
1334
- string,
1335
- { lineNumber: number; members: Writable<C4LayoutNode>[] }
1336
- >();
1337
- for (const [elName, grp] of elementToGroup) {
1338
- const node = nodeMap.get(elName);
1339
- if (!node) continue;
1340
- if (!seenGroups.has(grp.name)) {
1341
- seenGroups.set(grp.name, { lineNumber: grp.lineNumber, members: [] });
1342
- }
1343
- seenGroups.get(grp.name)!.members.push(node);
1344
- }
1345
- for (const [groupName, { lineNumber, members }] of seenGroups) {
1346
- if (members.length === 0) continue;
1347
- let gMinX = Infinity,
1348
- gMinY = Infinity,
1349
- gMaxX = -Infinity,
1350
- gMaxY = -Infinity;
1351
- for (const m of members) {
1352
- const left = m.x - m.width / 2;
1353
- const top = m.y - m.height / 2;
1354
- const right = m.x + m.width / 2;
1355
- const bottom = m.y + m.height / 2;
1356
- if (left < gMinX) gMinX = left;
1357
- if (top < gMinY) gMinY = top;
1358
- if (right > gMaxX) gMaxX = right;
1359
- if (bottom > gMaxY) gMaxY = bottom;
1360
- }
1361
- groupBoundaries.push({
1362
- label: groupName,
1363
- typeLabel: 'group',
1364
- lineNumber,
1365
- x: gMinX - GROUP_BOUNDARY_PAD,
1366
- y: gMinY - GROUP_BOUNDARY_PAD,
1367
- width: gMaxX - gMinX + GROUP_BOUNDARY_PAD * 2,
1368
- height: gMaxY - gMinY + GROUP_BOUNDARY_PAD * 2,
1369
- });
1370
- }
1371
- }
1372
-
1373
- // Compute bounding box of all content (nodes + boundary + group boundaries + edge points)
1374
- let minX = Infinity,
1375
- minY = Infinity,
1376
- maxX = -Infinity,
1377
- maxY = -Infinity;
1378
- for (const node of nodes) {
1379
- const left = node.x - node.width / 2;
1380
- const top = node.y - node.height / 2;
1381
- const right = node.x + node.width / 2;
1382
- const bottom = node.y + node.height / 2;
1383
- if (left < minX) minX = left;
1384
- if (top < minY) minY = top;
1385
- if (right > maxX) maxX = right;
1386
- if (bottom > maxY) maxY = bottom;
1387
- }
1388
- if (boundary.x < minX) minX = boundary.x;
1389
- if (boundary.y < minY) minY = boundary.y;
1390
- if (boundary.x + boundary.width > maxX) maxX = boundary.x + boundary.width;
1391
- if (boundary.y + boundary.height > maxY) maxY = boundary.y + boundary.height;
1392
- for (const gb of groupBoundaries) {
1393
- if (gb.x < minX) minX = gb.x;
1394
- if (gb.y < minY) minY = gb.y;
1395
- if (gb.x + gb.width > maxX) maxX = gb.x + gb.width;
1396
- if (gb.y + gb.height > maxY) maxY = gb.y + gb.height;
1397
- }
1398
- for (const edge of edges) {
1399
- for (const pt of edge.points) {
1400
- if (pt.x < minX) minX = pt.x;
1401
- if (pt.y < minY) minY = pt.y;
1402
- if (pt.x > maxX) maxX = pt.x;
1403
- if (pt.y > maxY) maxY = pt.y;
1404
- }
1405
- }
1406
-
1407
- // Shift everything so content starts at (MARGIN, MARGIN)
1408
- const shiftX = MARGIN - minX;
1409
- const shiftY = MARGIN - minY;
1410
- for (const node of nodes) {
1411
- node.x += shiftX;
1412
- node.y += shiftY;
1413
- }
1414
- boundary.x += shiftX;
1415
- boundary.y += shiftY;
1416
- for (const gb of groupBoundaries) {
1417
- gb.x += shiftX;
1418
- gb.y += shiftY;
1419
- }
1420
- for (const edge of edges) {
1421
- for (const pt of edge.points) {
1422
- pt.x += shiftX;
1423
- pt.y += shiftY;
1424
- }
1425
- }
1426
-
1427
- let totalWidth = maxX - minX + MARGIN * 2;
1428
- let totalHeight = maxY - minY + MARGIN * 2;
1429
-
1430
- const legendGroups = computeLegendGroups(parsed.tagGroups);
1431
-
1432
- // Position legend below diagram
1433
- if (legendGroups.length > 0) {
1434
- const legendY = totalHeight + MARGIN;
1435
- let legendX = MARGIN;
1436
- for (const lg of legendGroups) {
1437
- lg.x = legendX;
1438
- lg.y = legendY;
1439
- legendX += lg.width + 12;
1440
- }
1441
- const legendRight = legendX;
1442
- const legendBottom = legendY + LEGEND_HEIGHT;
1443
- if (legendRight > totalWidth) totalWidth = legendRight;
1444
- if (legendBottom > totalHeight) totalHeight = legendBottom;
1445
- }
1446
-
1447
- return {
1448
- nodes,
1449
- edges,
1450
- legend: legendGroups,
1451
- boundary,
1452
- groupBoundaries,
1453
- width: totalWidth,
1454
- height: totalHeight,
1455
- };
1456
- }
1457
-
1458
- // ============================================================
1459
- // Component-Level Layout
1460
- // ============================================================
1461
-
1462
- /**
1463
- * Layout components within a specific container, plus external elements
1464
- * that have relationships with those components.
1465
- */
1466
- export function layoutC4Components(
1467
- parsed: ParsedC4,
1468
- systemName: string,
1469
- containerName: string,
1470
- activeTagGroup?: string | null
1471
- ): C4LayoutResult {
1472
- // Find the system element by name
1473
- const system = parsed.elements.find(
1474
- (el) => el.name.toLowerCase() === systemName.toLowerCase()
1475
- );
1476
- if (!system) {
1477
- return {
1478
- nodes: [],
1479
- edges: [],
1480
- legend: [],
1481
- groupBoundaries: [],
1482
- width: 0,
1483
- height: 0,
1484
- };
1485
- }
1486
-
1487
- // Find the container within the system (direct children + group children)
1488
- let targetContainer: C4Element | undefined;
1489
- for (const child of system.children) {
1490
- if (
1491
- child.type === 'container' &&
1492
- child.name.toLowerCase() === containerName.toLowerCase()
1493
- ) {
1494
- targetContainer = child;
1495
- break;
1496
- }
1497
- }
1498
- if (!targetContainer) {
1499
- for (const group of system.groups) {
1500
- for (const child of group.children) {
1501
- if (
1502
- child.type === 'container' &&
1503
- child.name.toLowerCase() === containerName.toLowerCase()
1504
- ) {
1505
- targetContainer = child;
1506
- break;
1507
- }
1508
- }
1509
- if (targetContainer) break;
1510
- }
1511
- }
1512
- if (!targetContainer) {
1513
- return {
1514
- nodes: [],
1515
- edges: [],
1516
- legend: [],
1517
- groupBoundaries: [],
1518
- width: 0,
1519
- height: 0,
1520
- };
1521
- }
1522
-
1523
- // Collect all components: direct children + group children
1524
- const components: C4Element[] = [];
1525
- for (const child of targetContainer.children) {
1526
- if (child.type === 'component') components.push(child);
1527
- }
1528
- for (const group of targetContainer.groups) {
1529
- for (const child of group.children) {
1530
- if (child.type === 'component') components.push(child);
1531
- }
1532
- }
1533
-
1534
- if (components.length === 0) {
1535
- return {
1536
- nodes: [],
1537
- edges: [],
1538
- legend: [],
1539
- groupBoundaries: [],
1540
- width: 0,
1541
- height: 0,
1542
- };
1543
- }
1544
-
1545
- const componentNames = new Set(components.map((c) => c.name.toLowerCase()));
1546
-
1547
- // Build name → element map for all top-level elements and containers in this system
1548
- const topElementsByName = new Map<string, C4Element>();
1549
- for (const el of parsed.elements) {
1550
- topElementsByName.set(el.name.toLowerCase(), el);
1551
- // Also index containers in every system for external container resolution
1552
- for (const child of el.children) {
1553
- if (child.type === 'container') {
1554
- topElementsByName.set(child.name.toLowerCase(), child);
1555
- }
1556
- }
1557
- for (const group of el.groups) {
1558
- for (const child of group.children) {
1559
- if (child.type === 'container') {
1560
- topElementsByName.set(child.name.toLowerCase(), child);
1561
- }
1562
- }
1563
- }
1564
- }
1565
-
1566
- // Identify external elements: targets of component relationships not in this container,
1567
- // or elements whose relationships target components in this container
1568
- const externalNames = new Set<string>();
1569
-
1570
- // Forward: component → target outside this container
1571
- for (const component of components) {
1572
- for (const rel of component.relationships) {
1573
- const targetLower = rel.target.toLowerCase();
1574
- if (!componentNames.has(targetLower)) {
1575
- externalNames.add(targetLower);
1576
- }
1577
- }
1578
- }
1579
-
1580
- // Reverse: any element that targets a component in this container
1581
- const ownerMap = buildOwnershipMap(parsed.elements);
1582
- const allRels = collectAllRelationships(parsed.elements, ownerMap);
1583
- for (const { sourceName, rel } of allRels) {
1584
- // Skip relationships from within this container
1585
- if (componentNames.has(sourceName.toLowerCase())) continue;
1586
- // Check if target is a component in this container
1587
- if (componentNames.has(rel.target.toLowerCase())) {
1588
- // Resolve to the most specific known element: if source is a container, use it;
1589
- // otherwise roll up to system ancestor
1590
- const sourceAncestor = ownerMap.get(sourceName) ?? sourceName;
1591
- // If source is inside the same container, skip
1592
- if (sourceAncestor.toLowerCase() === targetContainer.name.toLowerCase())
1593
- continue;
1594
- if (sourceAncestor.toLowerCase() === system.name.toLowerCase()) {
1595
- // Source is in same system — try to resolve to container level
1596
- const sourceLower = sourceName.toLowerCase();
1597
- if (topElementsByName.has(sourceLower)) {
1598
- externalNames.add(sourceLower);
1599
- } else {
1600
- externalNames.add(sourceAncestor.toLowerCase());
1601
- }
1602
- } else {
1603
- externalNames.add(sourceAncestor.toLowerCase());
1604
- }
1605
- }
1606
- }
1607
-
1608
- // Resolve external elements
1609
- const externals: C4Element[] = [];
1610
- for (const name of externalNames) {
1611
- const el = topElementsByName.get(name);
1612
- if (el) externals.push(el);
1613
- }
1614
-
1615
- // Build element-to-group mapping for compound graph
1616
- const elementToGroup = new Map<
1617
- string,
1618
- { name: string; lineNumber: number }
1619
- >();
1620
- for (const group of targetContainer.groups) {
1621
- for (const child of group.children) {
1622
- if (child.type === 'component') {
1623
- elementToGroup.set(child.name, {
1624
- name: group.name,
1625
- lineNumber: group.lineNumber,
1626
- });
1627
- }
1628
- }
1629
- }
1630
- const hasGroups = elementToGroup.size > 0;
1631
-
1632
- // Create dagre graph — use compound when groups exist
1633
- const g = hasGroups
1634
- ? new dagre.graphlib.Graph({ compound: true })
1635
- : new dagre.graphlib.Graph();
1636
- g.setDefaultEdgeLabel(() => ({}));
1637
-
1638
- // Add virtual group parent nodes
1639
- if (hasGroups) {
1640
- const seenGroups = new Set<string>();
1641
- for (const { name } of elementToGroup.values()) {
1642
- if (!seenGroups.has(name)) {
1643
- seenGroups.add(name);
1644
- g.setNode('__group_' + name, {});
1645
- }
1646
- }
1647
- }
1648
-
1649
- // Add component nodes
1650
- const nameToElement = new Map<string, C4Element>();
1651
- for (const el of components) {
1652
- nameToElement.set(el.name, el);
1653
- const dims = computeC4NodeDimensions(el, { showTechnology: true });
1654
- g.setNode(el.name, { width: dims.width, height: dims.height });
1655
- // Set group parent
1656
- const grp = elementToGroup.get(el.name);
1657
- if (grp) {
1658
- g.setParent(el.name, '__group_' + grp.name);
1659
- }
1660
- }
1661
-
1662
- // Add external nodes
1663
- for (const el of externals) {
1664
- nameToElement.set(el.name, el);
1665
- const dims = computeC4NodeDimensions(el);
1666
- g.setNode(el.name, { width: dims.width, height: dims.height });
1667
- }
1668
-
1669
- // Collect component-level relationships
1670
- interface ComponentRel {
1671
- sourceName: string;
1672
- targetName: string;
1673
- label?: string;
1674
- technology?: string;
1675
- arrowType: C4ArrowType;
1676
- lineNumber: number;
1677
- }
1678
-
1679
- const componentRels: ComponentRel[] = [];
1680
- const seenEdgeKeys = new Set<string>();
1681
-
1682
- // Forward: component → target
1683
- for (const component of components) {
1684
- for (const rel of component.relationships) {
1685
- const targetEl = nameToElement.get(rel.target);
1686
- if (!targetEl) continue;
1687
- const key = `${component.name}→${rel.target}`;
1688
- if (!seenEdgeKeys.has(key)) {
1689
- seenEdgeKeys.add(key);
1690
- componentRels.push({
1691
- sourceName: component.name,
1692
- targetName: rel.target,
1693
- ...(rel.label !== undefined && { label: rel.label }),
1694
- ...(rel.technology !== undefined && { technology: rel.technology }),
1695
- arrowType: rel.arrowType,
1696
- lineNumber: rel.lineNumber,
1697
- });
1698
- }
1699
- }
1700
- }
1701
-
1702
- // Reverse: external → component
1703
- for (const { sourceName, rel } of allRels) {
1704
- if (componentNames.has(sourceName.toLowerCase())) continue;
1705
- if (!componentNames.has(rel.target.toLowerCase())) continue;
1706
-
1707
- const sourceAncestor = ownerMap.get(sourceName) ?? sourceName;
1708
- if (sourceAncestor.toLowerCase() === targetContainer.name.toLowerCase())
1709
- continue;
1710
-
1711
- // Resolve source to the external element we added
1712
- let resolvedSource: string | undefined;
1713
- if (nameToElement.has(sourceName)) {
1714
- resolvedSource = sourceName;
1715
- } else if (nameToElement.has(sourceAncestor)) {
1716
- resolvedSource = sourceAncestor;
1717
- }
1718
- if (!resolvedSource) continue;
1719
-
1720
- const key = `${resolvedSource}→${rel.target}`;
1721
- if (!seenEdgeKeys.has(key)) {
1722
- seenEdgeKeys.add(key);
1723
- componentRels.push({
1724
- sourceName: resolvedSource,
1725
- targetName: rel.target,
1726
- ...(rel.label !== undefined && { label: rel.label }),
1727
- ...(rel.technology !== undefined && { technology: rel.technology }),
1728
- arrowType: rel.arrowType,
1729
- lineNumber: rel.lineNumber,
1730
- });
1731
- }
1732
- }
1733
-
1734
- // Compute adaptive spacing based on edge fan-out
1735
- const spacing = computeAdaptiveSpacing(componentRels);
1736
- g.setGraph({
1737
- rankdir: 'TB',
1738
- nodesep: spacing.nodesep,
1739
- ranksep: spacing.ranksep,
1740
- edgesep: spacing.edgesep,
1741
- });
1742
-
1743
- // Add edges to dagre
1744
- for (const rel of componentRels) {
1745
- if (
1746
- nameToElement.has(rel.sourceName) &&
1747
- nameToElement.has(rel.targetName)
1748
- ) {
1749
- g.setEdge(rel.sourceName, rel.targetName, { label: rel.label ?? '' });
1750
- }
1751
- }
1752
-
1753
- // Run layout
1754
- dagre.layout(g);
1755
-
1756
- // Post-dagre crossing reduction (with group-aware partitioning)
1757
- const nodeGroupMap = hasGroups
1758
- ? new Map([...elementToGroup.entries()].map(([k, v]) => [k, v.name]))
1759
- : undefined;
1760
- reduceCrossings(
1761
- g,
1762
- componentRels
1763
- .filter(
1764
- (r) =>
1765
- nameToElement.has(r.sourceName) && nameToElement.has(r.targetName)
1766
- )
1767
- .map((r) => ({ source: r.sourceName, target: r.targetName })),
1768
- nodeGroupMap
1769
- );
1770
-
1771
- // Tag inheritance ancestors: container → system
1772
- const ancestors = [targetContainer, system];
1773
-
1774
- // Extract positioned nodes
1775
- const nodes: Writable<C4LayoutNode>[] = [];
1776
- for (const el of components) {
1777
- const pos = g.node(el.name);
1778
- const color = resolveNodeColor(
1779
- el,
1780
- parsed.tagGroups,
1781
- activeTagGroup ?? null,
1782
- ancestors
1783
- );
1784
- const tech = el.metadata['tech'] ?? el.metadata['technology'];
1785
- const hasComponents =
1786
- el.children.some((c) => c.type === 'component') ||
1787
- el.groups.some((grp) => grp.children.some((c) => c.type === 'component'));
1788
- const description = el.description?.join('\n');
1789
- const drillable = hasComponents || el.importPath ? true : undefined;
1790
- nodes.push({
1791
- id: el.name,
1792
- name: el.name,
1793
- type: 'component',
1794
- ...(description !== undefined && { description }),
1795
- metadata: el.metadata,
1796
- lineNumber: el.lineNumber,
1797
- ...(color !== undefined && { color }),
1798
- shape: el.shape,
1799
- ...(tech !== undefined && { technology: tech }),
1800
- ...(drillable !== undefined && { drillable }),
1801
- ...(el.importPath !== undefined && { importPath: el.importPath }),
1802
- x: pos.x,
1803
- y: pos.y,
1804
- width: pos.width,
1805
- height: pos.height,
1806
- });
1807
- }
1808
-
1809
- for (const el of externals) {
1810
- const pos = g.node(el.name);
1811
- const color = resolveNodeColor(
1812
- el,
1813
- parsed.tagGroups,
1814
- activeTagGroup ?? null
1815
- );
1816
- const description = el.description?.join('\n');
1817
- nodes.push({
1818
- id: el.name,
1819
- name: el.name,
1820
- type: el.type as 'person' | 'system' | 'container',
1821
- ...(description !== undefined && { description }),
1822
- metadata: el.metadata,
1823
- lineNumber: el.lineNumber,
1824
- ...(color !== undefined && { color }),
1825
- x: pos.x,
1826
- y: pos.y,
1827
- width: pos.width,
1828
- height: pos.height,
1829
- });
1830
- }
1831
-
1832
- // Extract edges
1833
- const edges: MutableC4LayoutEdge[] = componentRels
1834
- .filter(
1835
- (rel) =>
1836
- nameToElement.has(rel.sourceName) && nameToElement.has(rel.targetName)
1837
- )
1838
- .map((rel): MutableC4LayoutEdge => {
1839
- const edgeData = g.edge(rel.sourceName, rel.targetName);
1840
- return {
1841
- source: rel.sourceName,
1842
- target: rel.targetName,
1843
- arrowType: rel.arrowType,
1844
- ...(rel.label !== undefined && { label: rel.label }),
1845
- ...(rel.technology !== undefined && { technology: rel.technology }),
1846
- lineNumber: rel.lineNumber,
1847
- points: edgeData?.points ?? [],
1848
- };
1849
- });
1850
-
1851
- // Compute boundary box from component nodes only
1852
- const componentNodes = nodes.filter((n) => n.type === 'component');
1853
- let bMinX = Infinity,
1854
- bMinY = Infinity,
1855
- bMaxX = -Infinity,
1856
- bMaxY = -Infinity;
1857
- for (const n of componentNodes) {
1858
- const left = n.x - n.width / 2;
1859
- const top = n.y - n.height / 2;
1860
- const right = n.x + n.width / 2;
1861
- const bottom = n.y + n.height / 2;
1862
- if (left < bMinX) bMinX = left;
1863
- if (top < bMinY) bMinY = top;
1864
- if (right > bMaxX) bMaxX = right;
1865
- if (bottom > bMaxY) bMaxY = bottom;
1866
- }
1867
-
1868
- const boundary: Writable<C4LayoutBoundary> = {
1869
- label: targetContainer.name,
1870
- typeLabel: 'container',
1871
- lineNumber: targetContainer.lineNumber,
1872
- x: bMinX - BOUNDARY_PAD,
1873
- y: bMinY - BOUNDARY_PAD,
1874
- width: bMaxX - bMinX + BOUNDARY_PAD * 2,
1875
- height: bMaxY - bMinY + BOUNDARY_PAD * 2,
1876
- };
1877
-
1878
- // Compute group boundaries from member node positions
1879
- const groupBoundaries: Writable<C4LayoutBoundary>[] = [];
1880
- if (hasGroups) {
1881
- const nodeMap = new Map(componentNodes.map((n) => [n.name, n]));
1882
- const seenGroups = new Map<
1883
- string,
1884
- { lineNumber: number; members: Writable<C4LayoutNode>[] }
1885
- >();
1886
- for (const [elName, grp] of elementToGroup) {
1887
- const node = nodeMap.get(elName);
1888
- if (!node) continue;
1889
- if (!seenGroups.has(grp.name)) {
1890
- seenGroups.set(grp.name, { lineNumber: grp.lineNumber, members: [] });
1891
- }
1892
- seenGroups.get(grp.name)!.members.push(node);
1893
- }
1894
- for (const [groupName, { lineNumber, members }] of seenGroups) {
1895
- if (members.length === 0) continue;
1896
- let gMinX = Infinity,
1897
- gMinY = Infinity,
1898
- gMaxX = -Infinity,
1899
- gMaxY = -Infinity;
1900
- for (const m of members) {
1901
- const left = m.x - m.width / 2;
1902
- const top = m.y - m.height / 2;
1903
- const right = m.x + m.width / 2;
1904
- const bottom = m.y + m.height / 2;
1905
- if (left < gMinX) gMinX = left;
1906
- if (top < gMinY) gMinY = top;
1907
- if (right > gMaxX) gMaxX = right;
1908
- if (bottom > gMaxY) gMaxY = bottom;
1909
- }
1910
- groupBoundaries.push({
1911
- label: groupName,
1912
- typeLabel: 'group',
1913
- lineNumber,
1914
- x: gMinX - GROUP_BOUNDARY_PAD,
1915
- y: gMinY - GROUP_BOUNDARY_PAD,
1916
- width: gMaxX - gMinX + GROUP_BOUNDARY_PAD * 2,
1917
- height: gMaxY - gMinY + GROUP_BOUNDARY_PAD * 2,
1918
- });
1919
- }
1920
- }
1921
-
1922
- // Compute bounding box of all content (nodes + boundary + group boundaries + edge points)
1923
- let minX = Infinity,
1924
- minY = Infinity,
1925
- maxX = -Infinity,
1926
- maxY = -Infinity;
1927
- for (const node of nodes) {
1928
- const left = node.x - node.width / 2;
1929
- const top = node.y - node.height / 2;
1930
- const right = node.x + node.width / 2;
1931
- const bottom = node.y + node.height / 2;
1932
- if (left < minX) minX = left;
1933
- if (top < minY) minY = top;
1934
- if (right > maxX) maxX = right;
1935
- if (bottom > maxY) maxY = bottom;
1936
- }
1937
- if (boundary.x < minX) minX = boundary.x;
1938
- if (boundary.y < minY) minY = boundary.y;
1939
- if (boundary.x + boundary.width > maxX) maxX = boundary.x + boundary.width;
1940
- if (boundary.y + boundary.height > maxY) maxY = boundary.y + boundary.height;
1941
- for (const gb of groupBoundaries) {
1942
- if (gb.x < minX) minX = gb.x;
1943
- if (gb.y < minY) minY = gb.y;
1944
- if (gb.x + gb.width > maxX) maxX = gb.x + gb.width;
1945
- if (gb.y + gb.height > maxY) maxY = gb.y + gb.height;
1946
- }
1947
- for (const edge of edges) {
1948
- for (const pt of edge.points) {
1949
- if (pt.x < minX) minX = pt.x;
1950
- if (pt.y < minY) minY = pt.y;
1951
- if (pt.x > maxX) maxX = pt.x;
1952
- if (pt.y > maxY) maxY = pt.y;
1953
- }
1954
- }
1955
-
1956
- // Shift everything so content starts at (MARGIN, MARGIN)
1957
- const shiftX = MARGIN - minX;
1958
- const shiftY = MARGIN - minY;
1959
- for (const node of nodes) {
1960
- node.x += shiftX;
1961
- node.y += shiftY;
1962
- }
1963
- boundary.x += shiftX;
1964
- boundary.y += shiftY;
1965
- for (const gb of groupBoundaries) {
1966
- gb.x += shiftX;
1967
- gb.y += shiftY;
1968
- }
1969
- for (const edge of edges) {
1970
- for (const pt of edge.points) {
1971
- pt.x += shiftX;
1972
- pt.y += shiftY;
1973
- }
1974
- }
1975
-
1976
- let totalWidth = maxX - minX + MARGIN * 2;
1977
- let totalHeight = maxY - minY + MARGIN * 2;
1978
-
1979
- const legendGroups = computeLegendGroups(parsed.tagGroups);
1980
-
1981
- // Position legend below diagram
1982
- if (legendGroups.length > 0) {
1983
- const legendY = totalHeight + MARGIN;
1984
- let legendX = MARGIN;
1985
- for (const lg of legendGroups) {
1986
- lg.x = legendX;
1987
- lg.y = legendY;
1988
- legendX += lg.width + 12;
1989
- }
1990
- const legendRight = legendX;
1991
- const legendBottom = legendY + LEGEND_HEIGHT;
1992
- if (legendRight > totalWidth) totalWidth = legendRight;
1993
- if (legendBottom > totalHeight) totalHeight = legendBottom;
1994
- }
1995
-
1996
- return {
1997
- nodes,
1998
- edges,
1999
- legend: legendGroups,
2000
- boundary,
2001
- groupBoundaries,
2002
- width: totalWidth,
2003
- height: totalHeight,
2004
- };
2005
- }
2006
-
2007
- // ============================================================
2008
- // Deployment Diagram Layout
2009
- // ============================================================
2010
-
2011
- /**
2012
- * Resolve a container reference name to its C4Element by walking the parsed
2013
- * element tree. Matches container names case-insensitively.
2014
- */
2015
- function resolveContainerRef(
2016
- parsed: ParsedC4,
2017
- refName: string
2018
- ): C4Element | undefined {
2019
- const lower = refName.toLowerCase();
2020
- for (const el of parsed.elements) {
2021
- for (const child of el.children) {
2022
- if (child.type === 'container' && child.name.toLowerCase() === lower)
2023
- return child;
2024
- }
2025
- for (const group of el.groups) {
2026
- for (const child of group.children) {
2027
- if (child.type === 'container' && child.name.toLowerCase() === lower)
2028
- return child;
2029
- }
2030
- }
2031
- }
2032
- return undefined;
2033
- }
2034
-
2035
- /**
2036
- * Collect all container ref nodes from the deployment tree, flattened with
2037
- * their parent infra node ID for compound graph assignment.
2038
- */
2039
- interface DeploymentRefEntry {
2040
- refName: string;
2041
- element: C4Element;
2042
- infraId: string;
2043
- /** Line number of the infra node containing this ref. */
2044
- deployLineNumber: number;
2045
- }
2046
-
2047
- function collectDeploymentRefs(
2048
- nodes: readonly C4DeploymentNode[],
2049
- parsed: ParsedC4,
2050
- parentId: string | null,
2051
- refs: DeploymentRefEntry[],
2052
- infraIds: Map<string, C4DeploymentNode>,
2053
- infraParents: Map<string, string | null>
2054
- ): void {
2055
- for (const node of nodes) {
2056
- const infraId = `__infra_${node.name}`;
2057
- infraIds.set(infraId, node);
2058
- infraParents.set(infraId, parentId);
2059
-
2060
- for (const ref of node.containerRefs) {
2061
- const el = resolveContainerRef(parsed, ref);
2062
- if (el) {
2063
- refs.push({
2064
- refName: ref,
2065
- element: el,
2066
- infraId,
2067
- deployLineNumber: node.lineNumber,
2068
- });
2069
- }
2070
- }
2071
-
2072
- collectDeploymentRefs(
2073
- node.children,
2074
- parsed,
2075
- infraId,
2076
- refs,
2077
- infraIds,
2078
- infraParents
2079
- );
2080
- }
2081
- }
2082
-
2083
- /**
2084
- * Layout a C4 deployment diagram.
2085
- *
2086
- * Infrastructure nodes become boundary boxes (nested).
2087
- * Container refs inside them become cards.
2088
- * Edges are drawn between referenced containers that have relationships.
2089
- */
2090
- export function layoutC4Deployment(
2091
- parsed: ParsedC4,
2092
- activeTagGroup?: string | null
2093
- ): C4LayoutResult {
2094
- if (parsed.deployment.length === 0) {
2095
- return {
2096
- nodes: [],
2097
- edges: [],
2098
- legend: [],
2099
- groupBoundaries: [],
2100
- width: 0,
2101
- height: 0,
2102
- };
2103
- }
2104
-
2105
- // Collect all refs and infra node info
2106
- const refs: DeploymentRefEntry[] = [];
2107
- const infraIds = new Map<string, C4DeploymentNode>();
2108
- const infraParents = new Map<string, string | null>();
2109
- collectDeploymentRefs(
2110
- parsed.deployment,
2111
- parsed,
2112
- null,
2113
- refs,
2114
- infraIds,
2115
- infraParents
2116
- );
2117
-
2118
- if (refs.length === 0) {
2119
- return {
2120
- nodes: [],
2121
- edges: [],
2122
- legend: [],
2123
- groupBoundaries: [],
2124
- width: 0,
2125
- height: 0,
2126
- };
2127
- }
2128
-
2129
- // Deduplicate refs by element name (a container can appear in multiple infra
2130
- // nodes — keep first occurrence). Track which container names are in scope.
2131
- const seenRefs = new Map<string, DeploymentRefEntry>();
2132
- for (const ref of refs) {
2133
- const key = ref.element.name.toLowerCase();
2134
- if (!seenRefs.has(key)) seenRefs.set(key, ref);
2135
- }
2136
- const refEntries = [...seenRefs.values()];
2137
- const refNames = new Set(refEntries.map((r) => r.element.name.toLowerCase()));
2138
-
2139
- // Build a name→element map for resolved containers
2140
- const nameToElement = new Map<string, C4Element>();
2141
- for (const r of refEntries) {
2142
- nameToElement.set(r.element.name, r.element);
2143
- }
2144
-
2145
- // Build compound dagre graph: infra nodes as parents, container refs as leaf nodes
2146
- const g = new dagre.graphlib.Graph({ compound: true });
2147
- g.setDefaultEdgeLabel(() => ({}));
2148
-
2149
- // Add virtual parent nodes for each infra node
2150
- for (const [infraId] of infraIds) {
2151
- g.setNode(infraId, {});
2152
- const parentId = infraParents.get(infraId);
2153
- if (parentId) g.setParent(infraId, parentId);
2154
- }
2155
-
2156
- // Add container ref nodes
2157
- for (const r of refEntries) {
2158
- const dims = computeC4NodeDimensions(r.element, { showTechnology: true });
2159
- g.setNode(r.element.name, { width: dims.width, height: dims.height });
2160
- g.setParent(r.element.name, r.infraId);
2161
- }
2162
-
2163
- // Collect relationships between referenced containers
2164
- interface DeployRel {
2165
- sourceName: string;
2166
- targetName: string;
2167
- label?: string;
2168
- technology?: string;
2169
- arrowType: C4ArrowType;
2170
- lineNumber: number;
2171
- }
2172
- const deployRels: DeployRel[] = [];
2173
- const seenEdgeKeys = new Set<string>();
2174
-
2175
- for (const r of refEntries) {
2176
- for (const rel of r.element.relationships) {
2177
- if (refNames.has(rel.target.toLowerCase())) {
2178
- const key = `${r.element.name}\u2192${rel.target}`;
2179
- if (!seenEdgeKeys.has(key)) {
2180
- seenEdgeKeys.add(key);
2181
- deployRels.push({
2182
- sourceName: r.element.name,
2183
- targetName: rel.target,
2184
- ...(rel.label !== undefined && { label: rel.label }),
2185
- ...(rel.technology !== undefined && { technology: rel.technology }),
2186
- arrowType: rel.arrowType,
2187
- lineNumber: rel.lineNumber,
2188
- });
2189
- }
2190
- }
2191
- }
2192
- }
2193
-
2194
- // Adaptive spacing and graph config
2195
- const spacing = computeAdaptiveSpacing(deployRels);
2196
- g.setGraph({
2197
- rankdir: 'TB',
2198
- nodesep: spacing.nodesep,
2199
- ranksep: spacing.ranksep,
2200
- edgesep: spacing.edgesep,
2201
- });
2202
-
2203
- // Add edges to dagre
2204
- for (const rel of deployRels) {
2205
- if (
2206
- nameToElement.has(rel.sourceName) &&
2207
- nameToElement.has(rel.targetName)
2208
- ) {
2209
- g.setEdge(rel.sourceName, rel.targetName, { label: rel.label ?? '' });
2210
- }
2211
- }
2212
-
2213
- // Run layout
2214
- dagre.layout(g);
2215
-
2216
- // Post-dagre crossing reduction
2217
- const nodeInfraMap = new Map<string, string>();
2218
- for (const r of refEntries) nodeInfraMap.set(r.element.name, r.infraId);
2219
- reduceCrossings(
2220
- g,
2221
- deployRels
2222
- .filter(
2223
- (r) =>
2224
- nameToElement.has(r.sourceName) && nameToElement.has(r.targetName)
2225
- )
2226
- .map((r) => ({ source: r.sourceName, target: r.targetName })),
2227
- nodeInfraMap
2228
- );
2229
-
2230
- // Extract positioned nodes
2231
- const nodes: Writable<C4LayoutNode>[] = [];
2232
- for (const r of refEntries) {
2233
- const pos = g.node(r.element.name);
2234
- const color = resolveNodeColor(
2235
- r.element,
2236
- parsed.tagGroups,
2237
- activeTagGroup ?? null
2238
- );
2239
- const tech = r.element.metadata['tech'] ?? r.element.metadata['technology'];
2240
- const description = r.element.description?.join('\n');
2241
- nodes.push({
2242
- id: r.element.name,
2243
- name: r.element.name,
2244
- type: 'container',
2245
- ...(description !== undefined && { description }),
2246
- metadata: r.element.metadata,
2247
- lineNumber: r.element.lineNumber,
2248
- ...(color !== undefined && { color }),
2249
- shape: r.element.shape,
2250
- ...(tech !== undefined && { technology: tech }),
2251
- x: pos.x,
2252
- y: pos.y,
2253
- width: pos.width,
2254
- height: pos.height,
2255
- });
2256
- }
2257
-
2258
- // Extract edges
2259
- const edges: MutableC4LayoutEdge[] = deployRels
2260
- .filter(
2261
- (rel) =>
2262
- nameToElement.has(rel.sourceName) && nameToElement.has(rel.targetName)
2263
- )
2264
- .map((rel): MutableC4LayoutEdge => {
2265
- const edgeData = g.edge(rel.sourceName, rel.targetName);
2266
- return {
2267
- source: rel.sourceName,
2268
- target: rel.targetName,
2269
- arrowType: rel.arrowType,
2270
- ...(rel.label !== undefined && { label: rel.label }),
2271
- ...(rel.technology !== undefined && { technology: rel.technology }),
2272
- lineNumber: rel.lineNumber,
2273
- points: edgeData?.points ?? [],
2274
- };
2275
- });
2276
-
2277
- // Compute infrastructure boundary boxes from member node positions
2278
- const groupBoundaries: Writable<C4LayoutBoundary>[] = [];
2279
- const nodeMap = new Map(nodes.map((n) => [n.name, n]));
2280
-
2281
- // Collect members for each infra node (containers directly inside it)
2282
- const infraMembers = new Map<string, Writable<C4LayoutNode>[]>();
2283
- for (const r of refEntries) {
2284
- const members = infraMembers.get(r.infraId) ?? [];
2285
- const node = nodeMap.get(r.element.name);
2286
- if (node) members.push(node);
2287
- infraMembers.set(r.infraId, members);
2288
- }
2289
-
2290
- // Compute boundaries bottom-up: leaf infra nodes first, then parents.
2291
- const infraBounds = new Map<
2292
- string,
2293
- { x: number; y: number; width: number; height: number }
2294
- >();
2295
-
2296
- function computeInfraBounds(
2297
- infraId: string
2298
- ): { x: number; y: number; width: number; height: number } | null {
2299
- if (infraBounds.has(infraId)) return infraBounds.get(infraId)!;
2300
-
2301
- let bMinX = Infinity,
2302
- bMinY = Infinity,
2303
- bMaxX = -Infinity,
2304
- bMaxY = -Infinity;
2305
- let hasContent = false;
2306
-
2307
- // Direct container ref members
2308
- const members = infraMembers.get(infraId) ?? [];
2309
- for (const m of members) {
2310
- hasContent = true;
2311
- const left = m.x - m.width / 2;
2312
- const top = m.y - m.height / 2;
2313
- const right = m.x + m.width / 2;
2314
- const bottom = m.y + m.height / 2;
2315
- if (left < bMinX) bMinX = left;
2316
- if (top < bMinY) bMinY = top;
2317
- if (right > bMaxX) bMaxX = right;
2318
- if (bottom > bMaxY) bMaxY = bottom;
2319
- }
2320
-
2321
- // Child infra node boundaries
2322
- for (const [childId, parentId] of infraParents) {
2323
- if (parentId === infraId) {
2324
- const childBounds = computeInfraBounds(childId);
2325
- if (childBounds) {
2326
- hasContent = true;
2327
- if (childBounds.x < bMinX) bMinX = childBounds.x;
2328
- if (childBounds.y < bMinY) bMinY = childBounds.y;
2329
- if (childBounds.x + childBounds.width > bMaxX)
2330
- bMaxX = childBounds.x + childBounds.width;
2331
- if (childBounds.y + childBounds.height > bMaxY)
2332
- bMaxY = childBounds.y + childBounds.height;
2333
- }
2334
- }
2335
- }
2336
-
2337
- if (!hasContent) return null;
2338
-
2339
- const bounds = {
2340
- x: bMinX - BOUNDARY_PAD,
2341
- y: bMinY - BOUNDARY_PAD,
2342
- width: bMaxX - bMinX + BOUNDARY_PAD * 2,
2343
- height: bMaxY - bMinY + BOUNDARY_PAD * 2,
2344
- };
2345
- infraBounds.set(infraId, bounds);
2346
- return bounds;
2347
- }
2348
-
2349
- // Process all infra nodes (recursion handles ordering)
2350
- for (const [infraId, node] of infraIds) {
2351
- const bounds = computeInfraBounds(infraId);
2352
- if (bounds) {
2353
- const shapeLabel = node.shape !== 'default' ? node.shape : 'node';
2354
- groupBoundaries.push({
2355
- label: node.name,
2356
- typeLabel: shapeLabel,
2357
- lineNumber: node.lineNumber,
2358
- ...bounds,
2359
- });
2360
- }
2361
- }
2362
-
2363
- // Sort boundaries so outermost (largest area) are first — rendered bottom to top
2364
- groupBoundaries.sort((a, b) => b.width * b.height - a.width * a.height);
2365
-
2366
- // Compute total bounding box
2367
- let minX = Infinity,
2368
- minY = Infinity,
2369
- maxX = -Infinity,
2370
- maxY = -Infinity;
2371
- for (const node of nodes) {
2372
- const left = node.x - node.width / 2;
2373
- const top = node.y - node.height / 2;
2374
- const right = node.x + node.width / 2;
2375
- const bottom = node.y + node.height / 2;
2376
- if (left < minX) minX = left;
2377
- if (top < minY) minY = top;
2378
- if (right > maxX) maxX = right;
2379
- if (bottom > maxY) maxY = bottom;
2380
- }
2381
- for (const gb of groupBoundaries) {
2382
- if (gb.x < minX) minX = gb.x;
2383
- if (gb.y < minY) minY = gb.y;
2384
- if (gb.x + gb.width > maxX) maxX = gb.x + gb.width;
2385
- if (gb.y + gb.height > maxY) maxY = gb.y + gb.height;
2386
- }
2387
- for (const edge of edges) {
2388
- for (const pt of edge.points) {
2389
- if (pt.x < minX) minX = pt.x;
2390
- if (pt.y < minY) minY = pt.y;
2391
- if (pt.x > maxX) maxX = pt.x;
2392
- if (pt.y > maxY) maxY = pt.y;
2393
- }
2394
- }
2395
-
2396
- // Shift everything so content starts at (MARGIN, MARGIN)
2397
- const shiftX = MARGIN - minX;
2398
- const shiftY = MARGIN - minY;
2399
- for (const node of nodes) {
2400
- node.x += shiftX;
2401
- node.y += shiftY;
2402
- }
2403
- for (const gb of groupBoundaries) {
2404
- gb.x += shiftX;
2405
- gb.y += shiftY;
2406
- }
2407
- for (const edge of edges) {
2408
- for (const pt of edge.points) {
2409
- pt.x += shiftX;
2410
- pt.y += shiftY;
2411
- }
2412
- }
2413
-
2414
- let totalWidth = maxX - minX + MARGIN * 2;
2415
- let totalHeight = maxY - minY + MARGIN * 2;
2416
-
2417
- const legendGroups = computeLegendGroups(parsed.tagGroups);
2418
-
2419
- if (legendGroups.length > 0) {
2420
- const legendY = totalHeight + MARGIN;
2421
- let legendX = MARGIN;
2422
- for (const lg of legendGroups) {
2423
- lg.x = legendX;
2424
- lg.y = legendY;
2425
- legendX += lg.width + 12;
2426
- }
2427
- const legendRight = legendX;
2428
- const legendBottom = legendY + LEGEND_HEIGHT;
2429
- if (legendRight > totalWidth) totalWidth = legendRight;
2430
- if (legendBottom > totalHeight) totalHeight = legendBottom;
2431
- }
2432
-
2433
- return {
2434
- nodes,
2435
- edges,
2436
- legend: legendGroups,
2437
- groupBoundaries,
2438
- width: totalWidth,
2439
- height: totalHeight,
2440
- };
2441
- }