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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +48 -0
  4. package/README.md +99 -0
  5. package/dist/bar-chart/bar-chart.d.ts +93 -231
  6. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  7. package/dist/bar-chart/bar-chart.js +378 -565
  8. package/dist/bar-chart/bar-chart.js.map +1 -1
  9. package/dist/bar-chart/category-axis.d.ts +24 -41
  10. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  11. package/dist/bar-chart/category-axis.js +93 -115
  12. package/dist/bar-chart/category-axis.js.map +1 -1
  13. package/dist/bar-chart/chart-bars.d.ts +36 -70
  14. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  15. package/dist/bar-chart/chart-bars.js +156 -210
  16. package/dist/bar-chart/chart-bars.js.map +1 -1
  17. package/dist/bar-chart/index.d.ts +2 -2
  18. package/dist/bar-chart/index.d.ts.map +1 -1
  19. package/dist/bar-chart/index.js +1 -1
  20. package/dist/bar-chart/index.js.map +1 -1
  21. package/dist/bar-chart/shared.d.ts +6 -6
  22. package/dist/bar-chart/shared.d.ts.map +1 -1
  23. package/dist/bar-chart/shared.js +6 -6
  24. package/dist/bar-chart/shared.js.map +1 -1
  25. package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
  26. package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
  27. package/dist/browser/index.js +1 -1
  28. package/dist/browser/index.js.map +3 -3
  29. package/dist/browser/kit.js +1 -1
  30. package/dist/browser/manifest.json +3 -2
  31. package/dist/engine.d.ts +8 -6
  32. package/dist/engine.d.ts.map +1 -1
  33. package/dist/engine.js +8 -6
  34. package/dist/engine.js.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/kit/cartesian.d.ts +44 -0
  37. package/dist/kit/cartesian.d.ts.map +1 -0
  38. package/dist/kit/cartesian.js +59 -0
  39. package/dist/kit/cartesian.js.map +1 -0
  40. package/dist/kit/chart-border.d.ts +8 -11
  41. package/dist/kit/chart-border.d.ts.map +1 -1
  42. package/dist/kit/chart-border.js +15 -32
  43. package/dist/kit/chart-border.js.map +1 -1
  44. package/dist/kit/chart-canvas.d.ts +23 -21
  45. package/dist/kit/chart-canvas.d.ts.map +1 -1
  46. package/dist/kit/chart-canvas.js +33 -90
  47. package/dist/kit/chart-canvas.js.map +1 -1
  48. package/dist/kit/chart-legend.d.ts +79 -88
  49. package/dist/kit/chart-legend.d.ts.map +1 -1
  50. package/dist/kit/chart-legend.js +137 -186
  51. package/dist/kit/chart-legend.js.map +1 -1
  52. package/dist/kit/entries.d.ts +17 -0
  53. package/dist/kit/entries.d.ts.map +1 -0
  54. package/dist/kit/entries.js +19 -0
  55. package/dist/kit/entries.js.map +1 -0
  56. package/dist/kit/motion.d.ts +48 -0
  57. package/dist/kit/motion.d.ts.map +1 -0
  58. package/dist/kit/motion.js +63 -0
  59. package/dist/kit/motion.js.map +1 -0
  60. package/dist/kit/paints.d.ts +10 -0
  61. package/dist/kit/paints.d.ts.map +1 -0
  62. package/dist/kit/paints.js +12 -0
  63. package/dist/kit/paints.js.map +1 -0
  64. package/dist/kit/shared.d.ts +59 -8
  65. package/dist/kit/shared.d.ts.map +1 -1
  66. package/dist/kit/shared.js +105 -10
  67. package/dist/kit/shared.js.map +1 -1
  68. package/dist/kit/vertical-axis.d.ts +36 -61
  69. package/dist/kit/vertical-axis.d.ts.map +1 -1
  70. package/dist/kit/vertical-axis.js +97 -132
  71. package/dist/kit/vertical-axis.js.map +1 -1
  72. package/dist/line-chart/chart-line.d.ts +27 -53
  73. package/dist/line-chart/chart-line.d.ts.map +1 -1
  74. package/dist/line-chart/chart-line.js +89 -120
  75. package/dist/line-chart/chart-line.js.map +1 -1
  76. package/dist/line-chart/chart-region.d.ts +14 -14
  77. package/dist/line-chart/chart-region.d.ts.map +1 -1
  78. package/dist/line-chart/chart-region.js +26 -64
  79. package/dist/line-chart/chart-region.js.map +1 -1
  80. package/dist/line-chart/horizontal-axis.d.ts +32 -56
  81. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  82. package/dist/line-chart/horizontal-axis.js +85 -123
  83. package/dist/line-chart/horizontal-axis.js.map +1 -1
  84. package/dist/line-chart/index.d.ts +2 -2
  85. package/dist/line-chart/index.d.ts.map +1 -1
  86. package/dist/line-chart/index.js +1 -1
  87. package/dist/line-chart/index.js.map +1 -1
  88. package/dist/line-chart/line-chart.d.ts +111 -311
  89. package/dist/line-chart/line-chart.d.ts.map +1 -1
  90. package/dist/line-chart/line-chart.js +403 -639
  91. package/dist/line-chart/line-chart.js.map +1 -1
  92. package/dist/line-chart/shared.d.ts +6 -6
  93. package/dist/line-chart/shared.d.ts.map +1 -1
  94. package/dist/line-chart/shared.js +22 -7
  95. package/dist/line-chart/shared.js.map +1 -1
  96. package/dist/line-chart/x-scale.d.ts +1 -1
  97. package/dist/line-chart/x-scale.js +1 -1
  98. package/dist/line-chart/x-scale.js.map +1 -1
  99. package/dist/nodes.d.ts +6 -9
  100. package/dist/nodes.d.ts.map +1 -1
  101. package/dist/nodes.js +7 -9
  102. package/dist/nodes.js.map +1 -1
  103. package/dist/pie-chart/index.d.ts +2 -2
  104. package/dist/pie-chart/index.d.ts.map +1 -1
  105. package/dist/pie-chart/index.js +1 -1
  106. package/dist/pie-chart/index.js.map +1 -1
  107. package/dist/pie-chart/pie-chart.d.ts +77 -134
  108. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  109. package/dist/pie-chart/pie-chart.js +199 -268
  110. package/dist/pie-chart/pie-chart.js.map +1 -1
  111. package/dist/pie-chart/pie-legend.d.ts +54 -31
  112. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  113. package/dist/pie-chart/pie-legend.js +93 -107
  114. package/dist/pie-chart/pie-legend.js.map +1 -1
  115. package/dist/pie-chart/pie-ring.d.ts +142 -122
  116. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  117. package/dist/pie-chart/pie-ring.js +333 -361
  118. package/dist/pie-chart/pie-ring.js.map +1 -1
  119. package/dist/pie-chart/shared.d.ts +3 -3
  120. package/dist/pie-chart/shared.js +3 -3
  121. package/dist/pie-chart/shared.js.map +1 -1
  122. package/package.json +6 -5
  123. package/registry.json +23 -23
  124. package/src/bar-chart/bar-chart.ts +534 -814
  125. package/src/bar-chart/category-axis.ts +162 -177
  126. package/src/bar-chart/chart-bars.ts +288 -310
  127. package/src/bar-chart/index.ts +31 -31
  128. package/src/bar-chart/shared.ts +354 -354
  129. package/src/engine.ts +28 -26
  130. package/src/index.ts +5 -5
  131. package/src/kit/cartesian.ts +81 -0
  132. package/src/kit/chart-border.ts +50 -61
  133. package/src/kit/chart-canvas.ts +122 -157
  134. package/src/kit/chart-legend.ts +229 -250
  135. package/src/kit/entries.ts +20 -0
  136. package/src/kit/motion.ts +90 -0
  137. package/src/kit/paints.ts +15 -0
  138. package/src/kit/shared.ts +782 -675
  139. package/src/kit/vertical-axis.ts +194 -224
  140. package/src/line-chart/chart-line.ts +181 -208
  141. package/src/line-chart/chart-region.ts +106 -135
  142. package/src/line-chart/horizontal-axis.ts +166 -202
  143. package/src/line-chart/index.ts +39 -39
  144. package/src/line-chart/line-chart.ts +585 -929
  145. package/src/line-chart/shared.ts +473 -459
  146. package/src/line-chart/x-scale.ts +367 -367
  147. package/src/nodes.ts +18 -20
  148. package/src/pie-chart/index.ts +37 -37
  149. package/src/pie-chart/pie-chart.ts +293 -381
  150. package/src/pie-chart/pie-legend.ts +169 -159
  151. package/src/pie-chart/pie-ring.ts +848 -903
  152. package/src/pie-chart/shared.ts +490 -490
  153. package/dist/browser/chunks/chunk-RMHQ6GYU.js +0 -2
  154. package/dist/browser/chunks/chunk-RMHQ6GYU.js.map +0 -7
