@zombie-mermaid/svg-renderer 3.2.0 → 4.1.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/dist/index.cjs +48 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +2567 -1766
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +168 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +32 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +33 -4
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +12 -3
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/edge-bundling.ts +79 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +185 -25
- package/src/layout-engine.ts +42 -1
- package/src/registry.ts +3 -3
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/styles.ts +28 -1
- package/src/title-gaps.ts +161 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
3
|
+
|
|
4
|
+
// The marker used to share the faint colour and regular weight of the colon,
|
|
5
|
+
// so `~` read as `-` at 1x (#1237). It now gets the member-name colour and a
|
|
6
|
+
// bold weight, in its own tspan.
|
|
7
|
+
describe('class diagram visibility markers (#1237)', () => {
|
|
8
|
+
const svg = renderMermaidSVG(`classDiagram
|
|
9
|
+
class User {
|
|
10
|
+
+String a
|
|
11
|
+
-String b
|
|
12
|
+
#String c
|
|
13
|
+
~String d
|
|
14
|
+
}`)
|
|
15
|
+
|
|
16
|
+
for (const marker of ['+', '-', '#', '~']) {
|
|
17
|
+
it(`draws ${marker} in a bold, non-faint tspan`, () => {
|
|
18
|
+
expect(svg).toContain(
|
|
19
|
+
`<tspan fill="var(--_text-sec)" font-weight="700">${marker}</tspan>`,
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('keeps a faint spacer between marker and name', () => {
|
|
25
|
+
expect(svg).toContain('<tspan fill="var(--_text-faint)"> </tspan>')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
3
|
+
|
|
4
|
+
// Mono glyphs advance 0.6em (6.6px at the 11px attribute size). Chromium
|
|
5
|
+
// without subpixel glyph positioning (headless Linux) rounds each advance to a
|
|
6
|
+
// whole pixel, so the worst-case drawn width is chars * ceil(6.6) = chars * 7.
|
|
7
|
+
// A box sized from the unrounded estimate let the type run into the
|
|
8
|
+
// right-aligned name (#1262, same cause as #1238).
|
|
9
|
+
const WORST_CASE_ADVANCE = 7
|
|
10
|
+
// The row text puts two spaces between type and name, so at worst-case
|
|
11
|
+
// advances the gap must not shrink below two whole-pixel glyphs.
|
|
12
|
+
const MIN_GAP_BETWEEN_TYPE_AND_NAME = 2 * WORST_CASE_ADVANCE
|
|
13
|
+
|
|
14
|
+
const SRC = `erDiagram
|
|
15
|
+
CUSTOMER {
|
|
16
|
+
varchar(255) emailAddressPrimary
|
|
17
|
+
string name
|
|
18
|
+
}`
|
|
19
|
+
|
|
20
|
+
const attr = (tag: string, name: string) => {
|
|
21
|
+
const start = tag.indexOf(`${name}="`) + name.length + 2
|
|
22
|
+
return Number(tag.slice(start, tag.indexOf('"', start)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('ER attribute row padding (#1262)', () => {
|
|
26
|
+
const svg = renderMermaidSVG(SRC)
|
|
27
|
+
const texts = svg.split('<text')
|
|
28
|
+
const typeText = texts.find((t) => t.includes('varchar(255)')) as string
|
|
29
|
+
const nameText = texts.find((t) =>
|
|
30
|
+
t.includes('emailAddressPrimary'),
|
|
31
|
+
) as string
|
|
32
|
+
|
|
33
|
+
it('finds the type and name text', () => {
|
|
34
|
+
expect(typeText).toBeDefined()
|
|
35
|
+
expect(nameText).toBeDefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('leaves a gap between type and name even with whole-pixel glyph advances', () => {
|
|
39
|
+
const typeRight =
|
|
40
|
+
attr(typeText, 'x') + 'varchar(255)'.length * WORST_CASE_ADVANCE
|
|
41
|
+
const nameLeft =
|
|
42
|
+
attr(nameText, 'x') - 'emailAddressPrimary'.length * WORST_CASE_ADVANCE
|
|
43
|
+
expect(nameLeft - typeRight).toBeGreaterThanOrEqual(
|
|
44
|
+
MIN_GAP_BETWEEN_TYPE_AND_NAME,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1250: an ER diagram with no `direction` statement laid out left-to-right,
|
|
3
|
+
* while official Mermaid lays it out top-to-bottom. The relationship line
|
|
4
|
+
* between two entities is the layout axis: vertical when stacked, horizontal
|
|
5
|
+
* when side by side.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { renderMermaidSVG } from '@zombie-mermaid/svg-renderer'
|
|
9
|
+
|
|
10
|
+
const SOURCE = 'ORDER ||--|{ LINE_ITEM : contains'
|
|
11
|
+
|
|
12
|
+
function endpoints(svg: string): { dx: number; dy: number } {
|
|
13
|
+
const points = svg.match(/class="er-relationship"[^>]*points="([^"]+)"/)?.[1]
|
|
14
|
+
expect(points).toBeDefined()
|
|
15
|
+
const pts = (points ?? '').split(' ').map((p) => p.split(',').map(Number))
|
|
16
|
+
const first = pts[0] ?? []
|
|
17
|
+
const last = pts[pts.length - 1] ?? []
|
|
18
|
+
return {
|
|
19
|
+
dx: Math.abs((last[0] ?? 0) - (first[0] ?? 0)),
|
|
20
|
+
dy: Math.abs((last[1] ?? 0) - (first[1] ?? 0)),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ER default layout direction (#1250)', () => {
|
|
25
|
+
it('stacks entities top-to-bottom when the source has no direction', () => {
|
|
26
|
+
const { dx, dy } = endpoints(renderMermaidSVG(`erDiagram\n ${SOURCE}`))
|
|
27
|
+
expect(dy).toBeGreaterThan(dx)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('still honors an explicit `direction LR` in the source', () => {
|
|
31
|
+
const { dx, dy } = endpoints(
|
|
32
|
+
renderMermaidSVG(`erDiagram\n direction LR\n ${SOURCE}`),
|
|
33
|
+
)
|
|
34
|
+
expect(dx).toBeGreaterThan(dy)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('still honors the direction render option', () => {
|
|
38
|
+
const { dx, dy } = endpoints(
|
|
39
|
+
renderMermaidSVG(`erDiagram\n ${SOURCE}`, { direction: 'LR' }),
|
|
40
|
+
)
|
|
41
|
+
expect(dx).toBeGreaterThan(dy)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps declaration order within a layer and centers a shared child', () => {
|
|
45
|
+
const svg = renderMermaidSVG(`erDiagram
|
|
46
|
+
CUSTOMER ||--o{ ORDER : places
|
|
47
|
+
ORDER ||--|{ LINE_ITEM : contains
|
|
48
|
+
PRODUCT ||--o{ LINE_ITEM : includes`)
|
|
49
|
+
const box = (id: string): { left: number; right: number } => {
|
|
50
|
+
const start = svg.indexOf(`data-id="${id}"`)
|
|
51
|
+
expect(start).toBeGreaterThanOrEqual(0)
|
|
52
|
+
const m = svg
|
|
53
|
+
.slice(start)
|
|
54
|
+
.match(/>\s*<rect x="([\d.]+)"[^>]*width="([\d.]+)"/)
|
|
55
|
+
expect(m).not.toBeNull()
|
|
56
|
+
const x = Number(m?.[1])
|
|
57
|
+
return { left: x, right: x + Number(m?.[2]) }
|
|
58
|
+
}
|
|
59
|
+
const order = box('ORDER')
|
|
60
|
+
const product = box('PRODUCT')
|
|
61
|
+
const lineItem = box('LINE_ITEM')
|
|
62
|
+
// ORDER is declared before PRODUCT, so it sits to its left.
|
|
63
|
+
expect(order.left).toBeLessThan(product.left)
|
|
64
|
+
// LINE_ITEM sits between its two parents, not off to one side.
|
|
65
|
+
const mid = (lineItem.left + lineItem.right) / 2
|
|
66
|
+
expect(mid).toBeGreaterThan(order.left)
|
|
67
|
+
expect(mid).toBeLessThan(product.right)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1244: ER edge labels were drawn in `--_text-muted` (fg mixed at 40% into
|
|
3
|
+
* bg), ~2.4:1 on the default white background. Read the mix the renderer
|
|
4
|
+
* actually emits and assert it clears WCAG AA for text (4.5:1) against the
|
|
5
|
+
* default theme.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { DEFAULTS, MIX } from '@zombie-mermaid/core'
|
|
9
|
+
import { renderMermaidSVG } from '@zombie-mermaid/svg-renderer'
|
|
10
|
+
|
|
11
|
+
type Rgb = readonly [number, number, number]
|
|
12
|
+
|
|
13
|
+
function channels(hex: string): Rgb {
|
|
14
|
+
const c = (i: number) => parseInt(hex.slice(i, i + 2), 16)
|
|
15
|
+
return [c(1), c(3), c(5)]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function luminance([r, g, b]: Rgb): number {
|
|
19
|
+
const lin = (c: number) => {
|
|
20
|
+
const v = c / 255
|
|
21
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
22
|
+
}
|
|
23
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Contrast of `color-mix(in srgb, fg pct%, bg)` against bg. */
|
|
27
|
+
function mixContrast(pct: number): number {
|
|
28
|
+
const [fr, fgc, fb] = channels(DEFAULTS.fg)
|
|
29
|
+
const [br, bgc, bb] = channels(DEFAULTS.bg)
|
|
30
|
+
const mix = (f: number, b: number) => (f * pct + b * (100 - pct)) / 100
|
|
31
|
+
const mixed: Rgb = [mix(fr, br), mix(fgc, bgc), mix(fb, bb)]
|
|
32
|
+
const l1 = luminance(mixed)
|
|
33
|
+
const l2 = luminance([br, bgc, bb])
|
|
34
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('ER edge label contrast (#1244)', () => {
|
|
38
|
+
const svg = renderMermaidSVG('erDiagram\n ORDER ||--|{ LINE_ITEM : contains')
|
|
39
|
+
const label = svg.match(/<text[^>]*>contains<\/text>/)?.[0] ?? ''
|
|
40
|
+
const percent = Number(
|
|
41
|
+
label.match(
|
|
42
|
+
/fill="color-mix\(in srgb, var\(--fg\) (\d+)%, var\(--bg\)\)"/,
|
|
43
|
+
)?.[1],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
it('mixes the relationship label color from --fg and --bg', () => {
|
|
47
|
+
expect(Number.isFinite(percent)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clears WCAG AA (4.5:1) with the default theme', () => {
|
|
51
|
+
expect(mixContrast(percent)).toBeGreaterThanOrEqual(4.5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('is darker than the shared muted/secondary text tokens that failed AA', () => {
|
|
55
|
+
expect(mixContrast(MIX.textMuted)).toBeLessThan(4.5)
|
|
56
|
+
expect(mixContrast(MIX.textSec)).toBeLessThan(4.5)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1308: edges leaving `develop` in the git branching sample were merged onto
|
|
3
|
+
* one trunk, and the `approved` edge returning to `develop` ended on the same
|
|
4
|
+
* side, on top of that trunk. A fan-out/fan-in bundle is only drawn when
|
|
5
|
+
* nothing else attaches to the side it uses.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
|
|
9
|
+
import { layoutGraphSync } from '@zombie-mermaid/svg-renderer'
|
|
10
|
+
|
|
11
|
+
const GIT_BRANCHING = `graph LR
|
|
12
|
+
A[main] --> B[develop]
|
|
13
|
+
B --> C[feature/auth]
|
|
14
|
+
B --> D[feature/ui]
|
|
15
|
+
C --> E{PR Review}
|
|
16
|
+
D --> E
|
|
17
|
+
E -->|approved| B
|
|
18
|
+
B --> F[release/1.0]
|
|
19
|
+
F --> G{Tests?}
|
|
20
|
+
G -->|pass| A
|
|
21
|
+
G -->|fail| F`
|
|
22
|
+
|
|
23
|
+
const BUNDLED = `graph LR
|
|
24
|
+
B --> C[one]
|
|
25
|
+
B --> D[two]
|
|
26
|
+
B --> F[three]`
|
|
27
|
+
|
|
28
|
+
function route(source: string, from: string, to: string) {
|
|
29
|
+
const edge = layoutGraphSync(parseMermaid(source)).edges.find(
|
|
30
|
+
(e) => e.source === from && e.target === to,
|
|
31
|
+
)
|
|
32
|
+
expect(edge).toBeDefined()
|
|
33
|
+
return edge?.points ?? []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Pt = { x: number; y: number }
|
|
37
|
+
|
|
38
|
+
/** Count orthogonal segment pairs that cross at an interior point. */
|
|
39
|
+
function crossings(a: Pt[], b: Pt[]): number {
|
|
40
|
+
let n = 0
|
|
41
|
+
for (let i = 0; i + 1 < a.length; i++) {
|
|
42
|
+
for (let j = 0; j + 1 < b.length; j++) {
|
|
43
|
+
const [p, q, r, s] = [a[i]!, a[i + 1]!, b[j]!, b[j + 1]!]
|
|
44
|
+
const pH = Math.abs(p.y - q.y) < 0.5
|
|
45
|
+
const rH = Math.abs(r.y - s.y) < 0.5
|
|
46
|
+
if (pH === rH) continue
|
|
47
|
+
const [h1, h2, v1, v2] = pH ? [p, q, r, s] : [r, s, p, q]
|
|
48
|
+
if (
|
|
49
|
+
v1.x > Math.min(h1.x, h2.x) + 0.5 &&
|
|
50
|
+
v1.x < Math.max(h1.x, h2.x) - 0.5 &&
|
|
51
|
+
h1.y > Math.min(v1.y, v2.y) + 0.5 &&
|
|
52
|
+
h1.y < Math.max(v1.y, v2.y) - 0.5
|
|
53
|
+
)
|
|
54
|
+
n++
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return n
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('parallel edges leaving one node (#1308)', () => {
|
|
61
|
+
it('gives each edge out of develop its own first run', () => {
|
|
62
|
+
const exits = ['C', 'D', 'F'].map((t) => route(GIT_BRANCHING, 'B', t)[1])
|
|
63
|
+
const ys = new Set(exits.map((p) => Math.round(p?.y ?? 0)))
|
|
64
|
+
// The bundle drew all three as one run along y = develop's centre line.
|
|
65
|
+
expect(ys.size).toBe(3)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does not cross the approved edge over the runs leaving develop', () => {
|
|
69
|
+
const approved = route(GIT_BRANCHING, 'E', 'B')
|
|
70
|
+
for (const t of ['C', 'D', 'F']) {
|
|
71
|
+
expect(crossings(approved, route(GIT_BRANCHING, 'B', t))).toBe(0)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('still bundles a plain fan-out with nothing else on that side', () => {
|
|
76
|
+
const exits = ['C', 'D', 'F'].map((t) => route(BUNDLED, 'B', t)[1]!)
|
|
77
|
+
expect(new Set(exits.map((p) => Math.round(p.y))).size).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** Distinct rounded values of one coordinate of each edge's chosen point. */
|
|
82
|
+
function distinct(
|
|
83
|
+
source: string,
|
|
84
|
+
pairs: [string, string][],
|
|
85
|
+
pick: (pts: Pt[]) => number,
|
|
86
|
+
): number {
|
|
87
|
+
return new Set(
|
|
88
|
+
pairs.map(([from, to]) => Math.round(pick(route(source, from, to)))),
|
|
89
|
+
).size
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const first = (axis: 'x' | 'y') => (pts: Pt[]) => pts[1]![axis]
|
|
93
|
+
const last = (axis: 'x' | 'y') => (pts: Pt[]) => pts[pts.length - 1]![axis]
|
|
94
|
+
|
|
95
|
+
describe('bundle guard on the other orientations and sides (#1308)', () => {
|
|
96
|
+
const out: [string, string][] = [
|
|
97
|
+
['B', 'C'],
|
|
98
|
+
['B', 'D'],
|
|
99
|
+
['B', 'F'],
|
|
100
|
+
]
|
|
101
|
+
const into: [string, string][] = [
|
|
102
|
+
['A', 'T'],
|
|
103
|
+
['B', 'T'],
|
|
104
|
+
['C', 'T'],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
it('top-down: keeps edges leaving a node apart when a back edge returns to it', () => {
|
|
108
|
+
const src = `graph TD
|
|
109
|
+
B --> C
|
|
110
|
+
B --> D
|
|
111
|
+
B --> F
|
|
112
|
+
E --> B
|
|
113
|
+
C --> E`
|
|
114
|
+
expect(distinct(src, out, first('x'))).toBe(3)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('top-down: still bundles a plain fan-out', () => {
|
|
118
|
+
const src = `graph TD
|
|
119
|
+
B --> C
|
|
120
|
+
B --> D
|
|
121
|
+
B --> F`
|
|
122
|
+
expect(distinct(src, out, first('x'))).toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('left-to-right: keeps edges entering a node apart when one leaves it on that side', () => {
|
|
126
|
+
const src = `graph LR
|
|
127
|
+
A --> T
|
|
128
|
+
B --> T
|
|
129
|
+
C --> T
|
|
130
|
+
T --> A`
|
|
131
|
+
expect(distinct(src, into, last('y'))).toBe(3)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('left-to-right: still bundles a plain fan-in', () => {
|
|
135
|
+
const src = `graph LR
|
|
136
|
+
A --> T
|
|
137
|
+
B --> T
|
|
138
|
+
C --> T`
|
|
139
|
+
expect(distinct(src, into, last('y'))).toBe(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('top-down: keeps edges entering a node apart when one leaves it on that side', () => {
|
|
143
|
+
const src = `graph TD
|
|
144
|
+
A --> T
|
|
145
|
+
B --> T
|
|
146
|
+
C --> T
|
|
147
|
+
T --> A`
|
|
148
|
+
expect(distinct(src, into, last('x'))).toBe(3)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('top-down: still bundles a plain fan-in', () => {
|
|
152
|
+
const src = `graph TD
|
|
153
|
+
A --> T
|
|
154
|
+
B --> T
|
|
155
|
+
C --> T`
|
|
156
|
+
expect(distinct(src, into, last('x'))).toBe(1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('ignores self-loops and unrelated edges when deciding to bundle', () => {
|
|
160
|
+
const src = `graph LR
|
|
161
|
+
B --> B
|
|
162
|
+
B --> C
|
|
163
|
+
B --> D
|
|
164
|
+
B --> F
|
|
165
|
+
F --> G`
|
|
166
|
+
expect(distinct(src, out, first('y'))).toBe(1)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1242: lifelines were drawn straight through message labels and block
|
|
3
|
+
* titles, and a stick-figure actor's label sat on the first message arrow.
|
|
4
|
+
* The visual suite's tolerance can't see either, so assert the geometry.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
8
|
+
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
9
|
+
import { layoutSequenceDiagram } from '../sequence/layout.ts'
|
|
10
|
+
|
|
11
|
+
function lifelineSegments(
|
|
12
|
+
svg: string,
|
|
13
|
+
actor: string,
|
|
14
|
+
): Array<{ x: number; y1: number; y2: number }> {
|
|
15
|
+
const out: Array<{ x: number; y1: number; y2: number }> = []
|
|
16
|
+
const re =
|
|
17
|
+
/<line class="lifeline" data-actor="([^"]*)" x1="([\d.]+)" y1="([\d.]+)" x2="[\d.]+" y2="([\d.]+)"/g
|
|
18
|
+
for (const m of svg.matchAll(re)) {
|
|
19
|
+
if (m[1] !== actor) continue
|
|
20
|
+
out.push({ x: Number(m[2]), y1: Number(m[3]), y2: Number(m[4]) })
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Centre y of the `<text>` whose content is `label`. */
|
|
26
|
+
function textY(svg: string, label: string): number {
|
|
27
|
+
for (const m of svg.matchAll(
|
|
28
|
+
/<text [^>]*y="([\d.]+)"[^>]*>([^<]*)<\/text>/g,
|
|
29
|
+
)) {
|
|
30
|
+
if (m[2] === label) return Number(m[1])
|
|
31
|
+
}
|
|
32
|
+
return NaN
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when no lifeline segment of `actor` covers height `y`. */
|
|
36
|
+
function lifelineClearAt(svg: string, actor: string, y: number): boolean {
|
|
37
|
+
return lifelineSegments(svg, actor).every((s) => y < s.y1 || y > s.y2)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('lifelines stay out of message labels (#1242)', () => {
|
|
41
|
+
const svg = renderMermaidSVG(`sequenceDiagram
|
|
42
|
+
participant A
|
|
43
|
+
participant B
|
|
44
|
+
participant C
|
|
45
|
+
A->>B: first
|
|
46
|
+
A->>C: label crossing the middle lifeline
|
|
47
|
+
A->>B: next`)
|
|
48
|
+
|
|
49
|
+
it('cuts the crossed lifeline around the label instead of running through it', () => {
|
|
50
|
+
const labelY = textY(svg, 'label crossing the middle lifeline')
|
|
51
|
+
expect(lifelineSegments(svg, 'B').length).toBeGreaterThan(1)
|
|
52
|
+
expect(lifelineClearAt(svg, 'B', labelY)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves a lifeline whole where no label crosses it', () => {
|
|
56
|
+
expect(lifelineSegments(svg, 'A')).toHaveLength(1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('cuts lifelines around a block title tab', () => {
|
|
60
|
+
const blockSvg = renderMermaidSVG(`sequenceDiagram
|
|
61
|
+
participant A
|
|
62
|
+
participant B
|
|
63
|
+
A->>B: hi
|
|
64
|
+
alt Multiple panels
|
|
65
|
+
B->>A: yes
|
|
66
|
+
end`)
|
|
67
|
+
// The tab sits at the block's top-left; the lifeline under it is cut.
|
|
68
|
+
const rect = blockSvg.match(
|
|
69
|
+
/<g class="block"[^>]*>\s*<rect x="([\d.]+)" y="([\d.]+)"/,
|
|
70
|
+
)
|
|
71
|
+
const blockY = Number(rect?.[2])
|
|
72
|
+
expect(lifelineSegments(blockSvg, 'A').length).toBeGreaterThan(1)
|
|
73
|
+
expect(lifelineClearAt(blockSvg, 'A', blockY + 9)).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('cuts a lifeline beside a self-message label', () => {
|
|
77
|
+
const selfSvg = renderMermaidSVG(`sequenceDiagram
|
|
78
|
+
participant A
|
|
79
|
+
participant B
|
|
80
|
+
A->>A: a long self message label crossing B
|
|
81
|
+
A->>B: after`)
|
|
82
|
+
const y = textY(selfSvg, 'a long self message label crossing B')
|
|
83
|
+
expect(lifelineSegments(selfSvg, 'B').length).toBeGreaterThan(1)
|
|
84
|
+
expect(lifelineClearAt(selfSvg, 'B', y)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('cuts lifelines around a bare divider label', () => {
|
|
88
|
+
const divSvg = renderMermaidSVG(`sequenceDiagram
|
|
89
|
+
participant A
|
|
90
|
+
participant B
|
|
91
|
+
alt first
|
|
92
|
+
A->>B: x
|
|
93
|
+
else other case
|
|
94
|
+
B->>A: y
|
|
95
|
+
end`)
|
|
96
|
+
const y = textY(divSvg, '[other case]')
|
|
97
|
+
expect(lifelineSegments(divSvg, 'A').length).toBeGreaterThan(1)
|
|
98
|
+
expect(lifelineClearAt(divSvg, 'A', y)).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('ignores unlabeled blocks and dividers', () => {
|
|
102
|
+
const bareSvg = renderMermaidSVG(`sequenceDiagram
|
|
103
|
+
participant A
|
|
104
|
+
participant B
|
|
105
|
+
loop
|
|
106
|
+
B->>A: ping
|
|
107
|
+
else
|
|
108
|
+
A->>B: pong
|
|
109
|
+
end`)
|
|
110
|
+
expect(lifelineSegments(bareSvg, 'A').length).toBeGreaterThan(0)
|
|
111
|
+
expect(lifelineSegments(bareSvg, 'B').length).toBeGreaterThan(0)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('never drops a lifeline entirely, even under a label taller than it', () => {
|
|
115
|
+
const lines = Array.from({ length: 8 }, (_, i) => `l${i}`).join('<br/>')
|
|
116
|
+
const tall = renderMermaidSVG(`sequenceDiagram
|
|
117
|
+
participant A
|
|
118
|
+
participant B
|
|
119
|
+
participant C
|
|
120
|
+
A->>C: ${lines}`)
|
|
121
|
+
// The label spans B's whole lifeline; B keeps one uncut line.
|
|
122
|
+
expect(lifelineSegments(tall, 'B')).toHaveLength(1)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('stick-figure actor label clears the first message (#1242)', () => {
|
|
127
|
+
const src = `sequenceDiagram
|
|
128
|
+
actor U as User
|
|
129
|
+
participant S as System
|
|
130
|
+
U->>S: Click button`
|
|
131
|
+
const svg = renderMermaidSVG(src)
|
|
132
|
+
const layout = layoutSequenceDiagram(
|
|
133
|
+
parseSequenceDiagram(
|
|
134
|
+
src
|
|
135
|
+
.split('\n')
|
|
136
|
+
.slice(1)
|
|
137
|
+
.map((text, line) => ({ text: text.trim(), line })),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
it('starts the actor lifeline below its label', () => {
|
|
142
|
+
const [seg] = lifelineSegments(svg, 'U')
|
|
143
|
+
expect(seg!.y1).toBeGreaterThan(textY(svg, 'User') + 6)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('places the first message arrow clear of the label', () => {
|
|
147
|
+
const first = layout.messages[0]!
|
|
148
|
+
// label is 13px-ish text centred at textY; arrow must be well below it
|
|
149
|
+
expect(first.y).toBeGreaterThan(textY(svg, 'User') + 16)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keeps the original compact header when no stick figure is used', () => {
|
|
153
|
+
const plain = layoutSequenceDiagram(
|
|
154
|
+
parseSequenceDiagram([
|
|
155
|
+
{ text: 'participant A', line: 0 },
|
|
156
|
+
{ text: 'participant B', line: 1 },
|
|
157
|
+
{ text: 'A->>B: hi', line: 2 },
|
|
158
|
+
]),
|
|
159
|
+
)
|
|
160
|
+
expect(plain.messages[0]!.y - (plain.actors[0]!.y + 40)).toBe(20)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
3
|
+
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
4
|
+
import { layoutSequenceDiagram } from '../sequence/layout.ts'
|
|
5
|
+
|
|
6
|
+
const SRC = [
|
|
7
|
+
'participant C as Client',
|
|
8
|
+
'participant S as Server',
|
|
9
|
+
'C->>+S: Request',
|
|
10
|
+
'S->>+S: Process',
|
|
11
|
+
'S->>-S: Done',
|
|
12
|
+
'S-->>-C: Response',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
describe('nested self-message activation (#1241)', () => {
|
|
16
|
+
const layout = layoutSequenceDiagram(
|
|
17
|
+
parseSequenceDiagram(SRC.map((text, line) => ({ text, line }))),
|
|
18
|
+
)
|
|
19
|
+
const server = layout.activations.filter((a) => a.actorId === 'S')
|
|
20
|
+
|
|
21
|
+
it('yields two activations on the actor with depths 0 and 1', () => {
|
|
22
|
+
expect(server.map((a) => a.depth)).toEqual([0, 1])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('offsets the nested bar right by half a bar, inside the parent span', () => {
|
|
26
|
+
const outer = server.find((a) => a.depth === 0)!
|
|
27
|
+
const inner = server.find((a) => a.depth === 1)!
|
|
28
|
+
expect(inner.x - outer.x).toBe(outer.width / 2)
|
|
29
|
+
expect(inner.topY).toBeGreaterThan(outer.topY)
|
|
30
|
+
expect(inner.bottomY).toBeLessThan(outer.bottomY)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('draws both bars, outer first so the nested one is painted on top', () => {
|
|
34
|
+
const svg = renderMermaidSVG(`sequenceDiagram\n ${SRC.join('\n ')}`)
|
|
35
|
+
expect(svg.match(/class="activation"/g)).toHaveLength(2)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1310 — real mermaid.js (11.17.2) draws no line for an edge between a node
|
|
3
|
+
* and a subgraph that contains it (the edge path is zero-length). The SVG
|
|
4
|
+
* renderer used to draw degenerate stubs for it; it now omits the edge, so the
|
|
5
|
+
* output equals the same diagram without that edge.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { renderMermaidSVG } from '../../../../src/index.ts'
|
|
9
|
+
|
|
10
|
+
const base = 'flowchart TD; subgraph Sub; B; end; A --> B'
|
|
11
|
+
|
|
12
|
+
describe('SVG: an edge between a node and its own subgraph (#1310)', () => {
|
|
13
|
+
it.each([
|
|
14
|
+
['member to subgraph', `${base}; B --> Sub`],
|
|
15
|
+
['subgraph to member', `${base}; Sub --> B`],
|
|
16
|
+
[
|
|
17
|
+
'nested containment',
|
|
18
|
+
'flowchart TD; subgraph Outer; subgraph Inner; B; end; end; A --> B; B --> Outer; Inner --> B; Outer --> Inner',
|
|
19
|
+
],
|
|
20
|
+
])('renders %s identically to the diagram without the edge', (_, src) => {
|
|
21
|
+
const without = src.includes('Outer')
|
|
22
|
+
? 'flowchart TD; subgraph Outer; subgraph Inner; B; end; end; A --> B'
|
|
23
|
+
: base
|
|
24
|
+
expect(renderMermaidSVG(src)).toBe(renderMermaidSVG(without))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('still draws an edge from an outside node to the subgraph', () => {
|
|
28
|
+
expect(renderMermaidSVG(`${base}; X --> Sub`)).not.toBe(
|
|
29
|
+
renderMermaidSVG(base),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1243: with many categories the x-axis labels touched. The visual suite's
|
|
3
|
+
* tolerance can't see labels moving a few pixels, so assert on the layout.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { parseXYChart } from '@zombie-mermaid/mermaid-parser'
|
|
7
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
8
|
+
import { layoutXYChart } from '../xychart/layout.ts'
|
|
9
|
+
import { estimateTextWidth } from '../styles.ts'
|
|
10
|
+
|
|
11
|
+
function layout(src: string) {
|
|
12
|
+
return layoutXYChart(parseXYChart(splitStatements(src)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function shown(src: string) {
|
|
16
|
+
return layout(src).xAxis.ticks.filter((t) => t.label !== '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function minGap(ticks: ReturnType<typeof shown>): number {
|
|
20
|
+
let gap = Infinity
|
|
21
|
+
for (let i = 1; i < ticks.length; i++) {
|
|
22
|
+
const a = ticks[i - 1]!
|
|
23
|
+
const b = ticks[i]!
|
|
24
|
+
const half =
|
|
25
|
+
(estimateTextWidth(a.label, 14, 400) +
|
|
26
|
+
estimateTextWidth(b.label, 14, 400)) /
|
|
27
|
+
2
|
|
28
|
+
gap = Math.min(gap, b.labelX - a.labelX - half)
|
|
29
|
+
}
|
|
30
|
+
return gap
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const QUARTERS = `xychart-beta
|
|
34
|
+
x-axis [21Q1, 21Q2, 21Q3, 21Q4, 22Q1, 22Q2, 22Q3, 22Q4, 23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]
|
|
35
|
+
bar [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]`
|
|
36
|
+
|
|
37
|
+
describe('xychart x-axis label spacing (#1243)', () => {
|
|
38
|
+
it('drops labels so adjacent shown labels do not touch', () => {
|
|
39
|
+
const ticks = shown(QUARTERS)
|
|
40
|
+
expect(ticks.length).toBeLessThan(16)
|
|
41
|
+
expect(ticks.length).toBeGreaterThan(1)
|
|
42
|
+
expect(minGap(ticks)).toBeGreaterThan(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps every label when they all fit', () => {
|
|
46
|
+
const src = `xychart-beta
|
|
47
|
+
x-axis [Q1, Q2, Q3, Q4]
|
|
48
|
+
bar [1, 2, 3, 4]`
|
|
49
|
+
expect(shown(src).map((t) => t.label)).toEqual(['Q1', 'Q2', 'Q3', 'Q4'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not thin a roomy axis because of one long label', () => {
|
|
53
|
+
const src = `xychart-beta
|
|
54
|
+
x-axis [A, B, "A Rather Long Category", D, E]
|
|
55
|
+
bar [1, 2, 3, 4, 5]`
|
|
56
|
+
expect(shown(src)).toHaveLength(5)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps the tick marks of dropped labels', () => {
|
|
60
|
+
expect(layout(QUARTERS).xAxis.ticks).toHaveLength(16)
|
|
61
|
+
})
|
|
62
|
+
})
|