@diagrammo/dgmo 0.0.1
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/LICENSE +21 -0
- package/README.md +335 -0
- package/dist/index.cjs +6698 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +685 -0
- package/dist/index.d.ts +685 -0
- package/dist/index.js +6611 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/chartjs.ts +784 -0
- package/src/colors.ts +75 -0
- package/src/d3.ts +5021 -0
- package/src/dgmo-mermaid.ts +247 -0
- package/src/dgmo-router.ts +77 -0
- package/src/echarts.ts +1207 -0
- package/src/index.ts +126 -0
- package/src/palettes/bold.ts +59 -0
- package/src/palettes/catppuccin.ts +76 -0
- package/src/palettes/color-utils.ts +191 -0
- package/src/palettes/gruvbox.ts +77 -0
- package/src/palettes/index.ts +35 -0
- package/src/palettes/mermaid-bridge.ts +220 -0
- package/src/palettes/nord.ts +59 -0
- package/src/palettes/one-dark.ts +62 -0
- package/src/palettes/registry.ts +92 -0
- package/src/palettes/rose-pine.ts +76 -0
- package/src/palettes/solarized.ts +69 -0
- package/src/palettes/tokyo-night.ts +78 -0
- package/src/palettes/types.ts +67 -0
- package/src/sequence/parser.ts +531 -0
- package/src/sequence/participant-inference.ts +178 -0
- package/src/sequence/renderer.ts +1487 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Sequence Diagram SVG Renderer
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Selection from 'd3-selection';
|
|
6
|
+
import type { PaletteColors } from '../palettes';
|
|
7
|
+
import { resolveColor } from '../colors';
|
|
8
|
+
import type {
|
|
9
|
+
ParsedSequenceDgmo,
|
|
10
|
+
SequenceElement,
|
|
11
|
+
SequenceGroup,
|
|
12
|
+
SequenceMessage,
|
|
13
|
+
SequenceParticipant,
|
|
14
|
+
} from './parser';
|
|
15
|
+
import { isSequenceBlock, isSequenceSection } from './parser';
|
|
16
|
+
|
|
17
|
+
// ============================================================
|
|
18
|
+
// Layout Constants
|
|
19
|
+
// ============================================================
|
|
20
|
+
|
|
21
|
+
const PARTICIPANT_GAP = 160;
|
|
22
|
+
const PARTICIPANT_BOX_WIDTH = 120;
|
|
23
|
+
const PARTICIPANT_BOX_HEIGHT = 50;
|
|
24
|
+
const TOP_MARGIN = 20;
|
|
25
|
+
const TITLE_HEIGHT = 30;
|
|
26
|
+
const PARTICIPANT_Y_OFFSET = 10;
|
|
27
|
+
const SERVICE_BORDER_RADIUS = 10;
|
|
28
|
+
const MESSAGE_START_OFFSET = 30;
|
|
29
|
+
const LIFELINE_TAIL = 30;
|
|
30
|
+
const ARROWHEAD_SIZE = 8;
|
|
31
|
+
|
|
32
|
+
// Shared fill/stroke helpers
|
|
33
|
+
const fill = (palette: PaletteColors, isDark: boolean): string =>
|
|
34
|
+
`color-mix(in srgb, ${palette.primary} ${isDark ? '15%' : '30%'}, ${isDark ? palette.surface : palette.bg})`;
|
|
35
|
+
const stroke = (palette: PaletteColors): string => palette.textMuted;
|
|
36
|
+
const SW = 1.5;
|
|
37
|
+
const W = PARTICIPANT_BOX_WIDTH;
|
|
38
|
+
const H = PARTICIPANT_BOX_HEIGHT;
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// Participant Shape Renderers
|
|
42
|
+
// ============================================================
|
|
43
|
+
|
|
44
|
+
function renderRectParticipant(
|
|
45
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
46
|
+
palette: PaletteColors,
|
|
47
|
+
isDark: boolean
|
|
48
|
+
): void {
|
|
49
|
+
g.append('rect')
|
|
50
|
+
.attr('x', -W / 2)
|
|
51
|
+
.attr('y', 0)
|
|
52
|
+
.attr('width', W)
|
|
53
|
+
.attr('height', H)
|
|
54
|
+
.attr('rx', 2)
|
|
55
|
+
.attr('ry', 2)
|
|
56
|
+
.attr('fill', fill(palette, isDark))
|
|
57
|
+
.attr('stroke', stroke(palette))
|
|
58
|
+
.attr('stroke-width', SW);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderServiceParticipant(
|
|
62
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
63
|
+
palette: PaletteColors,
|
|
64
|
+
isDark: boolean
|
|
65
|
+
): void {
|
|
66
|
+
g.append('rect')
|
|
67
|
+
.attr('x', -W / 2)
|
|
68
|
+
.attr('y', 0)
|
|
69
|
+
.attr('width', W)
|
|
70
|
+
.attr('height', H)
|
|
71
|
+
.attr('rx', SERVICE_BORDER_RADIUS)
|
|
72
|
+
.attr('ry', SERVICE_BORDER_RADIUS)
|
|
73
|
+
.attr('fill', fill(palette, isDark))
|
|
74
|
+
.attr('stroke', stroke(palette))
|
|
75
|
+
.attr('stroke-width', SW);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderActorParticipant(
|
|
79
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
80
|
+
palette: PaletteColors
|
|
81
|
+
): void {
|
|
82
|
+
// Stick figure — no background, natural proportions
|
|
83
|
+
const headR = 8;
|
|
84
|
+
const cx = 0;
|
|
85
|
+
const headY = headR + 2;
|
|
86
|
+
const bodyTopY = headY + headR + 1;
|
|
87
|
+
const bodyBottomY = H * 0.65;
|
|
88
|
+
const legY = H - 2;
|
|
89
|
+
const armSpan = 16;
|
|
90
|
+
const legSpan = 12;
|
|
91
|
+
const s = stroke(palette);
|
|
92
|
+
const actorSW = 2.5;
|
|
93
|
+
|
|
94
|
+
g.append('circle')
|
|
95
|
+
.attr('cx', cx)
|
|
96
|
+
.attr('cy', headY)
|
|
97
|
+
.attr('r', headR)
|
|
98
|
+
.attr('fill', 'none')
|
|
99
|
+
.attr('stroke', s)
|
|
100
|
+
.attr('stroke-width', actorSW);
|
|
101
|
+
|
|
102
|
+
g.append('line')
|
|
103
|
+
.attr('x1', cx)
|
|
104
|
+
.attr('y1', bodyTopY)
|
|
105
|
+
.attr('x2', cx)
|
|
106
|
+
.attr('y2', bodyBottomY)
|
|
107
|
+
.attr('stroke', s)
|
|
108
|
+
.attr('stroke-width', actorSW);
|
|
109
|
+
|
|
110
|
+
g.append('line')
|
|
111
|
+
.attr('x1', cx - armSpan)
|
|
112
|
+
.attr('y1', bodyTopY + 5)
|
|
113
|
+
.attr('x2', cx + armSpan)
|
|
114
|
+
.attr('y2', bodyTopY + 5)
|
|
115
|
+
.attr('stroke', s)
|
|
116
|
+
.attr('stroke-width', actorSW);
|
|
117
|
+
|
|
118
|
+
g.append('line')
|
|
119
|
+
.attr('x1', cx)
|
|
120
|
+
.attr('y1', bodyBottomY)
|
|
121
|
+
.attr('x2', cx - legSpan)
|
|
122
|
+
.attr('y2', legY)
|
|
123
|
+
.attr('stroke', s)
|
|
124
|
+
.attr('stroke-width', actorSW);
|
|
125
|
+
|
|
126
|
+
g.append('line')
|
|
127
|
+
.attr('x1', cx)
|
|
128
|
+
.attr('y1', bodyBottomY)
|
|
129
|
+
.attr('x2', cx + legSpan)
|
|
130
|
+
.attr('y2', legY)
|
|
131
|
+
.attr('stroke', s)
|
|
132
|
+
.attr('stroke-width', actorSW);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderDatabaseParticipant(
|
|
136
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
137
|
+
palette: PaletteColors,
|
|
138
|
+
isDark: boolean
|
|
139
|
+
): void {
|
|
140
|
+
// Cylinder fitting within W x H
|
|
141
|
+
const ry = 7;
|
|
142
|
+
const topY = ry;
|
|
143
|
+
const bodyH = H - ry * 2;
|
|
144
|
+
const f = fill(palette, isDark);
|
|
145
|
+
const s = stroke(palette);
|
|
146
|
+
|
|
147
|
+
// Bottom ellipse (drawn first — rect will cover its top arc)
|
|
148
|
+
g.append('ellipse')
|
|
149
|
+
.attr('cx', 0)
|
|
150
|
+
.attr('cy', topY + bodyH)
|
|
151
|
+
.attr('rx', W / 2)
|
|
152
|
+
.attr('ry', ry)
|
|
153
|
+
.attr('fill', f)
|
|
154
|
+
.attr('stroke', s)
|
|
155
|
+
.attr('stroke-width', SW);
|
|
156
|
+
|
|
157
|
+
// Filled body (no stroke) to hide the top arc of the bottom ellipse
|
|
158
|
+
g.append('rect')
|
|
159
|
+
.attr('x', -W / 2)
|
|
160
|
+
.attr('y', topY)
|
|
161
|
+
.attr('width', W)
|
|
162
|
+
.attr('height', bodyH)
|
|
163
|
+
.attr('fill', f)
|
|
164
|
+
.attr('stroke', 'none');
|
|
165
|
+
|
|
166
|
+
// Side lines
|
|
167
|
+
g.append('line')
|
|
168
|
+
.attr('x1', -W / 2)
|
|
169
|
+
.attr('y1', topY)
|
|
170
|
+
.attr('x2', -W / 2)
|
|
171
|
+
.attr('y2', topY + bodyH)
|
|
172
|
+
.attr('stroke', s)
|
|
173
|
+
.attr('stroke-width', SW);
|
|
174
|
+
g.append('line')
|
|
175
|
+
.attr('x1', W / 2)
|
|
176
|
+
.attr('y1', topY)
|
|
177
|
+
.attr('x2', W / 2)
|
|
178
|
+
.attr('y2', topY + bodyH)
|
|
179
|
+
.attr('stroke', s)
|
|
180
|
+
.attr('stroke-width', SW);
|
|
181
|
+
|
|
182
|
+
// Top ellipse cap (drawn last, on top)
|
|
183
|
+
g.append('ellipse')
|
|
184
|
+
.attr('cx', 0)
|
|
185
|
+
.attr('cy', topY)
|
|
186
|
+
.attr('rx', W / 2)
|
|
187
|
+
.attr('ry', ry)
|
|
188
|
+
.attr('fill', f)
|
|
189
|
+
.attr('stroke', s)
|
|
190
|
+
.attr('stroke-width', SW);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderQueueParticipant(
|
|
194
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
195
|
+
palette: PaletteColors,
|
|
196
|
+
isDark: boolean
|
|
197
|
+
): void {
|
|
198
|
+
// Horizontal cylinder (pipe) — like database rotated 90 degrees
|
|
199
|
+
const rx = 10;
|
|
200
|
+
const leftX = -W / 2 + rx;
|
|
201
|
+
const bodyW = W - rx * 2;
|
|
202
|
+
const f = fill(palette, isDark);
|
|
203
|
+
const s = stroke(palette);
|
|
204
|
+
|
|
205
|
+
// Right ellipse (back face, drawn first — rect will cover its left arc)
|
|
206
|
+
g.append('ellipse')
|
|
207
|
+
.attr('cx', leftX + bodyW)
|
|
208
|
+
.attr('cy', H / 2)
|
|
209
|
+
.attr('rx', rx)
|
|
210
|
+
.attr('ry', H / 2)
|
|
211
|
+
.attr('fill', f)
|
|
212
|
+
.attr('stroke', s)
|
|
213
|
+
.attr('stroke-width', SW);
|
|
214
|
+
|
|
215
|
+
// Body rect (no stroke) to hide left arc of right ellipse
|
|
216
|
+
g.append('rect')
|
|
217
|
+
.attr('x', leftX)
|
|
218
|
+
.attr('y', 0)
|
|
219
|
+
.attr('width', bodyW)
|
|
220
|
+
.attr('height', H)
|
|
221
|
+
.attr('fill', f)
|
|
222
|
+
.attr('stroke', 'none');
|
|
223
|
+
|
|
224
|
+
// Top and bottom lines
|
|
225
|
+
g.append('line')
|
|
226
|
+
.attr('x1', leftX)
|
|
227
|
+
.attr('y1', 0)
|
|
228
|
+
.attr('x2', leftX + bodyW)
|
|
229
|
+
.attr('y2', 0)
|
|
230
|
+
.attr('stroke', s)
|
|
231
|
+
.attr('stroke-width', SW);
|
|
232
|
+
g.append('line')
|
|
233
|
+
.attr('x1', leftX)
|
|
234
|
+
.attr('y1', H)
|
|
235
|
+
.attr('x2', leftX + bodyW)
|
|
236
|
+
.attr('y2', H)
|
|
237
|
+
.attr('stroke', s)
|
|
238
|
+
.attr('stroke-width', SW);
|
|
239
|
+
|
|
240
|
+
// Left ellipse (front face, drawn last)
|
|
241
|
+
g.append('ellipse')
|
|
242
|
+
.attr('cx', leftX)
|
|
243
|
+
.attr('cy', H / 2)
|
|
244
|
+
.attr('rx', rx)
|
|
245
|
+
.attr('ry', H / 2)
|
|
246
|
+
.attr('fill', f)
|
|
247
|
+
.attr('stroke', s)
|
|
248
|
+
.attr('stroke-width', SW);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderCacheParticipant(
|
|
252
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
253
|
+
palette: PaletteColors,
|
|
254
|
+
isDark: boolean
|
|
255
|
+
): void {
|
|
256
|
+
// Dashed cylinder — variation of database to convey ephemeral storage
|
|
257
|
+
const ry = 7;
|
|
258
|
+
const topY = ry;
|
|
259
|
+
const bodyH = H - ry * 2;
|
|
260
|
+
const f = fill(palette, isDark);
|
|
261
|
+
const s = stroke(palette);
|
|
262
|
+
const dash = '4 3';
|
|
263
|
+
|
|
264
|
+
g.append('ellipse')
|
|
265
|
+
.attr('cx', 0)
|
|
266
|
+
.attr('cy', topY + bodyH)
|
|
267
|
+
.attr('rx', W / 2)
|
|
268
|
+
.attr('ry', ry)
|
|
269
|
+
.attr('fill', f)
|
|
270
|
+
.attr('stroke', s)
|
|
271
|
+
.attr('stroke-width', SW)
|
|
272
|
+
.attr('stroke-dasharray', dash);
|
|
273
|
+
|
|
274
|
+
g.append('rect')
|
|
275
|
+
.attr('x', -W / 2)
|
|
276
|
+
.attr('y', topY)
|
|
277
|
+
.attr('width', W)
|
|
278
|
+
.attr('height', bodyH)
|
|
279
|
+
.attr('fill', f)
|
|
280
|
+
.attr('stroke', 'none');
|
|
281
|
+
|
|
282
|
+
g.append('line')
|
|
283
|
+
.attr('x1', -W / 2)
|
|
284
|
+
.attr('y1', topY)
|
|
285
|
+
.attr('x2', -W / 2)
|
|
286
|
+
.attr('y2', topY + bodyH)
|
|
287
|
+
.attr('stroke', s)
|
|
288
|
+
.attr('stroke-width', SW)
|
|
289
|
+
.attr('stroke-dasharray', dash);
|
|
290
|
+
g.append('line')
|
|
291
|
+
.attr('x1', W / 2)
|
|
292
|
+
.attr('y1', topY)
|
|
293
|
+
.attr('x2', W / 2)
|
|
294
|
+
.attr('y2', topY + bodyH)
|
|
295
|
+
.attr('stroke', s)
|
|
296
|
+
.attr('stroke-width', SW)
|
|
297
|
+
.attr('stroke-dasharray', dash);
|
|
298
|
+
|
|
299
|
+
g.append('ellipse')
|
|
300
|
+
.attr('cx', 0)
|
|
301
|
+
.attr('cy', topY)
|
|
302
|
+
.attr('rx', W / 2)
|
|
303
|
+
.attr('ry', ry)
|
|
304
|
+
.attr('fill', f)
|
|
305
|
+
.attr('stroke', s)
|
|
306
|
+
.attr('stroke-width', SW)
|
|
307
|
+
.attr('stroke-dasharray', dash);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderNetworkingParticipant(
|
|
311
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
312
|
+
palette: PaletteColors,
|
|
313
|
+
isDark: boolean
|
|
314
|
+
): void {
|
|
315
|
+
// Hexagon fitting within W x H
|
|
316
|
+
const inset = 16;
|
|
317
|
+
const points = [
|
|
318
|
+
`${-W / 2 + inset},0`,
|
|
319
|
+
`${W / 2 - inset},0`,
|
|
320
|
+
`${W / 2},${H / 2}`,
|
|
321
|
+
`${W / 2 - inset},${H}`,
|
|
322
|
+
`${-W / 2 + inset},${H}`,
|
|
323
|
+
`${-W / 2},${H / 2}`,
|
|
324
|
+
].join(' ');
|
|
325
|
+
g.append('polygon')
|
|
326
|
+
.attr('points', points)
|
|
327
|
+
.attr('fill', fill(palette, isDark))
|
|
328
|
+
.attr('stroke', stroke(palette))
|
|
329
|
+
.attr('stroke-width', SW);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderFrontendParticipant(
|
|
333
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
334
|
+
palette: PaletteColors,
|
|
335
|
+
isDark: boolean
|
|
336
|
+
): void {
|
|
337
|
+
// Monitor shape fitting within W x H
|
|
338
|
+
const screenH = H - 10;
|
|
339
|
+
const s = stroke(palette);
|
|
340
|
+
g.append('rect')
|
|
341
|
+
.attr('x', -W / 2)
|
|
342
|
+
.attr('y', 0)
|
|
343
|
+
.attr('width', W)
|
|
344
|
+
.attr('height', screenH)
|
|
345
|
+
.attr('rx', 3)
|
|
346
|
+
.attr('ry', 3)
|
|
347
|
+
.attr('fill', fill(palette, isDark))
|
|
348
|
+
.attr('stroke', s)
|
|
349
|
+
.attr('stroke-width', SW);
|
|
350
|
+
// Stand
|
|
351
|
+
g.append('line')
|
|
352
|
+
.attr('x1', 0)
|
|
353
|
+
.attr('y1', screenH)
|
|
354
|
+
.attr('x2', 0)
|
|
355
|
+
.attr('y2', H - 2)
|
|
356
|
+
.attr('stroke', s)
|
|
357
|
+
.attr('stroke-width', SW);
|
|
358
|
+
// Base
|
|
359
|
+
g.append('line')
|
|
360
|
+
.attr('x1', -14)
|
|
361
|
+
.attr('y1', H - 2)
|
|
362
|
+
.attr('x2', 14)
|
|
363
|
+
.attr('y2', H - 2)
|
|
364
|
+
.attr('stroke', s)
|
|
365
|
+
.attr('stroke-width', SW);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderExternalParticipant(
|
|
369
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
370
|
+
palette: PaletteColors,
|
|
371
|
+
isDark: boolean
|
|
372
|
+
): void {
|
|
373
|
+
// Dashed border rectangle
|
|
374
|
+
g.append('rect')
|
|
375
|
+
.attr('x', -W / 2)
|
|
376
|
+
.attr('y', 0)
|
|
377
|
+
.attr('width', W)
|
|
378
|
+
.attr('height', H)
|
|
379
|
+
.attr('rx', 2)
|
|
380
|
+
.attr('ry', 2)
|
|
381
|
+
.attr('fill', fill(palette, isDark))
|
|
382
|
+
.attr('stroke', stroke(palette))
|
|
383
|
+
.attr('stroke-width', SW)
|
|
384
|
+
.attr('stroke-dasharray', '6 3');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderGatewayParticipant(
|
|
388
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
389
|
+
palette: PaletteColors,
|
|
390
|
+
isDark: boolean
|
|
391
|
+
): void {
|
|
392
|
+
renderRectParticipant(g, palette, isDark);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================================
|
|
396
|
+
// Render Sequence Builder (stack-based return placement)
|
|
397
|
+
// ============================================================
|
|
398
|
+
|
|
399
|
+
export interface RenderStep {
|
|
400
|
+
type: 'call' | 'return';
|
|
401
|
+
from: string;
|
|
402
|
+
to: string;
|
|
403
|
+
label: string;
|
|
404
|
+
messageIndex: number;
|
|
405
|
+
async?: boolean;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Build an ordered render sequence from flat messages.
|
|
410
|
+
* Uses a call stack to infer where returns should be placed:
|
|
411
|
+
* returns appear after all nested sub-calls complete.
|
|
412
|
+
*/
|
|
413
|
+
export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
|
|
414
|
+
const steps: RenderStep[] = [];
|
|
415
|
+
const stack: {
|
|
416
|
+
from: string;
|
|
417
|
+
to: string;
|
|
418
|
+
returnLabel?: string;
|
|
419
|
+
messageIndex: number;
|
|
420
|
+
}[] = [];
|
|
421
|
+
|
|
422
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
423
|
+
const msg = messages[mi];
|
|
424
|
+
// Pop returns for callees that are no longer the sender
|
|
425
|
+
while (stack.length > 0) {
|
|
426
|
+
const top = stack[stack.length - 1];
|
|
427
|
+
if (top.to === msg.from) break; // callee is still working
|
|
428
|
+
stack.pop();
|
|
429
|
+
steps.push({
|
|
430
|
+
type: 'return',
|
|
431
|
+
from: top.to,
|
|
432
|
+
to: top.from,
|
|
433
|
+
label: top.returnLabel || '',
|
|
434
|
+
messageIndex: top.messageIndex,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Emit call
|
|
439
|
+
steps.push({
|
|
440
|
+
type: 'call',
|
|
441
|
+
from: msg.from,
|
|
442
|
+
to: msg.to,
|
|
443
|
+
label: msg.label,
|
|
444
|
+
messageIndex: mi,
|
|
445
|
+
...(msg.async ? { async: true } : {}),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Async messages: no return arrow, no activation on target
|
|
449
|
+
if (msg.async) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (msg.from === msg.to) {
|
|
454
|
+
// Self-call: immediately emit return (completes instantly)
|
|
455
|
+
steps.push({
|
|
456
|
+
type: 'return',
|
|
457
|
+
from: msg.to,
|
|
458
|
+
to: msg.from,
|
|
459
|
+
label: msg.returnLabel || '',
|
|
460
|
+
messageIndex: mi,
|
|
461
|
+
});
|
|
462
|
+
} else {
|
|
463
|
+
// Push onto stack for pending return
|
|
464
|
+
stack.push({
|
|
465
|
+
from: msg.from,
|
|
466
|
+
to: msg.to,
|
|
467
|
+
returnLabel: msg.returnLabel,
|
|
468
|
+
messageIndex: mi,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Flush remaining returns
|
|
474
|
+
while (stack.length > 0) {
|
|
475
|
+
const top = stack.pop()!;
|
|
476
|
+
steps.push({
|
|
477
|
+
type: 'return',
|
|
478
|
+
from: top.to,
|
|
479
|
+
to: top.from,
|
|
480
|
+
label: top.returnLabel || '',
|
|
481
|
+
messageIndex: top.messageIndex,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return steps;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ============================================================
|
|
489
|
+
// Activation Computation
|
|
490
|
+
// ============================================================
|
|
491
|
+
|
|
492
|
+
export interface Activation {
|
|
493
|
+
participantId: string;
|
|
494
|
+
startStep: number;
|
|
495
|
+
endStep: number;
|
|
496
|
+
depth: number;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Compute activation rectangles from render steps.
|
|
501
|
+
* Each call pushes onto the callee's stack; each return pops it.
|
|
502
|
+
*/
|
|
503
|
+
export function computeActivations(steps: RenderStep[]): Activation[] {
|
|
504
|
+
const activations: Activation[] = [];
|
|
505
|
+
// Per-participant stack of open activations (step index)
|
|
506
|
+
const stacks = new Map<string, number[]>();
|
|
507
|
+
|
|
508
|
+
const getStack = (id: string): number[] => {
|
|
509
|
+
if (!stacks.has(id)) stacks.set(id, []);
|
|
510
|
+
return stacks.get(id)!;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
for (let i = 0; i < steps.length; i++) {
|
|
514
|
+
const step = steps[i];
|
|
515
|
+
if (step.type === 'call') {
|
|
516
|
+
const s = getStack(step.to);
|
|
517
|
+
s.push(i);
|
|
518
|
+
} else {
|
|
519
|
+
// return: step.from is the callee returning
|
|
520
|
+
const s = getStack(step.from);
|
|
521
|
+
if (s.length > 0) {
|
|
522
|
+
const startIdx = s.pop()!;
|
|
523
|
+
activations.push({
|
|
524
|
+
participantId: step.from,
|
|
525
|
+
startStep: startIdx,
|
|
526
|
+
endStep: i,
|
|
527
|
+
depth: s.length,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return activations;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ============================================================
|
|
537
|
+
// Position Override Sorting
|
|
538
|
+
// ============================================================
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Reorder participants based on explicit `position` overrides.
|
|
542
|
+
* Positive positions are 0-based from the left; negative positions count from the right (-1 = last).
|
|
543
|
+
* Unpositioned participants maintain their relative order, filling remaining slots.
|
|
544
|
+
*/
|
|
545
|
+
export function applyPositionOverrides(
|
|
546
|
+
participants: SequenceParticipant[]
|
|
547
|
+
): SequenceParticipant[] {
|
|
548
|
+
if (!participants.some((p) => p.position !== undefined)) return participants;
|
|
549
|
+
|
|
550
|
+
const total = participants.length;
|
|
551
|
+
const positioned: { participant: SequenceParticipant; index: number }[] = [];
|
|
552
|
+
const unpositioned: SequenceParticipant[] = [];
|
|
553
|
+
|
|
554
|
+
for (const p of participants) {
|
|
555
|
+
if (p.position !== undefined) {
|
|
556
|
+
// Resolve negative: -1 → last, -2 → second-to-last
|
|
557
|
+
let idx = p.position < 0 ? total + p.position : p.position;
|
|
558
|
+
// Clamp to valid range
|
|
559
|
+
idx = Math.max(0, Math.min(total - 1, idx));
|
|
560
|
+
positioned.push({ participant: p, index: idx });
|
|
561
|
+
} else {
|
|
562
|
+
unpositioned.push(p);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Sort positioned by target index for deterministic placement
|
|
567
|
+
positioned.sort((a, b) => a.index - b.index);
|
|
568
|
+
|
|
569
|
+
// Place positioned participants, resolving conflicts by finding nearest free slot
|
|
570
|
+
const result: (SequenceParticipant | null)[] = new Array(total).fill(null);
|
|
571
|
+
const usedIndices = new Set<number>();
|
|
572
|
+
|
|
573
|
+
for (const { participant, index } of positioned) {
|
|
574
|
+
let idx = index;
|
|
575
|
+
if (usedIndices.has(idx)) {
|
|
576
|
+
// Find nearest free slot
|
|
577
|
+
for (let offset = 1; offset < total; offset++) {
|
|
578
|
+
if (idx + offset < total && !usedIndices.has(idx + offset)) {
|
|
579
|
+
idx = idx + offset;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
if (idx - offset >= 0 && !usedIndices.has(idx - offset)) {
|
|
583
|
+
idx = idx - offset;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
result[idx] = participant;
|
|
589
|
+
usedIndices.add(idx);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Fill remaining slots with unpositioned participants in order
|
|
593
|
+
let uIdx = 0;
|
|
594
|
+
for (let i = 0; i < total; i++) {
|
|
595
|
+
if (result[i] === null) {
|
|
596
|
+
result[i] = unpositioned[uIdx++];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return result as SequenceParticipant[];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Group Ordering
|
|
604
|
+
// ============================================================
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Reorder participants so that members of the same group are adjacent.
|
|
608
|
+
* Groups appear in declaration order, followed by ungrouped participants.
|
|
609
|
+
*/
|
|
610
|
+
export function applyGroupOrdering(
|
|
611
|
+
participants: SequenceParticipant[],
|
|
612
|
+
groups: SequenceGroup[]
|
|
613
|
+
): SequenceParticipant[] {
|
|
614
|
+
if (groups.length === 0) return participants;
|
|
615
|
+
|
|
616
|
+
const groupedIds = new Set(groups.flatMap((g) => g.participantIds));
|
|
617
|
+
const result: SequenceParticipant[] = [];
|
|
618
|
+
const placed = new Set<string>();
|
|
619
|
+
|
|
620
|
+
// Place grouped participants in group declaration order
|
|
621
|
+
for (const group of groups) {
|
|
622
|
+
for (const id of group.participantIds) {
|
|
623
|
+
const p = participants.find((pp) => pp.id === id);
|
|
624
|
+
if (p && !placed.has(id)) {
|
|
625
|
+
result.push(p);
|
|
626
|
+
placed.add(id);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Append ungrouped participants in their original order
|
|
632
|
+
for (const p of participants) {
|
|
633
|
+
if (!groupedIds.has(p.id) && !placed.has(p.id)) {
|
|
634
|
+
result.push(p);
|
|
635
|
+
placed.add(p.id);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Main Renderer
|
|
643
|
+
// ============================================================
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Render a sequence diagram into the given container element.
|
|
647
|
+
*/
|
|
648
|
+
export function renderSequenceDiagram(
|
|
649
|
+
container: HTMLDivElement,
|
|
650
|
+
parsed: ParsedSequenceDgmo,
|
|
651
|
+
palette: PaletteColors,
|
|
652
|
+
isDark: boolean,
|
|
653
|
+
_onNavigateToLine?: (line: number) => void
|
|
654
|
+
): void {
|
|
655
|
+
// Clear previous content
|
|
656
|
+
d3Selection.select(container).selectAll('*').remove();
|
|
657
|
+
|
|
658
|
+
const { title, messages, elements, groups, options } = parsed;
|
|
659
|
+
const participants = applyPositionOverrides(
|
|
660
|
+
applyGroupOrdering(parsed.participants, groups)
|
|
661
|
+
);
|
|
662
|
+
if (participants.length === 0) return;
|
|
663
|
+
|
|
664
|
+
const activationsOff = options.activations?.toLowerCase() === 'off';
|
|
665
|
+
|
|
666
|
+
// Build render sequence with stack-based return placement
|
|
667
|
+
const renderSteps = buildRenderSequence(messages);
|
|
668
|
+
const activations = activationsOff ? [] : computeActivations(renderSteps);
|
|
669
|
+
const stepSpacing = 35;
|
|
670
|
+
|
|
671
|
+
// --- Block-aware Y spacing ---
|
|
672
|
+
// Extra spacing constants for block boundaries
|
|
673
|
+
const BLOCK_HEADER_SPACE = 30; // Extra space for frame label above first message in a block
|
|
674
|
+
const BLOCK_AFTER_SPACE = 15; // Extra space after a block ends (before next sibling)
|
|
675
|
+
|
|
676
|
+
// Build maps from messageIndex to render step indices (needed early for spacing)
|
|
677
|
+
const msgToFirstStep = new Map<number, number>();
|
|
678
|
+
const msgToLastStep = new Map<number, number>();
|
|
679
|
+
renderSteps.forEach((step, si) => {
|
|
680
|
+
if (!msgToFirstStep.has(step.messageIndex)) {
|
|
681
|
+
msgToFirstStep.set(step.messageIndex, si);
|
|
682
|
+
}
|
|
683
|
+
msgToLastStep.set(step.messageIndex, si);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Find the first message index in an element subtree
|
|
687
|
+
const findFirstMsgIndex = (els: SequenceElement[]): number => {
|
|
688
|
+
for (const el of els) {
|
|
689
|
+
if (isSequenceBlock(el)) {
|
|
690
|
+
const idx = findFirstMsgIndex(el.children);
|
|
691
|
+
if (idx >= 0) return idx;
|
|
692
|
+
} else if (!isSequenceSection(el)) {
|
|
693
|
+
const idx = messages.indexOf(el);
|
|
694
|
+
if (idx >= 0) return idx;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return -1;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// Compute extra Y offset needed before each message
|
|
701
|
+
const SECTION_SPACING = 40;
|
|
702
|
+
const extraBeforeMsg = new Map<number, number>();
|
|
703
|
+
const addExtra = (msgIdx: number, amount: number) => {
|
|
704
|
+
extraBeforeMsg.set(msgIdx, (extraBeforeMsg.get(msgIdx) || 0) + amount);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Track sections mapped to the message index they precede
|
|
708
|
+
const sectionBeforeMsg = new Map<
|
|
709
|
+
number,
|
|
710
|
+
import('./parser').SequenceSection[]
|
|
711
|
+
>();
|
|
712
|
+
|
|
713
|
+
const markElementSpacing = (els: SequenceElement[]): void => {
|
|
714
|
+
for (let i = 0; i < els.length; i++) {
|
|
715
|
+
const el = els[i];
|
|
716
|
+
|
|
717
|
+
// Handle sections — add spacing before the next message
|
|
718
|
+
if (isSequenceSection(el)) {
|
|
719
|
+
// Find the next message after this section
|
|
720
|
+
const nextMsgIdx =
|
|
721
|
+
i + 1 < els.length ? findFirstMsgIndex(els.slice(i + 1)) : -1;
|
|
722
|
+
if (nextMsgIdx >= 0) {
|
|
723
|
+
addExtra(nextMsgIdx, SECTION_SPACING);
|
|
724
|
+
const existing = sectionBeforeMsg.get(nextMsgIdx) || [];
|
|
725
|
+
existing.push(el);
|
|
726
|
+
sectionBeforeMsg.set(nextMsgIdx, existing);
|
|
727
|
+
}
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!isSequenceBlock(el)) continue;
|
|
732
|
+
|
|
733
|
+
// First message in this block needs header space
|
|
734
|
+
const firstIdx = findFirstMsgIndex(el.children);
|
|
735
|
+
if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);
|
|
736
|
+
|
|
737
|
+
// First message in else section needs header space
|
|
738
|
+
const firstElseIdx = findFirstMsgIndex(el.elseChildren);
|
|
739
|
+
if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);
|
|
740
|
+
|
|
741
|
+
// Recurse into nested blocks and sections
|
|
742
|
+
markElementSpacing(el.children);
|
|
743
|
+
markElementSpacing(el.elseChildren);
|
|
744
|
+
|
|
745
|
+
// Next sibling after this block needs after-block spacing
|
|
746
|
+
if (i + 1 < els.length) {
|
|
747
|
+
const nextIdx = findFirstMsgIndex([els[i + 1]]);
|
|
748
|
+
if (nextIdx >= 0) addExtra(nextIdx, BLOCK_AFTER_SPACE);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
if (elements && elements.length > 0) {
|
|
754
|
+
markElementSpacing(elements);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Group box layout constants (needed early for Y offset)
|
|
758
|
+
const GROUP_PADDING_X = 15;
|
|
759
|
+
const GROUP_PADDING_TOP = 22;
|
|
760
|
+
const GROUP_PADDING_BOTTOM = 8;
|
|
761
|
+
const GROUP_LABEL_SIZE = 11;
|
|
762
|
+
|
|
763
|
+
// Compute cumulative Y positions for each step
|
|
764
|
+
const titleOffset = title ? TITLE_HEIGHT : 0;
|
|
765
|
+
const groupOffset =
|
|
766
|
+
groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
|
|
767
|
+
const participantStartY =
|
|
768
|
+
TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
|
|
769
|
+
const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
|
|
770
|
+
const hasActors = participants.some((p) => p.type === 'actor');
|
|
771
|
+
const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
|
|
772
|
+
const stepYPositions: number[] = [];
|
|
773
|
+
{
|
|
774
|
+
let curY = lifelineStartY0 + messageStartOffset;
|
|
775
|
+
for (let i = 0; i < renderSteps.length; i++) {
|
|
776
|
+
const step = renderSteps[i];
|
|
777
|
+
// Add extra spacing before the first render step of a flagged message
|
|
778
|
+
if (msgToFirstStep.get(step.messageIndex) === i) {
|
|
779
|
+
const extra = extraBeforeMsg.get(step.messageIndex) || 0;
|
|
780
|
+
curY += extra;
|
|
781
|
+
}
|
|
782
|
+
stepYPositions.push(curY);
|
|
783
|
+
curY += stepSpacing;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const messageAreaHeight =
|
|
788
|
+
renderSteps.length > 0
|
|
789
|
+
? stepYPositions[stepYPositions.length - 1] -
|
|
790
|
+
lifelineStartY0 +
|
|
791
|
+
stepSpacing
|
|
792
|
+
: 0;
|
|
793
|
+
const lifelineLength = messageAreaHeight + LIFELINE_TAIL;
|
|
794
|
+
const totalWidth = Math.max(
|
|
795
|
+
participants.length * PARTICIPANT_GAP,
|
|
796
|
+
PARTICIPANT_BOX_WIDTH + 40
|
|
797
|
+
);
|
|
798
|
+
const totalHeight =
|
|
799
|
+
participantStartY +
|
|
800
|
+
PARTICIPANT_BOX_HEIGHT +
|
|
801
|
+
Math.max(lifelineLength, 40) +
|
|
802
|
+
40;
|
|
803
|
+
|
|
804
|
+
const { width: containerWidth } = container.getBoundingClientRect();
|
|
805
|
+
const svgWidth = Math.max(totalWidth, containerWidth);
|
|
806
|
+
|
|
807
|
+
// Center the diagram horizontally
|
|
808
|
+
const diagramWidth = participants.length * PARTICIPANT_GAP;
|
|
809
|
+
const offsetX =
|
|
810
|
+
Math.max(0, (svgWidth - diagramWidth) / 2) + PARTICIPANT_GAP / 2;
|
|
811
|
+
|
|
812
|
+
// Build participant x-position lookup
|
|
813
|
+
const participantX = new Map<string, number>();
|
|
814
|
+
participants.forEach((p, i) => {
|
|
815
|
+
participantX.set(p.id, offsetX + i * PARTICIPANT_GAP);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const svg = d3Selection
|
|
819
|
+
.select(container)
|
|
820
|
+
.append('svg')
|
|
821
|
+
.attr('width', '100%')
|
|
822
|
+
.attr('height', totalHeight)
|
|
823
|
+
.attr('viewBox', `0 0 ${svgWidth} ${totalHeight}`)
|
|
824
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
825
|
+
.attr('class', 'sequence-diagram')
|
|
826
|
+
.style(
|
|
827
|
+
'font-family',
|
|
828
|
+
'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif'
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
// Define arrowhead markers
|
|
832
|
+
const defs = svg.append('defs');
|
|
833
|
+
|
|
834
|
+
// Filled arrowhead for call arrows
|
|
835
|
+
defs
|
|
836
|
+
.append('marker')
|
|
837
|
+
.attr('id', 'seq-arrowhead')
|
|
838
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
839
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
840
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
841
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
842
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
843
|
+
.attr('orient', 'auto')
|
|
844
|
+
.append('polygon')
|
|
845
|
+
.attr(
|
|
846
|
+
'points',
|
|
847
|
+
`0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`
|
|
848
|
+
)
|
|
849
|
+
.attr('fill', palette.text);
|
|
850
|
+
|
|
851
|
+
// Open arrowhead for return arrows
|
|
852
|
+
defs
|
|
853
|
+
.append('marker')
|
|
854
|
+
.attr('id', 'seq-arrowhead-open')
|
|
855
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
856
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
857
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
858
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
859
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
860
|
+
.attr('orient', 'auto')
|
|
861
|
+
.append('polyline')
|
|
862
|
+
.attr(
|
|
863
|
+
'points',
|
|
864
|
+
`0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`
|
|
865
|
+
)
|
|
866
|
+
.attr('fill', 'none')
|
|
867
|
+
.attr('stroke', palette.textMuted)
|
|
868
|
+
.attr('stroke-width', 1.2);
|
|
869
|
+
|
|
870
|
+
// Open arrowhead for async (fire-and-forget) arrows — same as return but text color
|
|
871
|
+
defs
|
|
872
|
+
.append('marker')
|
|
873
|
+
.attr('id', 'seq-arrowhead-async')
|
|
874
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
875
|
+
.attr('refX', ARROWHEAD_SIZE)
|
|
876
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
877
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
878
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
879
|
+
.attr('orient', 'auto')
|
|
880
|
+
.append('polyline')
|
|
881
|
+
.attr(
|
|
882
|
+
'points',
|
|
883
|
+
`0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`
|
|
884
|
+
)
|
|
885
|
+
.attr('fill', 'none')
|
|
886
|
+
.attr('stroke', palette.text)
|
|
887
|
+
.attr('stroke-width', 1.2);
|
|
888
|
+
|
|
889
|
+
// Render title
|
|
890
|
+
if (title) {
|
|
891
|
+
svg
|
|
892
|
+
.append('text')
|
|
893
|
+
.attr('x', svgWidth / 2)
|
|
894
|
+
.attr('y', TOP_MARGIN + TITLE_HEIGHT * 0.7)
|
|
895
|
+
.attr('text-anchor', 'middle')
|
|
896
|
+
.attr('fill', palette.text)
|
|
897
|
+
.attr('font-size', 16)
|
|
898
|
+
.attr('font-weight', 'bold')
|
|
899
|
+
.text(title);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Render group boxes (behind participant shapes)
|
|
903
|
+
for (const group of groups) {
|
|
904
|
+
if (group.participantIds.length === 0) continue;
|
|
905
|
+
|
|
906
|
+
// Find X bounds from member participant positions
|
|
907
|
+
const memberXs = group.participantIds
|
|
908
|
+
.map((id) => participantX.get(id))
|
|
909
|
+
.filter((x): x is number => x !== undefined);
|
|
910
|
+
if (memberXs.length === 0) continue;
|
|
911
|
+
|
|
912
|
+
const minX =
|
|
913
|
+
Math.min(...memberXs) - PARTICIPANT_BOX_WIDTH / 2 - GROUP_PADDING_X;
|
|
914
|
+
const maxX =
|
|
915
|
+
Math.max(...memberXs) + PARTICIPANT_BOX_WIDTH / 2 + GROUP_PADDING_X;
|
|
916
|
+
const boxY = participantStartY - GROUP_PADDING_TOP;
|
|
917
|
+
const boxH =
|
|
918
|
+
PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
|
|
919
|
+
|
|
920
|
+
// Group box background
|
|
921
|
+
const resolvedGroupColor = group.color
|
|
922
|
+
? resolveColor(group.color, palette)
|
|
923
|
+
: undefined;
|
|
924
|
+
const fillColor = resolvedGroupColor
|
|
925
|
+
? `color-mix(in srgb, ${resolvedGroupColor} 10%, ${isDark ? palette.surface : palette.bg})`
|
|
926
|
+
: isDark
|
|
927
|
+
? palette.surface
|
|
928
|
+
: palette.bg;
|
|
929
|
+
const strokeColor = resolvedGroupColor || palette.textMuted;
|
|
930
|
+
|
|
931
|
+
svg
|
|
932
|
+
.append('rect')
|
|
933
|
+
.attr('x', minX)
|
|
934
|
+
.attr('y', boxY)
|
|
935
|
+
.attr('width', maxX - minX)
|
|
936
|
+
.attr('height', boxH)
|
|
937
|
+
.attr('rx', 6)
|
|
938
|
+
.attr('fill', fillColor)
|
|
939
|
+
.attr('stroke', strokeColor)
|
|
940
|
+
.attr('stroke-width', 1)
|
|
941
|
+
.attr('stroke-opacity', 0.5)
|
|
942
|
+
.attr('class', 'group-box')
|
|
943
|
+
.attr('data-group-line', String(group.lineNumber));
|
|
944
|
+
|
|
945
|
+
// Group label
|
|
946
|
+
svg
|
|
947
|
+
.append('text')
|
|
948
|
+
.attr('x', minX + 8)
|
|
949
|
+
.attr('y', boxY + GROUP_LABEL_SIZE + 4)
|
|
950
|
+
.attr('fill', strokeColor)
|
|
951
|
+
.attr('font-size', GROUP_LABEL_SIZE)
|
|
952
|
+
.attr('font-weight', 'bold')
|
|
953
|
+
.attr('opacity', 0.7)
|
|
954
|
+
.attr('class', 'group-label')
|
|
955
|
+
.attr('data-group-line', String(group.lineNumber))
|
|
956
|
+
.text(group.name);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Render each participant
|
|
960
|
+
const lifelineStartY = lifelineStartY0;
|
|
961
|
+
participants.forEach((participant, index) => {
|
|
962
|
+
const cx = offsetX + index * PARTICIPANT_GAP;
|
|
963
|
+
const cy = participantStartY;
|
|
964
|
+
|
|
965
|
+
renderParticipant(svg, participant, cx, cy, palette, isDark);
|
|
966
|
+
|
|
967
|
+
// Render lifeline
|
|
968
|
+
svg
|
|
969
|
+
.append('line')
|
|
970
|
+
.attr('x1', cx)
|
|
971
|
+
.attr('y1', lifelineStartY)
|
|
972
|
+
.attr('x2', cx)
|
|
973
|
+
.attr('y2', lifelineStartY + lifelineLength)
|
|
974
|
+
.attr('stroke', palette.textMuted)
|
|
975
|
+
.attr('stroke-width', 1)
|
|
976
|
+
.attr('stroke-dasharray', '6 4')
|
|
977
|
+
.attr('class', 'lifeline');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Helper: compute Y for a step index
|
|
981
|
+
const stepY = (i: number) => stepYPositions[i];
|
|
982
|
+
|
|
983
|
+
// Render block frames (behind everything else)
|
|
984
|
+
const FRAME_PADDING_X = 30;
|
|
985
|
+
const FRAME_PADDING_TOP = 42;
|
|
986
|
+
const FRAME_PADDING_BOTTOM = 15;
|
|
987
|
+
const FRAME_LABEL_HEIGHT = 18;
|
|
988
|
+
|
|
989
|
+
// Collect message indices from an element subtree
|
|
990
|
+
const collectMsgIndices = (els: SequenceElement[]): number[] => {
|
|
991
|
+
const indices: number[] = [];
|
|
992
|
+
for (const el of els) {
|
|
993
|
+
if (isSequenceBlock(el)) {
|
|
994
|
+
indices.push(
|
|
995
|
+
...collectMsgIndices(el.children),
|
|
996
|
+
...collectMsgIndices(el.elseChildren)
|
|
997
|
+
);
|
|
998
|
+
} else if (!isSequenceSection(el)) {
|
|
999
|
+
const idx = messages.indexOf(el);
|
|
1000
|
+
if (idx >= 0) indices.push(idx);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return indices;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Collect deferred draws (rendered after activations so they appear on top)
|
|
1007
|
+
const deferredLabels: Array<{
|
|
1008
|
+
x: number;
|
|
1009
|
+
y: number;
|
|
1010
|
+
text: string;
|
|
1011
|
+
bold: boolean;
|
|
1012
|
+
italic: boolean;
|
|
1013
|
+
blockLine?: number;
|
|
1014
|
+
}> = [];
|
|
1015
|
+
const deferredLines: Array<{
|
|
1016
|
+
x1: number;
|
|
1017
|
+
y1: number;
|
|
1018
|
+
x2: number;
|
|
1019
|
+
y2: number;
|
|
1020
|
+
}> = [];
|
|
1021
|
+
|
|
1022
|
+
// Recursive block renderer — draws borders/dividers now, defers label text
|
|
1023
|
+
const renderBlockFrames = (els: SequenceElement[], depth: number): void => {
|
|
1024
|
+
for (const el of els) {
|
|
1025
|
+
if (!isSequenceBlock(el)) continue;
|
|
1026
|
+
|
|
1027
|
+
const ifIndices = collectMsgIndices(el.children);
|
|
1028
|
+
const elseIndices = collectMsgIndices(el.elseChildren);
|
|
1029
|
+
const allIndices = [...ifIndices, ...elseIndices];
|
|
1030
|
+
if (allIndices.length === 0) continue;
|
|
1031
|
+
|
|
1032
|
+
// Find render step range
|
|
1033
|
+
let minStep = Infinity;
|
|
1034
|
+
let maxStep = -Infinity;
|
|
1035
|
+
for (const mi of allIndices) {
|
|
1036
|
+
const first = msgToFirstStep.get(mi);
|
|
1037
|
+
const last = msgToLastStep.get(mi);
|
|
1038
|
+
if (first !== undefined) minStep = Math.min(minStep, first);
|
|
1039
|
+
if (last !== undefined) maxStep = Math.max(maxStep, last);
|
|
1040
|
+
}
|
|
1041
|
+
if (minStep === Infinity) continue;
|
|
1042
|
+
|
|
1043
|
+
// Find participant X range
|
|
1044
|
+
const involved = new Set<string>();
|
|
1045
|
+
for (const mi of allIndices) {
|
|
1046
|
+
involved.add(messages[mi].from);
|
|
1047
|
+
involved.add(messages[mi].to);
|
|
1048
|
+
}
|
|
1049
|
+
let minPX = Infinity;
|
|
1050
|
+
let maxPX = -Infinity;
|
|
1051
|
+
for (const pid of involved) {
|
|
1052
|
+
const px = participantX.get(pid);
|
|
1053
|
+
if (px !== undefined) {
|
|
1054
|
+
minPX = Math.min(minPX, px);
|
|
1055
|
+
maxPX = Math.max(maxPX, px);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const frameX = minPX - FRAME_PADDING_X;
|
|
1060
|
+
const frameY = stepY(minStep) - FRAME_PADDING_TOP;
|
|
1061
|
+
const frameW = maxPX - minPX + FRAME_PADDING_X * 2;
|
|
1062
|
+
const frameH =
|
|
1063
|
+
stepY(maxStep) -
|
|
1064
|
+
stepY(minStep) +
|
|
1065
|
+
FRAME_PADDING_TOP +
|
|
1066
|
+
FRAME_PADDING_BOTTOM;
|
|
1067
|
+
|
|
1068
|
+
// Frame border
|
|
1069
|
+
svg
|
|
1070
|
+
.append('rect')
|
|
1071
|
+
.attr('x', frameX)
|
|
1072
|
+
.attr('y', frameY)
|
|
1073
|
+
.attr('width', frameW)
|
|
1074
|
+
.attr('height', frameH)
|
|
1075
|
+
.attr('fill', 'none')
|
|
1076
|
+
.attr('stroke', palette.textMuted)
|
|
1077
|
+
.attr('stroke-width', 1)
|
|
1078
|
+
.attr('stroke-dasharray', '2 3')
|
|
1079
|
+
.attr('rx', 3)
|
|
1080
|
+
.attr('ry', 3)
|
|
1081
|
+
.attr('class', 'block-frame')
|
|
1082
|
+
.attr('data-block-line', String(el.lineNumber));
|
|
1083
|
+
|
|
1084
|
+
// Defer label text (rendered on top of activations later)
|
|
1085
|
+
deferredLabels.push({
|
|
1086
|
+
x: frameX + 6,
|
|
1087
|
+
y: frameY + FRAME_LABEL_HEIGHT - 4,
|
|
1088
|
+
text: `${el.type} ${el.label}`,
|
|
1089
|
+
bold: true,
|
|
1090
|
+
italic: false,
|
|
1091
|
+
blockLine: el.lineNumber,
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Else divider
|
|
1095
|
+
if (elseIndices.length > 0) {
|
|
1096
|
+
let firstElseStep = Infinity;
|
|
1097
|
+
for (const mi of elseIndices) {
|
|
1098
|
+
const first = msgToFirstStep.get(mi);
|
|
1099
|
+
if (first !== undefined)
|
|
1100
|
+
firstElseStep = Math.min(firstElseStep, first);
|
|
1101
|
+
}
|
|
1102
|
+
if (firstElseStep < Infinity) {
|
|
1103
|
+
const dividerY = stepY(firstElseStep) - stepSpacing / 2;
|
|
1104
|
+
deferredLines.push({
|
|
1105
|
+
x1: frameX,
|
|
1106
|
+
y1: dividerY,
|
|
1107
|
+
x2: frameX + frameW,
|
|
1108
|
+
y2: dividerY,
|
|
1109
|
+
});
|
|
1110
|
+
deferredLabels.push({
|
|
1111
|
+
x: frameX + 6,
|
|
1112
|
+
y: dividerY + 14,
|
|
1113
|
+
text: 'else',
|
|
1114
|
+
bold: false,
|
|
1115
|
+
italic: true,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Recurse into nested blocks
|
|
1121
|
+
renderBlockFrames(el.children, depth + 1);
|
|
1122
|
+
renderBlockFrames(el.elseChildren, depth + 1);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
if (elements && elements.length > 0) {
|
|
1127
|
+
renderBlockFrames(elements, 0);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Render activation rectangles (behind arrows)
|
|
1131
|
+
const ACTIVATION_WIDTH = 10;
|
|
1132
|
+
const ACTIVATION_NEST_OFFSET = 6;
|
|
1133
|
+
activations.forEach((act) => {
|
|
1134
|
+
const px = participantX.get(act.participantId);
|
|
1135
|
+
if (px === undefined) return;
|
|
1136
|
+
|
|
1137
|
+
const x = px - ACTIVATION_WIDTH / 2 + act.depth * ACTIVATION_NEST_OFFSET;
|
|
1138
|
+
const y1 = stepY(act.startStep);
|
|
1139
|
+
const y2 = stepY(act.endStep);
|
|
1140
|
+
|
|
1141
|
+
// Opaque background to mask the lifeline
|
|
1142
|
+
svg
|
|
1143
|
+
.append('rect')
|
|
1144
|
+
.attr('x', x)
|
|
1145
|
+
.attr('y', y1)
|
|
1146
|
+
.attr('width', ACTIVATION_WIDTH)
|
|
1147
|
+
.attr('height', y2 - y1)
|
|
1148
|
+
.attr('fill', isDark ? palette.surface : palette.bg);
|
|
1149
|
+
|
|
1150
|
+
const actFill = `color-mix(in srgb, ${palette.primary} ${isDark ? '15%' : '30%'}, ${isDark ? palette.surface : palette.bg})`;
|
|
1151
|
+
svg
|
|
1152
|
+
.append('rect')
|
|
1153
|
+
.attr('x', x)
|
|
1154
|
+
.attr('y', y1)
|
|
1155
|
+
.attr('width', ACTIVATION_WIDTH)
|
|
1156
|
+
.attr('height', y2 - y1)
|
|
1157
|
+
.attr('fill', actFill)
|
|
1158
|
+
.attr('stroke', palette.primary)
|
|
1159
|
+
.attr('stroke-width', 1)
|
|
1160
|
+
.attr('stroke-opacity', 0.5)
|
|
1161
|
+
.attr('class', 'activation');
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// Render deferred else dividers (on top of activations)
|
|
1165
|
+
for (const ln of deferredLines) {
|
|
1166
|
+
svg
|
|
1167
|
+
.append('line')
|
|
1168
|
+
.attr('x1', ln.x1)
|
|
1169
|
+
.attr('y1', ln.y1)
|
|
1170
|
+
.attr('x2', ln.x2)
|
|
1171
|
+
.attr('y2', ln.y2)
|
|
1172
|
+
.attr('stroke', palette.textMuted)
|
|
1173
|
+
.attr('stroke-width', 1)
|
|
1174
|
+
.attr('stroke-dasharray', '2 3');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Render deferred block labels (on top of activations)
|
|
1178
|
+
for (const lbl of deferredLabels) {
|
|
1179
|
+
const t = svg
|
|
1180
|
+
.append('text')
|
|
1181
|
+
.attr('x', lbl.x)
|
|
1182
|
+
.attr('y', lbl.y)
|
|
1183
|
+
.attr('fill', palette.text)
|
|
1184
|
+
.attr('font-size', 11)
|
|
1185
|
+
.attr('class', 'block-label')
|
|
1186
|
+
.text(lbl.text);
|
|
1187
|
+
if (lbl.bold) t.attr('font-weight', 'bold');
|
|
1188
|
+
if (lbl.italic) t.attr('font-style', 'italic');
|
|
1189
|
+
if (lbl.blockLine !== undefined)
|
|
1190
|
+
t.attr('data-block-line', String(lbl.blockLine));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Helper: find max active activation depth for a participant at a step
|
|
1194
|
+
const activeDepthAt = (pid: string, stepIdx: number): number => {
|
|
1195
|
+
let maxDepth = -1;
|
|
1196
|
+
for (const act of activations) {
|
|
1197
|
+
if (
|
|
1198
|
+
act.participantId === pid &&
|
|
1199
|
+
act.startStep <= stepIdx &&
|
|
1200
|
+
stepIdx <= act.endStep &&
|
|
1201
|
+
act.depth > maxDepth
|
|
1202
|
+
) {
|
|
1203
|
+
maxDepth = act.depth;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return maxDepth;
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// Helper: compute arrow endpoint X, snapping to activation box edge
|
|
1210
|
+
const arrowEdgeX = (
|
|
1211
|
+
pid: string,
|
|
1212
|
+
stepIdx: number,
|
|
1213
|
+
side: 'left' | 'right'
|
|
1214
|
+
): number => {
|
|
1215
|
+
const px = participantX.get(pid)!;
|
|
1216
|
+
const depth = activeDepthAt(pid, stepIdx);
|
|
1217
|
+
if (depth < 0) return px;
|
|
1218
|
+
const offset = depth * ACTIVATION_NEST_OFFSET;
|
|
1219
|
+
return side === 'right'
|
|
1220
|
+
? px + ACTIVATION_WIDTH / 2 + offset
|
|
1221
|
+
: px - ACTIVATION_WIDTH / 2 + offset;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
// Render section dividers
|
|
1225
|
+
const leftmostX = Math.min(...Array.from(participantX.values()));
|
|
1226
|
+
const rightmostX = Math.max(...Array.from(participantX.values()));
|
|
1227
|
+
const sectionLineX1 = leftmostX - PARTICIPANT_BOX_WIDTH / 2 - 10;
|
|
1228
|
+
const sectionLineX2 = rightmostX + PARTICIPANT_BOX_WIDTH / 2 + 10;
|
|
1229
|
+
|
|
1230
|
+
for (const [msgIdx, secs] of sectionBeforeMsg.entries()) {
|
|
1231
|
+
const firstStep = msgToFirstStep.get(msgIdx);
|
|
1232
|
+
if (firstStep === undefined) continue;
|
|
1233
|
+
const nextY = stepY(firstStep);
|
|
1234
|
+
|
|
1235
|
+
for (let si = 0; si < secs.length; si++) {
|
|
1236
|
+
const sec = secs[si];
|
|
1237
|
+
const secY = nextY - SECTION_SPACING / 2 + si * 20;
|
|
1238
|
+
const lineColor = sec.color
|
|
1239
|
+
? resolveColor(sec.color, palette)
|
|
1240
|
+
: palette.textMuted;
|
|
1241
|
+
|
|
1242
|
+
// Horizontal divider line
|
|
1243
|
+
svg
|
|
1244
|
+
.append('line')
|
|
1245
|
+
.attr('x1', sectionLineX1)
|
|
1246
|
+
.attr('y1', secY)
|
|
1247
|
+
.attr('x2', sectionLineX2)
|
|
1248
|
+
.attr('y2', secY)
|
|
1249
|
+
.attr('stroke', lineColor)
|
|
1250
|
+
.attr('stroke-width', 1)
|
|
1251
|
+
.attr('stroke-dasharray', '6 3')
|
|
1252
|
+
.attr('opacity', 0.6)
|
|
1253
|
+
.attr('class', 'section-divider')
|
|
1254
|
+
.attr('data-line-number', String(sec.lineNumber))
|
|
1255
|
+
.attr('data-section', '');
|
|
1256
|
+
|
|
1257
|
+
// Label background knockout
|
|
1258
|
+
const labelText = sec.label;
|
|
1259
|
+
const labelWidth = labelText.length * 7 + 16;
|
|
1260
|
+
const labelX = (sectionLineX1 + sectionLineX2) / 2;
|
|
1261
|
+
svg
|
|
1262
|
+
.append('rect')
|
|
1263
|
+
.attr('x', labelX - labelWidth / 2)
|
|
1264
|
+
.attr('y', secY - 8)
|
|
1265
|
+
.attr('width', labelWidth)
|
|
1266
|
+
.attr('height', 16)
|
|
1267
|
+
.attr('fill', isDark ? palette.surface : palette.bg)
|
|
1268
|
+
.attr('class', 'section-label-bg')
|
|
1269
|
+
.attr('data-line-number', String(sec.lineNumber))
|
|
1270
|
+
.attr('data-section', '');
|
|
1271
|
+
|
|
1272
|
+
// Centered label text
|
|
1273
|
+
svg
|
|
1274
|
+
.append('text')
|
|
1275
|
+
.attr('x', labelX)
|
|
1276
|
+
.attr('y', secY + 4)
|
|
1277
|
+
.attr('text-anchor', 'middle')
|
|
1278
|
+
.attr('fill', lineColor)
|
|
1279
|
+
.attr('font-size', 11)
|
|
1280
|
+
.attr('font-weight', 'bold')
|
|
1281
|
+
.attr('class', 'section-label')
|
|
1282
|
+
.attr('data-line-number', String(sec.lineNumber))
|
|
1283
|
+
.attr('data-section', '')
|
|
1284
|
+
.text(labelText);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Render steps (calls and returns in stack-inferred order)
|
|
1289
|
+
const SELF_CALL_WIDTH = 30;
|
|
1290
|
+
const SELF_CALL_HEIGHT = 25;
|
|
1291
|
+
renderSteps.forEach((step, i) => {
|
|
1292
|
+
const fromX = participantX.get(step.from);
|
|
1293
|
+
const toX = participantX.get(step.to);
|
|
1294
|
+
if (fromX === undefined || toX === undefined) return;
|
|
1295
|
+
|
|
1296
|
+
const y = stepY(i);
|
|
1297
|
+
|
|
1298
|
+
if (step.type === 'call') {
|
|
1299
|
+
if (step.from === step.to) {
|
|
1300
|
+
// Self-call: loopback arrow from right edge of activation
|
|
1301
|
+
const x = arrowEdgeX(step.from, i, 'right');
|
|
1302
|
+
svg
|
|
1303
|
+
.append('path')
|
|
1304
|
+
.attr(
|
|
1305
|
+
'd',
|
|
1306
|
+
`M ${x} ${y} H ${x + SELF_CALL_WIDTH} V ${y + SELF_CALL_HEIGHT} H ${x}`
|
|
1307
|
+
)
|
|
1308
|
+
.attr('fill', 'none')
|
|
1309
|
+
.attr('stroke', palette.text)
|
|
1310
|
+
.attr('stroke-width', 1.2)
|
|
1311
|
+
.attr('marker-end', 'url(#seq-arrowhead)')
|
|
1312
|
+
.attr('class', 'message-arrow self-call')
|
|
1313
|
+
.attr(
|
|
1314
|
+
'data-line-number',
|
|
1315
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1316
|
+
)
|
|
1317
|
+
.attr('data-msg-index', String(step.messageIndex));
|
|
1318
|
+
|
|
1319
|
+
if (step.label) {
|
|
1320
|
+
svg
|
|
1321
|
+
.append('text')
|
|
1322
|
+
.attr('x', x + SELF_CALL_WIDTH + 5)
|
|
1323
|
+
.attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
|
|
1324
|
+
.attr('text-anchor', 'start')
|
|
1325
|
+
.attr('fill', palette.text)
|
|
1326
|
+
.attr('font-size', 12)
|
|
1327
|
+
.attr('class', 'message-label')
|
|
1328
|
+
.attr(
|
|
1329
|
+
'data-line-number',
|
|
1330
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1331
|
+
)
|
|
1332
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
1333
|
+
.text(step.label);
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
// Normal call arrow — snap to activation box edges
|
|
1337
|
+
const goingRight = fromX < toX;
|
|
1338
|
+
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
1339
|
+
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
1340
|
+
|
|
1341
|
+
const markerRef = step.async
|
|
1342
|
+
? 'url(#seq-arrowhead-async)'
|
|
1343
|
+
: 'url(#seq-arrowhead)';
|
|
1344
|
+
svg
|
|
1345
|
+
.append('line')
|
|
1346
|
+
.attr('x1', x1)
|
|
1347
|
+
.attr('y1', y)
|
|
1348
|
+
.attr('x2', x2)
|
|
1349
|
+
.attr('y2', y)
|
|
1350
|
+
.attr('stroke', palette.text)
|
|
1351
|
+
.attr('stroke-width', 1.2)
|
|
1352
|
+
.attr('marker-end', markerRef)
|
|
1353
|
+
.attr('class', 'message-arrow')
|
|
1354
|
+
.attr(
|
|
1355
|
+
'data-line-number',
|
|
1356
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1357
|
+
)
|
|
1358
|
+
.attr('data-msg-index', String(step.messageIndex));
|
|
1359
|
+
|
|
1360
|
+
if (step.label) {
|
|
1361
|
+
const midX = (x1 + x2) / 2;
|
|
1362
|
+
svg
|
|
1363
|
+
.append('text')
|
|
1364
|
+
.attr('x', midX)
|
|
1365
|
+
.attr('y', y - 8)
|
|
1366
|
+
.attr('text-anchor', 'middle')
|
|
1367
|
+
.attr('fill', palette.text)
|
|
1368
|
+
.attr('font-size', 12)
|
|
1369
|
+
.attr('class', 'message-label')
|
|
1370
|
+
.attr(
|
|
1371
|
+
'data-line-number',
|
|
1372
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1373
|
+
)
|
|
1374
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
1375
|
+
.text(step.label);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} else {
|
|
1379
|
+
if (step.from === step.to) {
|
|
1380
|
+
// Self-call return — already handled by the loopback path, skip
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
// Return arrow — snap to activation box edges
|
|
1384
|
+
const goingRight = fromX < toX;
|
|
1385
|
+
const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');
|
|
1386
|
+
const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');
|
|
1387
|
+
|
|
1388
|
+
svg
|
|
1389
|
+
.append('line')
|
|
1390
|
+
.attr('x1', x1)
|
|
1391
|
+
.attr('y1', y)
|
|
1392
|
+
.attr('x2', x2)
|
|
1393
|
+
.attr('y2', y)
|
|
1394
|
+
.attr('stroke', palette.textMuted)
|
|
1395
|
+
.attr('stroke-width', 1)
|
|
1396
|
+
.attr('stroke-dasharray', '6 4')
|
|
1397
|
+
.attr('marker-end', 'url(#seq-arrowhead-open)')
|
|
1398
|
+
.attr('class', 'return-arrow')
|
|
1399
|
+
.attr(
|
|
1400
|
+
'data-line-number',
|
|
1401
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1402
|
+
)
|
|
1403
|
+
.attr('data-msg-index', String(step.messageIndex));
|
|
1404
|
+
|
|
1405
|
+
if (step.label) {
|
|
1406
|
+
const midX = (x1 + x2) / 2;
|
|
1407
|
+
svg
|
|
1408
|
+
.append('text')
|
|
1409
|
+
.attr('x', midX)
|
|
1410
|
+
.attr('y', y - 6)
|
|
1411
|
+
.attr('text-anchor', 'middle')
|
|
1412
|
+
.attr('fill', palette.textMuted)
|
|
1413
|
+
.attr('font-size', 11)
|
|
1414
|
+
.attr('class', 'message-label')
|
|
1415
|
+
.attr(
|
|
1416
|
+
'data-line-number',
|
|
1417
|
+
String(messages[step.messageIndex].lineNumber)
|
|
1418
|
+
)
|
|
1419
|
+
.attr('data-msg-index', String(step.messageIndex))
|
|
1420
|
+
.text(step.label);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function renderParticipant(
|
|
1427
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1428
|
+
participant: SequenceParticipant,
|
|
1429
|
+
cx: number,
|
|
1430
|
+
cy: number,
|
|
1431
|
+
palette: PaletteColors,
|
|
1432
|
+
isDark: boolean
|
|
1433
|
+
): void {
|
|
1434
|
+
const g = svg
|
|
1435
|
+
.append('g')
|
|
1436
|
+
.attr('transform', `translate(${cx}, ${cy})`)
|
|
1437
|
+
.attr('class', 'participant')
|
|
1438
|
+
.attr('data-participant-id', participant.id);
|
|
1439
|
+
|
|
1440
|
+
// Render shape based on type
|
|
1441
|
+
switch (participant.type) {
|
|
1442
|
+
case 'actor':
|
|
1443
|
+
renderActorParticipant(g, palette);
|
|
1444
|
+
break;
|
|
1445
|
+
case 'database':
|
|
1446
|
+
renderDatabaseParticipant(g, palette, isDark);
|
|
1447
|
+
break;
|
|
1448
|
+
case 'service':
|
|
1449
|
+
renderServiceParticipant(g, palette, isDark);
|
|
1450
|
+
break;
|
|
1451
|
+
case 'queue':
|
|
1452
|
+
renderQueueParticipant(g, palette, isDark);
|
|
1453
|
+
break;
|
|
1454
|
+
case 'cache':
|
|
1455
|
+
renderCacheParticipant(g, palette, isDark);
|
|
1456
|
+
break;
|
|
1457
|
+
case 'networking':
|
|
1458
|
+
renderNetworkingParticipant(g, palette, isDark);
|
|
1459
|
+
break;
|
|
1460
|
+
case 'frontend':
|
|
1461
|
+
renderFrontendParticipant(g, palette, isDark);
|
|
1462
|
+
break;
|
|
1463
|
+
case 'external':
|
|
1464
|
+
renderExternalParticipant(g, palette, isDark);
|
|
1465
|
+
break;
|
|
1466
|
+
case 'gateway':
|
|
1467
|
+
renderGatewayParticipant(g, palette, isDark);
|
|
1468
|
+
break;
|
|
1469
|
+
default:
|
|
1470
|
+
renderRectParticipant(g, palette, isDark);
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Render label — below the shape for actors, centered inside for others
|
|
1475
|
+
const isActor = participant.type === 'actor';
|
|
1476
|
+
g.append('text')
|
|
1477
|
+
.attr('x', 0)
|
|
1478
|
+
.attr(
|
|
1479
|
+
'y',
|
|
1480
|
+
isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5
|
|
1481
|
+
)
|
|
1482
|
+
.attr('text-anchor', 'middle')
|
|
1483
|
+
.attr('fill', palette.text)
|
|
1484
|
+
.attr('font-size', 13)
|
|
1485
|
+
.attr('font-weight', 500)
|
|
1486
|
+
.text(participant.label);
|
|
1487
|
+
}
|