@@ -1,13 +1,9 @@
1
- var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
- return c > 3 && r && Object.defineProperty(target, key, r), r;
6
- };
7
- import { Rect, command, Graphics2D, Node2D, PathBuilder, Text, easeOut, fillOps, property, strokeOps, textOps, } from "@motionscript/sdk";
1
+ import { Graphics2D, PathBuilder, Rect, Text, Types, component2d, fillOps, shadowOps, strokeOps, textOps, } from "@motionscript/sdk";
2
+ import { asParts, fresh } from "../kit/entries.js";
3
+ import { frameEase } from "../kit/motion.js";
4
+ import { loadPaints } from "../kit/paints.js";
8
5
  import { clampUnit, formatPercent } from "../kit/shared.js";
9
6
  import { DEFAULT_PIE_CHART_THEME, pieArcs, relaxLabels, ringPoint, } from "./shared.js";
10
- import { declarePaints } from "@motionscript/sdk/component";
11
7
  /** Vertical breathing room between one label block and the next, in px. */
12
8
  const LABEL_GAP = 12;
13
9
  /** Breathing room past the outermost label, so it never touches the node's edge, in px. */
@@ -110,181 +106,25 @@ function wedgePath(a0, a1, rInner, rOuter, halfGap, cx) {
110
106
  return path;
111
107
  }
