@diagrammo/dgmo 0.8.4 → 0.8.6
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/.claude/commands/dgmo.md +300 -0
- package/.cursorrules +20 -2
- package/.github/copilot-instructions.md +20 -2
- package/.windsurfrules +20 -2
- package/AGENTS.md +23 -3
- package/dist/cli.cjs +191 -189
- package/dist/editor.cjs +5 -18
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +5 -18
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +543 -0
- package/dist/highlight.cjs.map +1 -0
- package/dist/highlight.d.cts +32 -0
- package/dist/highlight.d.ts +32 -0
- package/dist/highlight.js +513 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.cjs +3253 -3356
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -56
- package/dist/index.d.ts +77 -56
- package/dist/index.js +3247 -3349
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +113 -33
- package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
- package/gallery/fixtures/slope.dgmo +7 -6
- package/package.json +26 -6
- package/src/boxes-and-lines/collapse.ts +78 -0
- package/src/boxes-and-lines/layout.ts +319 -0
- package/src/boxes-and-lines/parser.ts +694 -0
- package/src/boxes-and-lines/renderer.ts +848 -0
- package/src/boxes-and-lines/types.ts +40 -0
- package/src/c4/parser.ts +10 -5
- package/src/c4/renderer.ts +232 -56
- package/src/chart.ts +9 -4
- package/src/cli.ts +49 -6
- package/src/completion.ts +25 -33
- package/src/d3.ts +187 -46
- package/src/dgmo-router.ts +3 -7
- package/src/echarts.ts +38 -2
- package/src/editor/highlight-api.ts +444 -0
- package/src/editor/keywords.ts +6 -19
- package/src/er/parser.ts +10 -4
- package/src/gantt/parser.ts +7 -4
- package/src/gantt/renderer.ts +3 -5
- package/src/index.ts +106 -50
- package/src/infra/parser.ts +7 -5
- package/src/infra/renderer.ts +2 -2
- package/src/kanban/parser.ts +7 -5
- package/src/kanban/renderer.ts +43 -18
- package/src/org/parser.ts +7 -4
- package/src/org/renderer.ts +40 -29
- package/src/sequence/parser.ts +11 -5
- package/src/sequence/renderer.ts +114 -45
- package/src/sitemap/parser.ts +8 -4
- package/src/sitemap/renderer.ts +137 -57
- package/src/utils/legend-svg.ts +44 -20
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +21 -1
- package/gallery/fixtures/initiative-status-full.dgmo +0 -46
- package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
- package/gallery/fixtures/initiative-status.dgmo +0 -9
- package/src/initiative-status/collapse.ts +0 -76
- package/src/initiative-status/filter.ts +0 -63
- package/src/initiative-status/layout.ts +0 -650
- package/src/initiative-status/parser.ts +0 -629
- package/src/initiative-status/renderer.ts +0 -1199
- package/src/initiative-status/types.ts +0 -57
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Boxes and Lines Diagram — D3 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 {
|
|
9
|
+
LEGEND_HEIGHT,
|
|
10
|
+
LEGEND_PILL_PAD,
|
|
11
|
+
LEGEND_PILL_FONT_SIZE,
|
|
12
|
+
LEGEND_CAPSULE_PAD,
|
|
13
|
+
LEGEND_DOT_R,
|
|
14
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
15
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
16
|
+
LEGEND_ENTRY_TRAIL,
|
|
17
|
+
LEGEND_GROUP_GAP,
|
|
18
|
+
measureLegendText,
|
|
19
|
+
} from '../utils/legend-constants';
|
|
20
|
+
import {
|
|
21
|
+
TITLE_FONT_SIZE,
|
|
22
|
+
TITLE_FONT_WEIGHT,
|
|
23
|
+
TITLE_Y,
|
|
24
|
+
} from '../utils/title-constants';
|
|
25
|
+
import { contrastText, mix } from '../palettes/color-utils';
|
|
26
|
+
import { resolveTagColor } from '../utils/tag-groups';
|
|
27
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
28
|
+
import type { PaletteColors } from '../palettes';
|
|
29
|
+
import type { ParsedBoxesAndLines, BLNode } from './types';
|
|
30
|
+
import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
|
|
31
|
+
|
|
32
|
+
// ── Constants (aligned with infra pattern) ─────────────────
|
|
33
|
+
const DIAGRAM_PADDING = 20;
|
|
34
|
+
const NODE_FONT_SIZE = 13;
|
|
35
|
+
const MIN_NODE_FONT_SIZE = 9;
|
|
36
|
+
const META_FONT_SIZE = 10;
|
|
37
|
+
const EDGE_LABEL_FONT_SIZE = 11;
|
|
38
|
+
const EDGE_STROKE_WIDTH = 1.5;
|
|
39
|
+
const NODE_STROKE_WIDTH = 1.5;
|
|
40
|
+
const NODE_RX = 8;
|
|
41
|
+
const COLLAPSE_BAR_HEIGHT = 4;
|
|
42
|
+
const ARROWHEAD_W = 5;
|
|
43
|
+
const ARROWHEAD_H = 4;
|
|
44
|
+
const CHAR_WIDTH_RATIO = 0.6;
|
|
45
|
+
const NODE_TEXT_PADDING = 12;
|
|
46
|
+
const GROUP_RX = 8;
|
|
47
|
+
const GROUP_LABEL_FONT_SIZE = 14;
|
|
48
|
+
|
|
49
|
+
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
50
|
+
type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
51
|
+
|
|
52
|
+
// ── Edge path generators ───────────────────────────────────
|
|
53
|
+
const lineGeneratorLR = d3Shape
|
|
54
|
+
.line<{ x: number; y: number }>()
|
|
55
|
+
.x((d) => d.x)
|
|
56
|
+
.y((d) => d.y)
|
|
57
|
+
.curve(d3Shape.curveMonotoneX);
|
|
58
|
+
|
|
59
|
+
const lineGeneratorTB = d3Shape
|
|
60
|
+
.line<{ x: number; y: number }>()
|
|
61
|
+
.x((d) => d.x)
|
|
62
|
+
.y((d) => d.y)
|
|
63
|
+
.curve(d3Shape.curveMonotoneY);
|
|
64
|
+
|
|
65
|
+
// ── Text fitting ───────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function splitCamelCase(word: string): string[] {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
let start = 0;
|
|
70
|
+
for (let i = 1; i < word.length; i++) {
|
|
71
|
+
const prev = word[i - 1];
|
|
72
|
+
const curr = word[i];
|
|
73
|
+
const next = i + 1 < word.length ? word[i + 1] : '';
|
|
74
|
+
const lowerToUpper =
|
|
75
|
+
prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
|
|
76
|
+
const upperRunEnd =
|
|
77
|
+
prev >= 'A' &&
|
|
78
|
+
prev <= 'Z' &&
|
|
79
|
+
curr >= 'A' &&
|
|
80
|
+
curr <= 'Z' &&
|
|
81
|
+
next >= 'a' &&
|
|
82
|
+
next <= 'z';
|
|
83
|
+
if (lowerToUpper || upperRunEnd) {
|
|
84
|
+
parts.push(word.slice(start, i));
|
|
85
|
+
start = i;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
parts.push(word.slice(start));
|
|
89
|
+
return parts.length > 1 ? parts : [word];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fitTextToNode(
|
|
93
|
+
label: string,
|
|
94
|
+
nodeWidth: number,
|
|
95
|
+
nodeHeight: number
|
|
96
|
+
): { lines: string[]; fontSize: number } {
|
|
97
|
+
const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
|
|
98
|
+
const lineHeight = 1.3;
|
|
99
|
+
|
|
100
|
+
for (
|
|
101
|
+
let fontSize = NODE_FONT_SIZE;
|
|
102
|
+
fontSize >= MIN_NODE_FONT_SIZE;
|
|
103
|
+
fontSize--
|
|
104
|
+
) {
|
|
105
|
+
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
106
|
+
const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
|
|
107
|
+
const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
|
|
108
|
+
if (maxCharsPerLine < 2 || maxLines < 1) continue;
|
|
109
|
+
if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
|
|
110
|
+
|
|
111
|
+
const words = label.split(/\s+/);
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
let current = '';
|
|
114
|
+
for (const word of words) {
|
|
115
|
+
const test = current ? `${current} ${word}` : word;
|
|
116
|
+
if (test.length <= maxCharsPerLine) {
|
|
117
|
+
current = test;
|
|
118
|
+
} else {
|
|
119
|
+
if (current) lines.push(current);
|
|
120
|
+
current = word;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (current) lines.push(current);
|
|
124
|
+
if (
|
|
125
|
+
lines.length <= maxLines &&
|
|
126
|
+
lines.every((l) => l.length <= maxCharsPerLine)
|
|
127
|
+
) {
|
|
128
|
+
return { lines, fontSize };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// CamelCase split
|
|
132
|
+
const camelWords: string[] = [];
|
|
133
|
+
for (const word of words) {
|
|
134
|
+
if (word.length > maxCharsPerLine)
|
|
135
|
+
camelWords.push(...splitCamelCase(word));
|
|
136
|
+
else camelWords.push(word);
|
|
137
|
+
}
|
|
138
|
+
const camelLines: string[] = [];
|
|
139
|
+
let cc = '';
|
|
140
|
+
for (const word of camelWords) {
|
|
141
|
+
const test = cc ? `${cc} ${word}` : word;
|
|
142
|
+
if (test.length <= maxCharsPerLine) {
|
|
143
|
+
cc = test;
|
|
144
|
+
} else {
|
|
145
|
+
if (cc) camelLines.push(cc);
|
|
146
|
+
cc = word;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cc) camelLines.push(cc);
|
|
150
|
+
if (
|
|
151
|
+
camelLines.length <= maxLines &&
|
|
152
|
+
camelLines.every((l) => l.length <= maxCharsPerLine)
|
|
153
|
+
) {
|
|
154
|
+
return { lines: camelLines, fontSize };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (fontSize > MIN_NODE_FONT_SIZE) continue;
|
|
158
|
+
|
|
159
|
+
// Hard-break
|
|
160
|
+
const hardLines: string[] = [];
|
|
161
|
+
for (const line of camelLines) {
|
|
162
|
+
if (line.length <= maxCharsPerLine) hardLines.push(line);
|
|
163
|
+
else
|
|
164
|
+
for (let i = 0; i < line.length; i += maxCharsPerLine)
|
|
165
|
+
hardLines.push(line.slice(i, i + maxCharsPerLine));
|
|
166
|
+
}
|
|
167
|
+
if (hardLines.length <= maxLines) return { lines: hardLines, fontSize };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
171
|
+
const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
|
|
172
|
+
const truncated =
|
|
173
|
+
label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
|
|
174
|
+
return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Color helpers ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function nodeColors(
|
|
180
|
+
node: BLNode,
|
|
181
|
+
tagGroups: TagGroup[],
|
|
182
|
+
activeGroupName: string | null,
|
|
183
|
+
palette: PaletteColors,
|
|
184
|
+
isDark: boolean
|
|
185
|
+
): { fill: string; stroke: string; text: string } {
|
|
186
|
+
const tagColor = resolveTagColor(node.metadata, tagGroups, activeGroupName);
|
|
187
|
+
if (tagColor) {
|
|
188
|
+
const fill = mix(tagColor, isDark ? palette.surface : palette.bg, 30);
|
|
189
|
+
const stroke = tagColor;
|
|
190
|
+
const text = contrastText(fill, '#eceff4', '#2e3440');
|
|
191
|
+
return { fill, stroke, text };
|
|
192
|
+
}
|
|
193
|
+
// Untagged fallback (matches infra node styling)
|
|
194
|
+
const fill = mix(palette.bg, palette.text, isDark ? 90 : 95);
|
|
195
|
+
const stroke = mix(palette.text, palette.bg, isDark ? 60 : 40);
|
|
196
|
+
const text = palette.text;
|
|
197
|
+
return { fill, stroke, text };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function edgeColor(
|
|
201
|
+
edge: BLLayoutEdge,
|
|
202
|
+
tagGroups: TagGroup[],
|
|
203
|
+
activeGroupName: string | null,
|
|
204
|
+
palette: PaletteColors
|
|
205
|
+
): string {
|
|
206
|
+
// Only color edges that have explicit tag metadata — otherwise neutral
|
|
207
|
+
const hasTagMeta =
|
|
208
|
+
Object.keys(edge.metadata).length > 0 && activeGroupName != null;
|
|
209
|
+
if (hasTagMeta) {
|
|
210
|
+
const tagColor = resolveTagColor(edge.metadata, tagGroups, activeGroupName);
|
|
211
|
+
if (tagColor) return tagColor;
|
|
212
|
+
}
|
|
213
|
+
return palette.textMuted;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Arrowhead markers ──────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
function ensureArrowMarkers(
|
|
219
|
+
defs: d3Selection.Selection<SVGDefsElement, unknown, null, undefined>,
|
|
220
|
+
colors: Set<string>
|
|
221
|
+
): void {
|
|
222
|
+
for (const color of colors) {
|
|
223
|
+
const id = `bl-arrow-${color.replace('#', '')}`;
|
|
224
|
+
if (!defs.select(`#${id}`).empty()) continue;
|
|
225
|
+
defs
|
|
226
|
+
.append('marker')
|
|
227
|
+
.attr('id', id)
|
|
228
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_W * 2} ${ARROWHEAD_H * 2}`)
|
|
229
|
+
.attr('refX', ARROWHEAD_W * 2)
|
|
230
|
+
.attr('refY', ARROWHEAD_H)
|
|
231
|
+
.attr('markerWidth', ARROWHEAD_W)
|
|
232
|
+
.attr('markerHeight', ARROWHEAD_H)
|
|
233
|
+
.attr('orient', 'auto')
|
|
234
|
+
.append('polygon')
|
|
235
|
+
.attr(
|
|
236
|
+
'points',
|
|
237
|
+
`0,0 ${ARROWHEAD_W * 2},${ARROWHEAD_H} 0,${ARROWHEAD_H * 2}`
|
|
238
|
+
)
|
|
239
|
+
.attr('fill', color);
|
|
240
|
+
|
|
241
|
+
// Reverse marker for bidirectional
|
|
242
|
+
const revId = `bl-arrow-rev-${color.replace('#', '')}`;
|
|
243
|
+
if (!defs.select(`#${revId}`).empty()) continue;
|
|
244
|
+
defs
|
|
245
|
+
.append('marker')
|
|
246
|
+
.attr('id', revId)
|
|
247
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_W * 2} ${ARROWHEAD_H * 2}`)
|
|
248
|
+
.attr('refX', 0)
|
|
249
|
+
.attr('refY', ARROWHEAD_H)
|
|
250
|
+
.attr('markerWidth', ARROWHEAD_W)
|
|
251
|
+
.attr('markerHeight', ARROWHEAD_H)
|
|
252
|
+
.attr('orient', 'auto')
|
|
253
|
+
.append('polygon')
|
|
254
|
+
.attr(
|
|
255
|
+
'points',
|
|
256
|
+
`${ARROWHEAD_W * 2},0 0,${ARROWHEAD_H} ${ARROWHEAD_W * 2},${ARROWHEAD_H * 2}`
|
|
257
|
+
)
|
|
258
|
+
.attr('fill', color);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Edge label overlap resolution ──────────────────────────
|
|
263
|
+
|
|
264
|
+
function resolveEdgeLabelOverlaps(
|
|
265
|
+
labels: { x: number; y: number; width: number; height: number }[]
|
|
266
|
+
): void {
|
|
267
|
+
const MAX_PASSES = 8;
|
|
268
|
+
const PAD = 4;
|
|
269
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
270
|
+
let moved = false;
|
|
271
|
+
for (let i = 0; i < labels.length; i++) {
|
|
272
|
+
for (let j = i + 1; j < labels.length; j++) {
|
|
273
|
+
const a = labels[i];
|
|
274
|
+
const b = labels[j];
|
|
275
|
+
const dx = Math.abs(a.x - b.x);
|
|
276
|
+
const dy = Math.abs(a.y - b.y);
|
|
277
|
+
const overlapX = (a.width + b.width) / 2 + PAD - dx;
|
|
278
|
+
const overlapY = (a.height + b.height) / 2 + PAD - dy;
|
|
279
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
280
|
+
const shift = overlapY / 2 + 1;
|
|
281
|
+
if (a.y < b.y) {
|
|
282
|
+
a.y -= shift;
|
|
283
|
+
b.y += shift;
|
|
284
|
+
} else {
|
|
285
|
+
a.y += shift;
|
|
286
|
+
b.y -= shift;
|
|
287
|
+
}
|
|
288
|
+
moved = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!moved) break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Main render function ───────────────────────────────────
|
|
297
|
+
|
|
298
|
+
export interface BLRenderOptions {
|
|
299
|
+
onClickItem?: (lineNumber: number) => void;
|
|
300
|
+
exportDims?: { width?: number; height?: number };
|
|
301
|
+
activeTagGroup?: string | null;
|
|
302
|
+
hiddenTagValues?: Map<string, Set<string>>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function renderBoxesAndLines(
|
|
306
|
+
container: HTMLDivElement,
|
|
307
|
+
parsed: ParsedBoxesAndLines,
|
|
308
|
+
layout: BLLayoutResult,
|
|
309
|
+
palette: PaletteColors,
|
|
310
|
+
isDark: boolean,
|
|
311
|
+
options?: BLRenderOptions
|
|
312
|
+
): void {
|
|
313
|
+
const { onClickItem, exportDims, activeTagGroup, hiddenTagValues } =
|
|
314
|
+
options ?? {};
|
|
315
|
+
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
316
|
+
|
|
317
|
+
const width = exportDims?.width ?? container.clientWidth;
|
|
318
|
+
const height = exportDims?.height ?? container.clientHeight;
|
|
319
|
+
if (width <= 0 || height <= 0) return;
|
|
320
|
+
|
|
321
|
+
// Determine active tag group
|
|
322
|
+
const activeGroup = activeTagGroup ?? parsed.options['active-tag'] ?? null;
|
|
323
|
+
|
|
324
|
+
// Build hidden set
|
|
325
|
+
const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
|
|
326
|
+
|
|
327
|
+
// Build node lookup
|
|
328
|
+
const nodeMap = new Map<string, BLNode>();
|
|
329
|
+
for (const node of parsed.nodes) nodeMap.set(node.label, node);
|
|
330
|
+
|
|
331
|
+
// Build layout node lookup
|
|
332
|
+
const layoutNodeMap = new Map<string, BLLayoutNode>();
|
|
333
|
+
for (const ln of layout.nodes) layoutNodeMap.set(ln.label, ln);
|
|
334
|
+
|
|
335
|
+
// Compute diagram bounds for scaling
|
|
336
|
+
const titleOffset = parsed.title ? 40 : 0;
|
|
337
|
+
const legendH = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
|
|
338
|
+
const contentW = layout.width;
|
|
339
|
+
const contentH = layout.height + titleOffset + legendH;
|
|
340
|
+
|
|
341
|
+
const scaleX = width / (contentW + DIAGRAM_PADDING * 2);
|
|
342
|
+
const scaleY = height / (contentH + DIAGRAM_PADDING * 2);
|
|
343
|
+
const scale = Math.min(scaleX, scaleY, 3);
|
|
344
|
+
|
|
345
|
+
const offsetX = (width - contentW * scale) / 2;
|
|
346
|
+
const offsetY = DIAGRAM_PADDING + titleOffset + legendH;
|
|
347
|
+
|
|
348
|
+
// Create SVG
|
|
349
|
+
const svg: D3Svg = d3Selection
|
|
350
|
+
.select(container)
|
|
351
|
+
.append('svg')
|
|
352
|
+
.attr('width', width)
|
|
353
|
+
.attr('height', height)
|
|
354
|
+
.style('font-family', FONT_FAMILY)
|
|
355
|
+
.style('background', palette.bg);
|
|
356
|
+
|
|
357
|
+
const defs = svg.append('defs');
|
|
358
|
+
|
|
359
|
+
// Title
|
|
360
|
+
if (parsed.title) {
|
|
361
|
+
svg
|
|
362
|
+
.append('text')
|
|
363
|
+
.attr('x', width / 2)
|
|
364
|
+
.attr('y', TITLE_Y)
|
|
365
|
+
.attr('text-anchor', 'middle')
|
|
366
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
367
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
368
|
+
.attr('fill', palette.text)
|
|
369
|
+
.text(parsed.title);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Main diagram group with scaling
|
|
373
|
+
const diagramG = svg
|
|
374
|
+
.append('g')
|
|
375
|
+
.attr('transform', `translate(${offsetX},${offsetY}) scale(${scale})`);
|
|
376
|
+
|
|
377
|
+
// Collect all edge colors for arrowhead markers
|
|
378
|
+
const arrowColors = new Set<string>();
|
|
379
|
+
const edgeColorMap = new Map<number, string>();
|
|
380
|
+
for (let i = 0; i < layout.edges.length; i++) {
|
|
381
|
+
const c = edgeColor(
|
|
382
|
+
layout.edges[i],
|
|
383
|
+
parsed.tagGroups,
|
|
384
|
+
activeGroup,
|
|
385
|
+
palette
|
|
386
|
+
);
|
|
387
|
+
arrowColors.add(c);
|
|
388
|
+
edgeColorMap.set(i, c);
|
|
389
|
+
}
|
|
390
|
+
ensureArrowMarkers(defs, arrowColors);
|
|
391
|
+
|
|
392
|
+
// ── Render groups (bottom layer) ───────────────────────
|
|
393
|
+
for (const group of layout.groups) {
|
|
394
|
+
const gx = group.x - group.width / 2;
|
|
395
|
+
const gy = group.y - group.height / 2;
|
|
396
|
+
|
|
397
|
+
const groupG = diagramG
|
|
398
|
+
.append('g')
|
|
399
|
+
.attr(
|
|
400
|
+
'class',
|
|
401
|
+
group.collapsed ? 'bl-group bl-group-collapsed' : 'bl-group'
|
|
402
|
+
)
|
|
403
|
+
.attr('data-line-number', String(group.lineNumber))
|
|
404
|
+
.attr('data-node-id', group.label)
|
|
405
|
+
.attr('data-group-toggle', group.label)
|
|
406
|
+
.style('cursor', 'pointer');
|
|
407
|
+
|
|
408
|
+
if (group.collapsed) {
|
|
409
|
+
// Collapsed: solid rounded rect matching node style + 6px collapse bar
|
|
410
|
+
const fillColor = isDark ? palette.surface : palette.bg;
|
|
411
|
+
const strokeColor = palette.border;
|
|
412
|
+
|
|
413
|
+
groupG
|
|
414
|
+
.append('rect')
|
|
415
|
+
.attr('x', gx)
|
|
416
|
+
.attr('y', gy)
|
|
417
|
+
.attr('width', group.width)
|
|
418
|
+
.attr('height', group.height)
|
|
419
|
+
.attr('rx', NODE_RX)
|
|
420
|
+
.attr('ry', NODE_RX)
|
|
421
|
+
.attr('fill', fillColor)
|
|
422
|
+
.attr('stroke', strokeColor)
|
|
423
|
+
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
424
|
+
|
|
425
|
+
// 6px collapse bar at bottom (clipped to rounded corners)
|
|
426
|
+
const clipId = `bl-clip-${group.label.replace(/[[\]\s]/g, '')}`;
|
|
427
|
+
groupG
|
|
428
|
+
.append('clipPath')
|
|
429
|
+
.attr('id', clipId)
|
|
430
|
+
.append('rect')
|
|
431
|
+
.attr('x', gx)
|
|
432
|
+
.attr('y', gy)
|
|
433
|
+
.attr('width', group.width)
|
|
434
|
+
.attr('height', group.height)
|
|
435
|
+
.attr('rx', NODE_RX);
|
|
436
|
+
groupG
|
|
437
|
+
.append('rect')
|
|
438
|
+
.attr('x', gx)
|
|
439
|
+
.attr('y', gy + group.height - COLLAPSE_BAR_HEIGHT)
|
|
440
|
+
.attr('width', group.width)
|
|
441
|
+
.attr('height', COLLAPSE_BAR_HEIGHT)
|
|
442
|
+
.attr('fill', strokeColor)
|
|
443
|
+
.attr('clip-path', `url(#${clipId})`)
|
|
444
|
+
.attr('class', 'bl-collapse-bar');
|
|
445
|
+
|
|
446
|
+
// Label centered vertically
|
|
447
|
+
groupG
|
|
448
|
+
.append('text')
|
|
449
|
+
.attr('class', 'bl-group-label')
|
|
450
|
+
.attr('x', group.x)
|
|
451
|
+
.attr('y', group.y)
|
|
452
|
+
.attr('text-anchor', 'middle')
|
|
453
|
+
.attr('dominant-baseline', 'central')
|
|
454
|
+
.attr('font-family', FONT_FAMILY)
|
|
455
|
+
.attr('font-size', GROUP_LABEL_FONT_SIZE)
|
|
456
|
+
.attr('font-weight', '600')
|
|
457
|
+
.attr('fill', palette.text)
|
|
458
|
+
.text(group.label);
|
|
459
|
+
} else {
|
|
460
|
+
// Expanded: background container with label
|
|
461
|
+
groupG
|
|
462
|
+
.append('rect')
|
|
463
|
+
.attr('x', gx)
|
|
464
|
+
.attr('y', gy)
|
|
465
|
+
.attr('width', group.width)
|
|
466
|
+
.attr('height', group.height)
|
|
467
|
+
.attr('rx', GROUP_RX)
|
|
468
|
+
.attr('ry', GROUP_RX)
|
|
469
|
+
.attr('fill', mix(palette.surface, palette.bg, 40))
|
|
470
|
+
.attr('stroke', palette.textMuted)
|
|
471
|
+
.attr('stroke-width', 1)
|
|
472
|
+
.attr('stroke-opacity', 0.35);
|
|
473
|
+
|
|
474
|
+
groupG
|
|
475
|
+
.append('text')
|
|
476
|
+
.attr('class', 'bl-group-label')
|
|
477
|
+
.attr('x', gx + group.width / 2)
|
|
478
|
+
.attr('y', gy + 18)
|
|
479
|
+
.attr('text-anchor', 'middle')
|
|
480
|
+
.attr('font-family', FONT_FAMILY)
|
|
481
|
+
.attr('font-size', GROUP_LABEL_FONT_SIZE)
|
|
482
|
+
.attr('font-weight', '600')
|
|
483
|
+
.attr('fill', palette.text)
|
|
484
|
+
.text(group.label);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Render edges ───────────────────────────────────────
|
|
489
|
+
// Collect label positions for overlap resolution
|
|
490
|
+
const labelPositions: {
|
|
491
|
+
x: number;
|
|
492
|
+
y: number;
|
|
493
|
+
width: number;
|
|
494
|
+
height: number;
|
|
495
|
+
idx: number;
|
|
496
|
+
}[] = [];
|
|
497
|
+
|
|
498
|
+
// Store edge group elements for label pass
|
|
499
|
+
const edgeGroups = new Map<number, D3G>();
|
|
500
|
+
|
|
501
|
+
for (let i = 0; i < layout.edges.length; i++) {
|
|
502
|
+
const le = layout.edges[i];
|
|
503
|
+
const color = edgeColorMap.get(i) ?? palette.textMuted;
|
|
504
|
+
|
|
505
|
+
// Check if hidden
|
|
506
|
+
if (hidden.size > 0) {
|
|
507
|
+
let isHidden = false;
|
|
508
|
+
for (const [groupKey, hiddenVals] of hidden) {
|
|
509
|
+
const val = le.metadata[groupKey];
|
|
510
|
+
if (val && hiddenVals.has(val.toLowerCase())) {
|
|
511
|
+
isHidden = true;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (isHidden) continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Apply parallel y-offset to points
|
|
519
|
+
const points = le.points.map((p) => ({ x: p.x, y: p.y + le.yOffset }));
|
|
520
|
+
if (points.length < 2) continue;
|
|
521
|
+
|
|
522
|
+
const edgeG = diagramG
|
|
523
|
+
.append('g')
|
|
524
|
+
.attr('class', 'bl-edge-group')
|
|
525
|
+
.attr('data-line-number', String(le.lineNumber));
|
|
526
|
+
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
527
|
+
|
|
528
|
+
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
529
|
+
const path = edgeG
|
|
530
|
+
.append('path')
|
|
531
|
+
.attr('class', 'bl-edge')
|
|
532
|
+
.attr(
|
|
533
|
+
'd',
|
|
534
|
+
(parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR)(
|
|
535
|
+
points
|
|
536
|
+
) ?? ''
|
|
537
|
+
)
|
|
538
|
+
.attr('fill', 'none')
|
|
539
|
+
.attr('stroke', color)
|
|
540
|
+
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
541
|
+
.attr('marker-end', `url(#${markerId})`);
|
|
542
|
+
|
|
543
|
+
if (le.bidirectional) {
|
|
544
|
+
const revId = `bl-arrow-rev-${color.replace('#', '')}`;
|
|
545
|
+
path.attr('marker-start', `url(#${revId})`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Edge label
|
|
549
|
+
if (le.label && le.labelX != null && le.labelY != null) {
|
|
550
|
+
const lw = le.label.length * EDGE_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
551
|
+
labelPositions.push({
|
|
552
|
+
x: le.labelX,
|
|
553
|
+
y: le.labelY + le.yOffset,
|
|
554
|
+
width: lw + 8,
|
|
555
|
+
height: EDGE_LABEL_FONT_SIZE + 6,
|
|
556
|
+
idx: i,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Resolve overlaps
|
|
562
|
+
resolveEdgeLabelOverlaps(labelPositions);
|
|
563
|
+
|
|
564
|
+
// Render edge labels into their edge groups
|
|
565
|
+
for (const lp of labelPositions) {
|
|
566
|
+
const le = layout.edges[lp.idx];
|
|
567
|
+
if (!le.label) continue;
|
|
568
|
+
|
|
569
|
+
const edgeG = edgeGroups.get(lp.idx);
|
|
570
|
+
const target = edgeG ?? diagramG;
|
|
571
|
+
|
|
572
|
+
target
|
|
573
|
+
.append('rect')
|
|
574
|
+
.attr('x', lp.x - lp.width / 2)
|
|
575
|
+
.attr('y', lp.y - lp.height / 2)
|
|
576
|
+
.attr('width', lp.width)
|
|
577
|
+
.attr('height', lp.height)
|
|
578
|
+
.attr('rx', 3)
|
|
579
|
+
.attr('fill', palette.bg)
|
|
580
|
+
.attr('opacity', 0.85);
|
|
581
|
+
|
|
582
|
+
target
|
|
583
|
+
.append('text')
|
|
584
|
+
.attr('x', lp.x)
|
|
585
|
+
.attr('y', lp.y + EDGE_LABEL_FONT_SIZE / 3)
|
|
586
|
+
.attr('text-anchor', 'middle')
|
|
587
|
+
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
588
|
+
.attr('fill', palette.textMuted)
|
|
589
|
+
.text(le.label);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Render nodes ───────────────────────────────────────
|
|
593
|
+
for (const ln of layout.nodes) {
|
|
594
|
+
const node = nodeMap.get(ln.label);
|
|
595
|
+
if (!node) continue;
|
|
596
|
+
|
|
597
|
+
// Check if hidden
|
|
598
|
+
if (hidden.size > 0) {
|
|
599
|
+
let isHidden = false;
|
|
600
|
+
for (const [groupKey, hiddenVals] of hidden) {
|
|
601
|
+
const val = node.metadata[groupKey];
|
|
602
|
+
if (val && hiddenVals.has(val.toLowerCase())) {
|
|
603
|
+
isHidden = true;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (isHidden) continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const colors = nodeColors(
|
|
611
|
+
node,
|
|
612
|
+
parsed.tagGroups,
|
|
613
|
+
activeGroup,
|
|
614
|
+
palette,
|
|
615
|
+
isDark
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const nodeG = diagramG
|
|
619
|
+
.append('g')
|
|
620
|
+
.attr('class', 'bl-node')
|
|
621
|
+
.attr('transform', `translate(${ln.x},${ln.y})`)
|
|
622
|
+
.attr('data-line-number', node.lineNumber)
|
|
623
|
+
.attr('data-node-id', node.label)
|
|
624
|
+
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
625
|
+
.style('--bl-node-stroke', colors.stroke);
|
|
626
|
+
|
|
627
|
+
// Add tag metadata as data attributes for legend hover dimming
|
|
628
|
+
for (const [key, val] of Object.entries(node.metadata)) {
|
|
629
|
+
nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (onClickItem) {
|
|
633
|
+
nodeG.on('click', () => onClickItem(node.lineNumber));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Rectangle card
|
|
637
|
+
const x = -ln.width / 2;
|
|
638
|
+
const y = -ln.height / 2;
|
|
639
|
+
|
|
640
|
+
// Background rect
|
|
641
|
+
nodeG
|
|
642
|
+
.append('rect')
|
|
643
|
+
.attr('x', x)
|
|
644
|
+
.attr('y', y)
|
|
645
|
+
.attr('width', ln.width)
|
|
646
|
+
.attr('height', ln.height)
|
|
647
|
+
.attr('rx', NODE_RX)
|
|
648
|
+
.attr('ry', NODE_RX)
|
|
649
|
+
.attr('fill', colors.fill)
|
|
650
|
+
.attr('stroke', colors.stroke)
|
|
651
|
+
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
652
|
+
|
|
653
|
+
// All text centered vertically using dominant-baseline: central
|
|
654
|
+
if (node.description) {
|
|
655
|
+
const lineH = NODE_FONT_SIZE * 1.3;
|
|
656
|
+
const gap = 2;
|
|
657
|
+
const totalH = lineH + gap + META_FONT_SIZE;
|
|
658
|
+
const labelY = -totalH / 2 + lineH / 2;
|
|
659
|
+
const descY = labelY + lineH / 2 + gap + META_FONT_SIZE / 2;
|
|
660
|
+
|
|
661
|
+
nodeG
|
|
662
|
+
.append('text')
|
|
663
|
+
.attr('x', 0)
|
|
664
|
+
.attr('y', labelY)
|
|
665
|
+
.attr('text-anchor', 'middle')
|
|
666
|
+
.attr('dominant-baseline', 'central')
|
|
667
|
+
.attr('font-size', NODE_FONT_SIZE)
|
|
668
|
+
.attr('font-weight', '600')
|
|
669
|
+
.attr('fill', colors.text)
|
|
670
|
+
.text(node.label);
|
|
671
|
+
|
|
672
|
+
const maxChars = Math.floor(
|
|
673
|
+
(ln.width - NODE_TEXT_PADDING * 2) / (META_FONT_SIZE * CHAR_WIDTH_RATIO)
|
|
674
|
+
);
|
|
675
|
+
const desc =
|
|
676
|
+
node.description.length > maxChars
|
|
677
|
+
? node.description.slice(0, maxChars - 1) + '\u2026'
|
|
678
|
+
: node.description;
|
|
679
|
+
const descEl = nodeG
|
|
680
|
+
.append('text')
|
|
681
|
+
.attr('x', 0)
|
|
682
|
+
.attr('y', descY)
|
|
683
|
+
.attr('text-anchor', 'middle')
|
|
684
|
+
.attr('dominant-baseline', 'central')
|
|
685
|
+
.attr('font-size', META_FONT_SIZE)
|
|
686
|
+
.attr('fill', palette.textMuted)
|
|
687
|
+
.text(desc);
|
|
688
|
+
if (desc !== node.description) {
|
|
689
|
+
descEl.append('title').text(node.description);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
const fitted = fitTextToNode(node.label, ln.width - 16, ln.height);
|
|
693
|
+
const lineH = fitted.fontSize * 1.3;
|
|
694
|
+
const totalH = fitted.lines.length * lineH;
|
|
695
|
+
for (let li = 0; li < fitted.lines.length; li++) {
|
|
696
|
+
nodeG
|
|
697
|
+
.append('text')
|
|
698
|
+
.attr('x', 0)
|
|
699
|
+
.attr('y', -totalH / 2 + lineH / 2 + li * lineH)
|
|
700
|
+
.attr('text-anchor', 'middle')
|
|
701
|
+
.attr('dominant-baseline', 'central')
|
|
702
|
+
.attr('font-size', fitted.fontSize)
|
|
703
|
+
.attr('font-weight', '600')
|
|
704
|
+
.attr('fill', colors.text)
|
|
705
|
+
.text(fitted.lines[li]);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── Render legend ──────────────────────────────────────
|
|
711
|
+
if (parsed.tagGroups.length > 0) {
|
|
712
|
+
renderLegend(svg, parsed, palette, isDark, activeGroup, width, titleOffset);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── Legend ──────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
function renderLegend(
|
|
719
|
+
svg: D3Svg,
|
|
720
|
+
parsed: ParsedBoxesAndLines,
|
|
721
|
+
palette: PaletteColors,
|
|
722
|
+
isDark: boolean,
|
|
723
|
+
activeGroup: string | null,
|
|
724
|
+
svgWidth: number,
|
|
725
|
+
titleOffset: number
|
|
726
|
+
): void {
|
|
727
|
+
const groupBg = isDark
|
|
728
|
+
? mix(palette.surface, palette.bg, 50)
|
|
729
|
+
: mix(palette.surface, palette.bg, 30);
|
|
730
|
+
const pillBorder = mix(palette.textMuted, palette.bg, 50);
|
|
731
|
+
|
|
732
|
+
// ── Pre-compute total legend width for centering ──
|
|
733
|
+
let totalW = 0;
|
|
734
|
+
for (const tg of parsed.tagGroups) {
|
|
735
|
+
const isActive = activeGroup?.toLowerCase() === tg.name.toLowerCase();
|
|
736
|
+
totalW +=
|
|
737
|
+
measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
738
|
+
if (isActive) {
|
|
739
|
+
totalW += 6;
|
|
740
|
+
for (const entry of tg.entries) {
|
|
741
|
+
totalW +=
|
|
742
|
+
LEGEND_DOT_R * 2 +
|
|
743
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
744
|
+
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
745
|
+
LEGEND_ENTRY_TRAIL;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
totalW += LEGEND_GROUP_GAP;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const legendX = Math.max(LEGEND_CAPSULE_PAD, (svgWidth - totalW) / 2);
|
|
752
|
+
const legendY = titleOffset + 4;
|
|
753
|
+
const legendG = svg
|
|
754
|
+
.append('g')
|
|
755
|
+
.attr('transform', `translate(${legendX},${legendY})`);
|
|
756
|
+
|
|
757
|
+
let x = 0;
|
|
758
|
+
|
|
759
|
+
// ── Tag group pills (collapsed when inactive, expanded when active) ──
|
|
760
|
+
for (const tg of parsed.tagGroups) {
|
|
761
|
+
const isActiveGroup = activeGroup?.toLowerCase() === tg.name.toLowerCase();
|
|
762
|
+
|
|
763
|
+
const groupG = legendG
|
|
764
|
+
.append('g')
|
|
765
|
+
.attr('class', 'bl-legend-group')
|
|
766
|
+
.attr('data-legend-group', tg.name.toLowerCase())
|
|
767
|
+
.style('cursor', 'pointer');
|
|
768
|
+
|
|
769
|
+
// Group name pill
|
|
770
|
+
const nameW =
|
|
771
|
+
measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
772
|
+
const tagPill = groupG
|
|
773
|
+
.append('rect')
|
|
774
|
+
.attr('x', x)
|
|
775
|
+
.attr('y', 0)
|
|
776
|
+
.attr('width', nameW)
|
|
777
|
+
.attr('height', LEGEND_HEIGHT)
|
|
778
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
779
|
+
.attr('fill', groupBg);
|
|
780
|
+
|
|
781
|
+
if (isActiveGroup) {
|
|
782
|
+
tagPill.attr('stroke', pillBorder).attr('stroke-width', 0.75);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
groupG
|
|
786
|
+
.append('text')
|
|
787
|
+
.attr('x', x + nameW / 2)
|
|
788
|
+
.attr('y', LEGEND_HEIGHT / 2)
|
|
789
|
+
.attr('text-anchor', 'middle')
|
|
790
|
+
.attr('dominant-baseline', 'central')
|
|
791
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
792
|
+
.attr('font-weight', 500)
|
|
793
|
+
.attr('fill', isActiveGroup ? palette.text : palette.textMuted)
|
|
794
|
+
.attr('pointer-events', 'none')
|
|
795
|
+
.text(tg.name);
|
|
796
|
+
|
|
797
|
+
x += nameW;
|
|
798
|
+
|
|
799
|
+
// Entries — only rendered when this group is active
|
|
800
|
+
if (isActiveGroup) {
|
|
801
|
+
x += 6;
|
|
802
|
+
for (const entry of tg.entries) {
|
|
803
|
+
const entryColor = entry.color || palette.textMuted;
|
|
804
|
+
const ew = measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE);
|
|
805
|
+
|
|
806
|
+
const entryG = groupG
|
|
807
|
+
.append('g')
|
|
808
|
+
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
809
|
+
.style('cursor', 'pointer');
|
|
810
|
+
|
|
811
|
+
entryG
|
|
812
|
+
.append('circle')
|
|
813
|
+
.attr('cx', x + LEGEND_DOT_R)
|
|
814
|
+
.attr('cy', LEGEND_HEIGHT / 2)
|
|
815
|
+
.attr('r', LEGEND_DOT_R)
|
|
816
|
+
.attr('fill', entryColor);
|
|
817
|
+
|
|
818
|
+
entryG
|
|
819
|
+
.append('text')
|
|
820
|
+
.attr('x', x + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
|
|
821
|
+
.attr('y', LEGEND_HEIGHT / 2)
|
|
822
|
+
.attr('dominant-baseline', 'central')
|
|
823
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
824
|
+
.attr('fill', palette.textMuted)
|
|
825
|
+
.text(entry.value);
|
|
826
|
+
|
|
827
|
+
x += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + ew + LEGEND_ENTRY_TRAIL;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
x += LEGEND_GROUP_GAP;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ── Export helper ──────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
export function renderBoxesAndLinesForExport(
|
|
838
|
+
container: HTMLDivElement,
|
|
839
|
+
parsed: ParsedBoxesAndLines,
|
|
840
|
+
layout: BLLayoutResult,
|
|
841
|
+
palette: PaletteColors,
|
|
842
|
+
isDark: boolean,
|
|
843
|
+
options?: { exportDims?: { width: number; height: number } }
|
|
844
|
+
): void {
|
|
845
|
+
renderBoxesAndLines(container, parsed, layout, palette, isDark, {
|
|
846
|
+
exportDims: options?.exportDims,
|
|
847
|
+
});
|
|
848
|
+
}
|