@tldraw/mermaid 4.6.0-canary.00a8c03b5687

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 (61) hide show
  1. package/README.md +195 -0
  2. package/dist-cjs/blueprint.js +17 -0
  3. package/dist-cjs/blueprint.js.map +7 -0
  4. package/dist-cjs/colors.js +173 -0
  5. package/dist-cjs/colors.js.map +7 -0
  6. package/dist-cjs/createMermaidDiagram.js +157 -0
  7. package/dist-cjs/createMermaidDiagram.js.map +7 -0
  8. package/dist-cjs/flowchartDiagram.js +202 -0
  9. package/dist-cjs/flowchartDiagram.js.map +7 -0
  10. package/dist-cjs/index.d.ts +114 -0
  11. package/dist-cjs/index.js +34 -0
  12. package/dist-cjs/index.js.map +7 -0
  13. package/dist-cjs/mindmapDiagram.js +139 -0
  14. package/dist-cjs/mindmapDiagram.js.map +7 -0
  15. package/dist-cjs/renderBlueprint.js +314 -0
  16. package/dist-cjs/renderBlueprint.js.map +7 -0
  17. package/dist-cjs/sequenceDiagram.js +686 -0
  18. package/dist-cjs/sequenceDiagram.js.map +7 -0
  19. package/dist-cjs/stateDiagram.js +373 -0
  20. package/dist-cjs/stateDiagram.js.map +7 -0
  21. package/dist-cjs/svgParsing.js +187 -0
  22. package/dist-cjs/svgParsing.js.map +7 -0
  23. package/dist-cjs/utils.js +75 -0
  24. package/dist-cjs/utils.js.map +7 -0
  25. package/dist-esm/blueprint.mjs +1 -0
  26. package/dist-esm/blueprint.mjs.map +7 -0
  27. package/dist-esm/colors.mjs +153 -0
  28. package/dist-esm/colors.mjs.map +7 -0
  29. package/dist-esm/createMermaidDiagram.mjs +127 -0
  30. package/dist-esm/createMermaidDiagram.mjs.map +7 -0
  31. package/dist-esm/flowchartDiagram.mjs +188 -0
  32. package/dist-esm/flowchartDiagram.mjs.map +7 -0
  33. package/dist-esm/index.d.mts +114 -0
  34. package/dist-esm/index.mjs +14 -0
  35. package/dist-esm/index.mjs.map +7 -0
  36. package/dist-esm/mindmapDiagram.mjs +119 -0
  37. package/dist-esm/mindmapDiagram.mjs.map +7 -0
  38. package/dist-esm/renderBlueprint.mjs +298 -0
  39. package/dist-esm/renderBlueprint.mjs.map +7 -0
  40. package/dist-esm/sequenceDiagram.mjs +666 -0
  41. package/dist-esm/sequenceDiagram.mjs.map +7 -0
  42. package/dist-esm/stateDiagram.mjs +359 -0
  43. package/dist-esm/stateDiagram.mjs.map +7 -0
  44. package/dist-esm/svgParsing.mjs +167 -0
  45. package/dist-esm/svgParsing.mjs.map +7 -0
  46. package/dist-esm/utils.mjs +55 -0
  47. package/dist-esm/utils.mjs.map +7 -0
  48. package/package.json +64 -0
  49. package/src/blueprint.ts +75 -0
  50. package/src/colors.ts +215 -0
  51. package/src/createMermaidDiagram.test.ts +31 -0
  52. package/src/createMermaidDiagram.ts +169 -0
  53. package/src/flowchartDiagram.ts +232 -0
  54. package/src/index.ts +18 -0
  55. package/src/mermaidDiagrams.test.ts +1157 -0
  56. package/src/mindmapDiagram.ts +169 -0
  57. package/src/renderBlueprint.ts +373 -0
  58. package/src/sequenceDiagram.ts +851 -0
  59. package/src/stateDiagram.ts +477 -0
  60. package/src/svgParsing.ts +240 -0
  61. package/src/utils.ts +73 -0
