@jarenjs/charts 0.34.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.
Files changed (56) hide show
  1. package/README.md +293 -0
  2. package/dist/types/component/index.d.ts +95 -0
  3. package/dist/types/core/axis.d.ts +77 -0
  4. package/dist/types/core/cartesian.d.ts +127 -0
  5. package/dist/types/core/chart.d.ts +58 -0
  6. package/dist/types/core/domain.d.ts +96 -0
  7. package/dist/types/core/marks.d.ts +86 -0
  8. package/dist/types/core/palette.d.ts +72 -0
  9. package/dist/types/core/scale.d.ts +59 -0
  10. package/dist/types/core/session.d.ts +85 -0
  11. package/dist/types/core/stream-adapter.d.ts +187 -0
  12. package/dist/types/index.d.ts +35 -0
  13. package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
  14. package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
  15. package/dist/types/types/bar.d.ts +213 -0
  16. package/dist/types/types/boxplot.d.ts +116 -0
  17. package/dist/types/types/candlestick.d.ts +218 -0
  18. package/dist/types/types/gauge.d.ts +68 -0
  19. package/dist/types/types/heatmap.d.ts +104 -0
  20. package/dist/types/types/line.d.ts +272 -0
  21. package/dist/types/types/map.d.ts +137 -0
  22. package/dist/types/types/pie.d.ts +146 -0
  23. package/dist/types/types/radar.d.ts +89 -0
  24. package/dist/types/types/sankey.d.ts +100 -0
  25. package/dist/types/types/scatter.d.ts +80 -0
  26. package/dist/types/types/streamgraph.d.ts +75 -0
  27. package/dist/types/types/treemap.d.ts +118 -0
  28. package/package.json +76 -0
  29. package/schemas/chart-definition.schema.json +448 -0
  30. package/src/component/index.js +125 -0
  31. package/src/core/axis.js +221 -0
  32. package/src/core/cartesian.js +192 -0
  33. package/src/core/chart.js +101 -0
  34. package/src/core/domain.js +123 -0
  35. package/src/core/marks.js +110 -0
  36. package/src/core/palette.js +126 -0
  37. package/src/core/scale.js +106 -0
  38. package/src/core/session.js +0 -0
  39. package/src/core/stream-adapter.js +613 -0
  40. package/src/index.js +40 -0
  41. package/src/transforms/benchmark-adapter.js +298 -0
  42. package/src/transforms/mermaid-adapter.js +19 -0
  43. package/src/types/bar.js +276 -0
  44. package/src/types/boxplot.js +216 -0
  45. package/src/types/candlestick.js +274 -0
  46. package/src/types/gauge.js +140 -0
  47. package/src/types/heatmap.js +176 -0
  48. package/src/types/line.js +349 -0
  49. package/src/types/map.js +378 -0
  50. package/src/types/pie.js +163 -0
  51. package/src/types/radar.js +224 -0
  52. package/src/types/sankey.js +391 -0
  53. package/src/types/scatter.js +148 -0
  54. package/src/types/streamgraph.js +158 -0
  55. package/src/types/treemap.js +322 -0
  56. package/styles/charts.css +83 -0
