@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
@@ -28,7 +28,11 @@ import type { ResolvedTagMap } from './tag-resolution';
28
28
  import { resolveActiveTagGroup } from '../utils/tag-groups';
29
29
  import { LEGEND_HEIGHT } from '../utils/legend-constants';
30
30
  import { renderLegendD3 } from '../utils/legend-d3';
31
- import type { LegendConfig, LegendState } from '../utils/legend-types';
31
+ import type {
32
+ LegendCallbacks,
33
+ LegendConfig,
34
+ LegendState,
35
+ } from '../utils/legend-types';
32
36
  import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
33
37
 
34
38
  // ============================================================
@@ -60,6 +64,11 @@ const NOTE_CHARS_PER_LINE = Math.floor(
60
64
  );
61
65
  const COLLAPSED_NOTE_H = 20;
62
66
  const COLLAPSED_NOTE_W = 40;
67
+ const ACTIVATION_WIDTH = 10;
68
+ const SELF_CALL_HEIGHT = 25;
69
+ const SELF_CALL_WIDTH = 30;
70
+ // Max note width that keeps a note within one participant lane
71
+ const NOTE_LANE_MAX = PARTICIPANT_GAP - ACTIVATION_WIDTH - NOTE_GAP; // 135px
63
72
 
64
73
  function wrapTextLines(text: string, maxChars: number): string[] {
65
74
  const rawLines = text.split('\n');
@@ -68,14 +77,26 @@ function wrapTextLines(text: string, maxChars: number): string[] {
68
77
  if (line.length <= maxChars) {
69
78
  wrapped.push(line);
70
79
  } else {
71
- const words = line.split(' ');
72
- let current = '';
80
+ // Preserve bullet prefix: keep "- " glued to the first content word
81
+ // so wrapping never produces a bare "-" line.
82
+ const bulletPrefix = line.startsWith('- ') ? '- ' : '';
83
+ const content = bulletPrefix ? line.slice(2) : line;
84
+ const words = content.split(' ');
85
+ let current = bulletPrefix;
73
86
  for (const word of words) {
74
- if (current && (current + ' ' + word).length > maxChars) {
87
+ const candidate = current ? current + ' ' + word : word;
88
+ if (
89
+ current &&
90
+ current !== bulletPrefix &&
91
+ candidate.length > maxChars
92
+ ) {
75
93
  wrapped.push(current);
76
94
  current = word;
77
95
  } else {
78
- current = current ? current + ' ' + word : word;
96
+ current =
97
+ current && current !== bulletPrefix
98
+ ? current + ' ' + word
99
+ : current + word;
79
100
  }
80
101
  }
81
102
  if (current) wrapped.push(current);
@@ -539,6 +560,10 @@ export interface SequenceRenderOptions {
539
560
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
540
561
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
541
562
  activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
563
+ expandAllNotes?: boolean; // Whether the "Expand Notes" toggle is active
564
+ onExpandAllNotes?: (expand: boolean) => void; // Toggle all notes expanded/collapsed
565
+ controlsExpanded?: boolean; // Controls group expanded state (managed by React)
566
+ onToggleControlsExpand?: () => void; // Callback to toggle controls group
542
567
  }
543
568
 
544
569
  /**
@@ -953,6 +978,37 @@ export function renderSequenceDiagram(
953
978
  );
954
979
  if (participants.length === 0) return;
955
980
 
981
+ // Participant index lookup — used to clamp note width within one lane
982
+ const participantIndexMap = new Map<string, number>();
983
+ participants.forEach((p, i) => participantIndexMap.set(p.id, i));
984
+
985
+ // Extra X shift for notes after self-calls
986
+ const SELF_CALL_NOTE_X_SHIFT =
987
+ ACTIVATION_WIDTH / 2 +
988
+ SELF_CALL_WIDTH +
989
+ NOTE_GAP -
990
+ (ACTIVATION_WIDTH + NOTE_GAP); // 25px
991
+
992
+ const noteEffectiveMaxW = (
993
+ participantId: string,
994
+ position: 'right' | 'left',
995
+ afterSelfCall = false
996
+ ): number => {
997
+ const idx = participantIndexMap.get(participantId);
998
+ if (idx === undefined) return NOTE_MAX_W;
999
+ const hasNeighbor =
1000
+ position === 'right' ? idx < participants.length - 1 : idx > 0;
1001
+ if (!hasNeighbor) return NOTE_MAX_W;
1002
+ const laneMax =
1003
+ afterSelfCall && position === 'right'
1004
+ ? NOTE_LANE_MAX - SELF_CALL_NOTE_X_SHIFT
1005
+ : NOTE_LANE_MAX;
1006
+ return Math.min(NOTE_MAX_W, laneMax);
1007
+ };
1008
+
1009
+ const charsForWidth = (maxW: number): number =>
1010
+ Math.floor((maxW - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W);
1011
+
956
1012
  const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
957
1013
 
958
1014
  // Tag resolution — shared utility handles priority chain:
@@ -1049,7 +1105,39 @@ export function renderSequenceDiagram(
1049
1105
  return msgToLastStep.get(closestMsgIndex) ?? -1;
1050
1106
  };
1051
1107
 
1052
- // Find the first visible message index in an element subtree
1108
+ // Check whether a note's preceding message is a self-call.
1109
+ // Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,
1110
+ // so notes after self-calls need a larger vertical offset.
1111
+ const isNoteAfterSelfCall = (note: SequenceNote): boolean => {
1112
+ let closestMsgIndex = -1;
1113
+ let closestLine = -1;
1114
+ for (let mi = 0; mi < messages.length; mi++) {
1115
+ if (
1116
+ messages[mi].lineNumber < note.lineNumber &&
1117
+ messages[mi].lineNumber > closestLine
1118
+ ) {
1119
+ closestLine = messages[mi].lineNumber;
1120
+ closestMsgIndex = mi;
1121
+ }
1122
+ }
1123
+ if (closestMsgIndex < 0) return false;
1124
+ const msg = messages[closestMsgIndex];
1125
+ return msg.from === msg.to;
1126
+ };
1127
+
1128
+ // Extra gap below self-call loop before note starts
1129
+ const SELF_CALL_NOTE_GAP = 8;
1130
+ const noteOffsetBelow = (note: SequenceNote): number =>
1131
+ isNoteAfterSelfCall(note)
1132
+ ? SELF_CALL_HEIGHT + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP
1133
+ : NOTE_OFFSET_BELOW;
1134
+
1135
+ // Find the first visible message index in an element subtree.
1136
+ // Use lineNumber lookup instead of indexOf — collapse projection creates
1137
+ // separate spread copies for messages[] and elements[], breaking reference equality.
1138
+ const msgLineToIdx = new Map<number, number>();
1139
+ messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
1140
+
1053
1141
  const findFirstMsgIndex = (els: SequenceElement[]): number => {
1054
1142
  for (const el of els) {
1055
1143
  if (isSequenceBlock(el)) {
@@ -1064,7 +1152,7 @@ export function renderSequenceDiagram(
1064
1152
  const elseIdx = findFirstMsgIndex(el.elseChildren);
1065
1153
  if (elseIdx >= 0) return elseIdx;
1066
1154
  } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
1067
- const idx = messages.indexOf(el as SequenceMessage);
1155
+ const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
1068
1156
  if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
1069
1157
  }
1070
1158
  }
@@ -1121,8 +1209,11 @@ export function renderSequenceDiagram(
1121
1209
  // When notes share horizontal space with subsequent arrows, generous vertical clearance
1122
1210
  // is needed so note boxes don't visually cover message labels.
1123
1211
  const NOTE_TRAILING_GAP = 35;
1124
- const computeNoteHeight = (text: string): number => {
1125
- const lines = wrapTextLines(text, NOTE_CHARS_PER_LINE);
1212
+ const computeNoteHeight = (
1213
+ text: string,
1214
+ maxChars: number = NOTE_CHARS_PER_LINE
1215
+ ): number => {
1216
+ const lines = wrapTextLines(text, maxChars);
1126
1217
  return lines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
1127
1218
  };
1128
1219
  let trailingNoteSpace = 0; // extra space for notes at the end with no following message
@@ -1131,15 +1222,18 @@ export function renderSequenceDiagram(
1131
1222
  const el = els[i];
1132
1223
  if (isSequenceNote(el)) {
1133
1224
  // Total vertical extent of notes from the message arrow:
1134
- // NOTE_OFFSET_BELOW (gap above first note)
1225
+ // offset (gap above first note — larger after self-calls)
1135
1226
  // + each note's height + NOTE_OFFSET_BELOW (inter-note gap)
1136
1227
  // + NOTE_TRAILING_GAP (gap below last note — clears next message label)
1137
- let totalExtent = NOTE_OFFSET_BELOW;
1228
+ const firstOffset = noteOffsetBelow(el as SequenceNote);
1229
+ let totalExtent = firstOffset;
1138
1230
  let j = i;
1139
1231
  while (j < els.length && isSequenceNote(els[j])) {
1140
1232
  const note = els[j] as SequenceNote;
1233
+ const sc = isNoteAfterSelfCall(note);
1234
+ const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
1141
1235
  const noteH = isNoteExpanded(note)
1142
- ? computeNoteHeight(note.text)
1236
+ ? computeNoteHeight(note.text, charsForWidth(maxW))
1143
1237
  : COLLAPSED_NOTE_H;
1144
1238
  totalExtent += noteH + NOTE_OFFSET_BELOW;
1145
1239
  j++;
@@ -1401,13 +1495,18 @@ export function renderSequenceDiagram(
1401
1495
  let noteTopY: number;
1402
1496
  if (prevNoteY !== undefined && prevNote) {
1403
1497
  // Stack below previous note
1498
+ const prevMaxW = noteEffectiveMaxW(
1499
+ prevNote.participantId,
1500
+ prevNote.position,
1501
+ isNoteAfterSelfCall(prevNote)
1502
+ );
1404
1503
  const prevNoteH = isNoteExpanded(prevNote)
1405
- ? computeNoteHeight(prevNote.text)
1504
+ ? computeNoteHeight(prevNote.text, charsForWidth(prevMaxW))
1406
1505
  : COLLAPSED_NOTE_H;
1407
1506
  noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
1408
1507
  } else {
1409
- // First note after a message
1410
- noteTopY = stepY(si) + NOTE_OFFSET_BELOW;
1508
+ // First note after a message — use larger offset after self-calls
1509
+ noteTopY = stepY(si) + noteOffsetBelow(el);
1411
1510
  }
1412
1511
  noteYMap.set(el, noteTopY);
1413
1512
  } else if (isSequenceBlock(el)) {
@@ -1435,8 +1534,13 @@ export function renderSequenceDiagram(
1435
1534
  )
1436
1535
  : layoutEndY;
1437
1536
  for (const [note, noteTopY] of noteYMap) {
1537
+ const maxW = noteEffectiveMaxW(
1538
+ note.participantId,
1539
+ note.position,
1540
+ isNoteAfterSelfCall(note)
1541
+ );
1438
1542
  const noteH = isNoteExpanded(note)
1439
- ? computeNoteHeight(note.text)
1543
+ ? computeNoteHeight(note.text, charsForWidth(maxW))
1440
1544
  : COLLAPSED_NOTE_H;
1441
1545
  contentBottomY = Math.max(
1442
1546
  contentBottomY,
@@ -1632,39 +1736,33 @@ export function renderSequenceDiagram(
1632
1736
  }
1633
1737
  }
1634
1738
 
1635
- // Render legend pills for tag groups
1636
- if (parsed.tagGroups.length > 0) {
1637
- const legendY = TOP_MARGIN + titleOffset;
1638
- // Resolve tag colors for legend entries
1639
- const resolvedGroups = parsed.tagGroups
1640
- .filter((tg) => tg.entries.length > 0)
1641
- .map((tg) => ({
1642
- name: tg.name,
1643
- entries: tg.entries.map((e) => ({
1644
- value: e.value,
1645
- color: e.color,
1646
- })),
1647
- }));
1648
- const legendConfig: LegendConfig = {
1649
- groups: resolvedGroups,
1650
- position: { placement: 'top-center', titleRelation: 'below-title' },
1651
- mode: 'fixed',
1652
- };
1653
- const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
1654
- const legendG = svg
1655
- .append('g')
1656
- .attr('class', 'sequence-legend')
1657
- .attr('transform', `translate(0,${legendY})`);
1658
- renderLegendD3(
1659
- legendG,
1660
- legendConfig,
1661
- legendState,
1662
- palette,
1663
- isDark,
1664
- undefined,
1665
- svgWidth
1666
- );
1667
- }
1739
+ // Collect all note line numbers (for controls group visibility + "all expanded" check)
1740
+ const allNoteLineNumbers: number[] = [];
1741
+ const collectNoteLines = (els: SequenceElement[]): void => {
1742
+ for (const el of els) {
1743
+ if (isSequenceNote(el)) {
1744
+ allNoteLineNumbers.push(el.lineNumber);
1745
+ } else if (isSequenceBlock(el)) {
1746
+ collectNoteLines(el.children);
1747
+ if ('elseChildren' in el) collectNoteLines(el.elseChildren);
1748
+ if ('branches' in el && Array.isArray(el.branches)) {
1749
+ for (const branch of el.branches) {
1750
+ collectNoteLines(branch.children);
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ };
1756
+ collectNoteLines(elements);
1757
+
1758
+ // Show controls group only in interactive mode (expandedNoteLines defined)
1759
+ // when notes exist and collapse-notes is not disabled
1760
+ const showNotesControl =
1761
+ allNoteLineNumbers.length > 0 &&
1762
+ !collapseNotesDisabled &&
1763
+ expandedNoteLines !== undefined;
1764
+
1765
+ const hasTagGroups = parsed.tagGroups.length > 0;
1668
1766
 
1669
1767
  // Build set of collapsed group names for drill-bar rendering
1670
1768
  const collapsedGroupNames = new Set<string>();
@@ -2093,7 +2191,6 @@ export function renderSequenceDiagram(
2093
2191
  }
2094
2192
 
2095
2193
  // Render activation rectangles (behind arrows)
2096
- const ACTIVATION_WIDTH = 10;
2097
2194
  const ACTIVATION_NEST_OFFSET = 6;
2098
2195
  activations.forEach((act) => {
2099
2196
  const px = participantX.get(act.participantId);
@@ -2285,8 +2382,7 @@ export function renderSequenceDiagram(
2285
2382
  }
2286
2383
 
2287
2384
  // Render steps (calls and returns in stack-inferred order)
2288
- const SELF_CALL_WIDTH = 30;
2289
- const SELF_CALL_HEIGHT = 25;
2385
+ // SELF_CALL_WIDTH is now a module-level constant
2290
2386
  renderSteps.forEach((step, i) => {
2291
2387
  const fromX = participantX.get(step.from);
2292
2388
  const toX = participantX.get(step.to);
@@ -2354,6 +2450,10 @@ export function renderSequenceDiagram(
2354
2450
  .attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
2355
2451
  .attr('text-anchor', 'start')
2356
2452
  .attr('fill', arrowColor)
2453
+ .attr('paint-order', 'stroke fill')
2454
+ .attr('stroke', palette.bg)
2455
+ .attr('stroke-width', 4)
2456
+ .attr('stroke-linejoin', 'round')
2357
2457
  .attr('font-size', 12)
2358
2458
  .attr('class', 'message-label')
2359
2459
  .attr(
@@ -2424,6 +2524,10 @@ export function renderSequenceDiagram(
2424
2524
  .attr('y', y - 8)
2425
2525
  .attr('text-anchor', 'middle')
2426
2526
  .attr('fill', arrowColor)
2527
+ .attr('paint-order', 'stroke fill')
2528
+ .attr('stroke', palette.bg)
2529
+ .attr('stroke-width', 4)
2530
+ .attr('stroke-linejoin', 'round')
2427
2531
  .attr('font-size', 12)
2428
2532
  .attr('class', 'message-label')
2429
2533
  .attr(
@@ -2498,6 +2602,10 @@ export function renderSequenceDiagram(
2498
2602
  .attr('y', y - 6)
2499
2603
  .attr('text-anchor', 'middle')
2500
2604
  .attr('fill', returnColor)
2605
+ .attr('paint-order', 'stroke fill')
2606
+ .attr('stroke', palette.bg)
2607
+ .attr('stroke-width', 4)
2608
+ .attr('stroke-linejoin', 'round')
2501
2609
  .attr('font-size', 11)
2502
2610
  .attr('class', 'message-label')
2503
2611
  .attr(
@@ -2539,15 +2647,27 @@ export function renderSequenceDiagram(
2539
2647
 
2540
2648
  if (expanded) {
2541
2649
  // --- Expanded note: full folded-corner box with wrapped text ---
2542
- const wrappedLines = wrapTextLines(el.text, NOTE_CHARS_PER_LINE);
2650
+ const afterSelfCall = isNoteAfterSelfCall(el);
2651
+ const maxW = noteEffectiveMaxW(
2652
+ el.participantId,
2653
+ el.position,
2654
+ afterSelfCall
2655
+ );
2656
+ const maxChars = charsForWidth(maxW);
2657
+ const wrappedLines = wrapTextLines(el.text, maxChars);
2543
2658
  const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
2544
2659
  const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
2545
2660
  const noteW = Math.min(
2546
- NOTE_MAX_W,
2661
+ maxW,
2547
2662
  Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
2548
2663
  );
2664
+ // Shift notes past self-call loopback when applicable
2665
+ const rightOffset =
2666
+ afterSelfCall && isRight
2667
+ ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2668
+ : ACTIVATION_WIDTH + NOTE_GAP;
2549
2669
  const noteX = isRight
2550
- ? px + ACTIVATION_WIDTH + NOTE_GAP
2670
+ ? px + rightOffset
2551
2671
  : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
2552
2672
 
2553
2673
  const noteG = svg
@@ -2621,8 +2741,13 @@ export function renderSequenceDiagram(
2621
2741
  } else {
2622
2742
  // --- Collapsed note: compact indicator ---
2623
2743
  const cFold = 6;
2744
+ const afterSelfCallC = isNoteAfterSelfCall(el);
2745
+ const rightOffsetC =
2746
+ afterSelfCallC && isRight
2747
+ ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2748
+ : ACTIVATION_WIDTH + NOTE_GAP;
2624
2749
  const noteX = isRight
2625
- ? px + ACTIVATION_WIDTH + NOTE_GAP
2750
+ ? px + rightOffsetC
2626
2751
  : px - ACTIVATION_WIDTH - NOTE_GAP - COLLAPSED_NOTE_W;
2627
2752
 
2628
2753
  const noteG = svg
@@ -2694,6 +2819,73 @@ export function renderSequenceDiagram(
2694
2819
  if (elements && elements.length > 0) {
2695
2820
  renderNoteElements(elements);
2696
2821
  }
2822
+
2823
+ // Render legend LAST so it sits on top of all other SVG elements
2824
+ // (group boxes, lifelines, participants, etc.) and can receive clicks.
2825
+ if (hasTagGroups || showNotesControl) {
2826
+ const controlsExpanded = options?.controlsExpanded ?? false;
2827
+
2828
+ const legendY = TOP_MARGIN + titleOffset;
2829
+ const resolvedGroups = parsed.tagGroups
2830
+ .filter((tg) => tg.entries.length > 0)
2831
+ .map((tg) => ({
2832
+ name: tg.name,
2833
+ entries: tg.entries.map((e) => ({
2834
+ value: e.value,
2835
+ color: e.color,
2836
+ })),
2837
+ }));
2838
+
2839
+ const allExpanded = showNotesControl && (options?.expandAllNotes ?? false);
2840
+
2841
+ const controlsGroup = showNotesControl
2842
+ ? {
2843
+ toggles: [
2844
+ {
2845
+ id: 'expand-all-notes',
2846
+ type: 'toggle' as const,
2847
+ label: 'Expand Notes',
2848
+ active: allExpanded,
2849
+ onToggle: () => {},
2850
+ },
2851
+ ],
2852
+ }
2853
+ : undefined;
2854
+
2855
+ const legendConfig: LegendConfig = {
2856
+ groups: resolvedGroups,
2857
+ position: { placement: 'top-center', titleRelation: 'below-title' },
2858
+ mode: 'fixed',
2859
+ controlsGroup,
2860
+ };
2861
+ const legendState: LegendState = {
2862
+ activeGroup: activeTagGroup ?? null,
2863
+ controlsExpanded,
2864
+ };
2865
+
2866
+ const legendCallbacks: LegendCallbacks = {
2867
+ onControlsExpand: () => {
2868
+ options?.onToggleControlsExpand?.();
2869
+ },
2870
+ onControlsToggle: (_toggleId: string, active: boolean) => {
2871
+ options?.onExpandAllNotes?.(active);
2872
+ },
2873
+ };
2874
+
2875
+ const legendG = svg
2876
+ .append('g')
2877
+ .attr('class', 'sequence-legend')
2878
+ .attr('transform', `translate(0,${legendY})`);
2879
+ renderLegendD3(
2880
+ legendG,
2881
+ legendConfig,
2882
+ legendState,
2883
+ palette,
2884
+ isDark,
2885
+ legendCallbacks,
2886
+ svgWidth
2887
+ );
2888
+ }
2697
2889
  }
2698
2890
 
2699
2891
  /**
@@ -2732,6 +2924,31 @@ export function buildNoteMessageMap(
2732
2924
  return map;
2733
2925
  }
2734
2926
 
2927
+ /**
2928
+ * Collect all note line numbers from a sequence diagram's elements.
2929
+ * Used by the app to compute the "expand all" set.
2930
+ */
2931
+ export function collectNoteLineNumbers(elements: SequenceElement[]): number[] {
2932
+ const result: number[] = [];
2933
+ const walk = (els: SequenceElement[]): void => {
2934
+ for (const el of els) {
2935
+ if (isSequenceNote(el)) {
2936
+ result.push(el.lineNumber);
2937
+ } else if (isSequenceBlock(el)) {
2938
+ walk(el.children);
2939
+ if (el.elseIfBranches) {
2940
+ for (const branch of el.elseIfBranches) {
2941
+ walk(branch.children);
2942
+ }
2943
+ }
2944
+ walk(el.elseChildren);
2945
+ }
2946
+ }
2947
+ };
2948
+ walk(elements);
2949
+ return result;
2950
+ }
2951
+
2735
2952
  function renderParticipant(
2736
2953
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2737
2954
  participant: SequenceParticipant,
package/src/sharing.ts CHANGED
@@ -30,6 +30,9 @@ export interface CompactViewState {
30
30
  rps?: number; // RPS multiplier (infra)
31
31
  spd?: number; // playback speed (infra)
32
32
  io?: Record<string, number>; // instance overrides (infra)
33
+ hd?: boolean; // hide descriptions (mindmap)
34
+ cbd?: boolean; // color by depth (mindmap)
35
+ rq?: string; // radar quadrant focus (tech-radar position)
33
36
  }
34
37
 
35
38
  export interface DecodedDiagramUrl {
@@ -22,6 +22,7 @@ export interface SitemapLayoutNode {
22
22
  metadata: Record<string, string>;
23
23
  /** Original (unfiltered) metadata for tag-based coloring and hover dimming */
24
24
  tagMetadata: Record<string, string>;
25
+ description?: string[];
25
26
  isContainer: boolean;
26
27
  lineNumber: number;
27
28
  color?: string;
@@ -161,23 +162,36 @@ function filterMetadata(
161
162
  return filtered;
162
163
  }
163
164
 
164
- function computeCardWidth(label: string, meta: Record<string, string>): number {
165
+ function computeCardWidth(
166
+ label: string,
167
+ meta: Record<string, string>,
168
+ descLines?: string[]
169
+ ): number {
165
170
  let maxChars = label.length;
166
171
  for (const [key, value] of Object.entries(meta)) {
167
172
  const lineChars = key.length + 2 + value.length;
168
173
  if (lineChars > maxChars) maxChars = lineChars;
169
174
  }
175
+ if (descLines) {
176
+ for (const dl of descLines) {
177
+ if (dl.length > maxChars) maxChars = dl.length;
178
+ }
179
+ }
170
180
  return Math.max(
171
181
  MIN_CARD_WIDTH,
172
182
  Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
173
183
  );
174
184
  }
175
185
 
176
- function computeCardHeight(meta: Record<string, string>): number {
186
+ function computeCardHeight(
187
+ meta: Record<string, string>,
188
+ descLineCount = 0
189
+ ): number {
177
190
  const metaCount = Object.keys(meta).length;
178
- if (metaCount === 0) return HEADER_HEIGHT + CARD_V_PAD;
191
+ const contentCount = metaCount + descLineCount;
192
+ if (contentCount === 0) return HEADER_HEIGHT + CARD_V_PAD;
179
193
  return (
180
- HEADER_HEIGHT + SEPARATOR_GAP + metaCount * META_LINE_HEIGHT + CARD_V_PAD
194
+ HEADER_HEIGHT + SEPARATOR_GAP + contentCount * META_LINE_HEIGHT + CARD_V_PAD
181
195
  );
182
196
  }
183
197
 
@@ -307,8 +321,8 @@ function flattenNodes(
307
321
  parentPageId,
308
322
  meta,
309
323
  fullMeta: { ...node.metadata },
310
- width: computeCardWidth(node.label, meta),
311
- height: computeCardHeight(meta),
324
+ width: computeCardWidth(node.label, meta, node.description),
325
+ height: computeCardHeight(meta, node.description?.length ?? 0),
312
326
  });
313
327
  // Pages can have children too (nested pages) — this page becomes the parentPageId
314
328
  if (node.children.length > 0) {
@@ -396,15 +410,15 @@ export function layoutSitemap(
396
410
  const pageNodeIds = new Set<string>();
397
411
  const collapsedContainerIds = new Set<string>();
398
412
 
399
- // Identify containers vs pages, and detect collapsed (empty) containers
413
+ // Identify containers vs pages, and detect collapsed containers.
414
+ // A container is collapsed iff hiddenCounts records it with a positive count
415
+ // (meaning collapseSitemapTree pruned its descendants). Source-level empty
416
+ // containers (never had children) are NOT treated as collapsed.
400
417
  for (const flat of flatNodes) {
401
418
  if (flat.sitemapNode.isContainer) {
402
419
  containerIds.add(flat.sitemapNode.id);
403
- // A container is "collapsed" if it has no children at all in the flat list
404
- const hasAnyChild = flatNodes.some(
405
- (f) => f.parentContainerId === flat.sitemapNode.id
406
- );
407
- if (!hasAnyChild) {
420
+ const hidden = hiddenCounts?.get(flat.sitemapNode.id) ?? 0;
421
+ if (hidden > 0) {
408
422
  collapsedContainerIds.add(flat.sitemapNode.id);
409
423
  }
410
424
  } else {
@@ -412,16 +426,31 @@ export function layoutSitemap(
412
426
  }
413
427
  }
414
428
 
429
+ // Sibling-page floor for collapsed containers — prevents collapsed containers
430
+ // from rendering smaller than meta-rich page cards in the same layout.
431
+ let pageMaxW = 0;
432
+ let pageMaxH = 0;
433
+ for (const f of flatNodes) {
434
+ if (!f.sitemapNode.isContainer) {
435
+ if (f.width > pageMaxW) pageMaxW = f.width;
436
+ if (f.height > pageMaxH) pageMaxH = f.height;
437
+ }
438
+ }
439
+
415
440
  // Add nodes to dagre
416
441
  for (const flat of flatNodes) {
417
442
  const node = flat.sitemapNode;
418
443
  if (node.isContainer) {
419
444
  if (collapsedContainerIds.has(node.id)) {
420
- // Collapsed container — regular node with explicit dimensions
445
+ // Collapsed container — regular node with explicit dimensions.
446
+ // Floor to max page-card dims so collapsed containers never look
447
+ // smaller than sibling page cards.
448
+ const flooredW = Math.max(flat.width, pageMaxW);
449
+ const flooredH = Math.max(flat.height, pageMaxH);
421
450
  g.setNode(node.id, {
422
451
  label: node.label,
423
- width: flat.width,
424
- height: flat.height,
452
+ width: flooredW,
453
+ height: flooredH,
425
454
  });
426
455
  } else {
427
456
  // Regular container — compound node with padding for child layout
@@ -504,6 +533,7 @@ export function layoutSitemap(
504
533
  label: node.label,
505
534
  metadata: flat.meta,
506
535
  tagMetadata: flat.fullMeta,
536
+ description: node.description,
507
537
  isContainer: false,
508
538
  lineNumber: node.lineNumber,
509
539
  color: resolveNodeColor(node, parsed.tagGroups, activeTagGroup ?? null),
@@ -546,7 +576,15 @@ export function layoutSitemap(
546
576
  node.children.length > 0 || (hc != null && hc > 0) || undefined,
547
577
  });
548
578
  } else {
549
- // Fallback
579
+ // Fallback — still apply the floor for consistency
580
+ const isCollapsed = collapsedContainerIds.has(node.id);
581
+ const flooredW = isCollapsed
582
+ ? Math.max(flat.width, pageMaxW)
583
+ : flat.width;
584
+ const fallbackH = isCollapsed
585
+ ? flat.height
586
+ : labelHeight + CONTAINER_PAD_BOTTOM;
587
+ const flooredH = isCollapsed ? Math.max(fallbackH, pageMaxH) : fallbackH;
550
588
  layoutContainers.push({
551
589
  nodeId: node.id,
552
590
  label: node.label,
@@ -556,8 +594,8 @@ export function layoutSitemap(
556
594
  tagMetadata: flat.fullMeta,
557
595
  x: MARGIN,
558
596
  y: MARGIN,
559
- width: flat.width,
560
- height: labelHeight + CONTAINER_PAD_BOTTOM,
597
+ width: flooredW,
598
+ height: flooredH,
561
599
  labelHeight,
562
600
  hiddenCount: hc,
563
601
  hasChildren: