@zombie-mermaid/svg-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zombie-mermaid/svg-renderer",
|
|
3
|
+
"version": "2.2.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "dist/index.cjs",
|
|
9
|
+
"module": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/index.d.cts",
|
|
19
|
+
"default": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src/",
|
|
29
|
+
"dist/",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zombie-mermaid/core": "workspace:*",
|
|
34
|
+
"@zombie-mermaid/mermaid-parser": "workspace:*",
|
|
35
|
+
"elkjs": "^0.11.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shared ELK edge-geometry extraction helpers
|
|
3
|
+
* (`extractEdgePoints` / `extractEdgeLabelPosition`).
|
|
4
|
+
*
|
|
5
|
+
* `from-elk.ts`, `class/layout.ts`, and `er/layout.ts` all exercise this
|
|
6
|
+
* module indirectly through full-diagram integration tests, but those
|
|
7
|
+
* only ever hit the "happy path" (an edge with sections and, sometimes,
|
|
8
|
+
* a placed label). These tests cover the defensive branches directly —
|
|
9
|
+
* missing/empty sections, missing/empty labels, an unplaced label, and
|
|
10
|
+
* missing bend points/label dimensions — so every branch has direct
|
|
11
|
+
* coverage rather than relying on an integration test to happen to hit it.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import type { ElkExtendedEdge } from 'elkjs'
|
|
15
|
+
import {
|
|
16
|
+
extractEdgePoints,
|
|
17
|
+
extractEdgeLabelPosition,
|
|
18
|
+
} from '@zombie-mermaid/svg-renderer'
|
|
19
|
+
|
|
20
|
+
function edge(partial: Partial<ElkExtendedEdge>): ElkExtendedEdge {
|
|
21
|
+
return { id: 'e0', sources: ['a'], targets: ['b'], ...partial }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('extractEdgePoints', () => {
|
|
25
|
+
it('returns an empty array when the edge has no sections property', () => {
|
|
26
|
+
expect(extractEdgePoints(edge({}))).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns an empty array when sections is an empty array', () => {
|
|
30
|
+
expect(extractEdgePoints(edge({ sections: [] }))).toEqual([])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('walks start -> end with no bend points', () => {
|
|
34
|
+
const e = edge({
|
|
35
|
+
sections: [
|
|
36
|
+
{
|
|
37
|
+
id: 's0',
|
|
38
|
+
startPoint: { x: 10, y: 20 },
|
|
39
|
+
endPoint: { x: 30, y: 40 },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
expect(extractEdgePoints(e)).toEqual([
|
|
44
|
+
{ x: 10, y: 20 },
|
|
45
|
+
{ x: 30, y: 40 },
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('walks start -> bendPoints -> end when bend points are present', () => {
|
|
50
|
+
const e = edge({
|
|
51
|
+
sections: [
|
|
52
|
+
{
|
|
53
|
+
id: 's0',
|
|
54
|
+
startPoint: { x: 0, y: 0 },
|
|
55
|
+
endPoint: { x: 100, y: 0 },
|
|
56
|
+
bendPoints: [
|
|
57
|
+
{ x: 25, y: 50 },
|
|
58
|
+
{ x: 75, y: 50 },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
})
|
|
63
|
+
expect(extractEdgePoints(e)).toEqual([
|
|
64
|
+
{ x: 0, y: 0 },
|
|
65
|
+
{ x: 25, y: 50 },
|
|
66
|
+
{ x: 75, y: 50 },
|
|
67
|
+
{ x: 100, y: 0 },
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('translates every point by offsetX/offsetY when provided', () => {
|
|
72
|
+
const e = edge({
|
|
73
|
+
sections: [
|
|
74
|
+
{
|
|
75
|
+
id: 's0',
|
|
76
|
+
startPoint: { x: 0, y: 0 },
|
|
77
|
+
endPoint: { x: 10, y: 10 },
|
|
78
|
+
bendPoints: [{ x: 5, y: 5 }],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
expect(extractEdgePoints(e, 100, 200)).toEqual([
|
|
83
|
+
{ x: 100, y: 200 },
|
|
84
|
+
{ x: 105, y: 205 },
|
|
85
|
+
{ x: 110, y: 210 },
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('only reads the first section when multiple are present', () => {
|
|
90
|
+
const e = edge({
|
|
91
|
+
sections: [
|
|
92
|
+
{ id: 's0', startPoint: { x: 0, y: 0 }, endPoint: { x: 1, y: 1 } },
|
|
93
|
+
{ id: 's1', startPoint: { x: 9, y: 9 }, endPoint: { x: 8, y: 8 } },
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
expect(extractEdgePoints(e)).toEqual([
|
|
97
|
+
{ x: 0, y: 0 },
|
|
98
|
+
{ x: 1, y: 1 },
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('extractEdgeLabelPosition', () => {
|
|
104
|
+
it('returns undefined when the edge has no labels property', () => {
|
|
105
|
+
expect(extractEdgeLabelPosition(edge({}))).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns undefined when labels is an empty array', () => {
|
|
109
|
+
expect(extractEdgeLabelPosition(edge({ labels: [] }))).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns undefined when the label has no x/y (ELK left it unplaced)', () => {
|
|
113
|
+
expect(
|
|
114
|
+
extractEdgeLabelPosition(edge({ labels: [{ text: 'foo' }] })),
|
|
115
|
+
).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns undefined when only x is set (y still unplaced)', () => {
|
|
119
|
+
expect(
|
|
120
|
+
extractEdgeLabelPosition(edge({ labels: [{ text: 'foo', x: 5 }] })),
|
|
121
|
+
).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('computes the label center, defaulting missing width/height to 0', () => {
|
|
125
|
+
expect(
|
|
126
|
+
extractEdgeLabelPosition(
|
|
127
|
+
edge({ labels: [{ text: 'foo', x: 10, y: 20 }] }),
|
|
128
|
+
),
|
|
129
|
+
).toEqual({ x: 10, y: 20 })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('computes the label center from x/y/width/height', () => {
|
|
133
|
+
expect(
|
|
134
|
+
extractEdgeLabelPosition(
|
|
135
|
+
edge({
|
|
136
|
+
labels: [{ text: 'foo', x: 10, y: 20, width: 40, height: 10 }],
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
).toEqual({ x: 30, y: 25 })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('translates the computed center by offsetX/offsetY when provided', () => {
|
|
143
|
+
expect(
|
|
144
|
+
extractEdgeLabelPosition(
|
|
145
|
+
edge({
|
|
146
|
+
labels: [{ text: 'foo', x: 10, y: 20, width: 40, height: 10 }],
|
|
147
|
+
}),
|
|
148
|
+
100,
|
|
149
|
+
200,
|
|
150
|
+
),
|
|
151
|
+
).toEqual({ x: 130, y: 225 })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('only reads the first label when multiple are present', () => {
|
|
155
|
+
expect(
|
|
156
|
+
extractEdgeLabelPosition(
|
|
157
|
+
edge({
|
|
158
|
+
labels: [
|
|
159
|
+
{ text: 'first', x: 0, y: 0 },
|
|
160
|
+
{ text: 'second', x: 100, y: 100 },
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
),
|
|
164
|
+
).toEqual({ x: 0, y: 0 })
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class diagram layout engine (ELK.js).
|
|
3
|
+
*
|
|
4
|
+
* Each class box has 3 compartments:
|
|
5
|
+
* 1. Header (class name + optional annotation)
|
|
6
|
+
* 2. Attributes section
|
|
7
|
+
* 3. Methods section
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ElkNode, ElkExtendedEdge } from 'elkjs'
|
|
11
|
+
import type {
|
|
12
|
+
ClassDiagram,
|
|
13
|
+
ClassNode,
|
|
14
|
+
ClassMember,
|
|
15
|
+
PositionedClassDiagram,
|
|
16
|
+
PositionedClassNode,
|
|
17
|
+
PositionedClassNote,
|
|
18
|
+
PositionedClassRelationship,
|
|
19
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
20
|
+
import { formatClassMember } from '@zombie-mermaid/mermaid-parser'
|
|
21
|
+
import type { ClassRenderOptions } from '@zombie-mermaid/core'
|
|
22
|
+
import {
|
|
23
|
+
estimateTextWidth,
|
|
24
|
+
estimateMonoTextWidth,
|
|
25
|
+
FONT_WEIGHTS,
|
|
26
|
+
resolveFontSizes,
|
|
27
|
+
} from '../styles.ts'
|
|
28
|
+
import { elkLayoutSync } from '../elk-instance.ts'
|
|
29
|
+
import {
|
|
30
|
+
extractEdgePoints,
|
|
31
|
+
extractEdgeLabelPosition,
|
|
32
|
+
} from '../layout-engine/elk-adapter-utils.ts'
|
|
33
|
+
import {
|
|
34
|
+
ELK_DIRECTION_FALLBACK,
|
|
35
|
+
baseElkLayoutOptions,
|
|
36
|
+
buildElkEdge,
|
|
37
|
+
buildElkLeafNode,
|
|
38
|
+
directionToElk,
|
|
39
|
+
} from '../layout-engine/elk-graph-builder.ts'
|
|
40
|
+
import { measureMultilineText, resolveNodeStyle } from '@zombie-mermaid/core'
|
|
41
|
+
|
|
42
|
+
/** Layout constants for class diagrams */
|
|
43
|
+
export const CLS = {
|
|
44
|
+
padding: 40,
|
|
45
|
+
boxPadX: 8,
|
|
46
|
+
headerBaseHeight: 32,
|
|
47
|
+
annotationHeight: 16,
|
|
48
|
+
memberRowHeight: 20,
|
|
49
|
+
sectionPadY: 8,
|
|
50
|
+
emptySectionHeight: 8,
|
|
51
|
+
minWidth: 120,
|
|
52
|
+
memberFontSize: 11,
|
|
53
|
+
memberFontWeight: 400,
|
|
54
|
+
nodeSpacing: 40,
|
|
55
|
+
layerSpacing: 60,
|
|
56
|
+
/** Horizontal / vertical padding inside a note box, around its text */
|
|
57
|
+
notePadX: 10,
|
|
58
|
+
notePadY: 6,
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Layout id for the i-th note. A class id is a run of non-whitespace
|
|
63
|
+
* (`\S+` in the parser), so an id containing a space can never collide
|
|
64
|
+
* with one; ELK treats ids as opaque strings.
|
|
65
|
+
*/
|
|
66
|
+
export function classNoteId(index: number): string {
|
|
67
|
+
return `note ${index}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Layout id for the dotted link joining note `noteId` to its class. */
|
|
71
|
+
function classNoteLinkId(noteId: string): string {
|
|
72
|
+
return `${noteId} link`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ClassSizeMap = Map<
|
|
76
|
+
string,
|
|
77
|
+
{
|
|
78
|
+
width: number
|
|
79
|
+
height: number
|
|
80
|
+
headerHeight: number
|
|
81
|
+
attrHeight: number
|
|
82
|
+
methodHeight: number
|
|
83
|
+
}
|
|
84
|
+
>
|
|
85
|
+
|
|
86
|
+
/** Size of each note box, keyed by its layout id. */
|
|
87
|
+
type NoteSizeMap = Map<string, { width: number; height: number }>
|
|
88
|
+
|
|
89
|
+
/** Build ELK graph and size map from a class diagram. */
|
|
90
|
+
function buildClassElkGraph(
|
|
91
|
+
diagram: ClassDiagram,
|
|
92
|
+
options: ClassRenderOptions,
|
|
93
|
+
): { elkGraph: ElkNode; classSizes: ClassSizeMap; noteSizes: NoteSizeMap } {
|
|
94
|
+
const classSizes: ClassSizeMap = new Map()
|
|
95
|
+
const noteSizes: NoteSizeMap = new Map()
|
|
96
|
+
const fontSizes = resolveFontSizes(options.fontSizes)
|
|
97
|
+
|
|
98
|
+
for (const cls of diagram.classes) {
|
|
99
|
+
const headerHeight = cls.annotation
|
|
100
|
+
? CLS.headerBaseHeight + CLS.annotationHeight
|
|
101
|
+
: CLS.headerBaseHeight
|
|
102
|
+
|
|
103
|
+
const attrHeight =
|
|
104
|
+
cls.attributes.length > 0
|
|
105
|
+
? cls.attributes.length * CLS.memberRowHeight + CLS.sectionPadY
|
|
106
|
+
: CLS.emptySectionHeight
|
|
107
|
+
|
|
108
|
+
const methodHeight =
|
|
109
|
+
cls.methods.length > 0
|
|
110
|
+
? cls.methods.length * CLS.memberRowHeight + CLS.sectionPadY
|
|
111
|
+
: CLS.emptySectionHeight
|
|
112
|
+
|
|
113
|
+
const headerTextW = estimateTextWidth(
|
|
114
|
+
cls.label,
|
|
115
|
+
fontSizes.nodeLabel,
|
|
116
|
+
FONT_WEIGHTS.nodeLabel,
|
|
117
|
+
)
|
|
118
|
+
const maxAttrW = maxMemberWidth(cls.attributes)
|
|
119
|
+
const maxMethodW = maxMemberWidth(cls.methods)
|
|
120
|
+
const width = Math.max(
|
|
121
|
+
CLS.minWidth,
|
|
122
|
+
headerTextW + CLS.boxPadX * 2,
|
|
123
|
+
maxAttrW + CLS.boxPadX * 2,
|
|
124
|
+
maxMethodW + CLS.boxPadX * 2,
|
|
125
|
+
)
|
|
126
|
+
const height = headerHeight + attrHeight + methodHeight
|
|
127
|
+
|
|
128
|
+
classSizes.set(cls.id, {
|
|
129
|
+
width,
|
|
130
|
+
height,
|
|
131
|
+
headerHeight,
|
|
132
|
+
attrHeight,
|
|
133
|
+
methodHeight,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Iterate classSizes directly (populated above, in diagram.classes order)
|
|
138
|
+
// rather than looking each class back up by id — sidesteps needing an
|
|
139
|
+
// assertion or invariant check for a lookup that can't actually miss.
|
|
140
|
+
const children: ElkNode[] = []
|
|
141
|
+
for (const [id, size] of classSizes) {
|
|
142
|
+
children.push(buildElkLeafNode(id, size))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Class edge labels carry no per-label layout options — placement is set
|
|
146
|
+
// once on the root graph below (`elk.edgeLabels.placement: CENTER`).
|
|
147
|
+
const labelStyle = { fontSize: fontSizes.edgeLabel }
|
|
148
|
+
|
|
149
|
+
const edges: ElkExtendedEdge[] = []
|
|
150
|
+
for (const [i, rel] of diagram.relationships.entries()) {
|
|
151
|
+
edges.push(
|
|
152
|
+
buildElkEdge({
|
|
153
|
+
id: `e${i}`,
|
|
154
|
+
source: rel.from,
|
|
155
|
+
target: rel.to,
|
|
156
|
+
label: rel.label,
|
|
157
|
+
labelStyle,
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Notes. Mirrors Mermaid's classDb.getData(): every note is a node of its
|
|
163
|
+
// own, and `note for X` adds an arrowless dotted edge note→class so the
|
|
164
|
+
// layout keeps the two adjacent (with a DOWN layout the note lands above
|
|
165
|
+
// its class, as it does in Mermaid's TB rendering). The link edges go
|
|
166
|
+
// after the relationship edges so relationship indices stay positional.
|
|
167
|
+
const classIds = new Set(classSizes.keys())
|
|
168
|
+
for (const [i, note] of diagram.notes.entries()) {
|
|
169
|
+
const id = classNoteId(i)
|
|
170
|
+
const metrics = measureMultilineText(
|
|
171
|
+
note.text,
|
|
172
|
+
fontSizes.edgeLabel,
|
|
173
|
+
FONT_WEIGHTS.edgeLabel,
|
|
174
|
+
)
|
|
175
|
+
const size = {
|
|
176
|
+
width: metrics.width + CLS.notePadX * 2,
|
|
177
|
+
height: metrics.height + CLS.notePadY * 2,
|
|
178
|
+
}
|
|
179
|
+
noteSizes.set(id, size)
|
|
180
|
+
children.push(buildElkLeafNode(id, size))
|
|
181
|
+
if (note.forClass !== undefined && classIds.has(note.forClass)) {
|
|
182
|
+
edges.push(
|
|
183
|
+
buildElkEdge({
|
|
184
|
+
id: classNoteLinkId(id),
|
|
185
|
+
source: id,
|
|
186
|
+
target: note.forClass,
|
|
187
|
+
labelStyle,
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const elkGraph: ElkNode = {
|
|
194
|
+
id: 'root',
|
|
195
|
+
layoutOptions: {
|
|
196
|
+
...baseElkLayoutOptions({
|
|
197
|
+
// Class diagrams have no `direction` concept — they always lay out
|
|
198
|
+
// top-down. See ELK_DIRECTION_FALLBACK for why that default is
|
|
199
|
+
// per-diagram-type rather than shared with ER's.
|
|
200
|
+
direction: directionToElk(undefined, ELK_DIRECTION_FALLBACK.class),
|
|
201
|
+
nodeSpacing: CLS.nodeSpacing,
|
|
202
|
+
layerSpacing: CLS.layerSpacing,
|
|
203
|
+
padding: CLS.padding,
|
|
204
|
+
}),
|
|
205
|
+
'elk.edgeLabels.placement': 'CENTER',
|
|
206
|
+
},
|
|
207
|
+
children,
|
|
208
|
+
edges,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { elkGraph, classSizes, noteSizes }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Extract positioned classes, relationships, and notes from ELK result. */
|
|
215
|
+
function extractClassLayout(
|
|
216
|
+
result: ElkNode,
|
|
217
|
+
diagram: ClassDiagram,
|
|
218
|
+
classSizes: ClassSizeMap,
|
|
219
|
+
noteSizes: NoteSizeMap,
|
|
220
|
+
): PositionedClassDiagram {
|
|
221
|
+
const classLookup = new Map<string, ClassNode>()
|
|
222
|
+
for (const cls of diagram.classes) classLookup.set(cls.id, cls)
|
|
223
|
+
|
|
224
|
+
const positionedClasses: PositionedClassNode[] = []
|
|
225
|
+
for (const child of result.children ?? []) {
|
|
226
|
+
const cls = classLookup.get(child.id)
|
|
227
|
+
if (cls) {
|
|
228
|
+
const size = classSizes.get(cls.id)
|
|
229
|
+
if (!size) {
|
|
230
|
+
// Unreachable — classSizes is populated for every diagram.classes
|
|
231
|
+
// entry, and classLookup/cls.id come from that same list.
|
|
232
|
+
/* v8 ignore next */
|
|
233
|
+
throw new Error(`Missing computed size for class "${cls.id}"`)
|
|
234
|
+
}
|
|
235
|
+
positionedClasses.push({
|
|
236
|
+
id: cls.id,
|
|
237
|
+
label: cls.label,
|
|
238
|
+
annotation: cls.annotation,
|
|
239
|
+
attributes: cls.attributes,
|
|
240
|
+
methods: cls.methods,
|
|
241
|
+
x: child.x ?? 0,
|
|
242
|
+
y: child.y ?? 0,
|
|
243
|
+
width: child.width ?? size.width,
|
|
244
|
+
height: child.height ?? size.height,
|
|
245
|
+
headerHeight: size.headerHeight,
|
|
246
|
+
attrHeight: size.attrHeight,
|
|
247
|
+
methodHeight: size.methodHeight,
|
|
248
|
+
interaction: diagram.interactions.get(cls.id),
|
|
249
|
+
// Same cascade the flowchart layout applies (src/layout-engine/
|
|
250
|
+
// from-elk.ts): classDef default → assigned class → `style`.
|
|
251
|
+
inlineStyle: resolveNodeStyle(cls.id, diagram),
|
|
252
|
+
// Kept separately from inlineStyle so the class name still reaches
|
|
253
|
+
// the SVG `class` attribute when it has no matching classDef.
|
|
254
|
+
className: diagram.classAssignments.get(cls.id),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const relationships: PositionedClassRelationship[] = []
|
|
260
|
+
const elkEdges = result.edges ?? []
|
|
261
|
+
// The first diagram.relationships.length edges are the relationships, in
|
|
262
|
+
// order — buildClassElkGraph creates exactly one ELK edge per relationship
|
|
263
|
+
// before appending any note links.
|
|
264
|
+
for (const [i, rel] of diagram.relationships.entries()) {
|
|
265
|
+
const elkEdge = elkEdges[i]
|
|
266
|
+
if (!elkEdge) {
|
|
267
|
+
// Unreachable — ELK returns every edge it was given.
|
|
268
|
+
/* v8 ignore next */
|
|
269
|
+
throw new Error(`Missing ELK edge for relationship ${i}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const points = extractEdgePoints(elkEdge)
|
|
273
|
+
const labelPosition = extractEdgeLabelPosition(elkEdge)
|
|
274
|
+
|
|
275
|
+
relationships.push({
|
|
276
|
+
from: rel.from,
|
|
277
|
+
to: rel.to,
|
|
278
|
+
type: rel.type,
|
|
279
|
+
markerAt: rel.markerAt,
|
|
280
|
+
label: rel.label,
|
|
281
|
+
fromCardinality: rel.fromCardinality,
|
|
282
|
+
toCardinality: rel.toCardinality,
|
|
283
|
+
points,
|
|
284
|
+
labelPosition,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Note links are matched by id rather than position — only notes whose
|
|
289
|
+
// class exists got an edge, so their count isn't derivable from the note list.
|
|
290
|
+
const linkEdges = new Map<string, ElkExtendedEdge>()
|
|
291
|
+
for (const elkEdge of elkEdges.slice(diagram.relationships.length)) {
|
|
292
|
+
linkEdges.set(elkEdge.id, elkEdge)
|
|
293
|
+
}
|
|
294
|
+
const childById = new Map<string, ElkNode>()
|
|
295
|
+
for (const child of result.children ?? []) childById.set(child.id, child)
|
|
296
|
+
|
|
297
|
+
const notes: PositionedClassNote[] = []
|
|
298
|
+
for (const [i, note] of diagram.notes.entries()) {
|
|
299
|
+
const id = classNoteId(i)
|
|
300
|
+
const child = childById.get(id)
|
|
301
|
+
const size = noteSizes.get(id)
|
|
302
|
+
if (!child || !size) {
|
|
303
|
+
// Unreachable — a child and a size are recorded for every note.
|
|
304
|
+
/* v8 ignore next */
|
|
305
|
+
throw new Error(`Missing layout for note ${i}`)
|
|
306
|
+
}
|
|
307
|
+
const link = linkEdges.get(classNoteLinkId(id))
|
|
308
|
+
notes.push({
|
|
309
|
+
id,
|
|
310
|
+
text: note.text,
|
|
311
|
+
...(link && note.forClass !== undefined
|
|
312
|
+
? { forClass: note.forClass }
|
|
313
|
+
: {}),
|
|
314
|
+
x: child.x ?? 0,
|
|
315
|
+
y: child.y ?? 0,
|
|
316
|
+
width: child.width ?? size.width,
|
|
317
|
+
height: child.height ?? size.height,
|
|
318
|
+
...(link ? { linkPoints: extractEdgePoints(link) } : {}),
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
width: result.width ?? 600,
|
|
324
|
+
height: result.height ?? 400,
|
|
325
|
+
classes: positionedClasses,
|
|
326
|
+
relationships,
|
|
327
|
+
notes,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Lay out a parsed class diagram using ELK.js (synchronous).
|
|
333
|
+
*/
|
|
334
|
+
export function layoutClassDiagramSync(
|
|
335
|
+
diagram: ClassDiagram,
|
|
336
|
+
options: ClassRenderOptions = {},
|
|
337
|
+
): PositionedClassDiagram {
|
|
338
|
+
if (diagram.classes.length === 0 && diagram.notes.length === 0) {
|
|
339
|
+
return { width: 0, height: 0, classes: [], relationships: [], notes: [] }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { elkGraph, classSizes, noteSizes } = buildClassElkGraph(
|
|
343
|
+
diagram,
|
|
344
|
+
options,
|
|
345
|
+
)
|
|
346
|
+
const result = elkLayoutSync(elkGraph, options.layoutCache)
|
|
347
|
+
return extractClassLayout(result, diagram, classSizes, noteSizes)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Calculate the max width of a list of class members (uses mono metrics) */
|
|
351
|
+
function maxMemberWidth(members: ClassMember[]): number {
|
|
352
|
+
if (members.length === 0) return 0
|
|
353
|
+
let maxW = 0
|
|
354
|
+
for (const m of members) {
|
|
355
|
+
const text = formatClassMember(m)
|
|
356
|
+
const w = estimateMonoTextWidth(text, CLS.memberFontSize)
|
|
357
|
+
if (w > maxW) maxW = w
|
|
358
|
+
}
|
|
359
|
+
return maxW
|
|
360
|
+
}
|