112
108
  /**
113
- * The ring itself: one wedge per {@link PieSlice}, sized by its share of the
114
- * total, swept clockwise from the theme's `startAngle` with a `gap` of clear
115
- * space between neighbours — a *constant-width* channel, the same at the hole as
116
- * at the rim (see {@link wedgePath}). A **selected** slice reaches further out
117
- * while its hole edge stays put — so it reads as that band swelling out of the
118
- * ring rather than breaking off it — and the rest of the ring dims behind it,
119
- * leaders and labels included.
120
- *
121
- * This is the only part of the chart family that draws a path: the wedges are
122
- * filled {@link PathBuilder} polygons approximating each arc, and the leaders are
123
- * stroked polylines — both in the node's centred, y-up space, so neither depends
124
- * on SVG arc-sweep conventions. The **labels are real {@link Text} nodes**
125
- * (stacked in a hug vertical flow and pinned by their inner edge), so no
126
- * glyph-width guesswork decides where a caption starts.
127
- *
128
- * ### Label placement
129
- * Each label sits either **inside** its wedge or **outside** on a leader.
130
- *
131
- * An inside label is centred on its slice's band and leads with the number, the
132
- * caption set small underneath — inside a wedge the reader already knows which
133
- * slice they're on, so the magnitude is the thing being read. It costs no gutter
134
- * and no eye round-trip, so a ring labelled that way draws considerably bigger in
135
- * the same box.
136
- *
137
- * A leader label sits outside on the end of a polyline that lifts off its wedge,
138
- * elbows, and runs horizontally into the text. Labels are split into a left and a
139
- * right column by which way their slice's bisector points, and each column is
140
- * de-conflicted independently (see {@link relaxLabels}): a label takes the spot
141
- * nearest its wedge that still clears its neighbour, and the column is squeezed
142
- * to fit the node's height rather than spilling out of it. The elbow then rides on
143
- * a circle just outside the ring **at the label's settled y**, so the run into the
144
- * text is always horizontal and a nudged label never drags its leader back across
145
- * the ring.
146
- *
147
- * Only the sides that actually carry a leader reserve a gutter, and the ring's
148
- * centre slides into whatever the other side left over ({@link centerX}) — so a
149
- * ring whose slices all leader one way doesn't leave a bald patch on the other.
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.
150
113
  */
