@diagrammo/dgmo 0.2.22 → 0.2.24

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,1916 @@
1
+ // ============================================================
2
+ // C4 Context Diagram SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import * as d3Shape from 'd3-shape';
7
+ import { FONT_FAMILY } from '../fonts';
8
+ import type { PaletteColors } from '../palettes';
9
+ import type { ParsedC4 } from './types';
10
+ import type { C4Shape } from './types';
11
+ import type { C4LayoutResult, C4LayoutNode, C4LayoutEdge, C4LayoutBoundary } from './layout';
12
+ import { parseC4 } from './parser';
13
+ import { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deployment, collectCardMetadata } from './layout';
14
+
15
+ // ============================================================
16
+ // Constants
17
+ // ============================================================
18
+
19
+ const DIAGRAM_PADDING = 20;
20
+ const MAX_SCALE = 3;
21
+ const TITLE_HEIGHT = 30;
22
+ const TITLE_FONT_SIZE = 20;
23
+ const TYPE_FONT_SIZE = 10;
24
+ const NAME_FONT_SIZE = 14;
25
+ const DESC_FONT_SIZE = 11;
26
+ const DESC_LINE_HEIGHT = 16;
27
+ const DESC_CHAR_WIDTH = 6.5;
28
+ const EDGE_LABEL_FONT_SIZE = 11;
29
+ const TECH_FONT_SIZE = 10;
30
+ const EDGE_STROKE_WIDTH = 1.5;
31
+ const NODE_STROKE_WIDTH = 1.5;
32
+ const CARD_RADIUS = 6;
33
+ const CARD_H_PAD = 20;
34
+ const CARD_V_PAD = 14;
35
+ const TYPE_LABEL_HEIGHT = 18;
36
+ const DIVIDER_GAP = 6;
37
+ const NAME_HEIGHT = 20;
38
+ const TECH_LINE_HEIGHT = 16;
39
+ const META_FONT_SIZE = 11;
40
+ const META_CHAR_WIDTH = 6.5;
41
+ const META_LINE_HEIGHT = 16;
42
+ const BOUNDARY_LABEL_FONT_SIZE = 12;
43
+ const BOUNDARY_STROKE_WIDTH = 1.5;
44
+ const BOUNDARY_RADIUS = 8;
45
+
46
+ // Drillable accent bar (matches org chart collapse bar)
47
+ const DRILL_BAR_HEIGHT = 6;
48
+
49
+ // Cylinder (database/cache) shape constants
50
+ const CYLINDER_RY = 8;
51
+
52
+ // Person stick-figure dimensions (sequence-diagram style, scaled for cards)
53
+ const PERSON_HEAD_R = 4;
54
+ const PERSON_ARM_SPAN = 10;
55
+ const PERSON_LEG_SPAN = 7;
56
+ const PERSON_ICON_W = PERSON_ARM_SPAN * 2; // total width including arms
57
+ const PERSON_SW = 1.5;
58
+
59
+ // Legend constants (match org)
60
+ const LEGEND_HEIGHT = 28;
61
+ const LEGEND_PILL_FONT_SIZE = 11;
62
+ const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
63
+ const LEGEND_PILL_PAD = 16;
64
+ const LEGEND_DOT_R = 4;
65
+ const LEGEND_ENTRY_FONT_SIZE = 10;
66
+ const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
67
+ const LEGEND_ENTRY_DOT_GAP = 4;
68
+ const LEGEND_ENTRY_TRAIL = 8;
69
+ const LEGEND_CAPSULE_PAD = 4;
70
+
71
+ // ============================================================
72
+ // Color helpers
73
+ // ============================================================
74
+
75
+ function mix(a: string, b: string, pct: number): string {
76
+ const parse = (h: string) => {
77
+ const r = h.replace('#', '');
78
+ const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
79
+ return [
80
+ parseInt(f.substring(0, 2), 16),
81
+ parseInt(f.substring(2, 4), 16),
82
+ parseInt(f.substring(4, 6), 16),
83
+ ];
84
+ };
85
+ const [ar, ag, ab] = parse(a),
86
+ [br, bg, bb] = parse(b),
87
+ t = pct / 100;
88
+ const c = (x: number, y: number) =>
89
+ Math.round(x * t + y * (1 - t))
90
+ .toString(16)
91
+ .padStart(2, '0');
92
+ return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
93
+ }
94
+
95
+ function typeColor(
96
+ type: 'person' | 'system' | 'container' | 'component',
97
+ palette: PaletteColors,
98
+ nodeColor?: string
99
+ ): string {
100
+ if (nodeColor) return nodeColor;
101
+ switch (type) {
102
+ case 'person': return palette.colors.blue;
103
+ case 'container': return palette.colors.purple;
104
+ case 'component': return palette.colors.green;
105
+ default: return palette.colors.teal;
106
+ }
107
+ }
108
+
109
+ function nodeFill(
110
+ palette: PaletteColors,
111
+ isDark: boolean,
112
+ type: 'person' | 'system' | 'container' | 'component',
113
+ nodeColor?: string
114
+ ): string {
115
+ const color = typeColor(type, palette, nodeColor);
116
+ return mix(color, isDark ? palette.surface : palette.bg, 25);
117
+ }
118
+
119
+ function nodeStroke(
120
+ palette: PaletteColors,
121
+ type: 'person' | 'system' | 'container' | 'component',
122
+ nodeColor?: string
123
+ ): string {
124
+ return typeColor(type, palette, nodeColor);
125
+ }
126
+
127
+ // ============================================================
128
+ // Text wrapping helper
129
+ // ============================================================
130
+
131
+ function wrapText(text: string, maxWidth: number, charWidth: number): string[] {
132
+ const words = text.split(/\s+/);
133
+ const lines: string[] = [];
134
+ let current = '';
135
+
136
+ for (const word of words) {
137
+ const test = current ? `${current} ${word}` : word;
138
+ if (test.length * charWidth > maxWidth && current) {
139
+ lines.push(current);
140
+ current = word;
141
+ } else {
142
+ current = test;
143
+ }
144
+ }
145
+ if (current) lines.push(current);
146
+ return lines;
147
+ }
148
+
149
+ // ============================================================
150
+ // Edge path generator
151
+ // ============================================================
152
+
153
+ const lineGenerator = d3Shape
154
+ .line<{ x: number; y: number }>()
155
+ .x((d) => d.x)
156
+ .y((d) => d.y)
157
+ .curve(d3Shape.curveBasis);
158
+
159
+ // ============================================================
160
+ // Edge line style helpers
161
+ // ============================================================
162
+
163
+ function isDashedEdge(arrowType: string): boolean {
164
+ return arrowType === 'async' || arrowType === 'bidirectional-async';
165
+ }
166
+
167
+ function hasBidirectionalMarkers(arrowType: string): boolean {
168
+ return arrowType === 'bidirectional' || arrowType === 'bidirectional-async';
169
+ }
170
+
171
+ // ============================================================
172
+ // Person stick-figure icon
173
+ // ============================================================
174
+
175
+ /**
176
+ * Stick-figure person icon matching the sequence diagram actor style.
177
+ * Drawn centered at (cx, cy) with total height ~22px.
178
+ */
179
+ function drawPersonIcon(
180
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
181
+ cx: number,
182
+ cy: number,
183
+ color: string
184
+ ): void {
185
+ const headY = cy - 7;
186
+ const bodyTopY = headY + PERSON_HEAD_R + 1;
187
+ const bodyBottomY = cy + 4;
188
+ const legY = cy + 10;
189
+
190
+ // Head
191
+ g.append('circle')
192
+ .attr('cx', cx)
193
+ .attr('cy', headY)
194
+ .attr('r', PERSON_HEAD_R)
195
+ .attr('fill', 'none')
196
+ .attr('stroke', color)
197
+ .attr('stroke-width', PERSON_SW);
198
+
199
+ // Body
200
+ g.append('line')
201
+ .attr('x1', cx)
202
+ .attr('y1', bodyTopY)
203
+ .attr('x2', cx)
204
+ .attr('y2', bodyBottomY)
205
+ .attr('stroke', color)
206
+ .attr('stroke-width', PERSON_SW);
207
+
208
+ // Arms
209
+ g.append('line')
210
+ .attr('x1', cx - PERSON_ARM_SPAN)
211
+ .attr('y1', bodyTopY + 3)
212
+ .attr('x2', cx + PERSON_ARM_SPAN)
213
+ .attr('y2', bodyTopY + 3)
214
+ .attr('stroke', color)
215
+ .attr('stroke-width', PERSON_SW);
216
+
217
+ // Left leg
218
+ g.append('line')
219
+ .attr('x1', cx)
220
+ .attr('y1', bodyBottomY)
221
+ .attr('x2', cx - PERSON_LEG_SPAN)
222
+ .attr('y2', legY)
223
+ .attr('stroke', color)
224
+ .attr('stroke-width', PERSON_SW);
225
+
226
+ // Right leg
227
+ g.append('line')
228
+ .attr('x1', cx)
229
+ .attr('y1', bodyBottomY)
230
+ .attr('x2', cx + PERSON_LEG_SPAN)
231
+ .attr('y2', legY)
232
+ .attr('stroke', color)
233
+ .attr('stroke-width', PERSON_SW);
234
+ }
235
+
236
+ // ============================================================
237
+ // Main Renderer
238
+ // ============================================================
239
+
240
+ type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
241
+
242
+ export function renderC4Context(
243
+ container: HTMLDivElement,
244
+ parsed: ParsedC4,
245
+ layout: C4LayoutResult,
246
+ palette: PaletteColors,
247
+ isDark: boolean,
248
+ onClickItem?: (lineNumber: number) => void,
249
+ exportDims?: { width?: number; height?: number },
250
+ activeTagGroup?: string | null
251
+ ): void {
252
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
253
+
254
+ const width = exportDims?.width ?? container.clientWidth;
255
+ const height = exportDims?.height ?? container.clientHeight;
256
+ if (width <= 0 || height <= 0) return;
257
+
258
+ const titleHeight = parsed.title ? TITLE_HEIGHT + 10 : 0;
259
+ const diagramW = layout.width;
260
+ const diagramH = layout.height;
261
+ const availH = height - titleHeight;
262
+ const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
263
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
264
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
265
+
266
+ const scaledW = diagramW * scale;
267
+ const scaledH = diagramH * scale;
268
+ const offsetX = (width - scaledW) / 2;
269
+ const offsetY = titleHeight + DIAGRAM_PADDING;
270
+
271
+ const svg = d3Selection
272
+ .select(container)
273
+ .append('svg')
274
+ .attr('width', width)
275
+ .attr('height', height)
276
+ .style('font-family', FONT_FAMILY);
277
+
278
+ // ── Marker defs ──
279
+ const defs = svg.append('defs');
280
+ const AW = 10;
281
+ const AH = 7;
282
+
283
+ // Filled triangle — end marker
284
+ defs
285
+ .append('marker')
286
+ .attr('id', 'c4-arrow-end')
287
+ .attr('viewBox', `0 0 ${AW} ${AH}`)
288
+ .attr('refX', AW)
289
+ .attr('refY', AH / 2)
290
+ .attr('markerWidth', AW)
291
+ .attr('markerHeight', AH)
292
+ .attr('orient', 'auto')
293
+ .append('polygon')
294
+ .attr('points', `0,0 ${AW},${AH / 2} 0,${AH}`)
295
+ .attr('fill', palette.textMuted);
296
+
297
+ // Filled triangle — start marker (for bidirectional)
298
+ defs
299
+ .append('marker')
300
+ .attr('id', 'c4-arrow-start')
301
+ .attr('viewBox', `0 0 ${AW} ${AH}`)
302
+ .attr('refX', 0)
303
+ .attr('refY', AH / 2)
304
+ .attr('markerWidth', AW)
305
+ .attr('markerHeight', AH)
306
+ .attr('orient', 'auto')
307
+ .append('polygon')
308
+ .attr('points', `${AW},0 0,${AH / 2} ${AW},${AH}`)
309
+ .attr('fill', palette.textMuted);
310
+
311
+ // ── Title ──
312
+ if (parsed.title) {
313
+ const titleEl = svg
314
+ .append('text')
315
+ .attr('class', 'chart-title')
316
+ .attr('x', width / 2)
317
+ .attr('y', 30)
318
+ .attr('text-anchor', 'middle')
319
+ .attr('fill', palette.text)
320
+ .attr('font-size', `${TITLE_FONT_SIZE}px`)
321
+ .attr('font-weight', '700')
322
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
323
+ .text(parsed.title);
324
+
325
+ if (parsed.titleLineNumber) {
326
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
327
+ if (onClickItem) {
328
+ titleEl
329
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
330
+ .on('mouseenter', function () {
331
+ d3Selection.select(this).attr('opacity', 0.7);
332
+ })
333
+ .on('mouseleave', function () {
334
+ d3Selection.select(this).attr('opacity', 1);
335
+ });
336
+ }
337
+ }
338
+ }
339
+
340
+ // ── Content group ──
341
+ const contentG = svg
342
+ .append('g')
343
+ .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
344
+
345
+ // ── Edges (behind nodes) ──
346
+ for (const edge of layout.edges) {
347
+ if (edge.points.length < 2) continue;
348
+
349
+ const edgeG = contentG
350
+ .append('g')
351
+ .attr('class', 'c4-edge-group')
352
+ .attr('data-line-number', String(edge.lineNumber));
353
+
354
+ if (onClickItem) {
355
+ edgeG.style('cursor', 'pointer').on('click', () => {
356
+ onClickItem(edge.lineNumber);
357
+ });
358
+ }
359
+
360
+ const edgeColor = palette.textMuted;
361
+ const dashed = isDashedEdge(edge.arrowType);
362
+ const bidir = hasBidirectionalMarkers(edge.arrowType);
363
+
364
+ const pathD = lineGenerator(edge.points);
365
+ if (pathD) {
366
+ const pathEl = edgeG
367
+ .append('path')
368
+ .attr('d', pathD)
369
+ .attr('fill', 'none')
370
+ .attr('stroke', edgeColor)
371
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
372
+ .attr('class', 'c4-edge')
373
+ .attr('marker-end', 'url(#c4-arrow-end)');
374
+
375
+ if (dashed) {
376
+ pathEl.attr('stroke-dasharray', '6 3');
377
+ }
378
+
379
+ if (bidir) {
380
+ pathEl.attr('marker-start', 'url(#c4-arrow-start)');
381
+ }
382
+ }
383
+
384
+ // Label at midpoint
385
+ if (edge.label || edge.technology) {
386
+ const midIdx = Math.floor(edge.points.length / 2);
387
+ const midPt = edge.points[midIdx];
388
+
389
+ const labelText = edge.label ?? '';
390
+ const techText = edge.technology ? `[${edge.technology}]` : '';
391
+
392
+ // Background rect
393
+ const textLen = Math.max(labelText.length, techText.length);
394
+ const bgW = textLen * 7 + 12;
395
+ const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
396
+
397
+ edgeG
398
+ .append('rect')
399
+ .attr('x', midPt.x - bgW / 2)
400
+ .attr('y', midPt.y - bgH / 2)
401
+ .attr('width', bgW)
402
+ .attr('height', bgH)
403
+ .attr('rx', 3)
404
+ .attr('fill', palette.bg)
405
+ .attr('opacity', 0.9)
406
+ .attr('class', 'c4-edge-label-bg');
407
+
408
+ let textY = midPt.y;
409
+ if (labelText && techText) {
410
+ textY = midPt.y - 4;
411
+ }
412
+
413
+ if (labelText) {
414
+ edgeG
415
+ .append('text')
416
+ .attr('x', midPt.x)
417
+ .attr('y', textY + 4)
418
+ .attr('text-anchor', 'middle')
419
+ .attr('fill', edgeColor)
420
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
421
+ .attr('class', 'c4-edge-label')
422
+ .text(labelText);
423
+ }
424
+
425
+ if (techText) {
426
+ edgeG
427
+ .append('text')
428
+ .attr('x', midPt.x)
429
+ .attr('y', (labelText ? textY + 18 : textY + 4))
430
+ .attr('text-anchor', 'middle')
431
+ .attr('fill', edgeColor)
432
+ .attr('font-size', TECH_FONT_SIZE)
433
+ .attr('font-style', 'italic')
434
+ .attr('class', 'c4-edge-tech')
435
+ .text(techText);
436
+ }
437
+ }
438
+ }
439
+
440
+ // ── Nodes (top layer) ──
441
+ for (const node of layout.nodes) {
442
+ const nodeG = contentG
443
+ .append('g')
444
+ .attr('transform', `translate(${node.x}, ${node.y})`)
445
+ .attr('class', 'c4-card')
446
+ .attr('data-line-number', String(node.lineNumber))
447
+ .attr('data-node-id', node.id);
448
+
449
+ if (node.importPath) {
450
+ nodeG.attr('data-import-path', node.importPath);
451
+ }
452
+
453
+ if (onClickItem) {
454
+ nodeG.style('cursor', 'pointer').on('click', () => {
455
+ onClickItem(node.lineNumber);
456
+ });
457
+ }
458
+
459
+ const w = node.width;
460
+ const h = node.height;
461
+ const fill = nodeFill(palette, isDark, node.type, node.color);
462
+ const stroke = nodeStroke(palette, node.type, node.color);
463
+
464
+ // Card background
465
+ nodeG
466
+ .append('rect')
467
+ .attr('x', -w / 2)
468
+ .attr('y', -h / 2)
469
+ .attr('width', w)
470
+ .attr('height', h)
471
+ .attr('rx', CARD_RADIUS)
472
+ .attr('ry', CARD_RADIUS)
473
+ .attr('fill', fill)
474
+ .attr('stroke', stroke)
475
+ .attr('stroke-width', NODE_STROKE_WIDTH);
476
+
477
+ let yPos = -h / 2 + CARD_V_PAD;
478
+
479
+ // Type label (e.g. «person» or «system»)
480
+ const typeLabel = `\u00AB${node.type}\u00BB`;
481
+ nodeG
482
+ .append('text')
483
+ .attr('x', 0)
484
+ .attr('y', yPos + TYPE_FONT_SIZE / 2)
485
+ .attr('text-anchor', 'middle')
486
+ .attr('dominant-baseline', 'central')
487
+ .attr('fill', palette.textMuted)
488
+ .attr('font-size', TYPE_FONT_SIZE)
489
+ .attr('font-style', 'italic')
490
+ .text(typeLabel);
491
+
492
+ yPos += TYPE_LABEL_HEIGHT;
493
+
494
+ // Name (bold) — above divider
495
+ if (node.type === 'person') {
496
+ // Person icon to the left of name
497
+ const nameCharWidth = NAME_FONT_SIZE * 0.6;
498
+ const textWidth = node.name.length * nameCharWidth;
499
+ const gap = 6;
500
+ const totalWidth = PERSON_ICON_W + gap + textWidth;
501
+ const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
502
+ const textX = iconCx + PERSON_ICON_W / 2 + gap;
503
+
504
+ drawPersonIcon(
505
+ nodeG as GSelection,
506
+ iconCx,
507
+ yPos + NAME_FONT_SIZE / 2 - 2,
508
+ stroke
509
+ );
510
+
511
+ nodeG
512
+ .append('text')
513
+ .attr('x', textX)
514
+ .attr('y', yPos + NAME_FONT_SIZE / 2)
515
+ .attr('text-anchor', 'start')
516
+ .attr('dominant-baseline', 'central')
517
+ .attr('fill', palette.text)
518
+ .attr('font-size', NAME_FONT_SIZE)
519
+ .attr('font-weight', 'bold')
520
+ .text(node.name);
521
+ } else {
522
+ nodeG
523
+ .append('text')
524
+ .attr('x', 0)
525
+ .attr('y', yPos + NAME_FONT_SIZE / 2)
526
+ .attr('text-anchor', 'middle')
527
+ .attr('dominant-baseline', 'central')
528
+ .attr('fill', palette.text)
529
+ .attr('font-size', NAME_FONT_SIZE)
530
+ .attr('font-weight', 'bold')
531
+ .text(node.name);
532
+ }
533
+
534
+ yPos += NAME_HEIGHT;
535
+
536
+ // Subtle divider — between name and description
537
+ nodeG
538
+ .append('line')
539
+ .attr('x1', -w / 2 + CARD_H_PAD / 2)
540
+ .attr('y1', yPos)
541
+ .attr('x2', w / 2 - CARD_H_PAD / 2)
542
+ .attr('y2', yPos)
543
+ .attr('stroke', stroke)
544
+ .attr('stroke-width', 0.5)
545
+ .attr('stroke-opacity', 0.4);
546
+
547
+ yPos += DIVIDER_GAP;
548
+
549
+ // Description (wrapping, muted)
550
+ if (node.description) {
551
+ const contentWidth = w - CARD_H_PAD * 2;
552
+ const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
553
+ for (const line of lines) {
554
+ nodeG
555
+ .append('text')
556
+ .attr('x', 0)
557
+ .attr('y', yPos + DESC_FONT_SIZE / 2)
558
+ .attr('text-anchor', 'middle')
559
+ .attr('dominant-baseline', 'central')
560
+ .attr('fill', palette.textMuted)
561
+ .attr('font-size', DESC_FONT_SIZE)
562
+ .text(line);
563
+ yPos += DESC_LINE_HEIGHT;
564
+ }
565
+ }
566
+
567
+ // Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
568
+ if (node.drillable) {
569
+ const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
570
+ nodeG.append('clipPath').attr('id', clipId)
571
+ .append('rect')
572
+ .attr('x', -w / 2).attr('y', -h / 2)
573
+ .attr('width', w).attr('height', h)
574
+ .attr('rx', CARD_RADIUS);
575
+ nodeG.append('rect')
576
+ .attr('x', -w / 2)
577
+ .attr('y', h / 2 - DRILL_BAR_HEIGHT)
578
+ .attr('width', w)
579
+ .attr('height', DRILL_BAR_HEIGHT)
580
+ .attr('fill', stroke)
581
+ .attr('clip-path', `url(#${clipId})`)
582
+ .attr('class', 'c4-drill-bar');
583
+ }
584
+ }
585
+
586
+ // ── Legend ──
587
+ if (!exportDims) {
588
+ for (const group of layout.legend) {
589
+ const isActive =
590
+ activeTagGroup != null &&
591
+ group.name.toLowerCase() === (activeTagGroup ?? '').toLowerCase();
592
+
593
+ if (activeTagGroup != null && !isActive) continue;
594
+
595
+ const groupBg = isDark
596
+ ? mix(palette.surface, palette.bg, 50)
597
+ : mix(palette.surface, palette.bg, 30);
598
+
599
+ const pillLabel = group.name;
600
+ const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
601
+
602
+ const gEl = contentG
603
+ .append('g')
604
+ .attr('transform', `translate(${group.x}, ${group.y})`)
605
+ .attr('class', 'c4-legend-group')
606
+ .attr('data-legend-group', group.name.toLowerCase())
607
+ .style('cursor', 'pointer');
608
+
609
+ if (isActive) {
610
+ gEl
611
+ .append('rect')
612
+ .attr('width', group.width)
613
+ .attr('height', LEGEND_HEIGHT)
614
+ .attr('rx', LEGEND_HEIGHT / 2)
615
+ .attr('fill', groupBg);
616
+ }
617
+
618
+ const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
619
+ const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
620
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
621
+
622
+ gEl
623
+ .append('rect')
624
+ .attr('x', pillX)
625
+ .attr('y', pillY)
626
+ .attr('width', pillWidth)
627
+ .attr('height', pillH)
628
+ .attr('rx', pillH / 2)
629
+ .attr('fill', isActive ? palette.bg : groupBg);
630
+
631
+ if (isActive) {
632
+ gEl
633
+ .append('rect')
634
+ .attr('x', pillX)
635
+ .attr('y', pillY)
636
+ .attr('width', pillWidth)
637
+ .attr('height', pillH)
638
+ .attr('rx', pillH / 2)
639
+ .attr('fill', 'none')
640
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
641
+ .attr('stroke-width', 0.75);
642
+ }
643
+
644
+ gEl
645
+ .append('text')
646
+ .attr('x', pillX + pillWidth / 2)
647
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
648
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
649
+ .attr('font-weight', '500')
650
+ .attr('fill', isActive ? palette.text : palette.textMuted)
651
+ .attr('text-anchor', 'middle')
652
+ .text(pillLabel);
653
+
654
+ if (isActive) {
655
+ let entryX = pillX + pillWidth + 4;
656
+ for (const entry of group.entries) {
657
+ const entryG = gEl
658
+ .append('g')
659
+ .attr('data-legend-entry', entry.value.toLowerCase())
660
+ .style('cursor', 'pointer');
661
+
662
+ entryG
663
+ .append('circle')
664
+ .attr('cx', entryX + LEGEND_DOT_R)
665
+ .attr('cy', LEGEND_HEIGHT / 2)
666
+ .attr('r', LEGEND_DOT_R)
667
+ .attr('fill', entry.color);
668
+
669
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
670
+ entryG
671
+ .append('text')
672
+ .attr('x', textX)
673
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
674
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
675
+ .attr('fill', palette.textMuted)
676
+ .text(entry.value);
677
+
678
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
684
+
685
+ // ============================================================
686
+ // Export convenience function
687
+ // ============================================================
688
+
689
+ export function renderC4ContextForExport(
690
+ content: string,
691
+ theme: 'light' | 'dark' | 'transparent',
692
+ palette: PaletteColors
693
+ ): string {
694
+ const parsed = parseC4(content, palette);
695
+ if (parsed.error || parsed.elements.length === 0) return '';
696
+
697
+ const layout = layoutC4Context(parsed);
698
+ const isDark = theme === 'dark';
699
+
700
+ const container = document.createElement('div');
701
+ const titleOffset = parsed.title ? TITLE_HEIGHT + 10 : 0;
702
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
703
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
704
+
705
+ container.style.width = `${exportWidth}px`;
706
+ container.style.height = `${exportHeight}px`;
707
+ container.style.position = 'absolute';
708
+ container.style.left = '-9999px';
709
+ document.body.appendChild(container);
710
+
711
+ try {
712
+ renderC4Context(container, parsed, layout, palette, isDark, undefined, {
713
+ width: exportWidth,
714
+ height: exportHeight,
715
+ });
716
+
717
+ const svgEl = container.querySelector('svg');
718
+ if (!svgEl) return '';
719
+
720
+ if (theme === 'transparent') {
721
+ svgEl.style.background = 'none';
722
+ }
723
+
724
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
725
+ svgEl.style.fontFamily = FONT_FAMILY;
726
+
727
+ return svgEl.outerHTML;
728
+ } finally {
729
+ document.body.removeChild(container);
730
+ }
731
+ }
732
+
733
+ // ============================================================
734
+ // Shape card backgrounds
735
+ // ============================================================
736
+
737
+ /**
738
+ * Draw a cylinder-shaped card background (for database/cache shapes).
739
+ * Replaces the simple rounded rect with a cylinder shape.
740
+ */
741
+ function drawCylinderCard(
742
+ nodeG: GSelection,
743
+ w: number,
744
+ h: number,
745
+ fill: string,
746
+ stroke: string,
747
+ dashed: boolean
748
+ ): void {
749
+ const ry = CYLINDER_RY;
750
+ // Build cylinder path: top ellipse, sides, bottom ellipse
751
+ const path = [
752
+ `M ${-w / 2} ${-h / 2 + ry}`,
753
+ `A ${w / 2} ${ry} 0 0 1 ${w / 2} ${-h / 2 + ry}`,
754
+ `L ${w / 2} ${h / 2 - ry}`,
755
+ `A ${w / 2} ${ry} 0 0 1 ${-w / 2} ${h / 2 - ry}`,
756
+ 'Z',
757
+ ].join(' ');
758
+
759
+ const el = nodeG
760
+ .append('path')
761
+ .attr('d', path)
762
+ .attr('fill', fill)
763
+ .attr('stroke', stroke)
764
+ .attr('stroke-width', NODE_STROKE_WIDTH);
765
+
766
+ if (dashed) {
767
+ el.attr('stroke-dasharray', '6 3');
768
+ }
769
+
770
+ // Top ellipse highlight (inner curve)
771
+ nodeG
772
+ .append('ellipse')
773
+ .attr('cx', 0)
774
+ .attr('cy', -h / 2 + ry)
775
+ .attr('rx', w / 2)
776
+ .attr('ry', ry)
777
+ .attr('fill', fill)
778
+ .attr('stroke', stroke)
779
+ .attr('stroke-width', NODE_STROKE_WIDTH);
780
+ }
781
+
782
+ /**
783
+ * Draw a standard card background rect, optionally dashed.
784
+ */
785
+ function drawCardRect(
786
+ nodeG: GSelection,
787
+ w: number,
788
+ h: number,
789
+ fill: string,
790
+ stroke: string,
791
+ dashed: boolean
792
+ ): void {
793
+ const el = nodeG
794
+ .append('rect')
795
+ .attr('x', -w / 2)
796
+ .attr('y', -h / 2)
797
+ .attr('width', w)
798
+ .attr('height', h)
799
+ .attr('rx', CARD_RADIUS)
800
+ .attr('ry', CARD_RADIUS)
801
+ .attr('fill', fill)
802
+ .attr('stroke', stroke)
803
+ .attr('stroke-width', NODE_STROKE_WIDTH);
804
+
805
+ if (dashed) {
806
+ el.attr('stroke-dasharray', '6 3');
807
+ }
808
+ }
809
+
810
+ // ============================================================
811
+ // Shared rendering helpers
812
+ // ============================================================
813
+
814
+ function renderEdges(
815
+ contentG: GSelection,
816
+ edges: C4LayoutEdge[],
817
+ palette: PaletteColors,
818
+ onClickItem?: (lineNumber: number) => void,
819
+ obstacleRects?: { x: number; y: number; w: number; h: number }[]
820
+ ): void {
821
+ // Collect labels for deferred rendering with collision avoidance
822
+ const pendingLabels: {
823
+ edgeG: GSelection;
824
+ labelText: string;
825
+ techText: string;
826
+ bgW: number;
827
+ bgH: number;
828
+ x: number;
829
+ y: number;
830
+ edgeColor: string;
831
+ edgeIdx: number;
832
+ }[] = [];
833
+
834
+ for (const edge of edges) {
835
+ if (edge.points.length < 2) continue;
836
+
837
+ const edgeG = contentG
838
+ .append('g')
839
+ .attr('class', 'c4-edge-group')
840
+ .attr('data-line-number', String(edge.lineNumber));
841
+
842
+ if (onClickItem) {
843
+ edgeG.style('cursor', 'pointer').on('click', () => {
844
+ onClickItem(edge.lineNumber);
845
+ });
846
+ }
847
+
848
+ const edgeColor = palette.textMuted;
849
+ const dashed = isDashedEdge(edge.arrowType);
850
+ const bidir = hasBidirectionalMarkers(edge.arrowType);
851
+
852
+ const pathD = lineGenerator(edge.points);
853
+ if (pathD) {
854
+ const pathEl = edgeG
855
+ .append('path')
856
+ .attr('d', pathD)
857
+ .attr('fill', 'none')
858
+ .attr('stroke', edgeColor)
859
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
860
+ .attr('class', 'c4-edge')
861
+ .attr('marker-end', 'url(#c4-arrow-end)');
862
+
863
+ if (dashed) {
864
+ pathEl.attr('stroke-dasharray', '6 3');
865
+ }
866
+
867
+ if (bidir) {
868
+ pathEl.attr('marker-start', 'url(#c4-arrow-start)');
869
+ }
870
+ }
871
+
872
+ // Collect label info for deferred placement
873
+ if (edge.label || edge.technology) {
874
+ const labelText = edge.label ?? '';
875
+ const techText = edge.technology ? `[${edge.technology}]` : '';
876
+ const textLen = Math.max(labelText.length, techText.length);
877
+ const bgW = textLen * 7 + 12;
878
+ const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
879
+
880
+ pendingLabels.push({
881
+ edgeG,
882
+ labelText,
883
+ techText,
884
+ bgW,
885
+ bgH,
886
+ x: 0,
887
+ y: 0,
888
+ edgeColor,
889
+ edgeIdx: edges.indexOf(edge),
890
+ });
891
+ }
892
+ }
893
+
894
+ // Place labels using maximum-clearance algorithm: for each label,
895
+ // find the position along its own edge that is farthest from all
896
+ // other edges and already-placed labels.
897
+ placeEdgeLabels(pendingLabels, edges, obstacleRects);
898
+
899
+ // Render all labels
900
+ for (const lbl of pendingLabels) {
901
+ lbl.edgeG
902
+ .append('rect')
903
+ .attr('x', lbl.x - lbl.bgW / 2)
904
+ .attr('y', lbl.y - lbl.bgH / 2)
905
+ .attr('width', lbl.bgW)
906
+ .attr('height', lbl.bgH)
907
+ .attr('rx', 3)
908
+ .attr('fill', palette.bg)
909
+ .attr('opacity', 0.9)
910
+ .attr('class', 'c4-edge-label-bg');
911
+
912
+ let textY = lbl.y;
913
+ if (lbl.labelText && lbl.techText) {
914
+ textY = lbl.y - 4;
915
+ }
916
+
917
+ if (lbl.labelText) {
918
+ lbl.edgeG
919
+ .append('text')
920
+ .attr('x', lbl.x)
921
+ .attr('y', textY + 4)
922
+ .attr('text-anchor', 'middle')
923
+ .attr('fill', lbl.edgeColor)
924
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
925
+ .attr('class', 'c4-edge-label')
926
+ .text(lbl.labelText);
927
+ }
928
+
929
+ if (lbl.techText) {
930
+ lbl.edgeG
931
+ .append('text')
932
+ .attr('x', lbl.x)
933
+ .attr('y', lbl.labelText ? textY + 18 : textY + 4)
934
+ .attr('text-anchor', 'middle')
935
+ .attr('fill', lbl.edgeColor)
936
+ .attr('font-size', TECH_FONT_SIZE)
937
+ .attr('font-style', 'italic')
938
+ .attr('class', 'c4-edge-tech')
939
+ .text(lbl.techText);
940
+ }
941
+ }
942
+ }
943
+
944
+ // ============================================================
945
+ // Edge Label Placement (Maximum Clearance)
946
+ // ============================================================
947
+
948
+ /** Interpolate a point at fraction t (0–1) along a polyline path. */
949
+ function interpolateAlongPath(
950
+ points: { x: number; y: number }[],
951
+ t: number
952
+ ): { x: number; y: number } {
953
+ if (points.length < 2) return points[0]!;
954
+
955
+ let totalLen = 0;
956
+ for (let i = 1; i < points.length; i++) {
957
+ const dx = points[i]!.x - points[i - 1]!.x;
958
+ const dy = points[i]!.y - points[i - 1]!.y;
959
+ totalLen += Math.sqrt(dx * dx + dy * dy);
960
+ }
961
+
962
+ const targetLen = t * totalLen;
963
+ let accumulated = 0;
964
+ for (let i = 1; i < points.length; i++) {
965
+ const dx = points[i]!.x - points[i - 1]!.x;
966
+ const dy = points[i]!.y - points[i - 1]!.y;
967
+ const segLen = Math.sqrt(dx * dx + dy * dy);
968
+ if (accumulated + segLen >= targetLen) {
969
+ const segT = segLen > 0 ? (targetLen - accumulated) / segLen : 0;
970
+ return {
971
+ x: points[i - 1]!.x + dx * segT,
972
+ y: points[i - 1]!.y + dy * segT,
973
+ };
974
+ }
975
+ accumulated += segLen;
976
+ }
977
+ return points[points.length - 1]!;
978
+ }
979
+
980
+ /** Minimum distance from point p to a line segment (a, b). */
981
+ function pointToSegmentDist(
982
+ p: { x: number; y: number },
983
+ a: { x: number; y: number },
984
+ b: { x: number; y: number }
985
+ ): number {
986
+ const dx = b.x - a.x;
987
+ const dy = b.y - a.y;
988
+ const lenSq = dx * dx + dy * dy;
989
+ if (lenSq === 0) {
990
+ const ex = p.x - a.x;
991
+ const ey = p.y - a.y;
992
+ return Math.sqrt(ex * ex + ey * ey);
993
+ }
994
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
995
+ t = Math.max(0, Math.min(1, t));
996
+ const projX = a.x + t * dx;
997
+ const projY = a.y + t * dy;
998
+ const ex = p.x - projX;
999
+ const ey = p.y - projY;
1000
+ return Math.sqrt(ex * ex + ey * ey);
1001
+ }
1002
+
1003
+ /** Minimum distance from point p to a polyline path. */
1004
+ function pointToPolylineDist(
1005
+ p: { x: number; y: number },
1006
+ points: { x: number; y: number }[]
1007
+ ): number {
1008
+ let minDist = Infinity;
1009
+ for (let i = 1; i < points.length; i++) {
1010
+ const d = pointToSegmentDist(p, points[i - 1]!, points[i]!);
1011
+ if (d < minDist) minDist = d;
1012
+ }
1013
+ return minDist;
1014
+ }
1015
+
1016
+ /** Check if a rect overlaps another rect. */
1017
+ function rectsOverlap(
1018
+ ax: number, ay: number, aw: number, ah: number,
1019
+ bx: number, by: number, bw: number, bh: number,
1020
+ pad: number
1021
+ ): boolean {
1022
+ return !(
1023
+ ax + aw / 2 + pad < bx - bw / 2 - pad ||
1024
+ bx + bw / 2 + pad < ax - aw / 2 - pad ||
1025
+ ay + ah / 2 + pad < by - bh / 2 - pad ||
1026
+ by + bh / 2 + pad < ay - ah / 2 - pad
1027
+ );
1028
+ }
1029
+
1030
+ /**
1031
+ * Place edge labels using maximum-clearance algorithm.
1032
+ *
1033
+ * For each edge with a label, samples candidate positions along the edge
1034
+ * path (avoiding the endpoints near nodes) and scores each by:
1035
+ * 1. Minimum distance to all OTHER edge paths (want: far from other lines)
1036
+ * 2. No overlap with already-placed labels (hard constraint)
1037
+ *
1038
+ * Labels are placed greedily: edges with fewer good positions go first.
1039
+ */
1040
+ /** Compute the tangent direction at fraction t along a polyline. */
1041
+ function tangentAt(
1042
+ points: { x: number; y: number }[],
1043
+ t: number
1044
+ ): { x: number; y: number } {
1045
+ if (points.length < 2) return { x: 0, y: 1 };
1046
+ let totalLen = 0;
1047
+ for (let i = 1; i < points.length; i++) {
1048
+ const dx = points[i]!.x - points[i - 1]!.x;
1049
+ const dy = points[i]!.y - points[i - 1]!.y;
1050
+ totalLen += Math.sqrt(dx * dx + dy * dy);
1051
+ }
1052
+ const targetLen = t * totalLen;
1053
+ let accumulated = 0;
1054
+ for (let i = 1; i < points.length; i++) {
1055
+ const dx = points[i]!.x - points[i - 1]!.x;
1056
+ const dy = points[i]!.y - points[i - 1]!.y;
1057
+ const segLen = Math.sqrt(dx * dx + dy * dy);
1058
+ if (accumulated + segLen >= targetLen || i === points.length - 1) {
1059
+ return { x: dx, y: dy };
1060
+ }
1061
+ accumulated += segLen;
1062
+ }
1063
+ return { x: 0, y: 1 };
1064
+ }
1065
+
1066
+ function placeEdgeLabels(
1067
+ labels: {
1068
+ edgeIdx: number;
1069
+ bgW: number;
1070
+ bgH: number;
1071
+ x: number;
1072
+ y: number;
1073
+ }[],
1074
+ edges: C4LayoutEdge[],
1075
+ obstacleRects?: { x: number; y: number; w: number; h: number }[]
1076
+ ): void {
1077
+ if (labels.length === 0) return;
1078
+
1079
+ // Collect all edge polylines
1080
+ const allPaths = edges.map((e) => e.points);
1081
+
1082
+ // Already-placed label rects for overlap checking
1083
+ const placedRects: { x: number; y: number; w: number; h: number }[] = [];
1084
+
1085
+ // Bias samples toward target end (50–90%) where edges have diverged
1086
+ const SAMPLES = [0.40, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90];
1087
+
1088
+ // Pre-compute candidate positions for each label
1089
+ const candidates = labels.map((lbl) => {
1090
+ const ownPath = allPaths[lbl.edgeIdx]!;
1091
+ return SAMPLES.map((t) => ({ pt: interpolateAlongPath(ownPath, t), t }));
1092
+ });
1093
+
1094
+ // Place greedily — label with fewest high-scoring candidates goes first
1095
+ const order = labels.map((_, i) => i);
1096
+
1097
+ // Sort: labels on edges with more nearby edges go first (hardest to place)
1098
+ order.sort((a, b) => {
1099
+ const midA = interpolateAlongPath(allPaths[labels[a]!.edgeIdx]!, 0.5);
1100
+ const midB = interpolateAlongPath(allPaths[labels[b]!.edgeIdx]!, 0.5);
1101
+ let nearA = 0, nearB = 0;
1102
+ for (let e = 0; e < allPaths.length; e++) {
1103
+ if (e === labels[a]!.edgeIdx) continue;
1104
+ if (pointToPolylineDist(midA, allPaths[e]!) < 100) nearA++;
1105
+ }
1106
+ for (let e = 0; e < allPaths.length; e++) {
1107
+ if (e === labels[b]!.edgeIdx) continue;
1108
+ if (pointToPolylineDist(midB, allPaths[e]!) < 100) nearB++;
1109
+ }
1110
+ return nearB - nearA; // Most constrained first
1111
+ });
1112
+
1113
+ for (const idx of order) {
1114
+ const lbl = labels[idx]!;
1115
+ const ownEdgeIdx = lbl.edgeIdx;
1116
+ const ownPath = allPaths[ownEdgeIdx]!;
1117
+ const cands = candidates[idx]!;
1118
+
1119
+ let bestScore = -Infinity;
1120
+ let bestPt = cands[Math.floor(cands.length / 2)]!.pt;
1121
+ let bestT = 0.5;
1122
+
1123
+ for (const { pt, t } of cands) {
1124
+ // Min distance to all OTHER edge paths
1125
+ let minEdgeDist = Infinity;
1126
+ for (let e = 0; e < allPaths.length; e++) {
1127
+ if (e === ownEdgeIdx) continue;
1128
+ const d = pointToPolylineDist(pt, allPaths[e]!);
1129
+ if (d < minEdgeDist) minEdgeDist = d;
1130
+ }
1131
+
1132
+ // Penalty for overlapping already-placed labels
1133
+ let labelOverlapPenalty = 0;
1134
+ for (const placed of placedRects) {
1135
+ if (rectsOverlap(pt.x, pt.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1136
+ labelOverlapPenalty += 200;
1137
+ }
1138
+ }
1139
+
1140
+ // Penalty for overlapping boundary/obstacle rects (e.g. boundary labels)
1141
+ if (obstacleRects) {
1142
+ for (const obs of obstacleRects) {
1143
+ if (rectsOverlap(pt.x, pt.y, lbl.bgW, lbl.bgH, obs.x + obs.w / 2, obs.y + obs.h / 2, obs.w, obs.h, 6)) {
1144
+ labelOverlapPenalty += 200;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ const score = minEdgeDist - labelOverlapPenalty;
1150
+ if (score > bestScore) {
1151
+ bestScore = score;
1152
+ bestPt = pt;
1153
+ bestT = t;
1154
+ }
1155
+ }
1156
+
1157
+ // Perpendicular offset: push label to the side of its edge with more
1158
+ // clearance from other edges. This makes it unambiguous which line a
1159
+ // label belongs to even when edges are close together.
1160
+ const tan = tangentAt(ownPath, bestT);
1161
+ const tLen = Math.sqrt(tan.x * tan.x + tan.y * tan.y);
1162
+ if (tLen > 0) {
1163
+ // Normal perpendicular to edge tangent
1164
+ const nx = -tan.y / tLen;
1165
+ const ny = tan.x / tLen;
1166
+ const offsetDist = lbl.bgH / 2 + 4;
1167
+ const sideA = { x: bestPt.x + nx * offsetDist, y: bestPt.y + ny * offsetDist };
1168
+ const sideB = { x: bestPt.x - nx * offsetDist, y: bestPt.y - ny * offsetDist };
1169
+
1170
+ // Score each side: clearance from other edges + overlap with placed labels
1171
+ let scoreA = Infinity, scoreB = Infinity;
1172
+ for (let e = 0; e < allPaths.length; e++) {
1173
+ if (e === ownEdgeIdx) continue;
1174
+ scoreA = Math.min(scoreA, pointToPolylineDist(sideA, allPaths[e]!));
1175
+ scoreB = Math.min(scoreB, pointToPolylineDist(sideB, allPaths[e]!));
1176
+ }
1177
+ for (const placed of placedRects) {
1178
+ if (rectsOverlap(sideA.x, sideA.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1179
+ scoreA -= 200;
1180
+ }
1181
+ if (rectsOverlap(sideB.x, sideB.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1182
+ scoreB -= 200;
1183
+ }
1184
+ }
1185
+ if (obstacleRects) {
1186
+ for (const obs of obstacleRects) {
1187
+ const cx = obs.x + obs.w / 2, cy = obs.y + obs.h / 2;
1188
+ if (rectsOverlap(sideA.x, sideA.y, lbl.bgW, lbl.bgH, cx, cy, obs.w, obs.h, 6)) {
1189
+ scoreA -= 200;
1190
+ }
1191
+ if (rectsOverlap(sideB.x, sideB.y, lbl.bgW, lbl.bgH, cx, cy, obs.w, obs.h, 6)) {
1192
+ scoreB -= 200;
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ const finalPt = scoreA >= scoreB ? sideA : sideB;
1198
+ lbl.x = finalPt.x;
1199
+ lbl.y = finalPt.y;
1200
+ } else {
1201
+ lbl.x = bestPt.x;
1202
+ lbl.y = bestPt.y;
1203
+ }
1204
+
1205
+ placedRects.push({ x: lbl.x, y: lbl.y, w: lbl.bgW, h: lbl.bgH });
1206
+ }
1207
+ }
1208
+
1209
+ function renderLegend(
1210
+ contentG: GSelection,
1211
+ layout: C4LayoutResult,
1212
+ palette: PaletteColors,
1213
+ isDark: boolean,
1214
+ activeTagGroup?: string | null
1215
+ ): void {
1216
+ for (const group of layout.legend) {
1217
+ const isActive =
1218
+ activeTagGroup != null &&
1219
+ group.name.toLowerCase() === (activeTagGroup ?? '').toLowerCase();
1220
+
1221
+ if (activeTagGroup != null && !isActive) continue;
1222
+
1223
+ const groupBg = isDark
1224
+ ? mix(palette.surface, palette.bg, 50)
1225
+ : mix(palette.surface, palette.bg, 30);
1226
+
1227
+ const pillLabel = group.name;
1228
+ const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1229
+
1230
+ const gEl = contentG
1231
+ .append('g')
1232
+ .attr('transform', `translate(${group.x}, ${group.y})`)
1233
+ .attr('class', 'c4-legend-group')
1234
+ .attr('data-legend-group', group.name.toLowerCase())
1235
+ .style('cursor', 'pointer');
1236
+
1237
+ if (isActive) {
1238
+ gEl
1239
+ .append('rect')
1240
+ .attr('width', group.width)
1241
+ .attr('height', LEGEND_HEIGHT)
1242
+ .attr('rx', LEGEND_HEIGHT / 2)
1243
+ .attr('fill', groupBg);
1244
+ }
1245
+
1246
+ const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
1247
+ const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
1248
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
1249
+
1250
+ gEl
1251
+ .append('rect')
1252
+ .attr('x', pillX)
1253
+ .attr('y', pillY)
1254
+ .attr('width', pillWidth)
1255
+ .attr('height', pillH)
1256
+ .attr('rx', pillH / 2)
1257
+ .attr('fill', isActive ? palette.bg : groupBg);
1258
+
1259
+ if (isActive) {
1260
+ gEl
1261
+ .append('rect')
1262
+ .attr('x', pillX)
1263
+ .attr('y', pillY)
1264
+ .attr('width', pillWidth)
1265
+ .attr('height', pillH)
1266
+ .attr('rx', pillH / 2)
1267
+ .attr('fill', 'none')
1268
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1269
+ .attr('stroke-width', 0.75);
1270
+ }
1271
+
1272
+ gEl
1273
+ .append('text')
1274
+ .attr('x', pillX + pillWidth / 2)
1275
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1276
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1277
+ .attr('font-weight', '500')
1278
+ .attr('fill', isActive ? palette.text : palette.textMuted)
1279
+ .attr('text-anchor', 'middle')
1280
+ .text(pillLabel);
1281
+
1282
+ if (isActive) {
1283
+ let entryX = pillX + pillWidth + 4;
1284
+ for (const entry of group.entries) {
1285
+ const entryG = gEl
1286
+ .append('g')
1287
+ .attr('data-legend-entry', entry.value.toLowerCase())
1288
+ .style('cursor', 'pointer');
1289
+
1290
+ entryG
1291
+ .append('circle')
1292
+ .attr('cx', entryX + LEGEND_DOT_R)
1293
+ .attr('cy', LEGEND_HEIGHT / 2)
1294
+ .attr('r', LEGEND_DOT_R)
1295
+ .attr('fill', entry.color);
1296
+
1297
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
1298
+ entryG
1299
+ .append('text')
1300
+ .attr('x', textX)
1301
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
1302
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1303
+ .attr('fill', palette.textMuted)
1304
+ .text(entry.value);
1305
+
1306
+ entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1307
+ }
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ // ============================================================
1313
+ // Container-Level Renderer
1314
+ // ============================================================
1315
+
1316
+ /**
1317
+ * Render a C4 container-level diagram showing containers inside a system boundary
1318
+ * with external elements outside.
1319
+ */
1320
+ export function renderC4Containers(
1321
+ container: HTMLDivElement,
1322
+ parsed: ParsedC4,
1323
+ layout: C4LayoutResult,
1324
+ palette: PaletteColors,
1325
+ isDark: boolean,
1326
+ onClickItem?: (lineNumber: number) => void,
1327
+ exportDims?: { width?: number; height?: number },
1328
+ activeTagGroup?: string | null
1329
+ ): void {
1330
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1331
+
1332
+ const width = exportDims?.width ?? container.clientWidth;
1333
+ const height = exportDims?.height ?? container.clientHeight;
1334
+ if (width <= 0 || height <= 0) return;
1335
+
1336
+ const titleHeight = parsed.title ? TITLE_HEIGHT + 10 : 0;
1337
+ const diagramW = layout.width;
1338
+ const diagramH = layout.height;
1339
+ const availH = height - titleHeight;
1340
+ const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
1341
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
1342
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
1343
+
1344
+ const scaledW = diagramW * scale;
1345
+ const scaledH = diagramH * scale;
1346
+ const offsetX = (width - scaledW) / 2;
1347
+ const offsetY = titleHeight + DIAGRAM_PADDING;
1348
+
1349
+ const svg = d3Selection
1350
+ .select(container)
1351
+ .append('svg')
1352
+ .attr('width', width)
1353
+ .attr('height', height)
1354
+ .style('font-family', FONT_FAMILY);
1355
+
1356
+ // ── Marker defs ──
1357
+ const defs = svg.append('defs');
1358
+ const AW = 10;
1359
+ const AH = 7;
1360
+
1361
+ defs
1362
+ .append('marker')
1363
+ .attr('id', 'c4-arrow-end')
1364
+ .attr('viewBox', `0 0 ${AW} ${AH}`)
1365
+ .attr('refX', AW)
1366
+ .attr('refY', AH / 2)
1367
+ .attr('markerWidth', AW)
1368
+ .attr('markerHeight', AH)
1369
+ .attr('orient', 'auto')
1370
+ .append('polygon')
1371
+ .attr('points', `0,0 ${AW},${AH / 2} 0,${AH}`)
1372
+ .attr('fill', palette.textMuted);
1373
+
1374
+ defs
1375
+ .append('marker')
1376
+ .attr('id', 'c4-arrow-start')
1377
+ .attr('viewBox', `0 0 ${AW} ${AH}`)
1378
+ .attr('refX', 0)
1379
+ .attr('refY', AH / 2)
1380
+ .attr('markerWidth', AW)
1381
+ .attr('markerHeight', AH)
1382
+ .attr('orient', 'auto')
1383
+ .append('polygon')
1384
+ .attr('points', `${AW},0 0,${AH / 2} ${AW},${AH}`)
1385
+ .attr('fill', palette.textMuted);
1386
+
1387
+ // ── Title ──
1388
+ if (parsed.title) {
1389
+ const titleEl = svg
1390
+ .append('text')
1391
+ .attr('class', 'chart-title')
1392
+ .attr('x', width / 2)
1393
+ .attr('y', 30)
1394
+ .attr('text-anchor', 'middle')
1395
+ .attr('fill', palette.text)
1396
+ .attr('font-size', `${TITLE_FONT_SIZE}px`)
1397
+ .attr('font-weight', '700')
1398
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1399
+ .text(parsed.title);
1400
+
1401
+ if (parsed.titleLineNumber) {
1402
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
1403
+ if (onClickItem) {
1404
+ titleEl
1405
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
1406
+ .on('mouseenter', function () {
1407
+ d3Selection.select(this).attr('opacity', 0.7);
1408
+ })
1409
+ .on('mouseleave', function () {
1410
+ d3Selection.select(this).attr('opacity', 1);
1411
+ });
1412
+ }
1413
+ }
1414
+ }
1415
+
1416
+ // ── Content group ──
1417
+ const contentG = svg
1418
+ .append('g')
1419
+ .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
1420
+
1421
+ // ── Boundary box (background layer) ──
1422
+ if (layout.boundary) {
1423
+ const b = layout.boundary;
1424
+ const boundaryFill = mix(palette.surface, palette.bg, 30);
1425
+ const boundaryStroke = mix(palette.textMuted, palette.bg, 50);
1426
+
1427
+ const boundaryG = contentG
1428
+ .append('g')
1429
+ .attr('class', 'c4-boundary')
1430
+ .attr('data-line-number', String(b.lineNumber));
1431
+
1432
+ if (onClickItem) {
1433
+ boundaryG.style('cursor', 'pointer').on('click', () => {
1434
+ onClickItem(b.lineNumber);
1435
+ });
1436
+ }
1437
+
1438
+ boundaryG
1439
+ .append('rect')
1440
+ .attr('x', b.x)
1441
+ .attr('y', b.y)
1442
+ .attr('width', b.width)
1443
+ .attr('height', b.height)
1444
+ .attr('rx', BOUNDARY_RADIUS)
1445
+ .attr('ry', BOUNDARY_RADIUS)
1446
+ .attr('fill', boundaryFill)
1447
+ .attr('stroke', boundaryStroke)
1448
+ .attr('stroke-width', BOUNDARY_STROKE_WIDTH);
1449
+
1450
+ // Boundary label
1451
+ boundaryG
1452
+ .append('text')
1453
+ .attr('x', b.x + 12)
1454
+ .attr('y', b.y + 16)
1455
+ .attr('fill', palette.textMuted)
1456
+ .attr('font-size', BOUNDARY_LABEL_FONT_SIZE)
1457
+ .attr('font-style', 'italic')
1458
+ .text(`${b.label} \u2014 ${b.typeLabel}`);
1459
+ }
1460
+
1461
+ // ── Group boundaries (between parent boundary and edges) ──
1462
+ if (layout.groupBoundaries.length > 0) {
1463
+ const groupFill = mix(palette.surface, palette.bg, 15);
1464
+ const groupStroke = mix(palette.textMuted, palette.bg, 60);
1465
+
1466
+ for (const gb of layout.groupBoundaries) {
1467
+ const gbG = contentG
1468
+ .append('g')
1469
+ .attr('class', 'c4-group-boundary')
1470
+ .attr('data-line-number', String(gb.lineNumber));
1471
+
1472
+ if (onClickItem) {
1473
+ gbG.style('cursor', 'pointer').on('click', () => {
1474
+ onClickItem(gb.lineNumber);
1475
+ });
1476
+ }
1477
+
1478
+ gbG
1479
+ .append('rect')
1480
+ .attr('x', gb.x)
1481
+ .attr('y', gb.y)
1482
+ .attr('width', gb.width)
1483
+ .attr('height', gb.height)
1484
+ .attr('rx', 6)
1485
+ .attr('ry', 6)
1486
+ .attr('fill', groupFill)
1487
+ .attr('stroke', groupStroke)
1488
+ .attr('stroke-width', 1);
1489
+
1490
+ // Group label — top-left, italic, name only
1491
+ gbG
1492
+ .append('text')
1493
+ .attr('x', gb.x + 10)
1494
+ .attr('y', gb.y + 14)
1495
+ .attr('fill', palette.textMuted)
1496
+ .attr('font-size', BOUNDARY_LABEL_FONT_SIZE)
1497
+ .attr('font-style', 'italic')
1498
+ .text(gb.label);
1499
+ }
1500
+ }
1501
+
1502
+ // ── Collect boundary label rects as obstacles for edge label placement ──
1503
+ const boundaryLabelObstacles: { x: number; y: number; w: number; h: number }[] = [];
1504
+ if (layout.boundary) {
1505
+ const b = layout.boundary;
1506
+ const labelText = `${b.label} \u2014 ${b.typeLabel}`;
1507
+ const w = labelText.length * 7 + 12;
1508
+ const h = BOUNDARY_LABEL_FONT_SIZE + 4;
1509
+ boundaryLabelObstacles.push({ x: b.x + 12, y: b.y + 16 - h + 4, w, h });
1510
+ }
1511
+ for (const gb of layout.groupBoundaries) {
1512
+ const w = gb.label.length * 7 + 12;
1513
+ const h = BOUNDARY_LABEL_FONT_SIZE + 4;
1514
+ boundaryLabelObstacles.push({ x: gb.x + 10, y: gb.y + 14 - h + 4, w, h });
1515
+ }
1516
+
1517
+ // ── Edges (behind nodes) ──
1518
+ renderEdges(contentG as GSelection, layout.edges, palette, onClickItem, boundaryLabelObstacles);
1519
+
1520
+ // ── Nodes ──
1521
+ for (const node of layout.nodes) {
1522
+ const nodeG = contentG
1523
+ .append('g')
1524
+ .attr('transform', `translate(${node.x}, ${node.y})`)
1525
+ .attr('class', 'c4-card')
1526
+ .attr('data-line-number', String(node.lineNumber))
1527
+ .attr('data-node-id', node.id);
1528
+
1529
+ if (node.shape) {
1530
+ nodeG.attr('data-shape', node.shape);
1531
+ }
1532
+
1533
+ if (node.importPath) {
1534
+ nodeG.attr('data-import-path', node.importPath);
1535
+ }
1536
+
1537
+ if (onClickItem) {
1538
+ nodeG.style('cursor', 'pointer').on('click', () => {
1539
+ onClickItem(node.lineNumber);
1540
+ });
1541
+ }
1542
+
1543
+ const w = node.width;
1544
+ const h = node.height;
1545
+ const fill = nodeFill(palette, isDark, node.type, node.color);
1546
+ const stroke = nodeStroke(palette, node.type, node.color);
1547
+ const shape = node.shape ?? 'default';
1548
+ const isExternalShape = shape === 'external';
1549
+
1550
+ // Card background — shape-specific
1551
+ if (shape === 'database' || shape === 'cache') {
1552
+ drawCylinderCard(nodeG as GSelection, w, h, fill, stroke, shape === 'cache');
1553
+ } else {
1554
+ drawCardRect(nodeG as GSelection, w, h, fill, stroke, isExternalShape);
1555
+ }
1556
+
1557
+ let yPos = -h / 2 + CARD_V_PAD;
1558
+
1559
+ // For cylinder shapes, offset content down past the top ellipse
1560
+ if (shape === 'database' || shape === 'cache') {
1561
+ yPos += CYLINDER_RY;
1562
+ }
1563
+
1564
+ // Type label — only for external elements (person/system); containers/components are the default
1565
+ if (node.type !== 'container' && node.type !== 'component') {
1566
+ const typeLabel = `\u00AB${node.type}\u00BB`;
1567
+ nodeG
1568
+ .append('text')
1569
+ .attr('x', 0)
1570
+ .attr('y', yPos + TYPE_FONT_SIZE / 2)
1571
+ .attr('text-anchor', 'middle')
1572
+ .attr('dominant-baseline', 'central')
1573
+ .attr('fill', palette.textMuted)
1574
+ .attr('font-size', TYPE_FONT_SIZE)
1575
+ .attr('font-style', 'italic')
1576
+ .text(typeLabel);
1577
+
1578
+ yPos += TYPE_LABEL_HEIGHT;
1579
+ }
1580
+
1581
+ // Name (bold)
1582
+ if (node.type === 'person') {
1583
+ const nameCharWidth = NAME_FONT_SIZE * 0.6;
1584
+ const textWidth = node.name.length * nameCharWidth;
1585
+ const gap = 6;
1586
+ const totalWidth = PERSON_ICON_W + gap + textWidth;
1587
+ const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
1588
+ const textX = iconCx + PERSON_ICON_W / 2 + gap;
1589
+
1590
+ drawPersonIcon(nodeG as GSelection, iconCx, yPos + NAME_FONT_SIZE / 2 - 2, stroke);
1591
+
1592
+ nodeG
1593
+ .append('text')
1594
+ .attr('x', textX)
1595
+ .attr('y', yPos + NAME_FONT_SIZE / 2)
1596
+ .attr('text-anchor', 'start')
1597
+ .attr('dominant-baseline', 'central')
1598
+ .attr('fill', palette.text)
1599
+ .attr('font-size', NAME_FONT_SIZE)
1600
+ .attr('font-weight', 'bold')
1601
+ .text(node.name);
1602
+ } else {
1603
+ nodeG
1604
+ .append('text')
1605
+ .attr('x', 0)
1606
+ .attr('y', yPos + NAME_FONT_SIZE / 2)
1607
+ .attr('text-anchor', 'middle')
1608
+ .attr('dominant-baseline', 'central')
1609
+ .attr('fill', palette.text)
1610
+ .attr('font-size', NAME_FONT_SIZE)
1611
+ .attr('font-weight', 'bold')
1612
+ .text(node.name);
1613
+ }
1614
+
1615
+ yPos += NAME_HEIGHT;
1616
+
1617
+ if (node.type === 'container') {
1618
+ // Container cards: description above divider, metadata below
1619
+
1620
+ // Description (above divider)
1621
+ if (node.description) {
1622
+ const contentWidth = w - CARD_H_PAD * 2;
1623
+ const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1624
+ for (const line of lines) {
1625
+ nodeG
1626
+ .append('text')
1627
+ .attr('x', 0)
1628
+ .attr('y', yPos + DESC_FONT_SIZE / 2)
1629
+ .attr('text-anchor', 'middle')
1630
+ .attr('dominant-baseline', 'central')
1631
+ .attr('fill', palette.textMuted)
1632
+ .attr('font-size', DESC_FONT_SIZE)
1633
+ .text(line);
1634
+ yPos += DESC_LINE_HEIGHT;
1635
+ }
1636
+ }
1637
+
1638
+ // Metadata rows below divider (org-chart style: "Key: Value")
1639
+ const metaEntries = collectCardMetadata(node.metadata);
1640
+ if (metaEntries.length > 0) {
1641
+ // Divider
1642
+ nodeG
1643
+ .append('line')
1644
+ .attr('x1', -w / 2 + CARD_H_PAD / 2)
1645
+ .attr('y1', yPos)
1646
+ .attr('x2', w / 2 - CARD_H_PAD / 2)
1647
+ .attr('y2', yPos)
1648
+ .attr('stroke', stroke)
1649
+ .attr('stroke-width', 0.5)
1650
+ .attr('stroke-opacity', 0.4);
1651
+
1652
+ yPos += DIVIDER_GAP;
1653
+
1654
+ const maxKeyLen = Math.max(...metaEntries.map((e) => e.key.length));
1655
+ const valueX = -w / 2 + CARD_H_PAD + (maxKeyLen + 2) * META_CHAR_WIDTH;
1656
+
1657
+ for (const entry of metaEntries) {
1658
+ // Key (muted)
1659
+ nodeG
1660
+ .append('text')
1661
+ .attr('x', -w / 2 + CARD_H_PAD)
1662
+ .attr('y', yPos + META_FONT_SIZE / 2)
1663
+ .attr('text-anchor', 'start')
1664
+ .attr('dominant-baseline', 'central')
1665
+ .attr('fill', palette.textMuted)
1666
+ .attr('font-size', META_FONT_SIZE)
1667
+ .text(`${entry.key}:`);
1668
+
1669
+ // Value (normal)
1670
+ nodeG
1671
+ .append('text')
1672
+ .attr('x', valueX)
1673
+ .attr('y', yPos + META_FONT_SIZE / 2)
1674
+ .attr('text-anchor', 'start')
1675
+ .attr('dominant-baseline', 'central')
1676
+ .attr('fill', palette.text)
1677
+ .attr('font-size', META_FONT_SIZE)
1678
+ .text(entry.value);
1679
+
1680
+ yPos += META_LINE_HEIGHT;
1681
+ }
1682
+ }
1683
+ } else {
1684
+ // External cards (person/system): same as context — divider then description
1685
+
1686
+ // Divider
1687
+ nodeG
1688
+ .append('line')
1689
+ .attr('x1', -w / 2 + CARD_H_PAD / 2)
1690
+ .attr('y1', yPos)
1691
+ .attr('x2', w / 2 - CARD_H_PAD / 2)
1692
+ .attr('y2', yPos)
1693
+ .attr('stroke', stroke)
1694
+ .attr('stroke-width', 0.5)
1695
+ .attr('stroke-opacity', 0.4);
1696
+
1697
+ yPos += DIVIDER_GAP;
1698
+
1699
+ // Description
1700
+ if (node.description) {
1701
+ const contentWidth = w - CARD_H_PAD * 2;
1702
+ const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1703
+ for (const line of lines) {
1704
+ nodeG
1705
+ .append('text')
1706
+ .attr('x', 0)
1707
+ .attr('y', yPos + DESC_FONT_SIZE / 2)
1708
+ .attr('text-anchor', 'middle')
1709
+ .attr('dominant-baseline', 'central')
1710
+ .attr('fill', palette.textMuted)
1711
+ .attr('font-size', DESC_FONT_SIZE)
1712
+ .text(line);
1713
+ yPos += DESC_LINE_HEIGHT;
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ // Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
1719
+ if (node.drillable) {
1720
+ const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
1721
+ nodeG.append('clipPath').attr('id', clipId)
1722
+ .append('rect')
1723
+ .attr('x', -w / 2).attr('y', -h / 2)
1724
+ .attr('width', w).attr('height', h)
1725
+ .attr('rx', CARD_RADIUS);
1726
+ nodeG.append('rect')
1727
+ .attr('x', -w / 2)
1728
+ .attr('y', h / 2 - DRILL_BAR_HEIGHT)
1729
+ .attr('width', w)
1730
+ .attr('height', DRILL_BAR_HEIGHT)
1731
+ .attr('fill', stroke)
1732
+ .attr('clip-path', `url(#${clipId})`)
1733
+ .attr('class', 'c4-drill-bar');
1734
+ }
1735
+ }
1736
+
1737
+ // ── Legend ──
1738
+ if (!exportDims) {
1739
+ renderLegend(contentG as GSelection, layout, palette, isDark, activeTagGroup);
1740
+ }
1741
+ }
1742
+
1743
+ // ============================================================
1744
+ // Container Export convenience function
1745
+ // ============================================================
1746
+
1747
+ export function renderC4ContainersForExport(
1748
+ content: string,
1749
+ systemName: string,
1750
+ theme: 'light' | 'dark' | 'transparent',
1751
+ palette: PaletteColors
1752
+ ): string {
1753
+ const parsed = parseC4(content, palette);
1754
+ if (parsed.error || parsed.elements.length === 0) return '';
1755
+
1756
+ const layout = layoutC4Containers(parsed, systemName);
1757
+ if (layout.nodes.length === 0) return '';
1758
+
1759
+ const isDark = theme === 'dark';
1760
+
1761
+ const el = document.createElement('div');
1762
+ const titleOffset = parsed.title ? TITLE_HEIGHT + 10 : 0;
1763
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
1764
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
1765
+
1766
+ el.style.width = `${exportWidth}px`;
1767
+ el.style.height = `${exportHeight}px`;
1768
+ el.style.position = 'absolute';
1769
+ el.style.left = '-9999px';
1770
+ document.body.appendChild(el);
1771
+
1772
+ try {
1773
+ renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
1774
+ width: exportWidth,
1775
+ height: exportHeight,
1776
+ });
1777
+
1778
+ const svgEl = el.querySelector('svg');
1779
+ if (!svgEl) return '';
1780
+
1781
+ if (theme === 'transparent') {
1782
+ svgEl.style.background = 'none';
1783
+ }
1784
+
1785
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1786
+ svgEl.style.fontFamily = FONT_FAMILY;
1787
+
1788
+ return svgEl.outerHTML;
1789
+ } finally {
1790
+ document.body.removeChild(el);
1791
+ }
1792
+ }
1793
+
1794
+ // ============================================================
1795
+ // Component Export convenience function
1796
+ // ============================================================
1797
+
1798
+ export function renderC4ComponentsForExport(
1799
+ content: string,
1800
+ systemName: string,
1801
+ containerName: string,
1802
+ theme: 'light' | 'dark' | 'transparent',
1803
+ palette: PaletteColors
1804
+ ): string {
1805
+ const parsed = parseC4(content, palette);
1806
+ if (parsed.error || parsed.elements.length === 0) return '';
1807
+
1808
+ const layout = layoutC4Components(parsed, systemName, containerName);
1809
+ if (layout.nodes.length === 0) return '';
1810
+
1811
+ const isDark = theme === 'dark';
1812
+
1813
+ const el = document.createElement('div');
1814
+ const titleOffset = parsed.title ? TITLE_HEIGHT + 10 : 0;
1815
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
1816
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
1817
+
1818
+ el.style.width = `${exportWidth}px`;
1819
+ el.style.height = `${exportHeight}px`;
1820
+ el.style.position = 'absolute';
1821
+ el.style.left = '-9999px';
1822
+ document.body.appendChild(el);
1823
+
1824
+ try {
1825
+ // Reuse the container renderer — it handles all node types generically
1826
+ renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
1827
+ width: exportWidth,
1828
+ height: exportHeight,
1829
+ });
1830
+
1831
+ const svgEl = el.querySelector('svg');
1832
+ if (!svgEl) return '';
1833
+
1834
+ if (theme === 'transparent') {
1835
+ svgEl.style.background = 'none';
1836
+ }
1837
+
1838
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1839
+ svgEl.style.fontFamily = FONT_FAMILY;
1840
+
1841
+ return svgEl.outerHTML;
1842
+ } finally {
1843
+ document.body.removeChild(el);
1844
+ }
1845
+ }
1846
+
1847
+ // ============================================================
1848
+ // Deployment Diagram Renderer
1849
+ // ============================================================
1850
+
1851
+ /**
1852
+ * Render a C4 deployment diagram interactively.
1853
+ * Reuses the container renderer — infrastructure boundaries are rendered
1854
+ * as group boundaries and container refs as cards (same visual pattern).
1855
+ */
1856
+ export function renderC4Deployment(
1857
+ container: HTMLDivElement,
1858
+ parsed: ParsedC4,
1859
+ layout: C4LayoutResult,
1860
+ palette: PaletteColors,
1861
+ isDark: boolean,
1862
+ onClickItem?: (lineNumber: number) => void,
1863
+ exportDims?: { width?: number; height?: number },
1864
+ activeTagGroup?: string | null,
1865
+ ): void {
1866
+ renderC4Containers(container, parsed, layout, palette, isDark, onClickItem, exportDims, activeTagGroup);
1867
+ }
1868
+
1869
+ /**
1870
+ * Export convenience function for deployment diagrams.
1871
+ */
1872
+ export function renderC4DeploymentForExport(
1873
+ content: string,
1874
+ theme: 'light' | 'dark' | 'transparent',
1875
+ palette: PaletteColors,
1876
+ ): string {
1877
+ const parsed = parseC4(content, palette);
1878
+ if (parsed.error || parsed.deployment.length === 0) return '';
1879
+
1880
+ const layout = layoutC4Deployment(parsed);
1881
+ if (layout.nodes.length === 0) return '';
1882
+
1883
+ const isDark = theme === 'dark';
1884
+
1885
+ const el = document.createElement('div');
1886
+ const titleOffset = parsed.title ? TITLE_HEIGHT + 10 : 0;
1887
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
1888
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
1889
+
1890
+ el.style.width = `${exportWidth}px`;
1891
+ el.style.height = `${exportHeight}px`;
1892
+ el.style.position = 'absolute';
1893
+ el.style.left = '-9999px';
1894
+ document.body.appendChild(el);
1895
+
1896
+ try {
1897
+ renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
1898
+ width: exportWidth,
1899
+ height: exportHeight,
1900
+ });
1901
+
1902
+ const svgEl = el.querySelector('svg');
1903
+ if (!svgEl) return '';
1904
+
1905
+ if (theme === 'transparent') {
1906
+ svgEl.style.background = 'none';
1907
+ }
1908
+
1909
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1910
+ svgEl.style.fontFamily = FONT_FAMILY;
1911
+
1912
+ return svgEl.outerHTML;
1913
+ } finally {
1914
+ document.body.removeChild(el);
1915
+ }
1916
+ }