@motionscript/charts 0.1.0 → 0.1.1-alpha.1

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 (215) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +98 -28
  3. package/dist/bar-chart/bar-chart.d.ts +242 -119
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +231 -260
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +16 -38
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +59 -68
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +60 -91
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +88 -125
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +5 -13
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -9
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +40 -132
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +70 -158
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-R3Q5EE6E.js +2 -0
  24. package/dist/browser/chunks/chunk-R3Q5EE6E.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +4 -4
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -17
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -17
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kit/cartesian.d.ts +49 -35
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +80 -42
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +37 -40
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +53 -47
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +16 -59
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +38 -76
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +2 -0
  50. package/dist/kit/entries.d.ts.map +1 -1
  51. package/dist/kit/entries.js +2 -0
  52. package/dist/kit/entries.js.map +1 -1
  53. package/dist/kit/horizontal-axis.d.ts +77 -0
  54. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  55. package/dist/kit/horizontal-axis.js +99 -0
  56. package/dist/kit/horizontal-axis.js.map +1 -0
  57. package/dist/kit/motion.d.ts +16 -3
  58. package/dist/kit/motion.d.ts.map +1 -1
  59. package/dist/kit/motion.js +15 -1
  60. package/dist/kit/motion.js.map +1 -1
  61. package/dist/{pie-chart → kit}/pie-legend.d.ts +14 -29
  62. package/dist/kit/pie-legend.d.ts.map +1 -0
  63. package/dist/kit/pie-legend.js +93 -0
  64. package/dist/kit/pie-legend.js.map +1 -0
  65. package/dist/kit/polar.d.ts +31 -0
  66. package/dist/kit/polar.d.ts.map +1 -0
  67. package/dist/kit/polar.js +33 -0
  68. package/dist/kit/polar.js.map +1 -0
  69. package/dist/kit/shared.d.ts +60 -214
  70. package/dist/kit/shared.d.ts.map +1 -1
  71. package/dist/kit/shared.js +91 -291
  72. package/dist/kit/shared.js.map +1 -1
  73. package/dist/kit/style.d.ts +254 -0
  74. package/dist/kit/style.d.ts.map +1 -0
  75. package/dist/kit/style.js +197 -0
  76. package/dist/kit/style.js.map +1 -0
  77. package/dist/kit/vertical-axis.d.ts +36 -45
  78. package/dist/kit/vertical-axis.d.ts.map +1 -1
  79. package/dist/kit/vertical-axis.js +69 -81
  80. package/dist/kit/vertical-axis.js.map +1 -1
  81. package/dist/line-chart/chart-line.d.ts +107 -40
  82. package/dist/line-chart/chart-line.d.ts.map +1 -1
  83. package/dist/line-chart/chart-line.js +172 -89
  84. package/dist/line-chart/chart-line.js.map +1 -1
  85. package/dist/line-chart/chart-region.d.ts +14 -27
  86. package/dist/line-chart/chart-region.d.ts.map +1 -1
  87. package/dist/line-chart/chart-region.js +13 -28
  88. package/dist/line-chart/chart-region.js.map +1 -1
  89. package/dist/line-chart/index.d.ts +5 -18
  90. package/dist/line-chart/index.d.ts.map +1 -1
  91. package/dist/line-chart/index.js +3 -16
  92. package/dist/line-chart/index.js.map +1 -1
  93. package/dist/line-chart/line-chart.d.ts +272 -119
  94. package/dist/line-chart/line-chart.d.ts.map +1 -1
  95. package/dist/line-chart/line-chart.js +309 -257
  96. package/dist/line-chart/line-chart.js.map +1 -1
  97. package/dist/line-chart/shared.d.ts +73 -130
  98. package/dist/line-chart/shared.d.ts.map +1 -1
  99. package/dist/line-chart/shared.js +195 -156
  100. package/dist/line-chart/shared.js.map +1 -1
  101. package/dist/line-chart/x-scale.d.ts +16 -55
  102. package/dist/line-chart/x-scale.d.ts.map +1 -1
  103. package/dist/line-chart/x-scale.js +44 -111
  104. package/dist/line-chart/x-scale.js.map +1 -1
  105. package/dist/nodes.d.ts +3 -13
  106. package/dist/nodes.d.ts.map +1 -1
  107. package/dist/nodes.js +9 -13
  108. package/dist/nodes.js.map +1 -1
  109. package/dist/pie-chart/index.d.ts +3 -15
  110. package/dist/pie-chart/index.d.ts.map +1 -1
  111. package/dist/pie-chart/index.js +1 -11
  112. package/dist/pie-chart/index.js.map +1 -1
  113. package/dist/pie-chart/pie-chart.d.ts +147 -85
  114. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-chart.js +65 -86
  116. package/dist/pie-chart/pie-chart.js.map +1 -1
  117. package/dist/pie-chart/pie-ring.d.ts +65 -196
  118. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  119. package/dist/pie-chart/pie-ring.js +154 -292
  120. package/dist/pie-chart/pie-ring.js.map +1 -1
  121. package/dist/pie-chart/shared.d.ts +78 -172
  122. package/dist/pie-chart/shared.d.ts.map +1 -1
  123. package/dist/pie-chart/shared.js +54 -177
  124. package/dist/pie-chart/shared.js.map +1 -1
  125. package/dist/radar-chart/index.d.ts +5 -0
  126. package/dist/radar-chart/index.d.ts.map +1 -0
  127. package/dist/radar-chart/index.js +5 -0
  128. package/dist/radar-chart/index.js.map +1 -0
  129. package/dist/radar-chart/radar-chart.d.ts +218 -0
  130. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-chart.js +224 -0
  132. package/dist/radar-chart/radar-chart.js.map +1 -0
  133. package/dist/radar-chart/radar-grid.d.ts +43 -0
  134. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-grid.js +61 -0
  136. package/dist/radar-chart/radar-grid.js.map +1 -0
  137. package/dist/radar-chart/radar-series.d.ts +59 -0
  138. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  139. package/dist/radar-chart/radar-series.js +71 -0
  140. package/dist/radar-chart/radar-series.js.map +1 -0
  141. package/dist/radar-chart/shared.d.ts +73 -0
  142. package/dist/radar-chart/shared.d.ts.map +1 -0
  143. package/dist/radar-chart/shared.js +123 -0
  144. package/dist/radar-chart/shared.js.map +1 -0
  145. package/dist/radial-chart/index.d.ts +6 -0
  146. package/dist/radial-chart/index.d.ts.map +1 -0
  147. package/dist/radial-chart/index.js +4 -0
  148. package/dist/radial-chart/index.js.map +1 -0
  149. package/dist/radial-chart/radial-chart.d.ts +216 -0
  150. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-chart.js +174 -0
  152. package/dist/radial-chart/radial-chart.js.map +1 -0
  153. package/dist/radial-chart/radial-rings.d.ts +121 -0
  154. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  155. package/dist/radial-chart/radial-rings.js +273 -0
  156. package/dist/radial-chart/radial-rings.js.map +1 -0
  157. package/dist/radial-chart/shared.d.ts +73 -0
  158. package/dist/radial-chart/shared.d.ts.map +1 -0
  159. package/dist/radial-chart/shared.js +79 -0
  160. package/dist/radial-chart/shared.js.map +1 -0
  161. package/package.json +4 -4
  162. package/registry.json +21 -6
  163. package/src/bar-chart/bar-chart.ts +274 -315
  164. package/src/bar-chart/category-axis.ts +53 -88
  165. package/src/bar-chart/chart-bars.ts +108 -205
  166. package/src/bar-chart/index.ts +8 -19
  167. package/src/bar-chart/shared.ts +81 -309
  168. package/src/engine.ts +8 -17
  169. package/src/index.ts +2 -0
  170. package/src/kit/cartesian.ts +118 -55
  171. package/src/kit/chart-canvas.ts +50 -80
  172. package/src/kit/chart-legend.ts +36 -97
  173. package/src/kit/entries.ts +4 -1
  174. package/src/kit/horizontal-axis.ts +114 -0
  175. package/src/kit/motion.ts +18 -2
  176. package/src/{pie-chart → kit}/pie-legend.ts +25 -65
  177. package/src/kit/polar.ts +54 -0
  178. package/src/kit/shared.ts +104 -487
  179. package/src/kit/style.ts +257 -0
  180. package/src/kit/vertical-axis.ts +81 -115
  181. package/src/line-chart/chart-line.ts +185 -91
  182. package/src/line-chart/chart-region.ts +14 -31
  183. package/src/line-chart/index.ts +14 -22
  184. package/src/line-chart/line-chart.ts +377 -315
  185. package/src/line-chart/shared.ts +229 -304
  186. package/src/line-chart/x-scale.ts +44 -121
  187. package/src/nodes.ts +9 -13
  188. package/src/pie-chart/index.ts +3 -35
  189. package/src/pie-chart/pie-chart.ts +83 -134
  190. package/src/pie-chart/pie-ring.ts +162 -368
  191. package/src/pie-chart/shared.ts +92 -379
  192. package/src/radar-chart/index.ts +4 -0
  193. package/src/radar-chart/radar-chart.ts +287 -0
  194. package/src/radar-chart/radar-grid.ts +78 -0
  195. package/src/radar-chart/radar-series.ts +88 -0
  196. package/src/radar-chart/shared.ts +146 -0
  197. package/src/radial-chart/index.ts +5 -0
  198. package/src/radial-chart/radial-chart.ts +210 -0
  199. package/src/radial-chart/radial-rings.ts +328 -0
  200. package/src/radial-chart/shared.ts +114 -0
  201. package/dist/browser/chunks/chunk-TJNHLQGV.js +0 -2
  202. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +0 -7
  203. package/dist/kit/chart-border.d.ts +0 -27
  204. package/dist/kit/chart-border.d.ts.map +0 -1
  205. package/dist/kit/chart-border.js +0 -28
  206. package/dist/kit/chart-border.js.map +0 -1
  207. package/dist/line-chart/horizontal-axis.d.ts +0 -79
  208. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  209. package/dist/line-chart/horizontal-axis.js +0 -129
  210. package/dist/line-chart/horizontal-axis.js.map +0 -1
  211. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  212. package/dist/pie-chart/pie-legend.js +0 -117
  213. package/dist/pie-chart/pie-legend.js.map +0 -1
  214. package/src/kit/chart-border.ts +0 -50
  215. package/src/line-chart/horizontal-axis.ts +0 -151
