@diagrammo/dgmo 0.8.21 → 0.8.23

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 (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
package/src/render.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  import { renderForExport } from './d3';
2
2
  import { renderExtendedChartForExport } from './echarts';
3
- import {
4
- parseDgmoChartType,
5
- getRenderCategory,
6
- parseDgmo,
7
- } from './dgmo-router';
3
+ import { getRenderCategory, parseDgmo } from './dgmo-router';
8
4
  import type { DgmoError } from './diagnostics';
9
5
  import { getPalette } from './palettes/registry';
10
6
  import type { CompactViewState } from './sharing';
@@ -84,9 +80,7 @@ export async function render(
84
80
  const paletteColors =
85
81
  getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
86
82
 
87
- const { diagnostics } = parseDgmo(content);
88
-
89
- const chartType = parseDgmoChartType(content);
83
+ const { diagnostics, chartType } = parseDgmo(content);
90
84
  const category = chartType ? getRenderCategory(chartType) : null;
91
85
 
92
86
  // Build viewState from legendState (backwards compat) or use provided viewState
@@ -236,6 +236,8 @@ type NoteParseResult =
236
236
  function parseNoteLine(
237
237
  trimmed: string,
238
238
  participants: SequenceParticipant[],
239
+ participantIds: Set<string>,
240
+ sortedParticipantsCache: SequenceParticipant[],
239
241
  lastMsgFrom: string | null
240
242
  ): NoteParseResult {
241
243
  const lower = trimmed.toLowerCase();
@@ -256,7 +258,7 @@ function parseNoteLine(
256
258
  if (!lastMsgFrom) return { kind: 'skip' };
257
259
  participantId = lastMsgFrom;
258
260
  }
259
- if (participants.some((p) => p.id === participantId)) {
261
+ if (participantIds.has(participantId)) {
260
262
  return { kind: 'multi-head', position, participantId };
261
263
  }
262
264
  // Participant not found — fall through to bare-note handler for proper resolution
@@ -284,13 +286,17 @@ function parseNoteLine(
284
286
  if (!afterPos) {
285
287
  // Just `note left` or `note right` — multi-line head
286
288
  if (!lastMsgFrom) return { kind: 'skip' };
287
- if (!participants.some((p) => p.id === lastMsgFrom))
288
- return { kind: 'skip' };
289
+ if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };
289
290
  return { kind: 'multi-head', position, participantId: lastMsgFrom };
290
291
  }
291
292
 
292
293
  // Try to match a known participant at the start of afterPos
293
- const resolved = resolveParticipantAndText(afterPos, participants);
294
+ const resolved = resolveParticipantAndText(
295
+ afterPos,
296
+ participants,
297
+ participantIds,
298
+ sortedParticipantsCache
299
+ );
294
300
  if (resolved) {
295
301
  if (resolved.text) {
296
302
  return {
@@ -316,8 +322,7 @@ function parseNoteLine(
316
322
 
317
323
  // Without `of`, treat remaining text as note content on the last-msg sender
318
324
  if (!lastMsgFrom) return { kind: 'skip' };
319
- if (!participants.some((p) => p.id === lastMsgFrom))
320
- return { kind: 'skip' };
325
+ if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };
321
326
  return {
322
327
  kind: 'single',
323
328
  position,
@@ -328,8 +333,7 @@ function parseNoteLine(
328
333
 
329
334
  // Plain `note text` — default position, last msg sender
330
335
  if (!lastMsgFrom) return { kind: 'skip' };
331
- if (!participants.some((p) => p.id === lastMsgFrom))
332
- return { kind: 'skip' };
336
+ if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };
333
337
  return {
334
338
  kind: 'single',
335
339
  position: 'right',
@@ -348,7 +352,9 @@ function parseNoteLine(
348
352
  */
349
353
  function resolveParticipantAndText(
350
354
  input: string,
351
- participants: SequenceParticipant[]
355
+ participants: SequenceParticipant[],
356
+ participantIds: Set<string>,
357
+ sortedParticipantsCache: SequenceParticipant[]
352
358
  ): { participantId: string; text: string } | null {
353
359
  // Handle quoted participant: `"Auth Service" text`
354
360
  if (input.startsWith('"') || input.startsWith("'")) {
@@ -356,7 +362,7 @@ function resolveParticipantAndText(
356
362
  const endQuote = input.indexOf(quote, 1);
357
363
  if (endQuote > 0) {
358
364
  const name = input.substring(1, endQuote);
359
- if (participants.some((p) => p.id === name)) {
365
+ if (participantIds.has(name)) {
360
366
  const text = input.substring(endQuote + 1).trim();
361
367
  return { participantId: name, text };
362
368
  }
@@ -364,8 +370,8 @@ function resolveParticipantAndText(
364
370
  return null;
365
371
  }
366
372
 
367
- // Sort participants by name length (longest first) for greedy matching
368
- const sorted = [...participants].sort((a, b) => b.id.length - a.id.length);
373
+ // Use pre-sorted participants (longest first) for greedy matching
374
+ const sorted = sortedParticipantsCache;
369
375
  for (const p of sorted) {
370
376
  if (input.startsWith(p.id)) {
371
377
  const remaining = input.substring(p.id.length);
@@ -443,6 +449,25 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
443
449
  // Group parsing state — tracks the active [Group] heading
444
450
  let activeGroup: SequenceGroup | null = null;
445
451
 
452
+ // Fast lookup set for participant existence checks (mirrors result.participants)
453
+ const participantIds = new Set<string>();
454
+
455
+ // Cache sorted participants (longest ID first) for greedy name matching in notes.
456
+ // Invalidated whenever a new participant is added.
457
+ let sortedParticipantsCache: SequenceParticipant[] = [];
458
+ let sortedCacheDirty = true;
459
+
460
+ /** Get sorted participants, rebuilding cache only when dirty. */
461
+ const getSortedParticipants = (): SequenceParticipant[] => {
462
+ if (sortedCacheDirty) {
463
+ sortedParticipantsCache = [...result.participants].sort(
464
+ (a, b) => b.id.length - a.id.length
465
+ );
466
+ sortedCacheDirty = false;
467
+ }
468
+ return sortedParticipantsCache;
469
+ };
470
+
446
471
  // Track participant → group name for duplicate membership detection
447
472
  const participantGroupMap = new Map<string, string>();
448
473
 
@@ -774,7 +799,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
774
799
  const position = posMatch ? parseInt(posMatch[1], 10) : undefined;
775
800
 
776
801
  // Avoid duplicate participant declarations
777
- if (!result.participants.some((p) => p.id === id)) {
802
+ if (!participantIds.has(id)) {
803
+ participantIds.add(id);
804
+ sortedCacheDirty = true;
778
805
  result.participants.push({
779
806
  id,
780
807
  label: alias || id,
@@ -808,7 +835,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
808
835
  const id = posOnlyMatch[1];
809
836
  const position = parseInt(posOnlyMatch[2], 10);
810
837
 
811
- if (!result.participants.some((p) => p.id === id)) {
838
+ if (!participantIds.has(id)) {
839
+ participantIds.add(id);
840
+ sortedCacheDirty = true;
812
841
  result.participants.push({
813
842
  id,
814
843
  label: id,
@@ -846,7 +875,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
846
875
  `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`
847
876
  );
848
877
  contentStarted = true;
849
- if (!result.participants.some((p) => p.id === id)) {
878
+ if (!participantIds.has(id)) {
879
+ participantIds.add(id);
880
+ sortedCacheDirty = true;
850
881
  result.participants.push({
851
882
  id,
852
883
  label: id,
@@ -882,7 +913,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
882
913
  ) {
883
914
  contentStarted = true;
884
915
  const id = bareCore;
885
- if (!result.participants.some((p) => p.id === id)) {
916
+ if (!participantIds.has(id)) {
917
+ participantIds.add(id);
886
918
  result.participants.push({
887
919
  id,
888
920
  label: id,
@@ -965,7 +997,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
965
997
  currentContainer().push(msg);
966
998
 
967
999
  // Auto-register participants
968
- if (!result.participants.some((p) => p.id === from)) {
1000
+ if (!participantIds.has(from)) {
1001
+ participantIds.add(from);
1002
+ sortedCacheDirty = true;
969
1003
  result.participants.push({
970
1004
  id: from,
971
1005
  label: from,
@@ -973,7 +1007,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
973
1007
  lineNumber,
974
1008
  });
975
1009
  }
976
- if (!result.participants.some((p) => p.id === to)) {
1010
+ if (!participantIds.has(to)) {
1011
+ participantIds.add(to);
1012
+ sortedCacheDirty = true;
977
1013
  result.participants.push({
978
1014
  id: to,
979
1015
  label: to,
@@ -1050,7 +1086,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1050
1086
  result.messages.push(msg);
1051
1087
  currentContainer().push(msg);
1052
1088
 
1053
- if (!result.participants.some((p) => p.id === from)) {
1089
+ if (!participantIds.has(from)) {
1090
+ participantIds.add(from);
1091
+ sortedCacheDirty = true;
1054
1092
  result.participants.push({
1055
1093
  id: from,
1056
1094
  label: from,
@@ -1058,7 +1096,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1058
1096
  lineNumber,
1059
1097
  });
1060
1098
  }
1061
- if (!result.participants.some((p) => p.id === to)) {
1099
+ if (!participantIds.has(to)) {
1100
+ participantIds.add(to);
1101
+ sortedCacheDirty = true;
1062
1102
  result.participants.push({
1063
1103
  id: to,
1064
1104
  label: to,
@@ -1182,6 +1222,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1182
1222
  const noteParsed = parseNoteLine(
1183
1223
  trimmed,
1184
1224
  result.participants,
1225
+ participantIds,
1226
+ getSortedParticipants(),
1185
1227
  lastMsgFrom
1186
1228
  );
1187
1229
  if (noteParsed) {
@@ -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
  // ============================================================
@@ -73,14 +77,26 @@ function wrapTextLines(text: string, maxChars: number): string[] {
73
77
  if (line.length <= maxChars) {
74
78
  wrapped.push(line);
75
79
  } else {
76
- const words = line.split(' ');
77
- 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;
78
86
  for (const word of words) {
79
- 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
+ ) {
80
93
  wrapped.push(current);
81
94
  current = word;
82
95
  } else {
83
- current = current ? current + ' ' + word : word;
96
+ current =
97
+ current && current !== bulletPrefix
98
+ ? current + ' ' + word
99
+ : current + word;
84
100
  }
85
101
  }
86
102
  if (current) wrapped.push(current);
@@ -544,6 +560,10 @@ export interface SequenceRenderOptions {
544
560
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
545
561
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
546
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
547
567
  }
548
568
 
549
569
  /**
@@ -1716,39 +1736,33 @@ export function renderSequenceDiagram(
1716
1736
  }
1717
1737
  }
1718
1738
 
1719
- // Render legend pills for tag groups
1720
- if (parsed.tagGroups.length > 0) {
1721
- const legendY = TOP_MARGIN + titleOffset;
1722
- // Resolve tag colors for legend entries
1723
- const resolvedGroups = parsed.tagGroups
1724
- .filter((tg) => tg.entries.length > 0)
1725
- .map((tg) => ({
1726
- name: tg.name,
1727
- entries: tg.entries.map((e) => ({
1728
- value: e.value,
1729
- color: e.color,
1730
- })),
1731
- }));
1732
- const legendConfig: LegendConfig = {
1733
- groups: resolvedGroups,
1734
- position: { placement: 'top-center', titleRelation: 'below-title' },
1735
- mode: 'fixed',
1736
- };
1737
- const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
1738
- const legendG = svg
1739
- .append('g')
1740
- .attr('class', 'sequence-legend')
1741
- .attr('transform', `translate(0,${legendY})`);
1742
- renderLegendD3(
1743
- legendG,
1744
- legendConfig,
1745
- legendState,
1746
- palette,
1747
- isDark,
1748
- undefined,
1749
- svgWidth
1750
- );
1751
- }
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;
1752
1766
 
1753
1767
  // Build set of collapsed group names for drill-bar rendering
1754
1768
  const collapsedGroupNames = new Set<string>();
@@ -2113,7 +2127,7 @@ export function renderSequenceDiagram(
2113
2127
  firstBranchStep = Math.min(firstBranchStep, first);
2114
2128
  }
2115
2129
  if (firstBranchStep < Infinity) {
2116
- const dividerY = stepY(firstBranchStep) - stepSpacing / 2;
2130
+ const dividerY = stepY(firstBranchStep) - BLOCK_HEADER_SPACE;
2117
2131
  deferredLines.push({
2118
2132
  x1: frameX,
2119
2133
  y1: dividerY,
@@ -2142,7 +2156,7 @@ export function renderSequenceDiagram(
2142
2156
  firstElseStep = Math.min(firstElseStep, first);
2143
2157
  }
2144
2158
  if (firstElseStep < Infinity) {
2145
- const dividerY = stepY(firstElseStep) - stepSpacing / 2;
2159
+ const dividerY = stepY(firstElseStep) - BLOCK_HEADER_SPACE;
2146
2160
  deferredLines.push({
2147
2161
  x1: frameX,
2148
2162
  y1: dividerY,
@@ -2805,6 +2819,73 @@ export function renderSequenceDiagram(
2805
2819
  if (elements && elements.length > 0) {
2806
2820
  renderNoteElements(elements);
2807
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
+ }
2808
2889
  }
2809
2890
 
2810
2891
  /**
@@ -2843,6 +2924,31 @@ export function buildNoteMessageMap(
2843
2924
  return map;
2844
2925
  }
2845
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
+
2846
2952
  function renderParticipant(
2847
2953
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2848
2954
  participant: SequenceParticipant,
package/src/sharing.ts CHANGED
@@ -32,6 +32,7 @@ export interface CompactViewState {
32
32
  io?: Record<string, number>; // instance overrides (infra)
33
33
  hd?: boolean; // hide descriptions (mindmap)
34
34
  cbd?: boolean; // color by depth (mindmap)
35
+ rq?: string; // radar quadrant focus (tech-radar position)
35
36
  }
36
37
 
37
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) {
@@ -519,6 +533,7 @@ export function layoutSitemap(
519
533
  label: node.label,
520
534
  metadata: flat.meta,
521
535
  tagMetadata: flat.fullMeta,
536
+ description: node.description,
522
537
  isContainer: false,
523
538
  lineNumber: node.lineNumber,
524
539
  color: resolveNodeColor(node, parsed.tagGroups, activeTagGroup ?? null),
@@ -25,6 +25,7 @@ import {
25
25
  ALL_CHART_TYPES,
26
26
  } from '../utils/parsing';
27
27
  import type { SitemapNode, ParsedSitemap } from './types';
28
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
28
29
 
29
30
  // ============================================================
30
31
  // Regexes
@@ -370,13 +371,7 @@ export function parseSitemap(
370
371
  : trimmed.match(METADATA_RE);
371
372
 
372
373
  if (containerMatch) {
373
- const rawLabel = containerMatch[1].trim();
374
- const { label, color } = extractColor(
375
- rawLabel,
376
- palette,
377
- result.diagnostics,
378
- lineNumber
379
- );
374
+ const label = containerMatch[1].trim();
380
375
 
381
376
  // Parse optional pipe metadata on the container line
382
377
  const pipeStr = containerMatch[2];
@@ -399,7 +394,6 @@ export function parseSitemap(
399
394
  parentId: null,
400
395
  isContainer: true,
401
396
  lineNumber,
402
- color,
403
397
  };
404
398
 
405
399
  attachNode(node, indent, indentStack, result);
@@ -435,6 +429,17 @@ export function parseSitemap(
435
429
  pushError(lineNumber, 'Metadata has no parent node');
436
430
  }
437
431
  } else {
432
+ // Check if this is a description line for a parent node
433
+ const descResult = tryStripDescriptionKeyword(trimmed);
434
+ if (descResult.isKeyword && indentStack.length > 0) {
435
+ const parent = findParentNode(indent, indentStack);
436
+ if (parent) {
437
+ if (!parent.description) parent.description = [];
438
+ parent.description.push(descResult.text.trim());
439
+ continue;
440
+ }
441
+ }
442
+
438
443
  // Node label — possibly with pipe-delimited metadata
439
444
  const node = parseNodeLabel(
440
445
  trimmed,
@@ -531,31 +536,35 @@ function parseNodeLabel(
531
536
  counter: number,
532
537
  aliasMap: Map<string, string> = new Map(),
533
538
  warnFn?: (line: number, msg: string) => void,
534
- diagnostics?: DgmoError[]
539
+ _diagnostics?: DgmoError[]
535
540
  ): SitemapNode {
536
541
  const segments = trimmed.split('|').map((s) => s.trim());
537
- const rawLabel = segments[0];
538
- const { label, color } = extractColor(
539
- rawLabel,
540
- palette,
541
- diagnostics,
542
- lineNumber
543
- );
542
+ const label = segments[0];
544
543
  const metadata = parsePipeMetadata(
545
544
  segments,
546
545
  aliasMap,
547
546
  warnFn ? () => warnFn(lineNumber, MULTIPLE_PIPE_ERROR) : undefined
548
547
  );
549
548
 
549
+ // Extract description from pipe metadata into dedicated field
550
+ let description: string[] | undefined;
551
+ if ('description' in metadata) {
552
+ const descVal = metadata['description'].trim();
553
+ if (descVal) {
554
+ description = [descVal];
555
+ }
556
+ delete metadata['description'];
557
+ }
558
+
550
559
  return {
551
560
  id: `node-${counter}`,
552
561
  label,
553
562
  metadata,
563
+ description,
554
564
  children: [],
555
565
  parentId: null,
556
566
  isContainer: false,
557
567
  lineNumber,
558
- color,
559
568
  };
560
569
  }
561
570