@zombie-mermaid/svg-renderer 4.2.2 → 5.0.0

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