@@ -1,116 +1,128 @@
1
- import { Graphics2D, PathBuilder, Rect, Text, Types, component2d, fillOps, shadowOps, strokeOps, textOps, } from "@motionscript/sdk";
1
+ import { Graphics2D, PathBuilder, Rect, Text, Types, clamp, component2d, strokeOps, textOps, } from "@motionscript/sdk";
2
2
  import { asParts, fresh } from "../kit/entries.js";
3
3
  import { frameEase } from "../kit/motion.js";
4
4
  import { loadPaints } from "../kit/paints.js";
5
- import { clampUnit, formatPercent } from "../kit/shared.js";
6
- import { DEFAULT_PIE_CHART_THEME, pieArcs, relaxLabels, ringPoint, } from "./shared.js";
7
- /** Vertical breathing room between one label block and the next, in px. */
5
+ import { resolvedFill, ringFills } from "../kit/polar.js";
6
+ import { formatPercent } from "../kit/shared.js";
7
+ import { PIE_SPOTLIGHT_DIM, pieArcs, relaxLabels, ringPoint, selectionFade, } from "./shared.js";
8
+ const DEFAULT_RING = { gap: 3, holeRadius: 0, startAngle: 0, selectedGrow: 1.08, cornerRadius: 4 };
9
+ const DEFAULT_LABEL = {
10
+ textStyle: { fontSize: 28, fontWeight: 500, textAlign: "center", fill: "#a1a1aa" },
11
+ percentStyle: { fontSize: 28, fontWeight: 700, textAlign: "center", fill: "#f4f4f5" },
12
+ insideStyle: { fontSize: 52, fontWeight: 700, textAlign: "center", fill: "#111113" },
13
+ leaderStroke: { weight: 2, fill: "#a1a1aa" },
14
+ };
15
+ const DEFAULT_CENTER = {
16
+ labelStyle: { fontSize: 50, fontWeight: 800, textAlign: "center", fill: "#f4f4f5" },
17
+ captionStyle: { fontSize: 22, fontWeight: 500, textAlign: "center", fill: "#a1a1aa" },
18
+ };
19
+ /** Black or white, whichever reads better on a solid `fill`; black for any other paint. */
20
+ export function onFill(fill) {
21
+ const layers = resolvedFill(fill);
22
+ const solid = layers.find((layer) => layer.type === "solid");
23
+ if (!solid)
24
+ return "#111113";
25
+ const [r, g, b] = solid.color;
26
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#111113" : "#ffffff";
27
+ }
8
28
  const LABEL_GAP = 12;
