@diagrammo/dgmo 0.47.0 → 0.48.0

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