@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/dist/cli.cjs +101 -101
- package/dist/index.cjs +521 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -12
- package/dist/index.d.ts +107 -12
- package/dist/index.js +518 -121
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/gantt/renderer.ts +151 -89
- package/src/index.ts +10 -2
- package/src/sequence/collapse.ts +169 -0
- package/src/sequence/parser.ts +14 -2
- package/src/sequence/renderer.ts +186 -49
- package/src/sharing.ts +86 -49
- package/src/utils/legend-constants.ts +11 -0
- package/src/utils/legend-d3.ts +171 -0
- package/src/utils/legend-layout.ts +140 -13
- package/src/utils/legend-types.ts +45 -0
package/package.json
CHANGED
package/src/gantt/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1938
|
-
const
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
if (
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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 {
|
|
467
|
+
export {
|
|
468
|
+
encodeDiagramUrl,
|
|
469
|
+
decodeDiagramUrl,
|
|
470
|
+
encodeViewState,
|
|
471
|
+
decodeViewState,
|
|
472
|
+
} from './sharing';
|
|
465
473
|
export type {
|
|
466
474
|
EncodeDiagramUrlOptions,
|
|
467
475
|
EncodeDiagramUrlResult,
|
|
468
|
-
|
|
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
|
+
}
|
package/src/sequence/parser.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|