151
- export class PieRing extends Node2D {
152
- /** Each slice's fill, resolved once so `renderSelf` doesn't re-resolve every frame. */
153
- fills = [];
154
- constructor(props) {
155
- super(props);
156
- this.fills = this.slices.map((slice) => fillOps.resolve(slice.fill));
157
- // One label per *drawn* slice (a zero-valued slice gets no wedge, so no
158
- // label either), in whichever of the two forms the chart asked for. Both are
159
- // real Text stacked in a hug vertical flow — the difference is only what they are
160
- // pinned to and which line leads.
161
- //
162
- // The *point* each label is pinned to stays reactive and follows the ring as
163
- // the box resizes or its slice pops out; only the choice of form, which
164
- // depends on props alone, is settled here.
165
- if (this.labelPlacement !== "none" && this.labelMode !== "none") {
166
- for (const arc of this.arcs)
167
- this.addLabel(arc);
168
- }
169
- // The hole's summary, pinned to the ring's centre so it tracks the sideways
170
- // shift a one-sided label column puts on the ring.
171
- if (this.hasCenterLabel)
172
- this.addCenterLabel();
114
+ export class RingGeometry {
115
+ s;
116
+ box;
117
+ constructor(s, box) {
118
+ this.s = s;
119
+ this.box = box;
173
120
  }
174
- /** Build slice `arc`'s label, in whichever form the chart's placement asks for. */
175
- addLabel(arc) {
176
- const slice = this.slices[arc.index];
177
- // Fades in with the ring's labels, then dims along with its own slice
178
- // whenever another slice takes the focus.
179
- const opacity = () => clampUnit(this.labelReveal) * this.sliceOpacity(arc.index);
180
- const wantsCategory = this.labelMode === "both" || this.labelMode === "category";
181
- const wantsPercent = this.labelMode === "both" || this.labelMode === "percent";
182
- if (this.labelPlacement === "inside") {
183
- // The number leads and the caption sits under it: inside a wedge the
184
- // reader already knows *which* wedge they are looking at, so the magnitude
185
- // is what the label is for. Both sizes are bound rather than read once —
186
- // the fitted size derives from the ring's radius, which isn't known until
187
- // the node has been laid out.
188
- const inside = [];
189
- if (wantsPercent) {
190
- inside.push(new Text({
191
- ...textOps.styleProps(this.label.insideStyle),
192
- text: formatPercent(arc.fraction),
193
- fontSize: () => this.insideFont,
194
- }));
195
- }
196
- if (wantsCategory) {
197
- inside.push(new Text({
198
- ...textOps.styleProps(this.label.insideStyle),
199
- text: slice.category,
200
- fontSize: () => this.labelMode === "category"
201
- ? this.insideFont
202
- : this.insideFont * INSIDE_CAPTION_RATIO,
203
- }));
204
- }
205
- this.add(new Rect({ flow: "vertical",
206
- width: "hug",
207
- height: "hug",
208
- gap: 0,
209
- align: "center",
210
- opacity,
211
- center: () => this.insideAnchor(arc.index),
212
- children: inside,
213
- }));
214
- return;
215
- }
216
- const side = this.sideOf(arc.mid);
217
- const at = () => this.placementFor(arc.index)?.text ?? { x: 0, y: 0 };
218
- const children = [];
219
- if (wantsCategory) {
220
- children.push(new Text({
221
- ...textOps.styleProps(this.label.textStyle),
222
- text: slice.category,
223
- textAlign: side > 0 ? "left" : "right",
224
- }));
225
- }
226
- if (wantsPercent) {
227
- children.push(new Text({
228
- ...textOps.styleProps(this.label.percentStyle),
229
- text: formatPercent(arc.fraction),
230
- textAlign: side > 0 ? "left" : "right",
231
- }));
232
- }
233
- this.add(new Rect({ flow: "vertical",
234
- width: "hug",
235
- height: "hug",
236
- gap: 2,
237
- align: side > 0 ? "centerLeft" : "centerRight",
238
- opacity,
239
- // Exactly one of these is set; `undefined` anchors are ignored.
240
- centerLeft: side > 0 ? at : undefined,
241
- centerRight: side > 0 ? undefined : at,
242
- children,
243
- }));
244
- }
245
- /** Build the hole's summary block. */
246
- addCenterLabel() {
247
- const children = [];
248
- if (this.centerLabel !== "") {
249
- children.push(new Text({
250
- ...textOps.styleProps(this.centerStyle.labelStyle),
251
- text: this.centerLabel,
252
- fontSize: () => this.centerFont,
253
- }));
254
- }
255
- if (this.centerCaption !== "") {
256
- children.push(new Text({
257
- ...textOps.styleProps(this.centerStyle.captionStyle),
258
- text: this.centerCaption,
259
- fontSize: () => this.centerFont *
260
- (sizeOf(this.centerStyle.captionStyle, 22) /
261
- sizeOf(this.centerStyle.labelStyle, 46)),
262
- }));
263
- }
264
- this.add(new Rect({ flow: "vertical",
265
- width: "hug",
266
- height: "hug",
267
- gap: 2,
268
- align: "center",
269
- opacity: () => clampUnit(this.centerReveal),
270
- center: () => ({ x: this.centerX, y: 0 }),
271
- children,
272
- }));
273
- }
274
- // ---- Arcs --------------------------------------------------------------
275
121
  /** The drawn slices' angular spans, re-read every frame so a value tween re-cuts them. */
276
122
  get arcs() {
277
- return pieArcs(this.slices, this.ring.startAngle);
278
- }
279
- /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
280
- sideOf(mid) {
281
- return Math.sin(mid) >= 0 ? 1 : -1;
123
+ return pieArcs(this.s.slices, this.s.ring.startAngle);
282
124
  }
283
125
  /** Whether any slice is labelled on a leader — the only thing that costs a gutter. */
284
126
  get hasLeaderLabels() {
285
- return (this.labelPlacement === "leader" &&
286
- this.labelMode !== "none" &&
287
- this.arcs.length > 0);
127
+ return this.s.labelPlacement === "leader" && this.s.labelMode !== "none" && this.arcs.length > 0;
288
128
  }
289
129
  // ---- Label sizing ------------------------------------------------------
290
130
  /**
@@ -316,17 +156,12 @@ export class PieRing extends Node2D {
316
156
  }
317
157
  /** Widest line, in glyphs at the leading line's size, an inside label runs to. */
318
158
  insideGlyphs(arc) {
319
- const category = this.slices[arc.index]?.category.length ?? 0;
320
- const percent = this.labelMode === "both" || this.labelMode === "percent"
321
- ? formatPercent(arc.fraction).length
322
- : 0;
159
+ const mode = this.s.labelMode;
160
+ const category = this.s.slices[arc.index]?.category.length ?? 0;
161
+ const percent = mode === "both" || mode === "percent" ? formatPercent(arc.fraction).length : 0;
323
162
  // The caption is set at a fraction of the leading line, except when it *is*
324
163
  // the leading line.
325
- const caption = this.labelMode === "category"
326
- ? category
327
- : this.labelMode === "both"
328
- ? category * INSIDE_CAPTION_RATIO
329
- : 0;
164
+ const caption = mode === "category" ? category : mode === "both" ? category * INSIDE_CAPTION_RATIO : 0;
330
165
  return Math.max(1, percent, caption);
331
166
  }
332
167
  /**
@@ -339,18 +174,13 @@ export class PieRing extends Node2D {
339
174
  * for the size the chart wants and the ring keeps it legal.
340
175
  */
341
176
  get insideFont() {
342
- let size = sizeOf(this.label.insideStyle, FALLBACK_SIZE * 1.9);
177
+ let size = sizeOf(this.s.label.insideStyle, FALLBACK_SIZE * 1.9);
343
178
  for (const arc of this.arcs) {
344
- const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) /
345
- (this.insideGlyphs(arc) * GLYPH_RATIO);
179
+ const fits = (this.insideRoom(arc) * INSIDE_LABEL_FIT) / (this.insideGlyphs(arc) * GLYPH_RATIO);
346
180
  size = Math.min(size, fits);
347
181
  }
348
182
  return Math.max(1, size);
349
183
  }
350
- /** Whether the hole carries a summary at all. */
351
- get hasCenterLabel() {
352
- return this.centerLabel !== "" || this.centerCaption !== "";
353
- }
354
184
  /**
355
185
  * Font size of the hole's summary: the configured size, capped so it still
356
186
  * fits across the hole. The cap matters because the hole's radius derives from
@@ -358,21 +188,22 @@ export class PieRing extends Node2D {
358
188
  * through the ring.
359
189
  */
360
190
  get centerFont() {
361
- const wanted = sizeOf(this.centerStyle.labelStyle, 46);
191
+ const { centerStyle, centerLabel, centerCaption } = this.s;
192
+ const wanted = sizeOf(centerStyle.labelStyle, 46);
362
193
  const hole = this.holeRadius;
363
194
  if (hole <= 0)
364
195
  return wanted;
365
- const captionRatio = sizeOf(this.centerStyle.captionStyle, 22) / Math.max(1, wanted);
196
+ const captionRatio = sizeOf(centerStyle.captionStyle, 22) / Math.max(1, wanted);
366
197
  // Both lines have to clear the hole, and the caption is set at a fraction of
367
198
  // this size — so what binds is whichever is wider once drawn, not whichever
368
199
  // has more characters.
369
- const glyphs = Math.max(1, this.centerLabel.length, this.centerCaption.length * captionRatio);
200
+ const glyphs = Math.max(1, centerLabel.length, centerCaption.length * captionRatio);
370
201
  return Math.min(wanted, (hole * 2 * CENTER_LABEL_FIT) / (glyphs * GLYPH_RATIO));
371
202
  }
372
- // ---- Ring geometry, all derived from layoutBounds ------------------------
203
+ // ---- Ring geometry, all derived from the box ---------------------------
373
204
  /** Rim radius of a fully selected slice as a multiple of the ring's, clamped sane. */
374
205
  get growFactor() {
375
- return Math.max(1, Math.min(1.6, this.ring.selectedGrow));
206
+ return Math.max(1, Math.min(1.6, this.s.ring.selectedGrow));
376
207
  }
377
208
  /** How far past the ring's rim a fully selected slice reaches, in px. */
378
209
  get maxPop() {
@@ -384,19 +215,18 @@ export class PieRing extends Node2D {
384
215
  // they cost no vertical reserve.
385
216
  if (!this.hasLeaderLabels)
386
217
  return 0;
387
- const caption = sizeOf(this.label.textStyle) * 1.25;
388
- const percent = sizeOf(this.label.percentStyle) * 1.25;
389
- if (this.labelMode === "both")
218
+ const caption = sizeOf(this.s.label.textStyle) * 1.25;
219
+ const percent = sizeOf(this.s.label.percentStyle) * 1.25;
220
+ if (this.s.labelMode === "both")
390
221
  return caption + percent + 2;
391
- return this.labelMode === "category" ? caption : percent;
222
+ return this.s.labelMode === "category" ? caption : percent;
392
223
  }
393
224
  /** Widest line, in glyphs, that a leader label for slice `index` can run to. */
394
225
  captionGlyphs(index) {
226
+ const mode = this.s.labelMode;
395
227
  // "100%" is the widest a percentage gets; captions are their own length.
396
- const percent = this.labelMode === "percent" || this.labelMode === "both" ? 4 : 0;
397
- const category = this.labelMode === "category" || this.labelMode === "both"
398
- ? (this.slices[index]?.category.length ?? 0)
399
- : 0;
228
+ const percent = mode === "percent" || mode === "both" ? 4 : 0;
229
+ const category = mode === "category" || mode === "both" ? (this.s.slices[index]?.category.length ?? 0) : 0;
400
230
  return Math.max(percent, category);
401
231
  }
402
232
  /**
@@ -411,14 +241,10 @@ export class PieRing extends Node2D {
411
241
  const gutters = { left: BARE_PAD, right: BARE_PAD };
412
242
  if (!this.hasLeaderLabels)
413
243
  return gutters;
414
- const glyph = sizeOf(this.label.textStyle) * GLYPH_RATIO;
244
+ const glyph = sizeOf(this.s.label.textStyle) * GLYPH_RATIO;
415
245
  for (const arc of this.arcs) {
416
- const room = LEADER_LENGTH +
417
- LABEL_STUB +
418
- TEXT_INSET +
419
- this.captionGlyphs(arc.index) * glyph +
420
- LABEL_PAD;
421
- const key = this.sideOf(arc.mid) > 0 ? "right" : "left";
246
+ const room = LEADER_LENGTH + LABEL_STUB + TEXT_INSET + this.captionGlyphs(arc.index) * glyph + LABEL_PAD;
247
+ const key = sideOf(arc.mid) > 0 ? "right" : "left";
422
248
  gutters[key] = Math.max(gutters[key], room);
423
249
  }
424
250
  return gutters;
@@ -441,12 +267,12 @@ export class PieRing extends Node2D {
441
267
  */
442
268
  get outerRadius() {
443
269
  const { left, right } = this.labelGutters;
444
- const room = Math.min((this.layoutBounds.width - left - right) / 2, this.layoutBounds.height / 2 - this.labelRowHeight - 4);
270
+ const room = Math.min((this.box.width - left - right) / 2, this.box.height / 2 - this.labelRowHeight - 4);
445
271
  return Math.max(0, room / this.growFactor);
446
272
  }
447
273
  /** Radius of the hole in the middle: a fraction of the outer radius, or explicit px. */
448
274
  get holeRadius() {
449
- const configured = this.ring.holeRadius;
275
+ const configured = this.s.ring.holeRadius;
450
276
  if (configured <= 0)
451
277
  return 0;
452
278
  const px = configured < 1 ? configured * this.outerRadius : configured;
@@ -458,7 +284,7 @@ export class PieRing extends Node2D {
458
284
  }
459
285
  /** How far slice `index` is currently pushed out, in `[0, 1]`. */
460
286
  popOf(index) {
461
- return clampUnit(this.pops[index] ?? 0);
287
+ return clampUnit(this.s.pops[index] ?? 0);
462
288
  }
463
289
  /** Radius of slice `index`'s outer edge, its selection growth included. */
464
290
  anchorRadius(index) {
@@ -475,7 +301,7 @@ export class PieRing extends Node2D {
475
301
  const hole = this.holeRadius;
476
302
  return hole > 0 ? (hole + rim) / 2 : rim * PIE_LABEL_RADIUS;
477
303
  }
478
- /** Node2D-space point slice `index`'s inside label is centred on. */
304
+ /** The point slice `index`'s inside label is centred on. */
479
305
  insideAnchor(index) {
480
306
  const arc = this.arcs.find((a) => a.index === index);
481
307
  if (arc === undefined)
@@ -490,14 +316,13 @@ export class PieRing extends Node2D {
490
316
  */
491
317
  get selectionStrength() {
492
318
  let strongest = 0;
493
- for (let i = 0; i < this.slices.length; i++) {
319
+ for (let i = 0; i < this.s.slices.length; i++)
494
320
  strongest = Math.max(strongest, this.popOf(i));
495
- }
496
321
  return strongest;
497
322
  }
498
323
  /**
499
324
  * Opacity slice `index` (and its leader and label) is drawn at: full while
500
- * nothing is selected, falling toward {@link dimOpacity} in proportion to how
325
+ * nothing is selected, falling toward `dimOpacity` in proportion to how
501
326
  * selected the *rest* of the ring is. A slice that's selected itself stays at
502
327
  * full — the two factors cancel.
503
328
  */
@@ -505,7 +330,12 @@ export class PieRing extends Node2D {
505
330
  const focus = this.selectionStrength;
506
331
  if (focus <= 0)
507
332
  return 1;
508
- return 1 - (1 - clampUnit(this.dimOpacity)) * focus * (1 - this.popOf(index));
333
+ return 1 - (1 - clampUnit(this.s.dimOpacity)) * focus * (1 - this.popOf(index));
334
+ }
335
+ /** Slice `index`'s share of the ring as its label reads it, off the live values. */
336
+ percentText(index) {
337
+ const arc = this.arcs.find((a) => a.index === index);
338
+ return formatPercent(arc?.fraction ?? 0);
509
339
  }
510
340
  // ---- Label placement ---------------------------------------------------
511
341
  /**
@@ -524,21 +354,15 @@ export class PieRing extends Node2D {
524
354
  return [];
525
355
  const radius = this.elbowRadius;
526
356
  const rowHeight = this.labelRowHeight;
527
- const top = this.layoutBounds.height / 2 - rowHeight / 2;
357
+ const top = this.box.height / 2 - rowHeight / 2;
528
358
  const bottom = -top;
529
359
  const cx = this.centerX;
530
360
  const right = [];
531
361
  const left = [];
532
362
  for (const arc of arcs) {
533
- const side = this.sideOf(arc.mid);
363
+ const side = sideOf(arc.mid);
534
364
  const ideal = ringPoint(radius, arc.mid).y;
535
- (side > 0 ? right : left).push({
536
- index: arc.index,
537
- side,
538
- mid: arc.mid,
539
- ideal,
540
- y: ideal,
541
- });
365
+ (side > 0 ? right : left).push({ index: arc.index, side, mid: arc.mid, ideal, y: ideal });
542
366
  }
543
367
  relaxLabels(right, rowHeight + LABEL_GAP, top, bottom);
544
368
  relaxLabels(left, rowHeight + LABEL_GAP, top, bottom);
@@ -567,142 +391,290 @@ export class PieRing extends Node2D {
567
391
  placementFor(index) {
568
392
  return this.placements().find((placement) => placement.index === index);
569
393
  }
570
- // ---- Draw --------------------------------------------------------------
571
- /**
572
- * The ring's paints live nowhere `ShapeNode` would look: one fill per slice,
573
- * resolved once into {@link fills}, plus the leader line's own stroke. Both are
574
- * passed explicitly — see {@link declarePaints}.
575
- */
576
- declareAssets(assets) {
577
- super.declareAssets(assets);
578
- declarePaints(this, assets, {
579
- fills: this.fills,
580
- strokes: [strokeOps.resolve(this.label.leaderStroke)],
394
+ }
395
+ /** Which side a leader off bisector `mid` runs to: +1 right, −1 left. */
396
+ function sideOf(mid) {
397
+ return Math.sin(mid) >= 0 ? 1 : -1;
398
+ }
399
+ 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
+ */
428
+ function ringLabels(s) {
429
+ const blocks = [];
430
+ // A label block's parent is the ring: its box is what the ring was laid out at.
431
+ const geometry = () => new RingGeometry(s, blocks[0]?.parent?.layoutBounds ?? NO_BOX);
432
+ 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);
436
+ const wantsCategory = s.labelMode === "both" || s.labelMode === "category";
437
+ const wantsPercent = s.labelMode === "both" || s.labelMode === "percent";
438
+ 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.
444
+ const inside = [];
445
+ if (wantsPercent) {
446
+ inside.push(new Text({
447
+ ...textOps.styleProps(s.label.insideStyle),
448
+ text: () => geometry().percentText(arc.index),
449
+ fontSize: () => geometry().insideFont,
450
+ }));
451
+ }
452
+ if (wantsCategory) {
453
+ inside.push(new Text({
454
+ ...textOps.styleProps(s.label.insideStyle),
455
+ text: () => s.slices[arc.index]?.category ?? "",
456
+ fontSize: () => (s.labelMode === "category" ? geometry().insideFont : geometry().insideFont * INSIDE_CAPTION_RATIO),
457
+ }));
458
+ }
459
+ return new Rect({
460
+ flow: "vertical",
461
+ width: "hug",
462
+ height: "hug",
463
+ gap: 0,
464
+ align: "center",
465
+ opacity,
466
+ center: () => geometry().insideAnchor(arc.index),
467
+ children: inside,
468
+ });
469
+ }
470
+ const side = sideOf(arc.mid);
471
+ const at = () => geometry().placementFor(arc.index)?.text ?? { x: 0, y: 0 };
472
+ const children = [];
473
+ if (wantsCategory) {
474
+ children.push(new Text({
475
+ ...textOps.styleProps(s.label.textStyle),
476
+ text: () => s.slices[arc.index]?.category ?? "",
477
+ textAlign: side > 0 ? "left" : "right",
478
+ }));
479
+ }
480
+ if (wantsPercent) {
481
+ children.push(new Text({
482
+ ...textOps.styleProps(s.label.percentStyle),
483
+ text: () => geometry().percentText(arc.index),
484
+ textAlign: side > 0 ? "left" : "right",
485
+ }));
486
+ }
487
+ return new Rect({
488
+ flow: "vertical",
489
+ width: "hug",
490
+ height: "hug",
491
+ gap: 2,
492
+ align: side > 0 ? "centerLeft" : "centerRight",
493
+ opacity,
494
+ // Exactly one of these is set; `undefined` anchors are ignored.
495
+ centerLeft: side > 0 ? at : undefined,
496
+ centerRight: side > 0 ? undefined : at,
497
+ children,
581
498
  });
499
+ };
500
+ if (s.labelPlacement !== "none" && s.labelMode !== "none") {
501
+ for (const arc of pieArcs(s.slices, s.ring.startAngle))
502
+ blocks.push(label(arc));
582
503
  }
583
- renderSelf(ctx) {
584
- const rOuter = this.outerRadius;
585
- if (rOuter <= 0)
586
- return;
587
- const arcs = this.arcs;
588
- if (arcs.length === 0)
589
- return;
590
- const sweep = clampUnit(this.growth);
591
- const revealEnd = this.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep;
592
- const hole = this.holeRadius;
593
- const halfGap = Math.max(0, this.ring.gap) / 2;
594
- // One shared frame (the whole ring square, grown slices included) pinned onto
595
- // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
596
- // own bbox and collapse them all onto the origin. It stays symmetric about the
597
- // *node's* origin — so path coordinates land exactly in node space — and is
598
- // grown by the ring's sideways offset rather than moved with it, which would
599
- // undo the very shift it has to contain.
600
- const cx = this.centerX;
601
- const half = rOuter + this.maxPop + 1 + Math.abs(cx);
602
- const centerBounds = [
603
- -half,
604
- -half,
605
- half,
606
- half,
607
- ];
608
- // Explicit width/height alongside centerBounds: a wedge path's command list
609
- // carries no implied extent, so a slice filled with an image or a video needs
610
- // this to size its decode.
611
- const span = half * 2;
612
- for (const arc of arcs) {
613
- // Clip the wedge to the growth-revealed arc; skip it until the sweep
614
- // reaches it, so the ring draws in as one clockwise wipe.
615
- const a1 = Math.min(arc.a1, revealEnd);
616
- if (a1 <= arc.a0)
617
- continue;
618
- const path = wedgePath(arc.a0, a1, hole, this.anchorRadius(arc.index), halfGap, cx);
619
- const g = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }));
620
- g.shadow(this.ring.shadow);
621
- g.fill(this.fills[arc.index] ?? []);
622
- g.stroke(this.ring.stroke);
623
- ctx.draw(g.opacity(this.sliceOpacity(arc.index)));
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.
506
+ if (s.centerLabel !== "" || s.centerCaption !== "") {
507
+ const children = [];
508
+ if (s.centerLabel !== "") {
509
+ children.push(new Text({
510
+ ...textOps.styleProps(s.centerStyle.labelStyle),
511
+ text: s.centerLabel,
512
+ fontSize: () => geometry().centerFont,
513
+ }));
624
514
  }
625
- // Leaders. Each polyline gets its own `Graphics2D`: several open polylines
626
- // chained into one command list are combined into a single surface before the
627
- // stroke, which closes each contour back on itself and paints the leaders as
628
- // thin triangles.
629
- const reveal = clampUnit(this.labelReveal);
630
- if (reveal <= 0.001)
631
- return;
632
- for (const placement of this.placements()) {
633
- ctx.draw(new Graphics2D()
634
- .line({ points: placement.leader, radius: ELBOW_RADIUS })
635
- .stroke(this.label.leaderStroke)
636
- .opacity(reveal * this.sliceOpacity(placement.index)));
515
+ if (s.centerCaption !== "") {
516
+ children.push(new Text({
517
+ ...textOps.styleProps(s.centerStyle.captionStyle),
518
+ text: s.centerCaption,
519
+ fontSize: () => geometry().centerFont * (sizeOf(s.centerStyle.captionStyle, 22) / sizeOf(s.centerStyle.labelStyle, 46)),
520
+ }));
637
521
  }
522
+ blocks.push(new Rect({
523
+ flow: "vertical",
524
+ width: "hug",
525
+ height: "hug",
526
+ gap: 2,
527
+ align: "center",
528
+ opacity: () => clampUnit(s.centerReveal),
529
+ center: () => ({ x: geometry().centerX, y: 0 }),
530
+ children,
531
+ }));
638
532
  }
639
- // ---- Entrance ----------------------------------------------------------
640
- /** Tween {@link growth} `0 → 1`, wiping the wedges in around the ring. */
641
- sweep(args) {
642
- // No priming to 0 first — the value at `t` is a function of `t`, so a repeat
643
- // call re-sweeps by definition rather than by having reset something.
644
- return this.command((t) => ({ growth: t }), args.duration, args.easing ?? easeOut());
645
- }
646
- /** Tween {@link labelReveal} `0 → 1`, fading the leaders and captions up. */
647
- revealLabels(args) {
648
- return this.command((t) => ({ labelReveal: t }), args.duration, args.easing ?? easeOut());
649
- }
650
- /**
651
- * Fade the hole's summary up. Deliberately not part of the ring's entrance: a
652
- * hole label is usually the chart's conclusion, so the scene decides when it
653
- * lands.
654
- */
655
- revealCenter(args = {}) {
656
- const from = clampUnit(this.centerReveal);
657
- return this.command((t) => ({ centerReveal: from + (1 - from) * t }), args.duration ?? 0.6, args.easing ?? easeOut());
658
- }
533
+ return asParts(blocks);
659
534
  }
660
- __decorate([
661
- property({ default: () => [] })
662
- ], PieRing.prototype, "slices", void 0);
663
- __decorate([
664
- property({ default: DEFAULT_PIE_CHART_THEME.ring })
665
- ], PieRing.prototype, "ring", void 0);
666
- __decorate([
667
- property({ default: DEFAULT_PIE_CHART_THEME.label })
668
- ], PieRing.prototype, "label", void 0);
669
- __decorate([
670
- property({ default: DEFAULT_PIE_CHART_THEME.center })
671
- ], PieRing.prototype, "centerStyle", void 0);
672
- __decorate([
673
- property({ default: "percent" })
674
- ], PieRing.prototype, "labelMode", void 0);
675
- __decorate([
676
- property({ default: "leader" })
677
- ], PieRing.prototype, "labelPlacement", void 0);
678
- __decorate([
679
- property({ default: 1 })
680
- ], PieRing.prototype, "growth", void 0);
681
- __decorate([
682
- property({ default: 1 })
683
- ], PieRing.prototype, "labelReveal", void 0);
684
- __decorate([
685
- property({ default: () => [] })
686
- ], PieRing.prototype, "pops", void 0);
687
- __decorate([
688
- property({ default: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity })
689
- ], PieRing.prototype, "dimOpacity", void 0);
690
- __decorate([
691
- property({ default: "" })
692
- ], PieRing.prototype, "centerLabel", void 0);
693
- __decorate([
694
- property({ default: "" })
695
- ], PieRing.prototype, "centerCaption", void 0);
696
- __decorate([
697
- property({ default: 1 })
698
- ], PieRing.prototype, "centerReveal", void 0);
699
- __decorate([
700
- command()
701
- ], PieRing.prototype, "sweep", null);
702
- __decorate([
703
- command()
704
- ], PieRing.prototype, "revealLabels", null);
705
- __decorate([
706
- command()
707
- ], PieRing.prototype, "revealCenter", null);
535
+ /**
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.
573
+ */
574
+ export const PieRing = component2d({
575
+ key: "pieRing",
576
+ state: {
577
+ 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),
581
+ labelMode: Types.custom("percent"),
582
+ labelPlacement: Types.custom("leader"),
583
+ growth: 1,
584
+ labelReveal: 1,
585
+ pops: fresh(() => []),
586
+ dimOpacity: DEFAULT_PIE_CHART_THEME.spotlight.dimOpacity,
587
+ centerLabel: "",
588
+ centerCaption: "",
589
+ centerReveal: 1,
590
+ },
591
+ // A plain node filled its parent; one made of its labels would hug them.
592
+ defaults: { width: "fill", height: "fill" },
593
+ 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.
596
+ load: (loader, state) => loadPaints(loader, {
597
+ fills: ringFills(state.slices),
598
+ shadows: [shadowOps.resolve(state.ring.shadow)],
599
+ strokes: [strokeOps.resolve(state.ring.stroke), strokeOps.resolve(state.label.leaderStroke)],
600
+ }),
601
+ 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
+ */
607
+ sweep: (timeline) => {
608
+ const ease = frameEase(timeline);
609
+ return timeline.animate((t) => ({ growth: ease(t) }));
610
+ },
611
+ /** Tween `labelReveal` `0 → 1`, fading the leaders and captions up. */
612
+ revealLabels: (timeline) => {
613
+ const ease = frameEase(timeline);
614
+ return timeline.animate((t) => ({ labelReveal: ease(t) }));
615
+ },
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
+ */
621
+ revealCenter: (timeline, state) => {
622
+ const ease = frameEase(timeline);
623
+ const from = clampUnit(state.centerReveal);
624
+ return timeline.animate((t) => ({ centerReveal: from + (1 - from) * ease(t) }));
625
+ },
626
+ },
627
+ render: (ctx, state) => {
628
+ const g = new RingGeometry(state, ctx.size);
629
+ const rOuter = g.outerRadius;
630
+ const arcs = rOuter > 0 ? g.arcs : [];
631
+ if (arcs.length > 0) {
632
+ const sweep = clampUnit(state.growth);
633
+ const revealEnd = state.ring.startAngle * (Math.PI / 180) + Math.PI * 2 * sweep;
634
+ const hole = g.holeRadius;
635
+ const halfGap = Math.max(0, state.ring.gap) / 2;
636
+ // One shared frame (the whole ring square, grown slices included) pinned onto
637
+ // every wedge path, so `Graphics2D.path()` doesn't re-centre each wedge on its
638
+ // 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.
642
+ const cx = g.centerX;
643
+ const half = rOuter + g.maxPop + 1 + Math.abs(cx);
644
+ const centerBounds = [-half, -half, half, half];
645
+ // Explicit width/height alongside centerBounds: a wedge path's command list
646
+ // carries no implied extent, so a slice filled with an image or a video needs
647
+ // this to size its decode.
648
+ const span = half * 2;
649
+ const fills = ringFills(state.slices);
650
+ 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.
653
+ const a1 = Math.min(arc.a1, revealEnd);
654
+ if (a1 <= arc.a0)
655
+ continue;
656
+ const path = wedgePath(arc.a0, a1, hole, g.anchorRadius(arc.index), halfGap, cx);
657
+ const wedge = new Graphics2D().path(path.toPathState({ centerBounds, width: span, height: span }));
658
+ wedge.shadow(state.ring.shadow);
659
+ wedge.fill(fills[arc.index] ?? []);
660
+ wedge.stroke(state.ring.stroke);
661
+ ctx.draw(wedge.opacity(g.sliceOpacity(arc.index)));
662
+ }
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);
668
+ if (reveal > 0.001) {
669
+ for (const placement of g.placements()) {
670
+ ctx.draw(new Graphics2D()
671
+ .line({ points: placement.leader, radius: ELBOW_RADIUS })
672
+ .stroke(state.label.leaderStroke)
673
+ .opacity(reveal * g.sliceOpacity(placement.index)));
674
+ }
675
+ }
676
+ }
677
+ ctx.children();
678
+ },
679
+ });
708
680
  //# sourceMappingURL=pie-ring.js.map