@motionscript/charts 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +49 -49
  4. package/README.md +99 -99
  5. package/dist/bar-chart/bar-chart.js.map +1 -1
  6. package/dist/bar-chart/category-axis.js.map +1 -1
  7. package/dist/bar-chart/chart-bars.js.map +1 -1
  8. package/dist/bar-chart/index.js.map +1 -1
  9. package/dist/bar-chart/shared.js.map +1 -1
  10. package/dist/browser/chunks/{chunk-XROBIRQK.js → chunk-TJNHLQGV.js} +1 -1
  11. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  12. package/dist/browser/index.js +1 -1
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/kit.js +1 -1
  15. package/dist/browser/manifest.json +1 -1
  16. package/dist/engine.d.ts +1 -1
  17. package/dist/engine.js +1 -1
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/kit/cartesian.js.map +1 -1
  21. package/dist/kit/chart-border.js.map +1 -1
  22. package/dist/kit/chart-canvas.js.map +1 -1
  23. package/dist/kit/chart-legend.js.map +1 -1
  24. package/dist/kit/entries.js.map +1 -1
  25. package/dist/kit/motion.js.map +1 -1
  26. package/dist/kit/paints.js.map +1 -1
  27. package/dist/kit/shared.js.map +1 -1
  28. package/dist/kit/vertical-axis.js.map +1 -1
  29. package/dist/line-chart/chart-line.js.map +1 -1
  30. package/dist/line-chart/chart-region.js.map +1 -1
  31. package/dist/line-chart/horizontal-axis.js.map +1 -1
  32. package/dist/line-chart/index.d.ts +1 -1
  33. package/dist/line-chart/index.js +1 -1
  34. package/dist/line-chart/index.js.map +1 -1
  35. package/dist/line-chart/line-chart.js.map +1 -1
  36. package/dist/line-chart/shared.js.map +1 -1
  37. package/dist/line-chart/x-scale.js.map +1 -1
  38. package/dist/nodes.js.map +1 -1
  39. package/dist/pie-chart/index.js.map +1 -1
  40. package/dist/pie-chart/pie-chart.js.map +1 -1
  41. package/dist/pie-chart/pie-legend.js.map +1 -1
  42. package/dist/pie-chart/pie-ring.js.map +1 -1
  43. package/dist/pie-chart/shared.js.map +1 -1
  44. package/package.json +4 -4
  45. package/registry.json +23 -23
  46. package/src/bar-chart/bar-chart.ts +542 -542
  47. package/src/bar-chart/category-axis.ts +162 -162
  48. package/src/bar-chart/chart-bars.ts +287 -287
  49. package/src/bar-chart/index.ts +31 -31
  50. package/src/bar-chart/shared.ts +354 -354
  51. package/src/engine.ts +28 -28
  52. package/src/index.ts +5 -5
  53. package/src/kit/cartesian.ts +81 -81
  54. package/src/kit/chart-border.ts +50 -50
  55. package/src/kit/chart-canvas.ts +122 -122
  56. package/src/kit/chart-legend.ts +229 -229
  57. package/src/kit/entries.ts +20 -20
  58. package/src/kit/motion.ts +90 -90
  59. package/src/kit/paints.ts +15 -15
  60. package/src/kit/shared.ts +782 -782
  61. package/src/kit/vertical-axis.ts +194 -194
  62. package/src/line-chart/chart-line.ts +156 -156
  63. package/src/line-chart/chart-region.ts +90 -90
  64. package/src/line-chart/horizontal-axis.ts +151 -151
  65. package/src/line-chart/index.ts +39 -39
  66. package/src/line-chart/line-chart.ts +598 -598
  67. package/src/line-chart/shared.ts +473 -473
  68. package/src/line-chart/x-scale.ts +382 -382
  69. package/src/nodes.ts +18 -18
  70. package/src/pie-chart/index.ts +37 -37
  71. package/src/pie-chart/pie-chart.ts +296 -296
  72. package/src/pie-chart/pie-legend.ts +169 -169
  73. package/src/pie-chart/pie-ring.ts +848 -848
  74. package/src/pie-chart/shared.ts +496 -496
  75. package/dist/browser/chunks/chunk-XROBIRQK.js.map +0 -7
  76. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,496 +1,496 @@
