@tldraw/mermaid 4.6.0-internal.c7df3c92455a

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 (55) hide show
  1. package/dist-cjs/blueprint.js +17 -0
  2. package/dist-cjs/blueprint.js.map +7 -0
  3. package/dist-cjs/colors.js +173 -0
  4. package/dist-cjs/colors.js.map +7 -0
  5. package/dist-cjs/createMermaidDiagram.js +144 -0
  6. package/dist-cjs/createMermaidDiagram.js.map +7 -0
  7. package/dist-cjs/flowchartDiagram.js +202 -0
  8. package/dist-cjs/flowchartDiagram.js.map +7 -0
  9. package/dist-cjs/index.d.ts +114 -0
  10. package/dist-cjs/index.js +34 -0
  11. package/dist-cjs/index.js.map +7 -0
  12. package/dist-cjs/renderBlueprint.js +314 -0
  13. package/dist-cjs/renderBlueprint.js.map +7 -0
  14. package/dist-cjs/sequenceDiagram.js +686 -0
  15. package/dist-cjs/sequenceDiagram.js.map +7 -0
  16. package/dist-cjs/stateDiagram.js +373 -0
  17. package/dist-cjs/stateDiagram.js.map +7 -0
  18. package/dist-cjs/svgParsing.js +187 -0
  19. package/dist-cjs/svgParsing.js.map +7 -0
  20. package/dist-cjs/utils.js +75 -0
  21. package/dist-cjs/utils.js.map +7 -0
  22. package/dist-esm/blueprint.mjs +1 -0
  23. package/dist-esm/blueprint.mjs.map +7 -0
  24. package/dist-esm/colors.mjs +153 -0
  25. package/dist-esm/colors.mjs.map +7 -0
  26. package/dist-esm/createMermaidDiagram.mjs +114 -0
  27. package/dist-esm/createMermaidDiagram.mjs.map +7 -0
  28. package/dist-esm/flowchartDiagram.mjs +188 -0
  29. package/dist-esm/flowchartDiagram.mjs.map +7 -0
  30. package/dist-esm/index.d.mts +114 -0
  31. package/dist-esm/index.mjs +14 -0
  32. package/dist-esm/index.mjs.map +7 -0
  33. package/dist-esm/renderBlueprint.mjs +298 -0
  34. package/dist-esm/renderBlueprint.mjs.map +7 -0
  35. package/dist-esm/sequenceDiagram.mjs +666 -0
  36. package/dist-esm/sequenceDiagram.mjs.map +7 -0
  37. package/dist-esm/stateDiagram.mjs +359 -0
  38. package/dist-esm/stateDiagram.mjs.map +7 -0
  39. package/dist-esm/svgParsing.mjs +167 -0
  40. package/dist-esm/svgParsing.mjs.map +7 -0
  41. package/dist-esm/utils.mjs +55 -0
  42. package/dist-esm/utils.mjs.map +7 -0
  43. package/package.json +62 -0
  44. package/src/blueprint.ts +75 -0
  45. package/src/colors.ts +215 -0
  46. package/src/createMermaidDiagram.test.ts +31 -0
  47. package/src/createMermaidDiagram.ts +155 -0
  48. package/src/flowchartDiagram.ts +232 -0
  49. package/src/index.ts +18 -0
  50. package/src/mermaidDiagrams.test.ts +880 -0
  51. package/src/renderBlueprint.ts +373 -0
  52. package/src/sequenceDiagram.ts +851 -0
  53. package/src/stateDiagram.ts +477 -0
  54. package/src/svgParsing.ts +240 -0
  55. package/src/utils.ts +73 -0