9
- /** Breathing room past the outermost label, so it never touches the node's edge, in px. */
10
29
  const LABEL_PAD = 14;
11
- /** Rough width of one glyph as a fraction of the font size, for gutter sizing. */
30
+ /** Rough glyph width as a fraction of the font size, for gutter sizing. */
12
31
  const GLYPH_RATIO = 0.62;
13
- /** Gap between a wedge's outer edge and where its leader lifts off, in px. */
14
32
  const LEADER_LIFTOFF = 6;
15
- /** How far past the ring's outer edge a leader runs before it elbows, in px. */
16
33
  const LEADER_LENGTH = 34;
17
- /** Horizontal run from the elbow to the label, in px. */
18
34
  const LABEL_STUB = 26;
19
- /** Gap between a leader's end and the label text it points at, in px. */
20
35
  const TEXT_INSET = 8;
21
- /**
22
- * Floor on the elbow's horizontal distance from the ring's axis, as a fraction
23
- * of the leader radius — so a label pushed clear above (or below) the ring still
24
- * elbows out sideways rather than running straight down the 12 o'clock line.
25
- */
36
+ /** Floor on the elbow's distance from the ring's axis, as a fraction of the leader radius, so a label pushed above or below the ring still elbows out sideways. */
26
37
  const MIN_ELBOW = 0.18;
27
- /** Segments used to tessellate a full turn of arc; a wedge takes its share. */
28
38
  const ARC_STEPS = 240;
29
- /** Rounding applied to the leader's elbow, in px. */
30
39
  const ELBOW_RADIUS = 8;
31
- /** Side padding used when nothing is leadered out — no gutter to reserve. */
32
40
  const BARE_PAD = 2;
33
- /** An inside label's caption size, as a fraction of its leading line. */
34
41
  const INSIDE_CAPTION_RATIO = 0.44;
35
- /**
36
- * Where an inside label sits on a **solid pie** (no hole to split the band
37
- * with), as a fraction of the wedge's radius — far enough out that the label
38
- * clears the point every wedge converges on.
39
- */
42
+ /** Where an inside label sits on a solid pie, as a fraction of the radius: far enough out to clear the point every wedge converges on. */
40
43
  const PIE_LABEL_RADIUS = 0.62;
41
- /** Fraction of the hole's diameter its summary is allowed to run to. */
42
44
  const CENTER_LABEL_FIT = 0.78;
43
- /** Fraction of the room inside a wedge that its label is allowed to run to. */
44
45
  const INSIDE_LABEL_FIT = 0.9;
45
- /** Below this, a wedge's bisector counts as axis-aligned and that term stops binding. */
46
+ /** Below this a wedge's bisector counts as axis-aligned and that bound stops binding. */
46
47
  const AXIS_EPSILON = 1e-3;
47
- /** Fallback type size when a theme style leaves it unset or on autofit. */
48
+ /** Type size when a theme style leaves it unset or on autofit. */
48
49
  const FALLBACK_SIZE = 26;
49
- /** A style's numeric size, guarding the autofit and unset cases. */
50
50
  function sizeOf(style, fallback = FALLBACK_SIZE) {
51
51
  const size = style.fontSize;
52
52
  return typeof size === "number" ? size : fallback;
53
53
  }
54
54
  /**
55
- * A ring-band wedge over `a0`→`a1` (radians, clockwise from 12 o'clock) spanning
56
- * `rInner`→`rOuter`, centred on `cx`. Ring wedges trace the outer arc, step in,
57
- * and trace the inner arc back; full pie wedges (`rInner <= 0`) close through the
58
- * centre.
59
- *
60
- * `halfGap` is **half the gap to the neighbouring slice, in px**, shaved off both
61
- * ends of the wedge. It's converted to an angle *per radius* (`halfGap / r` — a
62
- * wider angle near the hole than out at the rim), which is what keeps the channel
63
- * between two slices the same width all the way across the band. A fixed angular
64
- * gap instead reads as a thin pizza slice: right at the rim, pinched shut at the
65
- * hole. The inset is capped at the wedge's own half-span so a sliver of a slice
66
- * narrows to nothing rather than turning inside out.
55
+ * A wedge over `a0`..`a1` (radians clockwise from 12 o'clock) from `rInner` to
56
+ * `rOuter`, centred on `cx`; with `rInner <= 0` it closes at an apex pulled
57
+ * off the centre by the gap. `halfGap` is half the px gap to the neighbour,
58
+ * converted to an angle per radius so the channel keeps one width across the
59
+ * band; the inset is capped at the wedge's half-span so a sliver narrows to
60
+ * nothing rather than turning inside out. Corners are rounded by `corner` px
61
+ * (clamped to what the wedge can hold), except a pie's apex.
67
62
  */