1
- /**
2
- * Types and pure helpers specific to the {@link PieChart} family: how rows
3
- * become slices, how slices become arcs on a ring, where the labels around it
4
- * end up, and the theme all of that is drawn from.
5
- *
6
- * The one chart in the family that draws **one mark per row** rather than one
7
- * per column, which is why almost nothing about its geometry is shared: there is
8
- * no scale, no axis and no baseline, only a circle divided by share. What it
9
- * *does* share — the legend, the theme leaf resolvers, the palette, the
10
- * percentage format — comes from `../kit/shared.ts`.
11
- *
12
- * Kept renderer-agnostic (no `Node` imports) so a host's inspector mapping
13
- * can build a partial {@link PieChartTheme} without
14
- * pulling the node classes in.
15
- */
16
-
17
- import {
18
- lerpNumber,
19
- shadowOps,
20
- strokeOps,
21
- textOps,
22
- type DataRecord,
23
- type Fill,
24
- type Shadow,
25
- type ShadowResolved,
26
- type Stroke,
27
- type StrokeResolved,
28
- type TextStyle,
29
- type Vector2,
30
- } from "@motionscript/sdk"
31
-
32
- import {
33
- CHART_PALETTE,
34
- DEFAULT_LEGEND,
35
- DEFAULT_SERIES_FILLS,
36
- lerpLegendConfig,
37
- lerpSeriesFills,
38
- num,
39
- resolveLegendConfig,
40
- resolveSeriesFills,
41
- text,
42
- type LegendConfig,
43
- type LegendTheme,
44
- } from "../kit/shared"
45
-
46
- /**
47
- * One **resolved** slice of a {@link PieChart}: its caption, its magnitude, and
48
- * the paint its wedge and legend swatch take.
49
- *
50
- * This is what the ring and the legend consume, not what a scene authors: the
51
- * chart takes tabular `data` plus two field names and resolves it into these, so
52
- * the parts stay unaware of where a slice's caption, magnitude and paint came
53
- * from.
54
- */
55
- export interface PieSlice {
56
- /** Legend caption and label text for this slice. */
57
- category: string
58
- /** This slice's magnitude. Its wedge covers `value / (sum of all values)`. */
59
- value: number
60
- /** Wedge and swatch paint. */
61
- fill: Fill
62
- }
63
-
64
- /**
65
- * Resolve tabular rows into slices: one per row, captioned by the `category`
66
- * column, sized by the `value` column, painted from `palette` by row order
67
- * (cycling when the palette is shorter than the data).
68
- *
69
- * Paint comes from the palette rather than from the rows because a pie draws one
70
- * mark per *row* — unlike the line and bar charts, where a colour belongs to a
71
- * series (a whole column) and rides on the series entry instead. Keeping it out
72
- * of the rows is what lets a pie be fed straight from a CSV of plain numbers.
73
- */
74
- export function resolvePieSlices(
75
- data: DataRecord[],
76
- category: string,
77
- value: string,
78
- palette: Fill[]
79
- ): PieSlice[] {
80
- return data.map((row, i) => ({
81
- category: text(row, category),
82
- value: num(row, value),
83
- fill:
84
- palette.length > 0 ? palette[i % palette.length] : ("white" as Fill),
85
- }))
86
- }
87
-
88
- /** What a {@link PieChart}'s labels say. */
89
- export type PieLabelMode =
90
- /**
91
- * The category and its percentage, two lines. Their order flips with the
92
- * placement: a **leader** label reads category over percentage, an **inside**
93
- * label reads the percentage big over a small category caption — inside a
94
- * wedge the reader already knows which slice they're on, so the magnitude is
95
- * the thing being read.
96
- */
97
- | "both"
98
- /** The category only. */
99
- | "category"
100
- /** The percentage only — the pick when a legend already names the categories. */
101
- | "percent"
102
- /** No labels and no leader lines at all. */
103
- | "none"
104
-
105
- /**
106
- * Where a slice's label sits.
107
- *
108
- * `"inside"` drops the leader line entirely and centres the label in the wedge's
109
- * own band, which costs no gutter — so the ring draws bigger — and costs the eye
110
- * no round trip. `"leader"` is the classic treatment, and what a sliver too
111
- * narrow to hold text needs.
112
- */
113
- export type PieLabelPlacement = "inside" | "leader" | "none"
114
-
115
- /** Degrees → radians. */
116
- export const DEG = Math.PI / 180
117
-
118
- /** A slice's plotted value: negatives and non-finite values count as 0. */
119
- export function sliceValue(slice: PieSlice): number {
120
- return slice.value > 0 && Number.isFinite(slice.value) ? slice.value : 0
121
- }
122
-
123
- /** Sum of every slice's plotted value — the denominator every share is taken against. */
124
- export function sliceTotal(slices: PieSlice[]): number {
125
- let sum = 0
126
- for (const slice of slices) sum += sliceValue(slice)
127
- return sum
128
- }
129
-
130
- /**
131
- * The point at radius `r` and angle `a` on a ring centred on the origin, in the
132
- * node's centred, **y-up** space. Angles run **clockwise from 12 o'clock**:
133
- * `a = 0` points straight up (+y), and increasing `a` sweeps toward +x then −y.
134
- */
135
- export function ringPoint(r: number, a: number): Vector2 {
136
- return { x: r * Math.sin(a), y: r * Math.cos(a) }
137
- }
138
-
139
- /** One slice's resolved angular span on the ring. */
140
- export interface PieArc {
141
- /** Index of the slice in the source list. */
142
- index: number
143
- /** Start angle, radians clockwise from 12 o'clock. */
144
- a0: number
145
- /** End angle, radians clockwise from 12 o'clock. */
146
- a1: number
147
- /** Bisector angle — where this slice's label and pop-out point. */
148
- mid: number
149
- /** This slice's share of the total, in `[0, 1]`. */
150
- fraction: number
151
- }
152
-
153
- /**
154
- * Resolve slices into per-slice arcs: each covers exactly its share of the
155
- * circle, walking clockwise from `startDegrees` (0 = 12 o'clock), with the arcs
156
- * abutting. Zero/negative slices are skipped entirely; an all-zero list yields
157
- * no arcs.
158
- *
159
- * Arcs are the *undivided* shares — the gap between neighbours is not carved out
160
- * here, because a fixed angular gap reads as a wedge: wide at the rim, pinched
161
- * shut at the hole. It's applied when the wedge is drawn instead, as a
162
- * constant-width inset off both ends of every arc, so a slice still covers its
163
- * true percentage of the circle.
164
- */
165
- export function pieArcs(slices: PieSlice[], startDegrees: number): PieArc[] {
166
- const total = sliceTotal(slices)
167
- if (total <= 0) return []
168
-
169
- const arcs: PieArc[] = []
170
- let cursor = startDegrees * DEG
171
- slices.forEach((slice, index) => {
172
- const value = sliceValue(slice)
173
- if (value <= 0) return
174
- const fraction = value / total
175
- const a0 = cursor
176
- const a1 = a0 + fraction * Math.PI * 2
177
- cursor = a1
178
- arcs.push({ index, a0, a1, mid: (a0 + a1) / 2, fraction })
179
- })
180
- return arcs
181
- }
182
-
183
- /** A label competing for vertical space in one of the two label columns. */
184
- export interface LabelSlot {
185
- /** Where the label would sit if nothing else were in the way. */
186
- ideal: number
187
- /** Where it ends up once the column has been de-conflicted. Written by {@link relaxLabels}. */
188
- y: number
189
- }
190
-
191
- /**
192
- * Spread one column of labels so no two overlap, keeping each as close to its
193
- * `ideal` y as possible and the whole column inside `[bottom, top]`.
194
- *
195
- * Two passes, the standard treatment. Walking top-to-bottom, each label takes
196
- * the highest slot at or below its ideal that still clears the label above it by
197
- * `minGap`; if that pushes the tail past `bottom`, a reverse pass lifts it back
198
- * up. When there are more labels than the column can hold, the reverse pass wins
199
- * and the crowding is shared evenly rather than dumped off the bottom edge.
200
- *
201
- * Mutates each slot's `y` and returns `slots` re-ordered top-to-bottom.
202
- */
203
- export function relaxLabels<T extends LabelSlot>(
204
- slots: T[],
205
- minGap: number,
206
- top: number,
207
- bottom: number
208
- ): T[] {
209
- slots.sort((a, b) => b.ideal - a.ideal)
210
-
211
- let ceiling = top
212
- for (const slot of slots) {
213
- slot.y = Math.min(slot.ideal, ceiling)
214
- ceiling = slot.y - minGap
215
- }
216
-
217
- let floor = bottom
218
- for (let i = slots.length - 1; i >= 0; i--) {
219
- slots[i].y = Math.max(slots[i].y, floor)
220
- floor = slots[i].y + minGap
221
- }
222
-
223
- return slots
224
- }
225
-
226
- // ---- Theme ---------------------------------------------------------------
227
-
228
- /** The ring's geometry and paint — everything about the wedges but their colour. */
229
- export interface RingConfig {
230
- /**
231
- * Width of the channel between adjacent slices, in px, held constant all the
232
- * way across the band — the same clearance at the hole as at the rim. (An
233
- * angular gap would splay open toward the rim instead.)
234
- */
235
- gap: number
236
- /**
237
- * Radius of the hole punched through the middle. Below 1 it reads as a
238
- * fraction of the ring's outer radius (`0.6` leaves a band 40% of the radius
239
- * thick); 1 and up as an explicit pixel radius; `0` fills the middle in,
240
- * drawing solid pie wedges.
241
- */
242
- holeRadius: number
243
- /** Angle in degrees where the first slice begins, clockwise from 12 o'clock. */
244
- startAngle: number
245
- /**
246
- * How far out a selected slice's rim reaches, as a **multiple of the ring's
247
- * outer radius**. Being relative, the emphasis scales with the chart instead
248
- * of shrinking on a big one and swamping a small one. Clamped to `[1, 1.6]`.
249
- */
250
- selectedGrow: number
251
- /** Optional outline on every wedge. */
252
- stroke: Stroke
253
- /** Optional drop shadow on every wedge. */
254
- shadow: Shadow
255
- }
256
-
257
- /** How the ring's labels are set, and the leader lines that point at them. */
258
- export interface LabelConfig {
259
- /** The category caption on a label. */
260
- textStyle: TextStyle
261
- /** The percentage on a label. */
262
- percentStyle: TextStyle
263
- /**
264
- * Ink for a label set **inside** its wedge, both lines. Separate because an
265
- * inside label sits on the wedge's own paint rather than on the background, so
266
- * it usually wants the opposite end of the value range from everything else.
267
- */
268
- insideStyle: TextStyle
269
- /** The leader line running from a wedge out to its label. */
270
- leaderStroke: Stroke
271
- }
272
-
273
- /** The summary set in the ring's hole. */
274
- export interface CenterConfig {
275
- labelStyle: TextStyle
276
- captionStyle: TextStyle
277
- }
278
-
279
- /** A {@link PieChart}'s full, resolved visual theme. */
280
- export interface PieChartConfig {
281
- /** Wedge and swatch paint, cycled by **row** index (`i % length`). */
282
- sliceFills: Fill[]
283
- ring: RingConfig
284
- label: LabelConfig
285
- center: CenterConfig
286
- legend: LegendConfig
287
- /** How far everything outside the selection fades while any slice is selected. */
288
- spotlight: { dimOpacity: number }
289
- }
290
-
291
- /** Author-facing partial theme — every branch optional and deep-merged. */
292
- export type PieChartTheme = {
293
- sliceFills?: Fill[]
294
- ring?: Partial<RingConfig>
295
- label?: Partial<LabelConfig>
296
- center?: Partial<CenterConfig>
297
- legend?: LegendTheme
298
- spotlight?: Partial<PieChartConfig["spotlight"]>
299
- }
300
-
301
- const LABEL_TEXT_STYLE: TextStyle = {
302
- fontSize: 26,
303
- fontWeight: 600,
304
- textAlign: "center",
305
- fill: CHART_PALETTE.markerColor,
306
- }
307
-
308
- /**
309
- * Opacity the other slices fade to under a selection. Fainter than the line and
310
- * bar charts' `SPOTLIGHT_DIM`: a ring's slices touch, so at that a selected
311
- * slice barely separates from its neighbours.
312
- */
313
- export const PIE_SPOTLIGHT_DIM = 0.1
314
-
315
- /** The pie chart's out-of-the-box look. */
316
- export const DEFAULT_PIE_CHART_THEME: PieChartConfig = {
317
- sliceFills: DEFAULT_SERIES_FILLS,
318
- ring: {
319
- gap: 6,
320
- // A donut rather than a solid pie by default: the hole is the largest
321
- // uninterrupted area the chart owns and the one place a conclusion can be
322
- // set. `0` here would give that away before anyone had asked for it.
323
- holeRadius: 0.6,
324
- startAngle: 0,
325
- selectedGrow: 1.08,
326
- stroke: [],
327
- shadow: [],
328
- },
329
- label: {
330
- textStyle: LABEL_TEXT_STYLE,
331
- percentStyle: { ...LABEL_TEXT_STYLE, fontWeight: 700 },
332
- // Inside labels sit on the wedge's paint, which is a mid-tone from the
333
- // palette — so they lead with the panel colour rather than the axis ink.
334
- insideStyle: { ...LABEL_TEXT_STYLE, fontWeight: 800, fill: "#15130e" },
335
- leaderStroke: { weight: 2, fill: CHART_PALETTE.markerColor },
336
- },
337
- center: {
338
- labelStyle: {
339
- fontSize: 46,
340
- fontWeight: 800,
341
- textAlign: "center",
342
- fill: CHART_PALETTE.markerColor,
343
- },
344
- captionStyle: {
345
- fontSize: 22,
346
- fontWeight: 500,
347
- textAlign: "center",
348
- fill: CHART_PALETTE.markerColor,
349
- },
350
- },
351
- legend: DEFAULT_LEGEND,
352
- spotlight: { dimOpacity: PIE_SPOTLIGHT_DIM },
353
- }
354
-
355
- function resolveRingConfig(
356
- ring: Partial<RingConfig> | undefined,
357
- previous: RingConfig
358
- ): RingConfig {
359
- return {
360
- gap: ring?.gap ?? previous.gap,
361
- holeRadius: ring?.holeRadius ?? previous.holeRadius,
362
- startAngle: ring?.startAngle ?? previous.startAngle,
363
- selectedGrow: ring?.selectedGrow ?? previous.selectedGrow,
364
- stroke: strokeOps.resolve(ring?.stroke ?? previous.stroke),
365
- shadow: shadowOps.resolve(ring?.shadow ?? previous.shadow),
366
- }
367
- }
368
-
369
- function lerpRingConfig(
370
- from: RingConfig,
371
- to: RingConfig,
372
- t: number
373
- ): RingConfig {
374
- return {
375
- gap: lerpNumber(from.gap, to.gap, t),
376
- holeRadius: lerpNumber(from.holeRadius, to.holeRadius, t),
377
- startAngle: lerpNumber(from.startAngle, to.startAngle, t),
378
- selectedGrow: lerpNumber(from.selectedGrow, to.selectedGrow, t),
379
- stroke: strokeOps.lerp(
380
- from.stroke as StrokeResolved[],
381
- to.stroke as StrokeResolved[],
382
- t
383
- ),
384
- shadow: shadowOps.lerp(
385
- from.shadow as ShadowResolved[],
386
- to.shadow as ShadowResolved[],
387
- t
388
- ),
389
- }
390
- }
391
-
392
- function resolveLabelConfig(
393
- label: Partial<LabelConfig> | undefined,
394
- previous: LabelConfig
395
- ): LabelConfig {
396
- return {
397
- textStyle: textOps.resolveStyle(label?.textStyle, previous.textStyle),
398
- percentStyle: textOps.resolveStyle(
399
- label?.percentStyle,
400
- previous.percentStyle
401
- ),
402
- insideStyle: textOps.resolveStyle(label?.insideStyle, previous.insideStyle),
403
- leaderStroke: strokeOps.resolve(
404
- label?.leaderStroke ?? previous.leaderStroke
405
- ),
406
- }
407
- }
408
-
409
- function lerpLabelConfig(
410
- from: LabelConfig,
411
- to: LabelConfig,
412
- t: number
413
- ): LabelConfig {
414
- return {
415
- textStyle: textOps.lerpStyle(from.textStyle, to.textStyle, t),
416
- percentStyle: textOps.lerpStyle(from.percentStyle, to.percentStyle, t),
417
- insideStyle: textOps.lerpStyle(from.insideStyle, to.insideStyle, t),
418
- leaderStroke: strokeOps.lerp(
419
- from.leaderStroke as StrokeResolved[],
420
- to.leaderStroke as StrokeResolved[],
421
- t
422
- ),
423
- }
424
- }
425
-
426
- function resolveCenterConfig(
427
- center: Partial<CenterConfig> | undefined,
428
- previous: CenterConfig
429
- ): CenterConfig {
430
- return {
431
- labelStyle: textOps.resolveStyle(center?.labelStyle, previous.labelStyle),
432
- captionStyle: textOps.resolveStyle(
433
- center?.captionStyle,
434
- previous.captionStyle
435
- ),
436
- }
437
- }
438
-
439
- function lerpCenterConfig(
440
- from: CenterConfig,
441
- to: CenterConfig,
442
- t: number
443
- ): CenterConfig {
444
- return {
445
- labelStyle: textOps.lerpStyle(from.labelStyle, to.labelStyle, t),
446
- captionStyle: textOps.lerpStyle(from.captionStyle, to.captionStyle, t),
447
- }
448
- }
449
-
450
- /**
451
- * {@link PieChart}'s `theme` property mapper: merges the author's partial
452
- * {@link PieChartTheme} onto `previous` (falling back to
453
- * {@link DEFAULT_PIE_CHART_THEME} on first resolution), fully resolving every
454
- * leaf so the tween below always has resolved data to interpolate.
455
- */
456
- export function resolvePieChartTheme(
457
- theme: PieChartTheme | undefined,
458
- previous?: PieChartConfig
459
- ): PieChartConfig {
460
- const base = previous ?? DEFAULT_PIE_CHART_THEME
461
- return {
462
- sliceFills: resolveSeriesFills(theme?.sliceFills, base.sliceFills),
463
- ring: resolveRingConfig(theme?.ring, base.ring),
464
- label: resolveLabelConfig(theme?.label, base.label),
465
- center: resolveCenterConfig(theme?.center, base.center),
466
- legend: resolveLegendConfig(theme?.legend, base.legend),
467
- spotlight: {
468
- dimOpacity: theme?.spotlight?.dimOpacity ?? base.spotlight.dimOpacity,
469
- },
470
- }
471
- }
472
-
473
- /** {@link PieChart}'s `theme` property tween — leaf by leaf. */
474
- export function lerpPieChartTheme(
475
- from: PieChartConfig,
476
- to: PieChartConfig,
477
- t: number
478
- ): PieChartConfig {
479
- return {
480
- // A palette is a list of independent paints rather than one value, so it
481
- // interpolates pairwise by index; one that grows or shrinks holds the longer
482
- // side's tail, exactly as the cartesian charts' palettes do.
483
- sliceFills: lerpSeriesFills(from.sliceFills, to.sliceFills, t),
484
- ring: lerpRingConfig(from.ring, to.ring, t),
485
- label: lerpLabelConfig(from.label, to.label, t),
486
- center: lerpCenterConfig(from.center, to.center, t),
487
- legend: lerpLegendConfig(from.legend, to.legend, t),
488
- spotlight: {
489
- dimOpacity: lerpNumber(
490
- from.spotlight.dimOpacity,
491
- to.spotlight.dimOpacity,
492
- t
493
- ),
494
- },
495
- }
496
- }
1
+ /**
2
+ * Types and pure helpers specific to the {@link PieChart} family: how rows
3
+ * become slices, how slices become arcs on a ring, where the labels around it
4
+ * end up, and the theme all of that is drawn from.
5
+ *
6
+ * The one chart in the family that draws **one mark per row** rather than one
7
+ * per column, which is why almost nothing about its geometry is shared: there is
8
+ * no scale, no axis and no baseline, only a circle divided by share. What it
9
+ * *does* share — the legend, the theme leaf resolvers, the palette, the
10
+ * percentage format — comes from `../kit/shared.ts`.
11
+ *
12
+ * Kept renderer-agnostic (no `Node` imports) so a host's inspector mapping
13
+ * can build a partial {@link PieChartTheme} without
14
+ * pulling the node classes in.
15
+ */
16
+
17
+ import {
18
+ lerpNumber,
19
+ shadowOps,
20
+ strokeOps,
21
+ textOps,
22
+ type DataRecord,
23
+ type Fill,
24
+ type Shadow,
25
+ type ShadowResolved,
26
+ type Stroke,
27
+ type StrokeResolved,
28
+ type TextStyle,
29
+ type Vector2,
30
+ } from "@motionscript/sdk"
31
+
32
+ import {
33
+ CHART_PALETTE,
34
+ DEFAULT_LEGEND,
35
+ DEFAULT_SERIES_FILLS,
36
+ lerpLegendConfig,
37
+ lerpSeriesFills,
38
+ num,
39
+ resolveLegendConfig,
40
+ resolveSeriesFills,
41
+ text,
42
+ type LegendConfig,
43
+ type LegendTheme,
44
+ } from "../kit/shared"
45
+
46
+ /**
47
+ * One **resolved** slice of a {@link PieChart}: its caption, its magnitude, and
48
+ * the paint its wedge and legend swatch take.
49
+ *
50
+ * This is what the ring and the legend consume, not what a scene authors: the
51
+ * chart takes tabular `data` plus two field names and resolves it into these, so
52
+ * the parts stay unaware of where a slice's caption, magnitude and paint came
53
+ * from.
54
+ */
55
+ export interface PieSlice {
56
+ /** Legend caption and label text for this slice. */
57
+ category: string
58
+ /** This slice's magnitude. Its wedge covers `value / (sum of all values)`. */
59
+ value: number
60
+ /** Wedge and swatch paint. */
61
+ fill: Fill
62
+ }
63
+
64
+ /**
65
+ * Resolve tabular rows into slices: one per row, captioned by the `category`
66
+ * column, sized by the `value` column, painted from `palette` by row order
67
+ * (cycling when the palette is shorter than the data).
68
+ *
69
+ * Paint comes from the palette rather than from the rows because a pie draws one
70
+ * mark per *row* — unlike the line and bar charts, where a colour belongs to a
71
+ * series (a whole column) and rides on the series entry instead. Keeping it out
72
+ * of the rows is what lets a pie be fed straight from a CSV of plain numbers.
73
+ */
74
+ export function resolvePieSlices(
75
+ data: DataRecord[],
76
+ category: string,
77
+ value: string,
78
+ palette: Fill[]
79
+ ): PieSlice[] {
80
+ return data.map((row, i) => ({
81
+ category: text(row, category),
82
+ value: num(row, value),
83
+ fill:
84
+ palette.length > 0 ? palette[i % palette.length] : ("white" as Fill),
85
+ }))
86
+ }
87
+
88
+ /** What a {@link PieChart}'s labels say. */
89
+ export type PieLabelMode =
90
+ /**
91
+ * The category and its percentage, two lines. Their order flips with the
92
+ * placement: a **leader** label reads category over percentage, an **inside**
93
+ * label reads the percentage big over a small category caption — inside a
94
+ * wedge the reader already knows which slice they're on, so the magnitude is
95
+ * the thing being read.
96
+ */
97
+ | "both"
98
+ /** The category only. */
99
+ | "category"
100
+ /** The percentage only — the pick when a legend already names the categories. */
101
+ | "percent"
102
+ /** No labels and no leader lines at all. */
103
+ | "none"
104
+
105
+ /**
106
+ * Where a slice's label sits.
107
+ *
108
+ * `"inside"` drops the leader line entirely and centres the label in the wedge's
109
+ * own band, which costs no gutter — so the ring draws bigger — and costs the eye
110
+ * no round trip. `"leader"` is the classic treatment, and what a sliver too
111
+ * narrow to hold text needs.
112
+ */
113
+ export type PieLabelPlacement = "inside" | "leader" | "none"
114
+
115
+ /** Degrees → radians. */
116
+ export const DEG = Math.PI / 180
117
+
118
+ /** A slice's plotted value: negatives and non-finite values count as 0. */
119
+ export function sliceValue(slice: PieSlice): number {
120
+ return slice.value > 0 && Number.isFinite(slice.value) ? slice.value : 0
121
+ }
122
+
123
+ /** Sum of every slice's plotted value — the denominator every share is taken against. */
124
+ export function sliceTotal(slices: PieSlice[]): number {
125
+ let sum = 0
126
+ for (const slice of slices) sum += sliceValue(slice)
127
+ return sum
128
+ }
129
+
130
+ /**
131
+ * The point at radius `r` and angle `a` on a ring centred on the origin, in the
132
+ * node's centred, **y-up** space. Angles run **clockwise from 12 o'clock**:
133
+ * `a = 0` points straight up (+y), and increasing `a` sweeps toward +x then −y.
134
+ */
135
+ export function ringPoint(r: number, a: number): Vector2 {
136
+ return { x: r * Math.sin(a), y: r * Math.cos(a) }
137
+ }
138
+
139
+ /** One slice's resolved angular span on the ring. */
140
+ export interface PieArc {
141
+ /** Index of the slice in the source list. */
142
+ index: number
143
+ /** Start angle, radians clockwise from 12 o'clock. */
144
+ a0: number
145
+ /** End angle, radians clockwise from 12 o'clock. */
146
+ a1: number
147
+ /** Bisector angle — where this slice's label and pop-out point. */
148
+ mid: number
149
+ /** This slice's share of the total, in `[0, 1]`. */
150
+ fraction: number
151
+ }
152
+
153
+ /**
154
+ * Resolve slices into per-slice arcs: each covers exactly its share of the
155
+ * circle, walking clockwise from `startDegrees` (0 = 12 o'clock), with the arcs
156
+ * abutting. Zero/negative slices are skipped entirely; an all-zero list yields
157
+ * no arcs.
158
+ *
159
+ * Arcs are the *undivided* shares — the gap between neighbours is not carved out
160
+ * here, because a fixed angular gap reads as a wedge: wide at the rim, pinched
161
+ * shut at the hole. It's applied when the wedge is drawn instead, as a
162
+ * constant-width inset off both ends of every arc, so a slice still covers its
163
+ * true percentage of the circle.
164
+ */
165
+ export function pieArcs(slices: PieSlice[], startDegrees: number): PieArc[] {
166
+ const total = sliceTotal(slices)
167
+ if (total <= 0) return []
168
+
169
+ const arcs: PieArc[] = []
170
+ let cursor = startDegrees * DEG
171
+ slices.forEach((slice, index) => {
172
+ const value = sliceValue(slice)
173
+ if (value <= 0) return
174
+ const fraction = value / total
175
+ const a0 = cursor
176
+ const a1 = a0 + fraction * Math.PI * 2
177
+ cursor = a1
178
+ arcs.push({ index, a0, a1, mid: (a0 + a1) / 2, fraction })
179
+ })
180
+ return arcs
181
+ }
182
+
183
+ /** A label competing for vertical space in one of the two label columns. */
184
+ export interface LabelSlot {
185
+ /** Where the label would sit if nothing else were in the way. */
186
+ ideal: number
187
+ /** Where it ends up once the column has been de-conflicted. Written by {@link relaxLabels}. */
188
+ y: number
189
+ }
190
+
191
+ /**
192
+ * Spread one column of labels so no two overlap, keeping each as close to its
193
+ * `ideal` y as possible and the whole column inside `[bottom, top]`.
194
+ *
195
+ * Two passes, the standard treatment. Walking top-to-bottom, each label takes
196
+ * the highest slot at or below its ideal that still clears the label above it by
197
+ * `minGap`; if that pushes the tail past `bottom`, a reverse pass lifts it back
198
+ * up. When there are more labels than the column can hold, the reverse pass wins
199
+ * and the crowding is shared evenly rather than dumped off the bottom edge.
200
+ *
201
+ * Mutates each slot's `y` and returns `slots` re-ordered top-to-bottom.
202
+ */
203
+ export function relaxLabels<T extends LabelSlot>(
204
+ slots: T[],
205
+ minGap: number,
206
+ top: number,
207
+ bottom: number
208
+ ): T[] {
209
+ slots.sort((a, b) => b.ideal - a.ideal)
210
+
211
+ let ceiling = top
212
+ for (const slot of slots) {
213
+ slot.y = Math.min(slot.ideal, ceiling)
214
+ ceiling = slot.y - minGap
215
+ }
216
+
217
+ let floor = bottom
218
+ for (let i = slots.length - 1; i >= 0; i--) {
219
+ slots[i].y = Math.max(slots[i].y, floor)
220
+ floor = slots[i].y + minGap
221
+ }
222
+
223
+ return slots
224
+ }
225
+
226
+ // ---- Theme ---------------------------------------------------------------
227
+
228
+ /** The ring's geometry and paint — everything about the wedges but their colour. */
229
+ export interface RingConfig {
230
+ /**
231
+ * Width of the channel between adjacent slices, in px, held constant all the
232
+ * way across the band — the same clearance at the hole as at the rim. (An
233
+ * angular gap would splay open toward the rim instead.)
234
+ */
235
+ gap: number
236
+ /**
237
+ * Radius of the hole punched through the middle. Below 1 it reads as a
238
+ * fraction of the ring's outer radius (`0.6` leaves a band 40% of the radius
239
+ * thick); 1 and up as an explicit pixel radius; `0` fills the middle in,
240
+ * drawing solid pie wedges.
241
+ */
242
+ holeRadius: number
243
+ /** Angle in degrees where the first slice begins, clockwise from 12 o'clock. */
244
+ startAngle: number
245
+ /**
246
+ * How far out a selected slice's rim reaches, as a **multiple of the ring's
247
+ * outer radius**. Being relative, the emphasis scales with the chart instead
248
+ * of shrinking on a big one and swamping a small one. Clamped to `[1, 1.6]`.
249
+ */
250
+ selectedGrow: number
251
+ /** Optional outline on every wedge. */
252
+ stroke: Stroke
253
+ /** Optional drop shadow on every wedge. */
254
+ shadow: Shadow
255
+ }
256
+
257
+ /** How the ring's labels are set, and the leader lines that point at them. */
258
+ export interface LabelConfig {
259
+ /** The category caption on a label. */
260
+ textStyle: TextStyle
261
+ /** The percentage on a label. */
262
+ percentStyle: TextStyle
263
+ /**
264
+ * Ink for a label set **inside** its wedge, both lines. Separate because an
265
+ * inside label sits on the wedge's own paint rather than on the background, so
266
+ * it usually wants the opposite end of the value range from everything else.
267
+ */
268
+ insideStyle: TextStyle
269
+ /** The leader line running from a wedge out to its label. */
270
+ leaderStroke: Stroke
271
+ }
272
+
273
+ /** The summary set in the ring's hole. */
274
+ export interface CenterConfig {
275
+ labelStyle: TextStyle
276
+ captionStyle: TextStyle
277
+ }
278
+
279
+ /** A {@link PieChart}'s full, resolved visual theme. */
280
+ export interface PieChartConfig {
281
+ /** Wedge and swatch paint, cycled by **row** index (`i % length`). */
282
+ sliceFills: Fill[]
283
+ ring: RingConfig
284
+ label: LabelConfig
285
+ center: CenterConfig
286
+ legend: LegendConfig
287
+ /** How far everything outside the selection fades while any slice is selected. */
288
+ spotlight: { dimOpacity: number }
289
+ }
290
+
291
+ /** Author-facing partial theme — every branch optional and deep-merged. */
292
+ export type PieChartTheme = {
293
+ sliceFills?: Fill[]
294
+ ring?: Partial<RingConfig>
295
+ label?: Partial<LabelConfig>
296
+ center?: Partial<CenterConfig>
297
+ legend?: LegendTheme
298
+ spotlight?: Partial<PieChartConfig["spotlight"]>
299
+ }
300
+
301
+ const LABEL_TEXT_STYLE: TextStyle = {
302
+ fontSize: 26,
303
+ fontWeight: 600,
304
+ textAlign: "center",
305
+ fill: CHART_PALETTE.markerColor,
306
+ }
307
+
308
+ /**
309
+ * Opacity the other slices fade to under a selection. Fainter than the line and
310
+ * bar charts' `SPOTLIGHT_DIM`: a ring's slices touch, so at that a selected
311
+ * slice barely separates from its neighbours.
312
+ */
313
+ export const PIE_SPOTLIGHT_DIM = 0.1
314
+
315
+ /** The pie chart's out-of-the-box look. */
316
+ export const DEFAULT_PIE_CHART_THEME: PieChartConfig = {
317
+ sliceFills: DEFAULT_SERIES_FILLS,
318
+ ring: {
319
+ gap: 6,
320
+ // A donut rather than a solid pie by default: the hole is the largest
321
+ // uninterrupted area the chart owns and the one place a conclusion can be
322
+ // set. `0` here would give that away before anyone had asked for it.
323
+ holeRadius: 0.6,
324
+ startAngle: 0,
325
+ selectedGrow: 1.08,
326
+ stroke: [],
327
+ shadow: [],
328
+ },
329
+ label: {
330
+ textStyle: LABEL_TEXT_STYLE,
331
+ percentStyle: { ...LABEL_TEXT_STYLE, fontWeight: 700 },
332
+ // Inside labels sit on the wedge's paint, which is a mid-tone from the
333
+ // palette — so they lead with the panel colour rather than the axis ink.
334
+ insideStyle: { ...LABEL_TEXT_STYLE, fontWeight: 800, fill: "#15130e" },
335
+ leaderStroke: { weight: 2, fill: CHART_PALETTE.markerColor },
336
+ },
337
+ center: {
338
+ labelStyle: {
339
+ fontSize: 46,
340
+ fontWeight: 800,
341
+ textAlign: "center",
342
+ fill: CHART_PALETTE.markerColor,
343
+ },
344
+ captionStyle: {
345
+ fontSize: 22,
346
+ fontWeight: 500,
347
+ textAlign: "center",
348
+ fill: CHART_PALETTE.markerColor,
349
+ },
350
+ },
351
+ legend: DEFAULT_LEGEND,
352
+ spotlight: { dimOpacity: PIE_SPOTLIGHT_DIM },
353
+ }
354
+
355
+ function resolveRingConfig(
356
+ ring: Partial<RingConfig> | undefined,
357
+ previous: RingConfig
358
+ ): RingConfig {
359
+ return {
360
+ gap: ring?.gap ?? previous.gap,
361
+ holeRadius: ring?.holeRadius ?? previous.holeRadius,
362
+ startAngle: ring?.startAngle ?? previous.startAngle,
363
+ selectedGrow: ring?.selectedGrow ?? previous.selectedGrow,
364
+ stroke: strokeOps.resolve(ring?.stroke ?? previous.stroke),
365
+ shadow: shadowOps.resolve(ring?.shadow ?? previous.shadow),
366
+ }
367
+ }
368
+
369
+ function lerpRingConfig(
370
+ from: RingConfig,
371
+ to: RingConfig,
372
+ t: number
373
+ ): RingConfig {
374
+ return {
375
+ gap: lerpNumber(from.gap, to.gap, t),
376
+ holeRadius: lerpNumber(from.holeRadius, to.holeRadius, t),
377
+ startAngle: lerpNumber(from.startAngle, to.startAngle, t),
378
+ selectedGrow: lerpNumber(from.selectedGrow, to.selectedGrow, t),
379
+ stroke: strokeOps.lerp(
380
+ from.stroke as StrokeResolved[],
381
+ to.stroke as StrokeResolved[],
382
+ t
383
+ ),
384
+ shadow: shadowOps.lerp(
385
+ from.shadow as ShadowResolved[],
386
+ to.shadow as ShadowResolved[],
387
+ t
388
+ ),
389
+ }
390
+ }
391
+
392
+ function resolveLabelConfig(
393
+ label: Partial<LabelConfig> | undefined,
394
+ previous: LabelConfig
395
+ ): LabelConfig {
396
+ return {
397
+ textStyle: textOps.resolveStyle(label?.textStyle, previous.textStyle),
398
+ percentStyle: textOps.resolveStyle(
399
+ label?.percentStyle,
400
+ previous.percentStyle
401
+ ),
402
+ insideStyle: textOps.resolveStyle(label?.insideStyle, previous.insideStyle),
403
+ leaderStroke: strokeOps.resolve(
404
+ label?.leaderStroke ?? previous.leaderStroke
405
+ ),
406
+ }
407
+ }
408
+
409
+ function lerpLabelConfig(
410
+ from: LabelConfig,
411
+ to: LabelConfig,
412
+ t: number
413
+ ): LabelConfig {
414
+ return {
415
+ textStyle: textOps.lerpStyle(from.textStyle, to.textStyle, t),
416
+ percentStyle: textOps.lerpStyle(from.percentStyle, to.percentStyle, t),
417
+ insideStyle: textOps.lerpStyle(from.insideStyle, to.insideStyle, t),
418
+ leaderStroke: strokeOps.lerp(
419
+ from.leaderStroke as StrokeResolved[],
420
+ to.leaderStroke as StrokeResolved[],
421
+ t
422
+ ),
423
+ }
424
+ }
425
+
426
+ function resolveCenterConfig(
427
+ center: Partial<CenterConfig> | undefined,
428
+ previous: CenterConfig
429
+ ): CenterConfig {
430
+ return {
431
+ labelStyle: textOps.resolveStyle(center?.labelStyle, previous.labelStyle),
432
+ captionStyle: textOps.resolveStyle(
433
+ center?.captionStyle,
434
+ previous.captionStyle
435
+ ),
436
+ }
437
+ }
438
+
439
+ function lerpCenterConfig(
440
+ from: CenterConfig,
441
+ to: CenterConfig,
442
+ t: number
443
+ ): CenterConfig {
444
+ return {
445
+ labelStyle: textOps.lerpStyle(from.labelStyle, to.labelStyle, t),
446
+ captionStyle: textOps.lerpStyle(from.captionStyle, to.captionStyle, t),
447
+ }
448
+ }
449
+
450
+ /**
451
+ * {@link PieChart}'s `theme` property mapper: merges the author's partial
452
+ * {@link PieChartTheme} onto `previous` (falling back to
453
+ * {@link DEFAULT_PIE_CHART_THEME} on first resolution), fully resolving every
454
+ * leaf so the tween below always has resolved data to interpolate.
455
+ */
456
+ export function resolvePieChartTheme(
457
+ theme: PieChartTheme | undefined,
458
+ previous?: PieChartConfig
459
+ ): PieChartConfig {
460
+ const base = previous ?? DEFAULT_PIE_CHART_THEME
461
+ return {
462
+ sliceFills: resolveSeriesFills(theme?.sliceFills, base.sliceFills),
463
+ ring: resolveRingConfig(theme?.ring, base.ring),
464
+ label: resolveLabelConfig(theme?.label, base.label),
465
+ center: resolveCenterConfig(theme?.center, base.center),
466
+ legend: resolveLegendConfig(theme?.legend, base.legend),
467
+ spotlight: {
468
+ dimOpacity: theme?.spotlight?.dimOpacity ?? base.spotlight.dimOpacity,
469
+ },
470
+ }
471
+ }
472
+
473
+ /** {@link PieChart}'s `theme` property tween — leaf by leaf. */
474
+ export function lerpPieChartTheme(
475
+ from: PieChartConfig,
476
+ to: PieChartConfig,
477
+ t: number
478
+ ): PieChartConfig {
479
+ return {
480
+ // A palette is a list of independent paints rather than one value, so it
481
+ // interpolates pairwise by index; one that grows or shrinks holds the longer
482
+ // side's tail, exactly as the cartesian charts' palettes do.
483
+ sliceFills: lerpSeriesFills(from.sliceFills, to.sliceFills, t),
484
+ ring: lerpRingConfig(from.ring, to.ring, t),
485
+ label: lerpLabelConfig(from.label, to.label, t),
486
+ center: lerpCenterConfig(from.center, to.center, t),
487
+ legend: lerpLegendConfig(from.legend, to.legend, t),
488
+ spotlight: {
489
+ dimOpacity: lerpNumber(
490
+ from.spotlight.dimOpacity,
491
+ to.spotlight.dimOpacity,
492
+ t
493
+ ),
494
+ },
495
+ }
496
+ }