@tldraw/mermaid 4.6.0-canary.0bcbb3ed5bcb

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,477 @@
1
+ import type { StateStmt, StyleClass } from 'mermaid/dist/diagrams/state/stateDb.d.ts'
2
+ import type { TLGeoShape } from 'tldraw'
3
+ import type {
4
+ DiagramMermaidBlueprint,
5
+ MermaidBlueprintEdge,
6
+ MermaidBlueprintGeoNode,
7
+ } from './blueprint'
8
+ import { buildClassDefColorMap, type ParsedNodeColors } from './colors'
9
+ import {
10
+ buildNodeCentersFromSvg,
11
+ parseAllEdgePointsFromSvg,
12
+ parseClustersFromSvg,
13
+ type ParsedDiagramLayout,
14
+ parseNodesFromSvg,
15
+ scaleLayout,
16
+ } from './svgParsing'
17
+ import { getArrowBend, LAYOUT_SCALE, orderTopDown } from './utils'
18
+
19
+ function mapStateTypeToGeo(type: string): TLGeoShape['props']['geo'] {
20
+ switch (type) {
21
+ case 'choice':
22
+ return 'diamond'
23
+ case 'start':
24
+ case 'end':
25
+ return 'ellipse'
26
+ default:
27
+ return 'rectangle'
28
+ }
29
+ }
30
+
31
+ interface DiagramEdge {
32
+ id1: string
33
+ id2: string
34
+ relationTitle?: string
35
+ }
36
+
37
+ interface FlatState {
38
+ id: string
39
+ type: string
40
+ label: string
41
+ }
42
+
43
+ function getEffectiveType(state: StateStmt): string {
44
+ if (state.type && state.type !== 'default') return state.type
45
+ // Mermaid auto-generates start/end pseudo-state ids ending with "_start"
46
+ // or "_end", optionally followed by a disambiguation digit (e.g. "_start2").
47
+ if (/_start\d*$/.test(state.id)) return 'start'
48
+ if (/_end\d*$/.test(state.id)) return 'end'
49
+ return state.type || 'default'
50
+ }
51
+
52
+ const UNLABELED_TYPES = new Set(['start', 'end'])
53
+
54
+ function getStateLabel(state: StateStmt): string {
55
+ if (state.descriptions && state.descriptions.length > 0) {
56
+ return state.descriptions.join('\n')
57
+ }
58
+ if (state.description) return state.description
59
+ if (UNLABELED_TYPES.has(getEffectiveType(state))) return ''
60
+ return state.id
61
+ }
62
+
63
+ interface FlattenResult {
64
+ leafStates: Map<string, FlatState>
65
+ compoundLabels: Map<string, string>
66
+ parentOf: Map<string, string>
67
+ allEdges: DiagramEdge[]
68
+ }
69
+
70
+ function flattenStateHierarchy(
71
+ states: Map<string, StateStmt>,
72
+ relations: DiagramEdge[],
73
+ parentCompound: string | null = null,
74
+ topLevelStates?: Map<string, StateStmt>
75
+ ): FlattenResult {
76
+ const leafStates = new Map<string, FlatState>()
77
+ const compoundLabels = new Map<string, string>()
78
+ const parentOf = new Map<string, string>()
79
+ const allEdges: DiagramEdge[] = []
80
+ const root = topLevelStates ?? states
81
+
82
+ for (const [id, state] of states) {
83
+ if (parentCompound) parentOf.set(id, parentCompound)
84
+
85
+ if (state.doc && state.doc.length > 0) {
86
+ compoundLabels.set(id, state.description || id)
87
+
88
+ const childStatesMap = new Map<string, StateStmt>()
89
+ const childRelations: DiagramEdge[] = []
90
+
91
+ for (const stmt of state.doc) {
92
+ if (typeof stmt === 'string') {
93
+ // Mermaid emits raw strings for stereotyped state declarations
94
+ // like `state H <<history>>` — the ID and stereotype are stored
95
+ // as separate plain string entries. Look up the state in the
96
+ // top-level map so it gets proper parentage.
97
+ const topState = root.get(stmt)
98
+ if (topState && !childStatesMap.has(stmt)) {
99
+ childStatesMap.set(stmt, topState)
100
+ }
101
+ } else if (stmt.stmt === 'state' || stmt.stmt === 'default') {
102
+ const stateEntry = stmt as StateStmt
103
+ childStatesMap.set(stateEntry.id, stateEntry)
104
+ } else if (stmt.stmt === 'relation') {
105
+ const relation = stmt as unknown as {
106
+ state1: StateStmt
107
+ state2: StateStmt
108
+ description?: string
109
+ }
110
+ // Relation state refs are shallow objects without `doc`. Only add
111
+ // them when the state hasn't been registered yet so we don't
112
+ // overwrite a compound-state entry that carries its nested doc.
113
+ if (!childStatesMap.has(relation.state1.id)) {
114
+ childStatesMap.set(relation.state1.id, relation.state1)
115
+ }
116
+ if (!childStatesMap.has(relation.state2.id)) {
117
+ childStatesMap.set(relation.state2.id, relation.state2)
118
+ }
119
+ childRelations.push({
120
+ id1: relation.state1.id,
121
+ id2: relation.state2.id,
122
+ relationTitle: relation.description,
123
+ })
124
+ }
125
+ }
126
+
127
+ const nested = flattenStateHierarchy(childStatesMap, childRelations, id, root)
128
+ for (const [key, state] of nested.leafStates) leafStates.set(key, state)
129
+ for (const [key, label] of nested.compoundLabels) compoundLabels.set(key, label)
130
+ for (const [key, parent] of nested.parentOf) parentOf.set(key, parent)
131
+ allEdges.push(...nested.allEdges)
132
+ } else {
133
+ leafStates.set(id, {
134
+ id,
135
+ type: getEffectiveType(state),
136
+ label: getStateLabel(state),
137
+ })
138
+ }
139
+ }
140
+
141
+ allEdges.push(...relations)
142
+
143
+ return { leafStates, compoundLabels, parentOf, allEdges }
144
+ }
145
+
146
+ const FIXED_NODE_SIZES: Record<string, [number, number]> = {
147
+ start: [36, 36],
148
+ end: [40, 40],
149
+ }
150
+
151
+ function stateToNodes(
152
+ state: FlatState,
153
+ x: number,
154
+ y: number,
155
+ w: number,
156
+ h: number,
157
+ parentId: string | undefined,
158
+ colors: ParsedNodeColors | undefined
159
+ ): MermaidBlueprintGeoNode[] {
160
+ const base = { id: state.id, x, y, w, h, parentId, color: 'black' as const }
161
+ const label = state.label || undefined
162
+
163
+ switch (state.type) {
164
+ case 'note':
165
+ return [
166
+ {
167
+ ...base,
168
+ geo: 'rectangle',
169
+ label,
170
+ fill: 'solid',
171
+ color: 'yellow',
172
+ size: 's',
173
+ align: 'middle',
174
+ verticalAlign: 'middle',
175
+ },
176
+ ]
177
+ case 'start':
178
+ return [{ ...base, geo: 'ellipse', fill: 'solid' }]
179
+ case 'end': {
180
+ const innerSize = w * 0.6
181
+ return [
182
+ { ...base, geo: 'ellipse', fill: 'none' },
183
+ {
184
+ ...base,
185
+ id: `${state.id}__inner`,
186
+ x: x + (w - innerSize) / 2,
187
+ y: y + (h - innerSize) / 2,
188
+ w: innerSize,
189
+ h: innerSize,
190
+ geo: 'ellipse',
191
+ fill: 'solid',
192
+ },
193
+ ]
194
+ }
195
+ case 'fork':
196
+ case 'join': {
197
+ const barW = w * 4
198
+ const barH = Math.max(16, barW / 10)
199
+ return [
200
+ {
201
+ ...base,
202
+ x: x - (barW - w) / 2,
203
+ y: y + (h - barH) / 2,
204
+ w: barW,
205
+ h: barH,
206
+ geo: 'rectangle',
207
+ fill: 'solid',
208
+ },
209
+ ]
210
+ }
211
+ case 'choice':
212
+ return [
213
+ {
214
+ ...base,
215
+ geo: 'diamond',
216
+ label,
217
+ align: 'middle',
218
+ verticalAlign: 'middle',
219
+ size: 'm',
220
+ },
221
+ ]
222
+ default:
223
+ return [
224
+ {
225
+ ...base,
226
+ geo: mapStateTypeToGeo(state.type),
227
+ label,
228
+ ...(colors?.fillColor && { fill: 'solid' as const }),
229
+ ...(colors && { color: colors.strokeColor ?? colors.fillColor }),
230
+ align: 'middle',
231
+ verticalAlign: 'middle',
232
+ size: 'm',
233
+ },
234
+ ]
235
+ }
236
+ }
237
+
238
+ const FRAME_PAD = 24
239
+ const FRAME_TOP = 54
240
+
241
+ /** Parse state-diagram SVG layout data for use by {@link stateToBlueprint}. */
242
+ export function parseStateDiagramLayout(root: Element): ParsedDiagramLayout {
243
+ const nodes = parseNodesFromSvg(
244
+ root,
245
+ '.node',
246
+ (domId) => domId.match(/^state-(.+)-\d+$/)?.[1] ?? domId
247
+ )
248
+ const clusters = parseClustersFromSvg(root, '.statediagram-cluster')
249
+ const edges = parseAllEdgePointsFromSvg(root, (dataId) =>
250
+ /^edge\d+$/.test(dataId) ? { start: '', end: '' } : null
251
+ )
252
+ scaleLayout(nodes, clusters, edges, LAYOUT_SCALE)
253
+ return { nodes, clusters, edges }
254
+ }
255
+
256
+ /** Convert a parsed Mermaid state diagram into a tldraw blueprint of nodes and edges. */
257
+ export function stateToBlueprint(
258
+ layout: ParsedDiagramLayout,
259
+ states: Map<string, StateStmt>,
260
+ relations: DiagramEdge[],
261
+ classDefs?: Map<string, StyleClass>
262
+ ): DiagramMermaidBlueprint {
263
+ const stateColorMap = classDefs ? buildClassDefColorMap(classDefs, states) : new Map()
264
+ const { nodes: svgNodes, clusters: svgClusters, edges: svgEdges } = layout
265
+ const nodeCenters = buildNodeCentersFromSvg(svgNodes, svgClusters)
266
+
267
+ const { leafStates, compoundLabels, parentOf, allEdges } = flattenStateHierarchy(
268
+ states,
269
+ relations
270
+ )
271
+
272
+ // Collect notes attached to states and add them as synthetic leaf nodes + edges.
273
+ for (const [id, state] of states) {
274
+ const note = (state as StateStmt & { note?: { position?: string; text: string } }).note
275
+ if (!note) continue
276
+
277
+ const noteId = `${id}----note`
278
+ leafStates.set(noteId, { id: noteId, type: 'note', label: note.text.trim() })
279
+ allEdges.push({ id1: id, id2: noteId, relationTitle: undefined })
280
+ }
281
+
282
+ const nodeLayout = new Map<string, { absX: number; absY: number; w: number; h: number }>()
283
+ for (const [id, state] of leafStates) {
284
+ const svgNode = svgNodes.get(id)
285
+ if (!svgNode) continue
286
+
287
+ const fixed = FIXED_NODE_SIZES[state.type]
288
+ const nodeWidth = fixed ? fixed[0] : svgNode.width + 20
289
+ const nodeHeight = fixed ? fixed[1] : svgNode.height + 8
290
+ nodeLayout.set(id, {
291
+ absX: svgNode.center.x - nodeWidth / 2,
292
+ absY: svgNode.center.y - nodeHeight / 2,
293
+ w: nodeWidth,
294
+ h: nodeHeight,
295
+ })
296
+ }
297
+
298
+ const compoundIds = [...compoundLabels.keys()]
299
+ const frameBounds = new Map<string, { absX: number; absY: number; w: number; h: number }>()
300
+
301
+ // Use SVG cluster bounds as the authoritative frame size. Mermaid's
302
+ // layout already accounts for label width, padding, and special nodes
303
+ // like <<history>> that are rendered outside the visual cluster.
304
+ // Fall back to child-based computation only when no SVG cluster exists.
305
+ const bottomUp = orderTopDown(
306
+ compoundIds,
307
+ (id) => id,
308
+ (id) => parentOf.get(id)
309
+ ).reverse()
310
+
311
+ for (const compoundId of bottomUp) {
312
+ const cluster = svgClusters.get(compoundId)
313
+ if (cluster) {
314
+ frameBounds.set(compoundId, {
315
+ absX: cluster.topLeft.x,
316
+ absY: cluster.topLeft.y,
317
+ w: cluster.width,
318
+ h: cluster.height,
319
+ })
320
+ continue
321
+ }
322
+
323
+ let frameMinX = Infinity
324
+ let frameMinY = Infinity
325
+ let frameMaxX = -Infinity
326
+ let frameMaxY = -Infinity
327
+
328
+ for (const [id] of leafStates) {
329
+ if (parentOf.get(id) !== compoundId) continue
330
+
331
+ const layout = nodeLayout.get(id)
332
+ if (!layout) continue
333
+
334
+ frameMinX = Math.min(frameMinX, layout.absX)
335
+ frameMinY = Math.min(frameMinY, layout.absY)
336
+ frameMaxX = Math.max(frameMaxX, layout.absX + layout.w)
337
+ frameMaxY = Math.max(frameMaxY, layout.absY + layout.h)
338
+ }
339
+
340
+ for (const innerId of compoundIds) {
341
+ if (parentOf.get(innerId) !== compoundId) continue
342
+
343
+ const innerBounds = frameBounds.get(innerId)
344
+ if (!innerBounds) continue
345
+
346
+ frameMinX = Math.min(frameMinX, innerBounds.absX)
347
+ frameMinY = Math.min(frameMinY, innerBounds.absY)
348
+ frameMaxX = Math.max(frameMaxX, innerBounds.absX + innerBounds.w)
349
+ frameMaxY = Math.max(frameMaxY, innerBounds.absY + innerBounds.h)
350
+ }
351
+
352
+ if (!isFinite(frameMinX)) continue
353
+
354
+ frameBounds.set(compoundId, {
355
+ absX: frameMinX - FRAME_PAD,
356
+ absY: frameMinY - FRAME_TOP,
357
+ w: frameMaxX - frameMinX + FRAME_PAD * 2,
358
+ h: frameMaxY - frameMinY + FRAME_PAD + FRAME_TOP,
359
+ })
360
+ }
361
+
362
+ // Un-parent any leaf state whose center falls outside its parent frame.
363
+ // Mermaid renders some pseudo-states (e.g. <<history>>) outside the
364
+ // visual compound cluster even though they're declared inside it.
365
+ for (const [id] of leafStates) {
366
+ const pid = parentOf.get(id)
367
+ if (!pid) continue
368
+
369
+ const frame = frameBounds.get(pid)
370
+ const layout = nodeLayout.get(id)
371
+ if (!frame || !layout) continue
372
+
373
+ const cx = layout.absX + layout.w / 2
374
+ const cy = layout.absY + layout.h / 2
375
+ if (
376
+ cx < frame.absX ||
377
+ cx > frame.absX + frame.w ||
378
+ cy < frame.absY ||
379
+ cy > frame.absY + frame.h
380
+ ) {
381
+ parentOf.delete(id)
382
+ }
383
+ }
384
+
385
+ const nodes: MermaidBlueprintGeoNode[] = []
386
+ const blueprintEdges: MermaidBlueprintEdge[] = []
387
+
388
+ for (const compoundId of orderTopDown(
389
+ compoundIds,
390
+ (id) => id,
391
+ (id) => parentOf.get(id)
392
+ )) {
393
+ const bounds = frameBounds.get(compoundId)
394
+ if (!bounds) continue
395
+
396
+ nodes.push({
397
+ id: compoundId,
398
+ x: bounds.absX,
399
+ y: bounds.absY,
400
+ w: bounds.w,
401
+ h: bounds.h,
402
+ geo: 'rectangle',
403
+ parentId: parentOf.get(compoundId),
404
+ label: compoundLabels.get(compoundId) || compoundId,
405
+ fill: 'semi',
406
+ color: 'black',
407
+ dash: 'draw',
408
+ size: 's',
409
+ align: 'middle',
410
+ verticalAlign: 'start',
411
+ })
412
+ }
413
+
414
+ for (const [id, state] of leafStates) {
415
+ const layout = nodeLayout.get(id)
416
+ if (!layout) continue
417
+
418
+ nodes.push(
419
+ ...stateToNodes(
420
+ state,
421
+ layout.absX,
422
+ layout.absY,
423
+ layout.w,
424
+ layout.h,
425
+ parentOf.get(id),
426
+ stateColorMap.get(id)
427
+ )
428
+ )
429
+ }
430
+
431
+ const claimed = new Set<number>()
432
+
433
+ for (const edge of allEdges) {
434
+ const startCenter = nodeCenters.get(edge.id1)
435
+ const endCenter = nodeCenters.get(edge.id2)
436
+
437
+ let bend = 0
438
+ if (startCenter && endCenter) {
439
+ let bestIndex = -1
440
+ let bestDistance = Infinity
441
+ for (let edgeIndex = 0; edgeIndex < svgEdges.length; edgeIndex++) {
442
+ if (claimed.has(edgeIndex) || svgEdges[edgeIndex].points.length < 2) continue
443
+
444
+ const points = svgEdges[edgeIndex].points
445
+ const distance =
446
+ Math.hypot(points[0].x - startCenter.x, points[0].y - startCenter.y) +
447
+ Math.hypot(
448
+ points[points.length - 1].x - endCenter.x,
449
+ points[points.length - 1].y - endCenter.y
450
+ )
451
+ if (distance < bestDistance) {
452
+ bestDistance = distance
453
+ bestIndex = edgeIndex
454
+ }
455
+ }
456
+ if (bestIndex >= 0) {
457
+ claimed.add(bestIndex)
458
+ bend = getArrowBend(svgEdges[bestIndex])
459
+ }
460
+ }
461
+
462
+ const isNoteEdge = edge.id2.endsWith('----note') || edge.id1.endsWith('----note')
463
+ blueprintEdges.push({
464
+ startNodeId: edge.id1,
465
+ endNodeId: edge.id2,
466
+ label: edge.relationTitle,
467
+ bend,
468
+ ...(isNoteEdge && { dash: 'dotted' as const, arrowheadEnd: 'none' as const }),
469
+ })
470
+ }
471
+
472
+ const nodeIds = new Set(nodes.map((n) => n.id))
473
+ const validEdges = blueprintEdges.filter(
474
+ (e) => nodeIds.has(e.startNodeId) && nodeIds.has(e.endNodeId)
475
+ )
476
+ return { nodes, edges: validEdges }
477
+ }
@@ -0,0 +1,240 @@
1
+ export interface Vec2 {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export interface ParsedNode {
7
+ id: string
8
+ center: Vec2
9
+ width: number
10
+ height: number
11
+ }
12
+
13
+ export interface ParsedCluster {
14
+ id: string
15
+ topLeft: Vec2
16
+ width: number
17
+ height: number
18
+ }
19
+
20
+ type NodeIdParser = (domId: string) => string
21
+ type EdgeIdParser = (dataId: string) => { start: string; end: string } | null
22
+
23
+ function parseTranslate(attr: string | null): Vec2 {
24
+ if (!attr) return { x: 0, y: 0 }
25
+ // Matches SVG translate transforms, e.g. transform="translate(123.45, 67.8)".
26
+ // Handles scientific notation (1.2e+3). Group 1 = x offset, group 2 = y offset.
27
+ const translateMatch = attr.match(/translate\(\s*([\d.e+-]+)[,\s]+([\d.e+-]+)\s*\)/)
28
+ if (!translateMatch) return { x: 0, y: 0 }
29
+ return { x: parseFloat(translateMatch[1]), y: parseFloat(translateMatch[2]) }
30
+ }
31
+
32
+ export function getAccumulatedTranslate(el: Element): Vec2 {
33
+ let x = 0
34
+ let y = 0
35
+ let cur: Element | null = el.parentElement
36
+ while (cur) {
37
+ const parentTranslate = parseTranslate(cur.getAttribute('transform'))
38
+ x += parentTranslate.x
39
+ y += parentTranslate.y
40
+ cur = cur.parentElement
41
+ }
42
+ return { x, y }
43
+ }
44
+
45
+ /**
46
+ * Extract element dimensions from a live SVG element using getBBox(),
47
+ * falling back to attribute parsing for non-browser environments (jsdom).
48
+ */
49
+ function getNodeDimensions(groupEl: Element): { w: number; h: number } {
50
+ const shapeEl = groupEl.querySelector('.label-container')
51
+ if (shapeEl) {
52
+ try {
53
+ const bbox = (shapeEl as SVGGraphicsElement).getBBox()
54
+ if (bbox.width > 0 && bbox.height > 0) return { w: bbox.width, h: bbox.height }
55
+ } catch {
56
+ /* fall through */
57
+ }
58
+ }
59
+
60
+ try {
61
+ const bbox = (groupEl as SVGGraphicsElement).getBBox()
62
+ if (bbox.width > 0 && bbox.height > 0) return { w: bbox.width, h: bbox.height }
63
+ } catch {
64
+ /* fall through */
65
+ }
66
+
67
+ const rect = groupEl.querySelector('rect')
68
+ if (rect) {
69
+ const w = parseFloat(rect.getAttribute('width') || '0')
70
+ const h = parseFloat(rect.getAttribute('height') || '0')
71
+ if (w > 0 && h > 0) return { w, h }
72
+ }
73
+ const poly = groupEl.querySelector('polygon')
74
+ if (poly) {
75
+ const pts = (poly.getAttribute('points') || '')
76
+ .trim()
77
+ .split(/\s+/)
78
+ .map((pointStr) => pointStr.split(',').map(Number))
79
+ let minX = Infinity
80
+ let maxX = -Infinity
81
+ let minY = Infinity
82
+ let maxY = -Infinity
83
+ for (const [px, py] of pts) {
84
+ minX = Math.min(minX, px)
85
+ maxX = Math.max(maxX, px)
86
+ minY = Math.min(minY, py)
87
+ maxY = Math.max(maxY, py)
88
+ }
89
+ if (maxX > minX && maxY > minY) return { w: maxX - minX, h: maxY - minY }
90
+ }
91
+ const circle = groupEl.querySelector('circle')
92
+ if (circle) {
93
+ const r = parseFloat(circle.getAttribute('r') || '0')
94
+ if (r > 0) return { w: r * 2, h: r * 2 }
95
+ }
96
+ const ellipse = groupEl.querySelector('ellipse')
97
+ if (ellipse) {
98
+ const w = parseFloat(ellipse.getAttribute('rx') || '0') * 2
99
+ const h = parseFloat(ellipse.getAttribute('ry') || '0') * 2
100
+ if (w > 0 && h > 0) return { w, h }
101
+ }
102
+ return { w: 0, h: 0 }
103
+ }
104
+
105
+ export function parseNodesFromSvg(
106
+ root: Element,
107
+ selector: string,
108
+ idParser: NodeIdParser
109
+ ): Map<string, ParsedNode> {
110
+ const out = new Map<string, ParsedNode>()
111
+ for (const groupEl of root.querySelectorAll(selector)) {
112
+ const rawId = groupEl.getAttribute('id') || ''
113
+ const id = idParser(rawId)
114
+ const self = parseTranslate(groupEl.getAttribute('transform'))
115
+ const ancestor = getAccumulatedTranslate(groupEl)
116
+ const { w, h } = getNodeDimensions(groupEl)
117
+ out.set(id, {
118
+ id,
119
+ center: { x: ancestor.x + self.x, y: ancestor.y + self.y },
120
+ width: w,
121
+ height: h,
122
+ })
123
+ }
124
+ return out
125
+ }
126
+
127
+ export function parseClustersFromSvg(root: Element, selector: string): Map<string, ParsedCluster> {
128
+ const out = new Map<string, ParsedCluster>()
129
+ for (const groupEl of root.querySelectorAll(selector)) {
130
+ const id = groupEl.getAttribute('id') || ''
131
+ const rect = groupEl.querySelector('rect')
132
+ if (!rect) continue
133
+ const rx = parseFloat(rect.getAttribute('x') || '0')
134
+ const ry = parseFloat(rect.getAttribute('y') || '0')
135
+ const w = parseFloat(rect.getAttribute('width') || '0')
136
+ const h = parseFloat(rect.getAttribute('height') || '0')
137
+ const self = parseTranslate(groupEl.getAttribute('transform'))
138
+ const ancestor = getAccumulatedTranslate(groupEl)
139
+ out.set(id, {
140
+ id,
141
+ topLeft: { x: ancestor.x + self.x + rx, y: ancestor.y + self.y + ry },
142
+ width: w,
143
+ height: h,
144
+ })
145
+ }
146
+ return out
147
+ }
148
+
149
+ export interface ParsedEdge {
150
+ start: string
151
+ end: string
152
+ points: Vec2[]
153
+ }
154
+
155
+ /**
156
+ * Pre-parsed SVG layout for flowchart and state diagram converters.
157
+ * Contains already-scaled node, cluster, and edge data.
158
+ */
159
+ export interface ParsedDiagramLayout {
160
+ nodes: Map<string, ParsedNode>
161
+ clusters: Map<string, ParsedCluster>
162
+ edges: ParsedEdge[]
163
+ }
164
+
165
+ /**
166
+ * Parse every SVG edge path in DOM order (matching mermaid's edge list order).
167
+ * Unlike the old per-pair map, this preserves all parallel edges individually.
168
+ */
169
+ export function parseAllEdgePointsFromSvg(root: Element, parser: EdgeIdParser): ParsedEdge[] {
170
+ const out: ParsedEdge[] = []
171
+ for (const path of root.querySelectorAll('path[data-points]')) {
172
+ const dataId = path.getAttribute('data-id') || path.getAttribute('id') || ''
173
+ const dataPoints = path.getAttribute('data-points')
174
+ if (!dataPoints) continue
175
+ const parsed = parser(dataId)
176
+ if (!parsed) continue
177
+ try {
178
+ const points = JSON.parse(atob(dataPoints))
179
+ const ancestor = getAccumulatedTranslate(path as Element)
180
+ for (const point of points) {
181
+ point.x += ancestor.x
182
+ point.y += ancestor.y
183
+ }
184
+ out.push({ start: parsed.start, end: parsed.end, points })
185
+ } catch {
186
+ /* ignore malformed data */
187
+ }
188
+ }
189
+ return out
190
+ }
191
+
192
+ /**
193
+ * Build a map of node/cluster id → center (for flowchart and state diagram edge matching).
194
+ */
195
+ export function buildNodeCentersFromSvg(
196
+ nodes: Map<string, ParsedNode>,
197
+ clusters: Map<string, ParsedCluster>
198
+ ): Map<string, Vec2> {
199
+ const out = new Map<string, Vec2>()
200
+ for (const [id, node] of nodes) {
201
+ out.set(id, { x: node.center.x, y: node.center.y })
202
+ }
203
+ for (const [id, cluster] of clusters) {
204
+ out.set(id, {
205
+ x: cluster.topLeft.x + cluster.width / 2,
206
+ y: cluster.topLeft.y + cluster.height / 2,
207
+ })
208
+ }
209
+ return out
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Layout scaling and bounds
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export function scaleLayout(
217
+ nodes: Map<string, ParsedNode>,
218
+ clusters: Map<string, ParsedCluster>,
219
+ edges: ParsedEdge[],
220
+ scale: number
221
+ ): void {
222
+ for (const [, node] of nodes) {
223
+ node.center.x *= scale
224
+ node.center.y *= scale
225
+ node.width *= scale
226
+ node.height *= scale
227
+ }
228
+ for (const [, cluster] of clusters) {
229
+ cluster.topLeft.x *= scale
230
+ cluster.topLeft.y *= scale
231
+ cluster.width *= scale
232
+ cluster.height *= scale
233
+ }
234
+ for (const edge of edges) {
235
+ for (const point of edge.points) {
236
+ point.x *= scale
237
+ point.y *= scale
238
+ }
239
+ }
240
+ }