@@ -0,0 +1,378 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The map chart type: GeoJSON drawn in Web Mercator, optionally
4
+ * shaded by a feature property (a choropleth). Data shape:
5
+ *
6
+ * data = { features: <FeatureCollection | Feature[] | Feature>,
7
+ * points?: [{ at: [lon, lat], label?, value? }] }
8
+ * config = { type:'map', title?, value?, label?, log?, simplify?, aspect? }
9
+ *
10
+ * `value` names the feature property to shade by and `label` the one to
11
+ * name features by (default `'name'`); `points` is the convenience path
12
+ * for a caller holding a list of places rather than GeoJSON, and folds
13
+ * into the same shape list at build time so the AST has one.
14
+ *
15
+ * The AST is unit-space rings, lines and dots plus a normalized
16
+ * magnitude per shape — the projection and the simplification happen in
17
+ * the build (they are geometry *of the data*, not of the drawing), the
18
+ * colors, the stroke widths and the ramp legend in the render.
19
+ *
20
+ * Two things are worth knowing about the projection. It is Web Mercator
21
+ * because that is what every reader's mental model of a map already is,
22
+ * and it is applied **only here**: nothing measures on the result, and
23
+ * `@jarenjs/core/geo` keeps its area and distance functions on the
24
+ * sphere for exactly that reason. And it is fitted uniformly — the map
25
+ * is centred in whichever axis has room left over rather than stretched
26
+ * to fill the frame, which is the single most common way to make a map
27
+ * look wrong.
28
+ */
29
+
30
+ import { svgRoot, coord } from '@jarenjs/view/helpers';
31
+ import { clamp01 } from '@jarenjs/core/math';
32
+ import { bboxOf, bboxUnion, fitMercator, simplifyLine, simplifyRing } from '@jarenjs/core/geo';
33
+ import { formatTickValue } from '../core/axis.js';
34
+ import { FS_TITLE, annotateChart, chartTitle, legendRow } from '../core/cartesian.js';
35
+ import { SEQUENTIAL, sequentialColor } from '../core/palette.js';
36
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
37
+
38
+ /**
39
+ * @typedef {object} MapShapeAST
40
+ * @property {string} label
41
+ * @property {number|null} value the shading property, when the feature had one
42
+ * @property {number|null} t normalized magnitude (0..1), null when unshaded
43
+ * @property {Array<Array<number[]>>} rings polygon rings in unit space,
44
+ * exterior first then holes; `[u, v]` with v growing downward
45
+ * @property {Array<Array<number[]>>} lines line strings in unit space
46
+ * @property {Array<number[]>} dots point positions in unit space
47
+ */
48
+ /**
49
+ * @typedef {object} MapAST
50
+ * @property {'map'} type
51
+ * @property {string|null} title
52
+ * @property {number} aspect layout width:height ratio
53
+ * @property {number[]|null} bbox the geographic extent drawn, `[w,s,e,n]`
54
+ * @property {MapShapeAST[]} shapes
55
+ * @property {{min: number, max: number}|null} domain shading extent (null = unshaded)
56
+ * @property {{source: number, drawn: number}} vertices positions read vs kept
57
+ */
58
+
59
+ /**
60
+ * Default simplification tolerance, as a fraction of the frame's width.
61
+ * At the default 560px-wide chart this is about a third of a pixel, so
62
+ * the simplification is invisible by construction: it can only remove
63
+ * vertices that would have landed on a neighbour's pixel anyway. A
64
+ * caller drawing much larger passes a smaller number, and `false`
65
+ * switches it off. Exported because the streaming map accumulator
66
+ * simplifies on arrival with the same default.
67
+ */
68
+ export const MAP_SIMPLIFY = 0.0006;
69
+
70
+ /**
71
+ * Build the geometry-free map AST.
72
+ * @param {any} data
73
+ * @param {any} [config]
74
+ * @returns {MapAST}
75
+ */
76
+ export function buildMapAST(data, config = {}) {
77
+ const aspect = typeof config.aspect === 'number' && Number.isFinite(config.aspect) && config.aspect > 0
78
+ ? config.aspect : 1.6;
79
+ const valueKey = typeof config.value === 'string' ? config.value : null;
80
+ const labelKey = typeof config.label === 'string' && config.label !== '' ? config.label : 'name';
81
+ const tolerance = config.simplify === false ? 0
82
+ : (typeof config.simplify === 'number' && config.simplify >= 0 ? config.simplify : MAP_SIMPLIFY);
83
+ const log = config.log === true;
84
+
85
+ const entries = collectEntries(data, valueKey, labelKey);
86
+
87
+ let bbox = null;
88
+ for (const entry of entries) {
89
+ const box = bboxOf(entry.geometry);
90
+ if (box !== null)
91
+ bbox = bbox === null ? box : bboxUnion(bbox, box);
92
+ }
93
+
94
+ // the shading extent, over the features that actually carry a value
95
+ let min = Infinity;
96
+ let max = -Infinity;
97
+ for (const entry of entries) {
98
+ const v = entry.value;
99
+ if (v === null || (log && v <= 0)) continue;
100
+ if (v < min) min = v;
101
+ if (v > max) max = v;
102
+ }
103
+ const domain = Number.isFinite(min) ? { min, max } : null;
104
+ const span = domain === null ? 0
105
+ : log ? Math.log10(max) - Math.log10(min)
106
+ : max - min;
107
+
108
+ const shapes = [];
109
+ const counts = { source: 0, drawn: 0 };
110
+ if (bbox !== null) {
111
+ const fit = fitMercator(bbox, aspect);
112
+ for (const entry of entries) {
113
+ const shape = {
114
+ label: entry.label,
115
+ value: entry.value,
116
+ t: entry.value === null || domain === null || (log && entry.value <= 0) ? null
117
+ : span === 0 ? 0.5
118
+ : clamp01(log
119
+ ? (Math.log10(entry.value) - Math.log10(min)) / span
120
+ : (entry.value - min) / span),
121
+ rings: [],
122
+ lines: [],
123
+ dots: [],
124
+ };
125
+ collectParts(entry.geometry, fit, tolerance, shape, counts);
126
+ if (shape.rings.length !== 0 || shape.lines.length !== 0 || shape.dots.length !== 0)
127
+ shapes.push(shape);
128
+ }
129
+ }
130
+
131
+ return {
132
+ type: 'map',
133
+ title: config.title ?? null,
134
+ aspect,
135
+ bbox,
136
+ shapes,
137
+ domain,
138
+ vertices: counts,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Flatten the input into `{label, value, geometry}` entries. Accepts a
144
+ * FeatureCollection, an array of Features or geometries, or a single
145
+ * one; the `points` convenience list becomes Point geometries so
146
+ * everything downstream sees the same thing.
147
+ */
148
+ function collectEntries(data, valueKey, labelKey) {
149
+ const entries = [];
150
+ const source = data?.features ?? data;
151
+ const list = Array.isArray(source) ? source
152
+ : source?.type === 'FeatureCollection' ? (source.features ?? [])
153
+ : source === null || source === undefined ? [] : [source];
154
+
155
+ for (const item of list) {
156
+ if (item === null || typeof item !== 'object') continue;
157
+ const properties = item.type === 'Feature' ? (item.properties ?? {}) : item;
158
+ const geometry = item.type === 'Feature' ? item.geometry : item;
159
+ if (geometry === null || typeof geometry !== 'object') continue;
160
+ entries.push({
161
+ label: labelOf(properties, labelKey),
162
+ value: valueKey === null ? null : finiteOrNull(properties?.[valueKey]),
163
+ geometry,
164
+ });
165
+ }
166
+
167
+ for (const point of data?.points ?? []) {
168
+ if (point === null || typeof point !== 'object') continue;
169
+ const at = Array.isArray(point.at) ? point.at : null;
170
+ if (at === null || typeof at[0] !== 'number' || typeof at[1] !== 'number') continue;
171
+ entries.push({
172
+ label: labelOf(point, labelKey),
173
+ value: finiteOrNull(point.value),
174
+ geometry: { type: 'Point', coordinates: at },
175
+ });
176
+ }
177
+ return entries;
178
+ }
179
+
180
+ function labelOf(properties, labelKey) {
181
+ const named = properties?.[labelKey] ?? properties?.label;
182
+ return named === null || named === undefined ? '' : String(named);
183
+ }
184
+
185
+ function finiteOrNull(v) {
186
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
187
+ }
188
+
189
+ /**
190
+ * Walk a geometry, projecting and simplifying each part into the shape's
191
+ * unit-space lists. Unknown `type` values contribute nothing rather than
192
+ * throwing — a map of half-broken data should draw the half that works.
193
+ */
194
+ function collectParts(geometry, fit, tolerance, shape, counts) {
195
+ const { type, coordinates } = geometry;
196
+ if (type === 'GeometryCollection') {
197
+ for (const inner of geometry.geometries ?? []) {
198
+ if (inner !== null && typeof inner === 'object')
199
+ collectParts(inner, fit, tolerance, shape, counts);
200
+ }
201
+ return;
202
+ }
203
+ if (type === 'Point') {
204
+ const dot = project(coordinates, fit);
205
+ if (dot !== null) {
206
+ counts.source++;
207
+ counts.drawn++;
208
+ shape.dots.push(dot);
209
+ }
210
+ return;
211
+ }
212
+ if (type === 'MultiPoint') {
213
+ for (const position of coordinates ?? []) {
214
+ const dot = project(position, fit);
215
+ if (dot !== null) {
216
+ counts.source++;
217
+ counts.drawn++;
218
+ shape.dots.push(dot);
219
+ }
220
+ }
221
+ return;
222
+ }
223
+ if (type === 'LineString') {
224
+ pushLine(coordinates, fit, tolerance, shape, counts);
225
+ return;
226
+ }
227
+ if (type === 'MultiLineString') {
228
+ for (const part of coordinates ?? [])
229
+ pushLine(part, fit, tolerance, shape, counts);
230
+ return;
231
+ }
232
+ if (type === 'Polygon') {
233
+ for (const ring of coordinates ?? [])
234
+ pushRing(ring, fit, tolerance, shape, counts);
235
+ return;
236
+ }
237
+ if (type === 'MultiPolygon') {
238
+ for (const rings of coordinates ?? []) {
239
+ for (const ring of rings ?? [])
240
+ pushRing(ring, fit, tolerance, shape, counts);
241
+ }
242
+ }
243
+ }
244
+
245
+ function project(position, fit) {
246
+ if (!Array.isArray(position)) return null;
247
+ const lon = position[0];
248
+ const lat = position[1];
249
+ if (typeof lon !== 'number' || typeof lat !== 'number'
250
+ || !Number.isFinite(lon) || !Number.isFinite(lat))
251
+ return null;
252
+ return fit(lon, lat);
253
+ }
254
+
255
+ /** Project a run of positions, dropping the ones that are not positions. */
256
+ function projectRun(positions, fit, counts) {
257
+ if (!Array.isArray(positions)) return [];
258
+ const out = [];
259
+ for (const position of positions) {
260
+ const p = project(position, fit);
261
+ if (p !== null) {
262
+ counts.source++;
263
+ out.push(p);
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ function pushLine(positions, fit, tolerance, shape, counts) {
270
+ const run = projectRun(positions, fit, counts);
271
+ if (run.length < 2) return;
272
+ const drawn = tolerance > 0 ? simplifyLine(run, tolerance) : run;
273
+ counts.drawn += drawn.length;
274
+ shape.lines.push(drawn);
275
+ }
276
+
277
+ function pushRing(positions, fit, tolerance, shape, counts) {
278
+ const run = projectRun(positions, fit, counts);
279
+ if (run.length < 3) return;
280
+ const drawn = tolerance > 0 ? simplifyRing(run, tolerance) : run;
281
+ counts.drawn += drawn.length;
282
+ shape.rings.push(drawn);
283
+ }
284
+
285
+ /**
286
+ * Render a map AST to a pure-vnode SVG: one filled path per shape (all
287
+ * its rings as subpaths, `evenodd` so holes punch through whichever way
288
+ * the producer wound them), stroked paths for lines, dots for points,
289
+ * and a ramp key in the legend slot when the map is shaded.
290
+ * @param {MapAST} ast
291
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
292
+ * @param {string} hash
293
+ * @param {{rootClass?: string, keyPrefix?: string, ramp?: readonly string[], width?: number,
294
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
295
+ * @returns {any}
296
+ */
297
+ export function renderMapAST(ast, theme, hash, options = {}) {
298
+ const t = theme.tokens;
299
+ const ramp = options.ramp ?? SEQUENTIAL;
300
+ const tooltip = normalizeTooltip(options.tooltip);
301
+ const width = options.width ?? 560;
302
+
303
+ // The ramp key rides the frame's legend mechanism, exactly as the
304
+ // heatmap's does: five stops as swatches, the extent as end labels.
305
+ const rampStops = [0, 0.25, 0.5, 0.75, 1].map((v) => sequentialColor(v, ramp));
306
+ const legend = ast.domain === null ? null : rampStops.map((_, i) => ({
307
+ name: i === 0 ? formatTickValue(ast.domain.min)
308
+ : i === rampStops.length - 1 ? formatTickValue(ast.domain.max) : '',
309
+ swatch: i,
310
+ }));
311
+
312
+ // A map has no axes, no ticks and no gridlines, so it does not use the
313
+ // cartesian frame — only its title and legend chrome. That also lets
314
+ // the plot sit on an even margin, which a map needs and an axis-bearing
315
+ // plot (whose left margin holds the tick labels) cannot have.
316
+ const pad = 12;
317
+ const plotW = width - 2 * pad;
318
+ const plotH = plotW / ast.aspect;
319
+ const top = (ast.title ? 34 : 14) + (legend === null ? 0 : 22);
320
+ const height = top + plotH + 10;
321
+ const children = [];
322
+
323
+ if (ast.title)
324
+ children.push(chartTitle(width / 2, 22, ast.title, FS_TITLE, t));
325
+ if (legend !== null)
326
+ children.push(...legendRow(legend, width - 16, top - 10, theme, rampStops));
327
+
328
+ const toX = (u) => coord(pad + u * plotW);
329
+ const toY = (v) => coord(top + v * plotH);
330
+ const unshaded = ast.domain === null ? sequentialColor(0, ramp) : t.grid;
331
+
332
+ for (const shape of ast.shapes) {
333
+ const fill = shape.t === null ? unshaded : sequentialColor(shape.t, ramp);
334
+ const hover = shape.value === null
335
+ ? shape.label
336
+ : `${shape.label}: ${formatTickValue(shape.value)}`;
337
+ const descriptor = { type: 'map', label: shape.label, value: shape.value };
338
+
339
+ if (shape.rings.length !== 0) {
340
+ let d = '';
341
+ for (const ring of shape.rings) {
342
+ for (let i = 0; i < ring.length; i++)
343
+ d += (i === 0 ? 'M' : 'L') + toX(ring[i][0]) + ' ' + toY(ring[i][1]) + ' ';
344
+ d += 'Z ';
345
+ }
346
+ children.push(valueMark('path', {
347
+ d: d.trim(), fill, 'fill-rule': 'evenodd',
348
+ stroke: t.sliceStroke, 'stroke-width': 0.5,
349
+ 'stroke-linejoin': 'round', class: 'chart-map-area',
350
+ }, tooltip, hover, descriptor));
351
+ }
352
+ for (const line of shape.lines) {
353
+ let d = '';
354
+ for (let i = 0; i < line.length; i++)
355
+ d += (i === 0 ? 'M' : 'L') + toX(line[i][0]) + ' ' + toY(line[i][1]) + ' ';
356
+ children.push(valueMark('path', {
357
+ d: d.trim(), fill: 'none',
358
+ stroke: shape.t === null ? t.axis : fill, 'stroke-width': 1.5,
359
+ 'stroke-linejoin': 'round', 'stroke-linecap': 'round', class: 'chart-map-line',
360
+ }, tooltip, hover, descriptor));
361
+ }
362
+ for (const dot of shape.dots) {
363
+ // A dot with no value is a *marker* — a city on a population map —
364
+ // which is a different thing from an area with no data, so it takes
365
+ // ink rather than the ramp's "unshaded" grey and stays visible
366
+ // against whatever it sits on.
367
+ children.push(valueMark('circle', {
368
+ cx: toX(dot[0]), cy: toY(dot[1]), r: 3.5,
369
+ fill: shape.t === null ? t.text : fill,
370
+ stroke: t.sliceStroke, 'stroke-width': 1, class: 'chart-map-dot',
371
+ }, tooltip, hover, descriptor));
372
+ }
373
+ }
374
+
375
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-map-chart',
376
+ width, height, theme, children, (options.keyPrefix ?? 'map-') + hash);
377
+ return annotateChart(svg, ast.title);
378
+ }
@@ -0,0 +1,163 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The pie chart type, split into the geometry-free AST build and
4
+ * the SVG render (the two-stage contract every chart type follows).
5
+ * This is the extraction of the mermaid pie: `@jarenjs/mermaid` now
6
+ * delegates its `pie` diagrams here, passing its own class names,
7
+ * palette and theme through `render` options so its output is unchanged
8
+ * — charts never imports mermaid (the dependency arrow is one-way).
9
+ */
10
+
11
+ import { svgRoot, rect, group, textAt, num, polarPoint, textWidth } from '@jarenjs/view/helpers';
12
+ import { CATEGORICAL } from '../core/palette.js';
13
+ import { normalizeTooltip, markProps } from '../core/marks.js';
14
+
15
+ /**
16
+ * @typedef {object} PieSliceAST
17
+ * @property {string} label
18
+ * @property {number} value
19
+ * @property {number} frac fraction of the total (0..1)
20
+ * @property {number} start start angle (radians, 12 o'clock = -π/2)
21
+ * @property {number} end end angle (radians)
22
+ */
23
+ /**
24
+ * @typedef {object} PieAST
25
+ * @property {'pie'} type
26
+ * @property {string|null} title
27
+ * @property {number} total
28
+ * @property {number|null} inner donut hole radius as a fraction of the outer radius (null = solid pie)
29
+ * @property {PieSliceAST[]} slices
30
+ */
31
+
32
+ /**
33
+ * Build the geometry-free pie AST: labels/values → fractions and
34
+ * accumulated start/end angles, starting at 12 o'clock. `config.donut`
35
+ * turns the pie into a donut: `true` uses the default hole fraction,
36
+ * a number in (0, 1) sets it directly.
37
+ * @param {{slices?: {label: string, value: number}[]}} data
38
+ * @param {{title?: string|null, donut?: boolean|number}} [config]
39
+ * @returns {PieAST}
40
+ */
41
+ export function buildPieAST(data, config = {}) {
42
+ const input = data?.slices ?? [];
43
+ const total = input.reduce((s, x) => s + x.value, 0) || 1;
44
+ const slices = [];
45
+ let angle = -Math.PI / 2;
46
+ for (let i = 0; i < input.length; i++) {
47
+ const frac = input[i].value / total;
48
+ const next = angle + frac * Math.PI * 2;
49
+ slices.push({
50
+ label: input[i].label,
51
+ value: input[i].value,
52
+ frac,
53
+ start: angle,
54
+ end: next,
55
+ });
56
+ angle = next;
57
+ }
58
+ const donut = config.donut;
59
+ const inner = donut === true ? 0.55
60
+ : typeof donut === 'number' && donut > 0 && donut < 1 ? donut
61
+ : null;
62
+ return {
63
+ type: 'pie',
64
+ title: config.title ?? null,
65
+ total,
66
+ inner,
67
+ slices,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * @typedef {object} PieRenderOptions
73
+ * @property {string} [rootClass] the root `<svg>` class
74
+ * @property {string} [keyPrefix] vnode key prefix (`<prefix><hash>`)
75
+ * @property {string} [sliceClass] class per slice `<path>`
76
+ * @property {string} [legendClass] class per legend `<g>`
77
+ * @property {readonly string[]} [palette] slice colors
78
+ * @property {string} [textColor] title/legend text fill
79
+ * @property {string} [sliceStroke] slice separator stroke
80
+ * @property {boolean} [titles] per-slice hover `<title>` (default true).
81
+ * `@jarenjs/mermaid` turns it off: a mermaid pie is a *diagram*, and
82
+ * its emitted SVG is a byte-stable contract that hover text would
83
+ * break — the delegation exists to share geometry, not to change what
84
+ * mermaid renders.
85
+ * @property {import('../core/marks.js').ChartTooltipSpec} [tooltip]
86
+ * pointer bindings for a floating-tooltip host
87
+ */
88
+
89
+ /**
90
+ * Render a pie AST to a pure-vnode SVG: angles → arc paths, plus a
91
+ * swatch legend with percentages and an optional title. With
92
+ * `ast.inner === null` (no `donut` in the config) the slice path is
93
+ * emitted by the same wedge template as before the donut variant
94
+ * existed — solid-pie output is byte-stable, which is what keeps the
95
+ * mermaid delegation byte-identical; the annular geometry lives
96
+ * entirely in the other branch. Every slice carries a label/value
97
+ * `<title>` unless `options.titles` is false (mermaid's opt-out).
98
+ * @param {PieAST} ast
99
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
100
+ * @param {string} hash content hash for the vnode key
101
+ * @param {PieRenderOptions} [options]
102
+ * @returns {any} an SVG vnode
103
+ */
104
+ export function renderPieAST(ast, theme, hash, options = {}) {
105
+ const rootClass = options.rootClass ?? 'chart chart-svg chart-pie';
106
+ const keyPrefix = options.keyPrefix ?? 'pie-';
107
+ const sliceClass = options.sliceClass ?? 'chart-pie-slice';
108
+ const legendClass = options.legendClass ?? 'chart-pie-legend';
109
+ const palette = options.palette ?? CATEGORICAL;
110
+ const textColor = options.textColor ?? theme.tokens.text;
111
+ const sliceStroke = options.sliceStroke ?? theme.tokens.sliceStroke;
112
+ const titles = options.titles !== false;
113
+ const tooltip = normalizeTooltip(options.tooltip);
114
+ const fs = 14;
115
+ const R = 130;
116
+ const cx = R + 20;
117
+ const cy = R + 40;
118
+ const inner = ast.inner ?? null;
119
+ const r = inner === null ? 0 : R * inner;
120
+ const slices = [];
121
+ for (let i = 0; i < ast.slices.length; i++) {
122
+ const s = ast.slices[i];
123
+ const { x: x1, y: y1 } = polarPoint(cx, cy, R, s.start);
124
+ const { x: x2, y: y2 } = polarPoint(cx, cy, R, s.end);
125
+ const large = s.frac > 0.5 ? 1 : 0;
126
+ const color = palette[i % palette.length];
127
+ const i1 = polarPoint(cx, cy, r, s.start);
128
+ const i2 = polarPoint(cx, cy, r, s.end);
129
+ const d = inner === null
130
+ ? `M${num(cx)},${num(cy)} L${num(x1)},${num(y1)} A${R},${R} 0 ${large} 1 ${num(x2)},${num(y2)} Z`
131
+ : `M${num(i1.x)},${num(i1.y)} `
132
+ + `L${num(x1)},${num(y1)} A${R},${R} 0 ${large} 1 ${num(x2)},${num(y2)} `
133
+ + `L${num(i2.x)},${num(i2.y)} `
134
+ + `A${num(r)},${num(r)} 0 ${large} 0 ${num(i1.x)},${num(i1.y)} Z`;
135
+ const text = `${s.label}: ${s.value} (${(s.frac * 100).toFixed(1)}%)`;
136
+ const props = markProps(
137
+ { d, fill: color, stroke: sliceStroke, 'stroke-width': 1, class: sliceClass },
138
+ tooltip, text, { type: 'pie', label: s.label, value: s.value });
139
+ slices.push(titles ? ['path', props, ['title', {}, text]] : ['path', props]);
140
+ }
141
+
142
+ // Legend.
143
+ const legendX = 2 * R + 50;
144
+ let legendW = 120;
145
+ const legend = ast.slices.map((s, i) => {
146
+ const y = 40 + i * 24;
147
+ const label = `${s.label} (${(s.frac * 100).toFixed(1)}%)`;
148
+ legendW = Math.max(legendW, textWidth(label, fs) + 30);
149
+ return group({ class: legendClass }, [
150
+ rect(legendX, y, 14, 14, { fill: palette[i % palette.length] }),
151
+ textAt(legendX + 20, y + 12, label, fs, { fill: textColor }),
152
+ ]);
153
+ });
154
+
155
+ const children = [];
156
+ if (ast.title) {
157
+ children.push(textAt(cx, 24, ast.title, fs + 2, { 'font-weight': 'bold', 'text-anchor': 'middle', fill: textColor }));
158
+ }
159
+ children.push(...slices, ...legend);
160
+ const width = legendX + legendW + 20;
161
+ const height = 2 * R + 70;
162
+ return svgRoot(rootClass, width, height, theme, children, keyPrefix + hash);
163
+ }