@zombie-mermaid/svg-renderer 3.2.0 → 4.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/dist/index.cjs +45 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +2310 -1115
- 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__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -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 +31 -2
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +10 -1
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -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 +170 -20
- package/src/layout-engine.ts +38 -1
- package/src/registry.ts +2 -2
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/title-gaps.ts +146 -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,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,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
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Advance widths, in 1/2048 em, of Arial (the metrics Liberation Sans and
|
|
2
|
+
// Helvetica share) for the printable ASCII range, regular and bold. Mermaid's
|
|
3
|
+
// C4 renderer measures its text in the browser, and the 32 diagrams recorded
|
|
4
|
+
// in `__tests__/fixtures/c4-mermaid-reference.json` are laid out to within a
|
|
5
|
+
// fraction of a pixel by summing these advances, which a scaled copy of this
|
|
6
|
+
// package's own Inter-based estimate could not do (see `c4TextWidth`).
|
|
7
|
+
const UNITS_PER_EM = 2048
|
|
8
|
+
|
|
9
|
+
const REGULAR =
|
|
10
|
+
// Indexed from U+0020: ` !"#$%&'()*+,-./`
|
|
11
|
+
[
|
|
12
|
+
569, 569, 727, 1139, 1139, 1821, 1366, 391, 682, 682, 797, 1196, 569, 682,
|
|
13
|
+
569, 569,
|
|
14
|
+
// 0-9
|
|
15
|
+
1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139,
|
|
16
|
+
// :;<=>?@
|
|
17
|
+
569, 569, 1196, 1196, 1196, 1139, 2079,
|
|
18
|
+
// A-Z
|
|
19
|
+
1366, 1366, 1479, 1479, 1366, 1251, 1593, 1479, 569, 1024, 1366, 1139, 1706,
|
|
20
|
+
1479, 1593, 1366, 1593, 1479, 1366, 1251, 1479, 1366, 1933, 1366, 1366,
|
|
21
|
+
1251,
|
|
22
|
+
// [\]^_`
|
|
23
|
+
569, 569, 569, 961, 1139, 682,
|
|
24
|
+
// a-z
|
|
25
|
+
1139, 1139, 1024, 1139, 1139, 569, 1139, 1139, 455, 455, 1024, 455, 1706,
|
|
26
|
+
1139, 1139, 1139, 1139, 682, 1024, 569, 1139, 1024, 1479, 1024, 1024, 1024,
|
|
27
|
+
// {|}~
|
|
28
|
+
684, 532, 684, 1196,
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const BOLD = [
|
|
32
|
+
// ` !"#$%&'()*+,-./`
|
|
33
|
+
569, 682, 971, 1139, 1139, 1821, 1479, 487, 682, 682, 797, 1196, 569, 682,
|
|
34
|
+
569, 569,
|
|
35
|
+
// 0-9
|
|
36
|
+
1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139,
|
|
37
|
+
// :;<=>?@
|
|
38
|
+
682, 682, 1196, 1196, 1196, 1251, 1992,
|
|
39
|
+
// A-Z
|
|
40
|
+
1479, 1479, 1479, 1479, 1366, 1251, 1593, 1479, 569, 1139, 1479, 1251, 1706,
|
|
41
|
+
1479, 1593, 1366, 1593, 1479, 1366, 1251, 1479, 1366, 1933, 1366, 1366, 1251,
|
|
42
|
+
// [\]^_`
|
|
43
|
+
682, 569, 682, 1196, 1139, 682,
|
|
44
|
+
// a-z
|
|
45
|
+
1139, 1251, 1139, 1251, 1139, 682, 1251, 1251, 569, 569, 1139, 569, 1821,
|
|
46
|
+
1251, 1251, 1251, 1251, 797, 1139, 682, 1251, 1139, 1593, 1139, 1139, 1024,
|
|
47
|
+
// {|}~
|
|
48
|
+
801, 573, 801, 1196,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Width in em of `ch`, or `undefined` outside printable ASCII (the caller
|
|
53
|
+
* falls back to its own estimate).
|
|
54
|
+
*/
|
|
55
|
+
export function arialAdvance(ch: string, bold: boolean): number | undefined {
|
|
56
|
+
const code = ch.codePointAt(0)!
|
|
57
|
+
if (code < 0x20 || code > 0x7e) return undefined
|
|
58
|
+
return (bold ? BOLD : REGULAR)[code - 0x20]! / UNITS_PER_EM
|
|
59
|
+
}
|