@motionscript/charts 0.1.0-alpha.5 → 0.1.0-alpha.6

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 (141) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +30 -16
  3. package/dist/bar-chart/bar-chart.d.ts +93 -246
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +378 -569
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +24 -41
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +93 -115
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +36 -70
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +156 -210
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +2 -2
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +1 -1
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +3 -3
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +3 -3
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
  24. package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +3 -3
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -6
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -6
  32. package/dist/engine.js.map +1 -1
  33. package/dist/kit/cartesian.d.ts +44 -0
  34. package/dist/kit/cartesian.d.ts.map +1 -0
  35. package/dist/kit/cartesian.js +59 -0
  36. package/dist/kit/cartesian.js.map +1 -0
  37. package/dist/kit/chart-border.d.ts +8 -11
  38. package/dist/kit/chart-border.d.ts.map +1 -1
  39. package/dist/kit/chart-border.js +15 -32
  40. package/dist/kit/chart-border.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +23 -21
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +33 -90
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +79 -67
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +129 -154
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +17 -0
  50. package/dist/kit/entries.d.ts.map +1 -0
  51. package/dist/kit/entries.js +19 -0
  52. package/dist/kit/entries.js.map +1 -0
  53. package/dist/kit/motion.d.ts +48 -0
  54. package/dist/kit/motion.d.ts.map +1 -0
  55. package/dist/kit/motion.js +63 -0
  56. package/dist/kit/motion.js.map +1 -0
  57. package/dist/kit/paints.d.ts +10 -0
  58. package/dist/kit/paints.d.ts.map +1 -0
  59. package/dist/kit/paints.js +12 -0
  60. package/dist/kit/paints.js.map +1 -0
  61. package/dist/kit/shared.d.ts +31 -3
  62. package/dist/kit/shared.d.ts.map +1 -1
  63. package/dist/kit/shared.js +42 -3
  64. package/dist/kit/shared.js.map +1 -1
  65. package/dist/kit/vertical-axis.d.ts +36 -61
  66. package/dist/kit/vertical-axis.d.ts.map +1 -1
  67. package/dist/kit/vertical-axis.js +97 -132
  68. package/dist/kit/vertical-axis.js.map +1 -1
  69. package/dist/line-chart/chart-line.d.ts +25 -51
  70. package/dist/line-chart/chart-line.d.ts.map +1 -1
  71. package/dist/line-chart/chart-line.js +89 -120
  72. package/dist/line-chart/chart-line.js.map +1 -1
  73. package/dist/line-chart/chart-region.d.ts +14 -14
  74. package/dist/line-chart/chart-region.d.ts.map +1 -1
  75. package/dist/line-chart/chart-region.js +26 -64
  76. package/dist/line-chart/chart-region.js.map +1 -1
  77. package/dist/line-chart/horizontal-axis.d.ts +32 -56
  78. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  79. package/dist/line-chart/horizontal-axis.js +85 -123
  80. package/dist/line-chart/horizontal-axis.js.map +1 -1
  81. package/dist/line-chart/index.d.ts +2 -2
  82. package/dist/line-chart/index.d.ts.map +1 -1
  83. package/dist/line-chart/index.js +1 -1
  84. package/dist/line-chart/index.js.map +1 -1
  85. package/dist/line-chart/line-chart.d.ts +111 -319
  86. package/dist/line-chart/line-chart.d.ts.map +1 -1
  87. package/dist/line-chart/line-chart.js +403 -663
  88. package/dist/line-chart/line-chart.js.map +1 -1
  89. package/dist/line-chart/shared.d.ts +3 -3
  90. package/dist/line-chart/shared.d.ts.map +1 -1
  91. package/dist/line-chart/shared.js +3 -3
  92. package/dist/line-chart/shared.js.map +1 -1
  93. package/dist/nodes.d.ts +6 -9
  94. package/dist/nodes.d.ts.map +1 -1
  95. package/dist/nodes.js +7 -9
  96. package/dist/nodes.js.map +1 -1
  97. package/dist/pie-chart/index.d.ts +2 -2
  98. package/dist/pie-chart/index.d.ts.map +1 -1
  99. package/dist/pie-chart/index.js +1 -1
  100. package/dist/pie-chart/index.js.map +1 -1
  101. package/dist/pie-chart/pie-chart.d.ts +77 -134
  102. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  103. package/dist/pie-chart/pie-chart.js +200 -271
  104. package/dist/pie-chart/pie-chart.js.map +1 -1
  105. package/dist/pie-chart/pie-legend.d.ts +54 -31
  106. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  107. package/dist/pie-chart/pie-legend.js +93 -107
  108. package/dist/pie-chart/pie-legend.js.map +1 -1
  109. package/dist/pie-chart/pie-ring.d.ts +142 -131
  110. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-ring.js +333 -391
  112. package/dist/pie-chart/pie-ring.js.map +1 -1
  113. package/package.json +4 -4
  114. package/src/bar-chart/bar-chart.ts +415 -701
  115. package/src/bar-chart/category-axis.ts +95 -110
  116. package/src/bar-chart/chart-bars.ts +178 -200
  117. package/src/bar-chart/index.ts +2 -2
  118. package/src/bar-chart/shared.ts +3 -3
  119. package/src/engine.ts +8 -6
  120. package/src/kit/cartesian.ts +81 -0
  121. package/src/kit/chart-border.ts +22 -33
  122. package/src/kit/chart-canvas.ts +44 -79
  123. package/src/kit/chart-legend.ts +173 -189
  124. package/src/kit/entries.ts +20 -0
  125. package/src/kit/motion.ts +90 -0
  126. package/src/kit/paints.ts +15 -0
  127. package/src/kit/shared.ts +52 -3
  128. package/src/kit/vertical-axis.ts +110 -140
  129. package/src/line-chart/chart-line.ts +95 -122
  130. package/src/line-chart/chart-region.ts +30 -59
  131. package/src/line-chart/horizontal-axis.ts +99 -135
  132. package/src/line-chart/index.ts +2 -2
  133. package/src/line-chart/line-chart.ts +449 -825
  134. package/src/line-chart/shared.ts +3 -3
  135. package/src/nodes.ts +8 -10
  136. package/src/pie-chart/index.ts +2 -2
  137. package/src/pie-chart/pie-chart.ts +221 -311
  138. package/src/pie-chart/pie-legend.ts +124 -114
  139. package/src/pie-chart/pie-ring.ts +386 -473
  140. package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
  141. package/dist/browser/chunks/chunk-5ZPBV6BU.js.map +0 -7
