@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.
@@ -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
+ }