@diagrammo/dgmo 0.4.2 → 0.4.3
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/.claude/skills/dgmo-chart/SKILL.md +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +197 -154
- package/dist/index.cjs +8371 -3200
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +502 -58
- package/dist/index.d.ts +502 -58
- package/dist/index.js +8594 -3444
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +575 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1509 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- package/src/utils/tag-groups.ts +119 -0
package/src/sequence/renderer.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import * as d3Selection from 'd3-selection';
|
|
6
6
|
import type { PaletteColors } from '../palettes';
|
|
7
|
-
import {
|
|
7
|
+
import { mix } from '../palettes/color-utils';
|
|
8
8
|
import {
|
|
9
9
|
parseInlineMarkdown,
|
|
10
10
|
truncateBareUrl,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
export type { InlineSpan } from '../utils/inline-markdown';
|
|
14
14
|
export { parseInlineMarkdown, truncateBareUrl };
|
|
15
15
|
import { FONT_FAMILY } from '../fonts';
|
|
16
|
+
import { resolveColor } from '../colors';
|
|
16
17
|
import type {
|
|
17
18
|
ParsedSequenceDgmo,
|
|
18
19
|
SequenceElement,
|
|
@@ -22,6 +23,8 @@ import type {
|
|
|
22
23
|
SequenceParticipant,
|
|
23
24
|
} from './parser';
|
|
24
25
|
import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
|
|
26
|
+
import { resolveSequenceTags } from './tag-resolution';
|
|
27
|
+
import type { ResolvedTagMap } from './tag-resolution';
|
|
25
28
|
|
|
26
29
|
// ============================================================
|
|
27
30
|
// Layout Constants
|
|
@@ -51,6 +54,20 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
|
|
|
51
54
|
const COLLAPSED_NOTE_H = 20;
|
|
52
55
|
const COLLAPSED_NOTE_W = 40;
|
|
53
56
|
|
|
57
|
+
// Legend rendering constants (consistent with org chart legend)
|
|
58
|
+
const LEGEND_HEIGHT = 28;
|
|
59
|
+
const LEGEND_PILL_PAD = 16;
|
|
60
|
+
const LEGEND_PILL_FONT_SIZE = 11;
|
|
61
|
+
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
62
|
+
const LEGEND_CAPSULE_PAD = 4;
|
|
63
|
+
const LEGEND_DOT_R = 4;
|
|
64
|
+
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
65
|
+
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
66
|
+
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
67
|
+
const LEGEND_ENTRY_TRAIL = 8;
|
|
68
|
+
const LEGEND_GROUP_GAP = 12;
|
|
69
|
+
const LEGEND_BOTTOM_GAP = 8;
|
|
70
|
+
|
|
54
71
|
|
|
55
72
|
function wrapTextLines(text: string, maxChars: number): string[] {
|
|
56
73
|
const rawLines = text.split('\n');
|
|
@@ -75,22 +92,64 @@ function wrapTextLines(text: string, maxChars: number): string[] {
|
|
|
75
92
|
return wrapped;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Split a participant label into multiple lines if it exceeds the box width.
|
|
97
|
+
* Splits on spaces first, then dashes, then camelCase boundaries.
|
|
98
|
+
* Approximate max chars based on font-size 13 (~7.5px per char average).
|
|
99
|
+
*/
|
|
100
|
+
const LABEL_CHAR_WIDTH = 7.5;
|
|
101
|
+
const LABEL_MAX_CHARS = Math.floor((PARTICIPANT_BOX_WIDTH - 10) / LABEL_CHAR_WIDTH); // ~14 chars
|
|
102
|
+
|
|
103
|
+
function splitParticipantLabel(label: string): string[] {
|
|
104
|
+
if (label.length <= LABEL_MAX_CHARS) return [label];
|
|
105
|
+
|
|
106
|
+
// Split on spaces
|
|
107
|
+
if (label.includes(' ')) {
|
|
108
|
+
return wrapLabelWords(label.split(' '));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Split on dashes/underscores
|
|
112
|
+
if (/[-_]/.test(label)) {
|
|
113
|
+
const parts = label.split(/[-_]/);
|
|
114
|
+
return wrapLabelWords(parts);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
|
|
118
|
+
const camelParts = label.replace(/([a-z])([A-Z])/g, '$1\x00$2')
|
|
119
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
|
|
120
|
+
.split('\x00');
|
|
121
|
+
if (camelParts.length > 1) {
|
|
122
|
+
return wrapLabelWords(camelParts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [label];
|
|
88
126
|
}
|
|
89
127
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
128
|
+
/** Greedily join word parts into lines that fit within LABEL_MAX_CHARS. */
|
|
129
|
+
function wrapLabelWords(words: string[]): string[] {
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
let current = '';
|
|
132
|
+
for (const word of words) {
|
|
133
|
+
const test = current ? current + word : word;
|
|
134
|
+
if (test.length > LABEL_MAX_CHARS && current) {
|
|
135
|
+
lines.push(current);
|
|
136
|
+
current = word;
|
|
137
|
+
} else {
|
|
138
|
+
current = test;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (current) lines.push(current);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Shared fill/stroke helpers — accept optional color override for per-participant coloring
|
|
146
|
+
const fill = (palette: PaletteColors, isDark: boolean, color?: string): string =>
|
|
147
|
+
color
|
|
148
|
+
? mix(color, isDark ? palette.surface : palette.bg, isDark ? 30 : 40)
|
|
149
|
+
: isDark
|
|
150
|
+
? mix(palette.overlay, palette.surface, 50)
|
|
151
|
+
: mix(palette.bg, palette.surface, 50);
|
|
152
|
+
const stroke = (palette: PaletteColors, color?: string): string => color || palette.border;
|
|
94
153
|
const SW = 1.5;
|
|
95
154
|
const W = PARTICIPANT_BOX_WIDTH;
|
|
96
155
|
const H = PARTICIPANT_BOX_HEIGHT;
|
|
@@ -102,7 +161,8 @@ const H = PARTICIPANT_BOX_HEIGHT;
|
|
|
102
161
|
function renderRectParticipant(
|
|
103
162
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
104
163
|
palette: PaletteColors,
|
|
105
|
-
isDark: boolean
|
|
164
|
+
isDark: boolean,
|
|
165
|
+
color?: string
|
|
106
166
|
): void {
|
|
107
167
|
g.append('rect')
|
|
108
168
|
.attr('x', -W / 2)
|
|
@@ -111,15 +171,16 @@ function renderRectParticipant(
|
|
|
111
171
|
.attr('height', H)
|
|
112
172
|
.attr('rx', 2)
|
|
113
173
|
.attr('ry', 2)
|
|
114
|
-
.attr('fill', fill(palette, isDark))
|
|
115
|
-
.attr('stroke', stroke(palette))
|
|
174
|
+
.attr('fill', fill(palette, isDark, color))
|
|
175
|
+
.attr('stroke', stroke(palette, color))
|
|
116
176
|
.attr('stroke-width', SW);
|
|
117
177
|
}
|
|
118
178
|
|
|
119
179
|
function renderServiceParticipant(
|
|
120
180
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
121
181
|
palette: PaletteColors,
|
|
122
|
-
isDark: boolean
|
|
182
|
+
isDark: boolean,
|
|
183
|
+
color?: string
|
|
123
184
|
): void {
|
|
124
185
|
g.append('rect')
|
|
125
186
|
.attr('x', -W / 2)
|
|
@@ -128,14 +189,15 @@ function renderServiceParticipant(
|
|
|
128
189
|
.attr('height', H)
|
|
129
190
|
.attr('rx', SERVICE_BORDER_RADIUS)
|
|
130
191
|
.attr('ry', SERVICE_BORDER_RADIUS)
|
|
131
|
-
.attr('fill', fill(palette, isDark))
|
|
132
|
-
.attr('stroke', stroke(palette))
|
|
192
|
+
.attr('fill', fill(palette, isDark, color))
|
|
193
|
+
.attr('stroke', stroke(palette, color))
|
|
133
194
|
.attr('stroke-width', SW);
|
|
134
195
|
}
|
|
135
196
|
|
|
136
197
|
function renderActorParticipant(
|
|
137
198
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
138
|
-
palette: PaletteColors
|
|
199
|
+
palette: PaletteColors,
|
|
200
|
+
color?: string
|
|
139
201
|
): void {
|
|
140
202
|
// Stick figure — no background, natural proportions
|
|
141
203
|
const headR = 8;
|
|
@@ -146,7 +208,7 @@ function renderActorParticipant(
|
|
|
146
208
|
const legY = H - 2;
|
|
147
209
|
const armSpan = 16;
|
|
148
210
|
const legSpan = 12;
|
|
149
|
-
const s = stroke(palette);
|
|
211
|
+
const s = stroke(palette, color);
|
|
150
212
|
const actorSW = 2.5;
|
|
151
213
|
|
|
152
214
|
g.append('circle')
|
|
@@ -193,14 +255,15 @@ function renderActorParticipant(
|
|
|
193
255
|
function renderDatabaseParticipant(
|
|
194
256
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
195
257
|
palette: PaletteColors,
|
|
196
|
-
isDark: boolean
|
|
258
|
+
isDark: boolean,
|
|
259
|
+
color?: string
|
|
197
260
|
): void {
|
|
198
261
|
// Cylinder fitting within W x H
|
|
199
262
|
const ry = 7;
|
|
200
263
|
const topY = ry;
|
|
201
264
|
const bodyH = H - ry * 2;
|
|
202
|
-
const f = fill(palette, isDark);
|
|
203
|
-
const s = stroke(palette);
|
|
265
|
+
const f = fill(palette, isDark, color);
|
|
266
|
+
const s = stroke(palette, color);
|
|
204
267
|
|
|
205
268
|
// Bottom ellipse (drawn first — rect will cover its top arc)
|
|
206
269
|
g.append('ellipse')
|
|
@@ -252,14 +315,15 @@ function renderDatabaseParticipant(
|
|
|
252
315
|
function renderQueueParticipant(
|
|
253
316
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
254
317
|
palette: PaletteColors,
|
|
255
|
-
isDark: boolean
|
|
318
|
+
isDark: boolean,
|
|
319
|
+
color?: string
|
|
256
320
|
): void {
|
|
257
321
|
// Horizontal cylinder (pipe) — like database rotated 90 degrees
|
|
258
322
|
const rx = 10;
|
|
259
323
|
const leftX = -W / 2 + rx;
|
|
260
324
|
const bodyW = W - rx * 2;
|
|
261
|
-
const f = fill(palette, isDark);
|
|
262
|
-
const s = stroke(palette);
|
|
325
|
+
const f = fill(palette, isDark, color);
|
|
326
|
+
const s = stroke(palette, color);
|
|
263
327
|
|
|
264
328
|
// Right ellipse (back face, drawn first — rect will cover its left arc)
|
|
265
329
|
g.append('ellipse')
|
|
@@ -311,14 +375,15 @@ function renderQueueParticipant(
|
|
|
311
375
|
function renderCacheParticipant(
|
|
312
376
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
313
377
|
palette: PaletteColors,
|
|
314
|
-
isDark: boolean
|
|
378
|
+
isDark: boolean,
|
|
379
|
+
color?: string
|
|
315
380
|
): void {
|
|
316
381
|
// Dashed cylinder — variation of database to convey ephemeral storage
|
|
317
382
|
const ry = 7;
|
|
318
383
|
const topY = ry;
|
|
319
384
|
const bodyH = H - ry * 2;
|
|
320
|
-
const f = fill(palette, isDark);
|
|
321
|
-
const s = stroke(palette);
|
|
385
|
+
const f = fill(palette, isDark, color);
|
|
386
|
+
const s = stroke(palette, color);
|
|
322
387
|
const dash = '4 3';
|
|
323
388
|
|
|
324
389
|
// Bottom ellipse (back face)
|
|
@@ -373,7 +438,8 @@ function renderCacheParticipant(
|
|
|
373
438
|
function renderNetworkingParticipant(
|
|
374
439
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
375
440
|
palette: PaletteColors,
|
|
376
|
-
isDark: boolean
|
|
441
|
+
isDark: boolean,
|
|
442
|
+
color?: string
|
|
377
443
|
): void {
|
|
378
444
|
// Hexagon fitting within W x H
|
|
379
445
|
const inset = 16;
|
|
@@ -387,19 +453,20 @@ function renderNetworkingParticipant(
|
|
|
387
453
|
].join(' ');
|
|
388
454
|
g.append('polygon')
|
|
389
455
|
.attr('points', points)
|
|
390
|
-
.attr('fill', fill(palette, isDark))
|
|
391
|
-
.attr('stroke', stroke(palette))
|
|
456
|
+
.attr('fill', fill(palette, isDark, color))
|
|
457
|
+
.attr('stroke', stroke(palette, color))
|
|
392
458
|
.attr('stroke-width', SW);
|
|
393
459
|
}
|
|
394
460
|
|
|
395
461
|
function renderFrontendParticipant(
|
|
396
462
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
397
463
|
palette: PaletteColors,
|
|
398
|
-
isDark: boolean
|
|
464
|
+
isDark: boolean,
|
|
465
|
+
color?: string
|
|
399
466
|
): void {
|
|
400
467
|
// Monitor shape fitting within W x H
|
|
401
468
|
const screenH = H - 10;
|
|
402
|
-
const s = stroke(palette);
|
|
469
|
+
const s = stroke(palette, color);
|
|
403
470
|
g.append('rect')
|
|
404
471
|
.attr('x', -W / 2)
|
|
405
472
|
.attr('y', 0)
|
|
@@ -407,7 +474,7 @@ function renderFrontendParticipant(
|
|
|
407
474
|
.attr('height', screenH)
|
|
408
475
|
.attr('rx', 3)
|
|
409
476
|
.attr('ry', 3)
|
|
410
|
-
.attr('fill', fill(palette, isDark))
|
|
477
|
+
.attr('fill', fill(palette, isDark, color))
|
|
411
478
|
.attr('stroke', s)
|
|
412
479
|
.attr('stroke-width', SW);
|
|
413
480
|
// Stand
|
|
@@ -431,7 +498,8 @@ function renderFrontendParticipant(
|
|
|
431
498
|
function renderExternalParticipant(
|
|
432
499
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
433
500
|
palette: PaletteColors,
|
|
434
|
-
isDark: boolean
|
|
501
|
+
isDark: boolean,
|
|
502
|
+
color?: string
|
|
435
503
|
): void {
|
|
436
504
|
// Dashed border rectangle
|
|
437
505
|
g.append('rect')
|
|
@@ -441,8 +509,8 @@ function renderExternalParticipant(
|
|
|
441
509
|
.attr('height', H)
|
|
442
510
|
.attr('rx', 2)
|
|
443
511
|
.attr('ry', 2)
|
|
444
|
-
.attr('fill', fill(palette, isDark))
|
|
445
|
-
.attr('stroke', stroke(palette))
|
|
512
|
+
.attr('fill', fill(palette, isDark, color))
|
|
513
|
+
.attr('stroke', stroke(palette, color))
|
|
446
514
|
.attr('stroke-width', SW)
|
|
447
515
|
.attr('stroke-dasharray', '6 3');
|
|
448
516
|
}
|
|
@@ -450,9 +518,10 @@ function renderExternalParticipant(
|
|
|
450
518
|
function renderGatewayParticipant(
|
|
451
519
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
452
520
|
palette: PaletteColors,
|
|
453
|
-
isDark: boolean
|
|
521
|
+
isDark: boolean,
|
|
522
|
+
color?: string
|
|
454
523
|
): void {
|
|
455
|
-
renderRectParticipant(g, palette, isDark);
|
|
524
|
+
renderRectParticipant(g, palette, isDark, color);
|
|
456
525
|
}
|
|
457
526
|
|
|
458
527
|
// ============================================================
|
|
@@ -468,6 +537,7 @@ export interface SequenceRenderOptions {
|
|
|
468
537
|
collapsedSections?: Set<number>; // keyed by section lineNumber
|
|
469
538
|
expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
|
|
470
539
|
exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
|
|
540
|
+
activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
|
|
471
541
|
}
|
|
472
542
|
|
|
473
543
|
/**
|
|
@@ -738,37 +808,80 @@ export function applyPositionOverrides(
|
|
|
738
808
|
|
|
739
809
|
/**
|
|
740
810
|
* Reorder participants so that members of the same group are adjacent.
|
|
741
|
-
* Groups
|
|
811
|
+
* Groups are positioned at the point where their first member would naturally
|
|
812
|
+
* appear based on message order (first-occurrence positioning). This prevents
|
|
813
|
+
* groups declared at the top of the file from being placed before participants
|
|
814
|
+
* that appear in messages earlier.
|
|
815
|
+
*
|
|
816
|
+
* Explicit `position` overrides are handled separately by `applyPositionOverrides`.
|
|
742
817
|
*/
|
|
743
818
|
export function applyGroupOrdering(
|
|
744
819
|
participants: SequenceParticipant[],
|
|
745
|
-
groups: SequenceGroup[]
|
|
820
|
+
groups: SequenceGroup[],
|
|
821
|
+
messages: SequenceMessage[] = []
|
|
746
822
|
): SequenceParticipant[] {
|
|
747
823
|
if (groups.length === 0) return participants;
|
|
748
824
|
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
const placed = new Set<string>();
|
|
752
|
-
|
|
753
|
-
// Place grouped participants in group declaration order
|
|
825
|
+
// Build a map: participantId → group
|
|
826
|
+
const idToGroup = new Map<string, SequenceGroup>();
|
|
754
827
|
for (const group of groups) {
|
|
755
828
|
for (const id of group.participantIds) {
|
|
756
|
-
|
|
757
|
-
if (p && !placed.has(id)) {
|
|
758
|
-
result.push(p);
|
|
759
|
-
placed.add(id);
|
|
760
|
-
}
|
|
829
|
+
idToGroup.set(id, group);
|
|
761
830
|
}
|
|
762
831
|
}
|
|
763
832
|
|
|
764
|
-
//
|
|
833
|
+
// Build first-appearance index from messages (order in which participants
|
|
834
|
+
// are first referenced). Participants not in any message keep their
|
|
835
|
+
// declaration order from the participants array.
|
|
836
|
+
const appearanceOrder: string[] = [];
|
|
837
|
+
const seen = new Set<string>();
|
|
838
|
+
for (const msg of messages) {
|
|
839
|
+
for (const id of [msg.from, msg.to]) {
|
|
840
|
+
if (!seen.has(id)) {
|
|
841
|
+
seen.add(id);
|
|
842
|
+
appearanceOrder.push(id);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Append any participants not referenced in messages (declaration-only)
|
|
765
847
|
for (const p of participants) {
|
|
766
|
-
if (!
|
|
767
|
-
|
|
768
|
-
|
|
848
|
+
if (!seen.has(p.id)) {
|
|
849
|
+
seen.add(p.id);
|
|
850
|
+
appearanceOrder.push(p.id);
|
|
769
851
|
}
|
|
770
852
|
}
|
|
771
853
|
|
|
854
|
+
// Walk appearance order; when we encounter a grouped participant,
|
|
855
|
+
// insert the entire group at that position (if not already placed).
|
|
856
|
+
const result: SequenceParticipant[] = [];
|
|
857
|
+
const placed = new Set<string>();
|
|
858
|
+
const placedGroups = new Set<SequenceGroup>();
|
|
859
|
+
|
|
860
|
+
for (const id of appearanceOrder) {
|
|
861
|
+
if (placed.has(id)) continue;
|
|
862
|
+
|
|
863
|
+
const group = idToGroup.get(id);
|
|
864
|
+
if (group && !placedGroups.has(group)) {
|
|
865
|
+
// Place entire group here
|
|
866
|
+
placedGroups.add(group);
|
|
867
|
+
for (const gid of group.participantIds) {
|
|
868
|
+
const p = participants.find((pp) => pp.id === gid);
|
|
869
|
+
if (p && !placed.has(gid)) {
|
|
870
|
+
result.push(p);
|
|
871
|
+
placed.add(gid);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} else if (!group) {
|
|
875
|
+
// Ungrouped participant
|
|
876
|
+
const p = participants.find((pp) => pp.id === id);
|
|
877
|
+
if (p) {
|
|
878
|
+
result.push(p);
|
|
879
|
+
placed.add(id);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// If group already placed, skip (member already included)
|
|
883
|
+
}
|
|
884
|
+
|
|
772
885
|
return result;
|
|
773
886
|
}
|
|
774
887
|
|
|
@@ -798,12 +911,36 @@ export function renderSequenceDiagram(
|
|
|
798
911
|
const isNoteExpanded = (note: SequenceNote): boolean =>
|
|
799
912
|
expandedNoteLines === undefined || collapseNotesDisabled || expandedNoteLines.has(note.lineNumber);
|
|
800
913
|
const participants = applyPositionOverrides(
|
|
801
|
-
applyGroupOrdering(parsed.participants, groups)
|
|
914
|
+
applyGroupOrdering(parsed.participants, groups, messages)
|
|
802
915
|
);
|
|
803
916
|
if (participants.length === 0) return;
|
|
804
917
|
|
|
805
918
|
const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
|
|
806
919
|
|
|
920
|
+
// Tag resolution — compute resolved tag values and build color lookup
|
|
921
|
+
// Explicit render option wins (including null = "no active group"),
|
|
922
|
+
// then fall back to diagram-level `active-tag: Name` option for CLI/export
|
|
923
|
+
const activeTagGroup =
|
|
924
|
+
options?.activeTagGroup !== undefined
|
|
925
|
+
? options.activeTagGroup || undefined
|
|
926
|
+
: parsedOptions['active-tag'] || undefined;
|
|
927
|
+
let tagMap: ResolvedTagMap | undefined;
|
|
928
|
+
const tagValueToColor = new Map<string, string>();
|
|
929
|
+
if (activeTagGroup) {
|
|
930
|
+
tagMap = resolveSequenceTags(parsed, activeTagGroup);
|
|
931
|
+
const tg = parsed.tagGroups.find(
|
|
932
|
+
(g) => g.name.toLowerCase() === activeTagGroup.toLowerCase(),
|
|
933
|
+
);
|
|
934
|
+
if (tg) {
|
|
935
|
+
for (const entry of tg.entries) {
|
|
936
|
+
tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const getTagColor = (value: string | undefined): string | undefined =>
|
|
941
|
+
value ? tagValueToColor.get(value.toLowerCase()) : undefined;
|
|
942
|
+
const tagKey = activeTagGroup?.toLowerCase();
|
|
943
|
+
|
|
807
944
|
// Build hidden message set for collapse support
|
|
808
945
|
const hiddenMsgIndices = new Set<number>();
|
|
809
946
|
if (collapsedSections && collapsedSections.size > 0) {
|
|
@@ -1145,10 +1282,12 @@ export function renderSequenceDiagram(
|
|
|
1145
1282
|
|
|
1146
1283
|
// Compute cumulative Y positions for each step, with section dividers as stable anchors
|
|
1147
1284
|
const titleOffset = title ? TITLE_HEIGHT : 0;
|
|
1285
|
+
const legendOffset =
|
|
1286
|
+
parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_BOTTOM_GAP : 0;
|
|
1148
1287
|
const groupOffset =
|
|
1149
1288
|
groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
|
|
1150
1289
|
const participantStartY =
|
|
1151
|
-
TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
|
|
1290
|
+
TOP_MARGIN + titleOffset + legendOffset + PARTICIPANT_Y_OFFSET + groupOffset;
|
|
1152
1291
|
const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
|
|
1153
1292
|
const hasActors = participants.some((p) => p.type === 'actor');
|
|
1154
1293
|
const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
|
|
@@ -1337,6 +1476,81 @@ export function renderSequenceDiagram(
|
|
|
1337
1476
|
.attr('stroke', palette.text)
|
|
1338
1477
|
.attr('stroke-width', 1.2);
|
|
1339
1478
|
|
|
1479
|
+
// Per-color arrowhead markers for tag-driven coloring
|
|
1480
|
+
const arrowPoints = `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`;
|
|
1481
|
+
for (const [, color] of tagValueToColor) {
|
|
1482
|
+
const hex = color.replace('#', '');
|
|
1483
|
+
// Filled arrowhead (call arrows)
|
|
1484
|
+
defs
|
|
1485
|
+
.append('marker')
|
|
1486
|
+
.attr('id', `seq-arrowhead-c${hex}`)
|
|
1487
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
1488
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
1489
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
1490
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
1491
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
1492
|
+
.attr('orient', 'auto')
|
|
1493
|
+
.append('polygon')
|
|
1494
|
+
.attr('points', arrowPoints)
|
|
1495
|
+
.attr('fill', color);
|
|
1496
|
+
// Open arrowhead (async arrows)
|
|
1497
|
+
defs
|
|
1498
|
+
.append('marker')
|
|
1499
|
+
.attr('id', `seq-arrowhead-async-c${hex}`)
|
|
1500
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
1501
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
1502
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
1503
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
1504
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
1505
|
+
.attr('orient', 'auto')
|
|
1506
|
+
.append('polyline')
|
|
1507
|
+
.attr('points', arrowPoints)
|
|
1508
|
+
.attr('fill', 'none')
|
|
1509
|
+
.attr('stroke', color)
|
|
1510
|
+
.attr('stroke-width', 1.2);
|
|
1511
|
+
// Open arrowhead (return arrows)
|
|
1512
|
+
defs
|
|
1513
|
+
.append('marker')
|
|
1514
|
+
.attr('id', `seq-arrowhead-open-c${hex}`)
|
|
1515
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
1516
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
1517
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
1518
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
1519
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
1520
|
+
.attr('orient', 'auto')
|
|
1521
|
+
.append('polyline')
|
|
1522
|
+
.attr('points', arrowPoints)
|
|
1523
|
+
.attr('fill', 'none')
|
|
1524
|
+
.attr('stroke', color)
|
|
1525
|
+
.attr('stroke-width', 1.2);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Helper: resolve marker ref for tag-colored arrows
|
|
1529
|
+
const coloredMarker = (
|
|
1530
|
+
type: 'call' | 'async' | 'return',
|
|
1531
|
+
tagColor?: string,
|
|
1532
|
+
): string => {
|
|
1533
|
+
if (tagColor) {
|
|
1534
|
+
const hex = tagColor.replace('#', '');
|
|
1535
|
+
switch (type) {
|
|
1536
|
+
case 'call':
|
|
1537
|
+
return `url(#seq-arrowhead-c${hex})`;
|
|
1538
|
+
case 'async':
|
|
1539
|
+
return `url(#seq-arrowhead-async-c${hex})`;
|
|
1540
|
+
case 'return':
|
|
1541
|
+
return `url(#seq-arrowhead-open-c${hex})`;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
switch (type) {
|
|
1545
|
+
case 'call':
|
|
1546
|
+
return 'url(#seq-arrowhead)';
|
|
1547
|
+
case 'async':
|
|
1548
|
+
return 'url(#seq-arrowhead-async)';
|
|
1549
|
+
case 'return':
|
|
1550
|
+
return 'url(#seq-arrowhead-open)';
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1340
1554
|
// Render title
|
|
1341
1555
|
if (title) {
|
|
1342
1556
|
const titleEl = svg
|
|
@@ -1355,6 +1569,142 @@ export function renderSequenceDiagram(
|
|
|
1355
1569
|
}
|
|
1356
1570
|
}
|
|
1357
1571
|
|
|
1572
|
+
// Render legend pills for tag groups
|
|
1573
|
+
if (parsed.tagGroups.length > 0) {
|
|
1574
|
+
const legendY = TOP_MARGIN + titleOffset;
|
|
1575
|
+
const groupBg = isDark
|
|
1576
|
+
? mix(palette.surface, palette.bg, 50)
|
|
1577
|
+
: mix(palette.surface, palette.bg, 30);
|
|
1578
|
+
|
|
1579
|
+
// Pre-compute pill/capsule widths for centering
|
|
1580
|
+
const legendItems: Array<{
|
|
1581
|
+
group: typeof parsed.tagGroups[0];
|
|
1582
|
+
isActive: boolean;
|
|
1583
|
+
pillWidth: number;
|
|
1584
|
+
totalWidth: number;
|
|
1585
|
+
entries: Array<{ value: string; color: string }>;
|
|
1586
|
+
}> = [];
|
|
1587
|
+
for (const tg of parsed.tagGroups) {
|
|
1588
|
+
if (tg.entries.length === 0) continue;
|
|
1589
|
+
const isActive =
|
|
1590
|
+
!!activeTagGroup &&
|
|
1591
|
+
tg.name.toLowerCase() === activeTagGroup.toLowerCase();
|
|
1592
|
+
const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1593
|
+
const entries = tg.entries.map((e) => ({
|
|
1594
|
+
value: e.value,
|
|
1595
|
+
color: resolveColor(e.color),
|
|
1596
|
+
}));
|
|
1597
|
+
let totalWidth = pillWidth;
|
|
1598
|
+
if (isActive) {
|
|
1599
|
+
let entriesWidth = 0;
|
|
1600
|
+
for (const entry of entries) {
|
|
1601
|
+
entriesWidth +=
|
|
1602
|
+
LEGEND_DOT_R * 2 +
|
|
1603
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
1604
|
+
entry.value.length * LEGEND_ENTRY_FONT_W +
|
|
1605
|
+
LEGEND_ENTRY_TRAIL;
|
|
1606
|
+
}
|
|
1607
|
+
totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
|
|
1608
|
+
}
|
|
1609
|
+
legendItems.push({ group: tg, isActive, pillWidth, totalWidth, entries });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Center legend horizontally
|
|
1613
|
+
const totalLegendWidth =
|
|
1614
|
+
legendItems.reduce((s, item) => s + item.totalWidth, 0) +
|
|
1615
|
+
(legendItems.length - 1) * LEGEND_GROUP_GAP;
|
|
1616
|
+
let legendX = (svgWidth - totalLegendWidth) / 2;
|
|
1617
|
+
|
|
1618
|
+
for (const item of legendItems) {
|
|
1619
|
+
const gEl = svg
|
|
1620
|
+
.append('g')
|
|
1621
|
+
.attr('transform', `translate(${legendX}, ${legendY})`)
|
|
1622
|
+
.attr('class', 'sequence-legend-group')
|
|
1623
|
+
.attr('data-legend-group', item.group.name.toLowerCase())
|
|
1624
|
+
.style('cursor', 'pointer');
|
|
1625
|
+
|
|
1626
|
+
// Outer capsule background (active only)
|
|
1627
|
+
if (item.isActive) {
|
|
1628
|
+
gEl
|
|
1629
|
+
.append('rect')
|
|
1630
|
+
.attr('width', item.totalWidth)
|
|
1631
|
+
.attr('height', LEGEND_HEIGHT)
|
|
1632
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
1633
|
+
.attr('fill', groupBg);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const pillXOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
1637
|
+
const pillYOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
1638
|
+
const pillH = LEGEND_HEIGHT - (item.isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1639
|
+
|
|
1640
|
+
// Pill background
|
|
1641
|
+
gEl
|
|
1642
|
+
.append('rect')
|
|
1643
|
+
.attr('x', pillXOff)
|
|
1644
|
+
.attr('y', pillYOff)
|
|
1645
|
+
.attr('width', item.pillWidth)
|
|
1646
|
+
.attr('height', pillH)
|
|
1647
|
+
.attr('rx', pillH / 2)
|
|
1648
|
+
.attr('fill', item.isActive ? palette.bg : groupBg);
|
|
1649
|
+
|
|
1650
|
+
// Active pill border
|
|
1651
|
+
if (item.isActive) {
|
|
1652
|
+
gEl
|
|
1653
|
+
.append('rect')
|
|
1654
|
+
.attr('x', pillXOff)
|
|
1655
|
+
.attr('y', pillYOff)
|
|
1656
|
+
.attr('width', item.pillWidth)
|
|
1657
|
+
.attr('height', pillH)
|
|
1658
|
+
.attr('rx', pillH / 2)
|
|
1659
|
+
.attr('fill', 'none')
|
|
1660
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
1661
|
+
.attr('stroke-width', 0.75);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Pill text
|
|
1665
|
+
gEl
|
|
1666
|
+
.append('text')
|
|
1667
|
+
.attr('x', pillXOff + item.pillWidth / 2)
|
|
1668
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1669
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1670
|
+
.attr('font-weight', '500')
|
|
1671
|
+
.attr('fill', item.isActive ? palette.text : palette.textMuted)
|
|
1672
|
+
.attr('text-anchor', 'middle')
|
|
1673
|
+
.text(item.group.name);
|
|
1674
|
+
|
|
1675
|
+
// Entries inside capsule (active only)
|
|
1676
|
+
if (item.isActive) {
|
|
1677
|
+
let entryX = pillXOff + item.pillWidth + 4;
|
|
1678
|
+
for (const entry of item.entries) {
|
|
1679
|
+
const entryG = gEl
|
|
1680
|
+
.append('g')
|
|
1681
|
+
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
1682
|
+
.style('cursor', 'pointer');
|
|
1683
|
+
|
|
1684
|
+
entryG
|
|
1685
|
+
.append('circle')
|
|
1686
|
+
.attr('cx', entryX + LEGEND_DOT_R)
|
|
1687
|
+
.attr('cy', LEGEND_HEIGHT / 2)
|
|
1688
|
+
.attr('r', LEGEND_DOT_R)
|
|
1689
|
+
.attr('fill', entry.color);
|
|
1690
|
+
|
|
1691
|
+
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
1692
|
+
entryG
|
|
1693
|
+
.append('text')
|
|
1694
|
+
.attr('x', textX)
|
|
1695
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
1696
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
1697
|
+
.attr('fill', palette.textMuted)
|
|
1698
|
+
.text(entry.value);
|
|
1699
|
+
|
|
1700
|
+
entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
legendX += item.totalWidth + LEGEND_GROUP_GAP;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1358
1708
|
// Render group boxes (behind participant shapes)
|
|
1359
1709
|
for (const group of groups) {
|
|
1360
1710
|
if (group.participantIds.length === 0) continue;
|
|
@@ -1373,16 +1723,15 @@ export function renderSequenceDiagram(
|
|
|
1373
1723
|
const boxH =
|
|
1374
1724
|
PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
|
|
1375
1725
|
|
|
1376
|
-
// Group box background
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
? mix(resolvedGroupColor, isDark ? palette.surface : palette.bg, 10)
|
|
1726
|
+
// Group box background — use tag color if group has metadata for the active tag group
|
|
1727
|
+
const groupTagValue = tagKey && group.metadata?.[tagKey];
|
|
1728
|
+
const groupTagColor = getTagColor(groupTagValue || undefined);
|
|
1729
|
+
const fillColor = groupTagColor
|
|
1730
|
+
? mix(groupTagColor, isDark ? palette.surface : palette.bg, isDark ? 15 : 20)
|
|
1382
1731
|
: isDark
|
|
1383
1732
|
? palette.surface
|
|
1384
1733
|
: palette.bg;
|
|
1385
|
-
const strokeColor =
|
|
1734
|
+
const strokeColor = groupTagColor || palette.textMuted;
|
|
1386
1735
|
|
|
1387
1736
|
svg
|
|
1388
1737
|
.append('rect')
|
|
@@ -1418,19 +1767,29 @@ export function renderSequenceDiagram(
|
|
|
1418
1767
|
const cx = offsetX + index * PARTICIPANT_GAP;
|
|
1419
1768
|
const cy = participantStartY;
|
|
1420
1769
|
|
|
1421
|
-
|
|
1770
|
+
const pTagValue = tagMap?.participants.get(participant.id);
|
|
1771
|
+
const pTagColor = getTagColor(pTagValue);
|
|
1772
|
+
const pTagAttr =
|
|
1773
|
+
tagKey && pTagValue
|
|
1774
|
+
? { key: tagKey, value: pTagValue.toLowerCase() }
|
|
1775
|
+
: undefined;
|
|
1776
|
+
renderParticipant(svg, participant, cx, cy, palette, isDark, pTagColor, pTagAttr);
|
|
1422
1777
|
|
|
1423
1778
|
// Render lifeline
|
|
1424
|
-
svg
|
|
1779
|
+
const lifelineEl = svg
|
|
1425
1780
|
.append('line')
|
|
1426
1781
|
.attr('x1', cx)
|
|
1427
1782
|
.attr('y1', lifelineStartY)
|
|
1428
1783
|
.attr('x2', cx)
|
|
1429
1784
|
.attr('y2', lifelineStartY + lifelineLength)
|
|
1430
|
-
.attr('stroke', palette.textMuted)
|
|
1785
|
+
.attr('stroke', pTagColor || palette.textMuted)
|
|
1431
1786
|
.attr('stroke-width', 1)
|
|
1432
1787
|
.attr('stroke-dasharray', '6 4')
|
|
1433
|
-
.attr('class', 'lifeline')
|
|
1788
|
+
.attr('class', 'lifeline')
|
|
1789
|
+
.attr('data-participant-id', participant.id);
|
|
1790
|
+
if (tagKey && pTagValue) {
|
|
1791
|
+
lifelineEl.attr(`data-tag-${tagKey}`, pTagValue.toLowerCase());
|
|
1792
|
+
}
|
|
1434
1793
|
});
|
|
1435
1794
|
|
|
1436
1795
|
// Render block frames (behind everything else)
|
|
@@ -1650,6 +2009,14 @@ export function renderSequenceDiagram(
|
|
|
1650
2009
|
if (msg) coveredLines.push(msg.lineNumber);
|
|
1651
2010
|
}
|
|
1652
2011
|
|
|
2012
|
+
// Determine activation color from triggering message's tag
|
|
2013
|
+
const triggerMsg = messages[renderSteps[act.startStep]?.messageIndex];
|
|
2014
|
+
const actTagValue = triggerMsg
|
|
2015
|
+
? tagMap?.messages.get(triggerMsg.lineNumber)
|
|
2016
|
+
: undefined;
|
|
2017
|
+
const actTagColor = getTagColor(actTagValue);
|
|
2018
|
+
const actBaseColor = actTagColor || palette.primary;
|
|
2019
|
+
|
|
1653
2020
|
// Opaque background to mask the lifeline
|
|
1654
2021
|
svg
|
|
1655
2022
|
.append('rect')
|
|
@@ -1659,21 +2026,24 @@ export function renderSequenceDiagram(
|
|
|
1659
2026
|
.attr('height', y2 - y1)
|
|
1660
2027
|
.attr('fill', isDark ? palette.surface : palette.bg);
|
|
1661
2028
|
|
|
1662
|
-
const actFill = mix(
|
|
1663
|
-
svg
|
|
2029
|
+
const actFill = mix(actBaseColor, isDark ? palette.surface : palette.bg, isDark ? 15 : 30);
|
|
2030
|
+
const actRect = svg
|
|
1664
2031
|
.append('rect')
|
|
1665
2032
|
.attr('x', x)
|
|
1666
2033
|
.attr('y', y1)
|
|
1667
2034
|
.attr('width', ACTIVATION_WIDTH)
|
|
1668
2035
|
.attr('height', y2 - y1)
|
|
1669
2036
|
.attr('fill', actFill)
|
|
1670
|
-
.attr('stroke',
|
|
2037
|
+
.attr('stroke', actBaseColor)
|
|
1671
2038
|
.attr('stroke-width', 1)
|
|
1672
2039
|
.attr('stroke-opacity', 0.5)
|
|
1673
2040
|
.attr('data-participant-id', act.participantId)
|
|
1674
2041
|
.attr('data-msg-lines', coveredLines.join(','))
|
|
1675
2042
|
.attr('data-line-number', coveredLines[0] ?? '')
|
|
1676
2043
|
.attr('class', 'activation');
|
|
2044
|
+
if (tagKey && actTagValue) {
|
|
2045
|
+
actRect.attr(`data-tag-${tagKey}`, actTagValue.toLowerCase());
|
|
2046
|
+
}
|
|
1677
2047
|
});
|
|
1678
2048
|
|
|
1679
2049
|
// Render deferred else dividers (on top of activations)
|
|
@@ -1748,9 +2118,7 @@ export function renderSequenceDiagram(
|
|
|
1748
2118
|
if (secY === undefined) continue;
|
|
1749
2119
|
|
|
1750
2120
|
const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;
|
|
1751
|
-
const lineColor =
|
|
1752
|
-
? resolveColor(sec.color, palette)
|
|
1753
|
-
: palette.textMuted;
|
|
2121
|
+
const lineColor = palette.textMuted;
|
|
1754
2122
|
|
|
1755
2123
|
// Wrap section elements in a <g> for toggle.
|
|
1756
2124
|
// IMPORTANT: only the <g> carries data-line-number / data-section —
|
|
@@ -1843,27 +2211,54 @@ export function renderSequenceDiagram(
|
|
|
1843
2211
|
|
|
1844
2212
|
const y = stepY(i);
|
|
1845
2213
|
|
|
2214
|
+
const HIT_H = 20; // transparent hit area height (10px above + below arrow)
|
|
2215
|
+
|
|
2216
|
+
// Resolve tag color for this message
|
|
2217
|
+
const msg = messages[step.messageIndex];
|
|
2218
|
+
const msgTagValue = msg ? tagMap?.messages.get(msg.lineNumber) : undefined;
|
|
2219
|
+
const msgTagColor = getTagColor(msgTagValue);
|
|
2220
|
+
|
|
1846
2221
|
if (step.type === 'call') {
|
|
2222
|
+
const arrowColor = msgTagColor || palette.text;
|
|
2223
|
+
|
|
1847
2224
|
if (step.from === step.to) {
|
|
1848
2225
|
// Self-call: loopback arrow from right edge of activation
|
|
1849
2226
|
const x = arrowEdgeX(step.from, i, 'right');
|
|
1850
|
-
|
|
2227
|
+
|
|
2228
|
+
// Hit area for self-call
|
|
2229
|
+
svg.append('rect')
|
|
2230
|
+
.attr('x', x)
|
|
2231
|
+
.attr('y', y - 5)
|
|
2232
|
+
.attr('width', SELF_CALL_WIDTH)
|
|
2233
|
+
.attr('height', SELF_CALL_HEIGHT + 10)
|
|
2234
|
+
.attr('fill', 'transparent')
|
|
2235
|
+
.attr('class', 'message-hit-area')
|
|
2236
|
+
.attr('data-line-number', String(messages[step.messageIndex].lineNumber))
|
|
2237
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
2238
|
+
.attr('data-step-index', String(i));
|
|
2239
|
+
|
|
2240
|
+
const selfCallEl = svg
|
|
1851
2241
|
.append('path')
|
|
1852
2242
|
.attr(
|
|
1853
2243
|
'd',
|
|
1854
2244
|
`M ${x} ${y} H ${x + SELF_CALL_WIDTH} V ${y + SELF_CALL_HEIGHT} H ${x}`
|
|
1855
2245
|
)
|
|
1856
2246
|
.attr('fill', 'none')
|
|
1857
|
-
.attr('stroke',
|
|
2247
|
+
.attr('stroke', arrowColor)
|
|
1858
2248
|
.attr('stroke-width', 1.2)
|
|
1859
|
-
.attr('marker-end',
|
|
2249
|
+
.attr('marker-end', coloredMarker('call', msgTagColor))
|
|
1860
2250
|
.attr('class', 'message-arrow self-call')
|
|
1861
2251
|
.attr(
|
|
1862
2252
|
'data-line-number',
|
|
1863
2253
|
String(messages[step.messageIndex].lineNumber)
|
|
1864
2254
|
)
|
|
1865
2255
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1866
|
-
.attr('data-step-index', String(i))
|
|
2256
|
+
.attr('data-step-index', String(i))
|
|
2257
|
+
.attr('data-from', step.from)
|
|
2258
|
+
.attr('data-to', step.to);
|
|
2259
|
+
if (tagKey && msgTagValue) {
|
|
2260
|
+
selfCallEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2261
|
+
}
|
|
1867
2262
|
|
|
1868
2263
|
if (step.label) {
|
|
1869
2264
|
const labelEl = svg
|
|
@@ -1871,7 +2266,7 @@ export function renderSequenceDiagram(
|
|
|
1871
2266
|
.attr('x', x + SELF_CALL_WIDTH + 5)
|
|
1872
2267
|
.attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
|
|
1873
2268
|
.attr('text-anchor', 'start')
|
|
1874
|
-
.attr('fill',
|
|
2269
|
+
.attr('fill', arrowColor)
|
|
1875
2270
|
.attr('font-size', 12)
|
|
1876
2271
|
.attr('class', 'message-label')
|
|
1877
2272
|
.attr(
|
|
@@ -1880,6 +2275,9 @@ export function renderSequenceDiagram(
|
|
|
1880
2275
|
)
|
|
1881
2276
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1882
2277
|
.attr('data-step-index', String(i));
|
|
2278
|
+
if (tagKey && msgTagValue) {
|
|
2279
|
+
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2280
|
+
}
|
|
1883
2281
|
renderInlineText(labelEl, step.label, palette);
|
|
1884
2282
|
}
|
|
1885
2283
|
} else {
|
|
@@ -1888,16 +2286,28 @@ export function renderSequenceDiagram(
|
|
|
1888
2286
|
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
1889
2287
|
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
1890
2288
|
|
|
2289
|
+
// Hit area for call arrow
|
|
2290
|
+
svg.append('rect')
|
|
2291
|
+
.attr('x', Math.min(x1, x2))
|
|
2292
|
+
.attr('y', y - HIT_H / 2)
|
|
2293
|
+
.attr('width', Math.abs(x2 - x1))
|
|
2294
|
+
.attr('height', HIT_H)
|
|
2295
|
+
.attr('fill', 'transparent')
|
|
2296
|
+
.attr('class', 'message-hit-area')
|
|
2297
|
+
.attr('data-line-number', String(messages[step.messageIndex].lineNumber))
|
|
2298
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
2299
|
+
.attr('data-step-index', String(i));
|
|
2300
|
+
|
|
1891
2301
|
const markerRef = step.async
|
|
1892
|
-
? '
|
|
1893
|
-
: '
|
|
1894
|
-
svg
|
|
2302
|
+
? coloredMarker('async', msgTagColor)
|
|
2303
|
+
: coloredMarker('call', msgTagColor);
|
|
2304
|
+
const arrowEl = svg
|
|
1895
2305
|
.append('line')
|
|
1896
2306
|
.attr('x1', x1)
|
|
1897
2307
|
.attr('y1', y)
|
|
1898
2308
|
.attr('x2', x2)
|
|
1899
2309
|
.attr('y2', y)
|
|
1900
|
-
.attr('stroke',
|
|
2310
|
+
.attr('stroke', arrowColor)
|
|
1901
2311
|
.attr('stroke-width', 1.2)
|
|
1902
2312
|
.attr('marker-end', markerRef)
|
|
1903
2313
|
.attr('class', 'message-arrow')
|
|
@@ -1906,7 +2316,12 @@ export function renderSequenceDiagram(
|
|
|
1906
2316
|
String(messages[step.messageIndex].lineNumber)
|
|
1907
2317
|
)
|
|
1908
2318
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1909
|
-
.attr('data-step-index', String(i))
|
|
2319
|
+
.attr('data-step-index', String(i))
|
|
2320
|
+
.attr('data-from', step.from)
|
|
2321
|
+
.attr('data-to', step.to);
|
|
2322
|
+
if (tagKey && msgTagValue) {
|
|
2323
|
+
arrowEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2324
|
+
}
|
|
1910
2325
|
|
|
1911
2326
|
if (step.label) {
|
|
1912
2327
|
const midX = (x1 + x2) / 2;
|
|
@@ -1915,7 +2330,7 @@ export function renderSequenceDiagram(
|
|
|
1915
2330
|
.attr('x', midX)
|
|
1916
2331
|
.attr('y', y - 8)
|
|
1917
2332
|
.attr('text-anchor', 'middle')
|
|
1918
|
-
.attr('fill',
|
|
2333
|
+
.attr('fill', arrowColor)
|
|
1919
2334
|
.attr('font-size', 12)
|
|
1920
2335
|
.attr('class', 'message-label')
|
|
1921
2336
|
.attr(
|
|
@@ -1924,6 +2339,9 @@ export function renderSequenceDiagram(
|
|
|
1924
2339
|
)
|
|
1925
2340
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1926
2341
|
.attr('data-step-index', String(i));
|
|
2342
|
+
if (tagKey && msgTagValue) {
|
|
2343
|
+
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2344
|
+
}
|
|
1927
2345
|
renderInlineText(labelEl, step.label, palette);
|
|
1928
2346
|
}
|
|
1929
2347
|
}
|
|
@@ -1936,24 +2354,42 @@ export function renderSequenceDiagram(
|
|
|
1936
2354
|
const goingRight = fromX < toX;
|
|
1937
2355
|
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
1938
2356
|
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
2357
|
+
const returnColor = msgTagColor || palette.textMuted;
|
|
2358
|
+
|
|
2359
|
+
// Hit area for return arrow
|
|
2360
|
+
svg.append('rect')
|
|
2361
|
+
.attr('x', Math.min(x1, x2))
|
|
2362
|
+
.attr('y', y - HIT_H / 2)
|
|
2363
|
+
.attr('width', Math.abs(x2 - x1))
|
|
2364
|
+
.attr('height', HIT_H)
|
|
2365
|
+
.attr('fill', 'transparent')
|
|
2366
|
+
.attr('class', 'message-hit-area')
|
|
2367
|
+
.attr('data-line-number', String(messages[step.messageIndex].lineNumber))
|
|
2368
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
2369
|
+
.attr('data-step-index', String(i));
|
|
1939
2370
|
|
|
1940
|
-
svg
|
|
2371
|
+
const returnEl = svg
|
|
1941
2372
|
.append('line')
|
|
1942
2373
|
.attr('x1', x1)
|
|
1943
2374
|
.attr('y1', y)
|
|
1944
2375
|
.attr('x2', x2)
|
|
1945
2376
|
.attr('y2', y)
|
|
1946
|
-
.attr('stroke',
|
|
2377
|
+
.attr('stroke', returnColor)
|
|
1947
2378
|
.attr('stroke-width', 1)
|
|
1948
2379
|
.attr('stroke-dasharray', '6 4')
|
|
1949
|
-
.attr('marker-end',
|
|
2380
|
+
.attr('marker-end', coloredMarker('return', msgTagColor))
|
|
1950
2381
|
.attr('class', 'return-arrow')
|
|
1951
2382
|
.attr(
|
|
1952
2383
|
'data-line-number',
|
|
1953
2384
|
String(messages[step.messageIndex].lineNumber)
|
|
1954
2385
|
)
|
|
1955
2386
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1956
|
-
.attr('data-step-index', String(i))
|
|
2387
|
+
.attr('data-step-index', String(i))
|
|
2388
|
+
.attr('data-from', step.from)
|
|
2389
|
+
.attr('data-to', step.to);
|
|
2390
|
+
if (tagKey && msgTagValue) {
|
|
2391
|
+
returnEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2392
|
+
}
|
|
1957
2393
|
|
|
1958
2394
|
if (step.label) {
|
|
1959
2395
|
const midX = (x1 + x2) / 2;
|
|
@@ -1962,7 +2398,7 @@ export function renderSequenceDiagram(
|
|
|
1962
2398
|
.attr('x', midX)
|
|
1963
2399
|
.attr('y', y - 6)
|
|
1964
2400
|
.attr('text-anchor', 'middle')
|
|
1965
|
-
.attr('fill',
|
|
2401
|
+
.attr('fill', returnColor)
|
|
1966
2402
|
.attr('font-size', 11)
|
|
1967
2403
|
.attr('class', 'message-label')
|
|
1968
2404
|
.attr(
|
|
@@ -1971,6 +2407,9 @@ export function renderSequenceDiagram(
|
|
|
1971
2407
|
)
|
|
1972
2408
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1973
2409
|
.attr('data-step-index', String(i));
|
|
2410
|
+
if (tagKey && msgTagValue) {
|
|
2411
|
+
labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
|
|
2412
|
+
}
|
|
1974
2413
|
renderInlineText(labelEl, step.label, palette);
|
|
1975
2414
|
}
|
|
1976
2415
|
}
|
|
@@ -2194,7 +2633,9 @@ function renderParticipant(
|
|
|
2194
2633
|
cx: number,
|
|
2195
2634
|
cy: number,
|
|
2196
2635
|
palette: PaletteColors,
|
|
2197
|
-
isDark: boolean
|
|
2636
|
+
isDark: boolean,
|
|
2637
|
+
color?: string,
|
|
2638
|
+
tagAttr?: { key: string; value: string },
|
|
2198
2639
|
): void {
|
|
2199
2640
|
const g = svg
|
|
2200
2641
|
.append('g')
|
|
@@ -2202,51 +2643,73 @@ function renderParticipant(
|
|
|
2202
2643
|
.attr('class', 'participant')
|
|
2203
2644
|
.attr('data-participant-id', participant.id);
|
|
2204
2645
|
|
|
2646
|
+
// Set data-tag attribute for legend hover dimming
|
|
2647
|
+
if (tagAttr) {
|
|
2648
|
+
g.attr(`data-tag-${tagAttr.key}`, tagAttr.value);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2205
2651
|
// Render shape based on type
|
|
2206
2652
|
switch (participant.type) {
|
|
2207
2653
|
case 'actor':
|
|
2208
|
-
renderActorParticipant(g, palette);
|
|
2654
|
+
renderActorParticipant(g, palette, color);
|
|
2209
2655
|
break;
|
|
2210
2656
|
case 'database':
|
|
2211
|
-
renderDatabaseParticipant(g, palette, isDark);
|
|
2657
|
+
renderDatabaseParticipant(g, palette, isDark, color);
|
|
2212
2658
|
break;
|
|
2213
2659
|
case 'service':
|
|
2214
|
-
renderServiceParticipant(g, palette, isDark);
|
|
2660
|
+
renderServiceParticipant(g, palette, isDark, color);
|
|
2215
2661
|
break;
|
|
2216
2662
|
case 'queue':
|
|
2217
|
-
renderQueueParticipant(g, palette, isDark);
|
|
2663
|
+
renderQueueParticipant(g, palette, isDark, color);
|
|
2218
2664
|
break;
|
|
2219
2665
|
case 'cache':
|
|
2220
|
-
renderCacheParticipant(g, palette, isDark);
|
|
2666
|
+
renderCacheParticipant(g, palette, isDark, color);
|
|
2221
2667
|
break;
|
|
2222
2668
|
case 'networking':
|
|
2223
|
-
renderNetworkingParticipant(g, palette, isDark);
|
|
2669
|
+
renderNetworkingParticipant(g, palette, isDark, color);
|
|
2224
2670
|
break;
|
|
2225
2671
|
case 'frontend':
|
|
2226
|
-
renderFrontendParticipant(g, palette, isDark);
|
|
2672
|
+
renderFrontendParticipant(g, palette, isDark, color);
|
|
2227
2673
|
break;
|
|
2228
2674
|
case 'external':
|
|
2229
|
-
renderExternalParticipant(g, palette, isDark);
|
|
2675
|
+
renderExternalParticipant(g, palette, isDark, color);
|
|
2230
2676
|
break;
|
|
2231
2677
|
case 'gateway':
|
|
2232
|
-
renderGatewayParticipant(g, palette, isDark);
|
|
2678
|
+
renderGatewayParticipant(g, palette, isDark, color);
|
|
2233
2679
|
break;
|
|
2234
2680
|
default:
|
|
2235
|
-
renderRectParticipant(g, palette, isDark);
|
|
2681
|
+
renderRectParticipant(g, palette, isDark, color);
|
|
2236
2682
|
break;
|
|
2237
2683
|
}
|
|
2238
2684
|
|
|
2239
2685
|
// Render label — below the shape for actors, centered inside for others
|
|
2240
2686
|
const isActor = participant.type === 'actor';
|
|
2241
|
-
|
|
2687
|
+
const labelLines = splitParticipantLabel(participant.label);
|
|
2688
|
+
const fontSize = 13;
|
|
2689
|
+
const lineHeight = fontSize + 2;
|
|
2690
|
+
const textEl = g.append('text')
|
|
2242
2691
|
.attr('x', 0)
|
|
2243
|
-
.attr(
|
|
2244
|
-
'y',
|
|
2245
|
-
isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5
|
|
2246
|
-
)
|
|
2247
2692
|
.attr('text-anchor', 'middle')
|
|
2248
2693
|
.attr('fill', palette.text)
|
|
2249
|
-
.attr('font-size',
|
|
2250
|
-
.attr('font-weight', 500)
|
|
2251
|
-
|
|
2694
|
+
.attr('font-size', fontSize)
|
|
2695
|
+
.attr('font-weight', 500);
|
|
2696
|
+
|
|
2697
|
+
if (labelLines.length === 1) {
|
|
2698
|
+
textEl
|
|
2699
|
+
.attr('y', isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5)
|
|
2700
|
+
.text(participant.label);
|
|
2701
|
+
} else {
|
|
2702
|
+
// Multi-line: vertically center the lines within the box (or below for actors)
|
|
2703
|
+
const totalHeight = labelLines.length * lineHeight;
|
|
2704
|
+
const baseY = isActor
|
|
2705
|
+
? PARTICIPANT_BOX_HEIGHT + 14 - ((labelLines.length - 1) * lineHeight) / 2
|
|
2706
|
+
: PARTICIPANT_BOX_HEIGHT / 2 + 5 - (totalHeight - lineHeight) / 2;
|
|
2707
|
+
|
|
2708
|
+
labelLines.forEach((line, i) => {
|
|
2709
|
+
textEl.append('tspan')
|
|
2710
|
+
.attr('x', 0)
|
|
2711
|
+
.attr('dy', i === 0 ? `${baseY}px` : `${lineHeight}px`)
|
|
2712
|
+
.text(line);
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2252
2715
|
}
|