@motionscript/charts 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +49 -49
  4. package/README.md +99 -99
  5. package/dist/bar-chart/bar-chart.js.map +1 -1
  6. package/dist/bar-chart/category-axis.js.map +1 -1
  7. package/dist/bar-chart/chart-bars.js.map +1 -1
  8. package/dist/bar-chart/index.js.map +1 -1
  9. package/dist/bar-chart/shared.js.map +1 -1
  10. package/dist/browser/chunks/{chunk-XROBIRQK.js → chunk-TJNHLQGV.js} +1 -1
  11. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  12. package/dist/browser/index.js +1 -1
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/kit.js +1 -1
  15. package/dist/browser/manifest.json +1 -1
  16. package/dist/engine.d.ts +1 -1
  17. package/dist/engine.js +1 -1
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/kit/cartesian.js.map +1 -1
  21. package/dist/kit/chart-border.js.map +1 -1
  22. package/dist/kit/chart-canvas.js.map +1 -1
  23. package/dist/kit/chart-legend.js.map +1 -1
  24. package/dist/kit/entries.js.map +1 -1
  25. package/dist/kit/motion.js.map +1 -1
  26. package/dist/kit/paints.js.map +1 -1
  27. package/dist/kit/shared.js.map +1 -1
  28. package/dist/kit/vertical-axis.js.map +1 -1
  29. package/dist/line-chart/chart-line.js.map +1 -1
  30. package/dist/line-chart/chart-region.js.map +1 -1
  31. package/dist/line-chart/horizontal-axis.js.map +1 -1
  32. package/dist/line-chart/index.d.ts +1 -1
  33. package/dist/line-chart/index.js +1 -1
  34. package/dist/line-chart/index.js.map +1 -1
  35. package/dist/line-chart/line-chart.js.map +1 -1
  36. package/dist/line-chart/shared.js.map +1 -1
  37. package/dist/line-chart/x-scale.js.map +1 -1
  38. package/dist/nodes.js.map +1 -1
  39. package/dist/pie-chart/index.js.map +1 -1
  40. package/dist/pie-chart/pie-chart.js.map +1 -1
  41. package/dist/pie-chart/pie-legend.js.map +1 -1
  42. package/dist/pie-chart/pie-ring.js.map +1 -1
  43. package/dist/pie-chart/shared.js.map +1 -1
  44. package/package.json +4 -4
  45. package/registry.json +23 -23
  46. package/src/bar-chart/bar-chart.ts +542 -542
  47. package/src/bar-chart/category-axis.ts +162 -162
  48. package/src/bar-chart/chart-bars.ts +287 -287
  49. package/src/bar-chart/index.ts +31 -31
  50. package/src/bar-chart/shared.ts +354 -354
  51. package/src/engine.ts +28 -28
  52. package/src/index.ts +5 -5
  53. package/src/kit/cartesian.ts +81 -81
  54. package/src/kit/chart-border.ts +50 -50
  55. package/src/kit/chart-canvas.ts +122 -122
  56. package/src/kit/chart-legend.ts +229 -229
  57. package/src/kit/entries.ts +20 -20
  58. package/src/kit/motion.ts +90 -90
  59. package/src/kit/paints.ts +15 -15
  60. package/src/kit/shared.ts +782 -782
  61. package/src/kit/vertical-axis.ts +194 -194
  62. package/src/line-chart/chart-line.ts +156 -156
  63. package/src/line-chart/chart-region.ts +90 -90
  64. package/src/line-chart/horizontal-axis.ts +151 -151
  65. package/src/line-chart/index.ts +39 -39
  66. package/src/line-chart/line-chart.ts +598 -598
  67. package/src/line-chart/shared.ts +473 -473
  68. package/src/line-chart/x-scale.ts +382 -382
  69. package/src/nodes.ts +18 -18
  70. package/src/pie-chart/index.ts +37 -37
  71. package/src/pie-chart/pie-chart.ts +296 -296
  72. package/src/pie-chart/pie-legend.ts +169 -169
  73. package/src/pie-chart/pie-ring.ts +848 -848
  74. package/src/pie-chart/shared.ts +496 -496
  75. package/dist/browser/chunks/chunk-XROBIRQK.js.map +0 -7
  76. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,848 +1,848 @@
