@diagrammo/dgmo 0.8.21 → 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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
@@ -45,9 +45,9 @@ function measureIndent(line: string): number {
45
45
  function parsePipeMetadata(
46
46
  segment: string,
47
47
  aliasMap: Map<string, string>
48
- ): { metadata: Record<string, string>; description?: string } {
48
+ ): { metadata: Record<string, string>; description?: string[] } {
49
49
  const metadata: Record<string, string> = {};
50
- let description: string | undefined;
50
+ let description: string[] | undefined;
51
51
 
52
52
  const items = segment.split(',');
53
53
  for (const item of items) {
@@ -59,7 +59,7 @@ function parsePipeMetadata(
59
59
  const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
60
60
  const value = trimmed.slice(colonIdx + 1).trim();
61
61
  if (rawKey === 'description') {
62
- description = value;
62
+ description = [value];
63
63
  } else {
64
64
  const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
65
65
  metadata[resolvedKey] = value;
@@ -98,6 +98,25 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
98
98
  let lastNodeLabel: string | null = null;
99
99
  let lastSourceIsGroup = false;
100
100
 
101
+ // Description collection state
102
+ let descState: {
103
+ nodeLabel: string;
104
+ indent: number;
105
+ lines: string[];
106
+ edgeSeen: boolean;
107
+ } | null = null;
108
+
109
+ function flushDescription() {
110
+ if (descState && descState.lines.length > 0) {
111
+ const node = result.nodes.find((n) => n.label === descState!.nodeLabel);
112
+ if (node) {
113
+ const existing = node.description ?? [];
114
+ node.description = [...existing, ...descState!.lines];
115
+ }
116
+ }
117
+ descState = null;
118
+ }
119
+
101
120
  // Group stack for nesting
102
121
  interface GroupState {
103
122
  group: BLGroup;
@@ -283,7 +302,51 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
283
302
 
284
303
  // Non-indented line closes tag group
285
304
  if (currentTagGroup && indent === 0) {
286
- currentTagGroup = null; // eslint-disable-line no-useless-assignment
305
+ currentTagGroup = null;
306
+ }
307
+
308
+ // Description collection: indented non-edge lines under a node
309
+ if (descState !== null) {
310
+ if (indent > descState.indent) {
311
+ // Check if this is an edge line
312
+ if (trimmed.includes('->') || trimmed.includes('<->')) {
313
+ descState.edgeSeen = true;
314
+ // Fall through to normal edge processing
315
+ } else if (descState.edgeSeen) {
316
+ // Text after edges — emit warning
317
+ result.diagnostics.push(
318
+ makeDgmoError(
319
+ lineNum,
320
+ `Move description lines above edges for '${descState.nodeLabel}' — descriptions must come before -> lines`,
321
+ 'warning'
322
+ )
323
+ );
324
+ continue;
325
+ } else if (
326
+ /^-\s*\w/.test(trimmed) &&
327
+ !trimmed.startsWith('- ') &&
328
+ !trimmed.includes('->') &&
329
+ !trimmed.includes('<->')
330
+ ) {
331
+ // Looks like a malformed edge (e.g. "-Target" but not "- list item")
332
+ result.diagnostics.push(
333
+ makeDgmoError(
334
+ lineNum,
335
+ `Looks like an incomplete edge — did you mean "-> ${trimmed.slice(1).trim()}"?`,
336
+ 'warning'
337
+ )
338
+ );
339
+ descState.lines.push(trimmed);
340
+ continue;
341
+ } else {
342
+ // Collect as description
343
+ descState.lines.push(trimmed);
344
+ continue;
345
+ }
346
+ } else {
347
+ // Indent decreased — flush description
348
+ flushDescription();
349
+ }
287
350
  }
288
351
 
289
352
  // Close groups that are no longer scoped by indent
@@ -358,6 +421,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
358
421
  if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
359
422
  contentStarted = true;
360
423
  currentTagGroup = null;
424
+ flushDescription();
361
425
  const label = groupMatch[1];
362
426
 
363
427
  // Check nesting depth
@@ -416,7 +480,18 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
416
480
 
417
481
  // Indented shorthand: `-> Target` or `-label-> Target`
418
482
  if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
419
- if (!lastNodeLabel) {
483
+ // If the edge is at group-child indent level, use the containing group
484
+ const gs = currentGroupState();
485
+ const inGroup = gs && indent > gs.indent;
486
+ if (inGroup) {
487
+ const sourcePrefix = `[${gs.group.label}]`;
488
+ edgeText = `${sourcePrefix} ${trimmed}`;
489
+ } else if (lastNodeLabel) {
490
+ const sourcePrefix = lastSourceIsGroup
491
+ ? `[${lastNodeLabel}]`
492
+ : lastNodeLabel;
493
+ edgeText = `${sourcePrefix} ${trimmed}`;
494
+ } else {
420
495
  result.diagnostics.push(
421
496
  makeDgmoError(
422
497
  lineNum,
@@ -426,10 +501,6 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
426
501
  );
427
502
  continue;
428
503
  }
429
- const sourcePrefix = lastSourceIsGroup
430
- ? `[${lastNodeLabel}]`
431
- : lastNodeLabel;
432
- edgeText = `${sourcePrefix} ${trimmed}`;
433
504
  }
434
505
 
435
506
  const edge = parseEdgeLine(
@@ -449,6 +520,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
449
520
  // Node: everything else
450
521
  contentStarted = true;
451
522
  currentTagGroup = null;
523
+ flushDescription(); // Flush any pending description from previous node
452
524
  const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
453
525
  if (!node) {
454
526
  result.diagnostics.push(
@@ -486,8 +558,12 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
486
558
  }
487
559
 
488
560
  result.nodes.push(node);
561
+ descState = { nodeLabel: node.label, indent, lines: [], edgeSeen: false };
489
562
  }
490
563
 
564
+ // Flush any remaining description
565
+ flushDescription();
566
+
491
567
  // Close any remaining groups
492
568
  while (groupStack.length > 0) {
493
569
  const gs = groupStack.pop()!;
@@ -562,7 +638,7 @@ function parseNodeLine(
562
638
  _diagnostics: DgmoError[]
563
639
  ): BLNode | null {
564
640
  let metadata: Record<string, string> = {};
565
- let description: string | undefined;
641
+ let description: string[] | undefined;
566
642
 
567
643
  // Split on pipe for metadata
568
644
  const pipeIdx = trimmed.indexOf('|');
@@ -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,6 +37,9 @@ 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;
@@ -81,13 +89,25 @@ function splitCamelCase(word: string): string[] {
81
89
  return parts.length > 1 ? parts : [word];
82
90
  }
83
91
 
84
- 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(
85
98
  label: string,
86
99
  nodeWidth: number,
87
- nodeHeight: number
100
+ maxLines: number
88
101
  ): { lines: string[]; fontSize: number } {
89
102
  const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
90
- 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
+ }
91
111
 
92
112
  for (
93
113
  let fontSize = NODE_FONT_SIZE;
@@ -95,17 +115,15 @@ function fitTextToNode(
95
115
  fontSize--
96
116
  ) {
97
117
  const charWidth = fontSize * CHAR_WIDTH_RATIO;
98
- const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
99
- const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
100
- if (maxCharsPerLine < 2 || maxLines < 1) continue;
101
- if (label.length <= maxCharsPerLine) return { lines: [label], fontSize };
118
+ const maxChars = Math.floor(maxTextWidth / charWidth);
119
+ if (maxChars < 2) continue;
102
120
 
103
- const words = label.split(/\s+/);
121
+ // Wrap words into lines
104
122
  const lines: string[] = [];
105
123
  let current = '';
106
124
  for (const word of words) {
107
125
  const test = current ? `${current} ${word}` : word;
108
- if (test.length <= maxCharsPerLine) {
126
+ if (test.length <= maxChars) {
109
127
  current = test;
110
128
  } else {
111
129
  if (current) lines.push(current);
@@ -113,54 +131,39 @@ function fitTextToNode(
113
131
  }
114
132
  }
115
133
  if (current) lines.push(current);
116
- if (
117
- lines.length <= maxLines &&
118
- lines.every((l) => l.length <= maxCharsPerLine)
119
- ) {
134
+
135
+ // All lines fit at this font? Done.
136
+ if (lines.length <= maxLines && lines.every((l) => l.length <= maxChars)) {
120
137
  return { lines, fontSize };
121
138
  }
122
139
 
123
- // CamelCase split
124
- const camelWords: string[] = [];
125
- for (const word of words) {
126
- if (word.length > maxCharsPerLine)
127
- camelWords.push(...splitCamelCase(word));
128
- else camelWords.push(word);
129
- }
130
- const camelLines: string[] = [];
131
- let cc = '';
132
- for (const word of camelWords) {
133
- const test = cc ? `${cc} ${word}` : word;
134
- if (test.length <= maxCharsPerLine) {
135
- cc = test;
136
- } else {
137
- if (cc) camelLines.push(cc);
138
- cc = word;
139
- }
140
- }
141
- if (cc) camelLines.push(cc);
142
- if (
143
- camelLines.length <= maxLines &&
144
- camelLines.every((l) => l.length <= maxCharsPerLine)
145
- ) {
146
- 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 };
147
146
  }
148
147
 
149
- if (fontSize > MIN_NODE_FONT_SIZE) continue;
150
-
151
- // Hard-break
152
- const hardLines: string[] = [];
153
- for (const line of camelLines) {
154
- if (line.length <= maxCharsPerLine) hardLines.push(line);
155
- else
156
- for (let i = 0; i < line.length; i += maxCharsPerLine)
157
- 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';
158
160
  }
159
- if (hardLines.length <= maxLines) return { lines: hardLines, fontSize };
161
+ return { lines: result, fontSize };
160
162
  }
161
163
 
164
+ // Fallback at min font
162
165
  const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
163
- const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
166
+ const maxChars = Math.floor(maxTextWidth / charWidth);
164
167
  const truncated =
165
168
  label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
166
169
  return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
@@ -292,6 +295,10 @@ interface BLRenderOptions {
292
295
  exportDims?: { width?: number; height?: number };
293
296
  activeTagGroup?: string | null;
294
297
  hiddenTagValues?: Map<string, Set<string>>;
298
+ hideDescriptions?: boolean;
299
+ controlsExpanded?: boolean;
300
+ onToggleDescriptions?: (active: boolean) => void;
301
+ onToggleControlsExpand?: () => void;
295
302
  }
296
303
 
297
304
  export function renderBoxesAndLines(
@@ -302,8 +309,16 @@ export function renderBoxesAndLines(
302
309
  isDark: boolean,
303
310
  options?: BLRenderOptions
304
311
  ): void {
305
- const { onClickItem, exportDims, activeTagGroup, hiddenTagValues } =
306
- options ?? {};
312
+ const {
313
+ onClickItem,
314
+ exportDims,
315
+ activeTagGroup,
316
+ hiddenTagValues,
317
+ hideDescriptions,
318
+ controlsExpanded,
319
+ onToggleDescriptions,
320
+ onToggleControlsExpand,
321
+ } = options ?? {};
307
322
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
308
323
 
309
324
  const width = exportDims?.width ?? container.clientWidth;
@@ -330,7 +345,12 @@ export function renderBoxesAndLines(
330
345
 
331
346
  // Compute diagram bounds for scaling
332
347
  const titleOffset = parsed.title ? 40 : 0;
333
- 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;
334
354
 
335
355
  // Account for group label zone extensions (renderer-only, not in layout.height)
336
356
  const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
@@ -727,7 +747,12 @@ export function renderBoxesAndLines(
727
747
  }
728
748
 
729
749
  if (onClickItem) {
730
- 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
+ });
731
756
  }
732
757
 
733
758
  // Rectangle card
@@ -748,45 +773,146 @@ export function renderBoxesAndLines(
748
773
  .attr('stroke-width', NODE_STROKE_WIDTH);
749
774
 
750
775
  // All text centered vertically using dominant-baseline: central
751
- if (node.description) {
752
- const lineH = NODE_FONT_SIZE * 1.3;
753
- const gap = 2;
754
- const totalH = lineH + gap + META_FONT_SIZE;
755
- const labelY = -totalH / 2 + lineH / 2;
756
- 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
+ }
757
801
 
802
+ // Separator line (full width, matches infra style)
803
+ const sepY = -ln.height / 2 + headerH;
758
804
  nodeG
759
- .append('text')
760
- .attr('x', 0)
761
- .attr('y', labelY)
762
- .attr('text-anchor', 'middle')
763
- .attr('dominant-baseline', 'central')
764
- .attr('font-size', NODE_FONT_SIZE)
765
- .attr('font-weight', '600')
766
- .attr('fill', colors.text)
767
- .text(node.label);
768
-
769
- const maxChars = Math.floor(
770
- (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)
771
819
  );
772
- const desc =
773
- node.description.length > maxChars
774
- ? node.description.slice(0, maxChars - 1) + '\u2026'
775
- : node.description;
776
- const descEl = nodeG
777
- .append('text')
778
- .attr('x', 0)
779
- .attr('y', descY)
780
- .attr('text-anchor', 'middle')
781
- .attr('dominant-baseline', 'central')
782
- .attr('font-size', META_FONT_SIZE)
783
- .attr('fill', palette.textMuted)
784
- .text(desc);
785
- if (desc !== node.description) {
786
- 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);
787
907
  }
788
908
  } else {
789
- 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);
790
916
  const lineH = fitted.fontSize * 1.3;
791
917
  const totalH = fitted.lines.length * lineH;
792
918
  for (let li = 0; li < fitted.lines.length; li++) {
@@ -805,13 +931,46 @@ export function renderBoxesAndLines(
805
931
  }
806
932
 
807
933
  // ── Render legend ──────────────────────────────────────
808
- 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
+
809
956
  const legendConfig: LegendConfig = {
810
957
  groups: parsed.tagGroups,
811
958
  position: { placement: 'top-center', titleRelation: 'below-title' },
812
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
+ },
813
973
  };
814
- const legendState: LegendState = { activeGroup };
815
974
  const legendG = svg
816
975
  .append('g')
817
976
  .attr('transform', `translate(0,${titleOffset + 4})`);
@@ -821,7 +980,7 @@ export function renderBoxesAndLines(
821
980
  legendState,
822
981
  palette,
823
982
  isDark,
824
- undefined,
983
+ legendCallbacks,
825
984
  width
826
985
  );
827
986
  legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
@@ -5,7 +5,7 @@ export interface BLNode {
5
5
  label: string;
6
6
  lineNumber: number;
7
7
  metadata: Record<string, string>;
8
- description?: string;
8
+ description?: string[];
9
9
  }
10
10
 
11
11
  export interface BLEdge {