@zombie-mermaid/svg-renderer 4.0.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Regenerate with `pnpm run build:mono-font`
5
+ * (scripts/build-mono-font-subset.ts), which subsets
6
+ * third_party/fonts/jetbrains-mono-nl/JetBrainsMonoNL-Regular.ttf down to
7
+ * Basic Latin + Latin-1 Supplement — the self-hosted, embeddable
8
+ * `@font-face` packages/svg-renderer/src/styles.ts's `buildSvgStyleBlock()` inlines
9
+ * into an SVG's own `<style>` for its `.mono` rule (class-diagram method
10
+ * signatures, ER-diagram attribute types), replacing a Google Fonts CDN
11
+ * `@import` this library's SVG output previously depended on by default
12
+ * (#1061). See that script's header comment for why this subset is
13
+ * deliberately narrower than the site's own (demo/components/generated/
14
+ * mono-font-subset.ts).
15
+ */
16
+
17
+ /** The self-hosted family name every consumer's font stack should list
18
+ * first — matches this subset's own `name` table entry. */
19
+ export const MONO_FONT_FAMILY = 'JetBrains Mono NL'
20
+
21
+ /** The `@font-face` rule embedding the subset as a base64 `woff2` data
22
+ * URI — no separate network request, no page-depth-relative path to get
23
+ * wrong (see the script header comment for why data-URI over a static
24
+ * asset file). */
25
+ export const MONO_FONT_FACE_CSS = `@font-face {
26
+ font-family: 'JetBrains Mono NL';
27
+ font-style: normal;
28
+ font-weight: 400;
29
+ font-display: swap;
30
+ src: url(data:font/woff2;base64,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) format('woff2');
31
+ unicode-range: U+0020-007E, U+00A0-00FF;
32
+ }`
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 {
@@ -123,6 +123,45 @@ function adjustJunctionForGroups(
123
123
  return crossingGroup.y - GAP // TD
124
124
  }
125
125
 
126
+ /*
127
+ * How far an edge end may sit from a node's side and still count as attached
128
+ * to that side, in px (ELK routes to the box edge; alignment can shift it a
129
+ * little).
130
+ */
131
+ const SIDE_TOLERANCE = 2
132
+
133
+ /**
134
+ * True when an edge outside the bundle also attaches to `hub` on the side the
135
+ * bundle would use. Bundling collapses the group onto the side's midpoint, so
136
+ * an outside edge ending there (a back edge returning to the node, say) would
137
+ * land on the trunk and read as part of it. ELK's own lanes keep them apart,
138
+ * so that group is left as routed.
139
+ *
140
+ * `side` is the coordinate of the hub's side along the flow axis and
141
+ * `horizontal` says which axis that is (x for a left-to-right graph).
142
+ */
143
+ function hasOutsideEdgeOnSide(
144
+ edges: PositionedEdge[],
145
+ bundleEdges: Set<PositionedEdge>,
146
+ hubId: string,
147
+ side: number,
148
+ horizontal: boolean,
149
+ ): boolean {
150
+ for (const e of edges) {
151
+ if (bundleEdges.has(e) || e.source === e.target) continue
152
+ const end =
153
+ e.source === hubId
154
+ ? e.points[0]
155
+ : e.target === hubId
156
+ ? e.points[e.points.length - 1]
157
+ : undefined
158
+ if (!end) continue
159
+ if (Math.abs((horizontal ? end.x : end.y) - side) <= SIDE_TOLERANCE)
160
+ return true
161
+ }
162
+ return false
163
+ }
164
+
126
165
  /**
127
166
  * Bundle fan-out and fan-in edge paths so they share a common trunk segment.
128
167
  *
@@ -193,6 +232,16 @@ export function bundleEdgePaths(
193
232
  if (isHorizontal) {
194
233
  const exitX = isLR ? source.x + source.width : source.x
195
234
  const exitY = srcCY
235
+ if (
236
+ hasOutsideEdgeOnSide(
237
+ edges,
238
+ new Set(targets.map((t) => t.edge)),
239
+ sourceId,
240
+ exitX,
241
+ true,
242
+ )
243
+ )
244
+ continue
196
245
 
197
246
  const nearestX = isLR
198
247
  ? Math.min(...targets.map((t) => t.node.x))
@@ -229,6 +278,16 @@ export function bundleEdgePaths(
229
278
  } else {
230
279
  const exitX = srcCX
231
280
  const exitY = isBT ? source.y : source.y + source.height
281
+ if (
282
+ hasOutsideEdgeOnSide(
283
+ edges,
284
+ new Set(targets.map((t) => t.edge)),
285
+ sourceId,
286
+ exitY,
287
+ false,
288
+ )
289
+ )
290
+ continue
232
291
 
233
292
  const nearestY = isBT
234
293
  ? Math.max(...targets.map((t) => t.node.y + t.node.height))
@@ -307,6 +366,16 @@ export function bundleEdgePaths(
307
366
  if (isHorizontal) {
308
367
  const entryX = isLR ? target.x : target.x + target.width
309
368
  const entryY = tgtCY
369
+ if (
370
+ hasOutsideEdgeOnSide(
371
+ edges,
372
+ new Set(sources.map((s) => s.edge)),
373
+ targetId,
374
+ entryX,
375
+ true,
376
+ )
377
+ )
378
+ continue
310
379
 
311
380
  const farthestX = isLR
312
381
  ? Math.max(...sources.map((s) => s.node.x + s.node.width))
@@ -342,6 +411,16 @@ export function bundleEdgePaths(
342
411
  } else {
343
412
  const entryX = tgtCX
344
413
  const entryY = isBT ? target.y + target.height : target.y
414
+ if (
415
+ hasOutsideEdgeOnSide(
416
+ edges,
417
+ new Set(sources.map((s) => s.edge)),
418
+ targetId,
419
+ entryY,
420
+ false,
421
+ )
422
+ )
423
+ continue
345
424
 
346
425
  const farthestY = isBT
347
426
  ? Math.min(...sources.map((s) => s.node.y))
@@ -48,6 +48,18 @@ const STATE_MARKER_LAYER_CONSTRAINT: Partial<Record<string, string>> = {
48
48
  * Root-graph ELK options applied to state diagrams only (flowcharts keep
49
49
  * their tuned defaults).
50
50
  *
51
+ * Flowcharts deliberately do not get `MODEL_ORDER` cycle breaking (#1307).
52
+ * They already break cycles the way mermaid.js does, by reversing the
53
+ * depth-first back edges before ELK runs (`edgesReversedForLayout`), so ELK
54
+ * has no cycle left to break; adding `MODEL_ORDER` on top only moved nodes
55
+ * away from mermaid.js. Measured against mermaid 11.17.2 (relative placement
56
+ * of every node pair, `scripts/layout-oracle.ts` style): applied to every
57
+ * flowchart it changed 5 of the 26 gallery samples and 49 of 104 doc/test
58
+ * flowcharts, closer for 0 and 2, further for 4 and 41; applied only to
59
+ * graphs with a subgraph direction override (the one case where the
60
+ * pre-reversal is skipped) it changed 1 and 7, closer for 0 and 2, further
61
+ * for 1 and 5.
62
+ *
51
63
  * - `cycleBreaking: MODEL_ORDER` reverses the edge that points *against*
52
64
  * declaration order instead of whichever edge ELK's greedy heuristic picks.
53
65
  * The greedy default reversed the main chain of `[*] --> Idle`,
@@ -570,11 +582,9 @@ export function mermaidToElk(
570
582
  // Deliberately scoped to *only* the sibling-subgraph reversal, not a
571
583
  // wholesale "all clusters before all leaves" reordering (which mermaid's
572
584
  // own mechanism also does, globally): moving the whole subgraphs group
573
- // ahead of top-level leaf nodes changed which edge in a cycle ELK treats
574
- // as a feedback edge for a sample mixing top-level leaves and a
575
- // subgraph in a cyclic flow (e.g. "CI/CD Pipeline"), reordering the
576
- // entire rank structure rather than just left-right sibling position —
577
- // a much bigger, unreviewed blast radius than the reported bug needs.
585
+ // ahead of top-level leaf nodes changes which edge in a cycle ELK treats
586
+ // as a feedback edge. Cycles in a flowchart are broken before ELK sees
587
+ // them (`edgesReversedForLayout`), so this ordering no longer decides that.
578
588
  const stateGraph = isStateGraph(graph)
579
589
  const leafCount = rootChildren.length
580
590
  // State diagrams read in source order, so composites keep forward
@@ -30,6 +30,7 @@ import type {
30
30
  PositionedGraph,
31
31
  FlowchartRenderOptions,
32
32
  } from '@zombie-mermaid/core'
33
+ import { withoutOwnClusterEdges } from '@zombie-mermaid/core'
33
34
  import { elkLayoutSync } from './elk-instance.ts'
34
35
  import { resolveFontSizes } from './styles.ts'
35
36
  import { DEFAULTS } from './layout-engine/constants.ts'
@@ -80,9 +81,12 @@ function layoutWithHints(
80
81
  * share the engine (state, architecture) keep using `layoutGraphSync`.
81
82
  */
82
83
  export function layoutFlowchartSync(
83
- graph: MermaidGraph,
84
+ rawGraph: MermaidGraph,
84
85
  options: FlowchartRenderOptions = {},
85
86
  ): PositionedGraph {
87
+ // Real mermaid.js draws no line for an edge between a node and a subgraph
88
+ // containing it (#1310), so it is not laid out or rendered here either.
89
+ const graph = withoutOwnClusterEdges(rawGraph)
86
90
  if (canLayOutFlat(graph)) {
87
91
  const fontSizes = resolveFontSizes(options.fontSizes)
88
92
  const flat = layoutCompoundFlat(
@@ -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
+ }
@@ -0,0 +1,161 @@
1
+ // ============================================================================
2
+ // Pie chart SVG renderer
3
+ //
4
+ // Draws a PositionedPieChart (see ./layout.ts) in Mermaid's own structure and
5
+ // paint order: outline circle, slices, percentage labels, title, legend.
6
+ //
7
+ // Styling follows Mermaid's pieStyles.ts and default theme, mapped onto this
8
+ // library's CSS custom properties so every theme (and live theme switching
9
+ // through `--bg`/`--fg`/`--accent`) applies:
10
+ //
11
+ // Mermaid here
12
+ // pie1..pie12 --pie-color-0..11: the theme accent, then
13
+ // getSeriesColor() shades of it (the same
14
+ // palette xychart series use)
15
+ // slice opacity 0.7 opacity: 0.7 (on fill and stroke, as there)
16
+ // pieStrokeColor black, 2px var(--fg), 2px
17
+ // pieOuterStrokeColor black, 2px var(--fg), 2px
18
+ // title 25px, legend/labels 17px same sizes, regular weight
19
+ // text colours (black / #333) var(--_text)
20
+ //
21
+ // The legend swatch is filled and stroked with the slice colour at full
22
+ // opacity, as in Mermaid. There are no tooltips or hover effects: Mermaid
23
+ // draws none by default (`highlightSlice` is off), so `interactivity` does
24
+ // not change the output.
25
+ // ============================================================================
26
+
27
+ import type { PositionedPieChart } from '@zombie-mermaid/mermaid-parser'
28
+ import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
29
+ import {
30
+ svgOpenTag,
31
+ buildStyleBlock,
32
+ styleOpenTag,
33
+ escapeXml,
34
+ escapeAttr,
35
+ } from '@zombie-mermaid/core'
36
+ import {
37
+ getSeriesColor,
38
+ CHART_ACCENT_FALLBACK,
39
+ } from '@zombie-mermaid/mermaid-parser'
40
+ import { withDataSrc } from '../renderer.ts'
41
+ import { PIE } from './layout.ts'
42
+
43
+ function r(n: number): string {
44
+ return String(Math.round(n * 100) / 100)
45
+ }
46
+
47
+ /** CSS value for palette slot `index`: slot 0 follows `--accent` live. */
48
+ export function pieColorValue(
49
+ index: number,
50
+ accent: string | undefined,
51
+ bg: string,
52
+ ): string {
53
+ if (index === 0) return `var(--accent, ${CHART_ACCENT_FALLBACK})`
54
+ return getSeriesColor(index, accent ?? CHART_ACCENT_FALLBACK, bg)
55
+ }
56
+
57
+ function pieStyles(
58
+ chart: PositionedPieChart,
59
+ colors: DiagramColors,
60
+ nonce?: string,
61
+ ): string {
62
+ const used = new Set<number>()
63
+ for (const s of chart.slices) used.add(s.colorIndex)
64
+ for (const l of chart.legend) used.add(l.colorIndex)
65
+ const indices = [...used].sort((a, b) => a - b)
66
+
67
+ const vars = indices.map(
68
+ (i) =>
69
+ ` --pie-color-${i}: ${pieColorValue(i, colors.accent, colors.bg)};`,
70
+ )
71
+ const rules = indices.map(
72
+ (i) =>
73
+ ` .pie-color-${i} { fill: var(--pie-color-${i}); }\n` +
74
+ ` rect.pie-color-${i} { stroke: var(--pie-color-${i}); }`,
75
+ )
76
+ const varsBlock = vars.length > 0 ? `\n svg {\n${vars.join('\n')}\n }` : ''
77
+
78
+ return `${styleOpenTag(nonce)}
79
+ .pie-outer { fill: none; stroke: var(--fg); stroke-width: ${PIE.outerStrokeWidth}px; }
80
+ .pie-slice { stroke: var(--fg); stroke-width: 2px; opacity: 0.7; }
81
+ .pie-slice-label { fill: var(--_text); font-size: ${PIE.sectionFontSize}px; font-weight: ${PIE.fontWeight}; }
82
+ .pie-title { fill: var(--_text); font-size: ${PIE.titleFontSize}px; font-weight: ${PIE.fontWeight}; }
83
+ .pie-legend-text { fill: var(--_text); font-size: ${PIE.legendFontSize}px; font-weight: ${PIE.fontWeight}; }${varsBlock}
84
+ ${rules.join('\n')}
85
+ </style>`
86
+ }
87
+
88
+ /**
89
+ * Render a positioned pie chart as an SVG string.
90
+ *
91
+ * The accessible name is `title` (from `options.title`) when given,
92
+ * otherwise the chart's own `accTitle`; `accDescr` becomes the root
93
+ * `<desc>`. Both are dropped when `decorative` is set — see svgOpenTag() in
94
+ * packages/core/src/theme.ts.
95
+ */
96
+ export function renderPieSvg(
97
+ chart: PositionedPieChart,
98
+ colors: DiagramColors,
99
+ font: string = 'Inter',
100
+ transparent: boolean = false,
101
+ embedSource?: string,
102
+ title?: string,
103
+ decorative?: boolean,
104
+ emit: SvgEmitOptions = {},
105
+ ): string {
106
+ const parts: string[] = []
107
+
108
+ parts.push(
109
+ withDataSrc(
110
+ svgOpenTag(
111
+ chart.width,
112
+ chart.height,
113
+ colors,
114
+ transparent,
115
+ title ?? chart.accTitle,
116
+ decorative,
117
+ undefined,
118
+ emit.styleAttribute,
119
+ chart.accDescr,
120
+ ),
121
+ embedSource,
122
+ ),
123
+ )
124
+ parts.push(buildStyleBlock(font, false, emit.nonce))
125
+ parts.push(pieStyles(chart, colors, emit.nonce))
126
+
127
+ parts.push(
128
+ `<circle cx="${r(chart.cx)}" cy="${r(chart.cy)}" r="${r(chart.outerRadius)}" class="pie-outer"/>`,
129
+ )
130
+
131
+ for (const s of chart.slices) {
132
+ parts.push(
133
+ `<path d="${s.path}" class="pie-slice pie-color-${s.colorIndex}"` +
134
+ ` data-label="${escapeAttr(s.label)}" data-value="${s.value}"/>`,
135
+ )
136
+ }
137
+
138
+ for (const s of chart.slices) {
139
+ parts.push(
140
+ `<text x="${r(s.labelX)}" y="${r(s.labelY)}" text-anchor="middle" class="pie-slice-label">${escapeXml(s.percentText)}</text>`,
141
+ )
142
+ }
143
+
144
+ if (chart.title) {
145
+ parts.push(
146
+ `<text x="${r(chart.title.x)}" y="${r(chart.title.y)}" text-anchor="middle" class="pie-title">${escapeXml(chart.title.text)}</text>`,
147
+ )
148
+ }
149
+
150
+ for (const item of chart.legend) {
151
+ parts.push(
152
+ `<g class="pie-legend">` +
153
+ `<rect x="${r(item.x)}" y="${r(item.y)}" width="${item.size}" height="${item.size}" class="pie-legend-swatch pie-color-${item.colorIndex}"/>` +
154
+ `<text x="${r(item.textX)}" y="${r(item.textY)}" class="pie-legend-text">${escapeXml(item.text)}</text>` +
155
+ `</g>`,
156
+ )
157
+ }
158
+
159
+ parts.push('</svg>')
160
+ return parts.join('\n')
161
+ }