@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
|
@@ -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
|
+
}
|