@zombie-mermaid/svg-renderer 4.1.0 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "4.1.0",
3
+ "version": "4.2.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": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
39
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "4.1.0",
41
- "@zombie-mermaid/mermaid-parser": "4.1.0"
40
+ "@zombie-mermaid/core": "4.2.0",
41
+ "@zombie-mermaid/mermaid-parser": "4.2.0"
42
42
  }
43
43
  }
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parsePieChart } from '@zombie-mermaid/mermaid-parser'
3
+ import type { PositionedPieChart } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutPieChart, PIE } from '../pie/layout.ts'
5
+ import { estimateTextWidth } from '../styles.ts'
6
+
7
+ // Geometry follows Mermaid's pieRenderer.ts (default config): a 450px frame,
8
+ // radius 185 at (225, 225), slices clockwise from 12 o'clock in source
9
+ // order, labels at 0.75r, legend 216px right of the centre, rows 22px apart.
10
+
11
+ const layout = (src: string): PositionedPieChart =>
12
+ layoutPieChart(parsePieChart(src))
13
+
14
+ const TAU = Math.PI * 2
15
+ const close = (a: number, b: number) => expect(a).toBeCloseTo(b, 6)
16
+
17
+ describe('pie layout: Mermaid frame', () => {
18
+ it('uses a 450px-tall frame with radius 185 centred at (225, 225)', () => {
19
+ const p = layout('pie\n "A" : 1\n "B" : 1')
20
+ expect(p.height).toBe(450)
21
+ expect(p.radius).toBe(185)
22
+ expect(p.cx).toBe(225)
23
+ expect(p.cy).toBe(225)
24
+ // Outline circle: radius + half the 2px outer stroke.
25
+ expect(p.outerRadius).toBe(186)
26
+ })
27
+
28
+ it('places the title centred over the pie, baseline 25px from the top', () => {
29
+ const p = layout('pie title Pets\n "A" : 1')
30
+ expect(p.title).toEqual({ text: 'Pets', x: 225, y: 25 })
31
+ })
32
+
33
+ it('omits the title when there is none', () => {
34
+ expect(layout('pie\n "A" : 1').title).toBeUndefined()
35
+ })
36
+
37
+ it('sizes the width as pie + margin + swatch + gap + longest legend text', () => {
38
+ const p = layout('pie\n "Short" : 1\n "A much longer label" : 1')
39
+ const longest = estimateTextWidth('A much longer label', 17, 400)
40
+ expect(p.width).toBe(Math.ceil(450 + 40 + 18 + 4 + longest))
41
+ })
42
+
43
+ it('widens the viewBox for a title wider than the chart, keeping the pie centred under it', () => {
44
+ const title =
45
+ 'An extremely long pie chart title that runs well past the legend'
46
+ const p = layout(`pie title ${title}\n "A" : 1`)
47
+ const titleW = estimateTextWidth(title, 25, 400)
48
+ expect(titleW / 2).toBeGreaterThan(225)
49
+ // Content shifted right by the overhang on the left.
50
+ close(p.cx, titleW / 2)
51
+ close(p.title!.x, p.cx)
52
+ expect(p.width).toBeGreaterThanOrEqual(Math.floor(titleW))
53
+ })
54
+ })
55
+
56
+ describe('pie layout: slices', () => {
57
+ it("draws slices in source order, clockwise from 12 o'clock, unsorted", () => {
58
+ const p = layout('pie\n "Small" : 10\n "Big" : 70\n "Mid" : 20')
59
+ expect(p.slices.map((s) => s.label)).toEqual(['Small', 'Big', 'Mid'])
60
+ close(p.slices[0]!.startAngle, 0)
61
+ close(p.slices[0]!.endAngle, 0.1 * TAU)
62
+ close(p.slices[1]!.startAngle, 0.1 * TAU)
63
+ close(p.slices[1]!.endAngle, 0.8 * TAU)
64
+ close(p.slices[2]!.endAngle, TAU)
65
+ // First slice starts at the top of the circle.
66
+ expect(p.slices[0]!.path.startsWith('M225,40A185,185,0,0,1,')).toBe(true)
67
+ })
68
+
69
+ it('angles always cover exactly the full circle', () => {
70
+ const p = layout('pie\n "A" : 3.3\n "B" : 7.1\n "C" : 12.9\n "D" : 0.9')
71
+ const sweep = p.slices.reduce((t, s) => t + (s.endAngle - s.startAngle), 0)
72
+ close(sweep, TAU)
73
+ })
74
+
75
+ it('labels each slice with toFixed(0) of its share, at 0.75 of the radius', () => {
76
+ const p = layout(
77
+ 'pie\n "Calcium" : 42.96\n "Potassium" : 50.05\n "Magnesium" : 10.01\n "Iron" : 5',
78
+ )
79
+ // Mermaid's own docs example renders 40% / 46% / 9% / 5%.
80
+ expect(p.slices.map((s) => s.percentText)).toEqual([
81
+ '40%',
82
+ '46%',
83
+ '9%',
84
+ '5%',
85
+ ])
86
+ for (const s of p.slices) {
87
+ const mid = (s.startAngle + s.endAngle) / 2
88
+ close(s.labelX, 225 + 185 * 0.75 * Math.sin(mid))
89
+ close(s.labelY, 225 - 185 * 0.75 * Math.cos(mid))
90
+ }
91
+ })
92
+
93
+ it('uses whole-number percentages only, even for shares like 33.3%', () => {
94
+ const p = layout('pie\n "A" : 1\n "B" : 1\n "C" : 1')
95
+ expect(p.slices.map((s) => s.percentText)).toEqual(['33%', '33%', '33%'])
96
+ })
97
+
98
+ it('draws a single slice as a full circle labelled 100%, label straight below the centre', () => {
99
+ const p = layout('pie\n "Only" : 5')
100
+ expect(p.slices).toHaveLength(1)
101
+ const s = p.slices[0]!
102
+ expect(s.percentText).toBe('100%')
103
+ expect(s.path).toBe('M225,40A185,185,0,1,1,225,410A185,185,0,1,1,225,40Z')
104
+ close(s.labelX, 225)
105
+ close(s.labelY, 225 + 185 * 0.75)
106
+ })
107
+
108
+ it('leaves out slices under 1% but keeps them in the legend; the rest fill the circle', () => {
109
+ const p = layout('pie\n "Big" : 995\n "Tiny" : 5\n "Other" : 100')
110
+ // Tiny = 5/1100 = 0.45%.
111
+ expect(p.slices.map((s) => s.label)).toEqual(['Big', 'Other'])
112
+ expect(p.legend.map((l) => l.text)).toEqual(['Big', 'Tiny', 'Other'])
113
+ // Drawn slices share the full circle between them...
114
+ close(p.slices[0]!.endAngle, (995 / 1095) * TAU)
115
+ close(p.slices[1]!.endAngle, TAU)
116
+ // ...but percentages are of the whole total, omitted slices included.
117
+ expect(p.slices.map((s) => s.percentText)).toEqual(['90%', '9%'])
118
+ })
119
+
120
+ it('keeps a slice of exactly 1%', () => {
121
+ const p = layout('pie\n "A" : 99\n "B" : 1')
122
+ expect(p.slices.map((s) => s.label)).toEqual(['A', 'B'])
123
+ expect(p.slices[1]!.percentText).toBe('1%')
124
+ })
125
+
126
+ it('does not draw zero-value slices', () => {
127
+ const p = layout('pie\n "A" : 0\n "B" : 4\n "C" : 0')
128
+ expect(p.slices.map((s) => s.label)).toEqual(['B'])
129
+ expect(p.legend).toHaveLength(3)
130
+ })
131
+
132
+ it('draws no slices when every value is zero', () => {
133
+ const p = layout('pie\n "A" : 0\n "B" : 0')
134
+ expect(p.slices).toEqual([])
135
+ expect(p.legend).toHaveLength(2)
136
+ })
137
+
138
+ it('keeps palette slots tied to source position, so an omitted slice still uses one', () => {
139
+ const p = layout('pie\n "A" : 500\n "Tiny" : 1\n "C" : 500')
140
+ expect(p.slices.map((s) => s.colorIndex)).toEqual([0, 2])
141
+ expect(p.legend.map((l) => l.colorIndex)).toEqual([0, 1, 2])
142
+ })
143
+
144
+ it('wraps colours after 12 slices', () => {
145
+ const lines = Array.from({ length: 14 }, (_, i) => ` "S${i}" : 10`)
146
+ const p = layout(`pie\n${lines.join('\n')}`)
147
+ expect(p.slices.map((s) => s.colorIndex)).toEqual([
148
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1,
149
+ ])
150
+ })
151
+ })
152
+
153
+ describe('pie layout: legend', () => {
154
+ it('lists every slice to the right of the pie, 22px apart, centred on it', () => {
155
+ const p = layout('pie\n "A" : 1\n "B" : 2\n "C" : 3\n "D" : 4')
156
+ expect(p.legend.map((l) => l.x)).toEqual([441, 441, 441, 441])
157
+ // translate(216, i * 22 - 22 * 4 / 2) from the centre.
158
+ expect(p.legend.map((l) => l.y)).toEqual([181, 203, 225, 247])
159
+ for (const l of p.legend) {
160
+ expect(l.size).toBe(18)
161
+ expect(l.textX).toBe(l.x + 22)
162
+ expect(l.textY).toBe(l.y + 14)
163
+ }
164
+ })
165
+
166
+ it('shows `label [value]` with showData, value printed as-is', () => {
167
+ const p = layout(
168
+ 'pie showData\n "A" : 1234567\n "B" : 42.50\n "C" : 0.125',
169
+ )
170
+ expect(p.legend.map((l) => l.text)).toEqual([
171
+ 'A [1234567]',
172
+ 'B [42.5]',
173
+ 'C [0.125]',
174
+ ])
175
+ })
176
+
177
+ it('shows the bare label without showData', () => {
178
+ expect(layout('pie\n "A" : 1').legend[0]!.text).toBe('A')
179
+ })
180
+
181
+ it('grows the viewBox for a legend taller than the frame instead of clipping it', () => {
182
+ const lines = Array.from({ length: 30 }, (_, i) => ` "S${i}" : 1`)
183
+ const p = layout(`pie\n${lines.join('\n')}`)
184
+ // Mermaid would start the legend at 225 - 330 = -105, above the frame.
185
+ const first = p.legend[0]!
186
+ const last = p.legend[p.legend.length - 1]!
187
+ expect(first.y).toBe(0)
188
+ expect(last.y + last.size).toBeLessThanOrEqual(p.height)
189
+ // The pie moved down by the same amount and keeps its frame.
190
+ expect(p.cy).toBe(225 + 105)
191
+ expect(p.height).toBe(30 * 22 - 4)
192
+ })
193
+ })
194
+
195
+ describe('pie layout: empty chart', () => {
196
+ it('draws only the circle and title for a bare `pie`', () => {
197
+ const p = layout('pie title Nothing yet')
198
+ expect(p.slices).toEqual([])
199
+ expect(p.legend).toEqual([])
200
+ expect(p.title?.text).toBe('Nothing yet')
201
+ expect(p.height).toBe(450)
202
+ // No legend: the pie and its right margin only, not a cropped half.
203
+ expect(p.width).toBe(PIE.height + PIE.margin)
204
+ })
205
+ })
206
+
207
+ describe('pie layout: accessibility fields', () => {
208
+ it('carries accTitle and accDescr through', () => {
209
+ const p = layout(
210
+ 'pie\n accTitle: Pets\n accDescr: Dogs vs cats\n "A" : 1',
211
+ )
212
+ expect(p.accTitle).toBe('Pets')
213
+ expect(p.accDescr).toBe('Dogs vs cats')
214
+ })
215
+ })
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { __resetSvgTitleIdCounterForTests, THEMES } from '@zombie-mermaid/core'
3
+ import { getSeriesColor } from '@zombie-mermaid/mermaid-parser'
4
+ import { renderMermaidSVG } from '../../../../src/index.ts'
5
+
6
+ beforeEach(() => {
7
+ __resetSvgTitleIdCounterForTests()
8
+ })
9
+
10
+ const BASIC = `pie title Pets adopted by volunteers
11
+ "Dogs" : 386
12
+ "Cats" : 85
13
+ "Rats" : 15`
14
+
15
+ const count = (svg: string, re: RegExp) => (svg.match(re) ?? []).length
16
+
17
+ describe('pie SVG: structure', () => {
18
+ const svg = renderMermaidSVG(BASIC)
19
+
20
+ it('draws the outline circle, one path per slice, a label each, the title and a legend row each', () => {
21
+ expect(count(svg, /class="pie-outer"/g)).toBe(1)
22
+ expect(count(svg, /<path [^>]*class="pie-slice /g)).toBe(3)
23
+ expect(count(svg, /class="pie-slice-label"/g)).toBe(3)
24
+ expect(count(svg, /class="pie-title"/g)).toBe(1)
25
+ expect(count(svg, /<g class="pie-legend">/g)).toBe(3)
26
+ })
27
+
28
+ it("paints in Mermaid's order: circle, slices, labels, title, legend", () => {
29
+ const order = [
30
+ 'class="pie-outer"',
31
+ 'class="pie-slice ',
32
+ 'class="pie-slice-label"',
33
+ 'class="pie-title"',
34
+ 'class="pie-legend"',
35
+ ].map((m) => svg.indexOf(m))
36
+ expect(order).toEqual([...order].sort((a, b) => a - b))
37
+ expect(order.every((i) => i > 0)).toBe(true)
38
+ })
39
+
40
+ it('writes whole-number percentage labels', () => {
41
+ const labels = [...svg.matchAll(/class="pie-slice-label">([^<]*)</g)].map(
42
+ (m) => m[1],
43
+ )
44
+ // 386/486, 85/486, 15/486
45
+ expect(labels).toEqual(['79%', '17%', '3%'])
46
+ })
47
+
48
+ it('draws no callout lines', () => {
49
+ expect(svg).not.toMatch(/<polyline|<line /)
50
+ })
51
+
52
+ it('tags slices with their label and value', () => {
53
+ expect(svg).toContain('data-label="Dogs" data-value="386"')
54
+ })
55
+
56
+ it('sizes the root to the layout', () => {
57
+ expect(svg).toMatch(
58
+ /^<svg [^>]*viewBox="0 0 \d+ 450" width="\d+" height="450"/,
59
+ )
60
+ })
61
+
62
+ it('escapes labels and titles', () => {
63
+ const out = renderMermaidSVG('pie title A <b> & "c"\n "x<y & \\"z\\"" : 1')
64
+ expect(out).toContain(
65
+ 'class="pie-title">A &lt;b&gt; &amp; &quot;c&quot;</text>',
66
+ )
67
+ expect(out).toContain('data-label="x&lt;y &amp; &quot;z&quot;"')
68
+ expect(out).toContain(
69
+ 'class="pie-legend-text">x&lt;y &amp; &quot;z&quot;</text>',
70
+ )
71
+ })
72
+
73
+ it('renders an empty chart as the outline circle and title only', () => {
74
+ const out = renderMermaidSVG('pie title Empty')
75
+ expect(count(out, /class="pie-outer"/g)).toBe(1)
76
+ expect(out).not.toContain('class="pie-slice')
77
+ expect(out).not.toContain('class="pie-legend"')
78
+ expect(out).toContain('class="pie-title">Empty</text>')
79
+ })
80
+
81
+ it('renders a bare `pie` with no title', () => {
82
+ const out = renderMermaidSVG('pie')
83
+ expect(out).toContain('class="pie-outer"')
84
+ expect(out).not.toContain('class="pie-title"')
85
+ })
86
+ })
87
+
88
+ describe('pie SVG: styling and theme', () => {
89
+ it("uses Mermaid's 0.7 slice opacity, 2px strokes and text sizes", () => {
90
+ const svg = renderMermaidSVG(BASIC)
91
+ expect(svg).toContain(
92
+ '.pie-slice { stroke: var(--fg); stroke-width: 2px; opacity: 0.7; }',
93
+ )
94
+ expect(svg).toContain(
95
+ '.pie-outer { fill: none; stroke: var(--fg); stroke-width: 2px; }',
96
+ )
97
+ expect(svg).toMatch(/\.pie-title \{[^}]*font-size: 25px/)
98
+ expect(svg).toMatch(/\.pie-slice-label \{[^}]*font-size: 17px/)
99
+ expect(svg).toMatch(/\.pie-legend-text \{[^}]*font-size: 17px/)
100
+ })
101
+
102
+ it('fills slot 0 with the live theme accent and later slots with series shades', () => {
103
+ const svg = renderMermaidSVG(BASIC, {
104
+ bg: '#ffffff',
105
+ fg: '#111111',
106
+ accent: '#e11d48',
107
+ })
108
+ expect(svg).toContain('--pie-color-0: var(--accent, #3b82f6);')
109
+ expect(svg).toContain(
110
+ `--pie-color-1: ${getSeriesColor(1, '#e11d48', '#ffffff')};`,
111
+ )
112
+ expect(svg).toContain(
113
+ `--pie-color-2: ${getSeriesColor(2, '#e11d48', '#ffffff')};`,
114
+ )
115
+ expect(svg).toContain('.pie-color-1 { fill: var(--pie-color-1); }')
116
+ expect(svg).toContain('rect.pie-color-1 { stroke: var(--pie-color-1); }')
117
+ })
118
+
119
+ it('adapts the series shades to a dark background', () => {
120
+ const dark = THEMES['tokyo-night']!
121
+ const svg = renderMermaidSVG(BASIC, dark)
122
+ expect(svg).toContain(
123
+ `--pie-color-1: ${getSeriesColor(1, dark.accent ?? '#3b82f6', dark.bg)};`,
124
+ )
125
+ expect(svg).toContain(`--bg:${dark.bg}`)
126
+ // Light vs dark backgrounds pick different shades for the same slot.
127
+ expect(getSeriesColor(1, '#3b82f6', '#ffffff')).not.toBe(
128
+ getSeriesColor(1, '#3b82f6', dark.bg),
129
+ )
130
+ })
131
+
132
+ it('defines colour variables only for palette slots in use, wrapping after 12', () => {
133
+ const lines = Array.from({ length: 14 }, (_, i) => ` "S${i}" : 10`)
134
+ const svg = renderMermaidSVG(`pie\n${lines.join('\n')}`)
135
+ expect(count(svg, /--pie-color-\d+:/g)).toBe(12)
136
+ expect(svg).not.toContain('--pie-color-12')
137
+ expect(count(svg, /class="pie-slice pie-color-0"/g)).toBe(2)
138
+ })
139
+
140
+ it('resolves to concrete colours with resolveColors', () => {
141
+ const svg = renderMermaidSVG(BASIC, { resolveColors: true })
142
+ expect(svg).not.toMatch(/var\(--pie-color/)
143
+ })
144
+
145
+ it('honours nonce and styleAttribute: false', () => {
146
+ const svg = renderMermaidSVG(BASIC, { nonce: 'abc', styleAttribute: false })
147
+ expect(count(svg, /<style nonce="abc">/g)).toBe(2)
148
+ expect(svg).not.toMatch(/<style>/)
149
+ expect(svg.slice(0, svg.indexOf('>'))).not.toContain('style=')
150
+ })
151
+ })
152
+
153
+ describe('pie SVG: accessibility', () => {
154
+ it('uses accTitle as the accessible name and accDescr as the description', () => {
155
+ const svg = renderMermaidSVG(
156
+ 'pie\n accTitle: Pet adoptions\n accDescr: Dogs lead by far\n "Dogs" : 3\n "Cats" : 1',
157
+ )
158
+ expect(svg).toMatch(
159
+ /^<svg [^>]*role="img" aria-labelledby="zm-title-1" aria-describedby="zm-desc-1"/,
160
+ )
161
+ expect(svg).toContain('<title id="zm-title-1">Pet adoptions</title>')
162
+ expect(svg).toContain('<desc id="zm-desc-1">Dogs lead by far</desc>')
163
+ })
164
+
165
+ it('lets options.title override accTitle', () => {
166
+ const svg = renderMermaidSVG('pie\n accTitle: From source\n "A" : 1', {
167
+ title: 'From caller',
168
+ })
169
+ expect(svg).toContain('>From caller</title>')
170
+ expect(svg).not.toContain('From source')
171
+ })
172
+
173
+ it('does not use the visible title as the accessible name', () => {
174
+ const svg = renderMermaidSVG(BASIC)
175
+ expect(svg).toMatch(/^<svg [^>]*role="img"/)
176
+ expect(svg).not.toContain('<title')
177
+ })
178
+
179
+ it('drops the name and description for decorative output', () => {
180
+ const svg = renderMermaidSVG(
181
+ 'pie\n accTitle: T\n accDescr: D\n "A" : 1',
182
+ { decorative: true },
183
+ )
184
+ expect(svg).toMatch(/^<svg [^>]*aria-hidden="true"/)
185
+ expect(svg).not.toMatch(
186
+ /<title|<desc|aria-labelledby|aria-describedby|role=/,
187
+ )
188
+ })
189
+
190
+ it('embeds the source when asked', () => {
191
+ const svg = renderMermaidSVG(BASIC, { embedSource: true })
192
+ expect(svg).toMatch(/^<svg data-src="pie title Pets/)
193
+ })
194
+ })
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export * from './sequence/layout.ts'
62
62
  export * from './sequence/renderer.ts'
63
63
  export * from './xychart/layout.ts'
64
64
  export * from './xychart/renderer.ts'
65
+ export * from './pie/layout.ts'
66
+ export * from './pie/renderer.ts'
65
67
 
66
68
  import { decodeXML } from 'entities'
67
69
  import type {
@@ -0,0 +1,234 @@
1
+ // ============================================================================
2
+ // Pie chart layout
3
+ //
4
+ // Turns a parsed PieChart into absolute SVG geometry, following Mermaid's own
5
+ // renderer (packages/mermaid/src/diagrams/pie/pieRenderer.ts, default config:
6
+ // textPosition 0.75, no donut hole, legend on the right):
7
+ //
8
+ // - a 450 x 450 frame with a 40px margin, so the pie radius is 185 and its
9
+ // centre sits at (225, 225); a 2px outline circle of radius 186 around it
10
+ // - slices in source order, clockwise from 12 o'clock (d3.pie with
11
+ // sort(null)); slices under 1% of the total are left out and the rest
12
+ // share the full circle between them
13
+ // - each drawn slice labelled with `toFixed(0)` of its share of the
14
+ // *whole* total (omitted slices included), at 0.75 of the radius
15
+ // - the title centred over the pie with its baseline 25px from the top
16
+ // - a legend row for every slice, omitted ones included: an 18px swatch
17
+ // and the label (or `label [value]` with showData), rows 22px apart,
18
+ // vertically centred on the pie, starting 216px right of its centre
19
+ // - width = pie + margin + swatch + gap + longest legend text, widened to
20
+ // fit a title wider than that
21
+ //
22
+ // Two places differ from Mermaid on purpose, both where Mermaid's output is
23
+ // broken rather than a style choice:
24
+ // - A legend taller than the 450px frame (more than ~20 rows) grows the
25
+ // viewBox instead of being clipped at the top and bottom.
26
+ // - A chart with no slices keeps the pie's own width. Mermaid's width
27
+ // formula takes Math.max() of an empty list there (-Infinity), which
28
+ // leaves a viewBox that crops the circle in half.
29
+ //
30
+ // Lengths are kept from Mermaid as-is. Text widths come from this library's
31
+ // own metrics for its own font, not Mermaid's "trebuchet ms".
32
+ // ============================================================================
33
+
34
+ import type {
35
+ PieChart,
36
+ PositionedPieChart,
37
+ PositionedPieLegendItem,
38
+ PositionedPieSlice,
39
+ } from '@zombie-mermaid/mermaid-parser'
40
+ import { estimateTextWidth } from '../styles.ts'
41
+
42
+ /** Mermaid pieRenderer.ts constants (default config and default theme). */
43
+ export const PIE = {
44
+ /** Frame height; the pie's own width equals it. */
45
+ height: 450,
46
+ margin: 40,
47
+ legendRectSize: 18,
48
+ legendSpacing: 4,
49
+ /** `pie.textPosition` default: label distance as a fraction of the radius. */
50
+ textPosition: 0.75,
51
+ /** `pieOuterStrokeWidth`; the outline circle sits half of it outside the pie. */
52
+ outerStrokeWidth: 2,
53
+ /** `pieTitleTextSize` */
54
+ titleFontSize: 25,
55
+ /** `pieSectionTextSize` */
56
+ sectionFontSize: 17,
57
+ /** `pieLegendTextSize` */
58
+ legendFontSize: 17,
59
+ fontWeight: 400,
60
+ /** Mermaid's `pie1`..`pie12` scale wraps after this many colours. */
61
+ paletteSize: 12,
62
+ /** Slices below this percentage of the total are not drawn. */
63
+ minPercent: 1,
64
+ } as const
65
+
66
+ const TAU = Math.PI * 2
67
+
68
+ /** Round to two decimals for compact path data. */
69
+ function r(n: number): string {
70
+ return String(Math.round(n * 100) / 100)
71
+ }
72
+
73
+ /** Point on a circle, angle in radians clockwise from 12 o'clock (d3's convention). */
74
+ function polar(
75
+ cx: number,
76
+ cy: number,
77
+ radius: number,
78
+ angle: number,
79
+ ): { x: number; y: number } {
80
+ return { x: cx + radius * Math.sin(angle), y: cy - radius * Math.cos(angle) }
81
+ }
82
+
83
+ /** Wedge path, or a full circle when the slice covers the whole pie. */
84
+ function slicePath(
85
+ cx: number,
86
+ cy: number,
87
+ radius: number,
88
+ start: number,
89
+ end: number,
90
+ ): string {
91
+ if (end - start >= TAU - 1e-9) {
92
+ return (
93
+ `M${r(cx)},${r(cy - radius)}` +
94
+ `A${r(radius)},${r(radius)},0,1,1,${r(cx)},${r(cy + radius)}` +
95
+ `A${r(radius)},${r(radius)},0,1,1,${r(cx)},${r(cy - radius)}Z`
96
+ )
97
+ }
98
+ const p0 = polar(cx, cy, radius, start)
99
+ const p1 = polar(cx, cy, radius, end)
100
+ const large = end - start > Math.PI ? 1 : 0
101
+ return (
102
+ `M${r(p0.x)},${r(p0.y)}` +
103
+ `A${r(radius)},${r(radius)},0,${large},1,${r(p1.x)},${r(p1.y)}` +
104
+ `L${r(cx)},${r(cy)}Z`
105
+ )
106
+ }
107
+
108
+ /** Legend row text, as Mermaid builds it: `String(value)`, no number formatting. */
109
+ function legendText(label: string, value: number, showData: boolean): string {
110
+ return showData ? `${label} [${String(value)}]` : label
111
+ }
112
+
113
+ /** Lay out a parsed pie chart. Options are accepted for registry parity; none apply. */
114
+ export function layoutPieChart(
115
+ chart: PieChart,
116
+ _options: unknown = {},
117
+ ): PositionedPieChart {
118
+ const height = PIE.height
119
+ const pieWidth = height
120
+ const radius = Math.min(pieWidth, height) / 2 - PIE.margin
121
+ // Everything below is first computed in Mermaid's frame (pie centre at
122
+ // (225, 225)), then shifted so the viewBox starts at (0, 0).
123
+ const cx0 = pieWidth / 2
124
+ const cy0 = height / 2
125
+
126
+ const total = chart.slices.reduce((sum, s) => sum + s.value, 0)
127
+
128
+ // createPieArcs(): keep slices at or above 1% of the total. A zero total
129
+ // gives NaN here, which drops every slice, as in Mermaid. (Mermaid filters
130
+ // a second time on `toFixed(0) !== '0'`, which nothing at or above 1% can
131
+ // fail, so that step is not repeated.)
132
+ const drawn = chart.slices
133
+ .map((slice, index) => ({ slice, colorIndex: index % PIE.paletteSize }))
134
+ .filter(({ slice }) => (slice.value / total) * 100 >= PIE.minPercent)
135
+ // d3.pie() spreads the full circle over the slices it is given.
136
+ const drawnTotal = drawn.reduce((sum, d) => sum + d.slice.value, 0)
137
+
138
+ // Legend geometry (Mermaid's 'right' position).
139
+ const rowHeight = PIE.legendRectSize + PIE.legendSpacing
140
+ const rowCount = chart.slices.length
141
+ const legendOffset = (rowHeight * rowCount) / 2
142
+ const legendX0 = cx0 + 12 * PIE.legendRectSize
143
+ const legendTexts = chart.slices.map((s) =>
144
+ legendText(s.label, s.value, chart.showData),
145
+ )
146
+ const longestText = Math.max(
147
+ 0,
148
+ ...legendTexts.map((t) =>
149
+ estimateTextWidth(t, PIE.legendFontSize, PIE.fontWeight),
150
+ ),
151
+ )
152
+
153
+ const chartAndLegendWidth =
154
+ rowCount > 0
155
+ ? pieWidth +
156
+ PIE.margin +
157
+ PIE.legendRectSize +
158
+ PIE.legendSpacing +
159
+ longestText
160
+ : pieWidth + PIE.margin
161
+
162
+ const titleText = chart.title ?? ''
163
+ const titleWidth =
164
+ titleText.length > 0
165
+ ? estimateTextWidth(titleText, PIE.titleFontSize, PIE.fontWeight)
166
+ : 0
167
+ const titleLeft = cx0 - titleWidth / 2
168
+ const titleRight = cx0 + titleWidth / 2
169
+
170
+ const minX = Math.min(0, titleLeft)
171
+ const maxX = Math.max(chartAndLegendWidth, titleRight)
172
+ const legendTop = cy0 - legendOffset
173
+ const legendBottom =
174
+ cy0 + (rowCount - 1) * rowHeight - legendOffset + PIE.legendRectSize
175
+ const minY = rowCount > 0 ? Math.min(0, legendTop) : 0
176
+ const maxY = rowCount > 0 ? Math.max(height, legendBottom) : height
177
+
178
+ const dx = -minX
179
+ const dy = -minY
180
+ const cx = cx0 + dx
181
+ const cy = cy0 + dy
182
+
183
+ const slices: PositionedPieSlice[] = []
184
+ let angle = 0
185
+ const labelRadius = radius * PIE.textPosition
186
+ for (const { slice, colorIndex } of drawn) {
187
+ const start = angle
188
+ const end = angle + (slice.value / drawnTotal) * TAU
189
+ angle = end
190
+ const label = polar(cx, cy, labelRadius, (start + end) / 2)
191
+ slices.push({
192
+ label: slice.label,
193
+ value: slice.value,
194
+ colorIndex,
195
+ startAngle: start,
196
+ endAngle: end,
197
+ path: slicePath(cx, cy, radius, start, end),
198
+ percentText: `${((slice.value / total) * 100).toFixed(0)}%`,
199
+ labelX: label.x,
200
+ labelY: label.y,
201
+ })
202
+ }
203
+
204
+ const legend: PositionedPieLegendItem[] = legendTexts.map((text, index) => {
205
+ const x = legendX0 + dx
206
+ const y = cy0 + index * rowHeight - legendOffset + dy
207
+ return {
208
+ text,
209
+ colorIndex: index % PIE.paletteSize,
210
+ x,
211
+ y,
212
+ size: PIE.legendRectSize,
213
+ textX: x + PIE.legendRectSize + PIE.legendSpacing,
214
+ textY: y + PIE.legendRectSize - PIE.legendSpacing,
215
+ }
216
+ })
217
+
218
+ return {
219
+ width: Math.ceil(maxX - minX),
220
+ height: Math.ceil(maxY - minY),
221
+ cx,
222
+ cy,
223
+ radius,
224
+ outerRadius: radius + PIE.outerStrokeWidth / 2,
225
+ title:
226
+ titleText.length > 0
227
+ ? { text: titleText, x: cx, y: cy - (height - 50) / 2 }
228
+ : undefined,
229
+ slices,
230
+ legend,
231
+ accTitle: chart.accTitle,
232
+ accDescr: chart.accDescr,
233
+ }
234
+ }