@@ -0,0 +1,851 @@
1
+ import type { SequenceDB } from 'mermaid/dist/diagrams/sequence/sequenceDb.d.ts'
2
+ import type { Actor, Message } from 'mermaid/dist/diagrams/sequence/types.js'
3
+ import { TLArrowShapeArrowheadStyle, TLDefaultDashStyle, TLGeoShape } from 'tldraw'
4
+ import type {
5
+ DiagramMermaidBlueprint,
6
+ MermaidBlueprintEdge,
7
+ MermaidBlueprintGeoNode,
8
+ MermaidBlueprintLineNode,
9
+ } from './blueprint'
10
+ import { parseRgbToTldrawColor } from './colors'
11
+ import { getAccumulatedTranslate } from './svgParsing'
12
+
13
+ export interface SvgRect {
14
+ x: number
15
+ y: number
16
+ w: number
17
+ h: number
18
+ }
19
+
20
+ export interface ActorLayout {
21
+ x: number
22
+ y: number
23
+ w: number
24
+ h: number
25
+ bottomY: number
26
+ }
27
+
28
+ export interface ParsedSequenceLayout {
29
+ actorLayouts: ActorLayout[]
30
+ noteRects: SvgRect[]
31
+ }
32
+
33
+ const LINETYPE = {
34
+ SOLID: 0,
35
+ DOTTED: 1,
36
+ NOTE: 2,
37
+ SOLID_CROSS: 3,
38
+ DOTTED_CROSS: 4,
39
+ SOLID_OPEN: 5,
40
+ DOTTED_OPEN: 6,
41
+ LOOP_START: 10,
42
+ LOOP_END: 11,
43
+ ALT_START: 12,
44
+ ALT_ELSE: 13,
45
+ ALT_END: 14,
46
+ OPT_START: 15,
47
+ OPT_END: 16,
48
+ ACTIVE_START: 17,
49
+ ACTIVE_END: 18,
50
+ PAR_START: 19,
51
+ PAR_AND: 20,
52
+ PAR_END: 21,
53
+ RECT_START: 22,
54
+ RECT_END: 23,
55
+ SOLID_POINT: 24,
56
+ DOTTED_POINT: 25,
57
+ AUTONUMBER: 26,
58
+ CRITICAL_START: 27,
59
+ CRITICAL_OPTION: 28,
60
+ CRITICAL_END: 29,
61
+ BREAK_START: 30,
62
+ BREAK_END: 31,
63
+ PAR_OVER_START: 32,
64
+ BIDIRECTIONAL_SOLID: 33,
65
+ BIDIRECTIONAL_DOTTED: 34,
66
+ } as const satisfies SequenceDB['LINETYPE']
67
+
68
+ const PLACEMENT = {
69
+ LEFTOF: 0,
70
+ RIGHTOF: 1,
71
+ OVER: 2,
72
+ } as const satisfies SequenceDB['PLACEMENT']
73
+
74
+ const signalTypes: number[] = [
75
+ LINETYPE.SOLID,
76
+ LINETYPE.DOTTED,
77
+ LINETYPE.SOLID_CROSS,
78
+ LINETYPE.DOTTED_CROSS,
79
+ LINETYPE.SOLID_OPEN,
80
+ LINETYPE.DOTTED_OPEN,
81
+ LINETYPE.SOLID_POINT,
82
+ LINETYPE.DOTTED_POINT,
83
+ LINETYPE.BIDIRECTIONAL_SOLID,
84
+ LINETYPE.BIDIRECTIONAL_DOTTED,
85
+ ]
86
+
87
+ function isSignalMessage(type: number | undefined): boolean {
88
+ if (type === undefined) return false
89
+ return signalTypes.includes(type)
90
+ }
91
+
92
+ function isNoteMessage(type: number | undefined): boolean {
93
+ return type === LINETYPE.NOTE
94
+ }
95
+
96
+ function isActiveStart(type: number | undefined): boolean {
97
+ return type === LINETYPE.ACTIVE_START
98
+ }
99
+
100
+ function isActiveEnd(type: number | undefined): boolean {
101
+ return type === LINETYPE.ACTIVE_END
102
+ }
103
+
104
+ function isAutonumber(type: number | undefined): boolean {
105
+ return type === LINETYPE.AUTONUMBER
106
+ }
107
+
108
+ /** Returns the fragment keyword (e.g. "loop", "opt") if this message starts a combined fragment, or null. */
109
+ function getFragmentStartKeyword(type: number | undefined): string | null {
110
+ if (type === undefined) return null
111
+ if (type === LINETYPE.LOOP_START) return 'loop'
112
+ if (type === LINETYPE.ALT_START) return 'alt'
113
+ if (type === LINETYPE.OPT_START) return 'opt'
114
+ if (type === LINETYPE.PAR_START) return 'par'
115
+ if (type === LINETYPE.RECT_START) return 'rect'
116
+ if (type === LINETYPE.CRITICAL_START) return 'critical'
117
+ if (type === LINETYPE.BREAK_START) return 'break'
118
+ if (type === LINETYPE.PAR_OVER_START) return 'par'
119
+ return null
120
+ }
121
+
122
+ function isFragmentEnd(type: number | undefined): boolean {
123
+ if (type === undefined) return false
124
+ const endTypes: number[] = [
125
+ LINETYPE.LOOP_END,
126
+ LINETYPE.ALT_END,
127
+ LINETYPE.OPT_END,
128
+ LINETYPE.PAR_END,
129
+ LINETYPE.RECT_END,
130
+ LINETYPE.CRITICAL_END,
131
+ LINETYPE.BREAK_END,
132
+ ]
133
+ return endTypes.includes(type)
134
+ }
135
+
136
+ /** Returns a keyword if this message is a section separator within a combined fragment, or null. */
137
+ function getFragmentSeparatorKeyword(type: number | undefined): string | null {
138
+ if (type === LINETYPE.ALT_ELSE) return 'else'
139
+ if (type === LINETYPE.PAR_AND) return 'and'
140
+ if (type === LINETYPE.CRITICAL_OPTION) return 'option'
141
+ return null
142
+ }
143
+
144
+ function mapParticipantTypeToGeo(type: string): TLGeoShape['props']['geo'] {
145
+ switch (type) {
146
+ case 'actor':
147
+ return 'ellipse'
148
+ case 'database':
149
+ return 'oval'
150
+ default:
151
+ return 'rectangle'
152
+ }
153
+ }
154
+
155
+ /** Map a Mermaid LINETYPE value to tldraw arrow props. */
156
+ function mapLineTypeToArrowProps(type: number): {
157
+ dash: TLDefaultDashStyle
158
+ arrowheadEnd: TLArrowShapeArrowheadStyle
159
+ } {
160
+ switch (type) {
161
+ case LINETYPE.SOLID:
162
+ return { dash: 'solid', arrowheadEnd: 'arrow' }
163
+ case LINETYPE.DOTTED:
164
+ return { dash: 'dotted', arrowheadEnd: 'arrow' }
165
+ case LINETYPE.SOLID_CROSS:
166
+ return { dash: 'solid', arrowheadEnd: 'bar' }
167
+ case LINETYPE.DOTTED_CROSS:
168
+ return { dash: 'dotted', arrowheadEnd: 'bar' }
169
+ case LINETYPE.SOLID_OPEN:
170
+ return { dash: 'solid', arrowheadEnd: 'none' }
171
+ case LINETYPE.DOTTED_OPEN:
172
+ return { dash: 'dotted', arrowheadEnd: 'none' }
173
+ case LINETYPE.SOLID_POINT:
174
+ return { dash: 'solid', arrowheadEnd: 'arrow' }
175
+ case LINETYPE.DOTTED_POINT:
176
+ return { dash: 'dotted', arrowheadEnd: 'arrow' }
177
+ case LINETYPE.BIDIRECTIONAL_SOLID:
178
+ return { dash: 'solid', arrowheadEnd: 'arrow' }
179
+ case LINETYPE.BIDIRECTIONAL_DOTTED:
180
+ return { dash: 'dotted', arrowheadEnd: 'arrow' }
181
+ default:
182
+ return { dash: 'solid', arrowheadEnd: 'arrow' }
183
+ }
184
+ }
185
+
186
+ function isBidirectional(type: number): boolean {
187
+ return type === LINETYPE.BIDIRECTIONAL_SOLID || type === LINETYPE.BIDIRECTIONAL_DOTTED
188
+ }
189
+
190
+ const TARGET_ACTOR_SPACING = 300
191
+ const MIN_VERTICAL_GAP = 400
192
+ const FALLBACK_ACTOR_WIDTH = 200
193
+ const FALLBACK_ACTOR_HEIGHT = 70
194
+ const FALLBACK_ACTOR_SPACING = 100
195
+ const FALLBACK_EVENT_SPACING = 80
196
+ const FALLBACK_NOTE_WIDTH = 120
197
+ const FALLBACK_NOTE_HEIGHT = 50
198
+ const NOTE_PADDING = 5
199
+ // tldraw's hand-drawn font is wider than Mermaid's default, so we estimate
200
+ // the minimum note width from the label text to prevent wrapping.
201
+ const NOTE_CHAR_WIDTH = 11
202
+ const NOTE_TEXT_PADDING = 40
203
+ const FRAGMENT_PADDING_X = 30
204
+ const FRAGMENT_PADDING_TOP = 50
205
+ const FRAGMENT_PADDING_BOTTOM = 25
206
+ const ACTOR_PADDING_WIDTH = 30
207
+ const ACTOR_PADDING_HEIGHT = 10
208
+ const SELF_MSG_Y_OFFSET = 0.04
209
+ const SELF_MSG_BEND = -80
210
+ const ACTIVATION_BOX_WIDTH = 20
211
+ const ACTIVATION_NEST_OFFSET = 6
212
+ const ACTIVATION_PAD_RATIO = 0.15
213
+ const FRAGMENT_SECTION_LABEL_HEIGHT = 25
214
+ const FRAGMENT_SECTION_LABEL_PADDING = 5
215
+
216
+ interface FragmentSection {
217
+ title: string
218
+ firstEventIndex: number
219
+ }
220
+
221
+ interface OpenFragment {
222
+ keyword: string
223
+ sections: FragmentSection[]
224
+ firstEventIndex: number
225
+ actorKeys: Set<string>
226
+ }
227
+
228
+ interface FragmentSpan extends OpenFragment {
229
+ lastEventIndex: number
230
+ }
231
+
232
+ interface ActivationSpan {
233
+ participantKey: string
234
+ startEventIndex: number
235
+ endEventIndex: number
236
+ }
237
+
238
+ function parseSvgRects(root: Element, selector: string): SvgRect[] {
239
+ const results: SvgRect[] = []
240
+ for (const rect of root.querySelectorAll(selector)) {
241
+ const ancestor = getAccumulatedTranslate(rect)
242
+ const x = parseFloat(rect.getAttribute('x') || '0')
243
+ const y = parseFloat(rect.getAttribute('y') || '0')
244
+ const w = parseFloat(rect.getAttribute('width') || '0')
245
+ const h = parseFloat(rect.getAttribute('height') || '0')
246
+ if (w > 0 && h > 0) {
247
+ results.push({
248
+ x: Number.isFinite(ancestor.x + x) ? ancestor.x + x : 0,
249
+ y: Number.isFinite(ancestor.y + y) ? ancestor.y + y : 0,
250
+ w,
251
+ h,
252
+ })
253
+ }
254
+ }
255
+ return results
256
+ }
257
+
258
+ /**
259
+ * Stick-figure (actor) participants use SVG <line> elements instead of <rect>.
260
+ * We derive bounding rectangles from the min/max coordinates of each actor-man group.
261
+ */
262
+ function parseActorManRects(root: Element): SvgRect[] {
263
+ const results: SvgRect[] = []
264
+ for (const group of root.querySelectorAll('g.actor-man')) {
265
+ const ancestor = getAccumulatedTranslate(group)
266
+ let minX = Infinity
267
+ let minY = Infinity
268
+ let maxX = -Infinity
269
+ let maxY = -Infinity
270
+ for (const line of group.querySelectorAll('line')) {
271
+ for (const attr of ['x1', 'x2']) {
272
+ const coord = parseFloat(line.getAttribute(attr) || '0')
273
+ if (coord < minX) minX = coord
274
+ if (coord > maxX) maxX = coord
275
+ }
276
+ for (const attr of ['y1', 'y2']) {
277
+ const coord = parseFloat(line.getAttribute(attr) || '0')
278
+ if (coord < minY) minY = coord
279
+ if (coord > maxY) maxY = coord
280
+ }
281
+ }
282
+ if (Number.isFinite(minX) && Number.isFinite(minY)) {
283
+ results.push({
284
+ x: ancestor.x + minX,
285
+ y: ancestor.y + minY,
286
+ w: maxX - minX || FALLBACK_ACTOR_WIDTH,
287
+ h: maxY - minY || FALLBACK_ACTOR_HEIGHT,
288
+ })
289
+ }
290
+ }
291
+ return results
292
+ }
293
+
294
+ function computeActorLayouts(root: Element, actorCount: number, eventCount: number): ActorLayout[] {
295
+ const byX = (a: SvgRect, b: SvgRect) => a.x - b.x
296
+ let top = parseSvgRects(root, 'rect.actor-top').sort(byX)
297
+ let bottom = parseSvgRects(root, 'rect.actor-bottom').sort(byX)
298
+
299
+ const actorManRects = parseActorManRects(root).sort(byX)
300
+ if (actorManRects.length > 0 && (top.length < actorCount || bottom.length < actorCount)) {
301
+ const midY =
302
+ top.length > 0 && bottom.length > 0
303
+ ? (Math.max(...top.map((r) => r.y + r.h)) + Math.min(...bottom.map((r) => r.y))) / 2
304
+ : actorManRects.length >= 2
305
+ ? (actorManRects[0].y + actorManRects[actorManRects.length - 1].y) / 2
306
+ : 0
307
+ for (const rect of actorManRects) {
308
+ if (rect.y < midY) top.push(rect)
309
+ else bottom.push(rect)
310
+ }
311
+ top.sort(byX)
312
+ bottom.sort(byX)
313
+ }
314
+
315
+ if (top.length < actorCount || bottom.length < actorCount) {
316
+ const all = parseSvgRects(root, 'rect[class*="actor"]').sort((a, b) => a.y - b.y)
317
+ if (all.length >= 2 * actorCount) {
318
+ let maxGap = 0
319
+ let splitAt = actorCount
320
+ for (let i = 1; i < all.length; i++) {
321
+ const gap = all[i].y - all[i - 1].y
322
+ if (gap > maxGap) {
323
+ maxGap = gap
324
+ splitAt = i
325
+ }
326
+ }
327
+ top = all.slice(0, splitAt).sort(byX).slice(0, actorCount)
328
+ bottom = all.slice(splitAt).sort(byX).slice(0, actorCount)
329
+ } else {
330
+ top = []
331
+ bottom = []
332
+ }
333
+ } else {
334
+ top = top.slice(0, actorCount)
335
+ bottom = bottom.slice(0, actorCount)
336
+ }
337
+
338
+ if (actorManRects.length > 0) {
339
+ const actorManSet = new Set<SvgRect>(actorManRects)
340
+ const regularTops = top.filter((r) => !actorManSet.has(r))
341
+ const regularBottoms = bottom.filter((r) => !actorManSet.has(r))
342
+ const refHeight =
343
+ regularTops.length > 0 ? Math.max(...regularTops.map((r) => r.h)) : FALLBACK_ACTOR_HEIGHT
344
+ const refWidth =
345
+ regularTops.length > 0 ? Math.max(...regularTops.map((r) => r.w)) : FALLBACK_ACTOR_WIDTH
346
+ const refTopY = regularTops.length > 0 ? Math.min(...regularTops.map((r) => r.y)) : undefined
347
+ const refBottomEndY =
348
+ regularBottoms.length > 0 ? Math.max(...regularBottoms.map((r) => r.y + r.h)) : undefined
349
+
350
+ for (const rect of top) {
351
+ if (!actorManSet.has(rect)) continue
352
+ const centerY = rect.y + rect.h / 2
353
+ rect.h = refHeight
354
+ rect.w = Math.max(rect.w, refWidth)
355
+ rect.y = refTopY !== undefined ? refTopY : centerY - refHeight / 2
356
+ }
357
+ for (const rect of bottom) {
358
+ if (!actorManSet.has(rect)) continue
359
+ const centerY = rect.y + rect.h / 2
360
+ rect.h = refHeight
361
+ rect.w = Math.max(rect.w, refWidth)
362
+ rect.y = refBottomEndY !== undefined ? refBottomEndY - refHeight : centerY - refHeight / 2
363
+ }
364
+ }
365
+
366
+ if (top.length >= actorCount && bottom.length >= actorCount) {
367
+ const svgCenters = top.map((r) => r.x + r.w / 2)
368
+ const spacings: number[] = []
369
+ for (let i = 1; i < svgCenters.length; i++) {
370
+ spacings.push(svgCenters[i] - svgCenters[i - 1])
371
+ }
372
+ const minSpacing = spacings.length > 0 ? Math.min(...spacings) : 1
373
+ const scale = Math.max(1, TARGET_ACTOR_SPACING / minSpacing)
374
+
375
+ const xCenters = svgCenters.map((c) => (c - svgCenters[0]) * scale)
376
+ const totalSpan = xCenters.length > 1 ? xCenters[xCenters.length - 1] : 0
377
+
378
+ const topRowBottom = Math.max(...top.map((r) => r.y + r.h))
379
+ const bottomRowTop = Math.min(...bottom.map((r) => r.y))
380
+ const yStretch = Math.max(0, MIN_VERTICAL_GAP - (bottomRowTop - topRowBottom))
381
+ const topMinY = Math.min(...top.map((r) => r.y))
382
+ const bottomMaxY = Math.max(...bottom.map((r) => r.y + r.h))
383
+ const originY = -(bottomMaxY + yStretch + topMinY) / 2
384
+
385
+ return top.map((topRect, i) => {
386
+ const w = topRect.w + ACTOR_PADDING_WIDTH
387
+ const h = topRect.h + ACTOR_PADDING_HEIGHT
388
+ return {
389
+ x: xCenters[i] - totalSpan / 2 - w / 2,
390
+ y: originY + topRect.y,
391
+ w,
392
+ h,
393
+ bottomY: originY + bottom[i].y + yStretch,
394
+ }
395
+ })
396
+ }
397
+
398
+ const fallbackLifelineHeight = Math.max(300, eventCount * FALLBACK_EVENT_SPACING)
399
+ const totalWidth = actorCount * FALLBACK_ACTOR_WIDTH + (actorCount - 1) * FALLBACK_ACTOR_SPACING
400
+ const totalHeight = FALLBACK_ACTOR_HEIGHT * 2 + fallbackLifelineHeight
401
+ const startX = -totalWidth / 2
402
+ const startY = -totalHeight / 2
403
+ return Array.from({ length: actorCount }, (_, i) => ({
404
+ x: startX + i * (FALLBACK_ACTOR_WIDTH + FALLBACK_ACTOR_SPACING),
405
+ y: startY,
406
+ w: FALLBACK_ACTOR_WIDTH,
407
+ h: FALLBACK_ACTOR_HEIGHT,
408
+ bottomY: startY + totalHeight - FALLBACK_ACTOR_HEIGHT,
409
+ }))
410
+ }
411
+
412
+ function getMessageLabel(msg: Message): string | undefined {
413
+ return typeof msg.message === 'string' ? msg.message : undefined
414
+ }
415
+
416
+ /** Count how many renderable events (signals + notes) a message list contains. */
417
+ export function countSequenceEvents(messages: Message[]): number {
418
+ let count = 0
419
+ for (const msg of messages) {
420
+ if (isAutonumber(msg.type)) continue
421
+ if (getFragmentStartKeyword(msg.type)) continue
422
+ if (isFragmentEnd(msg.type)) continue
423
+ if (getFragmentSeparatorKeyword(msg.type)) continue
424
+ if (isActiveStart(msg.type) || isActiveEnd(msg.type)) continue
425
+ const isEvent =
426
+ (isSignalMessage(msg.type) && msg.from && msg.to) || (isNoteMessage(msg.type) && msg.from)
427
+ if (isEvent) count++
428
+ }
429
+ return count
430
+ }
431
+
432
+ /** Parse sequence-diagram SVG layout data for use by {@link sequenceToBlueprint}. */
433
+ export function parseSequenceLayout(
434
+ root: Element,
435
+ actorCount: number,
436
+ eventCount: number
437
+ ): ParsedSequenceLayout {
438
+ return {
439
+ actorLayouts: computeActorLayouts(root, actorCount, eventCount),
440
+ noteRects: parseSvgRects(root, 'rect.note'),
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Build a complete blueprint for a sequence diagram:
446
+ * top actors, lifelines, bottom actors, fragments, notes, and signal edges.
447
+ */
448
+ export function sequenceToBlueprint(
449
+ layout: ParsedSequenceLayout,
450
+ actors: Map<string, Actor>,
451
+ actorKeys: string[],
452
+ messages: Message[],
453
+ createdActors: Map<string, number> = new Map(),
454
+ destroyedActors: Map<string, number> = new Map()
455
+ ): DiagramMermaidBlueprint {
456
+ const actorCount = actorKeys.length
457
+ const keyIndex = new Map(actorKeys.map((key, i) => [key, i]))
458
+
459
+ const fragments: FragmentSpan[] = []
460
+ const fragmentStack: OpenFragment[] = []
461
+ const events: Message[] = []
462
+ const activationStack = new Map<string, number[]>()
463
+ const activationSpans: ActivationSpan[] = []
464
+
465
+ let autonumberStart = 0
466
+ let autonumberStep = 0
467
+ let autonumberVisible = false
468
+
469
+ for (const msg of messages) {
470
+ if (isAutonumber(msg.type)) {
471
+ autonumberStart = 1
472
+ autonumberStep = 1
473
+ autonumberVisible = true
474
+ continue
475
+ }
476
+
477
+ const keyword = getFragmentStartKeyword(msg.type)
478
+ if (keyword) {
479
+ fragmentStack.push({
480
+ keyword,
481
+ sections: [{ title: getMessageLabel(msg) ?? '', firstEventIndex: events.length }],
482
+ firstEventIndex: events.length,
483
+ actorKeys: new Set(),
484
+ })
485
+ continue
486
+ }
487
+
488
+ if (isFragmentEnd(msg.type)) {
489
+ const frag = fragmentStack.pop()
490
+ if (frag) fragments.push({ ...frag, lastEventIndex: events.length - 1 })
491
+ continue
492
+ }
493
+
494
+ if (getFragmentSeparatorKeyword(msg.type)) {
495
+ const current = fragmentStack[fragmentStack.length - 1]
496
+ if (current) {
497
+ current.sections.push({
498
+ title: getMessageLabel(msg) ?? '',
499
+ firstEventIndex: events.length,
500
+ })
501
+ }
502
+ continue
503
+ }
504
+
505
+ if (isActiveStart(msg.type)) {
506
+ const key = msg.from ?? msg.to
507
+ if (key) {
508
+ if (!activationStack.has(key)) activationStack.set(key, [])
509
+ // Explicit `activate` follows the triggering arrow,
510
+ // so the activation starts at the previous event.
511
+ activationStack.get(key)!.push(Math.max(0, events.length - 1))
512
+ }
513
+ continue
514
+ }
515
+
516
+ if (isActiveEnd(msg.type)) {
517
+ const key = msg.from ?? msg.to
518
+ if (key) {
519
+ const startIdx = activationStack.get(key)?.pop()
520
+ if (startIdx !== undefined) {
521
+ activationSpans.push({
522
+ participantKey: key,
523
+ startEventIndex: startIdx,
524
+ endEventIndex: Math.max(events.length - 1, startIdx),
525
+ })
526
+ }
527
+ }
528
+ continue
529
+ }
530
+
531
+ const isEvent =
532
+ (isSignalMessage(msg.type) && msg.from && msg.to) || (isNoteMessage(msg.type) && msg.from)
533
+ if (!isEvent) continue
534
+
535
+ for (const frag of fragmentStack) {
536
+ if (msg.from) frag.actorKeys.add(msg.from)
537
+ if (msg.to) frag.actorKeys.add(msg.to)
538
+ }
539
+ events.push(msg)
540
+ }
541
+
542
+ const layouts = layout.actorLayouts
543
+
544
+ // Pre-compute lifecycle event indices for created/destroyed actors.
545
+ // We scan events for the first signal targeting each created actor
546
+ // and the first signal from each destroyed actor.
547
+ const creationEventIndex = new Map<string, number>()
548
+ const destructionEventIndex = new Map<string, number>()
549
+ for (let i = 0; i < events.length; i++) {
550
+ const ev = events[i]
551
+ if (!isSignalMessage(ev.type)) continue
552
+
553
+ if (ev.to && createdActors.has(ev.to) && !creationEventIndex.has(ev.to)) {
554
+ creationEventIndex.set(ev.to, i)
555
+ }
556
+ if (ev.from && destroyedActors.has(ev.from) && !destructionEventIndex.has(ev.from)) {
557
+ destructionEventIndex.set(ev.from, i)
558
+ }
559
+ }
560
+
561
+ // Build blueprint
562
+ const svgNoteRects = layout.noteRects
563
+ let svgNoteIndex = 0
564
+ const nodes: MermaidBlueprintGeoNode[] = []
565
+ const lines: MermaidBlueprintLineNode[] = []
566
+ const edges: MermaidBlueprintEdge[] = []
567
+
568
+ const { y: firstY, h: firstH, bottomY: firstBottomY } = layouts[0]
569
+ const lifelineTop = firstY + firstH
570
+ const eventStep = (firstBottomY - lifelineTop) / (events.length + 1)
571
+
572
+ // --- Z-order: lifelines -> activations -> fragments -> actor boxes -> notes/arrows ---
573
+
574
+ // 1. Lifelines (behind everything)
575
+ for (let i = 0; i < actorCount; i++) {
576
+ const key = actorKeys[i]
577
+ const { x, y, w, h, bottomY } = layouts[i]
578
+
579
+ const isCreated = creationEventIndex.has(key)
580
+ const isDestroyed = destructionEventIndex.has(key)
581
+ const eventY = isCreated ? lifelineTop + eventStep * (creationEventIndex.get(key)! + 1) : 0
582
+ const topY = isCreated ? eventY + h / 2 : y + h
583
+ const botY = isDestroyed
584
+ ? lifelineTop + eventStep * (destructionEventIndex.get(key)! + 1)
585
+ : bottomY
586
+
587
+ const lifelineHeight = botY - topY
588
+ if (lifelineHeight > 0) {
589
+ lines.push({ id: `lifeline-${key}`, x: x + w / 2, y: topY, endY: lifelineHeight })
590
+ }
591
+ }
592
+
593
+ // 2. Activation boxes (just after lifelines)
594
+ const activationPad = eventStep * ACTIVATION_PAD_RATIO
595
+ const sortedSpans = activationSpans
596
+ .map((span, origIdx) => ({ ...span, origIdx }))
597
+ .sort((a, b) => {
598
+ const sizeA = a.endEventIndex - a.startEventIndex
599
+ const sizeB = b.endEventIndex - b.startEventIndex
600
+ return sizeB - sizeA || a.origIdx - b.origIdx
601
+ })
602
+
603
+ for (let i = 0; i < sortedSpans.length; i++) {
604
+ const span = sortedSpans[i]
605
+ const actorIdx = keyIndex.get(span.participantKey)
606
+ if (actorIdx === undefined) continue
607
+
608
+ const spanSize = span.endEventIndex - span.startEventIndex
609
+ let depth = 0
610
+ for (const other of sortedSpans) {
611
+ if (other === span) continue
612
+ const sameParticipant = other.participantKey === span.participantKey
613
+ const containsSpan =
614
+ other.startEventIndex <= span.startEventIndex && other.endEventIndex >= span.endEventIndex
615
+ const strictlyLarger = other.endEventIndex - other.startEventIndex > spanSize
616
+ if (sameParticipant && containsSpan && strictlyLarger) depth++
617
+ }
618
+
619
+ const layout = layouts[actorIdx]
620
+ const lifelineCenterX = layout.x + layout.w / 2
621
+ const boxTop = lifelineTop + eventStep * (span.startEventIndex + 1) - activationPad
622
+ const boxBottom = lifelineTop + eventStep * (span.endEventIndex + 1) + activationPad
623
+
624
+ nodes.push({
625
+ id: `activation-${span.origIdx}`,
626
+ x: lifelineCenterX - ACTIVATION_BOX_WIDTH / 2 + depth * ACTIVATION_NEST_OFFSET,
627
+ y: boxTop,
628
+ w: ACTIVATION_BOX_WIDTH,
629
+ h: boxBottom - boxTop,
630
+ geo: 'rectangle',
631
+ fill: 'solid',
632
+ color: 'light-violet',
633
+ size: 's',
634
+ })
635
+ }
636
+
637
+ // 3. Fragments
638
+ for (let fragmentIndex = 0; fragmentIndex < fragments.length; fragmentIndex++) {
639
+ const fragment = fragments[fragmentIndex]
640
+ if (fragment.lastEventIndex < fragment.firstEventIndex) continue
641
+
642
+ const fragTop = lifelineTop + eventStep * (fragment.firstEventIndex + 1) - FRAGMENT_PADDING_TOP
643
+ const fragBottom =
644
+ lifelineTop + eventStep * (fragment.lastEventIndex + 1) + FRAGMENT_PADDING_BOTTOM
645
+ const indices = [...fragment.actorKeys].map((k) => keyIndex.get(k)!).filter((idx) => idx >= 0)
646
+ if (indices.length === 0) continue
647
+
648
+ const minIndex = Math.min(...indices)
649
+ const maxIndex = Math.max(...indices)
650
+ const leftX = layouts[minIndex].x - FRAGMENT_PADDING_X
651
+ const fragW = layouts[maxIndex].x + layouts[maxIndex].w + FRAGMENT_PADDING_X - leftX
652
+ const fragH = fragBottom - fragTop
653
+
654
+ const rgbColor =
655
+ fragment.keyword === 'rect' ? parseRgbToTldrawColor(fragment.sections[0].title) : null
656
+ if (rgbColor) {
657
+ nodes.push({
658
+ id: `fragment-${fragmentIndex}`,
659
+ x: leftX,
660
+ y: fragTop,
661
+ w: fragW,
662
+ h: fragH,
663
+ geo: 'rectangle',
664
+ fill: rgbColor.hasAlpha ? 'semi' : 'solid',
665
+ color: rgbColor.color,
666
+ size: 's',
667
+ })
668
+ } else {
669
+ nodes.push({
670
+ id: `fragment-${fragmentIndex}`,
671
+ x: leftX,
672
+ y: fragTop,
673
+ w: fragW,
674
+ h: fragH,
675
+ geo: 'rectangle',
676
+ dash: 'dashed',
677
+ fill: 'none',
678
+ color: 'light-blue',
679
+ size: 's',
680
+ align: 'start',
681
+ verticalAlign: 'start',
682
+ label: `${fragment.keyword} [${fragment.sections[0].title}]`,
683
+ })
684
+
685
+ for (let s = 1; s < fragment.sections.length; s++) {
686
+ const section = fragment.sections[s]
687
+ const sepY = lifelineTop + eventStep * (section.firstEventIndex + 0.5)
688
+
689
+ lines.push({
690
+ id: `fragment-${fragmentIndex}-sep-${s}`,
691
+ x: leftX,
692
+ y: sepY,
693
+ endX: fragW,
694
+ endY: 0,
695
+ dash: 'dashed',
696
+ color: 'light-blue',
697
+ size: 's',
698
+ })
699
+
700
+ nodes.push({
701
+ id: `fragment-${fragmentIndex}-section-${s}`,
702
+ x: leftX + FRAGMENT_SECTION_LABEL_PADDING,
703
+ y: sepY + FRAGMENT_SECTION_LABEL_PADDING,
704
+ w: fragW - FRAGMENT_SECTION_LABEL_PADDING * 2,
705
+ h: FRAGMENT_SECTION_LABEL_HEIGHT,
706
+ geo: 'rectangle',
707
+ fill: 'none',
708
+ dash: 'dashed',
709
+ color: 'light-blue',
710
+ size: 's',
711
+ align: 'start',
712
+ verticalAlign: 'start',
713
+ label: `[${section.title}]`,
714
+ })
715
+ }
716
+ }
717
+ }
718
+
719
+ // 4. Actor boxes (top and bottom)
720
+ for (let i = 0; i < actorCount; i++) {
721
+ const key = actorKeys[i]
722
+ const actor = actors.get(key)
723
+ if (!actor) continue
724
+ const { x, y, w, h, bottomY } = layouts[i]
725
+ const isCreated = creationEventIndex.has(key)
726
+ const isDestroyed = destructionEventIndex.has(key)
727
+ const shared = {
728
+ geo: mapParticipantTypeToGeo(actor.type),
729
+ label: actor.description || actor.name || key,
730
+ align: 'middle' as const,
731
+ verticalAlign: 'middle' as const,
732
+ size: 's' as const,
733
+ }
734
+
735
+ const creationY = isCreated ? lifelineTop + eventStep * (creationEventIndex.get(key)! + 1) : 0
736
+ const topY = isCreated ? creationY - h / 2 : y
737
+ nodes.push({ id: `actor-top-${key}`, x, y: topY, w, h, ...shared })
738
+
739
+ if (!isDestroyed) {
740
+ nodes.push({ id: `actor-bottom-${key}`, x, y: bottomY, w, h, ...shared })
741
+ }
742
+ }
743
+
744
+ // 5. Events: signals and notes
745
+ const pendingCreations = new Set(createdActors.keys())
746
+ let sequenceNumber = autonumberStart
747
+
748
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
749
+ const msg = events[eventIndex]
750
+ const anchor = (eventIndex + 1) / (events.length + 1)
751
+
752
+ if (isSignalMessage(msg.type)) {
753
+ const fromKey = msg.from!
754
+ const toKey = msg.to!
755
+ if (!keyIndex.has(fromKey) || !keyIndex.has(toKey)) continue
756
+
757
+ const isCreationMessage = pendingCreations.has(toKey)
758
+ if (isCreationMessage) pendingCreations.delete(toKey)
759
+
760
+ const msgType = msg.type ?? LINETYPE.SOLID
761
+ const { dash, arrowheadEnd } = mapLineTypeToArrowProps(msgType)
762
+ const isSelf = fromKey === toKey
763
+ const bidir = !isSelf && isBidirectional(msgType)
764
+
765
+ const edge: MermaidBlueprintEdge = {
766
+ startNodeId: `lifeline-${fromKey}`,
767
+ endNodeId: isCreationMessage ? `actor-top-${toKey}` : `lifeline-${toKey}`,
768
+ label: getMessageLabel(msg),
769
+ bend: isSelf ? SELF_MSG_BEND : 0,
770
+ dash,
771
+ arrowheadEnd,
772
+ arrowheadStart: bidir ? 'arrow' : 'none',
773
+ size: 's',
774
+ anchorStartY: isSelf ? anchor - SELF_MSG_Y_OFFSET : anchor,
775
+ anchorEndY: isCreationMessage ? 0.5 : isSelf ? anchor + SELF_MSG_Y_OFFSET : anchor,
776
+ isExact: true,
777
+ isPrecise: true,
778
+ ...(isCreationMessage && { isExactEnd: false, isPreciseEnd: false }),
779
+ }
780
+
781
+ if (autonumberVisible) {
782
+ edge.decoration = { type: 'autonumber', value: String(sequenceNumber) }
783
+ sequenceNumber += autonumberStep
784
+ }
785
+
786
+ edges.push(edge)
787
+ } else if (isNoteMessage(msg.type)) {
788
+ const eventY = lifelineTop + eventStep * (eventIndex + 1)
789
+ const fromKey = msg.from!
790
+ const fromIdx = keyIndex.get(fromKey)
791
+ const toIdx = keyIndex.get(msg.to ?? fromKey)
792
+ if (fromIdx === undefined) continue
793
+
794
+ const label = getMessageLabel(msg)
795
+ // Mermaid types `placement` as a string enum but the runtime value is numeric
796
+ const msgPlacement = msg.placement as unknown as number | undefined
797
+ const fromCenterX = layouts[fromIdx].x + layouts[fromIdx].w / 2
798
+ const toCenterX = toIdx !== undefined ? layouts[toIdx].x + layouts[toIdx].w / 2 : fromCenterX
799
+ const isSpanning =
800
+ msgPlacement === PLACEMENT.OVER && msg.from !== msg.to && toIdx !== undefined
801
+
802
+ const svgNote = svgNoteRects[svgNoteIndex++]
803
+ const noteHeight = svgNote?.h ?? FALLBACK_NOTE_HEIGHT
804
+ const textWidth = label ? label.length * NOTE_CHAR_WIDTH + NOTE_TEXT_PADDING : 0
805
+ const baseWidth = Math.max(svgNote?.w ?? FALLBACK_NOTE_WIDTH, textWidth)
806
+ const noteWidth = isSpanning
807
+ ? Math.max(baseWidth, Math.abs(toCenterX - fromCenterX) + NOTE_PADDING)
808
+ : baseWidth
809
+
810
+ let noteX: number
811
+ if (msgPlacement === PLACEMENT.LEFTOF) {
812
+ noteX = fromCenterX - noteWidth - NOTE_PADDING
813
+ } else if (msgPlacement === PLACEMENT.RIGHTOF) {
814
+ noteX = fromCenterX + NOTE_PADDING
815
+ } else if (isSpanning) {
816
+ noteX = (fromCenterX + toCenterX) / 2 - noteWidth / 2
817
+ } else {
818
+ noteX = fromCenterX - noteWidth / 2
819
+ }
820
+
821
+ nodes.push({
822
+ id: `note-${eventIndex}`,
823
+ x: noteX,
824
+ y: eventY - noteHeight / 2,
825
+ w: noteWidth,
826
+ h: noteHeight,
827
+ geo: 'rectangle',
828
+ fill: 'solid',
829
+ color: 'yellow',
830
+ dash: 'draw',
831
+ size: 's',
832
+ align: 'middle',
833
+ verticalAlign: 'middle',
834
+ label,
835
+ })
836
+ }
837
+ }
838
+
839
+ return {
840
+ nodes,
841
+ edges,
842
+ lines,
843
+ groups: actorKeys.map((key) => {
844
+ const group = [`actor-top-${key}`, `lifeline-${key}`]
845
+ if (!destructionEventIndex.has(key)) {
846
+ group.push(`actor-bottom-${key}`)
847
+ }
848
+ return group
849
+ }),
850
+ }
851
+ }