@@ -1,28 +1,26 @@
1
1
  import {
2
- Rect,
3
- command,
4
- type AssetScope,
5
2
  Graphics2D,
6
- Node2D,
7
3
  PathBuilder,
4
+ Rect,
8
5
  Text,
9
- easeOut,
6
+ Types,
7
+ component2d,
10
8
  fillOps,
11
- property,
12
9
  shadowOps,
13
10
  strokeOps,
14
11
  textOps,
15
- type Command,
16
- type CommandArgs,
17
12
  type Fill,
18
13
  type FillResolved,
19
- type NodeConfig,
14
+ type NodeOf,
15
+ type Node2D,
20
16
  type Node2DProps,
21
- type RenderContext2D,
22
17
  type TextStyle,
23
18
  type Vector2,
24
19
  } from "@motionscript/sdk"
25
20
 
21
+ import { asParts, fresh } from "../kit/entries"
22
+ import { frameEase } from "../kit/motion"
23
+ import { loadPaints } from "../kit/paints"
26
24
  import { clampUnit, formatPercent } from "../kit/shared"
27
25
  import {
28
26
  DEFAULT_PIE_CHART_THEME,
@@ -37,7 +35,6 @@ import {
37
35
  type PieSlice,
38
36
  type RingConfig,
39
37
  } from "./shared"
40
- import { declarePaints } from "@motionscript/sdk/component"
41
38
 
42
39
  export interface PieRingProps extends Node2DProps {
43
40
  /** One entry per slice: its `category`, `value` and `fill`. */
@@ -219,254 +216,40 @@ function wedgePath(
219
216
  return path
220
217
  }
221
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
+
222
233
  /**
223
- * The ring itself: one wedge per {@link PieSlice}, sized by its share of the
224
- * total, swept clockwise from the theme's `startAngle` with a `gap` of clear
225
- * space between neighbours — a *constant-width* channel, the same at the hole as
226
- * at the rim (see {@link wedgePath}). A **selected** slice reaches further out
227
- * while its hole edge stays put — so it reads as that band swelling out of the
228
- * ring rather than breaking off it — and the rest of the ring dims behind it,
229
- * leaders and labels included.
230
- *
231
- * This is the only part of the chart family that draws a path: the wedges are
232
- * filled {@link PathBuilder} polygons approximating each arc, and the leaders are
233
- * stroked polylines — both in the node's centred, y-up space, so neither depends
234
- * on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
235
- * (stacked in a hug vertical flow and pinned by their inner edge), so no
236
- * glyph-width guesswork decides where a caption starts.
237
- *
238
- * ### Label placement
239
- * Each label sits either **inside** its wedge or **outside** on a leader.
240
- *
241
- * An inside label is centred on its slice's band and leads with the number, the
242
- * caption set small underneath — inside a wedge the reader already knows which
243
- * slice they're on, so the magnitude is the thing being read. It costs no gutter
244
- * and no eye round-trip, so a ring labelled that way draws considerably bigger in
245
- * the same box.
246
- *
247
- * A leader label sits outside on the end of a polyline that lifts off its wedge,
248
- * elbows, and runs horizontally into the text. Labels are split into a left and a
249
- * right column by which way their slice's bisector points, and each column is
250
- * de-conflicted independently (see {@link relaxLabels}): a label takes the spot
251
- * nearest its wedge that still clears its neighbour, and the column is squeezed
252
- * to fit the node's height rather than spilling out of it. The elbow then rides on
253
- * a circle just outside the ring **at the label's settled y**, so the run into the
254
- * text is always horizontal and a nudged label never drags its leader back across
255
- * the ring.
256
- *
257
- * Only the sides that actually carry a leader reserve a gutter, and the ring's
258
- * centre slides into whatever the other side left over ({@link centerX}) — so a
259
- * ring whose slices all leader one way doesn't leave a bald patch on the other.
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.
260
238
  */
261
- export class PieRing extends Node2D<PieRingProps> {
262
- @property({ default: () => [] as PieSlice[] }) declare slices: PieSlice[]
263
- @property({ default: DEFAULT_PIE_CHART_THEME.ring }) declare ring: RingConfig
264
- @property({ default: DEFAULT_PIE_CHART_THEME.label })
265
- declare label: LabelConfig
266
- @property({ default: DEFAULT_PIE_CHART_THEME.center })
267
- declare centerStyle: CenterConfig
268
- @property({ default: "percent" }) declare labelMode: PieLabelMode
269
- @property({ default: "leader" }) declare labelPlacement: PieLabelPlacement
270
- @property({ default: 1 }) declare growth: number
271
- @property({ default: 1 }) declare labelReveal: number
272
- @property({ default: () => [] as number[] }) declare pops: number[]
273
- @property({ default: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity })
274
- declare dimOpacity: number
275
- @property({ default: "" }) declare centerLabel: string
276
- @property({ default: "" }) declare centerCaption: string
277
- @property({ default: 1 }) declare centerReveal: number
278
-
279
- /**
280
- * Each slice's fill, resolved when it changes rather than every frame: the
281
- * slices are bound to the chart's rows and palette, so a theme tween hands
282
- * over new paint and `renderSelf` must see it, but a frame that moves nothing
283
- * must not re-resolve.
284
- */
285
- private fillCache: { source: Fill; resolved: FillResolved[] }[] = []
286
-
287
- private get resolvedFills(): FillResolved[][] {
288
- const slices = this.slices
289
- this.fillCache.length = slices.length
290
- return slices.map((slice, i) => {
291
- const cached = this.fillCache[i]
292
- if (cached !== undefined && cached.source === slice.fill) return cached.resolved
293
- const resolved = fillOps.resolve(slice.fill)
294
- this.fillCache[i] = { source: slice.fill, resolved }
295
- return resolved
296
- })
297
- }
298
-
299
- constructor(props?: NodeConfig<PieRing, PieRingProps>) {
300
- super(props)
301
-
302
- // One label per *drawn* slice (a zero-valued slice gets no wedge, so no
303
- // label either), in whichever of the two forms the chart asked for. Both are
304
- // real Text stacked in a hug vertical flow — the difference is only what they are
305
- // pinned to and which line leads.
306
- //
307
- // The *point* each label is pinned to stays reactive and follows the ring as
308
- // the box resizes or its slice pops out; only the choice of form, which
309
- // depends on props alone, is settled here.
310
- if (this.labelPlacement !== "none" && this.labelMode !== "none") {
311
- for (const arc of this.arcs) this.addLabel(arc)
312
- }
313
-
314
- // The hole's summary, pinned to the ring's centre so it tracks the sideways
315
- // shift a one-sided label column puts on the ring.
316
- if (this.hasCenterLabel) this.addCenterLabel()
317
- }
318
-
319
- /** Build slice `arc`'s label, in whichever form the chart's placement asks for. */
320
- private addLabel(arc: PieArc): void {
321
- // Fades in with the ring's labels, then dims along with its own slice
322
- // whenever another slice takes the focus.
323
- const opacity = () =>
324
- clampUnit(this.labelReveal) * this.sliceOpacity(arc.index)
325
- const wantsCategory =
326
- this.labelMode === "both" || this.labelMode === "category"
327
- const wantsPercent =
328
- this.labelMode === "both" || this.labelMode === "percent"
329
-
330
- if (this.labelPlacement === "inside") {
331
- // The number leads and the caption sits under it: inside a wedge the
332
- // reader already knows *which* wedge they are looking at, so the magnitude
333
- // is what the label is for. Both sizes are bound rather than read once —
334
- // the fitted size derives from the ring's radius, which isn't known until
335
- // the node has been laid out.
336
- const inside: Node2D[] = []
337
- if (wantsPercent) {
338
- inside.push(
339
- new Text({
340
- ...textOps.styleProps(this.label.insideStyle),
341
- text: () => this.percentText(arc.index),
342
- fontSize: () => this.insideFont,
343
- })
344
- )
345
- }
346
- if (wantsCategory) {
347
- inside.push(
348
- new Text({
349
- ...textOps.styleProps(this.label.insideStyle),
350
- text: () => this.slices[arc.index]?.category ?? "",
351
- fontSize: () =>
352
- this.labelMode === "category"
353
- ? this.insideFont
354
- : this.insideFont * INSIDE_CAPTION_RATIO,
355
- })
356
- )
357
- }
358
- this.add(
359
- new Rect({ flow: "vertical",
360
- width: "hug",
361
- height: "hug",
362
- gap: 0,
363
- align: "center",
364
- opacity,
365
- center: () => this.insideAnchor(arc.index),
366
- children: inside,
367
- })
368
- )
369
- return
370
- }
371
-
372
- const side = this.sideOf(arc.mid)
373
- const at = () => this.placementFor(arc.index)?.text ?? { x: 0, y: 0 }
374
- const children: Node2D[] = []
375
- if (wantsCategory) {
376
- children.push(
377
- new Text({
378
- ...textOps.styleProps(this.label.textStyle),
379
- text: () => this.slices[arc.index]?.category ?? "",
380
- textAlign: side > 0 ? "left" : "right",
381
- })
382
- )
383
- }
384
- if (wantsPercent) {
385
- children.push(
386
- new Text({
387
- ...textOps.styleProps(this.label.percentStyle),
388
- text: () => this.percentText(arc.index),
389
- textAlign: side > 0 ? "left" : "right",
390
- })
391
- )
392
- }
393
- this.add(
394
- new Rect({ flow: "vertical",
395
- width: "hug",
396
- height: "hug",
397
- gap: 2,
398
- align: side > 0 ? "centerLeft" : "centerRight",
399
- opacity,
400
- // Exactly one of these is set; `undefined` anchors are ignored.
401
- centerLeft: side > 0 ? at : undefined,
402
- centerRight: side > 0 ? undefined : at,
403
- children,
404
- })
405
- )
406
- }
407
-
408
- /** Slice `index`'s share of the ring as its label reads it, off the live values. */
409
- private percentText(index: number): string {
410
- const arc = this.arcs.find((a) => a.index === index)
411
- return formatPercent(arc?.fraction ?? 0)
412
- }
413
-
414
- /** Build the hole's summary block. */
415
- private addCenterLabel(): void {
416
- const children: Node2D[] = []
417
- if (this.centerLabel !== "") {
418
- children.push(
419
- new Text({
420
- ...textOps.styleProps(this.centerStyle.labelStyle),
421
- text: this.centerLabel,
422
- fontSize: () => this.centerFont,
423
- })
424
- )
425
- }
426
- if (this.centerCaption !== "") {
427
- children.push(
428
- new Text({
429
- ...textOps.styleProps(this.centerStyle.captionStyle),
430
- text: this.centerCaption,
431
- fontSize: () =>
432
- this.centerFont *
433
- (sizeOf(this.centerStyle.captionStyle, 22) /
434
- sizeOf(this.centerStyle.labelStyle, 46)),
435
- })
436
- )
437
- }
438
- this.add(
439
- new Rect({ flow: "vertical",
440
- width: "hug",
441
- height: "hug",
442
- gap: 2,
443
- align: "center",
444
- opacity: () => clampUnit(this.centerReveal),
445
- center: () => ({ x: this.centerX, y: 0 }),
446
- children,
447
- })
448
- )
449
- }
450
-
451
- // ---- Arcs --------------------------------------------------------------
239
+ export class RingGeometry {
240
+ constructor(
241
+ private readonly s: RingState,
242
+ private readonly box: { readonly width: number; readonly height: number }
243
+ ) { }
452
244
 
453
245
  /** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
454
- private get arcs(): PieArc[] {
455
- return pieArcs(this.slices, this.ring.startAngle)
456
- }
457
-
458
- /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
459
- private sideOf(mid: number): 1 | -1 {
460
- return Math.sin(mid) >= 0 ? 1 : -1
246
+ get arcs(): PieArc[] {
247
+ return pieArcs(this.s.slices, this.s.ring.startAngle)
461
248
  }
462
249
 
463
250
  /** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
464
- private get hasLeaderLabels(): boolean {
465
- return (
466
- this.labelPlacement === "leader" &&
467
- this.labelMode !== "none" &&
468
- this.arcs.length > 0
469
- )
251
+ get hasLeaderLabels(): boolean {
252
+ return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0
470
253
  }
471
254
 
472
255
  // ---- Label sizing ------------------------------------------------------
@@ -487,7 +270,7 @@ export class PieRing extends Node2D<PieRingProps> {
487
270
  * Measured off the *unpopped* radius, so a slice growing out of the ring
488
271
  * doesn't resize its own text mid-animation.
489
272
  */
490
- private insideRoom(arc: PieArc): number {
273
+ insideRoom(arc: PieArc): number {
491
274
  const outer = this.outerRadius
492
275
  const hole = this.holeRadius
493
276
  const band = hole > 0 ? outer - hole : outer * (1 - PIE_LABEL_RADIUS) * 2
@@ -500,20 +283,13 @@ export class PieRing extends Node2D<PieRingProps> {
500
283
  }
501
284
 
502
285
  /** Widest line, in glyphs at the leading line's size, an inside label runs to. */
503
- private insideGlyphs(arc: PieArc): number {
504
- const category = this.slices[arc.index]?.category.length ?? 0
505
- const percent =
506
- this.labelMode === "both" || this.labelMode === "percent"
507
- ? formatPercent(arc.fraction).length
508
- : 0
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
509
290
  // The caption is set at a fraction of the leading line, except when it *is*
510
291
  // the leading line.
511
- const caption =
512
- this.labelMode === "category"
513
- ? category
514
- : this.labelMode === "both"
515
- ? category * INSIDE_CAPTION_RATIO
516
- : 0
292
+ const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0
517
293
  return Math.max(1, percent, caption)
518
294
  }
519
295
 
@@ -526,77 +302,63 @@ export class PieRing extends Node2D<PieRingProps> {
526
302
  * So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
527
303
  * for the size the chart wants and the ring keeps it legal.
528
304
  */
529
- private get insideFont(): number {
530
- let size = sizeOf(this.label.insideStyle, FALLBACK_SIZE * 1.9)
305
+ get insideFont(): number {
306
+ let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9)
531
307
  for (const arc of this.arcs) {
532
- const fits =
533
- (this.insideRoom(arc) * INSIDE_LABEL_FIT) /
534
- (this.insideGlyphs(arc) * GLYPH_RATIO)
308
+ const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO)
535
309
  size = Math.min(size, fits)
536
310
  }
537
311
  return Math.max(1, size)
538
312
  }
539
313
 
540
- /** Whether the hole carries a summary at all. */
541
- private get hasCenterLabel(): boolean {
542
- return this.centerLabel !== "" || this.centerCaption !== ""
543
- }
544
-
545
314
  /**
546
315
  * Font size of the hole's summary: the configured size, capped so it still
547
316
  * fits across the hole. The cap matters because the hole's radius derives from
548
317
  * the box, so a chart that reflows smaller would otherwise push its summary out
549
318
  * through the ring.
550
319
  */
551
- private get centerFont(): number {
552
- const wanted = sizeOf(this.centerStyle.labelStyle, 46)
320
+ get centerFont(): number {
321
+ const { centerStyle, centerLabel, centerCaption } = this.s
322
+ const wanted = sizeOf(centerStyle.labelStyle, 46)
553
323
  const hole = this.holeRadius
554
324
  if (hole <= 0) return wanted
555
- const captionRatio =
556
- sizeOf(this.centerStyle.captionStyle, 22) / Math.max(1, wanted)
325
+ const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted)
557
326
  // Both lines have to clear the hole, and the caption is set at a fraction of
558
327
  // this size — so what binds is whichever is wider once drawn, not whichever
559
328
  // has more characters.
560
- const glyphs = Math.max(
561
- 1,
562
- this.centerLabel.length,
563
- this.centerCaption.length * captionRatio
564
- )
329
+ const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio)
565
330
  return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO))
566
331
  }
567
332
 
568
- // ---- Ring geometry, all derived from layoutBounds ------------------------
333
+ // ---- Ring geometry, all derived from the box ---------------------------
569
334
 
570
335
  /** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
571
- private get growFactor(): number {
572
- return Math.max(1, Math.min(1.6, this.ring.selectedGrow))
336
+ get growFactor(): number {
337
+ return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow))
573
338
  }
574
339
 
575
340
  /** How far past the ring's rim a fully selected slice reaches, in px. */
576
- private get maxPop(): number {
341
+ get maxPop(): number {
577
342
  return this.outerRadius * (this.growFactor - 1)
578
343
  }
579
344
 
580
345
  /** Height of one **leader** label block, in px — the unit those columns are spaced by. */
581
- private get labelRowHeight(): number {
346
+ get labelRowHeight(): number {
582
347
  // Inside labels are stacked in the band, not in a column beside the ring, so
583
348
  // they cost no vertical reserve.
584
349
  if (!this.hasLeaderLabels) return 0
585
- const caption = sizeOf(this.label.textStyle) * 1.25
586
- const percent = sizeOf(this.label.percentStyle) * 1.25
587
- if (this.labelMode === "both") return caption + percent + 2
588
- return this.labelMode === "category" ? caption : percent
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
589
354
  }
590
355
 
591
356
  /** Widest line, in glyphs, that a leader label for slice `index` can run to. */
592
- private captionGlyphs(index: number): number {
357
+ captionGlyphs(index: number): number {
358
+ const mode = this.s.labelMode
593
359
  // "100%" is the widest a percentage gets; captions are their own length.
594
- const percent =
595
- this.labelMode === "percent" || this.labelMode === "both" ? 4 : 0
596
- const category =
597
- this.labelMode === "category" || this.labelMode === "both"
598
- ? (this.slices[index]?.category.length ?? 0)
599
- : 0
360
+ const percent = mode === "percent" || mode === "both" ? 4 : 0
361
+ const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0
600
362
  return Math.max(percent, category)
601
363
  }
602
364
 
@@ -608,18 +370,13 @@ export class PieRing extends Node2D<PieRingProps> {
608
370
  * Excludes the ring's own selection-growth allowance, which scales with the
609
371
  * radius and so is divided out in {@link outerRadius} instead.
610
372
  */
611
- private get labelGutters(): { left: number; right: number } {
373
+ get labelGutters(): { left: number; right: number } {
612
374
  const gutters = { left: BARE_PAD, right: BARE_PAD }
613
375
  if (!this.hasLeaderLabels) return gutters
614
- const glyph = sizeOf(this.label.textStyle) * GLYPH_RATIO
376
+ const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO
615
377
  for (const arc of this.arcs) {
616
- const room =
617
- LEADER_LENGTH +
618
- LABEL_STUB +
619
- TEXT_INSET +
620
- this.captionGlyphs(arc.index) * glyph +
621
- LABEL_PAD
622
- const key = this.sideOf(arc.mid) > 0 ? "right" : "left"
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"
623
380
  gutters[key] = Math.max(gutters[key], room)
624
381
  }
625
382
  return gutters
@@ -631,7 +388,7 @@ export class PieRing extends Node2D<PieRingProps> {
631
388
  * empty half so the ring-plus-labels group stays centred in the box instead of
632
389
  * the ring sitting dead centre with a bald patch beside it.
633
390
  */
634
- private get centerX(): number {
391
+ get centerX(): number {
635
392
  const { left, right } = this.labelGutters
636
393
  return (left - right) / 2
637
394
  }
@@ -642,35 +399,32 @@ export class PieRing extends Node2D<PieRingProps> {
642
399
  * cover that — hence the divisor, which keeps even the grown slice inside the
643
400
  * box (and holds the ring the same size whether or not anything is selected).
644
401
  */
645
- private get outerRadius(): number {
402
+ get outerRadius(): number {
646
403
  const { left, right } = this.labelGutters
647
- const room = Math.min(
648
- (this.layoutBounds.width - left - right) / 2,
649
- this.layoutBounds.height / 2 - this.labelRowHeight - 4
650
- )
404
+ const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4)
651
405
  return Math.max(0, room / this.growFactor)
652
406
  }
653
407
 
654
408
  /** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
655
- private get holeRadius(): number {
656
- const configured = this.ring.holeRadius
409
+ get holeRadius(): number {
410
+ const configured = this.s.ring.holeRadius
657
411
  if (configured <= 0) return 0
658
412
  const px = configured < 1 ? configured * this.outerRadius : configured
659
413
  return Math.min(px, this.outerRadius * 0.95)
660
414
  }
661
415
 
662
416
  /** Radius of the circle every leader elbows on, clear of even a popped-out slice. */
663
- private get elbowRadius(): number {
417
+ get elbowRadius(): number {
664
418
  return this.outerRadius + this.maxPop + LEADER_LENGTH
665
419
  }
666
420
 
667
421
  /** How far slice `index` is currently pushed out, in `[0, 1]`. */
668
- private popOf(index: number): number {
669
- return clampUnit(this.pops[index] ?? 0)
422
+ popOf(index: number): number {
423
+ return clampUnit(this.s.pops[index] ?? 0)
670
424
  }
671
425
 
672
426
  /** Radius of slice `index`'s outer edge, its selection growth included. */
673
- private anchorRadius(index: number): number {
427
+ anchorRadius(index: number): number {
674
428
  return this.outerRadius + this.maxPop * this.popOf(index)
675
429
  }
676
430
 
@@ -680,14 +434,14 @@ export class PieRing extends Node2D<PieRingProps> {
680
434
  * crowd the point every wedge converges on. Rides the slice's own pop, so an
681
435
  * inside label travels out with the wedge it belongs to.
682
436
  */
683
- private bandMidRadius(index: number): number {
437
+ bandMidRadius(index: number): number {
684
438
  const rim = this.anchorRadius(index)
685
439
  const hole = this.holeRadius
686
440
  return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS
687
441
  }
688
442
 
689
- /** Node2D-space point slice `index`'s inside label is centred on. */
690
- private insideAnchor(index: number): Vector2 {
443
+ /** The point slice `index`'s inside label is centred on. */
444
+ insideAnchor(index: number): Vector2 {
691
445
  const arc = this.arcs.find((a) => a.index === index)
692
446
  if (arc === undefined) return { x: this.centerX, y: 0 }
693
447
  const point = ringPoint(this.bandMidRadius(index), arc.mid)
@@ -699,24 +453,28 @@ export class PieRing extends Node2D<PieRingProps> {
699
453
  * the same tween the pops do, so the dim below arrives with the growth rather
700
454
  * than needing an animation of its own.
701
455
  */
702
- private get selectionStrength(): number {
456
+ get selectionStrength(): number {
703
457
  let strongest = 0
704
- for (let i = 0; i < this.slices.length; i++) {
705
- strongest = Math.max(strongest, this.popOf(i))
706
- }
458
+ for (let i = 0; i < this.s.slices.length; i++) strongest = Math.max(strongest, this.popOf(i))
707
459
  return strongest
708
460
  }
709
461
 
710
462
  /**
711
463
  * Opacity slice `index` (and its leader and label) is drawn at: full while
712
- * nothing is selected, falling toward {@link dimOpacity} in proportion to how
464
+ * nothing is selected, falling toward `dimOpacity` in proportion to how
713
465
  * selected the *rest* of the ring is. A slice that's selected itself stays at
714
466
  * full — the two factors cancel.
715
467
  */
716
- private sliceOpacity(index: number): number {
468
+ sliceOpacity(index: number): number {
717
469
  const focus = this.selectionStrength
718
470
  if (focus <= 0) return 1
719
- return 1 - (1 - clampUnit(this.dimOpacity)) * focus * (1 - this.popOf(index))
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)
720
478
  }
721
479
 
722
480
  // ---- Label placement ---------------------------------------------------
@@ -729,14 +487,14 @@ export class PieRing extends Node2D<PieRingProps> {
729
487
  * the label's *settled* y, which keeps the final run horizontal and the whole
730
488
  * polyline outside the ring however far the label was nudged.
731
489
  */
732
- private placements(): LabelPlacement[] {
490
+ placements(): LabelPlacement[] {
733
491
  if (!this.hasLeaderLabels) return []
734
492
  const arcs = this.arcs
735
493
  if (arcs.length === 0 || this.outerRadius <= 0) return []
736
494
 
737
495
  const radius = this.elbowRadius
738
496
  const rowHeight = this.labelRowHeight
739
- const top = this.layoutBounds.height / 2 - rowHeight / 2
497
+ const top = this.box.height / 2 - rowHeight / 2
740
498
  const bottom = -top
741
499
  const cx = this.centerX
742
500
 
@@ -750,31 +508,20 @@ export class PieRing extends Node2D<PieRingProps> {
750
508
  const right: Slot[] = []
751
509
  const left: Slot[] = []
752
510
  for (const arc of arcs) {
753
- const side = this.sideOf(arc.mid)
511
+ const side = sideOf(arc.mid)
754
512
  const ideal = ringPoint(radius, arc.mid).y
755
- ;(side > 0 ? right : left).push({
756
- index: arc.index,
757
- side,
758
- mid: arc.mid,
759
- ideal,
760
- y: ideal,
761
- })
513
+ ;(side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal })
762
514
  }
763
515
  relaxLabels(right, rowHeight + LABEL_GAP, top, bottom)
764
516
  relaxLabels(left, rowHeight + LABEL_GAP, top, bottom)
765
517
 
766
518
  return [...right, ...left].map((slot) => {
767
- const start = ringPoint(
768
- this.anchorRadius(slot.index) + LEADER_LIFTOFF,
769
- slot.mid
770
- )
519
+ const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid)
771
520
  // The elbow sits where the label's y crosses the leader circle. Solving
772
521
  // x² + y² = radius² for x keeps it *on* that circle (never inside the
773
522
  // ring); a label pushed past the circle's top or bottom falls back to the
774
523
  // sideways floor so its leader still reads as pointing outward.
775
- const onCircle = Math.sqrt(
776
- Math.max(0, radius * radius - slot.y * slot.y)
777
- )
524
+ const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y))
778
525
  const elbowX = cx + slot.side * Math.max(onCircle, radius * MIN_ELBOW)
779
526
  const endX = cx + slot.side * (radius + LABEL_STUB)
780
527
 
@@ -792,144 +539,310 @@ export class PieRing extends Node2D<PieRingProps> {
792
539
  }
793
540
 
794
541
  /** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
795
- private placementFor(index: number): LabelPlacement | undefined {
542
+ placementFor(index: number): LabelPlacement | undefined {
796
543
  return this.placements().find((placement) => placement.index === index)
797
544
  }
545
+ }
798
546
 
799
- // ---- Draw --------------------------------------------------------------
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
+ }
800
551
 
801
- /**
802
- * The ring's paints live nowhere `ShapeNode` would look: one fill per slice,
803
- * resolved once into {@link fills}, the wedge outline and drop shadow of
804
- * {@link ring}, and the leader line's own stroke. All are passed explicitly —
805
- * see {@link declarePaints}.
806
- */
807
- override declareAssets(assets: AssetScope): void {
808
- super.declareAssets(assets)
809
- declarePaints(this, assets, {
810
- fills: [
811
- ...this.resolvedFills,
812
- // A shadow's paint is a fill like any other, so an image in one is
813
- // declared with the rest.
814
- ...shadowOps.resolve(this.ring.shadow).map((shadow) => shadow.fill),
815
- ],
816
- strokes: [
817
- strokeOps.resolve(this.ring.stroke),
818
- strokeOps.resolve(this.label.leaderStroke),
819
- ],
820
- })
821
- }
552
+ const NO_BOX = { width: 0, height: 0 }
822
553
 
823
- protected renderSelf(ctx: RenderContext2D): void {
824
- const rOuter = this.outerRadius
825
- if (rOuter <= 0) return
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[]>()
826
561
 
827
- const arcs = this.arcs
828
- if (arcs.length === 0) return
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
+ }
829
568
 
830
- const sweep = clampUnit(this.growth)
831
- const revealEnd = this.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep
832
- const hole = this.holeRadius
833
- const halfGap = Math.max(0, this.ring.gap) / 2
834
-
835
- // One shared frame (the whole ring square, grown slices included) pinned onto
836
- // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
837
- // own bbox and collapse them all onto the origin. It stays symmetric about the
838
- // *node's* origin — so path coordinates land exactly in node space — and is
839
- // grown by the ring's sideways offset rather than moved with it, which would
840
- // undo the very shift it has to contain.
841
- const cx = this.centerX
842
- const half = rOuter + this.maxPop + 1 + Math.abs(cx)
843
- const centerBounds: [number, number, number, number] = [
844
- -half,
845
- -half,
846
- half,
847
- half,
848
- ]
849
- // Explicit width/height alongside centerBounds: a wedge path's command list
850
- // carries no implied extent, so a slice filled with an image or a video needs
851
- // this to size its decode.
852
- const span = half * 2
853
-
854
- const fills = this.resolvedFills
855
- for (const arc of arcs) {
856
- // Clip the wedge to the growth-revealed arc; skip it until the sweep
857
- // reaches it, so the ring draws in as one clockwise wipe.
858
- const a1 = Math.min(arc.a1, revealEnd)
859
- if (a1 <= arc.a0) continue
860
-
861
- const path = wedgePath(
862
- arc.a0,
863
- a1,
864
- hole,
865
- this.anchorRadius(arc.index),
866
- halfGap,
867
- cx
868
- )
869
- const g = new Graphics2D().path(
870
- path.toPathState({ centerBounds, width: span, height: span })
871
- )
872
- g.shadow(this.ring.shadow)
873
- g.fill(fills[arc.index] ?? [])
874
- g.stroke(this.ring.stroke)
875
- ctx.draw(g.opacity(this.sliceOpacity(arc.index)))
876
- }
569
+ /** Each slice's fill, resolved. */
570
+ export function ringFills(slices: readonly PieSlice[]): FillResolved[][] {
571
+ return slices.map((slice) => resolvedFill(slice.fill))
572
+ }
877
573
 
878
- // Leaders. Each polyline gets its own `Graphics2D`: several open polylines
879
- // chained into one command list are combined into a single surface before the
880
- // stroke, which closes each contour back on itself and paints the leaders as
881
- // thin triangles.
882
- const reveal = clampUnit(this.labelReveal)
883
- if (reveal <= 0.001) return
884
- for (const placement of this.placements()) {
885
- ctx.draw(
886
- new Graphics2D()
887
- .line({ points: placement.leader, radius: ELBOW_RADIUS })
888
- .stroke(this.label.leaderStroke)
889
- .opacity(reveal * this.sliceOpacity(placement.index))
890
- )
891
- }
892
- }
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)
893
587
 
894
- // ---- Entrance ----------------------------------------------------------
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"
895
594
 
896
- /** Tween {@link growth} `0 → 1`, wiping the wedges in around the ring. */
897
- @command()
898
- sweep(
899
- args: CommandArgs<Record<string, never>> & { duration: number }
900
- ): Command<RingSweep> {
901
- // No priming to 0 first — the value at `t` is a function of `t`, so a repeat
902
- // call re-sweeps by definition rather than by having reset something.
903
- return this.command<RingSweep>((t) => ({ growth: t }), args.duration, args.easing ?? easeOut())
904
- }
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
+ }
905
627
 
906
- /** Tween {@link labelReveal} `0 → 1`, fading the leaders and captions up. */
907
- @command()
908
- revealLabels(
909
- args: CommandArgs<Record<string, never>> & { duration: number }
910
- ): Command<RingLabels> {
911
- return this.command<RingLabels>((t) => ({ labelReveal: t }), args.duration, args.easing ?? easeOut())
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
+ })
912
657
  }
913
658
 
914
- /**
915
- * Fade the hole's summary up. Deliberately not part of the ring's entrance: a
916
- * hole label is usually the chart's conclusion, so the scene decides when it
917
- * lands.
918
- */
919
- @command()
920
- revealCenter(
921
- args: CommandArgs<Record<string, never>> & { duration?: number } = {}
922
- ): Command<RingCenter> {
923
- const from = clampUnit(this.centerReveal)
924
- return this.command<RingCenter>(
925
- (t) => ({ centerReveal: from + (1 - from) * t }),
926
- args.duration ?? 0.6,
927
- args.easing ?? easeOut()
928
- )
659
+ if (s.labelPlacement !== "none" && s.labelMode !== "none") {
660
+ for (const arc of pieArcs(s.slices, s.ring.startAngle)) blocks.push(label(arc))
929
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)
930
694
  }
931
695
 
932
- /** The signals the ring's own commands move, as props types. */
933
- type RingSweep = { growth: number }
934
- type RingLabels = { labelReveal: number }
935
- type RingCenter = { centerReveal: number }
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>