@diagrammo/dgmo 0.2.22 → 0.2.23

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