@zombie-mermaid/svg-renderer 4.2.2 → 5.0.0
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/README.md +15 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -4
- package/dist/index.d.ts +23 -4
- package/dist/index.js +897 -849
- package/dist/index.js.map +1 -1
- package/package.json +14 -5
- package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
- package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
- package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
- package/src/__tests__/c4-svg.test.ts +0 -337
- package/src/__tests__/c4-text-width-1210.test.ts +0 -43
- package/src/__tests__/class-component-order-1249.test.ts +0 -40
- package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
- package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
- package/src/__tests__/class-namespace-1196.test.ts +0 -165
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
- package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
- package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
- package/src/__tests__/elk-adapter-utils.test.ts +0 -166
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
- package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
- package/src/__tests__/er-default-direction-1250.test.ts +0 -69
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
- package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
- package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
- package/src/__tests__/pie-layout.test.ts +0 -215
- package/src/__tests__/pie-svg.test.ts +0 -194
- package/src/__tests__/render-mermaid-svg.test.ts +0 -64
- package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
- package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
- package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
- package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
- package/src/c4/arial-widths.ts +0 -59
- package/src/c4/layout.ts +0 -680
- package/src/c4/metrics.ts +0 -261
- package/src/c4/renderer.ts +0 -366
- package/src/class/layout.ts +0 -525
- package/src/class/renderer.ts +0 -669
- package/src/edge-curves.ts +0 -205
- package/src/elk-instance.ts +0 -292
- package/src/er/layout.ts +0 -216
- package/src/er/renderer.ts +0 -516
- package/src/generated/mono-font-subset.ts +0 -32
- package/src/index.ts +0 -277
- package/src/layout-engine/back-edges.ts +0 -73
- package/src/layout-engine/compound-flat.ts +0 -916
- package/src/layout-engine/constants.ts +0 -19
- package/src/layout-engine/edge-bundling.ts +0 -458
- package/src/layout-engine/elk-adapter-utils.ts +0 -81
- package/src/layout-engine/elk-graph-builder.ts +0 -245
- package/src/layout-engine/from-elk.ts +0 -767
- package/src/layout-engine/inner-edges.ts +0 -329
- package/src/layout-engine/jog-straightening.ts +0 -52
- package/src/layout-engine/layer-alignment.ts +0 -174
- package/src/layout-engine/layout-hints.ts +0 -27
- package/src/layout-engine/to-elk.ts +0 -855
- package/src/layout-engine.ts +0 -115
- package/src/pie/layout.ts +0 -234
- package/src/pie/renderer.ts +0 -161
- package/src/registry.ts +0 -441
- package/src/renderer.ts +0 -1510
- package/src/resolve-colors.ts +0 -339
- package/src/sequence/layout.ts +0 -766
- package/src/sequence/renderer.ts +0 -687
- package/src/shape-clipping.ts +0 -197
- package/src/styles.ts +0 -145
- package/src/title-gaps.ts +0 -161
- package/src/xychart/layout.ts +0 -727
- package/src/xychart/renderer.ts +0 -676
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zombie-mermaid/svg-renderer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
|
|
6
6
|
"repository": {
|
|
@@ -30,14 +30,23 @@
|
|
|
30
30
|
"provenance": true
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
-
"src/",
|
|
34
33
|
"dist/",
|
|
35
34
|
"LICENSE"
|
|
36
35
|
],
|
|
37
36
|
"dependencies": {
|
|
38
|
-
"elkjs": "^0.11.0",
|
|
39
37
|
"entities": "^7.0.1",
|
|
40
|
-
"@zombie-mermaid/core": "
|
|
41
|
-
"@zombie-mermaid/mermaid-parser": "
|
|
38
|
+
"@zombie-mermaid/core": "5.0.0",
|
|
39
|
+
"@zombie-mermaid/mermaid-parser": "5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"elkjs": "^0.11.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"elkjs": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"elkjs": "^0.11.0"
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { c4RelLabelLines, parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import { layoutC4DiagramSync } from '../c4/layout.ts'
|
|
5
|
-
import { C4, c4TextWidth } from '../c4/metrics.ts'
|
|
6
|
-
|
|
7
|
-
// Mermaid starts a relationship label at the chord's midpoint, which can land
|
|
8
|
-
// it on a shape. The label slides along its line to clear every shape (#1290).
|
|
9
|
-
const layout = (src: string) =>
|
|
10
|
-
layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
|
|
11
|
-
|
|
12
|
-
// The C4 Container sample: `Reads/writes [SQL]` and `Publishes [AMQP]` both
|
|
13
|
-
// used to sit on the Database cylinder.
|
|
14
|
-
const container = `C4Container
|
|
15
|
-
Person(user, "User")
|
|
16
|
-
System_Boundary(sys, "Shop") {
|
|
17
|
-
Container(web, "Web App", "React", "Storefront")
|
|
18
|
-
Container(api, "API", "Node.js", "Business logic")
|
|
19
|
-
ContainerDb(db, "Database", "PostgreSQL", "Orders")
|
|
20
|
-
ContainerQueue(q, "Events", "Kafka")
|
|
21
|
-
}
|
|
22
|
-
System_Ext(pay, "Payments")
|
|
23
|
-
Rel(user, web, "Browses")
|
|
24
|
-
Rel(web, api, "Calls", "JSON/HTTPS")
|
|
25
|
-
Rel(api, db, "Reads/writes", "SQL")
|
|
26
|
-
BiRel(api, q, "Publishes", "AMQP")
|
|
27
|
-
Rel(api, pay, "Charges cards")`
|
|
28
|
-
|
|
29
|
-
function labelRects(d: ReturnType<typeof layout>) {
|
|
30
|
-
return d.relationships.flatMap((rel) => {
|
|
31
|
-
if (!rel.labelPosition) return []
|
|
32
|
-
const lines = c4RelLabelLines(rel)
|
|
33
|
-
return lines.map((line, i) => {
|
|
34
|
-
const isTech = rel.technology !== undefined && i === lines.length - 1
|
|
35
|
-
const w = c4TextWidth(line, C4.messageSize, 400)
|
|
36
|
-
const cx =
|
|
37
|
-
isTech && rel.technologyX !== undefined
|
|
38
|
-
? rel.technologyX
|
|
39
|
-
: rel.labelPosition!.x
|
|
40
|
-
const cy = rel.labelPosition!.y + (i === 0 ? 0 : C4.messageSize + 5)
|
|
41
|
-
return {
|
|
42
|
-
rel: `${rel.from}->${rel.to}`,
|
|
43
|
-
x0: cx - w / 2,
|
|
44
|
-
x1: cx + w / 2,
|
|
45
|
-
y0: cy - C4.messageSize / 2,
|
|
46
|
-
y1: cy + C4.messageSize / 2,
|
|
47
|
-
}
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function overlapping(d: ReturnType<typeof layout>): string[] {
|
|
53
|
-
const hits: string[] = []
|
|
54
|
-
for (const r of labelRects(d)) {
|
|
55
|
-
for (const e of d.elements) {
|
|
56
|
-
if (
|
|
57
|
-
r.x0 < e.x + e.width &&
|
|
58
|
-
r.x1 > e.x &&
|
|
59
|
-
r.y0 < e.y + e.height &&
|
|
60
|
-
r.y1 > e.y
|
|
61
|
-
) {
|
|
62
|
-
hits.push(`${r.rel} label over ${e.alias}`)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return hits
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
describe('C4 relationship labels clear the shapes (#1290)', () => {
|
|
70
|
-
it('keeps every label in the Container sample off every shape', () => {
|
|
71
|
-
expect(overlapping(layout(container))).toEqual([])
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('keeps a label on its own line, only sliding along it', () => {
|
|
75
|
-
const d = layout(container)
|
|
76
|
-
const rel = d.relationships.find((r) => r.to === 'db')!
|
|
77
|
-
const [s, e] = rel.points
|
|
78
|
-
const dx = e!.x - s!.x
|
|
79
|
-
const dy = e!.y - s!.y
|
|
80
|
-
const t = rel.labelPosition!
|
|
81
|
-
// Same distance from the chord as an unmoved label: Mermaid's label is
|
|
82
|
-
// half its own width to the right of the midpoint, never further off.
|
|
83
|
-
const across = Math.abs(
|
|
84
|
-
((t.x - s!.x) * dy - (t.y - s!.y) * dx) / Math.hypot(dx, dy),
|
|
85
|
-
)
|
|
86
|
-
expect(across).toBeLessThan(40)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('leaves a label that already clears every shape where Mermaid puts it', () => {
|
|
90
|
-
const d = layout(`C4Context
|
|
91
|
-
System(a, "A")
|
|
92
|
-
System(b, "B")
|
|
93
|
-
Rel(a, b, "Uses")`)
|
|
94
|
-
const rel = d.relationships[0]!
|
|
95
|
-
const [s, e] = rel.points
|
|
96
|
-
const mid = (s!.x + e!.x) / 2
|
|
97
|
-
const width = c4TextWidth('Uses', C4.messageSize, 400)
|
|
98
|
-
expect(rel.labelPosition!.x).toBeCloseTo(mid + width / 2, 5)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('does not let two labels in one gap sit on each other', () => {
|
|
102
|
-
const rects = labelRects(layout(container))
|
|
103
|
-
for (const [i, a] of rects.entries()) {
|
|
104
|
-
for (const b of rects.slice(i + 1)) {
|
|
105
|
-
if (a.rel === b.rel) continue
|
|
106
|
-
const apart =
|
|
107
|
-
a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0
|
|
108
|
-
expect(apart, `${a.rel} vs ${b.rel}`).toBe(true)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
})
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import { layoutC4DiagramSync } from '../c4/layout.ts'
|
|
5
|
-
import { c4PersonGeometry } from '../c4/metrics.ts'
|
|
6
|
-
|
|
7
|
-
// A relationship meets a cylinder, pipe or person where Mermaid's own shape
|
|
8
|
-
// outline puts it, not at the box around the shape (#1208). The pipe and the
|
|
9
|
-
// oblique person cases are checked against recorded Mermaid output in
|
|
10
|
-
// c4-mermaid-reference.test.ts.
|
|
11
|
-
const layout = (src: string) =>
|
|
12
|
-
layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
|
|
13
|
-
|
|
14
|
-
describe('C4 line ends on shaped elements (#1208)', () => {
|
|
15
|
-
// Row 1 holds four shapes and row 2 starts under the first, so the line from
|
|
16
|
-
// `b` down to the database arrives from the upper right.
|
|
17
|
-
const src = `C4Context
|
|
18
|
-
System(a, "A")
|
|
19
|
-
System(b, "B")
|
|
20
|
-
System(c, "C")
|
|
21
|
-
System(e, "E")
|
|
22
|
-
SystemDb(db, "Store")
|
|
23
|
-
Rel(b, db, "Reads")`
|
|
24
|
-
|
|
25
|
-
it('ends a slanted line on the curve of the cylinder cap, below the box top', () => {
|
|
26
|
-
const d = layout(src)
|
|
27
|
-
const store = d.elements.find((e) => e.alias === 'db')!
|
|
28
|
-
const [, end] = d.relationships[0]!.points
|
|
29
|
-
// 84.4px right of the centre the cap (rx 108, ry 15.8) has dropped
|
|
30
|
-
// 5.9px, so the line ends 5.9px under the top of the box, not on it.
|
|
31
|
-
expect(end!.y - store.y).toBeCloseTo(5.96, 1)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('ends a line into the bottom middle of a cylinder at the box bottom', () => {
|
|
35
|
-
const d = layout(`C4Context
|
|
36
|
-
SystemDb(db, "Store")
|
|
37
|
-
System(b, "B")
|
|
38
|
-
System(c, "C")
|
|
39
|
-
System(e, "E")
|
|
40
|
-
System(f, "F")
|
|
41
|
-
Rel(f, db, "Reads")`)
|
|
42
|
-
const store = d.elements.find((e) => e.alias === 'db')!
|
|
43
|
-
const [, end] = d.relationships[0]!.points
|
|
44
|
-
// Straight below the centre the ellipse is at its lowest: no lift.
|
|
45
|
-
expect(end!.y - (store.y + store.height)).toBeCloseTo(0, 5)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it("keeps a wide person head at Mermaid's 56px radius cap", () => {
|
|
49
|
-
expect(c4PersonGeometry(216).headRadius).toBeCloseTo(49.68, 2)
|
|
50
|
-
expect(c4PersonGeometry(400).headRadius).toBe(56)
|
|
51
|
-
})
|
|
52
|
-
})
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { readFileSync } from 'node:fs'
|
|
3
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
4
|
-
import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
5
|
-
import { layoutC4DiagramSync } from '../c4/layout.ts'
|
|
6
|
-
|
|
7
|
-
// Each case is a C4 source rendered by real Mermaid 11.17.2 (headless Chrome,
|
|
8
|
-
// screen width 1440), with the numbers read back from its SVG: the diagram
|
|
9
|
-
// size, the centre of every shape, and each relationship's line and label.
|
|
10
|
-
// The sources vary on purpose (shapes of every kind, wrapped names and
|
|
11
|
-
// descriptions, boundaries nested and side by side, relationships in every
|
|
12
|
-
// direction), so a constant tuned to one sample cannot satisfy all of them.
|
|
13
|
-
//
|
|
14
|
-
// Vertical numbers depend only on heights and gaps, so they must agree to a
|
|
15
|
-
// few pixels. Horizontal ones depend on text widths, which this package sums
|
|
16
|
-
// from Arial advances (Mermaid measures them in a browser), and relationship
|
|
17
|
-
// ends follow each shape's own outline, as Mermaid's do.
|
|
18
|
-
interface Reference {
|
|
19
|
-
name: string
|
|
20
|
-
source: string
|
|
21
|
-
width: number
|
|
22
|
-
height: number
|
|
23
|
-
elements: Record<string, [number, number]>
|
|
24
|
-
relationships: { line: number[]; label?: [number, number] }[]
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const cases: Reference[] = JSON.parse(
|
|
28
|
-
readFileSync(
|
|
29
|
-
new URL('./fixtures/c4-mermaid-reference.json', import.meta.url),
|
|
30
|
-
'utf8',
|
|
31
|
-
),
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
const Y = 3
|
|
35
|
-
const X = 2
|
|
36
|
-
const LINE = 3
|
|
37
|
-
// Relationships whose end this package cannot place within LINE, with the
|
|
38
|
-
// reason. Each is a limit of the shape outline, not of the text widths.
|
|
39
|
-
const WIDER_LINE = 9
|
|
40
|
-
const WIDER: Record<string, number[]> = {
|
|
41
|
-
// A line that skims along the top of a person's body, where the pill meets
|
|
42
|
-
// the head: 0.25px of height moves where it crosses by about 8px.
|
|
43
|
-
'v02-person-targets': [3],
|
|
44
|
-
}
|
|
45
|
-
const LABEL = 10
|
|
46
|
-
// Deliberate difference (#1209): Mermaid merges two relationships with the
|
|
47
|
-
// same from/to (the later one wins, one line drawn). This package draws both,
|
|
48
|
-
// so a relationship the author wrote is never dropped. Each entry is a case
|
|
49
|
-
// where Mermaid drew `n` lines for a source with `n + 1` relationships.
|
|
50
|
-
const DUPLICATE_PAIRS = new Set(['v07-dynamic'])
|
|
51
|
-
|
|
52
|
-
describe('C4 SVG layout against Mermaid on varied diagrams', () => {
|
|
53
|
-
it('covers a spread of diagrams', () => {
|
|
54
|
-
expect(cases.length).toBeGreaterThanOrEqual(30)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
for (const ref of cases) {
|
|
58
|
-
it(`${ref.name} matches Mermaid`, () => {
|
|
59
|
-
const d = layoutC4DiagramSync(parseC4Diagram(splitStatements(ref.source)))
|
|
60
|
-
expect(Math.abs(d.height - ref.height), 'diagram height').toBeLessThan(Y)
|
|
61
|
-
expect(Math.abs(d.width - ref.width), 'diagram width').toBeLessThan(X)
|
|
62
|
-
|
|
63
|
-
for (const e of d.elements) {
|
|
64
|
-
const r = ref.elements[e.alias]
|
|
65
|
-
if (!r) continue
|
|
66
|
-
const cx = e.x + e.width / 2
|
|
67
|
-
const cy = e.y + e.height / 2
|
|
68
|
-
expect(Math.abs(cx - r[0]), `${e.alias} centre x`).toBeLessThan(X)
|
|
69
|
-
expect(Math.abs(cy - r[1]), `${e.alias} centre y`).toBeLessThan(Y)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Mermaid merges duplicate pairs and this package draws both (see
|
|
73
|
-
// DUPLICATE_PAIRS), so the lines only line up when the counts agree.
|
|
74
|
-
// A count mismatch is only allowed for a listed case, and there it must
|
|
75
|
-
// be exactly the duplicate that is drawn extra.
|
|
76
|
-
if (DUPLICATE_PAIRS.has(ref.name)) {
|
|
77
|
-
expect(d.relationships.length, 'both duplicate lines drawn').toBe(
|
|
78
|
-
ref.relationships.length + 1,
|
|
79
|
-
)
|
|
80
|
-
const pairs = d.relationships.map((r) => `${r.from}>${r.to}`)
|
|
81
|
-
expect(
|
|
82
|
-
new Set(pairs).size,
|
|
83
|
-
'a pair is drawn twice, as written',
|
|
84
|
-
).toBeLessThan(pairs.length)
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
expect(d.relationships.length, 'relationship count').toBe(
|
|
88
|
-
ref.relationships.length,
|
|
89
|
-
)
|
|
90
|
-
d.relationships.forEach((rel, i) => {
|
|
91
|
-
const r = ref.relationships[i]!
|
|
92
|
-
const [s, e] = rel.points
|
|
93
|
-
const got = [s!.x, s!.y, e!.x, e!.y]
|
|
94
|
-
const limit = WIDER[ref.name]?.includes(i + 1) ? WIDER_LINE : LINE
|
|
95
|
-
got.forEach((v, k) => {
|
|
96
|
-
expect(
|
|
97
|
-
Math.abs(v - r.line[k]!),
|
|
98
|
-
`relationship ${i + 1} line[${k}]`,
|
|
99
|
-
).toBeLessThan(limit)
|
|
100
|
-
})
|
|
101
|
-
if (r.label && rel.labelPosition) {
|
|
102
|
-
// Mermaid's label can sit on a shape; this package slides it along
|
|
103
|
-
// the chord to clear it (#1290). That is the one allowed difference:
|
|
104
|
-
// the label may move along the line but not off it.
|
|
105
|
-
const dx = e!.x - s!.x
|
|
106
|
-
const dy = e!.y - s!.y
|
|
107
|
-
const len = Math.hypot(dx, dy)
|
|
108
|
-
const across = (p: { x: number; y: number }): number =>
|
|
109
|
-
((p.x - s!.x) * dy - (p.y - s!.y) * dx) / len
|
|
110
|
-
const ref0 = { x: r.label[0], y: r.label[1] }
|
|
111
|
-
const moved =
|
|
112
|
-
Math.abs(rel.labelPosition.x - ref0.x) >= LABEL ||
|
|
113
|
-
Math.abs(rel.labelPosition.y - ref0.y) >= LABEL
|
|
114
|
-
if (moved) {
|
|
115
|
-
expect(
|
|
116
|
-
Math.abs(across(rel.labelPosition) - across(ref0)),
|
|
117
|
-
`relationship ${i + 1} label is off the chord`,
|
|
118
|
-
).toBeLessThan(LABEL)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
})
|
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
-
import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
4
|
-
import type { PositionedC4Diagram } from '@zombie-mermaid/mermaid-parser'
|
|
5
|
-
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
6
|
-
import { layoutC4DiagramSync } from '../c4/layout.ts'
|
|
7
|
-
|
|
8
|
-
function layout(src: string, direction?: 'LR'): PositionedC4Diagram {
|
|
9
|
-
const d = parseC4Diagram(splitStatements(src))
|
|
10
|
-
return layoutC4DiagramSync(direction ? { ...d, direction } : d)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const el = (p: PositionedC4Diagram, alias: string) =>
|
|
14
|
-
p.elements.find((e) => e.alias === alias)!
|
|
15
|
-
const centerX = (e: { x: number; width: number }) => e.x + e.width / 2
|
|
16
|
-
|
|
17
|
-
// The layout follows Mermaid's own C4 renderer: shapes go in rows in
|
|
18
|
-
// declaration order, relationships are drawn afterwards and move nothing.
|
|
19
|
-
describe('C4 SVG layout (Mermaid grid)', () => {
|
|
20
|
-
const SYSTEMS = (n: number) =>
|
|
21
|
-
`C4Context\n${Array.from({ length: n }, (_, i) => ` System(s${i}, "S${i}")`).join('\n')}`
|
|
22
|
-
|
|
23
|
-
it('fills a row left to right in declaration order, four to a row', () => {
|
|
24
|
-
const p = layout(SYSTEMS(5))
|
|
25
|
-
const xs = [0, 1, 2, 3, 4].map((i) => el(p, `s${i}`).x)
|
|
26
|
-
// Columns are one shape plus two margins apart: 216 + 100.
|
|
27
|
-
expect(xs[1]! - xs[0]!).toBeCloseTo(316, 0)
|
|
28
|
-
expect(xs[2]! - xs[1]!).toBeCloseTo(316, 0)
|
|
29
|
-
expect(xs[3]! - xs[2]!).toBeCloseTo(316, 0)
|
|
30
|
-
const ys = [0, 1, 2, 3].map((i) => el(p, `s${i}`).y)
|
|
31
|
-
expect(new Set(ys).size).toBe(1)
|
|
32
|
-
// The fifth wraps to the next row, back under the first.
|
|
33
|
-
expect(el(p, 's4').x).toBeCloseTo(xs[0]!, 0)
|
|
34
|
-
expect(el(p, 's4').y).toBeGreaterThan(ys[0]! + 60)
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('ignores Rel_U/D/L/R hints and the direction, as Mermaid does', () => {
|
|
38
|
-
const plain = `C4Context
|
|
39
|
-
System(a, "A")
|
|
40
|
-
System(b, "B")
|
|
41
|
-
System(c, "C")
|
|
42
|
-
Rel(a, b, "x")
|
|
43
|
-
Rel(a, c, "y")`
|
|
44
|
-
const hinted = plain
|
|
45
|
-
.replace('Rel(a, b', 'Rel_D(a, b')
|
|
46
|
-
.replace('Rel(a, c', 'Rel_U(a, c')
|
|
47
|
-
const pos = (p: PositionedC4Diagram) =>
|
|
48
|
-
p.elements.map((e) => [e.alias, e.x, e.y])
|
|
49
|
-
expect(pos(layout(hinted))).toEqual(pos(layout(plain)))
|
|
50
|
-
expect(pos(layout(plain, 'LR'))).toEqual(pos(layout(plain)))
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('widens a shape whose text is wider than the standard width', () => {
|
|
54
|
-
const p = layout(`C4Context
|
|
55
|
-
Person(a, "Personal Banking Customer", "A customer of the bank")
|
|
56
|
-
System(b, "B")`)
|
|
57
|
-
expect(el(p, 'a').width).toBeGreaterThan(216)
|
|
58
|
-
expect(el(p, 'b').width).toBe(216)
|
|
59
|
-
// The next shape moves right by the same amount.
|
|
60
|
-
expect(el(p, 'b').x - (el(p, 'a').x + el(p, 'a').width)).toBeCloseTo(100, 0)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('wraps a long name to the text width and grows the box by a line', () => {
|
|
64
|
-
const one = layout('C4Context\nSystem(a, "Banking Banking Banking")')
|
|
65
|
-
const two = layout(
|
|
66
|
-
'C4Context\nSystem(a, "Banking Banking Banking Banking")',
|
|
67
|
-
)
|
|
68
|
-
expect(el(one, 'a').nameLines).toHaveLength(1)
|
|
69
|
-
expect(el(two, 'a').nameLines).toHaveLength(2)
|
|
70
|
-
expect(el(two, 'a').height - el(one, 'a').height).toBeCloseTo(15.35, 1)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('gives shapes the heights Mermaid does', () => {
|
|
74
|
-
const p = layout(`C4Container
|
|
75
|
-
System(plain, "Plain")
|
|
76
|
-
System(desc, "Described", "One line")
|
|
77
|
-
ContainerDb(db, "DB", "PG", "Rows")
|
|
78
|
-
ContainerQueue(q, "Q", "Kafka")`)
|
|
79
|
-
expect(el(p, 'plain').height).toBeCloseTo(71.07, 0)
|
|
80
|
-
expect(el(p, 'desc').height).toBeCloseTo(88.2, 0)
|
|
81
|
-
expect(el(p, 'db').height).toBeCloseTo(115.8, 0)
|
|
82
|
-
expect(el(p, 'q').height).toBeCloseTo(41.07, 0)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('reproduces the size and frame of a real Mermaid render', () => {
|
|
86
|
-
// The context sample, measured on Mermaid 11.17.2.
|
|
87
|
-
const p = layout(`C4Context
|
|
88
|
-
title System Context for Internet Banking
|
|
89
|
-
Person(customer, "Personal Banking Customer", "A customer of the bank")
|
|
90
|
-
Enterprise_Boundary(b0, "Bank") {
|
|
91
|
-
System(banking, "Internet Banking System", "Lets customers view accounts")
|
|
92
|
-
SystemDb(ledger, "Ledger", "Stores transactions")
|
|
93
|
-
}
|
|
94
|
-
System_Ext(mail, "E-mail System", "Sends e-mails")
|
|
95
|
-
Rel(customer, banking, "Uses", "HTTPS")`)
|
|
96
|
-
expect(p.width).toBeCloseTo(932, -1)
|
|
97
|
-
expect(p.height).toBeCloseTo(888.4, -1)
|
|
98
|
-
const frame = p.boundaries[0]!
|
|
99
|
-
expect(frame.x).toBeCloseTo(150, 0)
|
|
100
|
-
expect(frame.width).toBeCloseTo(632, 0)
|
|
101
|
-
expect(frame.height).toBeCloseTo(265.8, 0)
|
|
102
|
-
expect(centerX(el(p, 'banking'))).toBeCloseTo(308, 0)
|
|
103
|
-
expect(centerX(el(p, 'ledger'))).toBeCloseTo(624, 0)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('puts two boundaries on a row and keeps nested elements inside', () => {
|
|
107
|
-
const p = layout(`C4Container
|
|
108
|
-
Boundary(outer, "Outer", "System") {
|
|
109
|
-
Boundary(inner, "Inner") {
|
|
110
|
-
Container(a, "A", "Go")
|
|
111
|
-
}
|
|
112
|
-
Container(b, "B", "Go")
|
|
113
|
-
}
|
|
114
|
-
Boundary(other, "Other") {
|
|
115
|
-
Container(c, "C", "Go")
|
|
116
|
-
}
|
|
117
|
-
Boundary(third, "Third") {
|
|
118
|
-
Container(d, "D", "Go")
|
|
119
|
-
}
|
|
120
|
-
Rel(a, b, "x")`)
|
|
121
|
-
const by = (alias: string) => p.boundaries.find((b) => b.alias === alias)!
|
|
122
|
-
const outer = by('outer')
|
|
123
|
-
const inner = by('inner')
|
|
124
|
-
expect(outer.depth).toBe(0)
|
|
125
|
-
expect(inner.depth).toBe(1)
|
|
126
|
-
// Outer boundaries are listed first so inner ones paint on top.
|
|
127
|
-
expect(p.boundaries.indexOf(outer)).toBeLessThan(
|
|
128
|
-
p.boundaries.indexOf(inner),
|
|
129
|
-
)
|
|
130
|
-
const inside = (
|
|
131
|
-
o: { x: number; y: number; width: number; height: number },
|
|
132
|
-
i: { x: number; y: number; width: number; height: number },
|
|
133
|
-
) =>
|
|
134
|
-
i.x >= o.x &&
|
|
135
|
-
i.y >= o.y &&
|
|
136
|
-
i.x + i.width <= o.x + o.width &&
|
|
137
|
-
i.y + i.height <= o.y + o.height
|
|
138
|
-
expect(inside(outer, inner)).toBe(true)
|
|
139
|
-
expect(inside(inner, el(p, 'a'))).toBe(true)
|
|
140
|
-
expect(inside(outer, el(p, 'b'))).toBe(true)
|
|
141
|
-
expect(inside(inner, el(p, 'b'))).toBe(false)
|
|
142
|
-
// Mermaid lays boundaries out two to a row: the third starts a new row.
|
|
143
|
-
expect(by('other').y).toBeCloseTo(outer.y, 0)
|
|
144
|
-
expect(by('other').x).toBeGreaterThan(outer.x + outer.width)
|
|
145
|
-
expect(by('third').y).toBeGreaterThan(outer.y + outer.height)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('draws the first relationship straight and later ones as curves', () => {
|
|
149
|
-
const p = layout(`C4Context
|
|
150
|
-
System(a, "A")
|
|
151
|
-
System(b, "B")
|
|
152
|
-
System(c, "C")
|
|
153
|
-
Rel(a, b, "first")
|
|
154
|
-
Rel(a, c, "second")
|
|
155
|
-
Rel(b, c, "third")`)
|
|
156
|
-
const [first, second, third] = p.relationships
|
|
157
|
-
expect(first!.curve).toBeUndefined()
|
|
158
|
-
const [s, e] = second!.points
|
|
159
|
-
expect(second!.curve).toEqual({
|
|
160
|
-
x: s!.x + (e!.x - s!.x) / 4,
|
|
161
|
-
y: s!.y + (e!.y - s!.y) / 2,
|
|
162
|
-
})
|
|
163
|
-
expect(third!.curve).toBeDefined()
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('starts a relationship label at the middle of its chord', () => {
|
|
167
|
-
const p = layout(`C4Context
|
|
168
|
-
System(a, "A")
|
|
169
|
-
System(b, "B")
|
|
170
|
-
Rel(a, b, "label", "tech")`)
|
|
171
|
-
const rel = p.relationships[0]!
|
|
172
|
-
const [s, e] = rel.points
|
|
173
|
-
const midX = (s!.x + e!.x) / 2
|
|
174
|
-
// Mermaid gives its text routine the midpoint as the block's left edge, so
|
|
175
|
-
// the label is centred half its own width to the right of it.
|
|
176
|
-
expect(rel.labelPosition!.x).toBeGreaterThan(midX + 5)
|
|
177
|
-
expect(rel.labelPosition!.x).toBeLessThan(midX + 40)
|
|
178
|
-
expect(rel.labelPosition!.y).toBeCloseTo((s!.y + e!.y) / 2, 5)
|
|
179
|
-
// The technology is centred on its own, at least as far along.
|
|
180
|
-
expect(rel.technologyX!).toBeGreaterThanOrEqual(rel.labelPosition!.x)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('leaves a person along the drawn head and pill, not the box around them', () => {
|
|
184
|
-
const p = layout(`C4Context
|
|
185
|
-
Person(u, "Customer", "Buys things")
|
|
186
|
-
System(s, "Shop", "Online store")
|
|
187
|
-
Rel(u, s, "Orders from")`)
|
|
188
|
-
const u = el(p, 'u')
|
|
189
|
-
const start = p.relationships[0]!.points[0]!
|
|
190
|
-
const headCx = u.x + u.width / 2
|
|
191
|
-
const headCy = u.y + 49.68
|
|
192
|
-
// Mermaid's line leaves on the head circle (in its own drawing, at 295.7),
|
|
193
|
-
// well inside the box's right edge rather than in the air beside the head.
|
|
194
|
-
expect(start.x).toBeLessThan(u.x + u.width - 40)
|
|
195
|
-
expect(Math.hypot(start.x - headCx, start.y - headCy)).toBeCloseTo(49.68, 0)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('routes a relationship that ends on a populated boundary to its frame', () => {
|
|
199
|
-
const p = layout(`C4Context
|
|
200
|
-
System(o, "O")
|
|
201
|
-
Boundary(b, "B") {
|
|
202
|
-
System(s, "S")
|
|
203
|
-
}
|
|
204
|
-
Rel(o, b, "into the boundary")`)
|
|
205
|
-
const rel = p.relationships[0]!
|
|
206
|
-
const b = p.boundaries[0]!
|
|
207
|
-
const end = rel.points[rel.points.length - 1]!
|
|
208
|
-
const onFrame =
|
|
209
|
-
Math.abs(end.x - b.x) < 1 ||
|
|
210
|
-
Math.abs(end.x - (b.x + b.width)) < 1 ||
|
|
211
|
-
Math.abs(end.y - b.y) < 1 ||
|
|
212
|
-
Math.abs(end.y - (b.y + b.height)) < 1
|
|
213
|
-
expect(onFrame).toBe(true)
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
it('shifts everything below a title, as Mermaid does', () => {
|
|
217
|
-
const plain = layout('C4Context\nSystem(a, "A")')
|
|
218
|
-
const titled = layout('C4Context\ntitle T\nSystem(a, "A")')
|
|
219
|
-
expect(el(titled, 'a').y - el(plain, 'a').y).toBeCloseTo(60, 0)
|
|
220
|
-
expect(titled.titlePosition).toBeDefined()
|
|
221
|
-
expect(titled.height - plain.height).toBeCloseTo(60, 0)
|
|
222
|
-
})
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
describe('C4 SVG rendering', () => {
|
|
226
|
-
const SRC = `C4Container
|
|
227
|
-
title Shop
|
|
228
|
-
Person(u, "User")
|
|
229
|
-
Person_Ext(x, "Partner")
|
|
230
|
-
System_Boundary(sys, "Shop") {
|
|
231
|
-
Container(web, "Web App", "React", "Storefront")
|
|
232
|
-
ContainerDb(db, "Database", "PostgreSQL")
|
|
233
|
-
ContainerQueue(q, "Events", "Kafka")
|
|
234
|
-
Component(c, "Comp", "TS")
|
|
235
|
-
}
|
|
236
|
-
Rel(u, web, "Browses", "HTTPS")
|
|
237
|
-
BiRel(web, q, "Publishes")
|
|
238
|
-
Rel(web, db, "Reads")`
|
|
239
|
-
|
|
240
|
-
it('draws a person as a pill with a round head, only for persons', () => {
|
|
241
|
-
const svg = renderMermaidSVG(SRC)
|
|
242
|
-
// One circle per person (the user and the external partner).
|
|
243
|
-
expect(svg.match(/<circle/g)).toHaveLength(2)
|
|
244
|
-
expect(svg).not.toContain('c4-person-glyph')
|
|
245
|
-
expect(renderMermaidSVG('C4Context\nSystem(s, "S")')).not.toContain(
|
|
246
|
-
'<circle',
|
|
247
|
-
)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
it('draws cylinders for databases and pipes for queues', () => {
|
|
251
|
-
const svg = renderMermaidSVG(SRC)
|
|
252
|
-
expect(svg.match(/<ellipse/g)).toHaveLength(2) // one cap each: db, queue
|
|
253
|
-
expect(svg).toContain('data-shape="db"')
|
|
254
|
-
expect(svg).toContain('data-shape="queue"')
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it("shows a queue's ellipse face at its right end, as Mermaid does", () => {
|
|
258
|
-
const svg = renderMermaidSVG('C4Container\nContainerQueue(q, "Events")')
|
|
259
|
-
expect(svg).toContain('data-shape="queue"')
|
|
260
|
-
const ell = /<ellipse cx="([\d.-]+)" cy="[\d.-]+" rx="([\d.-]+)"/.exec(svg)!
|
|
261
|
-
const path = /<path d="M ([\d.-]+) [\d.-]+ H ([\d.-]+) A/.exec(svg)!
|
|
262
|
-
const cx = +ell[1]!
|
|
263
|
-
const rx = +ell[2]!
|
|
264
|
-
// The body starts at the face centre (right) and runs left to its
|
|
265
|
-
// rounded end.
|
|
266
|
-
expect(+path[1]!).toBeCloseTo(cx, 1)
|
|
267
|
-
expect(+path[2]!).toBeLessThan(cx - rx)
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
it('uses Mermaid’s palette by kind, lighter for external elements', () => {
|
|
271
|
-
const svg = renderMermaidSVG(SRC)
|
|
272
|
-
expect(svg).toContain('#08427b') // person
|
|
273
|
-
expect(svg).toContain('#686868') // external person
|
|
274
|
-
expect(svg).toContain('#438dd5') // container
|
|
275
|
-
expect(svg).toContain('#85bbf0') // component
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
it('draws the diagram title, boundary title and technology text', () => {
|
|
279
|
-
const svg = renderMermaidSVG(SRC)
|
|
280
|
-
expect(svg).toContain('class="c4-title"')
|
|
281
|
-
expect(svg).toContain('>Shop</text>')
|
|
282
|
-
expect(svg).toContain('[HTTPS]')
|
|
283
|
-
expect(svg).toContain('[Container: React]')
|
|
284
|
-
expect(svg).toContain('[Person]')
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('puts arrowheads at both ends of a BiRel and one end otherwise', () => {
|
|
288
|
-
const svg = renderMermaidSVG(
|
|
289
|
-
'C4Context\nSystem(a, "A")\nSystem(b, "B")\nRel(a, b, "one")',
|
|
290
|
-
)
|
|
291
|
-
const bi = renderMermaidSVG(
|
|
292
|
-
'C4Context\nSystem(a, "A")\nSystem(b, "B")\nBiRel(a, b, "two")',
|
|
293
|
-
)
|
|
294
|
-
expect(svg.match(/<polygon/g)).toHaveLength(1)
|
|
295
|
-
expect(bi.match(/<polygon/g)).toHaveLength(2)
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
it('draws the first relationship as a line and the rest as quadratic curves', () => {
|
|
299
|
-
const svg = renderMermaidSVG(`C4Context
|
|
300
|
-
System(a, "A")
|
|
301
|
-
System(b, "B")
|
|
302
|
-
System(c, "C")
|
|
303
|
-
Rel(a, b, "one")
|
|
304
|
-
Rel(a, c, "two")`)
|
|
305
|
-
const ds = [
|
|
306
|
-
...svg.matchAll(/<path class="c4-relationship"[^>]* d="([^"]+)"/g),
|
|
307
|
-
]
|
|
308
|
-
expect(ds).toHaveLength(2)
|
|
309
|
-
expect(ds[0]![1]).toMatch(/^M [\d.-]+,[\d.-]+ L /)
|
|
310
|
-
expect(ds[1]![1]).toMatch(/^M [\d.-]+,[\d.-]+ Q /)
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
it('escapes user text', () => {
|
|
314
|
-
const svg = renderMermaidSVG(
|
|
315
|
-
'C4Context\nSystem(a, "A <b> & \\"q\\"")\nSystem(b, "B")\nRel(a, b, "x<y")',
|
|
316
|
-
)
|
|
317
|
-
expect(svg).not.toContain('A <b>')
|
|
318
|
-
expect(svg).toContain('x<y')
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it('uses the diagram title as the accessible name when no title option is set', () => {
|
|
322
|
-
expect(renderMermaidSVG(SRC)).toContain('<title')
|
|
323
|
-
expect(renderMermaidSVG(SRC, { title: 'Mine' })).toContain('>Mine</title>')
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
it('renders empty and boundary-only diagrams', () => {
|
|
327
|
-
expect(renderMermaidSVG('C4Context')).toContain('<svg')
|
|
328
|
-
const svg = renderMermaidSVG('C4Context\nBoundary(b, "Empty", "Type")')
|
|
329
|
-
expect(svg).toContain('class="c4-boundary"')
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
it('is deterministic', () => {
|
|
333
|
-
// The accessible-name id counter advances per render; everything else is fixed.
|
|
334
|
-
const strip = (svg: string) => svg.replace(/zm-title-\d+/g, 'zm-title')
|
|
335
|
-
expect(strip(renderMermaidSVG(SRC))).toBe(strip(renderMermaidSVG(SRC)))
|
|
336
|
-
})
|
|
337
|
-
})
|