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