1
- import {
2
- Graphics2D,
3
- PathBuilder,
4
- Rect,
5
- Text,
6
- Types,
7
- component2d,
8
- fillOps,
9
- shadowOps,
10
- strokeOps,
11
- textOps,
12
- type Fill,
13
- type FillResolved,
14
- type NodeOf,
15
- type Node2D,
16
- type Node2DProps,
17
- type TextStyle,
18
- type Vector2,
19
- } from "@motionscript/sdk"
20
-
21
- import { asParts, fresh } from "../kit/entries"
22
- import { frameEase } from "../kit/motion"
23
- import { loadPaints } from "../kit/paints"
24
- import { clampUnit, formatPercent } from "../kit/shared"
25
- import {
26
- DEFAULT_PIE_CHART_THEME,
27
- pieArcs,
28
- relaxLabels,
29
- ringPoint,
30
- type CenterConfig,
31
- type LabelConfig,
32
- type PieArc,
33
- type PieLabelMode,
34
- type PieLabelPlacement,
35
- type PieSlice,
36
- type RingConfig,
37
- } from "./shared"
38
-
39
- export interface PieRingProps extends Node2DProps {
40
- /** One entry per slice: its `category`, `value` and `fill`. */
41
- slices: PieSlice[]
42
- /** Ring geometry and paint — gap, hole, start angle, selection growth. */
43
- ring: RingConfig
44
- /** How the labels are set, and the leader lines that point at them. */
45
- label: LabelConfig
46
- /**
47
- * How the hole's summary is set.
48
- *
49
- * Named `centerStyle` rather than `center` because every node already has a
50
- * `center` — the anchor its position is measured from.
51
- */
52
- centerStyle: CenterConfig
53
- /** What the labels say, or `"none"` to drop them entirely. */
54
- labelMode: PieLabelMode
55
- /** Where the labels sit: inside their wedge, outside on a leader, or nowhere. */
56
- labelPlacement: PieLabelPlacement
57
- /**
58
- * Sweep of the ring in `[0, 1]`: 0 draws nothing, 1 the full circle. The
59
- * wedges grow around clockwise from the ring's `startAngle` as this rises.
60
- */
61
- growth: number
62
- /**
63
- * Fade of the leader lines and labels in `[0, 1]`, held separate from
64
- * {@link growth} so they arrive *after* the ring has swept in.
65
- */
66
- labelReveal: number
67
- /**
68
- * How far each slice is currently pushed out of the ring, `0`–`1` per index.
69
- * Owned by the chart, so the ring and the legend read one signal.
70
- */
71
- pops: number[]
72
- /** Opacity everything *unselected* falls back to while any slice is selected. */
73
- dimOpacity: number
74
- /**
75
- * Text set in the middle of the ring's hole — the largest uninterrupted area
76
- * the chart owns, and on a donut otherwise empty. A one-line summary there
77
- * routinely does more work than the whole ring around it. Empty (the default)
78
- * leaves the hole clear; ignored when there is no hole.
79
- */
80
- centerLabel: string
81
- /** Smaller second line under {@link centerLabel}. Empty (the default) drops it. */
82
- centerCaption: string
83
- /**
84
- * Fade of the hole's summary in `[0, 1]`. Its own signal, untouched by the
85
- * ring's entrance — a hole label is usually a *conclusion*, so a scene that
86
- * wants it to land on a later beat sets this to 0 and calls
87
- * `PieChart.revealCenter` when it gets there.
88
- */
89
- centerReveal: number
90
- }
91
-
92
- /** Vertical breathing room between one label block and the next, in px. */
93
- const LABEL_GAP = 12
94
- /** Breathing room past the outermost label, so it never touches the node's edge, in px. */
95
- const LABEL_PAD = 14
96
- /** Rough width of one glyph as a fraction of the font size, for gutter sizing. */
97
- const GLYPH_RATIO = 0.62
98
- /** Gap between a wedge's outer edge and where its leader lifts off, in px. */
99
- const LEADER_LIFTOFF = 6
100
- /** How far past the ring's outer edge a leader runs before it elbows, in px. */
101
- const LEADER_LENGTH = 34
102
- /** Horizontal run from the elbow to the label, in px. */
103
- const LABEL_STUB = 26
104
- /** Gap between a leader's end and the label text it points at, in px. */
105
- const TEXT_INSET = 8
106
- /**
107
- * Floor on the elbow's horizontal distance from the ring's axis, as a fraction
108
- * of the leader radius — so a label pushed clear above (or below) the ring still
109
- * elbows out sideways rather than running straight down the 12 o'clock line.
110
- */
111
- const MIN_ELBOW = 0.18
112
- /** Segments used to tessellate a full turn of arc; a wedge takes its share. */
113
- const ARC_STEPS = 240
114
- /** Rounding applied to the leader's elbow, in px. */
115
- const ELBOW_RADIUS = 8
116
- /** Side padding used when nothing is leadered out — no gutter to reserve. */
117
- const BARE_PAD = 2
118
- /** An inside label's caption size, as a fraction of its leading line. */
119
- const INSIDE_CAPTION_RATIO = 0.44
120
- /**
121
- * Where an inside label sits on a **solid pie** (no hole to split the band
122
- * with), as a fraction of the wedge's radius — far enough out that the label
123
- * clears the point every wedge converges on.
124
- */
125
- const PIE_LABEL_RADIUS = 0.62
126
- /** Fraction of the hole's diameter its summary is allowed to run to. */
127
- const CENTER_LABEL_FIT = 0.78
128
- /** Fraction of the room inside a wedge that its label is allowed to run to. */
129
- const INSIDE_LABEL_FIT = 0.9
130
- /** Below this, a wedge's bisector counts as axis-aligned and that term stops binding. */
131
- const AXIS_EPSILON = 1e-3
132
- /** Fallback type size when a theme style leaves it unset or on autofit. */
133
- const FALLBACK_SIZE = 26
134
-
135
- /** A style's numeric size, guarding the autofit and unset cases. */
136
- function sizeOf(style: TextStyle, fallback = FALLBACK_SIZE): number {
137
- const size = style.fontSize
138
- return typeof size === "number" ? size : fallback
139
- }
140
-
141
- /** Where one slice's label sits and the polyline that points at it. */
142
- interface LabelPlacement {
143
- /** Index of the slice. */
144
- index: number
145
- /** Which side of the ring the label sits on: +1 right, −1 left. */
146
- side: 1 | -1
147
- /** Leader polyline: wedge edge → elbow → label. */
148
- leader: Vector2[]
149
- /** Point the label's inner edge is pinned to. */
150
- text: Vector2
151
- }
152
-
153
- /**
154
- * A ring-band wedge over `a0`→`a1` (radians, clockwise from 12 o'clock) spanning
155
- * `rInner`→`rOuter`, centred on `cx`. Ring wedges trace the outer arc, step in,
156
- * and trace the inner arc back; full pie wedges (`rInner <= 0`) close through the
157
- * centre.
158
- *
159
- * `halfGap` is **half the gap to the neighbouring slice, in px**, shaved off both
160
- * ends of the wedge. It's converted to an angle *per radius* (`halfGap / r` — a
161
- * wider angle near the hole than out at the rim), which is what keeps the channel
162
- * between two slices the same width all the way across the band. A fixed angular
163
- * gap instead reads as a thin pizza slice: right at the rim, pinched shut at the
164
- * hole. The inset is capped at the wedge's own half-span so a sliver of a slice
165
- * narrows to nothing rather than turning inside out.
166
- */
167
- function wedgePath(
168
- a0: number,
169
- a1: number,
170
- rInner: number,
171
- rOuter: number,
172
- halfGap: number,
173
- cx: number
174
- ): PathBuilder {
175
- const path = new PathBuilder()
176
- const span = a1 - a0
177
- const steps = Math.max(
178
- 2,
179
- Math.ceil((Math.abs(span) / (Math.PI * 2)) * ARC_STEPS)
180
- )
181
- /** Angular inset at radius `r` that puts the wedge's edge `halfGap` px off the divide. */
182
- const insetAt = (r: number): number =>
183
- r <= 0 ? 0 : Math.min(halfGap / r, span / 2.2)
184
- const arcTo = (r: number, i: number): Vector2 => {
185
- const inset = insetAt(r)
186
- const from = a0 + inset
187
- const to = a1 - inset
188
- const point = ringPoint(r, from + ((to - from) * i) / steps)
189
- return { x: point.x + cx, y: point.y }
190
- }
191
-
192
- if (rInner <= 0) {
193
- // Solid pie wedge: the sides converge on the centre, so only the outer rim
194
- // carries the gap.
195
- path.moveTo(cx, 0)
196
- for (let i = 0; i <= steps; i++) {
197
- const p = arcTo(rOuter, i)
198
- path.lineTo(p.x, p.y)
199
- }
200
- path.close()
201
- return path
202
- }
203
-
204
- for (let i = 0; i <= steps; i++) {
205
- const p = arcTo(rOuter, i)
206
- if (i === 0) path.moveTo(p.x, p.y)
207
- else path.lineTo(p.x, p.y)
208
- }
209
- // Back along the inner arc — its own (wider) inset makes each side edge a
210
- // straight chord sitting `halfGap` px off the divide at both ends.
211
- for (let i = steps; i >= 0; i--) {
212
- const p = arcTo(rInner, i)
213
- path.lineTo(p.x, p.y)
214
- }
215
- path.close()
216
- return path
217
- }
218
-
219
- /** What the ring's geometry reads off its entries. */
220
- interface RingState {
221
- readonly slices: PieSlice[]
222
- readonly ring: RingConfig
223
- readonly label: LabelConfig
224
- readonly centerStyle: CenterConfig
225
- readonly labelMode: PieLabelMode
226
- readonly labelPlacement: PieLabelPlacement
227
- readonly pops: readonly number[]
228
- readonly dimOpacity: number
229
- readonly centerLabel: string
230
- readonly centerCaption: string
231
- }
232
-
233
- /**
234
- * The ring's geometry for one box: every radius, gutter and label position,
235
- * derived from the entries and the size the ring was laid out at, and asked
236
- * afresh wherever it is read — the drawing, and the labels' bound positions
237
- * and sizes alike, so the two cannot disagree.
238
- */
239
- export class RingGeometry {
240
- constructor(
241
- private readonly s: RingState,
242
- private readonly box: { readonly width: number; readonly height: number }
243
- ) { }
244
-
245
- /** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
246
- get arcs(): PieArc[] {
247
- return pieArcs(this.s.slices, this.s.ring.startAngle)
248
- }
249
-
250
- /** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
251
- get hasLeaderLabels(): boolean {
252
- return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0
253
- }
254
-
255
- // ---- Label sizing ------------------------------------------------------
256
-
257
- /**
258
- * How wide a horizontal label centred in slice `arc`'s band can run before it
259
- * leaves the wedge, in px.
260
- *
261
- * Two things bound it, and which one binds depends entirely on where the wedge
262
- * sits. Stepping sideways from the label's centre moves you *radially* by
263
- * `dx·sin(mid)` and *tangentially* by `dx·cos(mid)`, so a wedge at 3 o'clock is
264
- * limited by the band's thickness (its label runs straight across the band)
265
- * while a wedge at 12 o'clock is limited by its own arc (its label runs along
266
- * the band, which is much roomier). Taking the smaller of the two is what stops
267
- * a label overflowing the ring on one chart and being pointlessly shrunk on the
268
- * next.
269
- *
270
- * Measured off the *unpopped* radius, so a slice growing out of the ring
271
- * doesn't resize its own text mid-animation.
272
- */
273
- insideRoom(arc: PieArc): number {
274
- const outer = this.outerRadius
275
- const hole = this.holeRadius
276
- const band = hole > 0 ? outer - hole : outer * (1 - PIE_LABEL_RADIUS) * 2
277
- const mid = hole > 0 ? (hole + outer) / 2 : outer * PIE_LABEL_RADIUS
278
- const sin = Math.abs(Math.sin(arc.mid))
279
- const cos = Math.abs(Math.cos(arc.mid))
280
- const acrossBand = sin > AXIS_EPSILON ? band / sin : Infinity
281
- const alongArc = cos > AXIS_EPSILON ? ((arc.a1 - arc.a0) * mid) / cos : Infinity
282
- return Math.max(0, Math.min(acrossBand, alongArc))
283
- }
284
-
285
- /** Widest line, in glyphs at the leading line's size, an inside label runs to. */
286
- insideGlyphs(arc: PieArc): number {
287
- const mode = this.s.labelMode
288
- const category = this.s.slices[arc.index]?.category.length ?? 0
289
- const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0
290
- // The caption is set at a fraction of the leading line, except when it *is*
291
- // the leading line.
292
- const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0
293
- return Math.max(1, percent, caption)
294
- }
295
-
296
- /**
297
- * Font size of an inside label's leading line: what the theme asked for,
298
- * shrunk if that wouldn't fit inside its wedge. **One size across every inside
299
- * label** — the smallest any of them needs — because labels at mismatched
300
- * sizes read as a mistake rather than as emphasis.
301
- *
302
- * So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
303
- * for the size the chart wants and the ring keeps it legal.
304
- */
305
- get insideFont(): number {
306
- let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9)
307
- for (const arc of this.arcs) {
308
- const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO)
309
- size = Math.min(size, fits)
310
- }
311
- return Math.max(1, size)
312
- }
313
-
314
- /**
315
- * Font size of the hole's summary: the configured size, capped so it still
316
- * fits across the hole. The cap matters because the hole's radius derives from
317
- * the box, so a chart that reflows smaller would otherwise push its summary out
318
- * through the ring.
319
- */
320
- get centerFont(): number {
321
- const { centerStyle, centerLabel, centerCaption } = this.s
322
- const wanted = sizeOf(centerStyle.labelStyle, 46)
323
- const hole = this.holeRadius
324
- if (hole <= 0) return wanted
325
- const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted)
326
- // Both lines have to clear the hole, and the caption is set at a fraction of
327
- // this size — so what binds is whichever is wider once drawn, not whichever
328
- // has more characters.
329
- const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio)
330
- return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO))
331
- }
332
-
333
- // ---- Ring geometry, all derived from the box ---------------------------
334
-
335
- /** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
336
- get growFactor(): number {
337
- return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow))
338
- }
339
-
340
- /** How far past the ring's rim a fully selected slice reaches, in px. */
341
- get maxPop(): number {
342
- return this.outerRadius * (this.growFactor - 1)
343
- }
344
-
345
- /** Height of one **leader** label block, in px — the unit those columns are spaced by. */
346
- get labelRowHeight(): number {
347
- // Inside labels are stacked in the band, not in a column beside the ring, so
348
- // they cost no vertical reserve.
349
- if (!this.hasLeaderLabels) return 0
350
- const caption = sizeOf(this.s.label.textStyle) * 1.25
351
- const percent = sizeOf(this.s.label.percentStyle) * 1.25
352
- if (this.s.labelMode === "both") return caption + percent + 2
353
- return this.s.labelMode === "category" ? caption : percent
354
- }
355
-
356
- /** Widest line, in glyphs, that a leader label for slice `index` can run to. */
357
- captionGlyphs(index: number): number {
358
- const mode = this.s.labelMode
359
- // "100%" is the widest a percentage gets; captions are their own length.
360
- const percent = mode === "percent" || mode === "both" ? 4 : 0
361
- const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0
362
- return Math.max(percent, category)
363
- }
364
-
365
- /**
366
- * Horizontal room reserved on each side for the leaders and their labels, in
367
- * px — measured **per side**, from the slices that actually leader out that
368
- * way. A ring whose labels are all inside reserves nothing and draws bigger.
369
- *
370
- * Excludes the ring's own selection-growth allowance, which scales with the
371
- * radius and so is divided out in {@link outerRadius} instead.
372
- */
373
- get labelGutters(): { left: number; right: number } {
374
- const gutters = { left: BARE_PAD, right: BARE_PAD }
375
- if (!this.hasLeaderLabels) return gutters
376
- const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO
377
- for (const arc of this.arcs) {
378
- const room = LEADER_LENGTH + LABEL_STUB + TEXT_INSET + this.captionGlyphs(arc.index) * glyph + LABEL_PAD
379
- const key = sideOf(arc.mid) > 0 ? "right" : "left"
380
- gutters[key] = Math.max(gutters[key], room)
381
- }
382
- return gutters
383
- }
384
-
385
- /**
386
- * How far the ring's centre sits from the node's, in px. Zero while the
387
- * gutters match; when the labels all fall one way it slides the ring into the
388
- * empty half so the ring-plus-labels group stays centred in the box instead of
389
- * the ring sitting dead centre with a bald patch beside it.
390
- */
391
- get centerX(): number {
392
- const { left, right } = this.labelGutters
393
- return (left - right) / 2
394
- }
395
-
396
- /**
397
- * Radius of the ring's outer edge, fitted to whatever the label gutters leave.
398
- * A fully selected slice reaches `radius × growFactor`, so the room has to
399
- * cover that — hence the divisor, which keeps even the grown slice inside the
400
- * box (and holds the ring the same size whether or not anything is selected).
401
- */
402
- get outerRadius(): number {
403
- const { left, right } = this.labelGutters
404
- const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4)
405
- return Math.max(0, room / this.growFactor)
406
- }
407
-
408
- /** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
409
- get holeRadius(): number {
410
- const configured = this.s.ring.holeRadius
411
- if (configured <= 0) return 0
412
- const px = configured < 1 ? configured * this.outerRadius : configured
413
- return Math.min(px, this.outerRadius * 0.95)
414
- }
415
-
416
- /** Radius of the circle every leader elbows on, clear of even a popped-out slice. */
417
- get elbowRadius(): number {
418
- return this.outerRadius + this.maxPop + LEADER_LENGTH
419
- }
420
-
421
- /** How far slice `index` is currently pushed out, in `[0, 1]`. */
422
- popOf(index: number): number {
423
- return clampUnit(this.s.pops[index] ?? 0)
424
- }
425
-
426
- /** Radius of slice `index`'s outer edge, its selection growth included. */
427
- anchorRadius(index: number): number {
428
- return this.outerRadius + this.maxPop * this.popOf(index)
429
- }
430
-
431
- /**
432
- * Radius an inside label sits at on slice `index`: halfway across the band on a
433
- * donut, and further out than halfway on a solid pie, where "halfway" would
434
- * crowd the point every wedge converges on. Rides the slice's own pop, so an
435
- * inside label travels out with the wedge it belongs to.
436
- */
437
- bandMidRadius(index: number): number {
438
- const rim = this.anchorRadius(index)
439
- const hole = this.holeRadius
440
- return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS
441
- }
442
-
443
- /** The point slice `index`'s inside label is centred on. */
444
- insideAnchor(index: number): Vector2 {
445
- const arc = this.arcs.find((a) => a.index === index)
446
- if (arc === undefined) return { x: this.centerX, y: 0 }
447
- const point = ringPoint(this.bandMidRadius(index), arc.mid)
448
- return { x: point.x + this.centerX, y: point.y }
449
- }
450
-
451
- /**
452
- * How selected the ring is overall, in `[0, 1]` — the most-grown slice. Rides
453
- * the same tween the pops do, so the dim below arrives with the growth rather
454
- * than needing an animation of its own.
455
- */
456
- get selectionStrength(): number {
457
- let strongest = 0
458
- for (let i = 0; i < this.s.slices.length; i++) strongest = Math.max(strongest, this.popOf(i))
459
- return strongest
460
- }
461
-
462
- /**
463
- * Opacity slice `index` (and its leader and label) is drawn at: full while
464
- * nothing is selected, falling toward `dimOpacity` in proportion to how
465
- * selected the *rest* of the ring is. A slice that's selected itself stays at
466
- * full — the two factors cancel.
467
- */
468
- sliceOpacity(index: number): number {
469
- const focus = this.selectionStrength
470
- if (focus <= 0) return 1
471
- return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index))
472
- }
473
-
474
- /** Slice `index`'s share of the ring as its label reads it, off the live values. */
475
- percentText(index: number): string {
476
- const arc = this.arcs.find((a) => a.index === index)
477
- return formatPercent(arc?.fraction ?? 0)
478
- }
479
-
480
- // ---- Label placement ---------------------------------------------------
481
-
482
- /**
483
- * Where every leader label sits and how its leader gets there. Labels are split
484
- * into a right and a left column by their slice's bisector, each column is
485
- * spread so nothing overlaps (and nothing leaves the box), and each leader is
486
- * then built as wedge edge → elbow → label. The elbow rides the leader circle at
487
- * the label's *settled* y, which keeps the final run horizontal and the whole
488
- * polyline outside the ring however far the label was nudged.
489
- */
490
- placements(): LabelPlacement[] {
491
- if (!this.hasLeaderLabels) return []
492
- const arcs = this.arcs
493
- if (arcs.length === 0 || this.outerRadius <= 0) return []
494
-
495
- const radius = this.elbowRadius
496
- const rowHeight = this.labelRowHeight
497
- const top = this.box.height / 2 - rowHeight / 2
498
- const bottom = -top
499
- const cx = this.centerX
500
-
501
- interface Slot {
502
- index: number
503
- side: 1 | -1
504
- mid: number
505
- ideal: number
506
- y: number
507
- }
508
- const right: Slot[] = []
509
- const left: Slot[] = []
510
- for (const arc of arcs) {
511
- const side = sideOf(arc.mid)
512
- const ideal = ringPoint(radius, arc.mid).y
513
- ;(side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal })
514
- }
515
- relaxLabels(right, rowHeight + LABEL_GAP, top, bottom)
516
- relaxLabels(left, rowHeight + LABEL_GAP, top, bottom)
517
-
518
- return [...right, ...left].map((slot) => {
519
- const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid)
520
- // The elbow sits where the label's y crosses the leader circle. Solving
521
- // x² + y² = radius² for x keeps it *on* that circle (never inside the
522
- // ring); a label pushed past the circle's top or bottom falls back to the
523
- // sideways floor so its leader still reads as pointing outward.
524
- const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y))
525
- const elbowX = cx + slot.side * Math.max(onCircle, radius * MIN_ELBOW)
526
- const endX = cx + slot.side * (radius + LABEL_STUB)
527
-
528
- return {
529
- index: slot.index,
530
- side: slot.side,
531
- leader: [
532
- { x: start.x + cx, y: start.y },
533
- { x: elbowX, y: slot.y },
534
- { x: endX, y: slot.y },
535
- ],
536
- text: { x: endX + slot.side * TEXT_INSET, y: slot.y },
537
- }
538
- })
539
- }
540
-
541
- /** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
542
- placementFor(index: number): LabelPlacement | undefined {
543
- return this.placements().find((placement) => placement.index === index)
544
- }
545
- }
546
-
547
- /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
548
- function sideOf(mid: number): 1 | -1 {
549
- return Math.sin(mid) >= 0 ? 1 : -1
550
- }
551
-
552
- const NO_BOX = { width: 0, height: 0 }
553
-
554
- /**
555
- * Each slice's fill, resolved once per paint rather than every frame: the slices
556
- * are bound to the chart's rows and palette, so a theme tween hands over new
557
- * paint and the drawing must see it, but a frame that moves nothing must not
558
- * re-resolve. Keyed by the paint itself, so it is shared and needs no node.
559
- */
560
- const resolvedFills = new WeakMap<object, FillResolved[]>()
561
-
562
- function resolvedFill(fill: Fill): FillResolved[] {
563
- if (typeof fill !== "object" || fill === null) return fillOps.resolve(fill)
564
- let resolved = resolvedFills.get(fill)
565
- if (resolved === undefined) resolvedFills.set(fill, resolved = fillOps.resolve(fill))
566
- return resolved
567
- }
568
-
569
- /** Each slice's fill, resolved. */
570
- export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
571
- return slices.map((slice) => resolvedFill(slice.fill))
572
- }
573
-
574
- /**
575
- * The labels a ring was built with: one per *drawn* slice (a zero-valued slice
576
- * gets no wedge, so no label either), in whichever of the two forms the chart
577
- * asked for, then the hole's summary. Both are real Text stacked in a hug
578
- * vertical flow — the difference is only what they are pinned to and which line
579
- * leads. The point each is pinned to stays reactive and follows the ring as the
580
- * box resizes or its slice pops out; only the choice of form, which depends on
581
- * entries alone, is settled here.
582
- */
583
- function ringLabels(s: RingState & { readonly labelReveal: number; readonly centerReveal: number }) {
584
- const blocks: Node2D[] = []
585
- // A label block's parent is the ring: its box is what the ring was laid out at.
586
- const geometry = () => new RingGeometry(s, (blocks[0]?.parent as Node2D | null | undefined)?.layoutBounds ?? NO_BOX)
587
-
588
- const label = (arc: PieArc): Node2D => {
589
- // Fades in with the ring's labels, then dims along with its own slice
590
- // whenever another slice takes the focus.
591
- const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index)
592
- const wantsCategory = s.labelMode === "both" || s.labelMode === "category"
593
- const wantsPercent = s.labelMode === "both" || s.labelMode === "percent"
594
-
595
- if (s.labelPlacement === "inside") {
596
- // The number leads and the caption sits under it: inside a wedge the
597
- // reader already knows *which* wedge they are looking at, so the magnitude
598
- // is what the label is for. Both sizes are bound rather than read once —
599
- // the fitted size derives from the ring's radius, which isn't known until
600
- // the node has been laid out.
601
- const inside: Node2D[] = []
602
- if (wantsPercent) {
603
- inside.push(new Text({
604
- ...textOps.styleProps(s.label.insideStyle),
605
- text: () => geometry().percentText(arc.index),
606
- fontSize: () => geometry().insideFont,
607
- }))
608
- }
609
- if (wantsCategory) {
610
- inside.push(new Text({
611
- ...textOps.styleProps(s.label.insideStyle),
612
- text: () => s.slices[arc.index]?.category ?? "",
613
- fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
614
- }))
615
- }
616
- return new Rect({
617
- flow: "vertical",
618
- width: "hug",
619
- height: "hug",
620
- gap: 0,
621
- align: "center",
622
- opacity,
623
- center: () => geometry().insideAnchor(arc.index),
624
- children: inside,
625
- })
626
- }
627
-
628
- const side = sideOf(arc.mid)
629
- const at = () => geometry().placementFor(arc.index)?.text ?? { x: 0, y: 0 }
630
- const children: Node2D[] = []
631
- if (wantsCategory) {
632
- children.push(new Text({
633
- ...textOps.styleProps(s.label.textStyle),
634
- text: () => s.slices[arc.index]?.category ?? "",
635
- textAlign: side > 0 ? "left" : "right",
636
- }))
637
- }
638
- if (wantsPercent) {
639
- children.push(new Text({
640
- ...textOps.styleProps(s.label.percentStyle),
641
- text: () => geometry().percentText(arc.index),
642
- textAlign: side > 0 ? "left" : "right",
643
- }))
644
- }
645
- return new Rect({
646
- flow: "vertical",
647
- width: "hug",
648
- height: "hug",
649
- gap: 2,
650
- align: side > 0 ? "centerLeft" : "centerRight",
651
- opacity,
652
- // Exactly one of these is set; `undefined` anchors are ignored.
653
- centerLeft: side > 0 ? at : undefined,
654
- centerRight: side > 0 ? undefined : at,
655
- children,
656
- })
657
- }
658
-
659
- if (s.labelPlacement !== "none" && s.labelMode !== "none") {
660
- for (const arc of pieArcs(s.slices, s.ring.startAngle)) blocks.push(label(arc))
661
- }
662
-
663
- // The hole's summary, pinned to the ring's centre so it tracks the sideways
664
- // shift a one-sided label column puts on the ring.
665
- if (s.centerLabel !== "" || s.centerCaption !== "") {
666
- const children: Node2D[] = []
667
- if (s.centerLabel !== "") {
668
- children.push(new Text({
669
- ...textOps.styleProps(s.centerStyle.labelStyle),
670
- text: s.centerLabel,
671
- fontSize: () => geometry().centerFont,
672
- }))
673
- }
674
- if (s.centerCaption !== "") {
675
- children.push(new Text({
676
- ...textOps.styleProps(s.centerStyle.captionStyle),
677
- text: s.centerCaption,
678
- fontSize: () =>
679
- geometry().centerFont * (sizeOf(s.centerStyle.captionStyle, 22) / sizeOf(s.centerStyle.labelStyle, 46)),
680
- }))
681
- }
682
- blocks.push(new Rect({
683
- flow: "vertical",
684
- width: "hug",
685
- height: "hug",
686
- gap: 2,
687
- align: "center",
688
- opacity: () => clampUnit(s.centerReveal),
689
- center: () => ({ x: geometry().centerX, y: 0 }),
690
- children,
691
- }))
692
- }
693
- return asParts(blocks)
694
- }
695
-
696
- /**
697
- * The ring itself: one wedge per {@link PieSlice}, sized by its share of the
698
- * total, swept clockwise from the theme's `startAngle` with a `gap` of clear
699
- * space between neighbours — a *constant-width* channel, the same at the hole as
700
- * at the rim (see {@link wedgePath}). A **selected** slice reaches further out
701
- * while its hole edge stays put — so it reads as that band swelling out of the
702
- * ring rather than breaking off it — and the rest of the ring dims behind it,
703
- * leaders and labels included.
704
- *
705
- * This is the only part of the chart family that draws a path: the wedges are
706
- * filled {@link PathBuilder} polygons approximating each arc, and the leaders are
707
- * stroked polylines — both in the node's centred, y-up space, so neither depends
708
- * on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
709
- * (stacked in a hug vertical flow and pinned by their inner edge), so no
710
- * glyph-width guesswork decides where a caption starts.
711
- *
712
- * ### Label placement
713
- * Each label sits either **inside** its wedge or **outside** on a leader.
714
- *
715
- * An inside label is centred on its slice's band and leads with the number, the
716
- * caption set small underneath — inside a wedge the reader already knows which
717
- * slice they're on, so the magnitude is the thing being read. It costs no gutter
718
- * and no eye round-trip, so a ring labelled that way draws considerably bigger in
719
- * the same box.
720
- *
721
- * A leader label sits outside on the end of a polyline that lifts off its wedge,
722
- * elbows, and runs horizontally into the text. Labels are split into a left and a
723
- * right column by which way their slice's bisector points, and each column is
724
- * de-conflicted independently (see {@link relaxLabels}): a label takes the spot
725
- * nearest its wedge that still clears its neighbour, and the column is squeezed
726
- * to fit the node's height rather than spilling out of it. The elbow then rides on
727
- * a circle just outside the ring **at the label's settled y**, so the run into the
728
- * text is always horizontal and a nudged label never drags its leader back across
729
- * the ring.
730
- *
731
- * Only the sides that actually carry a leader reserve a gutter, and the ring's
732
- * centre slides into whatever the other side left over — so a ring whose slices
733
- * all leader one way doesn't leave a bald patch on the other.
734
- */
735
- export const PieRing = component2d({
736
- key: "pieRing",
737
- state: {
738
- slices: fresh<PieSlice[]>(() => []),
739
- ring: Types.custom<RingConfig>(DEFAULT_PIE_CHART_THEME.ring),
740
- label: Types.custom<LabelConfig>(DEFAULT_PIE_CHART_THEME.label),
741
- centerStyle: Types.custom<CenterConfig>(DEFAULT_PIE_CHART_THEME.center),
742
- labelMode: Types.custom<PieLabelMode>("percent"),
743
- labelPlacement: Types.custom<PieLabelPlacement>("leader"),
744
- growth: 1,
745
- labelReveal: 1,
746
- pops: fresh<number[]>(() => []),
747
- dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
748
- centerLabel: "",
749
- centerCaption: "",
750
- centerReveal: 1,
751
- },
752
- // A plain node filled its parent; one made of its labels would hug them.
753
- defaults: { width: "fill", height: "fill" },
754
- children: (state) => ringLabels(state),
755
- // The ring's paints live nowhere a paint entry would look: one fill per slice,
756
- // the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
757
- load: (loader, state) =>
758
- loadPaints(loader, {
759
- fills: ringFills(state.slices),
760
- shadows: [shadowOps.resolve(state.ring.shadow)],
761
- strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
762
- }),
763
- commands: {
764
- /**
765
- * Tween `growth` `0 → 1`, wiping the wedges in around the ring. No priming
766
- * to 0 first — the value at `t` is a function of `t`, so a repeat call
767
- * re-sweeps by definition rather than by having reset something.
768
- */
769
- sweep: (timeline) => {
770
- const ease = frameEase(timeline)
771
- return timeline.animate((t) => ({ growth: ease(t) }))
772
- },
773
- /** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
774
- revealLabels: (timeline) => {
775
- const ease = frameEase(timeline)
776
- return timeline.animate((t) => ({ labelReveal: ease(t) }))
777
- },
778
- /**
779
- * Fade the hole's summary up. Deliberately not part of the ring's entrance: a
780
- * hole label is usually the chart's conclusion, so the scene decides when it
781
- * lands.
782
- */
783
- revealCenter: (timeline, state) => {
784
- const ease = frameEase(timeline)
785
- const from = clampUnit(state.centerReveal)
786
- return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }))
787
- },
788
- },
789
- render: (ctx, state) => {
790
- const g = new RingGeometry(state, ctx.size)
791
- const rOuter = g.outerRadius
792
- const arcs = rOuter > 0 ? g.arcs : []
793
- if (arcs.length > 0) {
794
- const sweep = clampUnit(state.growth)
795
- const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep
796
- const hole = g.holeRadius
797
- const halfGap = Math.max(0, state.ring.gap) / 2
798
-
799
- // One shared frame (the whole ring square, grown slices included) pinned onto
800
- // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
801
- // own bbox and collapse them all onto the origin. It stays symmetric about the
802
- // *node's* origin — so path coordinates land exactly in node space — and is
803
- // grown by the ring's sideways offset rather than moved with it, which would
804
- // undo the very shift it has to contain.
805
- const cx = g.centerX
806
- const half = rOuter + g.maxPop + 1 + Math.abs(cx)
807
- const centerBounds: [number, number, number, number] = [-half, -half, half, half]
808
- // Explicit width/height alongside centerBounds: a wedge path's command list
809
- // carries no implied extent, so a slice filled with an image or a video needs
810
- // this to size its decode.
811
- const span = half * 2
812
-
813
- const fills = ringFills(state.slices)
814
- for (const arc of arcs) {
815
- // Clip the wedge to the growth-revealed arc; skip it until the sweep
816
- // reaches it, so the ring draws in as one clockwise wipe.
817
- const a1 = Math.min(arc.a1, revealEnd)
818
- if (a1 <= arc.a0) continue
819
-
820
- const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx)
821
- const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }))
822
- wedge.shadow(state.ring.shadow)
823
- wedge.fill(fills[arc.index] ?? [])
824
- wedge.stroke(state.ring.stroke)
825
- ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)))
826
- }
827
-
828
- // Leaders. Each polyline gets its own `Graphics2D`: several open polylines
829
- // chained into one command list are combined into a single surface before the
830
- // stroke, which closes each contour back on itself and paints the leaders as
831
- // thin triangles.
832
- const reveal = clampUnit(state.labelReveal)
833
- if (reveal > 0.001) {
834
- for (const placement of g.placements()) {
835
- ctx.draw(
836
- new Graphics2D()
837
- .line({ points: placement.leader, radius: ELBOW_RADIUS })
838
- .stroke(state.label.leaderStroke)
839
- .opacity(reveal * g.sliceOpacity(placement.index))
840
- )
841
- }
842
- }
843
- }
844
- ctx.children()
845
- },
846
- })
847
-
848
- export type PieRing = NodeOf<typeof PieRing>
1
+ import {
2
+ Graphics2D,
3
+ PathBuilder,
4
+ Rect,
5
+ Text,
6
+ Types,
7
+ component2d,
8
+ fillOps,
9
+ shadowOps,
10
+ strokeOps,
11
+ textOps,
12
+ type Fill,
13
+ type FillResolved,
14
+ type NodeOf,
15
+ type Node2D,
16
+ type Node2DProps,
17
+ type TextStyle,
18
+ type Vector2,
19
+ } from "@motionscript/sdk"
20
+
21
+ import { asParts, fresh } from "../kit/entries"
22
+ import { frameEase } from "../kit/motion"
23
+ import { loadPaints } from "../kit/paints"
24
+ import { clampUnit, formatPercent } from "../kit/shared"
25
+ import {
26
+ DEFAULT_PIE_CHART_THEME,
27
+ pieArcs,
28
+ relaxLabels,
29
+ ringPoint,
30
+ type CenterConfig,
31
+ type LabelConfig,
32
+ type PieArc,
33
+ type PieLabelMode,
34
+ type PieLabelPlacement,
35
+ type PieSlice,
36
+ type RingConfig,
37
+ } from "./shared"
38
+
39
+ export interface PieRingProps extends Node2DProps {
40
+ /** One entry per slice: its `category`, `value` and `fill`. */
41
+ slices: PieSlice[]
42
+ /** Ring geometry and paint — gap, hole, start angle, selection growth. */
43
+ ring: RingConfig
44
+ /** How the labels are set, and the leader lines that point at them. */
45
+ label: LabelConfig
46
+ /**
47
+ * How the hole's summary is set.
48
+ *
49
+ * Named `centerStyle` rather than `center` because every node already has a
50
+ * `center` — the anchor its position is measured from.
51
+ */
52
+ centerStyle: CenterConfig
53
+ /** What the labels say, or `"none"` to drop them entirely. */
54
+ labelMode: PieLabelMode
55
+ /** Where the labels sit: inside their wedge, outside on a leader, or nowhere. */
56
+ labelPlacement: PieLabelPlacement
57
+ /**
58
+ * Sweep of the ring in `[0, 1]`: 0 draws nothing, 1 the full circle. The
59
+ * wedges grow around clockwise from the ring's `startAngle` as this rises.
60
+ */
61
+ growth: number
62
+ /**
63
+ * Fade of the leader lines and labels in `[0, 1]`, held separate from
64
+ * {@link growth} so they arrive *after* the ring has swept in.
65
+ */
66
+ labelReveal: number
67
+ /**
68
+ * How far each slice is currently pushed out of the ring, `0`–`1` per index.
69
+ * Owned by the chart, so the ring and the legend read one signal.
70
+ */
71
+ pops: number[]
72
+ /** Opacity everything *unselected* falls back to while any slice is selected. */
73
+ dimOpacity: number
74
+ /**
75
+ * Text set in the middle of the ring's hole — the largest uninterrupted area
76
+ * the chart owns, and on a donut otherwise empty. A one-line summary there
77
+ * routinely does more work than the whole ring around it. Empty (the default)
78
+ * leaves the hole clear; ignored when there is no hole.
79
+ */
80
+ centerLabel: string
81
+ /** Smaller second line under {@link centerLabel}. Empty (the default) drops it. */
82
+ centerCaption: string
83
+ /**
84
+ * Fade of the hole's summary in `[0, 1]`. Its own signal, untouched by the
85
+ * ring's entrance — a hole label is usually a *conclusion*, so a scene that
86
+ * wants it to land on a later beat sets this to 0 and calls
87
+ * `PieChart.revealCenter` when it gets there.
88
+ */
89
+ centerReveal: number
90
+ }
91
+
92
+ /** Vertical breathing room between one label block and the next, in px. */
93
+ const LABEL_GAP = 12
94
+ /** Breathing room past the outermost label, so it never touches the node's edge, in px. */
95
+ const LABEL_PAD = 14
96
+ /** Rough width of one glyph as a fraction of the font size, for gutter sizing. */
97
+ const GLYPH_RATIO = 0.62
98
+ /** Gap between a wedge's outer edge and where its leader lifts off, in px. */
99
+ const LEADER_LIFTOFF = 6
100
+ /** How far past the ring's outer edge a leader runs before it elbows, in px. */
101
+ const LEADER_LENGTH = 34
102
+ /** Horizontal run from the elbow to the label, in px. */
103
+ const LABEL_STUB = 26
104
+ /** Gap between a leader's end and the label text it points at, in px. */
105
+ const TEXT_INSET = 8
106
+ /**
107
+ * Floor on the elbow's horizontal distance from the ring's axis, as a fraction
108
+ * of the leader radius — so a label pushed clear above (or below) the ring still
109
+ * elbows out sideways rather than running straight down the 12 o'clock line.
110
+ */
111
+ const MIN_ELBOW = 0.18
112
+ /** Segments used to tessellate a full turn of arc; a wedge takes its share. */
113
+ const ARC_STEPS = 240
114
+ /** Rounding applied to the leader's elbow, in px. */
115
+ const ELBOW_RADIUS = 8
116
+ /** Side padding used when nothing is leadered out — no gutter to reserve. */
117
+ const BARE_PAD = 2
118
+ /** An inside label's caption size, as a fraction of its leading line. */
119
+ const INSIDE_CAPTION_RATIO = 0.44
120
+ /**
121
+ * Where an inside label sits on a **solid pie** (no hole to split the band
122
+ * with), as a fraction of the wedge's radius — far enough out that the label
123
+ * clears the point every wedge converges on.
124
+ */
125
+ const PIE_LABEL_RADIUS = 0.62
126
+ /** Fraction of the hole's diameter its summary is allowed to run to. */
127
+ const CENTER_LABEL_FIT = 0.78
128
+ /** Fraction of the room inside a wedge that its label is allowed to run to. */
129
+ const INSIDE_LABEL_FIT = 0.9
130
+ /** Below this, a wedge's bisector counts as axis-aligned and that term stops binding. */
131
+ const AXIS_EPSILON = 1e-3
132
+ /** Fallback type size when a theme style leaves it unset or on autofit. */
133
+ const FALLBACK_SIZE = 26
134
+
135
+ /** A style's numeric size, guarding the autofit and unset cases. */
136
+ function sizeOf(style: TextStyle, fallback = FALLBACK_SIZE): number {
137
+ const size = style.fontSize
138
+ return typeof size === "number" ? size : fallback
139
+ }
140
+
141
+ /** Where one slice's label sits and the polyline that points at it. */
142
+ interface LabelPlacement {
143
+ /** Index of the slice. */
144
+ index: number
145
+ /** Which side of the ring the label sits on: +1 right, −1 left. */
146
+ side: 1 | -1
147
+ /** Leader polyline: wedge edge → elbow → label. */
148
+ leader: Vector2[]
149
+ /** Point the label's inner edge is pinned to. */
150
+ text: Vector2
151
+ }
152
+
153
+ /**
154
+ * A ring-band wedge over `a0`→`a1` (radians, clockwise from 12 o'clock) spanning
155
+ * `rInner`→`rOuter`, centred on `cx`. Ring wedges trace the outer arc, step in,
156
+ * and trace the inner arc back; full pie wedges (`rInner <= 0`) close through the
157
+ * centre.
158
+ *
159
+ * `halfGap` is **half the gap to the neighbouring slice, in px**, shaved off both
160
+ * ends of the wedge. It's converted to an angle *per radius* (`halfGap / r` — a
161
+ * wider angle near the hole than out at the rim), which is what keeps the channel
162
+ * between two slices the same width all the way across the band. A fixed angular
163
+ * gap instead reads as a thin pizza slice: right at the rim, pinched shut at the
164
+ * hole. The inset is capped at the wedge's own half-span so a sliver of a slice
165
+ * narrows to nothing rather than turning inside out.
166
+ */
167
+ function wedgePath(
168
+ a0: number,
169
+ a1: number,
170
+ rInner: number,
171
+ rOuter: number,
172
+ halfGap: number,
173
+ cx: number
174
+ ): PathBuilder {
175
+ const path = new PathBuilder()
176
+ const span = a1 - a0
177
+ const steps = Math.max(
178
+ 2,
179
+ Math.ceil((Math.abs(span) / (Math.PI * 2)) * ARC_STEPS)
180
+ )
181
+ /** Angular inset at radius `r` that puts the wedge's edge `halfGap` px off the divide. */
182
+ const insetAt = (r: number): number =>
183
+ r <= 0 ? 0 : Math.min(halfGap / r, span / 2.2)
184
+ const arcTo = (r: number, i: number): Vector2 => {
185
+ const inset = insetAt(r)
186
+ const from = a0 + inset
187
+ const to = a1 - inset
188
+ const point = ringPoint(r, from + ((to - from) * i) / steps)
189
+ return { x: point.x + cx, y: point.y }
190
+ }
191
+
192
+ if (rInner <= 0) {
193
+ // Solid pie wedge: the sides converge on the centre, so only the outer rim
194
+ // carries the gap.
195
+ path.moveTo(cx, 0)
196
+ for (let i = 0; i <= steps; i++) {
197
+ const p = arcTo(rOuter, i)
198
+ path.lineTo(p.x, p.y)
199
+ }
200
+ path.close()
201
+ return path
202
+ }
203
+
204
+ for (let i = 0; i <= steps; i++) {
205
+ const p = arcTo(rOuter, i)
206
+ if (i === 0) path.moveTo(p.x, p.y)
207
+ else path.lineTo(p.x, p.y)
208
+ }
209
+ // Back along the inner arc — its own (wider) inset makes each side edge a
210
+ // straight chord sitting `halfGap` px off the divide at both ends.
211
+ for (let i = steps; i >= 0; i--) {
212
+ const p = arcTo(rInner, i)
213
+ path.lineTo(p.x, p.y)
214
+ }
215
+ path.close()
216
+ return path
217
+ }
218
+
219
+ /** What the ring's geometry reads off its entries. */
220
+ interface RingState {
221
+ readonly slices: PieSlice[]
222
+ readonly ring: RingConfig
223
+ readonly label: LabelConfig
224
+ readonly centerStyle: CenterConfig
225
+ readonly labelMode: PieLabelMode
226
+ readonly labelPlacement: PieLabelPlacement
227
+ readonly pops: readonly number[]
228
+ readonly dimOpacity: number
229
+ readonly centerLabel: string
230
+ readonly centerCaption: string
231
+ }
232
+
233
+ /**
234
+ * The ring's geometry for one box: every radius, gutter and label position,
235
+ * derived from the entries and the size the ring was laid out at, and asked
236
+ * afresh wherever it is read — the drawing, and the labels' bound positions
237
+ * and sizes alike, so the two cannot disagree.
238
+ */
239
+ export class RingGeometry {
240
+ constructor(
241
+ private readonly s: RingState,
242
+ private readonly box: { readonly width: number; readonly height: number }
243
+ ) { }
244
+
245
+ /** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
246
+ get arcs(): PieArc[] {
247
+ return pieArcs(this.s.slices, this.s.ring.startAngle)
248
+ }
249
+
250
+ /** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
251
+ get hasLeaderLabels(): boolean {
252
+ return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0
253
+ }
254
+
255
+ // ---- Label sizing ------------------------------------------------------
256
+
257
+ /**
258
+ * How wide a horizontal label centred in slice `arc`'s band can run before it
259
+ * leaves the wedge, in px.
260
+ *
261
+ * Two things bound it, and which one binds depends entirely on where the wedge
262
+ * sits. Stepping sideways from the label's centre moves you *radially* by
263
+ * `dx·sin(mid)` and *tangentially* by `dx·cos(mid)`, so a wedge at 3 o'clock is
264
+ * limited by the band's thickness (its label runs straight across the band)
265
+ * while a wedge at 12 o'clock is limited by its own arc (its label runs along
266
+ * the band, which is much roomier). Taking the smaller of the two is what stops
267
+ * a label overflowing the ring on one chart and being pointlessly shrunk on the
268
+ * next.
269
+ *
270
+ * Measured off the *unpopped* radius, so a slice growing out of the ring
271
+ * doesn't resize its own text mid-animation.
272
+ */
273
+ insideRoom(arc: PieArc): number {
274
+ const outer = this.outerRadius
275
+ const hole = this.holeRadius
276
+ const band = hole > 0 ? outer - hole : outer * (1 - PIE_LABEL_RADIUS) * 2
277
+ const mid = hole > 0 ? (hole + outer) / 2 : outer * PIE_LABEL_RADIUS
278
+ const sin = Math.abs(Math.sin(arc.mid))
279
+ const cos = Math.abs(Math.cos(arc.mid))
280
+ const acrossBand = sin > AXIS_EPSILON ? band / sin : Infinity
281
+ const alongArc = cos > AXIS_EPSILON ? ((arc.a1 - arc.a0) * mid) / cos : Infinity
282
+ return Math.max(0, Math.min(acrossBand, alongArc))
283
+ }
284
+
285
+ /** Widest line, in glyphs at the leading line's size, an inside label runs to. */
286
+ insideGlyphs(arc: PieArc): number {
287
+ const mode = this.s.labelMode
288
+ const category = this.s.slices[arc.index]?.category.length ?? 0
289
+ const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0
290
+ // The caption is set at a fraction of the leading line, except when it *is*
291
+ // the leading line.
292
+ const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0
293
+ return Math.max(1, percent, caption)
294
+ }
295
+
296
+ /**
297
+ * Font size of an inside label's leading line: what the theme asked for,
298
+ * shrunk if that wouldn't fit inside its wedge. **One size across every inside
299
+ * label** — the smallest any of them needs — because labels at mismatched
300
+ * sizes read as a mistake rather than as emphasis.
301
+ *
302
+ * So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
303
+ * for the size the chart wants and the ring keeps it legal.
304
+ */
305
+ get insideFont(): number {
306
+ let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9)
307
+ for (const arc of this.arcs) {
308
+ const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO)
309
+ size = Math.min(size, fits)
310
+ }
311
+ return Math.max(1, size)
312
+ }
313
+
314
+ /**
315
+ * Font size of the hole's summary: the configured size, capped so it still
316
+ * fits across the hole. The cap matters because the hole's radius derives from
317
+ * the box, so a chart that reflows smaller would otherwise push its summary out
318
+ * through the ring.
319
+ */
320
+ get centerFont(): number {
321
+ const { centerStyle, centerLabel, centerCaption } = this.s
322
+ const wanted = sizeOf(centerStyle.labelStyle, 46)
323
+ const hole = this.holeRadius
324
+ if (hole <= 0) return wanted
325
+ const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted)
326
+ // Both lines have to clear the hole, and the caption is set at a fraction of
327
+ // this size — so what binds is whichever is wider once drawn, not whichever
328
+ // has more characters.
329
+ const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio)
330
+ return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO))
331
+ }
332
+
333
+ // ---- Ring geometry, all derived from the box ---------------------------
334
+
335
+ /** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
336
+ get growFactor(): number {
337
+ return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow))
338
+ }
339
+
340
+ /** How far past the ring's rim a fully selected slice reaches, in px. */
341
+ get maxPop(): number {
342
+ return this.outerRadius * (this.growFactor - 1)
343
+ }
344
+
345
+ /** Height of one **leader** label block, in px — the unit those columns are spaced by. */
346
+ get labelRowHeight(): number {
347
+ // Inside labels are stacked in the band, not in a column beside the ring, so
348
+ // they cost no vertical reserve.
349
+ if (!this.hasLeaderLabels) return 0
350
+ const caption = sizeOf(this.s.label.textStyle) * 1.25
351
+ const percent = sizeOf(this.s.label.percentStyle) * 1.25
352
+ if (this.s.labelMode === "both") return caption + percent + 2
353
+ return this.s.labelMode === "category" ? caption : percent
354
+ }
355
+
356
+ /** Widest line, in glyphs, that a leader label for slice `index` can run to. */
357
+ captionGlyphs(index: number): number {
358
+ const mode = this.s.labelMode
359
+ // "100%" is the widest a percentage gets; captions are their own length.
360
+ const percent = mode === "percent" || mode === "both" ? 4 : 0
361
+ const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0
362
+ return Math.max(percent, category)
363
+ }
364
+
365
+ /**
366
+ * Horizontal room reserved on each side for the leaders and their labels, in
367
+ * px — measured **per side**, from the slices that actually leader out that
368
+ * way. A ring whose labels are all inside reserves nothing and draws bigger.
369
+ *
370
+ * Excludes the ring's own selection-growth allowance, which scales with the
371
+ * radius and so is divided out in {@link outerRadius} instead.
372
+ */
373
+ get labelGutters(): { left: number; right: number } {
374
+ const gutters = { left: BARE_PAD, right: BARE_PAD }
375
+ if (!this.hasLeaderLabels) return gutters
376
+ const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO
377
+ for (const arc of this.arcs) {
378
+ const room = LEADER_LENGTH + LABEL_STUB + TEXT_INSET + this.captionGlyphs(arc.index) * glyph + LABEL_PAD
379
+ const key = sideOf(arc.mid) > 0 ? "right" : "left"
380
+ gutters[key] = Math.max(gutters[key], room)
381
+ }
382
+ return gutters
383
+ }
384
+
385
+ /**
386
+ * How far the ring's centre sits from the node's, in px. Zero while the
387
+ * gutters match; when the labels all fall one way it slides the ring into the
388
+ * empty half so the ring-plus-labels group stays centred in the box instead of
389
+ * the ring sitting dead centre with a bald patch beside it.
390
+ */
391
+ get centerX(): number {
392
+ const { left, right } = this.labelGutters
393
+ return (left - right) / 2
394
+ }
395
+
396
+ /**
397
+ * Radius of the ring's outer edge, fitted to whatever the label gutters leave.
398
+ * A fully selected slice reaches `radius × growFactor`, so the room has to
399
+ * cover that — hence the divisor, which keeps even the grown slice inside the
400
+ * box (and holds the ring the same size whether or not anything is selected).
401
+ */
402
+ get outerRadius(): number {
403
+ const { left, right } = this.labelGutters
404
+ const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4)
405
+ return Math.max(0, room / this.growFactor)
406
+ }
407
+
408
+ /** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
409
+ get holeRadius(): number {
410
+ const configured = this.s.ring.holeRadius
411
+ if (configured <= 0) return 0
412
+ const px = configured < 1 ? configured * this.outerRadius : configured
413
+ return Math.min(px, this.outerRadius * 0.95)
414
+ }
415
+
416
+ /** Radius of the circle every leader elbows on, clear of even a popped-out slice. */
417
+ get elbowRadius(): number {
418
+ return this.outerRadius + this.maxPop + LEADER_LENGTH
419
+ }
420
+
421
+ /** How far slice `index` is currently pushed out, in `[0, 1]`. */
422
+ popOf(index: number): number {
423
+ return clampUnit(this.s.pops[index] ?? 0)
424
+ }
425
+
426
+ /** Radius of slice `index`'s outer edge, its selection growth included. */
427
+ anchorRadius(index: number): number {
428
+ return this.outerRadius + this.maxPop * this.popOf(index)
429
+ }
430
+
431
+ /**
432
+ * Radius an inside label sits at on slice `index`: halfway across the band on a
433
+ * donut, and further out than halfway on a solid pie, where "halfway" would
434
+ * crowd the point every wedge converges on. Rides the slice's own pop, so an
435
+ * inside label travels out with the wedge it belongs to.
436
+ */
437
+ bandMidRadius(index: number): number {
438
+ const rim = this.anchorRadius(index)
439
+ const hole = this.holeRadius
440
+ return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS
441
+ }
442
+
443
+ /** The point slice `index`'s inside label is centred on. */
444
+ insideAnchor(index: number): Vector2 {
445
+ const arc = this.arcs.find((a) => a.index === index)
446
+ if (arc === undefined) return { x: this.centerX, y: 0 }
447
+ const point = ringPoint(this.bandMidRadius(index), arc.mid)
448
+ return { x: point.x + this.centerX, y: point.y }
449
+ }
450
+
451
+ /**
452
+ * How selected the ring is overall, in `[0, 1]` — the most-grown slice. Rides
453
+ * the same tween the pops do, so the dim below arrives with the growth rather
454
+ * than needing an animation of its own.
455
+ */
456
+ get selectionStrength(): number {
457
+ let strongest = 0
458
+ for (let i = 0; i < this.s.slices.length; i++) strongest = Math.max(strongest, this.popOf(i))
459
+ return strongest
460
+ }
461
+
462
+ /**
463
+ * Opacity slice `index` (and its leader and label) is drawn at: full while
464
+ * nothing is selected, falling toward `dimOpacity` in proportion to how
465
+ * selected the *rest* of the ring is. A slice that's selected itself stays at
466
+ * full — the two factors cancel.
467
+ */
468
+ sliceOpacity(index: number): number {
469
+ const focus = this.selectionStrength
470
+ if (focus <= 0) return 1
471
+ return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index))
472
+ }
473
+
474
+ /** Slice `index`'s share of the ring as its label reads it, off the live values. */
475
+ percentText(index: number): string {
476
+ const arc = this.arcs.find((a) => a.index === index)
477
+ return formatPercent(arc?.fraction ?? 0)
478
+ }
479
+
480
+ // ---- Label placement ---------------------------------------------------
481
+
482
+ /**
483
+ * Where every leader label sits and how its leader gets there. Labels are split
484
+ * into a right and a left column by their slice's bisector, each column is
485
+ * spread so nothing overlaps (and nothing leaves the box), and each leader is
486
+ * then built as wedge edge → elbow → label. The elbow rides the leader circle at
487
+ * the label's *settled* y, which keeps the final run horizontal and the whole
488
+ * polyline outside the ring however far the label was nudged.
489
+ */
490
+ placements(): LabelPlacement[] {
491
+ if (!this.hasLeaderLabels) return []
492
+ const arcs = this.arcs
493
+ if (arcs.length === 0 || this.outerRadius <= 0) return []
494
+
495
+ const radius = this.elbowRadius
496
+ const rowHeight = this.labelRowHeight
497
+ const top = this.box.height / 2 - rowHeight / 2
498
+ const bottom = -top
499
+ const cx = this.centerX
500
+
501
+ interface Slot {
502
+ index: number
503
+ side: 1 | -1
504
+ mid: number
505
+ ideal: number
506
+ y: number
507
+ }
508
+ const right: Slot[] = []
509
+ const left: Slot[] = []
510
+ for (const arc of arcs) {
511
+ const side = sideOf(arc.mid)
512
+ const ideal = ringPoint(radius, arc.mid).y
513
+ ;(side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal })
514
+ }
515
+ relaxLabels(right, rowHeight + LABEL_GAP, top, bottom)
516
+ relaxLabels(left, rowHeight + LABEL_GAP, top, bottom)
517
+
518
+ return [...right, ...left].map((slot) => {
519
+ const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid)
520
+ // The elbow sits where the label's y crosses the leader circle. Solving
521
+ // x² + y² = radius² for x keeps it *on* that circle (never inside the
522
+ // ring); a label pushed past the circle's top or bottom falls back to the
523
+ // sideways floor so its leader still reads as pointing outward.
524
+ const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y))
525
+ const elbowX = cx + slot.side * Math.max(onCircle, radius * MIN_ELBOW)
526
+ const endX = cx + slot.side * (radius + LABEL_STUB)
527
+
528
+ return {
529
+ index: slot.index,
530
+ side: slot.side,
531
+ leader: [
532
+ { x: start.x + cx, y: start.y },
533
+ { x: elbowX, y: slot.y },
534
+ { x: endX, y: slot.y },
535
+ ],
536
+ text: { x: endX + slot.side * TEXT_INSET, y: slot.y },
537
+ }
538
+ })
539
+ }
540
+
541
+ /** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
542
+ placementFor(index: number): LabelPlacement | undefined {
543
+ return this.placements().find((placement) => placement.index === index)
544
+ }
545
+ }
546
+
547
+ /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
548
+ function sideOf(mid: number): 1 | -1 {
549
+ return Math.sin(mid) >= 0 ? 1 : -1
550
+ }
551
+
552
+ const NO_BOX = { width: 0, height: 0 }
553
+
554
+ /**
555
+ * Each slice's fill, resolved once per paint rather than every frame: the slices
556
+ * are bound to the chart's rows and palette, so a theme tween hands over new
557
+ * paint and the drawing must see it, but a frame that moves nothing must not
558
+ * re-resolve. Keyed by the paint itself, so it is shared and needs no node.
559
+ */
560
+ const resolvedFills = new WeakMap<object, FillResolved[]>()
561
+
562
+ function resolvedFill(fill: Fill): FillResolved[] {
563
+ if (typeof fill !== "object" || fill === null) return fillOps.resolve(fill)
564
+ let resolved = resolvedFills.get(fill)
565
+ if (resolved === undefined) resolvedFills.set(fill, resolved = fillOps.resolve(fill))
566
+ return resolved
567
+ }
568
+
569
+ /** Each slice's fill, resolved. */
570
+ export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
571
+ return slices.map((slice) => resolvedFill(slice.fill))
572
+ }
573
+
574
+ /**
575
+ * The labels a ring was built with: one per *drawn* slice (a zero-valued slice
576
+ * gets no wedge, so no label either), in whichever of the two forms the chart
577
+ * asked for, then the hole's summary. Both are real Text stacked in a hug
578
+ * vertical flow — the difference is only what they are pinned to and which line
579
+ * leads. The point each is pinned to stays reactive and follows the ring as the
580
+ * box resizes or its slice pops out; only the choice of form, which depends on
581
+ * entries alone, is settled here.
582
+ */
583
+ function ringLabels(s: RingState & { readonly labelReveal: number; readonly centerReveal: number }) {
584
+ const blocks: Node2D[] = []
585
+ // A label block's parent is the ring: its box is what the ring was laid out at.
586
+ const geometry = () => new RingGeometry(s, (blocks[0]?.parent as Node2D | null | undefined)?.layoutBounds ?? NO_BOX)
587
+
588
+ const label = (arc: PieArc): Node2D => {
589
+ // Fades in with the ring's labels, then dims along with its own slice
590
+ // whenever another slice takes the focus.
591
+ const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index)
592
+ const wantsCategory = s.labelMode === "both" || s.labelMode === "category"
593
+ const wantsPercent = s.labelMode === "both" || s.labelMode === "percent"
594
+
595
+ if (s.labelPlacement === "inside") {
596
+ // The number leads and the caption sits under it: inside a wedge the
597
+ // reader already knows *which* wedge they are looking at, so the magnitude
598
+ // is what the label is for. Both sizes are bound rather than read once —
599
+ // the fitted size derives from the ring's radius, which isn't known until
600
+ // the node has been laid out.
601
+ const inside: Node2D[] = []
602
+ if (wantsPercent) {
603
+ inside.push(new Text({
604
+ ...textOps.styleProps(s.label.insideStyle),
605
+ text: () => geometry().percentText(arc.index),
606
+ fontSize: () => geometry().insideFont,
607
+ }))
608
+ }
609
+ if (wantsCategory) {
610
+ inside.push(new Text({
611
+ ...textOps.styleProps(s.label.insideStyle),
612
+ text: () => s.slices[arc.index]?.category ?? "",
613
+ fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
614
+ }))
615
+ }
616
+ return new Rect({
617
+ flow: "vertical",
618
+ width: "hug",
619
+ height: "hug",
620
+ gap: 0,
621
+ align: "center",
622
+ opacity,
623
+ center: () => geometry().insideAnchor(arc.index),
624
+ children: inside,
625
+ })
626
+ }
627
+
628
+ const side = sideOf(arc.mid)
629
+ const at = () => geometry().placementFor(arc.index)?.text ?? { x: 0, y: 0 }
630
+ const children: Node2D[] = []
631
+ if (wantsCategory) {
632
+ children.push(new Text({
633
+ ...textOps.styleProps(s.label.textStyle),
634
+ text: () => s.slices[arc.index]?.category ?? "",
635
+ textAlign: side > 0 ? "left" : "right",
636
+ }))
637
+ }
638
+ if (wantsPercent) {
639
+ children.push(new Text({
640
+ ...textOps.styleProps(s.label.percentStyle),
641
+ text: () => geometry().percentText(arc.index),
642
+ textAlign: side > 0 ? "left" : "right",
643
+ }))
644
+ }
645
+ return new Rect({
646
+ flow: "vertical",
647
+ width: "hug",
648
+ height: "hug",
649
+ gap: 2,
650
+ align: side > 0 ? "centerLeft" : "centerRight",
651
+ opacity,
652
+ // Exactly one of these is set; `undefined` anchors are ignored.
653
+ centerLeft: side > 0 ? at : undefined,
654
+ centerRight: side > 0 ? undefined : at,
655
+ children,
656
+ })
657
+ }
658
+
659
+ if (s.labelPlacement !== "none" && s.labelMode !== "none") {
660
+ for (const arc of pieArcs(s.slices, s.ring.startAngle)) blocks.push(label(arc))
661
+ }
662
+
663
+ // The hole's summary, pinned to the ring's centre so it tracks the sideways
664
+ // shift a one-sided label column puts on the ring.
665
+ if (s.centerLabel !== "" || s.centerCaption !== "") {
666
+ const children: Node2D[] = []
667
+ if (s.centerLabel !== "") {
668
+ children.push(new Text({
669
+ ...textOps.styleProps(s.centerStyle.labelStyle),
670
+ text: s.centerLabel,
671
+ fontSize: () => geometry().centerFont,
672
+ }))
673
+ }
674
+ if (s.centerCaption !== "") {
675
+ children.push(new Text({
676
+ ...textOps.styleProps(s.centerStyle.captionStyle),
677
+ text: s.centerCaption,
678
+ fontSize: () =>
679
+ geometry().centerFont * (sizeOf(s.centerStyle.captionStyle, 22) / sizeOf(s.centerStyle.labelStyle, 46)),
680
+ }))
681
+ }
682
+ blocks.push(new Rect({
683
+ flow: "vertical",
684
+ width: "hug",
685
+ height: "hug",
686
+ gap: 2,
687
+ align: "center",
688
+ opacity: () => clampUnit(s.centerReveal),
689
+ center: () => ({ x: geometry().centerX, y: 0 }),
690
+ children,
691
+ }))
692
+ }
693
+ return asParts(blocks)
694
+ }
695
+
696
+ /**
697
+ * The ring itself: one wedge per {@link PieSlice}, sized by its share of the
698
+ * total, swept clockwise from the theme's `startAngle` with a `gap` of clear
699
+ * space between neighbours — a *constant-width* channel, the same at the hole as
700
+ * at the rim (see {@link wedgePath}). A **selected** slice reaches further out
701
+ * while its hole edge stays put — so it reads as that band swelling out of the
702
+ * ring rather than breaking off it — and the rest of the ring dims behind it,
703
+ * leaders and labels included.
704
+ *
705
+ * This is the only part of the chart family that draws a path: the wedges are
706
+ * filled {@link PathBuilder} polygons approximating each arc, and the leaders are
707
+ * stroked polylines — both in the node's centred, y-up space, so neither depends
708
+ * on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
709
+ * (stacked in a hug vertical flow and pinned by their inner edge), so no
710
+ * glyph-width guesswork decides where a caption starts.
711
+ *
712
+ * ### Label placement
713
+ * Each label sits either **inside** its wedge or **outside** on a leader.
714
+ *
715
+ * An inside label is centred on its slice's band and leads with the number, the
716
+ * caption set small underneath — inside a wedge the reader already knows which
717
+ * slice they're on, so the magnitude is the thing being read. It costs no gutter
718
+ * and no eye round-trip, so a ring labelled that way draws considerably bigger in
719
+ * the same box.
720
+ *
721
+ * A leader label sits outside on the end of a polyline that lifts off its wedge,
722
+ * elbows, and runs horizontally into the text. Labels are split into a left and a
723
+ * right column by which way their slice's bisector points, and each column is
724
+ * de-conflicted independently (see {@link relaxLabels}): a label takes the spot
725
+ * nearest its wedge that still clears its neighbour, and the column is squeezed
726
+ * to fit the node's height rather than spilling out of it. The elbow then rides on
727
+ * a circle just outside the ring **at the label's settled y**, so the run into the
728
+ * text is always horizontal and a nudged label never drags its leader back across
729
+ * the ring.
730
+ *
731
+ * Only the sides that actually carry a leader reserve a gutter, and the ring's
732
+ * centre slides into whatever the other side left over — so a ring whose slices
733
+ * all leader one way doesn't leave a bald patch on the other.
734
+ */
735
+ export const PieRing = component2d({
736
+ key: "pieRing",
737
+ state: {
738
+ slices: fresh<PieSlice[]>(() => []),
739
+ ring: Types.custom<RingConfig>(DEFAULT_PIE_CHART_THEME.ring),
740
+ label: Types.custom<LabelConfig>(DEFAULT_PIE_CHART_THEME.label),
741
+ centerStyle: Types.custom<CenterConfig>(DEFAULT_PIE_CHART_THEME.center),
742
+ labelMode: Types.custom<PieLabelMode>("percent"),
743
+ labelPlacement: Types.custom<PieLabelPlacement>("leader"),
744
+ growth: 1,
745
+ labelReveal: 1,
746
+ pops: fresh<number[]>(() => []),
747
+ dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
748
+ centerLabel: "",
749
+ centerCaption: "",
750
+ centerReveal: 1,
751
+ },
752
+ // A plain node filled its parent; one made of its labels would hug them.
753
+ defaults: { width: "fill", height: "fill" },
754
+ children: (state) => ringLabels(state),
755
+ // The ring's paints live nowhere a paint entry would look: one fill per slice,
756
+ // the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
757
+ load: (loader, state) =>
758
+ loadPaints(loader, {
759
+ fills: ringFills(state.slices),
760
+ shadows: [shadowOps.resolve(state.ring.shadow)],
761
+ strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
762
+ }),
763
+ commands: {
764
+ /**
765
+ * Tween `growth` `0 → 1`, wiping the wedges in around the ring. No priming
766
+ * to 0 first — the value at `t` is a function of `t`, so a repeat call
767
+ * re-sweeps by definition rather than by having reset something.
768
+ */
769
+ sweep: (timeline) => {
770
+ const ease = frameEase(timeline)
771
+ return timeline.animate((t) => ({ growth: ease(t) }))
772
+ },
773
+ /** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
774
+ revealLabels: (timeline) => {
775
+ const ease = frameEase(timeline)
776
+ return timeline.animate((t) => ({ labelReveal: ease(t) }))
777
+ },
778
+ /**
779
+ * Fade the hole's summary up. Deliberately not part of the ring's entrance: a
780
+ * hole label is usually the chart's conclusion, so the scene decides when it
781
+ * lands.
782
+ */
783
+ revealCenter: (timeline, state) => {
784
+ const ease = frameEase(timeline)
785
+ const from = clampUnit(state.centerReveal)
786
+ return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }))
787
+ },
788
+ },
789
+ render: (ctx, state) => {
790
+ const g = new RingGeometry(state, ctx.size)
791
+ const rOuter = g.outerRadius
792
+ const arcs = rOuter > 0 ? g.arcs : []
793
+ if (arcs.length > 0) {
794
+ const sweep = clampUnit(state.growth)
795
+ const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep
796
+ const hole = g.holeRadius
797
+ const halfGap = Math.max(0, state.ring.gap) / 2
798
+
799
+ // One shared frame (the whole ring square, grown slices included) pinned onto
800
+ // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
801
+ // own bbox and collapse them all onto the origin. It stays symmetric about the
802
+ // *node's* origin — so path coordinates land exactly in node space — and is
803
+ // grown by the ring's sideways offset rather than moved with it, which would
804
+ // undo the very shift it has to contain.
805
+ const cx = g.centerX
806
+ const half = rOuter + g.maxPop + 1 + Math.abs(cx)
807
+ const centerBounds: [number, number, number, number] = [-half, -half, half, half]
808
+ // Explicit width/height alongside centerBounds: a wedge path's command list
809
+ // carries no implied extent, so a slice filled with an image or a video needs
810
+ // this to size its decode.
811
+ const span = half * 2
812
+
813
+ const fills = ringFills(state.slices)
814
+ for (const arc of arcs) {
815
+ // Clip the wedge to the growth-revealed arc; skip it until the sweep
816
+ // reaches it, so the ring draws in as one clockwise wipe.
817
+ const a1 = Math.min(arc.a1, revealEnd)
818
+ if (a1 <= arc.a0) continue
819
+
820
+ const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx)
821
+ const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }))
822
+ wedge.shadow(state.ring.shadow)
823
+ wedge.fill(fills[arc.index] ?? [])
824
+ wedge.stroke(state.ring.stroke)
825
+ ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)))
826
+ }
827
+
828
+ // Leaders. Each polyline gets its own `Graphics2D`: several open polylines
829
+ // chained into one command list are combined into a single surface before the
830
+ // stroke, which closes each contour back on itself and paints the leaders as
831
+ // thin triangles.
832
+ const reveal = clampUnit(state.labelReveal)
833
+ if (reveal > 0.001) {
834
+ for (const placement of g.placements()) {
835
+ ctx.draw(
836
+ new Graphics2D()
837
+ .line({ points: placement.leader, radius: ELBOW_RADIUS })
838
+ .stroke(state.label.leaderStroke)
839
+ .opacity(reveal * g.sliceOpacity(placement.index))
840
+ )
841
+ }
842
+ }
843
+ }
844
+ ctx.children()
845
+ },
846
+ })
847
+
848
+ export type PieRing = NodeOf<typeof PieRing>