@diagrammo/dgmo 0.8.20 → 0.8.22

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 (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -7,7 +7,12 @@ import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import { LEGEND_HEIGHT } from '../utils/legend-constants';
9
9
  import { renderLegendD3 } from '../utils/legend-d3';
10
- import type { LegendConfig, LegendState } from '../utils/legend-types';
10
+ import type {
11
+ LegendConfig,
12
+ LegendState,
13
+ LegendCallbacks,
14
+ ControlsGroupToggle,
15
+ } from '../utils/legend-types';
11
16
  import {
12
17
  TITLE_FONT_SIZE,
13
18
  TITLE_FONT_WEIGHT,
@@ -17,6 +22,7 @@ import { contrastText, mix } from '../palettes/color-utils';
17
22
  import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
18
23
  import type { TagGroup } from '../utils/tag-groups';
19
24
  import type { PaletteColors } from '../palettes';
25
+ import { renderInlineText } from '../utils/inline-markdown';
20
26
  import type { ParsedBoxesAndLines, BLNode } from './types';
21
27
  import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
22
28
 
@@ -24,7 +30,6 @@ import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
24
30
  const DIAGRAM_PADDING = 20;
25
31
  const NODE_FONT_SIZE = 13;
26
32
  const MIN_NODE_FONT_SIZE = 9;
27
- const META_FONT_SIZE = 10;
28
33
  const EDGE_LABEL_FONT_SIZE = 11;
29
34
  const EDGE_STROKE_WIDTH = 1.5;
30
35
  const NODE_STROKE_WIDTH = 1.5;
@@ -32,10 +37,14 @@ const NODE_RX = 8;
32
37
  const COLLAPSE_BAR_HEIGHT = 4;
33
38
  const ARROWHEAD_W = 5;
34
39
  const ARROWHEAD_H = 4;
40
+ const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
41
+ const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px font (matches infra META_LINE_HEIGHT)
42
+ const MAX_DESC_LINES = 6;
35
43
  const CHAR_WIDTH_RATIO = 0.6;
36
44
  const NODE_TEXT_PADDING = 12;
37
45
  const GROUP_RX = 8;
38
46
  const GROUP_LABEL_FONT_SIZE = 14;
47
+ const GROUP_LABEL_ZONE = 32;
39
48
 
40
49
  type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
41
50
  type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
@@ -45,19 +54,13 @@ const lineGeneratorLR = d3Shape
45
54
  .line<{ x: number; y: number }>()
46
55
  .x((d) => d.x)
47
56
  .y((d) => d.y)
48
- .curve(d3Shape.curveMonotoneX);
57
+ .curve(d3Shape.curveBasis);
49
58
 
50
59
  const lineGeneratorTB = d3Shape
51
60
  .line<{ x: number; y: number }>()
52
61
  .x((d) => d.x)
53
62
  .y((d) => d.y)
54
- .curve(d3Shape.curveMonotoneY);
55
-
56
- const lineGeneratorLinear = d3Shape
57
- .line<{ x: number; y: number }>()
58
- .x((d) => d.x)
59
- .y((d) => d.y)
60
- .curve(d3Shape.curveLinear);
63
+ .curve(d3Shape.curveBasis);
61
64
 
62
65
  // ── Text fitting ───────────────────────────────────────────
63
66
 
@@ -86,13 +89,25 @@ function splitCamelCase(word: string): string[] {
86
89
  return parts.length > 1 ? parts : [word];
87
90
  }
88
91
 
89
- function fitTextToNode(
92
+ /**
93
+ * Fit a label into a header zone for described nodes.
94
+ * Strategy: split first (spaces, dashes, camelCase), wrap into lines,
95
+ * shrink font if needed, truncate individual lines with "…" — never hard-break.
96
+ */
97
+ function fitLabelToHeader(
90
98
  label: string,
91
99
  nodeWidth: number,
92
- nodeHeight: number
100
+ maxLines: number
93
101
  ): { lines: string[]; fontSize: number } {
94
102
  const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
95
- const lineHeight = 1.3;
103
+
104
+ // Split on spaces and dashes, then camelCase split each part
105
+ const rawParts = label.split(/(\s+|-)/);
106
+ const words: string[] = [];
107
+ for (const part of rawParts) {
108
+ if (!part || /^\s+$/.test(part) || part === '-') continue;
109
+ words.push(...splitCamelCase(part));
110
+ }
96
111
 
97
112
  for (
98
113
  let fontSize = NODE_FONT_SIZE;
@@ -100,17 +115,15 @@ function fitTextToNode(
100
115
  fontSize--
101
116
  ) {
102
117
  const charWidth = fontSize * CHAR_WIDTH_RATIO;
103
- const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
104
- const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
105
- if (maxCharsPerLine < 2 || maxLines < 1) continue;
106
- if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
118
+ const maxChars = Math.floor(maxTextWidth / charWidth);
119
+ if (maxChars < 2) continue;
107
120
 
108
- const words = label.split(/\s+/);
121
+ // Wrap words into lines
109
122
  const lines: string[] = [];
110
123
  let current = '';
111
124
  for (const word of words) {
112
125
  const test = current ? `${current} ${word}` : word;
113
- if (test.length <= maxCharsPerLine) {
126
+ if (test.length <= maxChars) {
114
127
  current = test;
115
128
  } else {
116
129
  if (current) lines.push(current);
@@ -118,54 +131,39 @@ function fitTextToNode(
118
131
  }
119
132
  }
120
133
  if (current) lines.push(current);
121
- if (
122
- lines.length <= maxLines &&
123
- lines.every((l) => l.length <= maxCharsPerLine)
124
- ) {
134
+
135
+ // All lines fit at this font? Done.
136
+ if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
125
137
  return { lines, fontSize };
126
138
  }
127
139
 
128
- // CamelCase split
129
- const camelWords: string[] = [];
130
- for (const word of words) {
131
- if (word.length > maxCharsPerLine)
132
- camelWords.push(...splitCamelCase(word));
133
- else camelWords.push(word);
134
- }
135
- const camelLines: string[] = [];
136
- let cc = '';
137
- for (const word of camelWords) {
138
- const test = cc ? `${cc} ${word}` : word;
139
- if (test.length <= maxCharsPerLine) {
140
- cc = test;
141
- } else {
142
- if (cc) camelLines.push(cc);
143
- cc = word;
144
- }
145
- }
146
- if (cc) camelLines.push(cc);
147
- if (
148
- camelLines.length <= maxLines &&
149
- camelLines.every((l) => l.length <= maxCharsPerLine)
150
- ) {
151
- return { lines: camelLines, fontSize };
140
+ // Lines fit in count but some are too wide? Truncate those lines.
141
+ if (lines.length <= maxLines) {
142
+ const result = lines.map((l) =>
143
+ l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
144
+ );
145
+ return { lines: result, fontSize };
152
146
  }
153
147
 
154
- if (fontSize > MIN_NODE_FONT_SIZE) continue;
155
-
156
- // Hard-break
157
- const hardLines: string[] = [];
158
- for (const line of camelLines) {
159
- if (line.length <= maxCharsPerLine) hardLines.push(line);
160
- else
161
- for (let i = 0; i < line.length; i += maxCharsPerLine)
162
- hardLines.push(line.slice(i, i + maxCharsPerLine));
148
+ // Too many lines — take first maxLines, truncate last + any oversized
149
+ const result = lines
150
+ .slice(0, maxLines)
151
+ .map((l) =>
152
+ l.length > maxChars ? l.slice(0, maxChars - 1) + '\u2026' : l
153
+ );
154
+ const last = result[maxLines - 1];
155
+ if (!last.endsWith('\u2026')) {
156
+ result[maxLines - 1] =
157
+ last.length >= maxChars
158
+ ? last.slice(0, maxChars - 1) + '\u2026'
159
+ : last + '\u2026';
163
160
  }
164
- if (hardLines.length <= maxLines) return { lines: hardLines, fontSize };
161
+ return { lines: result, fontSize };
165
162
  }
166
163
 
164
+ // Fallback at min font
167
165
  const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
168
- const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
166
+ const maxChars = Math.floor(maxTextWidth / charWidth);
169
167
  const truncated =
170
168
  label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
171
169
  return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
@@ -297,6 +295,10 @@ interface BLRenderOptions {
297
295
  exportDims?: { width?: number; height?: number };
298
296
  activeTagGroup?: string | null;
299
297
  hiddenTagValues?: Map<string, Set<string>>;
298
+ hideDescriptions?: boolean;
299
+ controlsExpanded?: boolean;
300
+ onToggleDescriptions?: (active: boolean) => void;
301
+ onToggleControlsExpand?: () => void;
300
302
  }
301
303
 
302
304
  export function renderBoxesAndLines(
@@ -307,8 +309,16 @@ export function renderBoxesAndLines(
307
309
  isDark: boolean,
308
310
  options?: BLRenderOptions
309
311
  ): void {
310
- const { onClickItem, exportDims, activeTagGroup, hiddenTagValues } =
311
- options ?? {};
312
+ const {
313
+ onClickItem,
314
+ exportDims,
315
+ activeTagGroup,
316
+ hiddenTagValues,
317
+ hideDescriptions,
318
+ controlsExpanded,
319
+ onToggleDescriptions,
320
+ onToggleControlsExpand,
321
+ } = options ?? {};
312
322
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
313
323
 
314
324
  const width = exportDims?.width ?? container.clientWidth;
@@ -335,9 +345,24 @@ export function renderBoxesAndLines(
335
345
 
336
346
  // Compute diagram bounds for scaling
337
347
  const titleOffset = parsed.title ? 40 : 0;
338
- const legendH = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
348
+ const hasAnyDescriptions = parsed.nodes.some(
349
+ (n) => n.description && n.description.length > 0
350
+ );
351
+ const needsLegend =
352
+ parsed.tagGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
353
+ const legendH = needsLegend ? LEGEND_HEIGHT + 8 : 0;
354
+
355
+ // Account for group label zone extensions (renderer-only, not in layout.height)
356
+ const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
357
+ let labelZoneExtension = 0;
358
+ for (const group of parsed.groups) {
359
+ if (group.children.some((c) => groupLabelsSet.has(c))) {
360
+ labelZoneExtension += GROUP_LABEL_ZONE;
361
+ }
362
+ }
363
+
339
364
  const contentW = layout.width;
340
- const contentH = layout.height + titleOffset + legendH;
365
+ const contentH = layout.height + titleOffset + legendH + labelZoneExtension;
341
366
 
342
367
  const scaleX = width / (contentW + DIAGRAM_PADDING * 2);
343
368
  const scaleY = height / (contentH + DIAGRAM_PADDING * 2);
@@ -390,10 +415,29 @@ export function renderBoxesAndLines(
390
415
  }
391
416
  ensureArrowMarkers(defs, arrowColors);
392
417
 
393
- // ── Render groups (bottom layer) ───────────────────────
394
- for (const group of layout.groups) {
418
+ // ── Render groups (bottom layer, largest first for nesting) ──
419
+ const sortedGroups = [...layout.groups].sort(
420
+ (a, b) => b.width * b.height - a.width * a.height
421
+ );
422
+ // Identify groups that contain sub-groups — only those need extra label space
423
+ const groupLabels = new Set(layout.groups.map((g) => g.label));
424
+ const hasSubGroups = new Set<string>();
425
+ for (const group of parsed.groups) {
426
+ for (const child of group.children) {
427
+ if (groupLabels.has(child)) hasSubGroups.add(group.label);
428
+ }
429
+ }
430
+
431
+ for (const group of sortedGroups) {
395
432
  const gx = group.x - group.width / 2;
396
- const gy = group.y - group.height / 2;
433
+ // Only extend top for groups that contain sub-groups (dagre under-pads these)
434
+ const needsExtra = !group.collapsed && hasSubGroups.has(group.label);
435
+ const gy = needsExtra
436
+ ? group.y - group.height / 2 - GROUP_LABEL_ZONE
437
+ : group.y - group.height / 2;
438
+ const groupHeight = needsExtra
439
+ ? group.height + GROUP_LABEL_ZONE
440
+ : group.height;
397
441
 
398
442
  const groupG = diagramG
399
443
  .append('g')
@@ -464,7 +508,7 @@ export function renderBoxesAndLines(
464
508
  .attr('x', gx)
465
509
  .attr('y', gy)
466
510
  .attr('width', group.width)
467
- .attr('height', group.height)
511
+ .attr('height', groupHeight)
468
512
  .attr('rx', GROUP_RX)
469
513
  .attr('ry', GROUP_RX)
470
514
  .attr('fill', mix(palette.surface, palette.bg, 40))
@@ -516,8 +560,73 @@ export function renderBoxesAndLines(
516
560
  if (isHidden) continue;
517
561
  }
518
562
 
519
- // Apply parallel y-offset to points
520
- const points = le.points.map((p) => ({ x: p.x, y: p.y + le.yOffset }));
563
+ // Self-loop: render as a smooth circular arc below the node
564
+ if (le.source === le.target) {
565
+ const nodeLayout = layoutNodeMap.get(le.source);
566
+ if (nodeLayout) {
567
+ const edgeG = diagramG
568
+ .append('g')
569
+ .attr('class', 'bl-edge-group')
570
+ .attr('data-line-number', String(le.lineNumber));
571
+ edgeGroups.set(i, edgeG as unknown as D3G);
572
+
573
+ const markerId = `bl-arrow-${color.replace('#', '')}`;
574
+ const cx = nodeLayout.x;
575
+ const cy = nodeLayout.y;
576
+ const hw = nodeLayout.width / 2;
577
+ const hh = nodeLayout.height / 2;
578
+ const pad = 20; // clearance from node edge
579
+
580
+ // Arc exits from bottom of right side, swings wide, returns to right of bottom side
581
+ const startX = cx + hw;
582
+ const startY = cy + hh * 0.4;
583
+ const endX = cx + hw * 0.4;
584
+ const endY = cy + hh;
585
+
586
+ // Control points swing far out to create a smooth circular arc
587
+ const cp1x = startX + hw + pad;
588
+ const cp1y = startY;
589
+ const cp2x = endX;
590
+ const cp2y = endY + hh + pad;
591
+
592
+ edgeG
593
+ .append('path')
594
+ .attr('class', 'bl-edge')
595
+ .attr(
596
+ 'd',
597
+ `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
598
+ )
599
+ .attr('fill', 'none')
600
+ .attr('stroke', color)
601
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
602
+ .attr('marker-end', `url(#${markerId})`);
603
+ }
604
+ continue;
605
+ }
606
+
607
+ // Parallel edge fan: construct explicit 5-point geometry so lines
608
+ // bundle at ports and visibly spread apart in the middle.
609
+ let points: { x: number; y: number }[];
610
+ if (le.yOffset !== 0 && le.parallelCount > 1) {
611
+ const srcLayout = layoutNodeMap.get(le.source);
612
+ const tgtLayout = layoutNodeMap.get(le.target);
613
+ const srcY = srcLayout?.y ?? le.points[0]?.y ?? 0;
614
+ const tgtY = tgtLayout?.y ?? le.points[le.points.length - 1]?.y ?? 0;
615
+ const srcX = le.points[0]?.x ?? 0;
616
+ const tgtX = le.points[le.points.length - 1]?.x ?? 0;
617
+ const midX = (srcX + tgtX) / 2;
618
+ const midY = (srcY + tgtY) / 2;
619
+
620
+ points = [
621
+ { x: srcX, y: srcY }, // port (bundled)
622
+ { x: srcX + (midX - srcX) * 0.3, y: srcY + le.yOffset * 0.5 }, // separate
623
+ { x: midX, y: midY + le.yOffset }, // full spread
624
+ { x: tgtX - (tgtX - midX) * 0.3, y: tgtY + le.yOffset * 0.5 }, // converge
625
+ { x: tgtX, y: tgtY }, // port (bundled)
626
+ ];
627
+ } else {
628
+ points = le.points.map((p) => ({ x: p.x, y: p.y }));
629
+ }
521
630
  if (points.length < 2) continue;
522
631
 
523
632
  const edgeG = diagramG
@@ -527,11 +636,7 @@ export function renderBoxesAndLines(
527
636
  edgeGroups.set(i, edgeG as unknown as D3G);
528
637
 
529
638
  const markerId = `bl-arrow-${color.replace('#', '')}`;
530
- const gen = le.deferred
531
- ? lineGeneratorLinear
532
- : parsed.direction === 'TB'
533
- ? lineGeneratorTB
534
- : lineGeneratorLR;
639
+ const gen = parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR;
535
640
  const path = edgeG
536
641
  .append('path')
537
642
  .attr('class', 'bl-edge')
@@ -546,14 +651,25 @@ export function renderBoxesAndLines(
546
651
  path.attr('marker-start', `url(#${revId})`);
547
652
  }
548
653
 
549
- // Edge label
654
+ // Edge label — for parallel edges, place relative to each line:
655
+ // negative offset (top line) → label above, zero → on line, positive → below
550
656
  if (le.label && le.labelX != null && le.labelY != null) {
551
657
  const lw = le.label.length * EDGE_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO;
658
+ const labelH = EDGE_LABEL_FONT_SIZE + 6;
659
+ let ly: number;
660
+ if (le.parallelCount > 1 && le.yOffset !== 0) {
661
+ // Position label on the line at midpoint, shifted above/below based on offset sign
662
+ const lineY = le.labelY + 10 + le.yOffset; // +10 to undo the -10 in layout
663
+ const labelShift = le.yOffset < 0 ? -labelH : labelH;
664
+ ly = lineY + labelShift * 0.5;
665
+ } else {
666
+ ly = le.labelY + le.yOffset;
667
+ }
552
668
  labelPositions.push({
553
669
  x: le.labelX,
554
- y: le.labelY + le.yOffset,
670
+ y: ly,
555
671
  width: lw + 8,
556
- height: EDGE_LABEL_FONT_SIZE + 6,
672
+ height: labelH,
557
673
  idx: i,
558
674
  });
559
675
  }
@@ -631,7 +747,12 @@ export function renderBoxesAndLines(
631
747
  }
632
748
 
633
749
  if (onClickItem) {
634
- nodeG.on('click', () => onClickItem(node.lineNumber));
750
+ nodeG.on('click', (event: Event) => {
751
+ // Don't intercept clicks on links in description text
752
+ const target = event.target as Element | null;
753
+ if (target?.closest('a')) return;
754
+ onClickItem(node.lineNumber);
755
+ });
635
756
  }
636
757
 
637
758
  // Rectangle card
@@ -652,45 +773,146 @@ export function renderBoxesAndLines(
652
773
  .attr('stroke-width', NODE_STROKE_WIDTH);
653
774
 
654
775
  // All text centered vertically using dominant-baseline: central
655
- if (node.description) {
656
- const lineH = NODE_FONT_SIZE * 1.3;
657
- const gap = 2;
658
- const totalH = lineH + gap + META_FONT_SIZE;
659
- const labelY = -totalH / 2 + lineH / 2;
660
- const descY = labelY + lineH / 2 + gap + META_FONT_SIZE / 2;
776
+ const desc = node.description;
777
+ if (desc && desc.length > 0 && !hideDescriptions) {
778
+ // Label in header zone — split on spaces/dashes/camelCase, up to 3 lines
779
+ const MAX_LABEL_LINES = 3;
780
+ const fitted = fitLabelToHeader(node.label, ln.width, MAX_LABEL_LINES);
781
+ const labelLines = fitted.lines;
782
+ const labelLineH = fitted.fontSize * 1.3;
783
+ const labelTotalH = labelLines.length * labelLineH;
784
+ const headerH = labelTotalH + 12; // 12px padding
785
+ const headerCenterY = -ln.height / 2 + headerH / 2;
786
+ for (let li = 0; li < labelLines.length; li++) {
787
+ nodeG
788
+ .append('text')
789
+ .attr('x', 0)
790
+ .attr(
791
+ 'y',
792
+ headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
793
+ )
794
+ .attr('text-anchor', 'middle')
795
+ .attr('dominant-baseline', 'central')
796
+ .attr('font-size', fitted.fontSize)
797
+ .attr('font-weight', '600')
798
+ .attr('fill', colors.text)
799
+ .text(labelLines[li]);
800
+ }
661
801
 
802
+ // Separator line (full width, matches infra style)
803
+ const sepY = -ln.height / 2 + headerH;
662
804
  nodeG
663
- .append('text')
664
- .attr('x', 0)
665
- .attr('y', labelY)
666
- .attr('text-anchor', 'middle')
667
- .attr('dominant-baseline', 'central')
668
- .attr('font-size', NODE_FONT_SIZE)
669
- .attr('font-weight', '600')
670
- .attr('fill', colors.text)
671
- .text(node.label);
672
-
673
- const maxChars = Math.floor(
674
- (ln.width - NODE_TEXT_PADDING * 2) / (META_FONT_SIZE * CHAR_WIDTH_RATIO)
805
+ .append('line')
806
+ .attr('x1', -ln.width / 2)
807
+ .attr('y1', sepY)
808
+ .attr('x2', ln.width / 2)
809
+ .attr('y2', sepY)
810
+ .attr('stroke', colors.stroke)
811
+ .attr('stroke-opacity', 0.3)
812
+ .attr('stroke-width', 1);
813
+
814
+ // Description lines with word wrapping and inline markdown
815
+ const descStartY = sepY + 4 + DESC_FONT_SIZE;
816
+ const maxTextWidth = ln.width - NODE_TEXT_PADDING * 2;
817
+ const charsPerLine = Math.floor(
818
+ maxTextWidth / (DESC_FONT_SIZE * CHAR_WIDTH_RATIO)
675
819
  );
676
- const desc =
677
- node.description.length > maxChars
678
- ? node.description.slice(0, maxChars - 1) + '\u2026'
679
- : node.description;
680
- const descEl = nodeG
681
- .append('text')
682
- .attr('x', 0)
683
- .attr('y', descY)
684
- .attr('text-anchor', 'middle')
685
- .attr('dominant-baseline', 'central')
686
- .attr('font-size', META_FONT_SIZE)
687
- .attr('fill', palette.textMuted)
688
- .text(desc);
689
- if (desc !== node.description) {
690
- descEl.append('title').text(node.description);
820
+ const descLineH = DESC_FONT_SIZE * DESC_LINE_HEIGHT;
821
+
822
+ // Estimate display length strip markdown syntax for measurement
823
+ const displayLen = (text: string): number =>
824
+ text
825
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
826
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
827
+ .replace(/\*(.+?)\*/g, '$1') // *italic* → italic
828
+ .replace(/`(.+?)`/g, '$1') // `code` → code
829
+ .replace(/https?:\/\/\S+/g, (u) => u.slice(0, 20)).length; // bare URLs shortened
830
+ const hasMarkdown = (text: string): boolean =>
831
+ /\[.+?\]\(.+?\)|https?:\/\/|www\./.test(text);
832
+
833
+ // Build wrapped lines from description
834
+ const wrappedLines: string[] = [];
835
+ for (let descLine of desc) {
836
+ // Render `- ` as bullet
837
+ if (descLine.startsWith('- ')) descLine = '\u2022 ' + descLine.slice(2);
838
+ // Normalize bare URLs: `http example.com` → `http://example.com`
839
+ descLine = descLine.replace(
840
+ /\bhttps?\s+([\w][\w.-]+\.[a-z]{2,}(?:\/\S*)?)/gi,
841
+ (_, domain) => `https://${domain}`
842
+ );
843
+ if (displayLen(descLine) <= charsPerLine) {
844
+ wrappedLines.push(descLine);
845
+ } else {
846
+ // Word wrap using display lengths
847
+ // Keep bullet attached to first word
848
+ let words: string[];
849
+ if (descLine.startsWith('\u2022 ')) {
850
+ const rest = descLine.slice(2);
851
+ const restWords = rest.split(/\s+/);
852
+ words = [`\u2022 ${restWords[0]}`, ...restWords.slice(1)];
853
+ } else {
854
+ words = descLine.split(/\s+/);
855
+ }
856
+ let current = '';
857
+ for (const word of words) {
858
+ const test = current ? `${current} ${word}` : word;
859
+ if (displayLen(test) <= charsPerLine) {
860
+ current = test;
861
+ } else {
862
+ if (current) wrappedLines.push(current);
863
+ // Don't truncate words containing markdown/links
864
+ current =
865
+ !hasMarkdown(word) && word.length > charsPerLine
866
+ ? word.slice(0, charsPerLine - 1) + '\u2026'
867
+ : word;
868
+ }
869
+ }
870
+ if (current) wrappedLines.push(current);
871
+ }
872
+ }
873
+
874
+ const truncated = wrappedLines.length > MAX_DESC_LINES;
875
+ const visibleLines = truncated
876
+ ? wrappedLines.slice(0, MAX_DESC_LINES)
877
+ : wrappedLines;
878
+
879
+ for (let li = 0; li < visibleLines.length; li++) {
880
+ let lineText = visibleLines[li];
881
+ // Truncate last line if there are more lines beyond the cap
882
+ if (truncated && li === visibleLines.length - 1) {
883
+ lineText =
884
+ lineText.length >= charsPerLine
885
+ ? lineText.slice(0, charsPerLine - 1) + '\u2026'
886
+ : lineText + '\u2026';
887
+ }
888
+ // Bulleted lines left-align, plain lines center
889
+ const isBullet = lineText.startsWith('\u2022');
890
+ const textEl = nodeG
891
+ .append('text')
892
+ .attr('x', isBullet ? -ln.width / 2 + 6 : 0)
893
+ .attr('y', descStartY + li * descLineH)
894
+ .attr('text-anchor', isBullet ? 'start' : 'middle')
895
+ .attr('dominant-baseline', 'central')
896
+ .attr('font-size', DESC_FONT_SIZE)
897
+ .attr('fill', palette.textMuted);
898
+ renderInlineText(textEl, lineText, palette, DESC_FONT_SIZE);
899
+ }
900
+
901
+ // Tooltip when truncated
902
+ if (truncated) {
903
+ const fullText = desc.join(' ');
904
+ const tooltipText =
905
+ fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
906
+ nodeG.append('title').text(tooltipText);
691
907
  }
692
908
  } else {
693
- const fitted = fitTextToNode(node.label, ln.width - 16, ln.height);
909
+ // Compact label use same split-first algorithm (camelCase, no hard-break)
910
+ // 16px vertical padding (8 top + 8 bottom) to keep text off borders
911
+ const maxLabelLines = Math.max(
912
+ 2,
913
+ Math.floor((ln.height - 16) / (MIN_NODE_FONT_SIZE * 1.3))
914
+ );
915
+ const fitted = fitLabelToHeader(node.label, ln.width, maxLabelLines);
694
916
  const lineH = fitted.fontSize * 1.3;
695
917
  const totalH = fitted.lines.length * lineH;
696
918
  for (let li = 0; li < fitted.lines.length; li++) {
@@ -709,13 +931,46 @@ export function renderBoxesAndLines(
709
931
  }
710
932
 
711
933
  // ── Render legend ──────────────────────────────────────
712
- if (parsed.tagGroups.length > 0) {
934
+ const hasDescriptions = parsed.nodes.some(
935
+ (n) => n.description && n.description.length > 0
936
+ );
937
+ const hasLegend = parsed.tagGroups.length > 0 || hasDescriptions;
938
+
939
+ if (hasLegend) {
940
+ // Build controls group for description toggle
941
+ let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
942
+ if (hasDescriptions && onToggleDescriptions) {
943
+ controlsGroup = {
944
+ toggles: [
945
+ {
946
+ id: 'descriptions',
947
+ type: 'toggle',
948
+ label: 'Descriptions',
949
+ active: !hideDescriptions,
950
+ onToggle: () => {},
951
+ },
952
+ ],
953
+ };
954
+ }
955
+
713
956
  const legendConfig: LegendConfig = {
714
957
  groups: parsed.tagGroups,
715
958
  position: { placement: 'top-center', titleRelation: 'below-title' },
716
959
  mode: 'fixed',
960
+ controlsGroup,
961
+ };
962
+ const legendState: LegendState = {
963
+ activeGroup,
964
+ controlsExpanded,
965
+ };
966
+ const legendCallbacks: LegendCallbacks = {
967
+ onControlsExpand: onToggleControlsExpand,
968
+ onControlsToggle: (toggleId, active) => {
969
+ if (toggleId === 'descriptions' && onToggleDescriptions) {
970
+ onToggleDescriptions(active);
971
+ }
972
+ },
717
973
  };
718
- const legendState: LegendState = { activeGroup };
719
974
  const legendG = svg
720
975
  .append('g')
721
976
  .attr('transform', `translate(0,${titleOffset + 4})`);
@@ -725,7 +980,7 @@ export function renderBoxesAndLines(
725
980
  legendState,
726
981
  palette,
727
982
  isDark,
728
- undefined,
983
+ legendCallbacks,
729
984
  width
730
985
  );
731
986
  legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
@@ -743,10 +998,12 @@ export function renderBoxesAndLinesForExport(
743
998
  options?: {
744
999
  exportDims?: { width: number; height: number };
745
1000
  activeTagGroup?: string | null;
1001
+ hiddenTagValues?: Map<string, Set<string>>;
746
1002
  }
747
1003
  ): void {
748
1004
  renderBoxesAndLines(container, parsed, layout, palette, isDark, {
749
1005
  exportDims: options?.exportDims,
750
1006
  activeTagGroup: options?.activeTagGroup,
1007
+ hiddenTagValues: options?.hiddenTagValues,
751
1008
  });
752
1009
  }