68
- function wedgePath(a0, a1, rInner, rOuter, halfGap, cx) {
63
+ export function wedgePath(a0, a1, rInner, rOuter, halfGap, corner, cx) {
69
64
  const path = new PathBuilder();
70
65
  const span = a1 - a0;
71
66
  const steps = Math.max(2, Math.ceil((Math.abs(span) / (Math.PI * 2)) * ARC_STEPS));
72
- /** Angular inset at radius `r` that puts the wedge's edge `halfGap` px off the divide. */
73
- const insetAt = (r) => r <= 0 ? 0 : Math.min(halfGap / r, span / 2.2);
74
- const arcTo = (r, i) => {
75
- const inset = insetAt(r);
76
- const from = a0 + inset;
77
- const to = a1 - inset;
78
- const point = ringPoint(r, from + ((to - from) * i) / steps);
79
- return { x: point.x + cx, y: point.y };
67
+ const insetAt = (r) => (r <= 0 ? 0 : Math.min(halfGap / r, span / 2.2));
68
+ const at = (r, a) => {
69
+ const p = ringPoint(r, a);
70
+ return { x: p.x + cx, y: p.y };
80
71
  };
81
- if (rInner <= 0) {
82
- // Solid pie wedge: the sides converge on the centre, so only the outer rim
83
- // carries the gap.
84
- path.moveTo(cx, 0);
72
+ const hasHole = rInner > 0;
73
+ const oa0 = a0 + insetAt(rOuter);
74
+ const oa1 = a1 - insetAt(rOuter);
75
+ const ia0 = a0 + insetAt(rInner);
76
+ const ia1 = a1 - insetAt(rInner);
77
+ const O0 = at(rOuter, oa0);
78
+ const O1 = at(rOuter, oa1);
79
+ const half = Math.max(1e-3, Math.min(span / 2, Math.PI / 2));
80
+ const apex = at(halfGap > 0 ? Math.min(halfGap / Math.sin(half), rOuter * 0.8) : 0, (a0 + a1) / 2);
81
+ const I0 = hasHole ? at(rInner, ia0) : apex;
82
+ const I1 = hasHole ? at(rInner, ia1) : apex;
83
+ const band = hasHole ? rOuter - rInner : rOuter;
84
+ const rho = Math.max(0, Math.min(corner, band / 2.2, ((oa1 - oa0) * rOuter) / 2.2, hasHole ? ((ia1 - ia0) * rInner) / 2.2 : Infinity));
85
+ const rounded = rho >= 0.5;
86
+ const toward = (from, to, d) => {
87
+ const len = Math.hypot(to.x - from.x, to.y - from.y) || 1;
88
+ const k = Math.min(0.5, d / len);
89
+ return { x: from.x + (to.x - from.x) * k, y: from.y + (to.y - from.y) * k };
90
+ };
91
+ const dOuter = rounded ? rho / Math.max(1, rOuter) : 0;
92
+ const dInner = rounded && hasHole ? rho / rInner : 0;
93
+ const arc = (r, from, to) => {
85
94
  for (let i = 0; i <= steps; i++) {
86
- const p = arcTo(rOuter, i);
95
+ const p = at(r, from + ((to - from) * i) / steps);
87
96
  path.lineTo(p.x, p.y);
88
97
  }
89
- path.close();
90
- return path;
91
- }
92
- for (let i = 0; i <= steps; i++) {
93
- const p = arcTo(rOuter, i);
94
- if (i === 0)
95
- path.moveTo(p.x, p.y);
98
+ };
99
+ const round = (corner, next) => {
100
+ if (rounded)
101
+ path.quadraticCurveTo(corner.x, corner.y, next.x, next.y);
96
102
  else
97
- path.lineTo(p.x, p.y);
103
+ path.lineTo(corner.x, corner.y);
104
+ };
105
+ const first = at(rOuter, oa0 + dOuter);
106
+ path.moveTo(first.x, first.y);
107
+ arc(rOuter, oa0 + dOuter, oa1 - dOuter);
108
+ round(O1, toward(O1, I1, rho));
109
+ if (hasHole) {
110
+ const beforeI1 = toward(I1, O1, rho);
111
+ path.lineTo(beforeI1.x, beforeI1.y);
112
+ round(I1, at(rInner, ia1 - dInner));
113
+ arc(rInner, ia1 - dInner, ia0 + dInner);
114
+ round(I0, toward(I0, O0, rho));
98
115
  }
99
- // Back along the inner arc — its own (wider) inset makes each side edge a
100
- // straight chord sitting `halfGap` px off the divide at both ends.
101
- for (let i = steps; i >= 0; i--) {
102
- const p = arcTo(rInner, i);
103
- path.lineTo(p.x, p.y);
116
+ else {
117
+ path.lineTo(apex.x, apex.y);
104
118
  }
119
+ const beforeO0 = toward(O0, I0, rounded ? rho : 0);
120
+ path.lineTo(beforeO0.x, beforeO0.y);
121
+ round(O0, first);
105
122
  path.close();
106
123
  return path;
107
124
  }
108
- /**
109
- * The ring's geometry for one box: every radius, gutter and label position,
110
- * derived from the entries and the size the ring was laid out at, and asked
111
- * afresh wherever it is read — the drawing, and the labels' bound positions
112
- * and sizes alike, so the two cannot disagree.
113
- */
125
+ /** Every radius, gutter and label position for one box, derived afresh wherever read so the drawing and the labels' bound positions cannot disagree. */
114
126
  export class RingGeometry {
115
127
  s;
116
128
  box;
@@ -118,30 +130,19 @@ export class RingGeometry {
118
130
  this.s = s;
119
131
  this.box = box;
120
132
  }
121
- /** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
133
+ /** Re-read every frame so a value tween re-cuts them. */
122
134
  get arcs() {
123
135
  return pieArcs(this.s.slices, this.s.ring.startAngle);
124
136
  }
125
- /** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
137
+ /** Leader labels are the only thing that costs a gutter. */
126
138
  get hasLeaderLabels() {
127
- return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0;
139
+ return this.s.labelPlacement === "outside" && this.s.labelMode !== "none" && this.arcs.length > 0;
128
140
  }
129
- // ---- Label sizing ------------------------------------------------------
130
141
  /**
131
- * How wide a horizontal label centred in slice `arc`'s band can run before it
132
- * leaves the wedge, in px.
133
- *
134
- * Two things bound it, and which one binds depends entirely on where the wedge
135
- * sits. Stepping sideways from the label's centre moves you *radially* by
136
- * `dx·sin(mid)` and *tangentially* by `dx·cos(mid)`, so a wedge at 3 o'clock is
137
- * limited by the band's thickness (its label runs straight across the band)
138
- * while a wedge at 12 o'clock is limited by its own arc (its label runs along
139
- * the band, which is much roomier). Taking the smaller of the two is what stops
140
- * a label overflowing the ring on one chart and being pointlessly shrunk on the
141
- * next.
142
- *
143
- * Measured off the *unpopped* radius, so a slice growing out of the ring
144
- * doesn't resize its own text mid-animation.
142
+ * Px a horizontal label centred in `arc`'s band can run before leaving the
143
+ * wedge: bound by the band's thickness near 3 o'clock, by its arc near 12.
144
+ * Measured off the unpopped radius, so a slice growing out of the ring does not
145
+ * resize its own text mid-animation.
145
146
  */
146
147
  insideRoom(arc) {
147
148
  const outer = this.outerRadius;
@@ -159,20 +160,11 @@ export class RingGeometry {
159
160
  const mode = this.s.labelMode;
160
161
  const category = this.s.slices[arc.index]?.category.length ?? 0;
161
162
  const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0;
162
- // The caption is set at a fraction of the leading line, except when it *is*
163
- // the leading line.
163
+ // The caption is a fraction of the leading line, unless it is the leading line.
164
164
  const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0;
165
165
  return Math.max(1, percent, caption);
166
166
  }
167
- /**
168
- * Font size of an inside label's leading line: what the theme asked for,
169
- * shrunk if that wouldn't fit inside its wedge. **One size across every inside
170
- * label** — the smallest any of them needs — because labels at mismatched
171
- * sizes read as a mistake rather than as emphasis.
172
- *
173
- * So the theme's `insideStyle.fontSize` is an upper bound, not a promise: ask
174
- * for the size the chart wants and the ring keeps it legal.
175
- */
167
+ /** The theme's `insideStyle.fontSize` shrunk to fit, an upper bound. One size across every inside label (the smallest any needs): mismatched sizes read as a mistake. */
176
168
  get insideFont() {
177
169
  let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9);
178
170
  for (const arc of this.arcs) {
@@ -181,12 +173,7 @@ export class RingGeometry {
181
173
  }
182
174
  return Math.max(1, size);
183
175
  }
184
- /**
185
- * Font size of the hole's summary: the configured size, capped so it still
186
- * fits across the hole. The cap matters because the hole's radius derives from
187
- * the box, so a chart that reflows smaller would otherwise push its summary out
188
- * through the ring.
189
- */
176
+ /** The configured size capped to fit across the hole, which derives from the box: a smaller reflow would otherwise push the summary through the ring. */
190
177
  get centerFont() {
191
178
  const { centerStyle, centerLabel, centerCaption } = this.s;
192
179
  const wanted = sizeOf(centerStyle.labelStyle, 46);
@@ -194,25 +181,19 @@ export class RingGeometry {
194
181
  if (hole <= 0)
195
182
  return wanted;
196
183
  const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted);
197
- // Both lines have to clear the hole, and the caption is set at a fraction of
198
- // this size — so what binds is whichever is wider once drawn, not whichever
199
- // has more characters.
184
+ // Both lines must clear the hole; the caption is a fraction of this size, so the wider once drawn binds, not the longer.
200
185
  const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio);
201
186
  return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO));
202
187
  }
203
- // ---- Ring geometry, all derived from the box ---------------------------
204
- /** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
188
+ /** Rim radius of a fully selected slice as a multiple of the ring's. */
205
189
  get growFactor() {
206
190
  return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow));
207
191
  }
208
- /** How far past the ring's rim a fully selected slice reaches, in px. */
209
192
  get maxPop() {
210
193
  return this.outerRadius * (this.growFactor - 1);
211
194
  }
212
- /** Height of one **leader** label block, in px — the unit those columns are spaced by. */
195
+ /** Height of one leader label block, the unit the columns are spaced by. Inside labels cost no vertical reserve. */
213
196
  get labelRowHeight() {
214
- // Inside labels are stacked in the band, not in a column beside the ring, so
215
- // they cost no vertical reserve.
216
197
  if (!this.hasLeaderLabels)
217
198
  return 0;
218
199
  const caption = sizeOf(this.s.label.textStyle) * 1.25;
@@ -221,7 +202,7 @@ export class RingGeometry {
221
202
  return caption + percent + 2;
222
203
  return this.s.labelMode === "category" ? caption : percent;
223
204
  }
224
- /** Widest line, in glyphs, that a leader label for slice `index` can run to. */
205
+ /** Widest line in glyphs a leader label for slice `index` runs to. */
225
206
  captionGlyphs(index) {
226
207
  const mode = this.s.labelMode;
227
208
  // "100%" is the widest a percentage gets; captions are their own length.
@@ -229,14 +210,7 @@ export class RingGeometry {
229
210
  const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0;
230
211
  return Math.max(percent, category);
231
212
  }
232
- /**
233
- * Horizontal room reserved on each side for the leaders and their labels, in
234
- * px — measured **per side**, from the slices that actually leader out that
235
- * way. A ring whose labels are all inside reserves nothing and draws bigger.
236
- *
237
- * Excludes the ring's own selection-growth allowance, which scales with the
238
- * radius and so is divided out in {@link outerRadius} instead.
239
- */
213
+ /** Room reserved per side for leaders and labels, from the slices that leader out that way. Excludes selection growth, which scales with the radius and is divided out in {@link outerRadius}. */
240
214
  get labelGutters() {
241
215
  const gutters = { left: BARE_PAD, right: BARE_PAD };
242
216
  if (!this.hasLeaderLabels)
@@ -249,28 +223,17 @@ export class RingGeometry {
249
223
  }
250
224
  return gutters;
251
225
  }
252
- /**
253
- * How far the ring's centre sits from the node's, in px. Zero while the
254
- * gutters match; when the labels all fall one way it slides the ring into the
255
- * empty half so the ring-plus-labels group stays centred in the box instead of
256
- * the ring sitting dead centre with a bald patch beside it.
257
- */
226
+ /** Slides the ring into the empty half when labels all fall one way, keeping the ring-plus-labels group centred. */
258
227
  get centerX() {
259
228
  const { left, right } = this.labelGutters;
260
229
  return (left - right) / 2;
261
230
  }
262
- /**
263
- * Radius of the ring's outer edge, fitted to whatever the label gutters leave.
264
- * A fully selected slice reaches `radius × growFactor`, so the room has to
265
- * cover that — hence the divisor, which keeps even the grown slice inside the
266
- * box (and holds the ring the same size whether or not anything is selected).
267
- */
231
+ /** Fitted to what the gutters leave. The divisor keeps a fully selected slice (`radius * growFactor`) inside the box, and the ring one size whether or not anything is selected. */
268
232
  get outerRadius() {
269
233
  const { left, right } = this.labelGutters;
270
234
  const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4);
271
235
  return Math.max(0, room / this.growFactor);
272
236
  }
273
- /** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
274
237
  get holeRadius() {
275
238
  const configured = this.s.ring.holeRadius;
276
239
  if (configured <= 0)
@@ -278,30 +241,22 @@ export class RingGeometry {
278
241
  const px = configured < 1 ? configured * this.outerRadius : configured;
279
242
  return Math.min(px, this.outerRadius * 0.95);
280
243
  }
281
- /** Radius of the circle every leader elbows on, clear of even a popped-out slice. */
244
+ /** The circle every leader elbows on, clear of even a popped-out slice. */
282
245
  get elbowRadius() {
283
246
  return this.outerRadius + this.maxPop + LEADER_LENGTH;
284
247
  }
285
- /** How far slice `index` is currently pushed out, in `[0, 1]`. */
286
248
  popOf(index) {
287
- return clampUnit(this.s.pops[index] ?? 0);
249
+ return clamp(this.s.pops[index] ?? 0, 0, 1);
288
250
  }
289
- /** Radius of slice `index`'s outer edge, its selection growth included. */
290
251
  anchorRadius(index) {
291
252
  return this.outerRadius + this.maxPop * this.popOf(index);
292
253
  }
293
- /**
294
- * Radius an inside label sits at on slice `index`: halfway across the band on a
295
- * donut, and further out than halfway on a solid pie, where "halfway" would
296
- * crowd the point every wedge converges on. Rides the slice's own pop, so an
297
- * inside label travels out with the wedge it belongs to.
298
- */
254
+ /** Halfway across the band on a donut, further out on a solid pie, where halfway would crowd the point the wedges converge on. Rides the slice's pop. */
299
255
  bandMidRadius(index) {
300
256
  const rim = this.anchorRadius(index);
301
257
  const hole = this.holeRadius;
302
258
  return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS;
303
259
  }
304
- /** The point slice `index`'s inside label is centred on. */
305
260
  insideAnchor(index) {
306
261
  const arc = this.arcs.find((a) => a.index === index);
307
262
  if (arc === undefined)
@@ -309,43 +264,22 @@ export class RingGeometry {
309
264
  const point = ringPoint(this.bandMidRadius(index), arc.mid);
310
265
  return { x: point.x + this.centerX, y: point.y };
311
266
  }
312
- /**
313
- * How selected the ring is overall, in `[0, 1]` — the most-grown slice. Rides
314
- * the same tween the pops do, so the dim below arrives with the growth rather
315
- * than needing an animation of its own.
316
- */
267
+ /** The most-grown slice, so the dim in {@link sliceOpacity} rides the pops' tween. */
317
268
  get selectionStrength() {
318
269
  let strongest = 0;
319
270
  for (let i = 0; i < this.s.slices.length; i++)
320
271
  strongest = Math.max(strongest, this.popOf(i));
321
272
  return strongest;
322
273
  }
323
- /**
324
- * Opacity slice `index` (and its leader and label) is drawn at: full while
325
- * nothing is selected, falling toward `dimOpacity` in proportion to how
326
- * selected the *rest* of the ring is. A slice that's selected itself stays at
327
- * full — the two factors cancel.
328
- */
274
+ /** Opacity of slice `index` with its leader and label. */
329
275
  sliceOpacity(index) {
330
- const focus = this.selectionStrength;
331
- if (focus <= 0)
332
- return 1;
333
- return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index));
276
+ return selectionFade(this.selectionStrength, this.s.dimOpacity, this.popOf(index));
334
277
  }
335
- /** Slice `index`'s share of the ring as its label reads it, off the live values. */
336
278
  percentText(index) {
337
279
  const arc = this.arcs.find((a) => a.index === index);
338
280
  return formatPercent(arc?.fraction ?? 0);
339
281
  }
340
- // ---- Label placement ---------------------------------------------------
341
- /**
342
- * Where every leader label sits and how its leader gets there. Labels are split
343
- * into a right and a left column by their slice's bisector, each column is
344
- * spread so nothing overlaps (and nothing leaves the box), and each leader is
345
- * then built as wedge edge → elbow → label. The elbow rides the leader circle at
346
- * the label's *settled* y, which keeps the final run horizontal and the whole
347
- * polyline outside the ring however far the label was nudged.
348
- */
282
+ /** Labels split into left and right columns by their slice's bisector, each spread so nothing overlaps or leaves the box, then a leader built to each. */
349
283
  placements() {
350
284
  if (!this.hasLeaderLabels)
351
285
  return [];
@@ -369,7 +303,7 @@ export class RingGeometry {
369
303
  return [...right, ...left].map((slot) => {
370
304
  const start = ringPoint(this.anchorRadius(slot.index) + LEADER_LIFTOFF, slot.mid);
371
305
  // The elbow sits where the label's y crosses the leader circle. Solving
372
- // x² + y² = radius² for x keeps it *on* that circle (never inside the
306
+ // x^2 + y^2 = radius^2 for x keeps it on that circle (never inside the
373
307
  // ring); a label pushed past the circle's top or bottom falls back to the
374
308
  // sideways floor so its leader still reads as pointing outward.
375
309
  const onCircle = Math.sqrt(Math.max(0, radius * radius - slot.y * slot.y));
@@ -387,65 +321,32 @@ export class RingGeometry {
387
321
  };
388
322
  });
389
323
  }
390
- /** This frame's placement for slice `index`, or `undefined` if it isn't leadered. */
391
324
  placementFor(index) {
392
325
  return this.placements().find((placement) => placement.index === index);
393
326
  }
394
327
  }
395
- /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
396
328
  function sideOf(mid) {
397
329
  return Math.sin(mid) >= 0 ? 1 : -1;
398
330
  }
399
331
  const NO_BOX = { width: 0, height: 0 };
400
- /**
401
- * Each slice's fill, resolved once per paint rather than every frame: the slices
402
- * are bound to the chart's rows and palette, so a theme tween hands over new
403
- * paint and the drawing must see it, but a frame that moves nothing must not
404
- * re-resolve. Keyed by the paint itself, so it is shared and needs no node.
405
- */
406
- const resolvedFills = new WeakMap();
407
- function resolvedFill(fill) {
408
- if (typeof fill !== "object" || fill === null)
409
- return fillOps.resolve(fill);
410
- let resolved = resolvedFills.get(fill);
411
- if (resolved === undefined)
412
- resolvedFills.set(fill, resolved = fillOps.resolve(fill));
413
- return resolved;
414
- }
415
- /** Each slice's fill, resolved. */
416
- export function ringFills(slices) {
417
- return slices.map((slice) => resolvedFill(slice.fill));
418
- }
419
- /**
420
- * The labels a ring was built with: one per *drawn* slice (a zero-valued slice
421
- * gets no wedge, so no label either), in whichever of the two forms the chart
422
- * asked for, then the hole's summary. Both are real Text stacked in a hug
423
- * vertical flow — the difference is only what they are pinned to and which line
424
- * leads. The point each is pinned to stays reactive and follows the ring as the
425
- * box resizes or its slice pops out; only the choice of form, which depends on
426
- * entries alone, is settled here.
427
- */
332
+ /** Fills resolved once per paint, not per frame: a theme tween hands over new paint, but a frame that moves nothing must not re-resolve. Keyed by the paint, so it needs no node. */
333
+ /** One label per drawn slice (a zero-valued slice has no wedge), then the hole's summary. Only the choice of form is settled here; the pinned points stay reactive. */
428
334
  function ringLabels(s) {
429
335
  const blocks = [];
430
- // A label block's parent is the ring: its box is what the ring was laid out at.
336
+ // A label block's parent is the ring, whose box it was laid out at.
431
337
  const geometry = () => new RingGeometry(s, blocks[0]?.parent?.layoutBounds ?? NO_BOX);
432
338
  const label = (arc) => {
433
- // Fades in with the ring's labels, then dims along with its own slice
434
- // whenever another slice takes the focus.
435
- const opacity = () => clampUnit(s.labelReveal) * geometry().sliceOpacity(arc.index);
339
+ const opacity = () => clamp(s.labelReveal, 0, 1) * geometry().sliceOpacity(arc.index);
436
340
  const wantsCategory = s.labelMode === "both" || s.labelMode === "category";
437
341
  const wantsPercent = s.labelMode === "both" || s.labelMode === "percent";
438
342
  if (s.labelPlacement === "inside") {
439
- // The number leads and the caption sits under it: inside a wedge the
440
- // reader already knows *which* wedge they are looking at, so the magnitude
441
- // is what the label is for. Both sizes are bound rather than read once —
442
- // the fitted size derives from the ring's radius, which isn't known until
443
- // the node has been laid out.
343
+ // The number leads: inside a wedge the reader knows which slice it is. Sizes are bound because the fitted size derives from a radius unknown until layout.
444
344
  const inside = [];
445
345
  if (wantsPercent) {
446
346
  inside.push(new Text({
447
347
  ...textOps.styleProps(s.label.insideStyle),
448
348
  text: () => geometry().percentText(arc.index),
349
+ fill: () => onFill(s.slices[arc.index]?.fill ?? "white"),
449
350
  fontSize: () => geometry().insideFont,
450
351
  }));
451
352
  }
@@ -453,6 +354,7 @@ function ringLabels(s) {
453
354
  inside.push(new Text({
454
355
  ...textOps.styleProps(s.label.insideStyle),
455
356
  text: () => s.slices[arc.index]?.category ?? "",
357
+ fill: () => onFill(s.slices[arc.index]?.fill ?? "white"),
456
358
  fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
457
359
  }));
458
360
  }
@@ -491,7 +393,6 @@ function ringLabels(s) {
491
393
  gap: 2,
492
394
  align: side > 0 ? "centerLeft" : "centerRight",
493
395
  opacity,
494
- // Exactly one of these is set; `undefined` anchors are ignored.
495
396
  centerLeft: side > 0 ? at : undefined,
496
397
  centerRight: side > 0 ? undefined : at,
497
398
  children,
@@ -501,8 +402,7 @@ function ringLabels(s) {
501
402
  for (const arc of pieArcs(s.slices, s.ring.startAngle))
502
403
  blocks.push(label(arc));
503
404
  }
504
- // The hole's summary, pinned to the ring's centre so it tracks the sideways
505
- // shift a one-sided label column puts on the ring.
405
+ // Pinned to the ring's centre, so it tracks the sideways shift a one-sided label column causes.
506
406
  if (s.centerLabel !== "" || s.centerCaption !== "") {
507
407
  const children = [];
508
408
  if (s.centerLabel !== "") {
@@ -525,7 +425,7 @@ function ringLabels(s) {
525
425
  height: "hug",
526
426
  gap: 2,
527
427
  align: "center",
528
- opacity: () => clampUnit(s.centerReveal),
428
+ opacity: () => clamp(s.centerReveal, 0, 1) * clamp(geometry().holeRadius / 20, 0, 1),
529
429
  center: () => ({ x: geometry().centerX, y: 0 }),
530
430
  children,
531
431
  }));
@@ -533,94 +433,60 @@ function ringLabels(s) {
533
433
  return asParts(blocks);
534
434
  }
535
435
  /**
536
- * The ring itself: one wedge per {@link PieSlice}, sized by its share of the
537
- * total, swept clockwise from the theme's `startAngle` with a `gap` of clear
538
- * space between neighbours — a *constant-width* channel, the same at the hole as
539
- * at the rim (see {@link wedgePath}). A **selected** slice reaches further out
540
- * while its hole edge stays put — so it reads as that band swelling out of the
541
- * ring rather than breaking off it — and the rest of the ring dims behind it,
542
- * leaders and labels included.
543
- *
544
- * This is the only part of the chart family that draws a path: the wedges are
545
- * filled {@link PathBuilder} polygons approximating each arc, and the leaders are
546
- * stroked polylines — both in the node's centred, y-up space, so neither depends
547
- * on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
548
- * (stacked in a hug vertical flow and pinned by their inner edge), so no
549
- * glyph-width guesswork decides where a caption starts.
550
- *
551
- * ### Label placement
552
- * Each label sits either **inside** its wedge or **outside** on a leader.
553
- *
554
- * An inside label is centred on its slice's band and leads with the number, the
555
- * caption set small underneath — inside a wedge the reader already knows which
556
- * slice they're on, so the magnitude is the thing being read. It costs no gutter
557
- * and no eye round-trip, so a ring labelled that way draws considerably bigger in
558
- * the same box.
559
- *
560
- * A leader label sits outside on the end of a polyline that lifts off its wedge,
561
- * elbows, and runs horizontally into the text. Labels are split into a left and a
562
- * right column by which way their slice's bisector points, and each column is
563
- * de-conflicted independently (see {@link relaxLabels}): a label takes the spot
564
- * nearest its wedge that still clears its neighbour, and the column is squeezed
565
- * to fit the node's height rather than spilling out of it. The elbow then rides on
566
- * a circle just outside the ring **at the label's settled y**, so the run into the
567
- * text is always horizontal and a nudged label never drags its leader back across
568
- * the ring.
569
- *
570
- * Only the sides that actually carry a leader reserve a gutter, and the ring's
571
- * centre slides into whatever the other side left over — so a ring whose slices
572
- * all leader one way doesn't leave a bald patch on the other.
436
+ * The ring: one wedge per {@link PieSlice} swept clockwise from `startAngle`,
437
+ * with a constant-width `gap` (see {@link wedgePath}). A selected slice swells
438
+ * out while its hole edge stays put, and the rest dims, leaders and labels
439
+ * included. The only path-drawing part of the chart family; labels are real
440
+ * {@link Text} nodes, so no glyph-width guesswork places a caption. Only the
441
+ * sides that carry a leader reserve a gutter, and the ring slides into the rest.
573
442
  */
574
443
  export const PieRing = component2d({
575
444
  key: "pieRing",
576
445
  state: {
577
446
  slices: fresh(() => []),
578
- ring: Types.custom(DEFAULT_PIE_CHART_THEME.ring),
579
- label: Types.custom(DEFAULT_PIE_CHART_THEME.label),
580
- centerStyle: Types.custom(DEFAULT_PIE_CHART_THEME.center),
447
+ ring: Types.custom(DEFAULT_RING),
448
+ label: Types.custom(DEFAULT_LABEL),
449
+ // Not `center`: every node already has one, the anchor its position is measured from.
450
+ centerStyle: Types.custom(DEFAULT_CENTER),
581
451
  labelMode: Types.custom("percent"),
582
- labelPlacement: Types.custom("leader"),
452
+ labelPlacement: Types.custom("outside"),
453
+ /** Sweep `[0, 1]`: the wedges grow clockwise from `startAngle` as it rises. */
583
454
  growth: 1,
455
+ /** Fade of leaders and labels, separate from `growth` so they arrive after the sweep. */
584
456
  labelReveal: 1,
457
+ /** Per-slice pop `[0, 1]`, owned by the chart so the ring and legend read one signal. */
585
458
  pops: fresh(() => []),
586
- dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
459
+ /** Opacity everything unselected falls back to while any slice is selected. */
460
+ dimOpacity: PIE_SPOTLIGHT_DIM,
461
+ /** Empty leaves the hole clear; ignored when there is no hole. */
587
462
  centerLabel: "",
588
463
  centerCaption: "",
464
+ /** The hole's summary fade, untouched by the entrance: a scene that wants the conclusion on a later beat sets 0 and calls `revealCenter`. */
589
465
  centerReveal: 1,
590
466
  },
591
467
  // A plain node filled its parent; one made of its labels would hug them.
592
468
  defaults: { width: "fill", height: "fill" },
593
469
  children: (state) => ringLabels(state),
594
- // The ring's paints live nowhere a paint entry would look: one fill per slice,
595
- // the wedge outline and drop shadow of `ring`, and the leader line's own stroke.
470
+ // The paints sit past the paint entries: a fill per slice, the wedge outline and shadow, and the leader stroke.
596
471
  load: (loader, state) => loadPaints(loader, {
597
472
  fills: ringFills(state.slices),
598
- shadows: [shadowOps.resolve(state.ring.shadow)],
599
- strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
473
+ strokes: [strokeOps.resolve(state.label.leaderStroke)],
600
474
  }),
601
475
  commands: {
602
- /**
603
- * Tween `growth` `0 → 1`, wiping the wedges in around the ring. No priming
604
- * to 0 first — the value at `t` is a function of `t`, so a repeat call
605
- * re-sweeps by definition rather than by having reset something.
606
- */
476
+ /** Wipe the wedges in around the ring (`growth` 0 to 1). */
607
477
  sweep: (timeline) => {
608
478
  const ease = frameEase(timeline);
609
479
  return timeline.animate((t) => ({ growth: ease(t) }));
610
480
  },
611
- /** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
481
+ /** Fade the leaders and captions up. */
612
482
  revealLabels: (timeline) => {
613
483
  const ease = frameEase(timeline);
614
484
  return timeline.animate((t) => ({ labelReveal: ease(t) }));
615
485
  },
616
- /**
617
- * Fade the hole's summary up. Deliberately not part of the ring's entrance: a
618
- * hole label is usually the chart's conclusion, so the scene decides when it
619
- * lands.
620
- */
486
+ /** Fade the hole's summary up; not part of the entrance, since it is usually the conclusion. */
621
487
  revealCenter: (timeline, state) => {
622
488
  const ease = frameEase(timeline);
623
- const from = clampUnit(state.centerReveal);
489
+ const from = clamp(state.centerReveal, 0, 1);
624
490
  return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }));
625
491
  },
626
492
  },
@@ -629,16 +495,16 @@ export const PieRing = component2d({
629
495
  const rOuter = g.outerRadius;
630
496
  const arcs = rOuter > 0 ? g.arcs : [];
631
497
  if (arcs.length > 0) {
632
- const sweep = clampUnit(state.growth);
498
+ const sweep = clamp(state.growth, 0, 1);
633
499
  const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep;
634
500
  const hole = g.holeRadius;
635
501
  const halfGap = Math.max(0, state.ring.gap) / 2;
636
502
  // One shared frame (the whole ring square, grown slices included) pinned onto
637
503
  // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
638
504
  // own bbox and collapse them all onto the origin. It stays symmetric about the
639
- // *node's* origin — so path coordinates land exactly in node space — and is
640
- // grown by the ring's sideways offset rather than moved with it, which would
641
- // undo the very shift it has to contain.
505
+ // node's origin, so path coordinates land exactly in node space, and is grown
506
+ // by the ring's sideways offset rather than moved with it, which would undo
507
+ // the very shift it has to contain.
642
508
  const cx = g.centerX;
643
509
  const half = rOuter + g.maxPop + 1 + Math.abs(cx);
644
510
  const centerBounds = [-half, -half, half, half];
@@ -648,23 +514,19 @@ export const PieRing = component2d({
648
514
  const span = half * 2;
649
515
  const fills = ringFills(state.slices);
650
516
  for (const arc of arcs) {
651
- // Clip the wedge to the growth-revealed arc; skip it until the sweep
652
- // reaches it, so the ring draws in as one clockwise wipe.
517
+ // Clipped to the revealed arc, so the ring draws in as one clockwise wipe.
653
518
  const a1 = Math.min(arc.a1, revealEnd);
654
519
  if (a1 <= arc.a0)
655
520
  continue;
656
- const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx);
521
+ const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, Math.max(0, state.ring.cornerRadius), cx);
657
522
  const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }));
658
- wedge.shadow(state.ring.shadow);
659
523
  wedge.fill(fills[arc.index] ?? []);
660
- wedge.stroke(state.ring.stroke);
661
524
  ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)));
662
525
  }
663
- // Leaders. Each polyline gets its own `Graphics2D`: several open polylines
664
- // chained into one command list are combined into a single surface before the
665
- // stroke, which closes each contour back on itself and paints the leaders as
666
- // thin triangles.
667
- const reveal = clampUnit(state.labelReveal);
526
+ // One `Graphics2D` per leader: open polylines chained into one command list
527
+ // are combined into a single surface before the stroke, which closes each
528
+ // contour and paints the leaders as thin triangles.
529
+ const reveal = clamp(state.labelReveal, 0, 1);
668
530
  if (reveal > 0.001) {
669
531
  for (const placement of g.placements()) {
670
532
  ctx.draw(new Graphics2D()