@diagrammo/dgmo 0.8.18 → 0.8.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.8.18",
3
+ "version": "0.8.19",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,9 +20,11 @@ import {
20
20
  LEGEND_ENTRY_TRAIL,
21
21
  LEGEND_GROUP_GAP,
22
22
  LEGEND_ICON_W,
23
+ LEGEND_GEAR_PILL_W,
23
24
  measureLegendText,
24
25
  } from '../utils/legend-constants';
25
26
  import { renderLegendD3 } from '../utils/legend-d3';
27
+ import { controlsGroupCapsuleWidth } from '../utils/legend-layout';
26
28
  import type {
27
29
  LegendConfig,
28
30
  LegendState,
@@ -233,6 +235,8 @@ export function renderGantt(
233
235
  options?.currentActiveGroup
234
236
  );
235
237
  let criticalPathActive = false;
238
+ let dependenciesActive = !!resolved.options.dependencies;
239
+ let controlsExpanded = false;
236
240
 
237
241
  // ── Build row list (structural vs tag mode) ─────────────
238
242
 
@@ -264,11 +268,21 @@ export function renderGantt(
264
268
 
265
269
  const totalRows = rows.length;
266
270
 
271
+ // Pre-compute critical path / dependency flags (needed for legend height reservation)
272
+ const hasCriticalPath =
273
+ resolved.options.criticalPath &&
274
+ resolved.tasks.some((t) => t.isCriticalPath);
275
+ const hasDependencies =
276
+ resolved.options.dependencies &&
277
+ resolved.tasks.some((t) => t.task.dependencies.length > 0);
278
+
267
279
  // Vertical layout — matches timeline pattern (d3.ts:3649-3655)
268
280
  const title = resolved.options.title;
269
281
  const titleHeight = title ? 50 : 20;
270
282
  const tagLegendReserve =
271
- resolved.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
283
+ resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies
284
+ ? LEGEND_HEIGHT + 8
285
+ : 0;
272
286
  const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
273
287
  const hasOverheadLabels =
274
288
  resolved.markers.length > 0 || resolved.eras.length > 0;
@@ -327,13 +341,9 @@ export function renderGantt(
327
341
 
328
342
  // ── Tag legend (interactive) ────────────────────────────
329
343
 
330
- const hasCriticalPath =
331
- resolved.options.criticalPath &&
332
- resolved.tasks.some((t) => t.isCriticalPath);
333
-
334
344
  function drawLegend() {
335
345
  svg.selectAll('.gantt-tag-legend-container').remove();
336
- if (resolved.tagGroups.length > 0 || hasCriticalPath) {
346
+ if (resolved.tagGroups.length > 0 || hasCriticalPath || hasDependencies) {
337
347
  const legendY = titleHeight;
338
348
  renderTagLegend(
339
349
  svg,
@@ -359,17 +369,41 @@ export function renderGantt(
359
369
  recolorBars();
360
370
  },
361
371
  () => {
362
- criticalPathActive = !criticalPathActive;
372
+ controlsExpanded = !controlsExpanded;
363
373
  drawLegend();
364
374
  },
365
375
  currentSwimlaneGroup,
366
376
  onSwimlaneChange,
367
377
  viewMode,
368
- resolved.tasks
378
+ resolved.tasks,
379
+ controlsExpanded,
380
+ hasDependencies,
381
+ dependenciesActive,
382
+ (toggleId, active) => {
383
+ if (toggleId === 'critical-path') {
384
+ criticalPathActive = active;
385
+ } else if (toggleId === 'dependencies') {
386
+ dependenciesActive = active;
387
+ // Show/hide dependency arrows
388
+ g.selectAll<SVGElement, unknown>(
389
+ '.gantt-dep-arrow, .gantt-dep-arrowhead, .gantt-dep-label'
390
+ ).attr('display', active ? null : 'none');
391
+ }
392
+ drawLegend();
393
+ }
369
394
  );
370
395
  }
371
396
  }
372
397
 
398
+ function restoreHighlight() {
399
+ if (criticalPathActive) {
400
+ applyCriticalPathHighlight(svg, g);
401
+ } else {
402
+ svg.attr('data-critical-path-active', null);
403
+ resetHighlight(g, svg);
404
+ }
405
+ }
406
+
373
407
  function recolorBars() {
374
408
  g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
375
409
  const el = d3Selection.select(this);
@@ -568,7 +602,7 @@ export function renderGantt(
568
602
  }
569
603
  })
570
604
  .on('mouseleave', () => {
571
- resetHighlight(g, svg);
605
+ restoreHighlight();
572
606
  hideGanttDateIndicators(g);
573
607
  });
574
608
 
@@ -979,7 +1013,7 @@ export function renderGantt(
979
1013
  .text(task.label);
980
1014
  })
981
1015
  .on('mouseleave', () => {
982
- resetHighlight(g, svg);
1016
+ restoreHighlight();
983
1017
  hideGanttDateIndicators(g);
984
1018
  g.selectAll('.gantt-milestone-hover-label').remove();
985
1019
  });
@@ -1023,11 +1057,7 @@ export function renderGantt(
1023
1057
  })
1024
1058
  .on('mouseleave', () => {
1025
1059
  if (resolved.options.dependencies) {
1026
- if (criticalPathActive) {
1027
- applyCriticalPathHighlight(svg, g);
1028
- } else {
1029
- resetHighlight(g, svg);
1030
- }
1060
+ restoreHighlight();
1031
1061
  }
1032
1062
  resetTaskLabels(svg);
1033
1063
  hideGanttDateIndicators(g);
@@ -1704,6 +1734,7 @@ function applyCriticalPathHighlight(
1704
1734
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1705
1735
  chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
1706
1736
  ) {
1737
+ svg.attr('data-critical-path-active', 'true');
1707
1738
  chartG.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
1708
1739
  const el = d3Selection.select(this);
1709
1740
  el.attr(
@@ -1827,6 +1858,34 @@ function drawSwimlaneIcon(
1827
1858
  return iconG;
1828
1859
  }
1829
1860
 
1861
+ function buildControlsToggles(
1862
+ hasCriticalPath: boolean,
1863
+ criticalPathActive: boolean,
1864
+ hasDependencies: boolean,
1865
+ dependenciesActive: boolean
1866
+ ): import('../utils/legend-types').ControlsGroupToggle[] {
1867
+ const toggles: import('../utils/legend-types').ControlsGroupToggle[] = [];
1868
+ if (hasCriticalPath) {
1869
+ toggles.push({
1870
+ id: 'critical-path',
1871
+ type: 'toggle',
1872
+ label: 'Critical Path',
1873
+ active: criticalPathActive,
1874
+ onToggle: () => {},
1875
+ });
1876
+ }
1877
+ if (hasDependencies) {
1878
+ toggles.push({
1879
+ id: 'dependencies',
1880
+ type: 'toggle',
1881
+ label: 'Dependencies',
1882
+ active: dependenciesActive,
1883
+ onToggle: () => {},
1884
+ });
1885
+ }
1886
+ return toggles;
1887
+ }
1888
+
1830
1889
  function renderTagLegend(
1831
1890
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1832
1891
  chartG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
@@ -1841,16 +1900,16 @@ function renderTagLegend(
1841
1900
  criticalPathActive: boolean,
1842
1901
  optionLineNumbers: Record<string, number>,
1843
1902
  onToggle?: (groupName: string) => void,
1844
- onToggleCriticalPath?: () => void,
1903
+ onToggleControlsExpand?: () => void,
1845
1904
  currentSwimlaneGroup?: string | null,
1846
1905
  onSwimlaneChange?: (group: string | null) => void,
1847
1906
  legendViewMode?: boolean,
1848
- resolvedTasks?: ResolvedTask[]
1907
+ resolvedTasks?: ResolvedTask[],
1908
+ controlsExpanded = false,
1909
+ hasDependencies = false,
1910
+ dependenciesActive = false,
1911
+ onControlsToggle?: (toggleId: string, active: boolean) => void
1849
1912
  ): void {
1850
- const groupBg = isDark
1851
- ? mix(palette.surface, palette.bg, 50)
1852
- : mix(palette.surface, palette.bg, 30);
1853
-
1854
1913
  // Build visible groups: active group expanded + swimlane group as compact pill
1855
1914
  let visibleGroups: TagGroup[];
1856
1915
  if (activeGroupName) {
@@ -1934,13 +1993,16 @@ function renderTagLegend(
1934
1993
  }
1935
1994
  totalW += Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
1936
1995
 
1937
- // Critical Path pill width
1938
- const cpLabel = 'Critical Path';
1939
- const cpPillW =
1940
- measureLegendText(cpLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1941
- if (hasCriticalPath) {
1996
+ // Controls group width — replaces standalone critical path pill
1997
+ const hasControls = hasCriticalPath || hasDependencies;
1998
+ const controlsToggleLabels: Array<{ label: string }> = [];
1999
+ if (hasCriticalPath) controlsToggleLabels.push({ label: 'Critical Path' });
2000
+ if (hasDependencies) controlsToggleLabels.push({ label: 'Dependencies' });
2001
+ if (hasControls) {
1942
2002
  if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
1943
- totalW += cpPillW;
2003
+ totalW += controlsExpanded
2004
+ ? controlsGroupCapsuleWidth(controlsToggleLabels)
2005
+ : LEGEND_GEAR_PILL_W;
1944
2006
  }
1945
2007
 
1946
2008
  // Center over full container (matching title centering)
@@ -1952,8 +2014,6 @@ function renderTagLegend(
1952
2014
  .attr('class', 'gantt-tag-legend-container')
1953
2015
  .attr('transform', `translate(${legendX}, ${legendY})`);
1954
2016
 
1955
- let cursorX = 0;
1956
-
1957
2017
  // Render tag groups via centralized legend system
1958
2018
  if (visibleGroups.length > 0) {
1959
2019
  const showIcon = !legendViewMode && tagGroups.length > 0;
@@ -1969,6 +2029,13 @@ function renderTagLegend(
1969
2029
  };
1970
2030
  });
1971
2031
 
2032
+ const controlsToggles = buildControlsToggles(
2033
+ hasCriticalPath,
2034
+ criticalPathActive,
2035
+ hasDependencies,
2036
+ dependenciesActive
2037
+ );
2038
+
1972
2039
  const legendConfig: LegendConfig = {
1973
2040
  groups: legendGroups,
1974
2041
  position: {
@@ -1977,16 +2044,30 @@ function renderTagLegend(
1977
2044
  },
1978
2045
  mode: 'fixed' as const,
1979
2046
  capsulePillAddonWidth: iconReserve,
2047
+ controlsGroup:
2048
+ controlsToggles.length > 0 ? { toggles: controlsToggles } : undefined,
2049
+ };
2050
+ const legendState: LegendState = {
2051
+ activeGroup: activeGroupName,
2052
+ controlsExpanded,
1980
2053
  };
1981
- const legendState: LegendState = { activeGroup: activeGroupName };
1982
2054
 
1983
- const tagGroupsW =
2055
+ let tagGroupsW =
1984
2056
  visibleGroups.reduce((s, _, i) => s + groupWidths[i], 0) +
1985
2057
  Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
2058
+ // Add controls group space to the renderLegendD3 container width
2059
+ if (hasControls) {
2060
+ if (visibleGroups.length > 0) tagGroupsW += LEGEND_GROUP_GAP;
2061
+ tagGroupsW += controlsExpanded
2062
+ ? controlsGroupCapsuleWidth(controlsToggleLabels)
2063
+ : LEGEND_GEAR_PILL_W;
2064
+ }
1986
2065
  const tagGroupG = legendRow.append('g');
1987
2066
 
1988
2067
  const legendCallbacks: LegendCallbacks = {
1989
2068
  onGroupToggle: onToggle,
2069
+ onControlsExpand: onToggleControlsExpand,
2070
+ onControlsToggle,
1990
2071
  onEntryHover: (groupName, entryValue) => {
1991
2072
  const tagKey = groupName.toLowerCase();
1992
2073
  if (entryValue) {
@@ -2092,67 +2173,43 @@ function renderTagLegend(
2092
2173
  legendCallbacks,
2093
2174
  tagGroupsW
2094
2175
  );
2176
+ } else if (hasControls) {
2177
+ // No tag groups, but controls group needs rendering
2178
+ const controlsToggles = buildControlsToggles(
2179
+ hasCriticalPath,
2180
+ criticalPathActive,
2181
+ hasDependencies,
2182
+ dependenciesActive
2183
+ );
2095
2184
 
2096
- for (let i = 0; i < visibleGroups.length; i++) {
2097
- cursorX += groupWidths[i] + LEGEND_GROUP_GAP;
2098
- }
2099
- }
2100
-
2101
- // Critical Path pill
2102
- if (hasCriticalPath) {
2103
- const cpLineNum = optionLineNumbers['critical-path'];
2104
- const cpG = legendRow
2105
- .append('g')
2106
- .attr('transform', `translate(${cursorX}, 0)`)
2107
- .attr('class', 'gantt-legend-critical-path')
2108
- .style('cursor', 'pointer')
2109
- .on('click', () => {
2110
- if (onToggleCriticalPath) onToggleCriticalPath();
2111
- });
2112
- if (cpLineNum) cpG.attr('data-line-number', String(cpLineNum));
2113
-
2114
- cpG
2115
- .append('rect')
2116
- .attr('width', cpPillW)
2117
- .attr('height', LEGEND_HEIGHT)
2118
- .attr('rx', LEGEND_HEIGHT / 2)
2119
- .attr('fill', criticalPathActive ? palette.bg : groupBg);
2120
-
2121
- if (criticalPathActive) {
2122
- cpG
2123
- .append('rect')
2124
- .attr('width', cpPillW)
2125
- .attr('height', LEGEND_HEIGHT)
2126
- .attr('rx', LEGEND_HEIGHT / 2)
2127
- .attr('fill', 'none')
2128
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
2129
- .attr('stroke-width', 0.75);
2130
- }
2131
-
2132
- cpG
2133
- .append('text')
2134
- .attr('x', cpPillW / 2)
2135
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
2136
- .attr('text-anchor', 'middle')
2137
- .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
2138
- .attr('font-weight', '500')
2139
- .attr('fill', criticalPathActive ? palette.text : palette.textMuted)
2140
- .text(cpLabel);
2185
+ const legendConfig: LegendConfig = {
2186
+ groups: [],
2187
+ position: {
2188
+ placement: 'top-center' as const,
2189
+ titleRelation: 'below-title' as const,
2190
+ },
2191
+ mode: 'fixed' as const,
2192
+ controlsGroup: { toggles: controlsToggles },
2193
+ };
2141
2194
 
2142
- // Apply persistent highlighting when active
2143
- if (criticalPathActive) {
2144
- applyCriticalPathHighlight(svg, chartG);
2145
- }
2195
+ const tagGroupG = legendRow.append('g');
2196
+ renderLegendD3(
2197
+ tagGroupG,
2198
+ legendConfig,
2199
+ { activeGroup: null, controlsExpanded },
2200
+ palette,
2201
+ isDark,
2202
+ {
2203
+ onControlsExpand: onToggleControlsExpand,
2204
+ onControlsToggle,
2205
+ },
2206
+ totalW
2207
+ );
2208
+ }
2146
2209
 
2147
- cpG
2148
- .on('mouseenter', () => {
2149
- applyCriticalPathHighlight(svg, chartG);
2150
- })
2151
- .on('mouseleave', () => {
2152
- if (!criticalPathActive) {
2153
- resetHighlightAll(svg, chartG);
2154
- }
2155
- });
2210
+ // Apply persistent critical path highlighting when active
2211
+ if (criticalPathActive) {
2212
+ applyCriticalPathHighlight(svg, chartG);
2156
2213
  }
2157
2214
  }
2158
2215
 
@@ -2990,6 +3047,11 @@ function resetHighlight(
2990
3047
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2991
3048
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
2992
3049
  ): void {
3050
+ // If critical path is actively toggled ON, restore its highlight instead of full reset
3051
+ if (svg.attr('data-critical-path-active') === 'true') {
3052
+ applyCriticalPathHighlight(svg, g);
3053
+ return;
3054
+ }
2993
3055
  g.selectAll<SVGGElement, unknown>('.gantt-task, .gantt-milestone').attr(
2994
3056
  'opacity',
2995
3057
  1
package/src/index.ts CHANGED
@@ -410,6 +410,9 @@ export type {
410
410
  SequenceRenderOptions,
411
411
  } from './sequence/renderer';
412
412
 
413
+ export { applyCollapseProjection } from './sequence/collapse';
414
+ export type { CollapsedView } from './sequence/collapse';
415
+
413
416
  // ============================================================
414
417
  // Colors & Palettes
415
418
  // ============================================================
@@ -461,11 +464,16 @@ export type { PaletteConfig, PaletteColors } from './palettes';
461
464
  // Sharing (URL encoding/decoding)
462
465
  // ============================================================
463
466
 
464
- export { encodeDiagramUrl, decodeDiagramUrl } from './sharing';
467
+ export {
468
+ encodeDiagramUrl,
469
+ decodeDiagramUrl,
470
+ encodeViewState,
471
+ decodeViewState,
472
+ } from './sharing';
465
473
  export type {
466
474
  EncodeDiagramUrlOptions,
467
475
  EncodeDiagramUrlResult,
468
- DiagramViewState,
476
+ CompactViewState,
469
477
  DecodedDiagramUrl,
470
478
  } from './sharing';
471
479
 
@@ -0,0 +1,169 @@
1
+ // ============================================================
2
+ // Collapse Projection for Sequence Diagram Groups
3
+ // ============================================================
4
+ //
5
+ // Pure projection function that transforms a parsed sequence diagram
6
+ // by collapsing specified groups into single virtual participants.
7
+ // The parsed AST (ParsedSequenceDgmo) stays immutable.
8
+
9
+ import type {
10
+ ParsedSequenceDgmo,
11
+ SequenceElement,
12
+ SequenceGroup,
13
+ SequenceMessage,
14
+ SequenceParticipant,
15
+ } from './parser';
16
+ import { isSequenceBlock, isSequenceNote, isSequenceSection } from './parser';
17
+
18
+ export interface CollapsedView {
19
+ participants: SequenceParticipant[];
20
+ messages: SequenceMessage[];
21
+ elements: SequenceElement[];
22
+ groups: SequenceGroup[];
23
+ /** Maps member participant ID → collapsed group name */
24
+ collapsedGroupIds: Map<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Project a parsed sequence diagram into a collapsed view.
29
+ *
30
+ * @param parsed - The immutable parsed sequence diagram
31
+ * @param collapsedGroups - Set of group lineNumbers that should be collapsed
32
+ * @returns A new CollapsedView with remapped participants, messages, elements, and groups
33
+ */
34
+ export function applyCollapseProjection(
35
+ parsed: ParsedSequenceDgmo,
36
+ collapsedGroups: Set<number>
37
+ ): CollapsedView {
38
+ if (collapsedGroups.size === 0) {
39
+ return {
40
+ participants: parsed.participants,
41
+ messages: parsed.messages,
42
+ elements: parsed.elements,
43
+ groups: parsed.groups,
44
+ collapsedGroupIds: new Map(),
45
+ };
46
+ }
47
+
48
+ // Build memberToGroup map: participantId → group name
49
+ const memberToGroup = new Map<string, string>();
50
+ const collapsedGroupNames = new Set<string>();
51
+ for (const group of parsed.groups) {
52
+ if (collapsedGroups.has(group.lineNumber)) {
53
+ collapsedGroupNames.add(group.name);
54
+ for (const memberId of group.participantIds) {
55
+ memberToGroup.set(memberId, group.name);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Participants: remove members of collapsed groups, insert virtual participant per group
61
+ // Skip non-member participants that collide with a collapsed group name
62
+ const participants: SequenceParticipant[] = [];
63
+ const insertedGroups = new Set<string>();
64
+
65
+ for (const p of parsed.participants) {
66
+ const groupName = memberToGroup.get(p.id);
67
+ if (groupName) {
68
+ // Replace first occurrence with virtual group participant
69
+ if (!insertedGroups.has(groupName)) {
70
+ insertedGroups.add(groupName);
71
+ const group = parsed.groups.find(
72
+ (g) => g.name === groupName && collapsedGroups.has(g.lineNumber)
73
+ )!;
74
+ participants.push({
75
+ id: groupName,
76
+ label: groupName,
77
+ type: 'default',
78
+ lineNumber: group.lineNumber,
79
+ });
80
+ }
81
+ // Skip member — it's absorbed into the group
82
+ } else if (collapsedGroupNames.has(p.id)) {
83
+ // Skip — participant name collides with a collapsed group name;
84
+ // the virtual group participant takes precedence
85
+ } else {
86
+ participants.push(p);
87
+ }
88
+ }
89
+
90
+ // Remap helper
91
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
92
+
93
+ // Messages: remap from/to, preserving order
94
+ const messages: SequenceMessage[] = parsed.messages.map((msg) => ({
95
+ ...msg,
96
+ from: remap(msg.from),
97
+ to: remap(msg.to),
98
+ }));
99
+
100
+ // Elements: deep clone with remapping and internal return suppression
101
+ const elements = remapElements(parsed.elements, memberToGroup);
102
+
103
+ // Groups: remove collapsed groups (they're now virtual participants)
104
+ const groups = parsed.groups.filter(
105
+ (g) => !collapsedGroups.has(g.lineNumber)
106
+ );
107
+
108
+ return {
109
+ participants,
110
+ messages,
111
+ elements,
112
+ groups,
113
+ collapsedGroupIds: memberToGroup,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Deep clone and remap elements, suppressing internal returns within collapsed groups.
119
+ */
120
+ function remapElements(
121
+ elements: SequenceElement[],
122
+ memberToGroup: Map<string, string>
123
+ ): SequenceElement[] {
124
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
125
+ const result: SequenceElement[] = [];
126
+
127
+ for (const el of elements) {
128
+ if (isSequenceSection(el)) {
129
+ // Sections have no participant references — pass through unchanged
130
+ result.push(el);
131
+ } else if (isSequenceNote(el)) {
132
+ // Remap note participant
133
+ result.push({
134
+ ...el,
135
+ participantId: remap(el.participantId),
136
+ });
137
+ } else if (isSequenceBlock(el)) {
138
+ // Recurse into block children
139
+ result.push({
140
+ ...el,
141
+ children: remapElements(el.children, memberToGroup),
142
+ elseChildren: remapElements(el.elseChildren, memberToGroup),
143
+ ...(el.elseIfBranches
144
+ ? {
145
+ elseIfBranches: el.elseIfBranches.map((branch) => ({
146
+ ...branch,
147
+ children: remapElements(branch.children, memberToGroup),
148
+ })),
149
+ }
150
+ : {}),
151
+ });
152
+ } else {
153
+ // Message element
154
+ const msg = el as SequenceMessage;
155
+ const from = remap(msg.from);
156
+ const to = remap(msg.to);
157
+
158
+ // Suppress internal return: both endpoints in same collapsed group
159
+ // and this is a return message (unlabeled response)
160
+ if (from === to && from !== msg.from && !msg.label) {
161
+ continue; // internal return suppressed
162
+ }
163
+
164
+ result.push({ ...msg, from, to });
165
+ }
166
+ }
167
+
168
+ return result;
169
+ }
@@ -154,6 +154,8 @@ export interface SequenceGroup {
154
154
  lineNumber: number;
155
155
  /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */
156
156
  metadata?: Record<string, string>;
157
+ /** Whether this group is collapsed by default */
158
+ collapsed?: boolean;
157
159
  }
158
160
 
159
161
  /**
@@ -502,8 +504,17 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
502
504
  const groupColor = groupMatch[2]?.trim();
503
505
  let groupMeta: Record<string, string> | undefined;
504
506
 
505
- // Parse pipe metadata AFTER the closing bracket
506
- const afterBracket = groupMatch[3]?.trim() || '';
507
+ // Parse collapse keyword and pipe metadata AFTER the closing bracket
508
+ let afterBracket = groupMatch[3]?.trim() || '';
509
+ let isCollapsed = false;
510
+
511
+ // Extract `collapse` keyword (before any pipe metadata)
512
+ const collapseMatch = afterBracket.match(/^collapse\b/i);
513
+ if (collapseMatch) {
514
+ isCollapsed = true;
515
+ afterBracket = afterBracket.slice(collapseMatch[0].length).trim();
516
+ }
517
+
507
518
  if (afterBracket.startsWith('|')) {
508
519
  const segments = afterBracket.split('|');
509
520
  const meta = parsePipeMetadata(segments, aliasMap, () =>
@@ -524,6 +535,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
524
535
  participantIds: [],
525
536
  lineNumber,
526
537
  ...(groupMeta ? { metadata: groupMeta } : {}),
538
+ ...(isCollapsed ? { collapsed: true } : {}),
527
539
  };
528
540
  result.groups.push(activeGroup);
529
541
  continue;