@diagrammo/dgmo 0.8.5 → 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.
Files changed (64) hide show
  1. package/.claude/commands/dgmo.md +33 -0
  2. package/.cursorrules +20 -2
  3. package/.github/copilot-instructions.md +20 -2
  4. package/.windsurfrules +20 -2
  5. package/AGENTS.md +23 -3
  6. package/dist/cli.cjs +189 -190
  7. package/dist/editor.cjs +3 -18
  8. package/dist/editor.cjs.map +1 -1
  9. package/dist/editor.js +3 -18
  10. package/dist/editor.js.map +1 -1
  11. package/dist/highlight.cjs +4 -21
  12. package/dist/highlight.cjs.map +1 -1
  13. package/dist/highlight.js +4 -21
  14. package/dist/highlight.js.map +1 -1
  15. package/dist/index.cjs +2785 -2996
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +56 -56
  18. package/dist/index.d.ts +56 -56
  19. package/dist/index.js +2780 -2989
  20. package/dist/index.js.map +1 -1
  21. package/docs/ai-integration.md +1 -1
  22. package/docs/language-reference.md +97 -25
  23. package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
  24. package/package.json +1 -1
  25. package/src/boxes-and-lines/collapse.ts +78 -0
  26. package/src/boxes-and-lines/layout.ts +319 -0
  27. package/src/boxes-and-lines/parser.ts +694 -0
  28. package/src/boxes-and-lines/renderer.ts +848 -0
  29. package/src/boxes-and-lines/types.ts +40 -0
  30. package/src/c4/parser.ts +10 -5
  31. package/src/c4/renderer.ts +232 -56
  32. package/src/chart.ts +9 -4
  33. package/src/cli.ts +6 -5
  34. package/src/completion.ts +25 -33
  35. package/src/d3.ts +26 -27
  36. package/src/dgmo-router.ts +3 -7
  37. package/src/echarts.ts +38 -2
  38. package/src/editor/keywords.ts +4 -19
  39. package/src/er/parser.ts +10 -4
  40. package/src/gantt/parser.ts +7 -4
  41. package/src/gantt/renderer.ts +3 -5
  42. package/src/index.ts +17 -26
  43. package/src/infra/parser.ts +7 -5
  44. package/src/infra/renderer.ts +2 -2
  45. package/src/kanban/parser.ts +7 -5
  46. package/src/kanban/renderer.ts +43 -18
  47. package/src/org/parser.ts +7 -4
  48. package/src/org/renderer.ts +40 -29
  49. package/src/sequence/parser.ts +11 -5
  50. package/src/sequence/renderer.ts +114 -45
  51. package/src/sitemap/parser.ts +8 -4
  52. package/src/sitemap/renderer.ts +137 -57
  53. package/src/utils/legend-svg.ts +44 -20
  54. package/src/utils/parsing.ts +1 -1
  55. package/src/utils/tag-groups.ts +21 -1
  56. package/gallery/fixtures/initiative-status-full.dgmo +0 -46
  57. package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
  58. package/gallery/fixtures/initiative-status.dgmo +0 -9
  59. package/src/initiative-status/collapse.ts +0 -76
  60. package/src/initiative-status/filter.ts +0 -63
  61. package/src/initiative-status/layout.ts +0 -650
  62. package/src/initiative-status/parser.ts +0 -629
  63. package/src/initiative-status/renderer.ts +0 -1199
  64. 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
+ }