@@ -0,0 +1,1157 @@
1
+ import type { FlowEdge, FlowSubGraph, FlowVertex } from 'mermaid/dist/diagrams/flowchart/types.js'
2
+ import type { MindmapNode } from 'mermaid/dist/diagrams/mindmap/mindmapTypes.js'
3
+ import type { Actor, Message } from 'mermaid/dist/diagrams/sequence/types.js'
4
+ import type { StateStmt } from 'mermaid/dist/diagrams/state/stateDb.d.ts'
5
+ import type { DiagramMermaidBlueprint } from './blueprint'
6
+ import { flowchartToBlueprint } from './flowchartDiagram'
7
+ import { mindmapToBlueprint, type ParsedMindmapLayout } from './mindmapDiagram'
8
+ import {
9
+ countSequenceEvents,
10
+ sequenceToBlueprint,
11
+ type ParsedSequenceLayout,
12
+ } from './sequenceDiagram'
13
+ import { stateToBlueprint } from './stateDiagram'
14
+ import type { ParsedCluster, ParsedDiagramLayout, ParsedEdge, ParsedNode } from './svgParsing'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function node(id: string, cx: number, cy: number, w: number, h: number): ParsedNode {
21
+ return { id, center: { x: cx, y: cy }, width: w, height: h }
22
+ }
23
+
24
+ function cluster(id: string, x: number, y: number, w: number, h: number): ParsedCluster {
25
+ return { id, topLeft: { x, y }, width: w, height: h }
26
+ }
27
+
28
+ function edge(start: string, end: string, points: [number, number][]): ParsedEdge {
29
+ return { start, end, points: points.map(([x, y]) => ({ x, y })) }
30
+ }
31
+
32
+ function diagramLayout(
33
+ nodes: ParsedNode[],
34
+ clusters: ParsedCluster[] = [],
35
+ edges: ParsedEdge[] = []
36
+ ): ParsedDiagramLayout {
37
+ return {
38
+ nodes: new Map(nodes.map((n) => [n.id, n])),
39
+ clusters: new Map(clusters.map((c) => [c.id, c])),
40
+ edges,
41
+ }
42
+ }
43
+
44
+ function vertex(
45
+ id: string,
46
+ opts: { text?: string; type?: string; classes?: string[]; styles?: string[] } = {}
47
+ ): [string, FlowVertex] {
48
+ return [
49
+ id,
50
+ {
51
+ id,
52
+ text: opts.text ?? id,
53
+ type: (opts.type ?? 'rect') as FlowVertex['type'],
54
+ classes: opts.classes ?? [],
55
+ styles: opts.styles ?? [],
56
+ } as FlowVertex,
57
+ ]
58
+ }
59
+
60
+ function flowEdge(start: string, end: string, opts: Partial<FlowEdge> = {}): FlowEdge {
61
+ return {
62
+ start,
63
+ end,
64
+ type: opts.type ?? 'arrow_point',
65
+ text: opts.text ?? '',
66
+ stroke: opts.stroke ?? 'normal',
67
+ style: opts.style,
68
+ } as FlowEdge
69
+ }
70
+
71
+ function subGraph(id: string, title: string, nodes: string[]): FlowSubGraph {
72
+ return { id, title, nodes } as FlowSubGraph
73
+ }
74
+
75
+ function findNode(bp: DiagramMermaidBlueprint, id: string) {
76
+ return bp.nodes.find((n) => n.id === id)
77
+ }
78
+
79
+ function findNodeByLabel(bp: DiagramMermaidBlueprint, label: string) {
80
+ return bp.nodes.find((n) => n.label === label)
81
+ }
82
+
83
+ function findEdge(bp: DiagramMermaidBlueprint, from: string, to: string) {
84
+ return bp.edges.find((e) => e.startNodeId === from && e.endNodeId === to)
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Flowchart tests
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe('flowchartToBlueprint', () => {
92
+ it('maps nodes with correct id, label, geo, and positions', () => {
93
+ const layout = diagramLayout([node('A', 100, 50, 80, 40), node('B', 100, 150, 60, 60)])
94
+ const vertices = new Map([
95
+ vertex('A', { text: 'Start', type: 'rect' }),
96
+ vertex('B', { text: 'Is it?', type: 'diamond' }),
97
+ ])
98
+ const edges = [flowEdge('A', 'B')]
99
+
100
+ const bp = flowchartToBlueprint(layout, vertices, edges)
101
+
102
+ expect(bp.nodes).toHaveLength(2)
103
+ const a = findNode(bp, 'A')!
104
+ expect(a.label).toBe('Start')
105
+ expect(a.geo).toBe('rectangle')
106
+ expect(a.x).toBe(100 - 80 / 2)
107
+ expect(a.y).toBe(50 - 40 / 2)
108
+ expect(a.w).toBe(80)
109
+ expect(a.h).toBe(40)
110
+
111
+ const b = findNode(bp, 'B')!
112
+ expect(b.label).toBe('Is it?')
113
+ expect(b.geo).toBe('diamond')
114
+ })
115
+
116
+ it('maps edge labels and arrowhead types', () => {
117
+ const layout = diagramLayout(
118
+ [node('A', 100, 50, 80, 40), node('B', 300, 50, 80, 40)],
119
+ [],
120
+ [
121
+ edge('A', 'B', [
122
+ [140, 50],
123
+ [260, 50],
124
+ ]),
125
+ ]
126
+ )
127
+ const vertices = new Map([vertex('A'), vertex('B')])
128
+ const edges = [flowEdge('A', 'B', { text: 'Yes', type: 'arrow_point' })]
129
+
130
+ const bp = flowchartToBlueprint(layout, vertices, edges)
131
+
132
+ expect(bp.edges).toHaveLength(1)
133
+ expect(bp.edges[0].label).toBe('Yes')
134
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
135
+ expect(bp.edges[0].startNodeId).toBe('A')
136
+ expect(bp.edges[0].endNodeId).toBe('B')
137
+ })
138
+
139
+ it('maps various geo shape types', () => {
140
+ const types: [string, string][] = [
141
+ ['diamond', 'diamond'],
142
+ ['ellipse', 'ellipse'],
143
+ ['circle', 'ellipse'],
144
+ ['hexagon', 'hexagon'],
145
+ ['trapezoid', 'trapezoid'],
146
+ ['lean_right', 'rhombus'],
147
+ ['lean_left', 'rhombus-2'],
148
+ ['rect', 'rectangle'],
149
+ ['round', 'rectangle'],
150
+ ['subroutine', 'rectangle'],
151
+ ]
152
+
153
+ for (const [mermaidType, expectedGeo] of types) {
154
+ const id = `node_${mermaidType}`
155
+ const layout = diagramLayout([node(id, 0, 0, 80, 40)])
156
+ const vertices = new Map([vertex(id, { type: mermaidType })])
157
+
158
+ const bp = flowchartToBlueprint(layout, vertices, [])
159
+ expect(findNode(bp, id)!.geo).toBe(expectedGeo)
160
+ }
161
+ })
162
+
163
+ it('handles circle type with equal width/height', () => {
164
+ const layout = diagramLayout([node('C', 100, 100, 60, 80)])
165
+ const vertices = new Map([vertex('C', { type: 'circle' })])
166
+
167
+ const bp = flowchartToBlueprint(layout, vertices, [])
168
+
169
+ const c = findNode(bp, 'C')!
170
+ expect(c.w).toBe(c.h)
171
+ expect(c.w).toBe(80)
172
+ })
173
+
174
+ it('creates subgraph frames with correct parent-child relationships', () => {
175
+ const layout = diagramLayout(
176
+ [node('a1', 50, 80, 60, 30), node('a2', 150, 80, 60, 30), node('b1', 300, 80, 60, 30)],
177
+ [cluster('sg1', 10, 40, 200, 100), cluster('sg2', 260, 40, 100, 100)]
178
+ )
179
+ const vertices = new Map([vertex('a1'), vertex('a2'), vertex('b1')])
180
+ const subGraphs = [
181
+ subGraph('sg1', 'Group One', ['a1', 'a2']),
182
+ subGraph('sg2', 'Group Two', ['b1']),
183
+ ]
184
+
185
+ const bp = flowchartToBlueprint(layout, vertices, [], subGraphs)
186
+
187
+ const sg1 = findNode(bp, 'sg1')!
188
+ expect(sg1.label).toBe('Group One')
189
+ expect(sg1.fill).toBe('semi')
190
+ expect(sg1.verticalAlign).toBe('start')
191
+
192
+ expect(findNode(bp, 'a1')!.parentId).toBe('sg1')
193
+ expect(findNode(bp, 'a2')!.parentId).toBe('sg1')
194
+ expect(findNode(bp, 'b1')!.parentId).toBe('sg2')
195
+ })
196
+
197
+ it('computes bend for curved edges', () => {
198
+ const layout = diagramLayout(
199
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
200
+ [],
201
+ [
202
+ edge('A', 'B', [
203
+ [20, 0],
204
+ [100, -50],
205
+ [180, 0],
206
+ ]),
207
+ ]
208
+ )
209
+ const vertices = new Map([vertex('A'), vertex('B')])
210
+ const edges = [flowEdge('A', 'B')]
211
+
212
+ const bp = flowchartToBlueprint(layout, vertices, edges)
213
+ expect(bp.edges[0].bend).not.toBe(0)
214
+ })
215
+
216
+ it('handles self-loop edges with non-zero bend', () => {
217
+ const layout = diagramLayout(
218
+ [node('D', 100, 100, 80, 40)],
219
+ [],
220
+ [
221
+ edge('D', 'D', [
222
+ [100, 80],
223
+ [140, 40],
224
+ [100, 120],
225
+ ]),
226
+ ]
227
+ )
228
+ const vertices = new Map([vertex('D')])
229
+ const edges = [flowEdge('D', 'D')]
230
+
231
+ const bp = flowchartToBlueprint(layout, vertices, edges)
232
+ expect(bp.edges[0].startNodeId).toBe('D')
233
+ expect(bp.edges[0].endNodeId).toBe('D')
234
+ expect(bp.edges[0].bend).not.toBe(0)
235
+ })
236
+
237
+ it('filters out edges referencing missing nodes', () => {
238
+ const layout = diagramLayout([node('A', 0, 0, 40, 40)])
239
+ const vertices = new Map([vertex('A')])
240
+ const edges = [flowEdge('A', 'MISSING')]
241
+
242
+ const bp = flowchartToBlueprint(layout, vertices, edges)
243
+ expect(bp.edges).toHaveLength(0)
244
+ })
245
+
246
+ it('maps classDef fill colors', () => {
247
+ const layout = diagramLayout([node('A', 50, 50, 80, 40), node('B', 200, 50, 80, 40)])
248
+ const vertices = new Map([
249
+ vertex('A', { text: 'Start', classes: ['green'] }),
250
+ vertex('B', { text: 'End', classes: ['blue'] }),
251
+ ])
252
+ const classDefs = new Map<string, { styles: string[] }>([
253
+ ['green', { styles: ['fill:#00ff00'] }],
254
+ ['blue', { styles: ['fill:#0000ff'] }],
255
+ ])
256
+
257
+ const bp = flowchartToBlueprint(layout, vertices, [], undefined, classDefs as any)
258
+
259
+ expect(findNodeByLabel(bp, 'Start')!.fill).toBe('solid')
260
+ expect(findNodeByLabel(bp, 'Start')!.color).toBe('light-green')
261
+ expect(findNodeByLabel(bp, 'End')!.fill).toBe('solid')
262
+ expect(findNodeByLabel(bp, 'End')!.color).toBe('blue')
263
+ })
264
+
265
+ it('maps inline style fill/stroke colors', () => {
266
+ const layout = diagramLayout([node('A', 50, 50, 80, 40)])
267
+ const vertices = new Map([
268
+ vertex('A', { text: 'Styled', styles: ['fill:#ffebee', 'stroke:#c62828'] }),
269
+ ])
270
+
271
+ const bp = flowchartToBlueprint(layout, vertices, [])
272
+
273
+ const a = findNodeByLabel(bp, 'Styled')!
274
+ expect(a.fill).toBe('solid')
275
+ expect(a.color).toBe('red')
276
+ })
277
+
278
+ it('maps edge dash styles from stroke-dasharray', () => {
279
+ const layout = diagramLayout(
280
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
281
+ [],
282
+ [
283
+ edge('A', 'B', [
284
+ [20, 0],
285
+ [180, 0],
286
+ ]),
287
+ ]
288
+ )
289
+ const vertices = new Map([vertex('A'), vertex('B')])
290
+ const edges = [flowEdge('A', 'B', { style: ['stroke-dasharray: 4 3'] })]
291
+
292
+ const bp = flowchartToBlueprint(layout, vertices, edges)
293
+ expect(bp.edges[0].dash).toBe('dashed')
294
+ })
295
+
296
+ it('maps dotted stroke to dotted dash', () => {
297
+ const layout = diagramLayout(
298
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
299
+ [],
300
+ [
301
+ edge('A', 'B', [
302
+ [20, 0],
303
+ [180, 0],
304
+ ]),
305
+ ]
306
+ )
307
+ const vertices = new Map([vertex('A'), vertex('B')])
308
+ const edges = [flowEdge('A', 'B', { stroke: 'dotted' })]
309
+
310
+ const bp = flowchartToBlueprint(layout, vertices, edges)
311
+ expect(bp.edges[0].dash).toBe('dotted')
312
+ })
313
+
314
+ it('maps double_arrow edge type to bidirectional arrowheads', () => {
315
+ const layout = diagramLayout(
316
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
317
+ [],
318
+ [
319
+ edge('A', 'B', [
320
+ [20, 0],
321
+ [180, 0],
322
+ ]),
323
+ ]
324
+ )
325
+ const vertices = new Map([vertex('A'), vertex('B')])
326
+ const edges = [flowEdge('A', 'B', { type: 'double_arrow_point' })]
327
+
328
+ const bp = flowchartToBlueprint(layout, vertices, edges)
329
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
330
+ expect(bp.edges[0].arrowheadStart).toBe('arrow')
331
+ })
332
+ })
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // State diagram tests
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe('stateToBlueprint', () => {
339
+ function stateStmt(id: string, opts: Partial<StateStmt> = {}): [string, StateStmt] {
340
+ return [
341
+ id,
342
+ {
343
+ id,
344
+ type: opts.type ?? 'default',
345
+ description: opts.description ?? '',
346
+ descriptions: opts.descriptions,
347
+ doc: opts.doc,
348
+ note: (opts as any).note,
349
+ classes: opts.classes,
350
+ stmt: 'state',
351
+ } as StateStmt,
352
+ ]
353
+ }
354
+
355
+ it('maps leaf states with correct geo and labels', () => {
356
+ const layout = diagramLayout([
357
+ node('Still', 100, 100, 80, 40),
358
+ node('Moving', 250, 100, 80, 40),
359
+ ])
360
+ const states = new Map([stateStmt('Still'), stateStmt('Moving')])
361
+ const relations = [{ id1: 'Still', id2: 'Moving', relationTitle: 'go' }]
362
+
363
+ const bp = stateToBlueprint(layout, states, relations)
364
+
365
+ expect(findNode(bp, 'Still')!.label).toBe('Still')
366
+ expect(findNode(bp, 'Still')!.geo).toBe('rectangle')
367
+ expect(findNode(bp, 'Moving')!.label).toBe('Moving')
368
+ expect(findEdge(bp, 'Still', 'Moving')!.label).toBe('go')
369
+ })
370
+
371
+ it('maps start/end pseudo-states to ellipses', () => {
372
+ const layout = diagramLayout([
373
+ node('root_start', 50, 50, 20, 20),
374
+ node('root_end', 300, 50, 20, 20),
375
+ node('Idle', 175, 50, 80, 40),
376
+ ])
377
+ const states = new Map([
378
+ stateStmt('root_start', { type: 'start' }),
379
+ stateStmt('root_end', { type: 'end' }),
380
+ stateStmt('Idle'),
381
+ ])
382
+ const relations = [
383
+ { id1: 'root_start', id2: 'Idle' },
384
+ { id1: 'Idle', id2: 'root_end' },
385
+ ]
386
+
387
+ const bp = stateToBlueprint(layout, states, relations)
388
+
389
+ const start = findNode(bp, 'root_start')!
390
+ expect(start.geo).toBe('ellipse')
391
+ expect(start.fill).toBe('solid')
392
+
393
+ const end = findNode(bp, 'root_end')!
394
+ expect(end.geo).toBe('ellipse')
395
+ expect(end.fill).toBe('none')
396
+
397
+ const endInner = findNode(bp, 'root_end__inner')!
398
+ expect(endInner.geo).toBe('ellipse')
399
+ expect(endInner.fill).toBe('solid')
400
+ })
401
+
402
+ it('maps choice states to diamonds', () => {
403
+ const layout = diagramLayout([node('D', 100, 100, 40, 40)])
404
+ const states = new Map([stateStmt('D', { type: 'choice' })])
405
+
406
+ const bp = stateToBlueprint(layout, states, [])
407
+ expect(findNode(bp, 'D')!.geo).toBe('diamond')
408
+ })
409
+
410
+ it('maps fork/join states to wide bars', () => {
411
+ const layout = diagramLayout([node('F', 100, 100, 20, 20)])
412
+ const states = new Map([stateStmt('F', { type: 'fork' })])
413
+
414
+ const bp = stateToBlueprint(layout, states, [])
415
+
416
+ const f = findNode(bp, 'F')!
417
+ expect(f.geo).toBe('rectangle')
418
+ expect(f.fill).toBe('solid')
419
+ expect(f.w).toBeGreaterThan(20)
420
+ })
421
+
422
+ it('creates compound state frames from clusters', () => {
423
+ const layout = diagramLayout(
424
+ [node('fA', 80, 100, 60, 30)],
425
+ [cluster('First', 20, 40, 200, 120)]
426
+ )
427
+ const states = new Map([
428
+ stateStmt('First', {
429
+ description: 'First',
430
+ doc: [
431
+ { stmt: 'state', id: 'fA', type: 'default', description: '' } as unknown as StateStmt,
432
+ ],
433
+ }),
434
+ stateStmt('fA'),
435
+ ])
436
+
437
+ const bp = stateToBlueprint(layout, states, [])
438
+
439
+ const first = findNode(bp, 'First')!
440
+ expect(first.fill).toBe('semi')
441
+ expect(first.label).toBe('First')
442
+ expect(findNode(bp, 'fA')!.parentId).toBe('First')
443
+ })
444
+
445
+ it('creates note nodes with yellow color', () => {
446
+ const layout = diagramLayout([
447
+ node('Idle', 100, 100, 80, 40),
448
+ node('Idle----note', 250, 100, 100, 40),
449
+ ])
450
+ const states = new Map([stateStmt('Idle', { note: { text: 'Important note' } } as any)])
451
+
452
+ const bp = stateToBlueprint(layout, states, [])
453
+
454
+ const noteNode = findNode(bp, 'Idle----note')!
455
+ expect(noteNode.label).toBe('Important note')
456
+ expect(noteNode.color).toBe('yellow')
457
+ expect(noteNode.fill).toBe('solid')
458
+
459
+ const noteEdge = findEdge(bp, 'Idle', 'Idle----note')!
460
+ expect(noteEdge.dash).toBe('dotted')
461
+ expect(noteEdge.arrowheadEnd).toBe('none')
462
+ })
463
+
464
+ it('maps classDef fill colors', () => {
465
+ const layout = diagramLayout([node('Idle', 100, 100, 80, 40)])
466
+ const states = new Map([stateStmt('Idle', { classes: ['green'] })])
467
+ const classDefs = new Map([['green', { styles: ['fill:#00ff00'] }]])
468
+
469
+ const bp = stateToBlueprint(layout, states, [], classDefs as any)
470
+
471
+ expect(findNode(bp, 'Idle')!.fill).toBe('solid')
472
+ expect(findNode(bp, 'Idle')!.color).toBe('light-green')
473
+ })
474
+
475
+ it('computes edge bend from SVG edge waypoints', () => {
476
+ const layout = diagramLayout(
477
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
478
+ [],
479
+ [
480
+ edge('', '', [
481
+ [0, 0],
482
+ [100, -40],
483
+ [200, 0],
484
+ ]),
485
+ ]
486
+ )
487
+ const states = new Map([stateStmt('A'), stateStmt('B')])
488
+ const relations = [{ id1: 'A', id2: 'B' }]
489
+
490
+ const bp = stateToBlueprint(layout, states, relations)
491
+ expect(findEdge(bp, 'A', 'B')!.bend).not.toBe(0)
492
+ })
493
+
494
+ it('filters out edges referencing missing nodes', () => {
495
+ const layout = diagramLayout([node('A', 0, 0, 40, 40)])
496
+ const states = new Map([stateStmt('A')])
497
+ const relations = [{ id1: 'A', id2: 'MISSING' }]
498
+
499
+ const bp = stateToBlueprint(layout, states, relations)
500
+ expect(bp.edges).toHaveLength(0)
501
+ })
502
+
503
+ it('uses auto-detected start/end types from ID suffixes', () => {
504
+ const layout = diagramLayout([
505
+ node('root_start', 50, 50, 20, 20),
506
+ node('root_end2', 300, 50, 20, 20),
507
+ ])
508
+ const states = new Map([
509
+ stateStmt('root_start', { type: 'default' }),
510
+ stateStmt('root_end2', { type: 'default' }),
511
+ ])
512
+
513
+ const bp = stateToBlueprint(layout, states, [])
514
+
515
+ expect(findNode(bp, 'root_start')!.geo).toBe('ellipse')
516
+ expect(findNode(bp, 'root_end2')!.geo).toBe('ellipse')
517
+ })
518
+ })
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Sequence diagram tests
522
+ // ---------------------------------------------------------------------------
523
+
524
+ describe('sequenceToBlueprint', () => {
525
+ const LINETYPE = {
526
+ SOLID: 0,
527
+ DOTTED: 1,
528
+ NOTE: 2,
529
+ SOLID_CROSS: 3,
530
+ DOTTED_CROSS: 4,
531
+ SOLID_OPEN: 5,
532
+ DOTTED_OPEN: 6,
533
+ LOOP_START: 10,
534
+ LOOP_END: 11,
535
+ ALT_START: 12,
536
+ ALT_ELSE: 13,
537
+ ALT_END: 14,
538
+ OPT_START: 15,
539
+ OPT_END: 16,
540
+ ACTIVE_START: 17,
541
+ ACTIVE_END: 18,
542
+ PAR_START: 19,
543
+ PAR_AND: 20,
544
+ PAR_END: 21,
545
+ AUTONUMBER: 26,
546
+ CRITICAL_START: 27,
547
+ CRITICAL_OPTION: 28,
548
+ CRITICAL_END: 29,
549
+ BREAK_START: 30,
550
+ BREAK_END: 31,
551
+ BIDIRECTIONAL_SOLID: 33,
552
+ BIDIRECTIONAL_DOTTED: 34,
553
+ } as const
554
+
555
+ const PLACEMENT = {
556
+ LEFTOF: 0,
557
+ RIGHTOF: 1,
558
+ OVER: 2,
559
+ } as const
560
+
561
+ function actor(key: string, opts: Partial<Actor> = {}): [string, Actor] {
562
+ return [
563
+ key,
564
+ {
565
+ name: opts.name ?? key,
566
+ description: opts.description ?? key,
567
+ type: opts.type ?? 'participant',
568
+ } as Actor,
569
+ ]
570
+ }
571
+
572
+ function msg(type: number, from: string, to: string, message = ''): Message {
573
+ return { type, from, to, message } as Message
574
+ }
575
+
576
+ function noteMsg(from: string, message: string, placement: number, to?: string): Message {
577
+ return { type: LINETYPE.NOTE, from, to: to ?? from, message, placement } as unknown as Message
578
+ }
579
+
580
+ function twoActorLayout(): ParsedSequenceLayout {
581
+ return {
582
+ actorLayouts: [
583
+ { x: -150, y: -200, w: 100, h: 50, bottomY: 200 },
584
+ { x: 150, y: -200, w: 100, h: 50, bottomY: 200 },
585
+ ],
586
+ noteRects: [],
587
+ }
588
+ }
589
+
590
+ function threeActorLayout(
591
+ noteRects: { x: number; y: number; w: number; h: number }[] = []
592
+ ): ParsedSequenceLayout {
593
+ return {
594
+ actorLayouts: [
595
+ { x: -300, y: -200, w: 100, h: 50, bottomY: 200 },
596
+ { x: 0, y: -200, w: 100, h: 50, bottomY: 200 },
597
+ { x: 300, y: -200, w: 100, h: 50, bottomY: 200 },
598
+ ],
599
+ noteRects,
600
+ }
601
+ }
602
+
603
+ it('creates top/bottom actor boxes and lifelines', () => {
604
+ const layout = twoActorLayout()
605
+ const actors = new Map([actor('Alice'), actor('Bob')])
606
+ const messages = [msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hello')]
607
+
608
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
609
+
610
+ expect(findNode(bp, 'actor-top-Alice')).toBeDefined()
611
+ expect(findNode(bp, 'actor-top-Bob')).toBeDefined()
612
+ expect(findNode(bp, 'actor-bottom-Alice')).toBeDefined()
613
+ expect(findNode(bp, 'actor-bottom-Bob')).toBeDefined()
614
+
615
+ expect(bp.lines!.find((l) => l.id === 'lifeline-Alice')).toBeDefined()
616
+ expect(bp.lines!.find((l) => l.id === 'lifeline-Bob')).toBeDefined()
617
+ })
618
+
619
+ it('creates groups for actor elements', () => {
620
+ const layout = twoActorLayout()
621
+ const actors = new Map([actor('Alice'), actor('Bob')])
622
+ const messages = [msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hi')]
623
+
624
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
625
+
626
+ expect(bp.groups).toEqual([
627
+ ['actor-top-Alice', 'lifeline-Alice', 'actor-bottom-Alice'],
628
+ ['actor-top-Bob', 'lifeline-Bob', 'actor-bottom-Bob'],
629
+ ])
630
+ })
631
+
632
+ it('creates signal edges with correct labels and dash styles', () => {
633
+ const layout = twoActorLayout()
634
+ const actors = new Map([actor('Alice'), actor('John')])
635
+ const messages = [
636
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello John, how are you?'),
637
+ msg(LINETYPE.DOTTED, 'John', 'Alice', 'Great!'),
638
+ msg(LINETYPE.SOLID_OPEN, 'Alice', 'John', 'See you later!'),
639
+ ]
640
+
641
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
642
+
643
+ expect(bp.edges).toHaveLength(3)
644
+ expect(bp.edges[0].label).toBe('Hello John, how are you?')
645
+ expect(bp.edges[0].dash).toBe('solid')
646
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
647
+
648
+ expect(bp.edges[1].label).toBe('Great!')
649
+ expect(bp.edges[1].dash).toBe('dotted')
650
+
651
+ expect(bp.edges[2].label).toBe('See you later!')
652
+ expect(bp.edges[2].arrowheadEnd).toBe('none')
653
+ })
654
+
655
+ it('maps arrow type variations correctly', () => {
656
+ const layout = twoActorLayout()
657
+ const actors = new Map([actor('A'), actor('B')])
658
+ const messages = [
659
+ msg(LINETYPE.SOLID_CROSS, 'A', 'B', 'cross'),
660
+ msg(LINETYPE.DOTTED_CROSS, 'B', 'A', 'dotted cross'),
661
+ msg(LINETYPE.DOTTED_OPEN, 'A', 'B', 'dotted open'),
662
+ ]
663
+
664
+ const bp = sequenceToBlueprint(layout, actors, ['A', 'B'], messages)
665
+
666
+ expect(bp.edges[0].arrowheadEnd).toBe('bar')
667
+ expect(bp.edges[0].dash).toBe('solid')
668
+ expect(bp.edges[1].arrowheadEnd).toBe('bar')
669
+ expect(bp.edges[1].dash).toBe('dotted')
670
+ expect(bp.edges[2].arrowheadEnd).toBe('none')
671
+ expect(bp.edges[2].dash).toBe('dotted')
672
+ })
673
+
674
+ it('creates self-message with non-zero bend', () => {
675
+ const layout = twoActorLayout()
676
+ const actors = new Map([actor('W')])
677
+ const messages = [msg(LINETYPE.SOLID, 'W', 'W', 'execute()')]
678
+
679
+ const bp = sequenceToBlueprint(layout, actors, ['W'], messages)
680
+
681
+ const selfEdge = bp.edges.find(
682
+ (e) => e.startNodeId === 'lifeline-W' && e.endNodeId === 'lifeline-W'
683
+ )
684
+ expect(selfEdge).toBeDefined()
685
+ expect(selfEdge!.label).toBe('execute()')
686
+ expect(selfEdge!.bend).not.toBe(0)
687
+ })
688
+
689
+ it('creates loop fragments', () => {
690
+ const layout = threeActorLayout()
691
+ const actors = new Map([actor('P'), actor('Q'), actor('W')])
692
+ const messages = [
693
+ msg(LINETYPE.SOLID, 'P', 'Q', 'enqueue'),
694
+ { type: LINETYPE.LOOP_START, message: 'poll' } as unknown as Message,
695
+ msg(LINETYPE.SOLID, 'W', 'Q', 'dequeue'),
696
+ msg(LINETYPE.DOTTED, 'Q', 'W', 'job?'),
697
+ { type: LINETYPE.LOOP_END } as unknown as Message,
698
+ ]
699
+
700
+ const bp = sequenceToBlueprint(layout, actors, ['P', 'Q', 'W'], messages)
701
+
702
+ const fragment = findNode(bp, 'fragment-0')
703
+ expect(fragment).toBeDefined()
704
+ expect(fragment!.label).toContain('loop')
705
+ expect(fragment!.label).toContain('poll')
706
+ })
707
+
708
+ it('creates alt/else fragments with separator lines and section labels', () => {
709
+ const layout = twoActorLayout()
710
+ const actors = new Map([actor('User'), actor('App')])
711
+ const messages = [
712
+ msg(LINETYPE.SOLID, 'User', 'App', 'Sign in'),
713
+ { type: LINETYPE.ALT_START, message: 'Credentials valid' } as unknown as Message,
714
+ msg(LINETYPE.DOTTED, 'App', 'User', 'Redirect to dashboard'),
715
+ { type: LINETYPE.ALT_ELSE, message: 'Credentials invalid' } as unknown as Message,
716
+ msg(LINETYPE.DOTTED, 'App', 'User', 'Show error'),
717
+ { type: LINETYPE.ALT_END } as unknown as Message,
718
+ ]
719
+
720
+ const bp = sequenceToBlueprint(layout, actors, ['User', 'App'], messages)
721
+
722
+ const fragment = findNode(bp, 'fragment-0')
723
+ expect(fragment).toBeDefined()
724
+ expect(fragment!.label).toBe('alt [Credentials valid]')
725
+
726
+ const sepLine = bp.lines!.find((l) => l.id === 'fragment-0-sep-1')
727
+ expect(sepLine).toBeDefined()
728
+ expect(sepLine!.dash).toBe('dashed')
729
+ expect(sepLine!.endY).toBe(0)
730
+ expect(sepLine!.endX).toBeGreaterThan(0)
731
+
732
+ const sectionLabel = findNode(bp, 'fragment-0-section-1')
733
+ expect(sectionLabel).toBeDefined()
734
+ expect(sectionLabel!.label).toBe('[Credentials invalid]')
735
+ })
736
+
737
+ it('creates note nodes with yellow color and correct labels', () => {
738
+ const noteRect = { x: 10, y: 50, w: 120, h: 40 }
739
+ const layout: ParsedSequenceLayout = {
740
+ actorLayouts: [
741
+ { x: -150, y: -200, w: 100, h: 50, bottomY: 200 },
742
+ { x: 150, y: -200, w: 100, h: 50, bottomY: 200 },
743
+ ],
744
+ noteRects: [noteRect],
745
+ }
746
+ const actors = new Map([actor('Alice'), actor('John')])
747
+ const messages = [
748
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello'),
749
+ noteMsg('Alice', 'Alice thinks', PLACEMENT.RIGHTOF),
750
+ ]
751
+
752
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
753
+
754
+ const note = bp.nodes.find((n) => n.id.startsWith('note-'))
755
+ expect(note).toBeDefined()
756
+ expect(note!.label).toBe('Alice thinks')
757
+ expect(note!.color).toBe('yellow')
758
+ expect(note!.fill).toBe('solid')
759
+ expect(note!.geo).toBe('rectangle')
760
+ })
761
+
762
+ it('creates activation boxes', () => {
763
+ const layout = twoActorLayout()
764
+ const actors = new Map([actor('Alice'), actor('John')])
765
+ const messages = [
766
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello'),
767
+ { type: LINETYPE.ACTIVE_START, from: 'John' } as unknown as Message,
768
+ msg(LINETYPE.DOTTED, 'John', 'Alice', 'Reply'),
769
+ { type: LINETYPE.ACTIVE_END, from: 'John' } as unknown as Message,
770
+ ]
771
+
772
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
773
+
774
+ const activations = bp.nodes.filter((n) => n.id.startsWith('activation-'))
775
+ expect(activations).toHaveLength(1)
776
+ expect(activations[0].fill).toBe('solid')
777
+ expect(activations[0].color).toBe('light-violet')
778
+ expect(activations[0].w).toBe(20)
779
+ expect(activations[0].h).toBeGreaterThan(0)
780
+ })
781
+
782
+ it('adds autonumber decorations', () => {
783
+ const layout = twoActorLayout()
784
+ const actors = new Map([actor('Alice'), actor('Bob')])
785
+ const messages = [
786
+ { type: LINETYPE.AUTONUMBER } as unknown as Message,
787
+ msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hello'),
788
+ msg(LINETYPE.DOTTED, 'Bob', 'Alice', 'Hi'),
789
+ msg(LINETYPE.SOLID, 'Alice', 'Bob', 'How are you?'),
790
+ ]
791
+
792
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
793
+
794
+ expect(bp.edges).toHaveLength(3)
795
+ expect(bp.edges[0].decoration).toEqual({ type: 'autonumber', value: '1' })
796
+ expect(bp.edges[1].decoration).toEqual({ type: 'autonumber', value: '2' })
797
+ expect(bp.edges[2].decoration).toEqual({ type: 'autonumber', value: '3' })
798
+ })
799
+
800
+ it('maps bidirectional arrows', () => {
801
+ const layout = twoActorLayout()
802
+ const actors = new Map([actor('A'), actor('B')])
803
+ const messages = [msg(LINETYPE.BIDIRECTIONAL_SOLID, 'A', 'B', 'sync')]
804
+
805
+ const bp = sequenceToBlueprint(layout, actors, ['A', 'B'], messages)
806
+
807
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
808
+ expect(bp.edges[0].arrowheadStart).toBe('arrow')
809
+ })
810
+
811
+ it('handles created actors with late-appearing top box', () => {
812
+ const layout = threeActorLayout()
813
+ const actors = new Map([actor('User'), actor('App'), actor('JobRunner')])
814
+ const createdActors = new Map([['JobRunner', 1]])
815
+ const messages = [
816
+ msg(LINETYPE.SOLID, 'User', 'App', 'Start report'),
817
+ msg(LINETYPE.SOLID, 'App', 'JobRunner', 'Spawn job'),
818
+ msg(LINETYPE.DOTTED, 'JobRunner', 'App', 'Job started'),
819
+ ]
820
+
821
+ const bp = sequenceToBlueprint(
822
+ layout,
823
+ actors,
824
+ ['User', 'App', 'JobRunner'],
825
+ messages,
826
+ createdActors
827
+ )
828
+
829
+ const jobRunnerTop = findNode(bp, 'actor-top-JobRunner')!
830
+ expect(jobRunnerTop).toBeDefined()
831
+ const userTop = findNode(bp, 'actor-top-User')!
832
+ expect(jobRunnerTop.y).toBeGreaterThan(userTop.y)
833
+
834
+ const creationEdge = bp.edges.find((e) => e.label === 'Spawn job')!
835
+ expect(creationEdge.endNodeId).toBe('actor-top-JobRunner')
836
+ })
837
+
838
+ it('maps actor types to correct geo', () => {
839
+ const layout: ParsedSequenceLayout = {
840
+ actorLayouts: [{ x: 0, y: -200, w: 100, h: 50, bottomY: 200 }],
841
+ noteRects: [],
842
+ }
843
+ const actors = new Map([actor('User', { type: 'actor' })])
844
+ const messages = [msg(LINETYPE.SOLID, 'User', 'User', 'think')]
845
+
846
+ const bp = sequenceToBlueprint(layout, actors, ['User'], messages)
847
+
848
+ expect(findNode(bp, 'actor-top-User')!.geo).toBe('ellipse')
849
+ })
850
+ })
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // countSequenceEvents tests
854
+ // ---------------------------------------------------------------------------
855
+
856
+ describe('countSequenceEvents', () => {
857
+ it('counts only signal and note messages', () => {
858
+ const messages = [
859
+ { type: 0, from: 'A', to: 'B', message: 'Hello' },
860
+ { type: 1, from: 'B', to: 'A', message: 'Hi' },
861
+ { type: 10, message: 'loop' },
862
+ { type: 11 },
863
+ { type: 2, from: 'A', to: 'A', message: 'note' },
864
+ ] as unknown as Message[]
865
+
866
+ expect(countSequenceEvents(messages)).toBe(3)
867
+ })
868
+
869
+ it('skips autonumber, fragment, and activation control messages', () => {
870
+ const messages = [
871
+ { type: 26 },
872
+ { type: 12, message: 'alt' },
873
+ { type: 13, message: 'else' },
874
+ { type: 14 },
875
+ { type: 17, from: 'A' },
876
+ { type: 18, from: 'A' },
877
+ { type: 0, from: 'A', to: 'B', message: 'real' },
878
+ ] as unknown as Message[]
879
+
880
+ expect(countSequenceEvents(messages)).toBe(1)
881
+ })
882
+ })
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // Mindmap tests
886
+ // ---------------------------------------------------------------------------
887
+
888
+ function mindmapNode(
889
+ id: number,
890
+ descr: string,
891
+ opts: {
892
+ type?: number
893
+ level?: number
894
+ section?: number
895
+ isRoot?: boolean
896
+ children?: MindmapNode[]
897
+ } = {}
898
+ ): MindmapNode {
899
+ return {
900
+ id,
901
+ descr,
902
+ type: opts.type ?? 0,
903
+ level: opts.level ?? 0,
904
+ section: opts.section ?? 0,
905
+ isRoot: opts.isRoot ?? false,
906
+ children: opts.children ?? [],
907
+ } as MindmapNode
908
+ }
909
+
910
+ function mindmapLayout(nodes: ParsedNode[]): ParsedMindmapLayout {
911
+ return {
912
+ nodes: new Map(nodes.map((n) => [n.id, n])),
913
+ }
914
+ }
915
+
916
+ const emptySvg = document.createElement('div')
917
+
918
+ function mockSvgWithColors(colors: Map<string, string>): Element {
919
+ const root = document.createElement('div')
920
+ for (const [id, fill] of colors) {
921
+ const group = document.createElement('div')
922
+ group.classList.add('node')
923
+ group.setAttribute('id', `node_${id}`)
924
+ const rect = document.createElement('rect')
925
+ rect.style.fill = fill
926
+ group.appendChild(rect)
927
+ root.appendChild(group)
928
+ }
929
+ return root
930
+ }
931
+
932
+ describe('mindmapToBlueprint', () => {
933
+ it('maps root and child nodes with correct labels and positions', () => {
934
+ const layout = mindmapLayout([
935
+ node('0', 0, 0, 120, 60),
936
+ node('1', -200, 100, 80, 40),
937
+ node('2', 200, 100, 80, 40),
938
+ ])
939
+ const tree = mindmapNode(0, 'Root', {
940
+ isRoot: true,
941
+ level: 0,
942
+ section: -1,
943
+ children: [
944
+ mindmapNode(1, 'Child A', { level: 1, section: 0 }),
945
+ mindmapNode(2, 'Child B', { level: 1, section: 1 }),
946
+ ],
947
+ })
948
+
949
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
950
+
951
+ expect(bp.nodes).toHaveLength(3)
952
+
953
+ const root = findNodeByLabel(bp, 'Root')!
954
+ expect(root).toBeDefined()
955
+ expect(root.x).toBe(0 - 120 / 2)
956
+ expect(root.y).toBe(0 - 60 / 2)
957
+ expect(root.w).toBe(120)
958
+ expect(root.h).toBe(60)
959
+ expect(root.fill).toBe('solid')
960
+ expect(root.size).toBe('l')
961
+ expect(root.align).toBe('middle')
962
+ expect(root.verticalAlign).toBe('middle')
963
+
964
+ const childA = findNodeByLabel(bp, 'Child A')!
965
+ expect(childA).toBeDefined()
966
+ expect(childA.size).toBe('m')
967
+ })
968
+
969
+ it('creates edges from parent to children with no arrowheads', () => {
970
+ const layout = mindmapLayout([
971
+ node('0', 0, 0, 100, 50),
972
+ node('1', -150, 100, 80, 40),
973
+ node('2', 150, 100, 80, 40),
974
+ ])
975
+ const tree = mindmapNode(0, 'Root', {
976
+ isRoot: true,
977
+ level: 0,
978
+ children: [
979
+ mindmapNode(1, 'A', { level: 1, section: 0 }),
980
+ mindmapNode(2, 'B', { level: 1, section: 1 }),
981
+ ],
982
+ })
983
+
984
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
985
+
986
+ expect(bp.edges).toHaveLength(2)
987
+ for (const e of bp.edges) {
988
+ expect(e.startNodeId).toBe('0')
989
+ expect(e.arrowheadEnd).toBe('none')
990
+ expect(e.arrowheadStart).toBe('none')
991
+ expect(e.bend).toBe(0)
992
+ }
993
+ })
994
+
995
+ it('assigns decreasing edge sizes by tree depth', () => {
996
+ const layout = mindmapLayout([
997
+ node('0', 0, 0, 100, 50),
998
+ node('1', 0, 100, 80, 40),
999
+ node('2', 0, 200, 60, 30),
1000
+ node('3', 0, 300, 60, 30),
1001
+ ])
1002
+ const tree = mindmapNode(0, 'Root', {
1003
+ isRoot: true,
1004
+ level: 0,
1005
+ children: [
1006
+ mindmapNode(1, 'L1', {
1007
+ level: 1,
1008
+ section: 0,
1009
+ children: [
1010
+ mindmapNode(2, 'L2', {
1011
+ level: 2,
1012
+ section: 0,
1013
+ children: [mindmapNode(3, 'L3', { level: 3, section: 0 })],
1014
+ }),
1015
+ ],
1016
+ }),
1017
+ ],
1018
+ })
1019
+
1020
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1021
+
1022
+ expect(bp.edges).toHaveLength(3)
1023
+ expect(findEdge(bp, '0', '1')!.size).toBe('l')
1024
+ expect(findEdge(bp, '1', '2')!.size).toBe('m')
1025
+ expect(findEdge(bp, '2', '3')!.size).toBe('s')
1026
+ })
1027
+
1028
+ it('maps mindmap node types to correct geo shapes', () => {
1029
+ const TYPES = { DEFAULT: 0, ROUNDED_RECT: 1, RECT: 2, CIRCLE: 3, CLOUD: 4, BANG: 5, HEXAGON: 6 }
1030
+ const layout = mindmapLayout([
1031
+ node('0', 0, 0, 100, 50),
1032
+ node('1', -300, 100, 80, 40),
1033
+ node('2', -150, 100, 80, 40),
1034
+ node('3', 0, 100, 60, 60),
1035
+ node('4', 150, 100, 80, 40),
1036
+ node('5', 300, 100, 80, 40),
1037
+ node('6', 450, 100, 80, 40),
1038
+ ])
1039
+ const tree = mindmapNode(0, 'Root', {
1040
+ isRoot: true,
1041
+ level: 0,
1042
+ children: [
1043
+ mindmapNode(1, 'Default', { type: TYPES.DEFAULT, level: 1, section: 0 }),
1044
+ mindmapNode(2, 'Rect', { type: TYPES.RECT, level: 1, section: 1 }),
1045
+ mindmapNode(3, 'Circle', { type: TYPES.CIRCLE, level: 1, section: 2 }),
1046
+ mindmapNode(4, 'Cloud', { type: TYPES.CLOUD, level: 1, section: 3 }),
1047
+ mindmapNode(5, 'Bang', { type: TYPES.BANG, level: 1, section: 4 }),
1048
+ mindmapNode(6, 'Hexagon', { type: TYPES.HEXAGON, level: 1, section: 5 }),
1049
+ ],
1050
+ })
1051
+
1052
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1053
+
1054
+ expect(findNodeByLabel(bp, 'Default')!.geo).toBe('rectangle')
1055
+ expect(findNodeByLabel(bp, 'Rect')!.geo).toBe('rectangle')
1056
+ expect(findNodeByLabel(bp, 'Circle')!.geo).toBe('ellipse')
1057
+ expect(findNodeByLabel(bp, 'Cloud')!.geo).toBe('cloud')
1058
+ expect(findNodeByLabel(bp, 'Bang')!.geo).toBe('star')
1059
+ expect(findNodeByLabel(bp, 'Hexagon')!.geo).toBe('hexagon')
1060
+ })
1061
+
1062
+ it('uses circle type to equalize width and height', () => {
1063
+ const CIRCLE = 3
1064
+ const layout = mindmapLayout([node('0', 0, 0, 100, 50), node('1', 200, 0, 60, 80)])
1065
+ const tree = mindmapNode(0, 'Root', {
1066
+ isRoot: true,
1067
+ level: 0,
1068
+ children: [mindmapNode(1, 'Round', { type: CIRCLE, level: 1, section: 0 })],
1069
+ })
1070
+
1071
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1072
+
1073
+ const round = findNodeByLabel(bp, 'Round')!
1074
+ expect(round.w).toBe(round.h)
1075
+ expect(round.w).toBe(80)
1076
+ })
1077
+
1078
+ it('uses SVG-extracted colors, falls back to black', () => {
1079
+ const layout = mindmapLayout([
1080
+ node('0', 0, 0, 100, 50),
1081
+ node('1', -150, 100, 80, 40),
1082
+ node('2', 150, 100, 80, 40),
1083
+ ])
1084
+ const tree = mindmapNode(0, 'Root', {
1085
+ isRoot: true,
1086
+ level: 0,
1087
+ section: -1,
1088
+ children: [
1089
+ mindmapNode(1, 'With Color', { level: 1, section: 0 }),
1090
+ mindmapNode(2, 'No Color', { level: 1, section: 1 }),
1091
+ ],
1092
+ })
1093
+ const svg = mockSvgWithColors(new Map([['1', 'rgb(224, 49, 49)']]))
1094
+
1095
+ const bp = mindmapToBlueprint(layout, tree, svg)
1096
+
1097
+ expect(findNodeByLabel(bp, 'With Color')!.color).toBe('red')
1098
+ expect(findNodeByLabel(bp, 'No Color')!.color).toBe('black')
1099
+ })
1100
+
1101
+ it('colors edges to match their target node', () => {
1102
+ const layout = mindmapLayout([node('0', 0, 0, 100, 50), node('1', 150, 100, 80, 40)])
1103
+ const tree = mindmapNode(0, 'Root', {
1104
+ isRoot: true,
1105
+ level: 0,
1106
+ children: [mindmapNode(1, 'Child', { level: 1, section: 0 })],
1107
+ })
1108
+ const svg = mockSvgWithColors(new Map([['1', 'rgb(9, 146, 104)']]))
1109
+
1110
+ const bp = mindmapToBlueprint(layout, tree, svg)
1111
+
1112
+ expect(bp.edges).toHaveLength(1)
1113
+ expect(bp.edges[0].color).toBe('green')
1114
+ })
1115
+
1116
+ it('all nodes have solid fill', () => {
1117
+ const layout = mindmapLayout([node('0', 0, 0, 100, 50), node('1', 150, 100, 80, 40)])
1118
+ const tree = mindmapNode(0, 'Root', {
1119
+ isRoot: true,
1120
+ level: 0,
1121
+ children: [mindmapNode(1, 'Child', { level: 1, section: 0 })],
1122
+ })
1123
+
1124
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1125
+
1126
+ for (const n of bp.nodes) {
1127
+ expect(n.fill).toBe('solid')
1128
+ }
1129
+ })
1130
+
1131
+ it('filters out edges referencing nodes missing from SVG layout', () => {
1132
+ // SVG only has root — child node missing from parsed layout
1133
+ const layout = mindmapLayout([node('0', 0, 0, 100, 50)])
1134
+ const tree = mindmapNode(0, 'Root', {
1135
+ isRoot: true,
1136
+ level: 0,
1137
+ children: [mindmapNode(1, 'Missing', { level: 1, section: 0 })],
1138
+ })
1139
+
1140
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1141
+
1142
+ expect(bp.nodes).toHaveLength(1)
1143
+ expect(bp.edges).toHaveLength(0)
1144
+ })
1145
+
1146
+ it('defaults to black when no SVG color is extracted', () => {
1147
+ const layout = mindmapLayout([node('0', 0, 0, 100, 50)])
1148
+ const tree = mindmapNode(0, 'Root', {
1149
+ isRoot: true,
1150
+ level: 0,
1151
+ })
1152
+
1153
+ const bp = mindmapToBlueprint(layout, tree, emptySvg)
1154
+
1155
+ expect(findNodeByLabel(bp, 'Root')!.color).toBe('black')
1156
+ })
1157
+ })