@diagrammo/dgmo 0.2.21 → 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.
- package/dist/cli.cjs +119 -113
- package/dist/index.cjs +6317 -2337
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +264 -1
- package/dist/index.d.ts +264 -1
- package/dist/index.js +6299 -2337
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/layout.ts +2137 -0
- package/src/c4/parser.ts +809 -0
- package/src/c4/renderer.ts +1916 -0
- package/src/c4/types.ts +86 -0
- package/src/class/renderer.ts +2 -2
- package/src/cli.ts +54 -10
- package/src/d3.ts +148 -10
- package/src/dgmo-router.ts +13 -0
- package/src/echarts.ts +7 -8
- package/src/er/renderer.ts +2 -2
- package/src/graph/flowchart-renderer.ts +1 -1
- package/src/index.ts +54 -0
- package/src/initiative-status/layout.ts +217 -0
- package/src/initiative-status/parser.ts +246 -0
- package/src/initiative-status/renderer.ts +834 -0
- package/src/initiative-status/types.ts +43 -0
- package/src/kanban/renderer.ts +23 -3
- package/src/org/layout.ts +64 -26
- package/src/org/renderer.ts +47 -18
- package/src/org/resolver.ts +3 -1
- package/src/render.ts +9 -1
- package/src/sequence/participant-inference.ts +1 -0
- package/src/sequence/renderer.ts +12 -6
package/src/c4/layout.ts
ADDED
|
@@ -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
|
+
}
|