@zombie-mermaid/svg-renderer 2.2.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,698 @@
1
+ import type {
2
+ SequenceDiagram,
3
+ PositionedSequenceDiagram,
4
+ PositionedActor,
5
+ Lifeline,
6
+ PositionedMessage,
7
+ Activation,
8
+ PositionedBlock,
9
+ PositionedNote,
10
+ PositionedParticipantBox,
11
+ } from '@zombie-mermaid/mermaid-parser'
12
+ import type { RenderOptions, SequenceRenderOptions } from '@zombie-mermaid/core'
13
+ import { estimateTextWidth, FONT_WEIGHTS, resolveFontSizes } from '../styles.ts'
14
+
15
+ // ============================================================================
16
+ // Sequence diagram layout engine
17
+ //
18
+ // Custom timeline-based layout (no ELK — sequence diagrams aren't graphs).
19
+ //
20
+ // Layout strategy:
21
+ // 1. Space actors horizontally based on label widths + min gap
22
+ // 2. Stack messages vertically in chronological order
23
+ // 3. Track activation boxes via a stack
24
+ // 4. Position blocks (loop/alt/opt) as background rectangles
25
+ // 5. Position notes next to their target actors
26
+ // ============================================================================
27
+
28
+ /** Layout constants specific to sequence diagrams */
29
+ const SEQ = {
30
+ /** Padding around the entire diagram */
31
+ padding: 30,
32
+ /** Minimum gap between actor centers */
33
+ actorGap: 140,
34
+ /** Actor box height */
35
+ actorHeight: 40,
36
+ /** Horizontal padding inside actor boxes */
37
+ actorPadX: 16,
38
+ /** Vertical space between actor boxes and first message */
39
+ headerGap: 20,
40
+ /** Vertical space per message row */
41
+ messageRowHeight: 40,
42
+ /** Extra vertical space for self-messages (they loop back) */
43
+ selfMessageHeight: 30,
44
+ /** Activation box width (narrow rectangle on lifeline) */
45
+ activationWidth: 10,
46
+ /** Block padding (loop/alt borders) */
47
+ blockPadX: 10,
48
+ blockPadTop: 40,
49
+ blockPadBottom: 8,
50
+ /** Extra vertical space before the first message in a block (room for the header label) */
51
+ blockHeaderExtra: 28,
52
+ /** Extra vertical space before a message at a divider boundary (room for else/and label) */
53
+ dividerExtra: 24,
54
+ /** Note dimensions */
55
+ noteWidth: 60,
56
+ notePadX: 12,
57
+ notePadY: 6,
58
+ noteGap: 10,
59
+ /** Gap between a message arrow and a note positioned directly after it */
60
+ noteOffsetAfterMessage: 8,
61
+ /** Gap between consecutively stacked notes */
62
+ noteStackGap: 4,
63
+ /**
64
+ * `box … end` group padding: horizontal clearance beyond the outermost
65
+ * member's box, vertical clearance above the actor boxes (under the label
66
+ * band) and below the lifeline bottoms.
67
+ */
68
+ boxPad: 10,
69
+ } as const
70
+
71
+ /**
72
+ * Height of the label band at the top of a `box … end` group, in px — the
73
+ * label's font size plus vertical breathing room. Shared with the renderer
74
+ * so the label lands centred in the band the layout reserved for it.
75
+ */
76
+ export function boxLabelHeight(labelFontSize: number): number {
77
+ return labelFontSize + 8
78
+ }
79
+
80
+ /** Resolved sequence-layout config — same shape as {@link SEQ} but mutable numbers. */
81
+ type SeqConfig = { [K in keyof typeof SEQ]: number }
82
+
83
+ /**
84
+ * Merge user-provided sequence-layout overrides over the {@link SEQ} defaults.
85
+ * Any field left unspecified (or `undefined`) falls back to its default.
86
+ */
87
+ function resolveSeqOptions(overrides?: RenderOptions['sequence']): SeqConfig {
88
+ return {
89
+ ...SEQ,
90
+ actorHeight: overrides?.actorHeight ?? SEQ.actorHeight,
91
+ headerGap: overrides?.headerGap ?? SEQ.headerGap,
92
+ messageRowHeight: overrides?.messageRowHeight ?? SEQ.messageRowHeight,
93
+ noteOffsetAfterMessage:
94
+ overrides?.noteOffsetAfterMessage ?? SEQ.noteOffsetAfterMessage,
95
+ noteStackGap: overrides?.noteStackGap ?? SEQ.noteStackGap,
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Lay out a parsed sequence diagram.
101
+ * Returns a fully positioned diagram ready for SVG rendering.
102
+ */
103
+ export function layoutSequenceDiagram(
104
+ diagram: SequenceDiagram,
105
+ options: SequenceRenderOptions = {},
106
+ ): PositionedSequenceDiagram {
107
+ if (diagram.actors.length === 0) {
108
+ return {
109
+ width: 0,
110
+ height: 0,
111
+ actors: [],
112
+ lifelines: [],
113
+ messages: [],
114
+ activations: [],
115
+ blocks: [],
116
+ notes: [],
117
+ boxes: [],
118
+ }
119
+ }
120
+
121
+ const seq = resolveSeqOptions(options.sequence)
122
+ const fontSizes = resolveFontSizes(options.fontSizes)
123
+
124
+ // 1. Calculate actor widths and assign horizontal positions (center X)
125
+ const actorWidths = diagram.actors.map((a) => {
126
+ const textW = estimateTextWidth(
127
+ a.label,
128
+ fontSizes.nodeLabel,
129
+ FONT_WEIGHTS.nodeLabel,
130
+ )
131
+ return Math.max(textW + seq.actorPadX * 2, 80)
132
+ })
133
+
134
+ // Build actor center X positions with minimum gap
135
+ const actorCenterX: number[] = []
136
+ let currentX = seq.padding + actorWidths[0]! / 2
137
+ for (let i = 0; i < diagram.actors.length; i++) {
138
+ if (i > 0) {
139
+ const minGap = Math.max(
140
+ seq.actorGap,
141
+ (actorWidths[i - 1]! + actorWidths[i]!) / 2 + 40,
142
+ )
143
+ currentX += minGap
144
+ }
145
+ actorCenterX.push(currentX)
146
+ }
147
+
148
+ // Build actor ID → index lookup
149
+ const actorIndex = new Map<string, number>()
150
+ for (let i = 0; i < diagram.actors.length; i++) {
151
+ actorIndex.set(diagram.actors[i]!.id, i)
152
+ }
153
+
154
+ // 2. Position actors at the top. A `box … end` group draws a full-height
155
+ // background starting at the top padding, so when any (non-empty) box
156
+ // exists the actor row moves down to leave room for the box's top
157
+ // padding and — if any box has a label — its label band. Mermaid does
158
+ // the same (`bumpVerticalPos(boxes[0].textMaxHeight)`).
159
+ const renderedBoxes = diagram.boxes.filter((b) => b.actorIds.length > 0)
160
+ const anyBoxLabel = renderedBoxes.some((b) => b.label !== '')
161
+ const boxTopInset =
162
+ renderedBoxes.length === 0
163
+ ? 0
164
+ : seq.boxPad + (anyBoxLabel ? boxLabelHeight(fontSizes.edgeLabel) : 0)
165
+ const actorY = seq.padding + boxTopInset
166
+ const actors: PositionedActor[] = diagram.actors.map((a, i) => ({
167
+ id: a.id,
168
+ label: a.label,
169
+ type: a.type,
170
+ x: actorCenterX[i]!,
171
+ y: actorY,
172
+ width: actorWidths[i]!,
173
+ height: seq.actorHeight,
174
+ }))
175
+
176
+ // Box backgrounds span from the outermost member's left edge to the
177
+ // outermost member's right edge (plus padding) — any participant declared
178
+ // between two members sits visually inside, as in Mermaid. Heights are
179
+ // filled in once the diagram bottom is known (step 8 below).
180
+ const boxes: PositionedParticipantBox[] = renderedBoxes.map((box) => {
181
+ const idxs = box.actorIds.map((id) => actorIndex.get(id) ?? 0)
182
+ const lo = Math.min(...idxs)
183
+ const hi = Math.max(...idxs)
184
+ const left = actorCenterX[lo]! - actorWidths[lo]! / 2 - seq.boxPad
185
+ const right = actorCenterX[hi]! + actorWidths[hi]! / 2 + seq.boxPad
186
+ const positioned: PositionedParticipantBox = {
187
+ label: box.label,
188
+ x: left,
189
+ y: seq.padding,
190
+ width: right - left,
191
+ height: 0,
192
+ }
193
+ if (box.color !== undefined) positioned.color = box.color
194
+ return positioned
195
+ })
196
+
197
+ // 3. Stack messages vertically
198
+ let messageY = actorY + seq.actorHeight + seq.headerGap
199
+ const messages: PositionedMessage[] = []
200
+
201
+ // Pre-scan blocks to determine which message indices need extra vertical
202
+ // space for block headers (e.g. "alt [Valid credentials]") or divider
203
+ // labels (e.g. "[else Invalid]"). Without this, messages inside blocks
204
+ // overlap with the header/divider text that sits above them.
205
+ const extraSpaceBefore = new Map<number, number>()
206
+ for (const block of diagram.blocks) {
207
+ // First message in the block needs room for the block header label
208
+ const prev = extraSpaceBefore.get(block.startIndex) ?? 0
209
+ extraSpaceBefore.set(block.startIndex, Math.max(prev, seq.blockHeaderExtra))
210
+
211
+ // Each divider (else/and) needs room for the divider label
212
+ for (const div of block.dividers) {
213
+ const prevDiv = extraSpaceBefore.get(div.index) ?? 0
214
+ extraSpaceBefore.set(div.index, Math.max(prevDiv, seq.dividerExtra))
215
+ }
216
+ }
217
+
218
+ // Pre-group notes by the message index they follow, so we can position
219
+ // them inline during the message stacking loop (avoids overlap bugs).
220
+ const notesByAfterIndex = new Map<number, typeof diagram.notes>()
221
+ for (const note of diagram.notes) {
222
+ const list = notesByAfterIndex.get(note.afterIndex) ?? []
223
+ list.push(note)
224
+ notesByAfterIndex.set(note.afterIndex, list)
225
+ }
226
+ const positionedNotes: PositionedNote[] = []
227
+
228
+ // Handle notes that appear before the first message (afterIndex === -1)
229
+ const notesBeforeFirstMsg = notesByAfterIndex.get(-1)
230
+ if (notesBeforeFirstMsg && notesBeforeFirstMsg.length > 0) {
231
+ let noteY = messageY
232
+ for (const note of notesBeforeFirstMsg) {
233
+ const noteW = Math.max(
234
+ seq.noteWidth,
235
+ estimateTextWidth(
236
+ note.text,
237
+ fontSizes.edgeLabel,
238
+ FONT_WEIGHTS.edgeLabel,
239
+ ) +
240
+ seq.notePadX * 2,
241
+ )
242
+ const noteH = fontSizes.edgeLabel + seq.notePadY * 2
243
+
244
+ // X positioning based on actor position and note type
245
+ const firstActorIdx = actorIndex.get(note.actorIds[0] ?? '') ?? 0
246
+ let noteX: number
247
+ if (note.position === 'left') {
248
+ noteX =
249
+ actorCenterX[firstActorIdx]! -
250
+ actorWidths[firstActorIdx]! / 2 -
251
+ noteW -
252
+ seq.noteGap
253
+ } else if (note.position === 'right') {
254
+ noteX =
255
+ actorCenterX[firstActorIdx]! +
256
+ actorWidths[firstActorIdx]! / 2 +
257
+ seq.noteGap
258
+ } else {
259
+ // over — center between first and last actor
260
+ if (note.actorIds.length > 1) {
261
+ const lastActorIdx =
262
+ actorIndex.get(note.actorIds[note.actorIds.length - 1] ?? '') ??
263
+ firstActorIdx
264
+ noteX =
265
+ (actorCenterX[firstActorIdx]! + actorCenterX[lastActorIdx]!) / 2 -
266
+ noteW / 2
267
+ } else {
268
+ noteX = actorCenterX[firstActorIdx]! - noteW / 2
269
+ }
270
+ }
271
+
272
+ positionedNotes.push({
273
+ text: note.text,
274
+ x: noteX,
275
+ y: noteY,
276
+ width: noteW,
277
+ height: noteH,
278
+ position: note.position,
279
+ actors: note.actorIds,
280
+ })
281
+
282
+ noteY += noteH + seq.noteStackGap
283
+ }
284
+
285
+ messageY = Math.max(messageY, noteY + seq.messageRowHeight / 2)
286
+ }
287
+
288
+ // Track activation stack per actor: array of { startY, depth } objects
289
+ // Depth is used to offset nested activations horizontally for visual clarity
290
+ const activationStacks = new Map<
291
+ string,
292
+ { startY: number; depth: number }[]
293
+ >()
294
+ const activations: Activation[] = []
295
+ const nestingOffset = 4 // Horizontal offset per nesting level
296
+
297
+ /** Open an activation bar on `actorId` at row `y` (stacks for nesting). */
298
+ function startActivation(actorId: string, y: number): void {
299
+ const stack = activationStacks.get(actorId) ?? []
300
+ activationStacks.set(actorId, stack)
301
+ const depth = stack.length // Current depth before pushing
302
+ stack.push({ startY: y, depth })
303
+ }
304
+
305
+ /**
306
+ * Close the innermost open activation on `actorId` at row `y`. A
307
+ * deactivate with nothing open is ignored rather than fatal (Mermaid
308
+ * itself errors here; this renderer has always been lenient about it).
309
+ */
310
+ function endActivation(actorId: string, y: number): void {
311
+ const stack = activationStacks.get(actorId)
312
+ if (!stack || stack.length === 0) return
313
+ const { startY, depth } = stack.pop()!
314
+ const idx = actorIndex.get(actorId) ?? 0
315
+ // Offset nested activations to the right for visual distinction
316
+ const xOffset = depth * nestingOffset
317
+ activations.push({
318
+ actorId,
319
+ x: actorCenterX[idx]! - seq.activationWidth / 2 + xOffset,
320
+ topY: startY,
321
+ bottomY: y,
322
+ width: seq.activationWidth,
323
+ })
324
+ }
325
+
326
+ /**
327
+ * Standalone `activate`/`deactivate` statements, grouped by the message
328
+ * they follow. Applied at that message's row — the same row the `+`/`-`
329
+ * shorthand on that message would use — through the same two helpers, so
330
+ * the two spellings can't drift apart.
331
+ */
332
+ const activationEventsByAfterIndex = new Map<
333
+ number,
334
+ typeof diagram.activations
335
+ >()
336
+ for (const event of diagram.activations) {
337
+ const list = activationEventsByAfterIndex.get(event.afterIndex) ?? []
338
+ list.push(event)
339
+ activationEventsByAfterIndex.set(event.afterIndex, list)
340
+ }
341
+ function applyActivationEvents(afterIndex: number, y: number): void {
342
+ for (const event of activationEventsByAfterIndex.get(afterIndex) ?? []) {
343
+ if (event.kind === 'start') startActivation(event.actorId, y)
344
+ else endActivation(event.actorId, y)
345
+ }
346
+ }
347
+
348
+ // Events before the first message open at the top of the message area.
349
+ applyActivationEvents(-1, messageY)
350
+
351
+ for (let msgIdx = 0; msgIdx < diagram.messages.length; msgIdx++) {
352
+ const msg = diagram.messages[msgIdx]!
353
+ const fromIdx = actorIndex.get(msg.from) ?? 0
354
+ const toIdx = actorIndex.get(msg.to) ?? 0
355
+ const isSelf = msg.from === msg.to
356
+
357
+ // Add extra vertical space if this message sits below a block header or divider
358
+ const extra = extraSpaceBefore.get(msgIdx) ?? 0
359
+ if (extra > 0) messageY += extra
360
+
361
+ const x1 = actorCenterX[fromIdx]!
362
+ let x2 = actorCenterX[toIdx]!
363
+
364
+ // A `create participant` message ends at the near edge of the box it
365
+ // creates rather than at the lifeline centre — the box sits on this row
366
+ // (see the created-actor placement after this loop), so an arrow to the
367
+ // centre would run underneath it. Mermaid's renderer makes the same
368
+ // adjustment (`receiverAdjustment(actor, width / 2)`).
369
+ const createsRecipient =
370
+ !isSelf && diagram.actors[toIdx]!.createdAt === msgIdx
371
+ if (createsRecipient) {
372
+ const halfW = actorWidths[toIdx]! / 2
373
+ x2 += x1 < x2 ? -halfW : halfW
374
+ }
375
+
376
+ messages.push({
377
+ from: msg.from,
378
+ to: msg.to,
379
+ label: msg.label,
380
+ lineStyle: msg.lineStyle,
381
+ arrowHead: msg.arrowHead,
382
+ x1,
383
+ x2,
384
+ y: messageY,
385
+ isSelf,
386
+ bidirectional: msg.bidirectional ?? false,
387
+ seqNumber: msg.seqNumber,
388
+ })
389
+
390
+ // Handle activation - track nesting depth for visual offset. The `+`
391
+ // shorthand activates the recipient, `-` deactivates the sender (see
392
+ // parser.ts); standalone statements following this message apply at
393
+ // the same row, after the shorthand, in source order.
394
+ if (msg.activate) startActivation(msg.to, messageY)
395
+ if (msg.deactivate) endActivation(msg.from, messageY)
396
+ applyActivationEvents(msgIdx, messageY)
397
+
398
+ // Advance messageY past the message itself
399
+ messageY += isSelf
400
+ ? seq.selfMessageHeight + seq.messageRowHeight
401
+ : seq.messageRowHeight
402
+ // The created box is centred on this row, so its lower half hangs
403
+ // below the arrow — give the next row room to clear it (Mermaid bumps
404
+ // by the same half-height after a creating message).
405
+ if (createsRecipient) messageY += seq.actorHeight / 2
406
+
407
+ // Position notes that appear after this message.
408
+ // Notes start below the self-message loop (if self) or below the arrow,
409
+ // and consecutive notes stack vertically. If notes extend beyond the
410
+ // normal message advance, push messageY further so subsequent messages
411
+ // don't overlap.
412
+ const notesForMsg = notesByAfterIndex.get(msgIdx)
413
+ if (notesForMsg && notesForMsg.length > 0) {
414
+ // Self-message loops extend selfMessageHeight below msg.y;
415
+ // normal arrows sit at msg.y with no extension below.
416
+ const selfLoopExtra = isSelf ? seq.selfMessageHeight : 0
417
+ let noteY =
418
+ messages[msgIdx]!.y + selfLoopExtra + seq.noteOffsetAfterMessage
419
+
420
+ for (const note of notesForMsg) {
421
+ const noteW = Math.max(
422
+ seq.noteWidth,
423
+ estimateTextWidth(
424
+ note.text,
425
+ fontSizes.edgeLabel,
426
+ FONT_WEIGHTS.edgeLabel,
427
+ ) +
428
+ seq.notePadX * 2,
429
+ )
430
+ const noteH = fontSizes.edgeLabel + seq.notePadY * 2
431
+
432
+ // X positioning based on actor position and note type
433
+ const firstActorIdx = actorIndex.get(note.actorIds[0] ?? '') ?? 0
434
+ let noteX: number
435
+ if (note.position === 'left') {
436
+ noteX =
437
+ actorCenterX[firstActorIdx]! -
438
+ actorWidths[firstActorIdx]! / 2 -
439
+ noteW -
440
+ seq.noteGap
441
+ } else if (note.position === 'right') {
442
+ noteX =
443
+ actorCenterX[firstActorIdx]! +
444
+ actorWidths[firstActorIdx]! / 2 +
445
+ seq.noteGap
446
+ } else {
447
+ // over — center between first and last actor
448
+ if (note.actorIds.length > 1) {
449
+ const lastActorIdx =
450
+ actorIndex.get(note.actorIds[note.actorIds.length - 1] ?? '') ??
451
+ firstActorIdx
452
+ noteX =
453
+ (actorCenterX[firstActorIdx]! + actorCenterX[lastActorIdx]!) / 2 -
454
+ noteW / 2
455
+ } else {
456
+ noteX = actorCenterX[firstActorIdx]! - noteW / 2
457
+ }
458
+ }
459
+
460
+ positionedNotes.push({
461
+ text: note.text,
462
+ x: noteX,
463
+ y: noteY,
464
+ width: noteW,
465
+ height: noteH,
466
+ position: note.position,
467
+ actors: note.actorIds,
468
+ })
469
+
470
+ noteY += noteH + seq.noteStackGap // Stack next note below with gap
471
+ }
472
+
473
+ // Push messageY forward if notes extended beyond the normal advance.
474
+ // Add half a row height so the next message's label (rendered at msg.y - 6)
475
+ // has clearance from the last note's bottom edge.
476
+ messageY = Math.max(messageY, noteY + seq.messageRowHeight / 2)
477
+ }
478
+ }
479
+
480
+ // Close any unclosed activations (preserving depth for offset)
481
+ for (const [actorId, stack] of activationStacks) {
482
+ for (const { startY, depth } of stack) {
483
+ const idx = actorIndex.get(actorId) ?? 0
484
+ const xOffset = depth * nestingOffset
485
+ activations.push({
486
+ actorId,
487
+ x: actorCenterX[idx]! - seq.activationWidth / 2 + xOffset,
488
+ topY: startY,
489
+ bottomY: messageY - seq.messageRowHeight / 2,
490
+ width: seq.activationWidth,
491
+ })
492
+ }
493
+ }
494
+
495
+ // 4. Position blocks (loop/alt/opt)
496
+ const blocks: PositionedBlock[] = diagram.blocks.map((block) => {
497
+ // Block spans from the Y of startIndex to endIndex messages
498
+ const startMsg = messages[block.startIndex]
499
+ const endMsg = messages[block.endIndex]
500
+ const blockTop = (startMsg?.y ?? messageY) - seq.blockPadTop
501
+ const blockBottom = (endMsg?.y ?? messageY) + seq.blockPadBottom + 12
502
+
503
+ // Block width spans all actors involved in its messages
504
+ const involvedActors = new Set<number>()
505
+ for (let mi = block.startIndex; mi <= block.endIndex; mi++) {
506
+ const m = diagram.messages[mi]
507
+ if (m) {
508
+ involvedActors.add(actorIndex.get(m.from) ?? 0)
509
+ involvedActors.add(actorIndex.get(m.to) ?? 0)
510
+ }
511
+ }
512
+ // Fallback: span all actors if none involved
513
+ if (involvedActors.size === 0) {
514
+ for (let ai = 0; ai < diagram.actors.length; ai++) involvedActors.add(ai)
515
+ }
516
+ const minIdx = Math.min(...involvedActors)
517
+ const maxIdx = Math.max(...involvedActors)
518
+ const blockLeft =
519
+ actorCenterX[minIdx]! - actorWidths[minIdx]! / 2 - seq.blockPadX
520
+ const blockRight =
521
+ actorCenterX[maxIdx]! + actorWidths[maxIdx]! / 2 + seq.blockPadX
522
+
523
+ // Position dividers — offset from message Y so the divider label text
524
+ // (rendered at divider.y + 14 in the renderer) clears the message label
525
+ // (rendered at msg.y - 6).
526
+ //
527
+ // Default offset 28 gives ~8px baseline clearance, which is sufficient
528
+ // when the divider label (left-aligned at block edge) and message label
529
+ // (centered between actors) don't share horizontal space. When they DO
530
+ // overlap horizontally (e.g. long divider labels like "[Account locked]"
531
+ // next to centered message labels like "403 Forbidden"), we increase the
532
+ // offset to 36 so text bounding boxes have ~5px visual clearance.
533
+ const dividers = block.dividers.map((d) => {
534
+ const msg = messages[d.index]
535
+ const msgY = msg?.y ?? messageY
536
+ let offset = 28
537
+
538
+ // Dynamic overlap detection: increase offset when the divider label
539
+ // and message label occupy the same horizontal region, which would
540
+ // cause vertical text overlap at the default 8px baseline gap.
541
+ if (d.label && msg?.label) {
542
+ const divLabelText = `[${d.label}]`
543
+ const divLabelW = estimateTextWidth(
544
+ divLabelText,
545
+ fontSizes.edgeLabel,
546
+ FONT_WEIGHTS.edgeLabel,
547
+ )
548
+ const divLabelLeft = blockLeft + 8
549
+ const divLabelRight = divLabelLeft + divLabelW
550
+
551
+ const msgLabelW = estimateTextWidth(
552
+ msg.label,
553
+ fontSizes.edgeLabel,
554
+ FONT_WEIGHTS.edgeLabel,
555
+ )
556
+ // Self-messages render labels at x1 + 36 (left-aligned); normal
557
+ // messages center the label between the two actor lifelines.
558
+ const msgLabelLeft = msg.isSelf
559
+ ? msg.x1 + 36
560
+ : (msg.x1 + msg.x2) / 2 - msgLabelW / 2
561
+ const msgLabelRight = msgLabelLeft + msgLabelW
562
+
563
+ if (divLabelRight > msgLabelLeft && divLabelLeft < msgLabelRight) {
564
+ offset = 36
565
+ }
566
+ }
567
+
568
+ return { y: msgY - offset, label: d.label }
569
+ })
570
+
571
+ return {
572
+ type: block.type,
573
+ label: block.label,
574
+ x: blockLeft,
575
+ y: blockTop,
576
+ width: blockRight - blockLeft,
577
+ height: blockBottom - blockTop,
578
+ dividers,
579
+ }
580
+ })
581
+
582
+ // 5. Notes — already positioned inline during the message stacking loop
583
+ // (step 3) to properly account for self-message loops and vertical stacking.
584
+ const notes = positionedNotes
585
+
586
+ // 6. Bounding-box post-processing
587
+ //
588
+ // Notes positioned "left of" the first actor or "right of" the last actor
589
+ // can extend beyond the actor-based viewport. Compute the true bounding box
590
+ // across all positioned elements, then shift everything right if anything
591
+ // extends left of the desired padding margin and expand the width to fit.
592
+ const diagramBottom = messageY + seq.padding
593
+
594
+ // Find global X extents across actors, blocks, notes, and message labels
595
+ let globalMinX: number = seq.padding // actors already start at seq.padding
596
+ let globalMaxX = 0
597
+ for (const a of actors) {
598
+ globalMinX = Math.min(globalMinX, a.x - a.width / 2)
599
+ globalMaxX = Math.max(globalMaxX, a.x + a.width / 2)
600
+ }
601
+ for (const b of blocks) {
602
+ globalMinX = Math.min(globalMinX, b.x)
603
+ globalMaxX = Math.max(globalMaxX, b.x + b.width)
604
+ }
605
+ for (const n of notes) {
606
+ globalMinX = Math.min(globalMinX, n.x)
607
+ globalMaxX = Math.max(globalMaxX, n.x + n.width)
608
+ }
609
+ for (const b of boxes) {
610
+ globalMinX = Math.min(globalMinX, b.x)
611
+ globalMaxX = Math.max(globalMaxX, b.x + b.width)
612
+ }
613
+ // Include self-message labels in bounding box — they extend to the right of the actor
614
+ // and could be clipped if not accounted for in the SVG width
615
+ for (const m of messages) {
616
+ if (m.isSelf && m.label) {
617
+ const loopW = 30 // matches renderer loopW
618
+ const labelPadding = 8
619
+ const labelLeft = m.x1 + loopW + labelPadding
620
+ const labelWidth = estimateTextWidth(
621
+ m.label,
622
+ fontSizes.edgeLabel,
623
+ FONT_WEIGHTS.edgeLabel,
624
+ )
625
+ globalMaxX = Math.max(globalMaxX, labelLeft + labelWidth + 8) // +8 for safety margin
626
+ }
627
+ }
628
+
629
+ // If elements extend left of the desired padding, shift everything right
630
+ const shiftX = globalMinX < seq.padding ? seq.padding - globalMinX : 0
631
+ if (shiftX > 0) {
632
+ for (const a of actors) a.x += shiftX
633
+ for (const m of messages) {
634
+ m.x1 += shiftX
635
+ m.x2 += shiftX
636
+ }
637
+ for (const act of activations) act.x += shiftX
638
+ for (const b of blocks) {
639
+ b.x += shiftX
640
+ }
641
+ for (const n of notes) n.x += shiftX
642
+ for (const b of boxes) b.x += shiftX
643
+ // Also shift actor center X array (used for lifelines below)
644
+ for (let i = 0; i < actorCenterX.length; i++) actorCenterX[i]! += shiftX
645
+ }
646
+
647
+ // 7. Participant lifecycle: a created participant's box moves from the
648
+ // header down to its creating message's row, centred on the arrow
649
+ // (Mermaid: `actor.starty = lineStartY - actor.height / 2`), and a
650
+ // destroyed participant's lifeline stops at its destroying message.
651
+ // Both indices come from the parser, which already verified the
652
+ // message exists and involves the actor — the `?? messageY` fallbacks
653
+ // only guard the type, not a reachable state.
654
+ for (let i = 0; i < diagram.actors.length; i++) {
655
+ const createdAt = diagram.actors[i]!.createdAt
656
+ if (createdAt !== undefined) {
657
+ actors[i]!.y = (messages[createdAt]?.y ?? messageY) - seq.actorHeight / 2
658
+ }
659
+ }
660
+
661
+ // 8. Calculate final lifelines (after shift so X positions are correct)
662
+ const lifelines: Lifeline[] = diagram.actors.map((a, i) => {
663
+ const destroyedAt = a.destroyedAt
664
+ const lifeline: Lifeline = {
665
+ actorId: a.id,
666
+ x: actorCenterX[i]!,
667
+ topY: actors[i]!.y + seq.actorHeight,
668
+ bottomY:
669
+ destroyedAt === undefined
670
+ ? diagramBottom - seq.padding
671
+ : (messages[destroyedAt]?.y ?? messageY),
672
+ }
673
+ if (destroyedAt !== undefined) lifeline.destroyed = true
674
+ return lifeline
675
+ })
676
+
677
+ // Box backgrounds run from the top padding to just under the lifeline
678
+ // bottoms (boxPad < padding, so this stays inside the diagram height).
679
+ for (const b of boxes) {
680
+ b.height = diagramBottom - seq.padding + seq.boxPad - b.y
681
+ }
682
+
683
+ // 9. Calculate diagram dimensions from the bounding box
684
+ const diagramWidth = globalMaxX + shiftX + seq.padding
685
+ const diagramHeight = diagramBottom
686
+
687
+ return {
688
+ width: Math.max(diagramWidth, 200),
689
+ height: Math.max(diagramHeight, 100),
690
+ actors,
691
+ lifelines,
692
+ messages,
693
+ activations,
694
+ blocks,
695
+ notes,
696
+ boxes,
697
+ }
698
+ }