@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.
- package/dist-cjs/blueprint.js +17 -0
- package/dist-cjs/blueprint.js.map +7 -0
- package/dist-cjs/colors.js +173 -0
- package/dist-cjs/colors.js.map +7 -0
- package/dist-cjs/createMermaidDiagram.js +144 -0
- package/dist-cjs/createMermaidDiagram.js.map +7 -0
- package/dist-cjs/flowchartDiagram.js +202 -0
- package/dist-cjs/flowchartDiagram.js.map +7 -0
- package/dist-cjs/index.d.ts +114 -0
- package/dist-cjs/index.js +34 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/renderBlueprint.js +314 -0
- package/dist-cjs/renderBlueprint.js.map +7 -0
- package/dist-cjs/sequenceDiagram.js +686 -0
- package/dist-cjs/sequenceDiagram.js.map +7 -0
- package/dist-cjs/stateDiagram.js +373 -0
- package/dist-cjs/stateDiagram.js.map +7 -0
- package/dist-cjs/svgParsing.js +187 -0
- package/dist-cjs/svgParsing.js.map +7 -0
- package/dist-cjs/utils.js +75 -0
- package/dist-cjs/utils.js.map +7 -0
- package/dist-esm/blueprint.mjs +1 -0
- package/dist-esm/blueprint.mjs.map +7 -0
- package/dist-esm/colors.mjs +153 -0
- package/dist-esm/colors.mjs.map +7 -0
- package/dist-esm/createMermaidDiagram.mjs +114 -0
- package/dist-esm/createMermaidDiagram.mjs.map +7 -0
- package/dist-esm/flowchartDiagram.mjs +188 -0
- package/dist-esm/flowchartDiagram.mjs.map +7 -0
- package/dist-esm/index.d.mts +114 -0
- package/dist-esm/index.mjs +14 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/renderBlueprint.mjs +298 -0
- package/dist-esm/renderBlueprint.mjs.map +7 -0
- package/dist-esm/sequenceDiagram.mjs +666 -0
- package/dist-esm/sequenceDiagram.mjs.map +7 -0
- package/dist-esm/stateDiagram.mjs +359 -0
- package/dist-esm/stateDiagram.mjs.map +7 -0
- package/dist-esm/svgParsing.mjs +167 -0
- package/dist-esm/svgParsing.mjs.map +7 -0
- package/dist-esm/utils.mjs +55 -0
- package/dist-esm/utils.mjs.map +7 -0
- package/package.json +62 -0
- package/src/blueprint.ts +75 -0
- package/src/colors.ts +215 -0
- package/src/createMermaidDiagram.test.ts +31 -0
- package/src/createMermaidDiagram.ts +155 -0
- package/src/flowchartDiagram.ts +232 -0
- package/src/index.ts +18 -0
- package/src/mermaidDiagrams.test.ts +880 -0
- package/src/renderBlueprint.ts +373 -0
- package/src/sequenceDiagram.ts +851 -0
- package/src/stateDiagram.ts +477 -0
- package/src/svgParsing.ts +240 -0
- 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
|
+
}
|