@diagrammo/dgmo 0.8.18 → 0.8.20
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 +89 -130
- package/dist/index.cjs +1202 -993
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -114
- package/dist/index.d.ts +216 -114
- package/dist/index.js +1211 -985
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +73 -0
- package/package.json +22 -9
- package/src/boxes-and-lines/parser.ts +8 -3
- package/src/c4/parser.ts +8 -7
- package/src/class/parser.ts +6 -0
- package/src/cli.ts +1 -9
- package/src/d3.ts +16 -234
- package/src/dgmo-router.ts +97 -5
- package/src/diagnostics.ts +16 -6
- package/src/echarts.ts +43 -10
- package/src/er/parser.ts +22 -2
- package/src/gantt/renderer.ts +153 -91
- package/src/graph/flowchart-parser.ts +89 -52
- package/src/graph/state-parser.ts +60 -35
- package/src/index.ts +23 -18
- package/src/infra/parser.ts +9 -2
- package/src/kanban/renderer.ts +2 -2
- package/src/palettes/color-utils.ts +4 -12
- package/src/palettes/index.ts +0 -4
- package/src/render.ts +30 -16
- package/src/sequence/collapse.ts +169 -0
- package/src/sequence/parser.ts +21 -4
- package/src/sequence/renderer.ts +198 -52
- package/src/sharing.ts +86 -49
- package/src/sitemap/renderer.ts +1 -6
- package/src/utils/arrows.ts +180 -11
- package/src/utils/d3-types.ts +4 -0
- package/src/utils/legend-constants.ts +11 -4
- 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/src/utils/time-ticks.ts +213 -0
- package/src/branding.ts +0 -67
- package/src/dgmo-mermaid.ts +0 -262
- package/src/palettes/mermaid-bridge.ts +0 -220
|
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
|
|
|
2
2
|
import type { DgmoError } from '../diagnostics';
|
|
3
3
|
import type { PaletteColors } from '../palettes';
|
|
4
4
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
5
|
+
import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
|
|
5
6
|
import {
|
|
6
7
|
measureIndent,
|
|
7
8
|
extractColor,
|
|
@@ -31,6 +32,8 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
|
|
|
31
32
|
* Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
|
|
32
33
|
*/
|
|
33
34
|
function splitArrows(line: string): string[] {
|
|
35
|
+
// Mirrors flowchart-parser.ts splitArrows. TD-9 longest-match: arrow token
|
|
36
|
+
// is the maximal run of `-+>`. See that file for the full algorithm rationale.
|
|
34
37
|
const segments: string[] = [];
|
|
35
38
|
const arrowPositions: {
|
|
36
39
|
start: number;
|
|
@@ -40,41 +43,52 @@ function splitArrows(line: string): string[] {
|
|
|
40
43
|
}[] = [];
|
|
41
44
|
|
|
42
45
|
let searchFrom = 0;
|
|
46
|
+
let scanFloor = 0;
|
|
43
47
|
while (searchFrom < line.length) {
|
|
44
48
|
const idx = line.indexOf('->', searchFrom);
|
|
45
49
|
if (idx === -1) break;
|
|
46
50
|
|
|
47
|
-
let
|
|
51
|
+
let runStart = idx;
|
|
52
|
+
while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
|
|
53
|
+
const arrowEnd = idx + 2;
|
|
54
|
+
|
|
55
|
+
let arrowStart: number;
|
|
48
56
|
let label: string | undefined;
|
|
49
57
|
let color: string | undefined;
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
let openingStart = -1;
|
|
60
|
+
for (let i = scanFloor; i < runStart; i++) {
|
|
61
|
+
if (line[i] !== '-') continue;
|
|
62
|
+
const prevIsWsOrFloor =
|
|
63
|
+
i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
|
|
64
|
+
if (prevIsWsOrFloor) {
|
|
65
|
+
openingStart = i;
|
|
66
|
+
break;
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
arrowStart = scanBack;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (openingStart !== -1) {
|
|
71
|
+
let openingEnd = openingStart;
|
|
72
|
+
while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
|
|
73
|
+
|
|
74
|
+
const arrowContent = line.substring(openingEnd, runStart);
|
|
75
|
+
const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
|
|
76
|
+
if (colorMatch) {
|
|
77
|
+
color = colorMatch[1].trim();
|
|
78
|
+
const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
|
|
79
|
+
if (labelPart) label = labelPart;
|
|
80
|
+
} else {
|
|
81
|
+
const labelPart = arrowContent.trim();
|
|
82
|
+
if (labelPart) label = labelPart;
|
|
73
83
|
}
|
|
84
|
+
arrowStart = openingStart;
|
|
85
|
+
} else {
|
|
86
|
+
arrowStart = runStart;
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
arrowPositions.push({ start: arrowStart, end:
|
|
77
|
-
searchFrom =
|
|
89
|
+
arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
|
|
90
|
+
searchFrom = arrowEnd;
|
|
91
|
+
scanFloor = arrowEnd;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
if (arrowPositions.length === 0) return [line];
|
|
@@ -111,19 +125,30 @@ function parseArrowToken(
|
|
|
111
125
|
diagnostics: DgmoError[]
|
|
112
126
|
): ArrowInfo {
|
|
113
127
|
if (token === '->') return {};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
// TD-11: `-(X)->` is a color if and only if X is a recognized palette
|
|
129
|
+
// color; otherwise the whole `(X)` becomes the label. Delegate recognition
|
|
130
|
+
// to the shared `matchColorParens` helper.
|
|
131
|
+
const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
|
|
132
|
+
if (bareParen) {
|
|
133
|
+
const colorName = matchColorParens(bareParen[1]);
|
|
134
|
+
if (colorName) {
|
|
135
|
+
return {
|
|
136
|
+
color: resolveColorWithDiagnostic(
|
|
137
|
+
colorName,
|
|
138
|
+
lineNumber,
|
|
139
|
+
diagnostics,
|
|
140
|
+
palette
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// fall through — whole `(X)` becomes label
|
|
145
|
+
}
|
|
124
146
|
const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
|
|
125
147
|
if (m) {
|
|
126
|
-
const
|
|
148
|
+
const rawLabel = m[1] ?? '';
|
|
149
|
+
const labelResult = parseInArrowLabel(rawLabel, lineNumber);
|
|
150
|
+
diagnostics.push(...labelResult.diagnostics);
|
|
151
|
+
const label = labelResult.label;
|
|
127
152
|
const color = m[2]
|
|
128
153
|
? resolveColorWithDiagnostic(
|
|
129
154
|
m[2].trim(),
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,18 @@
|
|
|
5
5
|
export { makeDgmoError, formatDgmoError } from './diagnostics';
|
|
6
6
|
export type { DgmoError, DgmoSeverity } from './diagnostics';
|
|
7
7
|
|
|
8
|
+
// ============================================================
|
|
9
|
+
// Arrow helpers (in-arrow label validation)
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
parseInArrowLabel,
|
|
14
|
+
validateLabelCharacters,
|
|
15
|
+
matchColorParens,
|
|
16
|
+
ARROW_DIAGNOSTIC_CODES,
|
|
17
|
+
} from './utils/arrows';
|
|
18
|
+
export type { ParseInArrowLabelResult } from './utils/arrows';
|
|
19
|
+
|
|
8
20
|
// ============================================================
|
|
9
21
|
// Unified API
|
|
10
22
|
// ============================================================
|
|
@@ -38,9 +50,9 @@ export {
|
|
|
38
50
|
orderArcNodes,
|
|
39
51
|
parseTimelineDate,
|
|
40
52
|
addDurationToDate,
|
|
41
|
-
computeTimeTicks,
|
|
42
53
|
formatDateLabel,
|
|
43
54
|
} from './d3';
|
|
55
|
+
export { computeTimeTicks } from './utils/time-ticks';
|
|
44
56
|
export type {
|
|
45
57
|
ParsedVisualization,
|
|
46
58
|
VisualizationType,
|
|
@@ -73,9 +85,6 @@ export {
|
|
|
73
85
|
RULE_COUNT,
|
|
74
86
|
} from './sequence/participant-inference';
|
|
75
87
|
|
|
76
|
-
export { parseQuadrant } from './dgmo-mermaid';
|
|
77
|
-
export type { ParsedQuadrant } from './dgmo-mermaid';
|
|
78
|
-
|
|
79
88
|
export { parseFlowchart, looksLikeFlowchart } from './graph/flowchart-parser';
|
|
80
89
|
|
|
81
90
|
export { parseState, looksLikeState } from './graph/state-parser';
|
|
@@ -378,8 +387,6 @@ export type {
|
|
|
378
387
|
LegendHandle,
|
|
379
388
|
LegendPalette,
|
|
380
389
|
} from './utils/legend-types';
|
|
381
|
-
export { buildMermaidQuadrant } from './dgmo-mermaid';
|
|
382
|
-
|
|
383
390
|
// ============================================================
|
|
384
391
|
// Renderers (produce SVG output)
|
|
385
392
|
// ============================================================
|
|
@@ -410,6 +417,9 @@ export type {
|
|
|
410
417
|
SequenceRenderOptions,
|
|
411
418
|
} from './sequence/renderer';
|
|
412
419
|
|
|
420
|
+
export { applyCollapseProjection } from './sequence/collapse';
|
|
421
|
+
export type { CollapsedView } from './sequence/collapse';
|
|
422
|
+
|
|
413
423
|
// ============================================================
|
|
414
424
|
// Colors & Palettes
|
|
415
425
|
// ============================================================
|
|
@@ -434,7 +444,6 @@ export {
|
|
|
434
444
|
hexToHSL,
|
|
435
445
|
hslToHex,
|
|
436
446
|
hexToHSLString,
|
|
437
|
-
mute,
|
|
438
447
|
tint,
|
|
439
448
|
shade,
|
|
440
449
|
getSeriesColors,
|
|
@@ -450,9 +459,6 @@ export {
|
|
|
450
459
|
boldPalette,
|
|
451
460
|
draculaPalette,
|
|
452
461
|
monokaiPalette,
|
|
453
|
-
// Mermaid bridge
|
|
454
|
-
buildMermaidThemeVars,
|
|
455
|
-
buildThemeCSS,
|
|
456
462
|
} from './palettes';
|
|
457
463
|
|
|
458
464
|
export type { PaletteConfig, PaletteColors } from './palettes';
|
|
@@ -461,11 +467,16 @@ export type { PaletteConfig, PaletteColors } from './palettes';
|
|
|
461
467
|
// Sharing (URL encoding/decoding)
|
|
462
468
|
// ============================================================
|
|
463
469
|
|
|
464
|
-
export {
|
|
470
|
+
export {
|
|
471
|
+
encodeDiagramUrl,
|
|
472
|
+
decodeDiagramUrl,
|
|
473
|
+
encodeViewState,
|
|
474
|
+
decodeViewState,
|
|
475
|
+
} from './sharing';
|
|
465
476
|
export type {
|
|
466
477
|
EncodeDiagramUrlOptions,
|
|
467
478
|
EncodeDiagramUrlResult,
|
|
468
|
-
|
|
479
|
+
CompactViewState,
|
|
469
480
|
DecodedDiagramUrl,
|
|
470
481
|
} from './sharing';
|
|
471
482
|
|
|
@@ -492,9 +503,3 @@ export type {
|
|
|
492
503
|
} from './completion';
|
|
493
504
|
|
|
494
505
|
export { parseFirstLine, ALL_CHART_TYPES } from './utils/parsing';
|
|
495
|
-
|
|
496
|
-
// ============================================================
|
|
497
|
-
// Branding
|
|
498
|
-
// ============================================================
|
|
499
|
-
|
|
500
|
-
export { injectBranding } from './branding';
|
package/src/infra/parser.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
10
10
|
import { resolveColorWithDiagnostic } from '../colors';
|
|
11
|
+
import { parseInArrowLabel } from '../utils/arrows';
|
|
11
12
|
import {
|
|
12
13
|
measureIndent,
|
|
13
14
|
parseFirstLine,
|
|
@@ -477,7 +478,10 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
477
478
|
// Async labeled connection: ~label~> Target
|
|
478
479
|
const asyncConnMatch = trimmed.match(ASYNC_CONNECTION_RE);
|
|
479
480
|
if (asyncConnMatch) {
|
|
480
|
-
const
|
|
481
|
+
const rawLabel = asyncConnMatch[1] ?? '';
|
|
482
|
+
const labelResult = parseInArrowLabel(rawLabel, lineNumber);
|
|
483
|
+
result.diagnostics.push(...labelResult.diagnostics);
|
|
484
|
+
const label = labelResult.label ?? '';
|
|
481
485
|
const targetRaw = asyncConnMatch[2].trim();
|
|
482
486
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
483
487
|
const targetName = pipeMeta.clean || targetRaw;
|
|
@@ -551,7 +555,10 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
551
555
|
// Labeled connection: -label-> Target | split: N%, fanout: 3
|
|
552
556
|
const connMatch = trimmed.match(CONNECTION_RE);
|
|
553
557
|
if (connMatch) {
|
|
554
|
-
const
|
|
558
|
+
const rawLabel = connMatch[1] ?? '';
|
|
559
|
+
const labelResult = parseInArrowLabel(rawLabel, lineNumber);
|
|
560
|
+
result.diagnostics.push(...labelResult.diagnostics);
|
|
561
|
+
const label = labelResult.label ?? '';
|
|
555
562
|
const targetRaw = connMatch[2].trim();
|
|
556
563
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
557
564
|
const targetName = pipeMeta.clean || targetRaw;
|
package/src/kanban/renderer.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
// Public options object
|
|
29
29
|
// ============================================================
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
interface KanbanInteractiveOptions {
|
|
32
32
|
onNavigateToLine?: (line: number) => void;
|
|
33
33
|
exportDims?: { width: number; height: number };
|
|
34
34
|
activeTagGroup?: string | null;
|
|
@@ -608,7 +608,7 @@ interface SwimlaneBucket {
|
|
|
608
608
|
cellsByColumn: Record<string, KanbanCard[]>;
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
-
|
|
611
|
+
function bucketCardsBySwimlane(
|
|
612
612
|
columns: KanbanColumn[],
|
|
613
613
|
swimlaneGroup: KanbanTagGroup
|
|
614
614
|
): SwimlaneBucket[] {
|
|
@@ -84,17 +84,6 @@ export function hexToHSLString(hex: string): string {
|
|
|
84
84
|
// Color Manipulation
|
|
85
85
|
// ============================================================
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
* Derive a muted (desaturated, darkened) variant of a color.
|
|
89
|
-
* Used by the Mermaid theme generator for dark-mode fills.
|
|
90
|
-
*
|
|
91
|
-
* Algorithm: cap saturation at 35% and lightness at 36%.
|
|
92
|
-
*/
|
|
93
|
-
export function mute(hex: string): string {
|
|
94
|
-
const { h, s, l } = hexToHSL(hex);
|
|
95
|
-
return hslToHex(h, Math.min(s, 35), Math.min(l, 36));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
87
|
/**
|
|
99
88
|
* Blend a color toward white (light mode quadrant fills).
|
|
100
89
|
* amount: 0 = original, 1 = white
|
|
@@ -232,7 +221,10 @@ export function getSeriesColors(palette: PaletteColors): string[] {
|
|
|
232
221
|
* saturation and lightness, guaranteeing every segment gets a unique,
|
|
233
222
|
* perceptually distinct color regardless of segment count.
|
|
234
223
|
*/
|
|
235
|
-
export function getSegmentColors(
|
|
224
|
+
export function getSegmentColors(
|
|
225
|
+
palette: PaletteColors,
|
|
226
|
+
count: number
|
|
227
|
+
): string[] {
|
|
236
228
|
const base = getSeriesColors(palette);
|
|
237
229
|
const unique = [...new Set(base)];
|
|
238
230
|
const hsls = unique.map(hexToHSL);
|
package/src/palettes/index.ts
CHANGED
|
@@ -14,7 +14,6 @@ export {
|
|
|
14
14
|
hexToHSL,
|
|
15
15
|
hslToHex,
|
|
16
16
|
hexToHSLString,
|
|
17
|
-
mute,
|
|
18
17
|
tint,
|
|
19
18
|
shade,
|
|
20
19
|
getSeriesColors,
|
|
@@ -34,6 +33,3 @@ export { tokyoNightPalette } from './tokyo-night';
|
|
|
34
33
|
|
|
35
34
|
export { draculaPalette } from './dracula';
|
|
36
35
|
export { monokaiPalette } from './monokai';
|
|
37
|
-
|
|
38
|
-
// Re-export Mermaid bridge
|
|
39
|
-
export { buildMermaidThemeVars, buildThemeCSS } from './mermaid-bridge';
|
package/src/render.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { renderForExport } from './d3';
|
|
2
2
|
import { renderExtendedChartForExport } from './echarts';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
parseDgmoChartType,
|
|
5
|
+
getRenderCategory,
|
|
6
|
+
parseDgmo,
|
|
7
|
+
} from './dgmo-router';
|
|
8
|
+
import type { DgmoError } from './diagnostics';
|
|
4
9
|
import { getPalette } from './palettes/registry';
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -45,13 +50,13 @@ async function ensureDom(): Promise<void> {
|
|
|
45
50
|
*
|
|
46
51
|
* @param content - DGMO source text
|
|
47
52
|
* @param options - Optional theme and palette settings
|
|
48
|
-
* @returns SVG string,
|
|
53
|
+
* @returns Object with `svg` (SVG string, empty on error) and `diagnostics` (parse errors/warnings)
|
|
49
54
|
*
|
|
50
55
|
* @example
|
|
51
56
|
* ```ts
|
|
52
57
|
* import { render } from '@diagrammo/dgmo';
|
|
53
58
|
*
|
|
54
|
-
* const svg = await render(`pie Languages
|
|
59
|
+
* const { svg, diagnostics } = await render(`pie Languages
|
|
55
60
|
* TypeScript: 45
|
|
56
61
|
* Python: 30
|
|
57
62
|
* Rust: 25`);
|
|
@@ -62,7 +67,6 @@ export async function render(
|
|
|
62
67
|
options?: {
|
|
63
68
|
theme?: 'light' | 'dark' | 'transparent';
|
|
64
69
|
palette?: string;
|
|
65
|
-
branding?: boolean;
|
|
66
70
|
c4Level?: 'context' | 'containers' | 'components' | 'deployment';
|
|
67
71
|
c4System?: string;
|
|
68
72
|
c4Container?: string;
|
|
@@ -70,14 +74,15 @@ export async function render(
|
|
|
70
74
|
/** Legend state for export — controls which tag group is shown in exported SVG. */
|
|
71
75
|
legendState?: { activeGroup?: string; hiddenAttributes?: string[] };
|
|
72
76
|
}
|
|
73
|
-
): Promise<string> {
|
|
77
|
+
): Promise<{ svg: string; diagnostics: DgmoError[] }> {
|
|
74
78
|
const theme = options?.theme ?? 'light';
|
|
75
79
|
const paletteName = options?.palette ?? 'nord';
|
|
76
|
-
const branding = options?.branding ?? false;
|
|
77
80
|
|
|
78
81
|
const paletteColors =
|
|
79
82
|
getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
|
|
80
83
|
|
|
84
|
+
const { diagnostics } = parseDgmo(content);
|
|
85
|
+
|
|
81
86
|
const chartType = parseDgmoChartType(content);
|
|
82
87
|
const category = chartType ? getRenderCategory(chartType) : null;
|
|
83
88
|
|
|
@@ -92,18 +97,27 @@ export async function render(
|
|
|
92
97
|
: undefined;
|
|
93
98
|
|
|
94
99
|
if (category === 'data-chart') {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const svg = await renderExtendedChartForExport(
|
|
101
|
+
content,
|
|
102
|
+
theme,
|
|
103
|
+
paletteColors
|
|
104
|
+
);
|
|
105
|
+
return { svg, diagnostics };
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
// Visualization/diagram and unknown/null types all go through the unified renderer
|
|
101
109
|
await ensureDom();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
const svg = await renderForExport(
|
|
111
|
+
content,
|
|
112
|
+
theme,
|
|
113
|
+
paletteColors,
|
|
114
|
+
legendExportState,
|
|
115
|
+
{
|
|
116
|
+
c4Level: options?.c4Level,
|
|
117
|
+
c4System: options?.c4System,
|
|
118
|
+
c4Container: options?.c4Container,
|
|
119
|
+
tagGroup: options?.tagGroup,
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
return { svg, diagnostics };
|
|
109
123
|
}
|
|
@@ -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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { inferParticipantType } from './participant-inference';
|
|
6
6
|
import type { DgmoError } from '../diagnostics';
|
|
7
7
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
8
|
-
import { parseArrow } from '../utils/arrows';
|
|
8
|
+
import { parseArrow, parseInArrowLabel } from '../utils/arrows';
|
|
9
9
|
import {
|
|
10
10
|
measureIndent,
|
|
11
11
|
extractColor,
|
|
@@ -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;
|
|
@@ -933,9 +945,14 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
933
945
|
}
|
|
934
946
|
if (labeledArrow) {
|
|
935
947
|
contentStarted = true;
|
|
936
|
-
const { from, to, label, async: isAsync } = labeledArrow;
|
|
948
|
+
const { from, to, label: rawLabel, async: isAsync } = labeledArrow;
|
|
937
949
|
lastMsgFrom = from;
|
|
938
950
|
|
|
951
|
+
// TD-13/TD-14: validate in-arrow label characters
|
|
952
|
+
const labelResult = parseInArrowLabel(rawLabel, lineNumber);
|
|
953
|
+
labelResult.diagnostics.forEach((d) => result.diagnostics.push(d));
|
|
954
|
+
const label = labelResult.label ?? rawLabel;
|
|
955
|
+
|
|
939
956
|
const msg: SequenceMessage = {
|
|
940
957
|
from,
|
|
941
958
|
to,
|