@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -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
|
+
}
|