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

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 (146) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +30 -16
  3. package/dist/bar-chart/bar-chart.d.ts +133 -242
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +378 -569
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +35 -41
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +93 -115
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +50 -70
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +156 -210
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +2 -2
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +1 -1
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +3 -3
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +3 -3
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
  24. package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +3 -3
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -6
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -6
  32. package/dist/engine.js.map +1 -1
  33. package/dist/kit/cartesian.d.ts +44 -0
  34. package/dist/kit/cartesian.d.ts.map +1 -0
  35. package/dist/kit/cartesian.js +59 -0
  36. package/dist/kit/cartesian.js.map +1 -0
  37. package/dist/kit/chart-border.d.ts +10 -11
  38. package/dist/kit/chart-border.d.ts.map +1 -1
  39. package/dist/kit/chart-border.js +15 -32
  40. package/dist/kit/chart-border.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +32 -21
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +33 -90
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +123 -64
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +129 -154
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +17 -0
  50. package/dist/kit/entries.d.ts.map +1 -0
  51. package/dist/kit/entries.js +19 -0
  52. package/dist/kit/entries.js.map +1 -0
  53. package/dist/kit/motion.d.ts +48 -0
  54. package/dist/kit/motion.d.ts.map +1 -0
  55. package/dist/kit/motion.js +63 -0
  56. package/dist/kit/motion.js.map +1 -0
  57. package/dist/kit/paints.d.ts +10 -0
  58. package/dist/kit/paints.d.ts.map +1 -0
  59. package/dist/kit/paints.js +12 -0
  60. package/dist/kit/paints.js.map +1 -0
  61. package/dist/kit/shared.d.ts +31 -3
  62. package/dist/kit/shared.d.ts.map +1 -1
  63. package/dist/kit/shared.js +42 -3
  64. package/dist/kit/shared.js.map +1 -1
  65. package/dist/kit/vertical-axis.d.ts +48 -61
  66. package/dist/kit/vertical-axis.d.ts.map +1 -1
  67. package/dist/kit/vertical-axis.js +97 -132
  68. package/dist/kit/vertical-axis.js.map +1 -1
  69. package/dist/line-chart/chart-line.d.ts +60 -77
  70. package/dist/line-chart/chart-line.d.ts.map +1 -1
  71. package/dist/line-chart/chart-line.js +100 -121
  72. package/dist/line-chart/chart-line.js.map +1 -1
  73. package/dist/line-chart/chart-region.d.ts +32 -33
  74. package/dist/line-chart/chart-region.d.ts.map +1 -1
  75. package/dist/line-chart/chart-region.js +31 -65
  76. package/dist/line-chart/chart-region.js.map +1 -1
  77. package/dist/line-chart/horizontal-axis.d.ts +58 -64
  78. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  79. package/dist/line-chart/horizontal-axis.js +94 -123
  80. package/dist/line-chart/horizontal-axis.js.map +1 -1
  81. package/dist/line-chart/index.d.ts +2 -2
  82. package/dist/line-chart/index.d.ts.map +1 -1
  83. package/dist/line-chart/index.js +1 -1
  84. package/dist/line-chart/index.js.map +1 -1
  85. package/dist/line-chart/line-chart.d.ts +156 -319
  86. package/dist/line-chart/line-chart.d.ts.map +1 -1
  87. package/dist/line-chart/line-chart.js +403 -663
  88. package/dist/line-chart/line-chart.js.map +1 -1
  89. package/dist/line-chart/shared.d.ts +3 -3
  90. package/dist/line-chart/shared.d.ts.map +1 -1
  91. package/dist/line-chart/shared.js +3 -3
  92. package/dist/line-chart/shared.js.map +1 -1
  93. package/dist/nodes.d.ts +6 -9
  94. package/dist/nodes.d.ts.map +1 -1
  95. package/dist/nodes.js +7 -9
  96. package/dist/nodes.js.map +1 -1
  97. package/dist/pie-chart/index.d.ts +2 -2
  98. package/dist/pie-chart/index.d.ts.map +1 -1
  99. package/dist/pie-chart/index.js +1 -1
  100. package/dist/pie-chart/index.js.map +1 -1
  101. package/dist/pie-chart/pie-chart.d.ts +108 -132
  102. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  103. package/dist/pie-chart/pie-chart.js +200 -271
  104. package/dist/pie-chart/pie-chart.js.map +1 -1
  105. package/dist/pie-chart/pie-legend.d.ts +84 -31
  106. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  107. package/dist/pie-chart/pie-legend.js +93 -107
  108. package/dist/pie-chart/pie-legend.js.map +1 -1
  109. package/dist/pie-chart/pie-ring.d.ts +160 -131
  110. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-ring.js +333 -391
  112. package/dist/pie-chart/pie-ring.js.map +1 -1
  113. package/dist/pie-chart/shared.d.ts +2 -2
  114. package/dist/pie-chart/shared.d.ts.map +1 -1
  115. package/dist/pie-chart/shared.js +2 -2
  116. package/dist/pie-chart/shared.js.map +1 -1
  117. package/package.json +4 -4
  118. package/src/bar-chart/bar-chart.ts +415 -701
  119. package/src/bar-chart/category-axis.ts +95 -110
  120. package/src/bar-chart/chart-bars.ts +178 -200
  121. package/src/bar-chart/index.ts +2 -2
  122. package/src/bar-chart/shared.ts +3 -3
  123. package/src/engine.ts +8 -6
  124. package/src/kit/cartesian.ts +81 -0
  125. package/src/kit/chart-border.ts +22 -33
  126. package/src/kit/chart-canvas.ts +44 -79
  127. package/src/kit/chart-legend.ts +173 -189
  128. package/src/kit/entries.ts +20 -0
  129. package/src/kit/motion.ts +90 -0
  130. package/src/kit/paints.ts +15 -0
  131. package/src/kit/shared.ts +52 -3
  132. package/src/kit/vertical-axis.ts +110 -140
  133. package/src/line-chart/chart-line.ts +102 -154
  134. package/src/line-chart/chart-region.ts +35 -80
  135. package/src/line-chart/horizontal-axis.ts +101 -152
  136. package/src/line-chart/index.ts +2 -2
  137. package/src/line-chart/line-chart.ts +449 -825
  138. package/src/line-chart/shared.ts +3 -3
  139. package/src/nodes.ts +8 -10
  140. package/src/pie-chart/index.ts +2 -2
  141. package/src/pie-chart/pie-chart.ts +221 -311
  142. package/src/pie-chart/pie-legend.ts +124 -114
  143. package/src/pie-chart/pie-ring.ts +386 -473
  144. package/src/pie-chart/shared.ts +2 -2
  145. package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
  146. package/dist/browser/chunks/chunk-5ZPBV6BU.js.map +0 -7
@@ -12,7 +12,7 @@
12
12
  * Kept renderer-agnostic (no `Node` imports) so every part shares one
13
13
  * scale/format/palette/theme vocabulary, and so a host's own inspector
14
14
  * mapping can build a partial {@link ChartTheme} without
15
- * pulling the node classes in.
15
+ * pulling the components in.
16
16
  */
17
17
 
18
18
  import {
@@ -365,7 +365,7 @@ function lerpRegionConfig(
365
365
  }
366
366
 
367
367
  /**
368
- * {@link LineChart}'s `theme` @property mapper: merges the author's partial
368
+ * {@link LineChart}'s `theme` entry's resolve: merges the author's partial
369
369
  * {@link ChartTheme} onto `previous` (falling back to
370
370
  * {@link DEFAULT_CHART_THEME} on first resolution), fully resolving every leaf.
371
371
  */
@@ -384,7 +384,7 @@ export function resolveChartTheme(
384
384
  }
385
385
  }
386
386
 
387
- /** {@link LineChart}'s `theme` @property tween — leaf by leaf. */
387
+ /** {@link LineChart}'s `theme` entry's lerp — leaf by leaf. */
388
388
  export function lerpChartTheme(
389
389
  from: ChartConfig,
390
390
  to: ChartConfig,
package/src/nodes.ts CHANGED
@@ -6,15 +6,13 @@ import { PieChart } from "./pie-chart";
6
6
  * Every node type this package publishes.
7
7
  *
8
8
  * A host registers these by handing the array to an engine —
9
- * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
10
- * key. The decorator only *declares* that key: nothing is registered by the mere
11
- * act of importing a module, so a registry holds exactly what its owner asked
12
- * for and two engines in one process can differ about it.
9
+ * `new Engine(platform, { nodes: NODES })` — which reads each component's key.
10
+ * Defining a component only *declares* that key: nothing is registered by
11
+ * importing a module, so a registry holds exactly what its owner asked for.
13
12
  *
14
- * Listing class **values** is also what survives bundling. This package declares
15
- * `sideEffects: false`, which licenses a bundler to drop a module nothing
16
- * imports a value from, and a document names a node type by string — so
17
- * `import "./line-chart"` for its side effect is exactly what gets shaken out, where an
18
- * array of bindings is a data dependency that cannot be.
13
+ * Listing the component **values** is also what survives bundling: this package
14
+ * declares `sideEffects: false`, and a document names a node type by string, so
15
+ * an import kept only for its side effect is exactly what gets shaken out.
19
16
  */
20
- export const NODES = [LineChart, BarChart, PieChart];
17
+ // Typed by name, so the declaration names each component rather than spelling out its type through core.
18
+ export const NODES: (typeof LineChart | typeof BarChart | typeof PieChart)[] = [LineChart, BarChart, PieChart];
@@ -7,8 +7,8 @@
7
7
  * pie; it defaults to a donut, because the hole is the only place a chart has
8
8
  * to put its conclusion.
9
9
  */
10
- export { PieChart } from "./pie-chart"
11
- export type { PieChartProps } from "./pie-chart"
10
+ export { PieChart, isSliceSelected } from "./pie-chart"
11
+ export type { PieChartProps, PieChartValues } from "./pie-chart"
12
12
  export { PieRing } from "./pie-ring"
13
13
  export type { PieRingProps } from "./pie-ring"
14
14
  export { PieLegend } from "./pie-legend"
@@ -1,18 +1,19 @@
1
1
  import {
2
2
  Rect,
3
- command,
4
- node,
5
- Node2D,
3
+ Types,
4
+ component2d,
6
5
  easeOut,
7
- property,
8
- type Command,
9
- type CommandArgs,
6
+ type ChildView,
10
7
  type DataRecord,
11
- type EasingFunction,
12
- type NodeConfig,
8
+ type NodeOf,
9
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
10
+ type Node2D,
13
11
  type Node2DProps,
12
+ type Timeline,
14
13
  } from "@motionscript/sdk"
15
14
 
15
+ import { asParts, fresh } from "../kit/entries"
16
+ import { beats, type Beat } from "../kit/motion"
16
17
  import { clampUnit } from "../kit/shared"
17
18
  import { PieLegend } from "./pie-legend"
18
19
  import { PieRing } from "./pie-ring"
@@ -27,12 +28,6 @@ import {
27
28
  type PieLabelPlacement,
28
29
  type PieSlice,
29
30
  } from "./shared"
30
- import {
31
- asCommand,
32
- hold,
33
- sequence as sequenceOf,
34
- together,
35
- } from "@motionscript/sdk/component"
36
31
 
37
32
  export interface PieChartProps extends Node2DProps {
38
33
  /** One row per slice — a plain record of field name to value. */
@@ -55,329 +50,244 @@ export interface PieChartProps extends Node2DProps {
55
50
  showLegend?: boolean
56
51
  /** Gap between the ring and the legend, in px. */
57
52
  gap?: number
58
- /** Text set in the ring's hole — see {@link revealCenter}. */
53
+ /** Text set in the ring's hole — see `revealCenter`. */
59
54
  centerLabel?: string
60
55
  /** Smaller second line under {@link centerLabel}. */
61
56
  centerCaption?: string
62
57
  /**
63
58
  * Whether the hole's summary is drawn at rest. `true` (the default) is the
64
59
  * plain answer; a scene that wants the conclusion to land on a later beat sets
65
- * this `false` and calls {@link revealCenter} when it gets there.
60
+ * this `false` and runs `revealCenter` when it gets there.
66
61
  */
67
62
  showCenter?: boolean
68
63
  }
69
64
 
65
+ /** What the selection reads: the rows, how they are cut, and the pops the commands move. */
66
+ export interface PieChartValues {
67
+ readonly data: DataRecord[]
68
+ readonly categoryField: string
69
+ readonly valueField: string
70
+ readonly theme: PieChartConfig
71
+ readonly pops: number[]
72
+ }
73
+
70
74
  /**
71
- * How {@link PieChart.enter} divides its duration: the wedges sweeping in, then
72
- * the labels and the legend arriving together. Shares of the whole, so they sum
73
- * to 1 — the entrance takes exactly the time it was given, for the reason spelt
74
- * out on `LineChart.enter`.
75
+ * How `enter` divides its duration: the wedges sweeping in, then the labels and
76
+ * the legend arriving together. Shares of the whole, so they sum to 1.
75
77
  */
76
78
  const SWEEP_SHARE = 0.65
77
79
  const LABEL_SHARE = 0.35
78
80
 
79
- /** What the selection commands move: the chart's own {@link PieChart.pops}. */
80
- type SlicePops = { pops: number[] }
81
+ const SLICE_ARG = Types.number(0, { min: 0, step: 1 })
82
+
83
+ /** The slices the ring and the legend draw, resolved from the rows and the palette. */
84
+ function slicesOf(s: PieChartValues): PieSlice[] {
85
+ return resolvePieSlices(s.data, s.categoryField, s.valueField, s.theme.sliceFills)
86
+ }
87
+
88
+ /** How far slice `index` is pushed out, in `[0, 1]`. */
89
+ function popAt(s: PieChartValues, index: number): number {
90
+ return clampUnit(s.pops[index] ?? 0)
91
+ }
92
+
93
+ /** Whether slice `index` is grown out past the ring's rim: past half way, so a slice mid-swell is not yet counted. */
94
+ export function isSliceSelected(s: PieChartValues, index: number): boolean {
95
+ return popAt(s, index) > 0.5
96
+ }
97
+
98
+ /**
99
+ * How far each slice is dimmed by the selection, one entry per slice: 1 while
100
+ * nothing is out, and for the selected slice itself; falling toward the theme's
101
+ * `dimOpacity` for the rest in proportion to how far the selection has grown.
102
+ */
103
+ function sliceDims(s: PieChartValues): number[] {
104
+ let focus = 0
105
+ for (let i = 0; i < s.data.length; i++) focus = Math.max(focus, popAt(s, i))
106
+ const dim = s.theme.spotlight.dimOpacity
107
+ if (focus <= 0) return s.data.map(() => 1)
108
+ return s.data.map((_, i) => 1 - (1 - clampUnit(dim)) * focus * (1 - popAt(s, i)))
109
+ }
110
+
111
+ type AnyTimeline = Timeline<any, any>
112
+
113
+ /**
114
+ * Every slice's pop eased from where it is to what `pop` asks for. A move that
115
+ * changes nothing still takes its slot, writing nothing.
116
+ */
117
+ function popping(timeline: AnyTimeline, s: PieChartValues, pop: (index: number) => number) {
118
+ const from = s.data.map((_, i) => popAt(s, i))
119
+ const targets = s.data.map((_, i) => clampUnit(pop(i)))
120
+ if (from.every((value, i) => value === targets[i])) return timeline.wait(timeline.duration)
121
+ return timeline.animate((t) => ({ pops: from.map((value, i) => value + (targets[i] - value) * t) }))
122
+ }
123
+
124
+ /** The ring and the legend, found through the chart's children: the last row of them is the one it built. */
125
+ function partsOf(children: readonly ChildView[]) {
126
+ for (let i = children.length - 1; i >= 0; i--) {
127
+ const row = children[i]
128
+ const ring = Rect.is(row) ? row.children.find(PieRing.is) : undefined
129
+ if (ring) return { ring, legend: row.children.find(PieLegend.is) ?? null }
130
+ }
131
+ return null
132
+ }
81
133
 
82
134
  /**
83
- * A pie or donut chart, composed from two independent parts rather than one
84
- * monolithic draw — the third of the chart family, built the same way as the
85
- * line and bar charts beside it:
135
+ * A pie or donut chart, composed from two parts laid out by a horizontal-flow
136
+ * {@link Rect} — the ring filling whatever the hugging legend leaves, both
137
+ * centred:
86
138
  *
87
139
  * - {@link PieRing} — the wedges, each sized by its share of the total, with its
88
- * label set inside its own band or pinned outside on a leader line, and the
89
- * summary in the hole. The only part of the family that draws a path.
140
+ * label inside its own band or pinned outside on a leader line, and the
141
+ * summary in the hole.
90
142
  * - {@link PieLegend} — a column of swatch-and-caption rows naming each slice.
91
143
  *
92
- * The two are laid out by a plain horizontal-flow {@link Rect}: the ring fills whatever the
93
- * hugging legend leaves, both centred, so the chart re-flows if its box changes.
144
+ * **One mark per row, not per column**: a pie's palette is indexed by row and
145
+ * there is nothing to hide, since dropping a slice would re-cut every other
146
+ * wedge. `selectSlice` and its siblings are what a pie has instead — a slice
147
+ * swells out of the ring and everything else dims behind it.
94
148
  *
95
- * **One mark per row, not per column** — which is what makes this the odd one of
96
- * the three. A line or a bar chart plots *series*, so its colours belong to
97
- * columns and hiding one is a sensible thing to ask for. A pie plots rows, so its
98
- * palette is indexed by row and there is nothing to hide: dropping a slice would
99
- * silently re-cut every other wedge's share, which is a different chart rather
100
- * than the same chart with a series switched off. {@link selectSlice} and its
101
- * siblings are what a pie has instead — a slice swells out of the ring, its hole
102
- * edge staying put, and everything else dims behind it.
103
- *
104
- * **Resting state is the finished chart** — the same bargain the other two make:
105
- * an editor paints a node's Initial state on a paused canvas, so a chart that
106
- * only appeared once a command had run would be edited blind. {@link enter} arms
107
- * every part back to its pre-entrance state before animating. The one exception
108
- * is the hole's summary, which a scene can hold back with `showCenter: false`
109
- * because it is usually the chart's *conclusion* rather than part of its setup.
149
+ * **Resting state is the finished chart**; `enter` arms every part on its first
150
+ * frame. The one exception is the hole's summary, which a scene can hold back
151
+ * with `showCenter: false`, because it is usually the chart's *conclusion*.
110
152
  */
111
- @node({
153
+ export const PieChart = component2d({
112
154
  key: "pieChart",
113
- parentKey: "node",
114
- forkable: true,
115
- layout: {
116
- children: "freeform",
117
- defaultWidthMode: "fixed",
118
- defaultHeightMode: "fixed",
119
- acceptsChildren: true,
155
+ state: {
156
+ data: fresh<DataRecord[]>(() => []),
157
+ categoryField: "",
158
+ valueField: "",
159
+ theme: Types.custom<PieChartTheme, PieChartConfig>(DEFAULT_PIE_CHART_THEME, { resolve: resolvePieChartTheme, lerp: lerpPieChartTheme }),
160
+ labelMode: Types.custom<PieLabelMode>("percent"),
161
+ labelPlacement: Types.custom<PieLabelPlacement>("leader"),
162
+ gap: 40,
163
+ centerLabel: "",
164
+ centerCaption: "",
165
+ /**
166
+ * How far each slice is pushed out of the ring, `0`–`1` per index. Held here
167
+ * rather than on the ring because two parts read it: the wedges grow out by
168
+ * it, and the legend entries dim by it. Seeded flush, one to a row.
169
+ */
170
+ pops: fresh<number[]>(() => []),
171
+ /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
172
+ showLegend: Types.boolean(true, { animatable: false }),
173
+ /** Read as the chart is built: whether the hole's summary is drawn at rest. */
174
+ showCenter: Types.boolean(true, { animatable: false }),
120
175
  },
121
- seed: {
122
- width: 680,
123
- height: 480,
176
+ defaults: {
177
+ // Its parts fill it; a node made of them would hug them.
178
+ width: "fill",
179
+ height: "fill",
180
+ pops: (state) => slicesOf(state).map(() => 0),
124
181
  },
125
- })
126
- export class PieChart extends Node2D<PieChartProps> {
127
- @property({ default: () => [] as DataRecord[] }) declare data: DataRecord[]
128
- @property({ default: "" }) declare categoryField: string
129
- @property({ default: "" }) declare valueField: string
130
- @property({
131
- default: DEFAULT_PIE_CHART_THEME,
132
- mapper: resolvePieChartTheme,
133
- tween: lerpPieChartTheme,
134
- })
135
- declare theme: PieChartTheme
136
- @property({ default: "percent" }) declare labelMode: PieLabelMode
137
- @property({ default: "leader" }) declare labelPlacement: PieLabelPlacement
138
- @property({ default: 40 }) declare gap: number
139
- @property({ default: "" }) declare centerLabel: string
140
- @property({ default: "" }) declare centerCaption: string
141
-
142
- /**
143
- * How far each slice is currently pushed out of the ring, `0`–`1` per index.
144
- *
145
- * Held here rather than on the ring because two parts read it: the wedges grow
146
- * out by it, and the legend entries dim by it. One signal, one tween, and the
147
- * two can't drift.
148
- */
149
- @property({ default: () => [] as number[] }) declare pops: number[]
150
-
151
- // ---- Part handles (built in the constructor) --------------------------
152
- private ring: PieRing
153
- private legend: PieLegend | null = null
154
-
155
- /** `this.theme` read back through its *resolved* shape — see `LineChart.theme`. */
156
- private get resolvedTheme(): PieChartConfig {
157
- return this.theme as unknown as PieChartConfig
158
- }
159
-
160
- /** The slices the ring and the legend draw, resolved from the rows and the palette. */
161
- private get slices(): PieSlice[] {
162
- return resolvePieSlices(
163
- this.data,
164
- this.categoryField,
165
- this.valueField,
166
- this.resolvedTheme.sliceFills
167
- )
168
- }
169
-
170
- /**
171
- * How far each slice is dimmed by the current selection, one entry per slice —
172
- * 1 while nothing is selected, and for the selected slice itself; falling
173
- * toward the theme's `dimOpacity` for the rest in proportion to how far the
174
- * selection has grown.
175
- */
176
- private sliceDims(): number[] {
177
- let focus = 0
178
- for (let i = 0; i < this.data.length; i++) {
179
- focus = Math.max(focus, this.popAt(i))
180
- }
181
- const dim = this.resolvedTheme.spotlight.dimOpacity
182
- if (focus <= 0) return this.data.map(() => 1)
183
- return this.data.map(
184
- (_, i) => 1 - (1 - clampUnit(dim)) * focus * (1 - this.popAt(i))
185
- )
186
- }
187
-
188
- /** How far slice `index` is currently pushed out, in `[0, 1]`. */
189
- private popAt(index: number): number {
190
- return clampUnit(this.pops[index] ?? 0)
191
- }
192
-
193
- constructor(props?: NodeConfig<PieChart, PieChartProps>) {
194
- super(props)
195
-
196
- // The whole chart is composed from plain props/`data` — no context needed,
197
- // so the parts are built here (super() applied the props above). The slice
198
- // *structure* (one label and one legend row per row of `data`) is read once,
199
- // like every other chart's series list; everything the parts draw with,
200
- // including the slices themselves and the paint they take from the palette,
201
- // is passed as a closure, so `chart.to({ theme })` genuinely re-paints the
202
- // ring and the swatches and a `set` of `data` re-cuts the wedges.
203
- const slices = this.slices
204
- const showLegend = props?.showLegend !== false
205
- this.pops = slices.map(() => 0)
206
-
207
- this.ring = new PieRing({
208
- width: "fill",
209
- height: "fill",
210
- slices: () => this.slices,
211
- ring: () => this.resolvedTheme.ring,
212
- label: () => this.resolvedTheme.label,
213
- centerStyle: () => this.resolvedTheme.center,
214
- labelMode: this.labelMode,
215
- labelPlacement: this.labelPlacement,
216
- pops: () => this.pops,
217
- dimOpacity: () => this.resolvedTheme.spotlight.dimOpacity,
218
- centerLabel: this.centerLabel,
219
- centerCaption: this.centerCaption,
220
- centerReveal: props?.showCenter === false ? 0 : 1,
221
- })
222
-
223
- const children: Node2D[] = [this.ring]
224
- if (showLegend) {
225
- this.legend = new PieLegend({
226
- slices: () => this.slices,
227
- legend: () => this.resolvedTheme.legend,
228
- dims: () => this.sliceDims(),
229
- })
230
- children.push(this.legend)
231
- }
232
-
233
- this.add(
234
- new Rect({ flow: "horizontal",
182
+ // The slice *structure* (one label and one legend row per row) is read once;
183
+ // the slices themselves, their paint and the selection are bound, so a theme
184
+ // tween re-paints and a `set` of `data` re-cuts the wedges.
185
+ children: (state) => {
186
+ const theme = () => state.theme
187
+ const slices = () => slicesOf(state)
188
+ const parts: Node2D[] = [
189
+ new PieRing({
235
190
  width: "fill",
236
191
  height: "fill",
237
- gap: this.gap,
238
- align: "center",
239
- children,
240
- })
241
- )
242
- }
243
-
244
- // ---- Orchestrated entrance --------------------------------------------
245
-
246
- /**
247
- * Prime every part at its pre-entrance state — no wedges swept, no labels, the
248
- * legend shifted and transparent.
249
- *
250
- * Called by {@link enter} before its first frame so nothing flashes ahead of
251
- * its beat. Idempotent, and public so a scene can hide a chart ahead of time
252
- * without starting the entrance. The hole's summary is deliberately untouched:
253
- * it has its own signal, so a scene that set it up to land later keeps that.
254
- */
255
- arm(): void {
256
- this.ring.set({ growth: 0, labelReveal: 0 })
257
- this.legend?.arm()
258
- }
259
-
260
- /**
261
- * Animate the whole chart in: the wedges sweep in clockwise from the theme's
262
- * `startAngle`, then the labels fade up and the legend staggers in together.
263
- *
264
- * `duration` is the **whole** entrance; the phases are shares of it.
265
- */
266
- @command()
267
- enter(
268
- args: CommandArgs<Record<string, never>> & { duration: number }
269
- ): Command<Record<string, never>> {
270
- const duration = args.duration
271
- const easing = args.easing ?? easeOut()
272
- this.arm()
273
-
274
- return asCommand(
275
- sequenceOf(
276
- this.ring.sweep({ duration: duration * SWEEP_SHARE, easing }),
277
- together(
278
- this.ring.revealLabels({ duration: duration * LABEL_SHARE, easing }),
279
- this.legend
280
- ? this.legend.enter({ duration: duration * LABEL_SHARE, easing })
281
- : hold(duration * LABEL_SHARE)
282
- )
283
- ),
284
- duration
285
- )
286
- }
287
-
288
- /**
289
- * Fade the hole's summary up.
290
- *
291
- * Held out of {@link enter} on purpose: what sits in a donut's hole is usually
292
- * the chart's conclusion, so the scene lands it on the beat it belongs to
293
- * rather than giving it away with the first frame. Pair it with
294
- * `showCenter: false`, which is what leaves the hole clear to begin with.
295
- */
296
- @command()
297
- revealCenter(
298
- args: CommandArgs<Record<string, never>> & { duration?: number } = {}
299
- ): Command<{ centerReveal: number }> {
300
- return this.ring.revealCenter({ duration: args.duration ?? 0.6, easing: args.easing ?? easeOut() })
301
- }
302
-
303
- // ---- Selection ---------------------------------------------------------
304
-
305
- /** True when slice `index` is currently grown out past the ring's rim. */
306
- isSelected(index: number): boolean {
307
- return this.popAt(index) > 0.5
308
- }
309
-
310
- /**
311
- * Grow slice `index` out past the ring's rim and dim the rest of the chart
312
- * behind it, leaving any other selected slice where it is.
313
- */
314
- @command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
315
- selectSlice(
316
- args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
317
- ): Command<SlicePops> {
318
- const { sliceIndex: index } = args.data as { sliceIndex: number }
319
- const duration = args.duration ?? 0.45
320
- const easing = args.easing ?? easeOut()
321
- return this.tweenPops((i) => (i === index ? 1 : this.popAt(i)), duration, easing)
322
- }
323
-
324
- /** Settle slice `index` back flush with the ring. */
325
- @command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
326
- deselectSlice(
327
- args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
328
- ): Command<SlicePops> {
329
- const { sliceIndex: index } = args.data as { sliceIndex: number }
330
- const duration = args.duration ?? 0.45
331
- const easing = args.easing ?? easeOut()
332
- return this.tweenPops((i) => (i === index ? 0 : this.popAt(i)), duration, easing)
333
- }
334
-
335
- /** Move the focus to slice `index` alone, settling whatever else was out. */
336
- @command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
337
- selectOnlySlice(
338
- args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
339
- ): Command<SlicePops> {
340
- const { sliceIndex: index } = args.data as { sliceIndex: number }
341
- const duration = args.duration ?? 0.45
342
- const easing = args.easing ?? easeOut()
343
- return this.tweenPops((i) => (i === index ? 1 : 0), duration, easing)
344
- }
345
-
346
- /** Settle every slice flush and bring the whole chart back to full. */
347
- @command()
348
- clearSelection(
349
- args: CommandArgs<Record<string, never>> & { duration?: number } = {}
350
- ): Command<SlicePops> {
351
- const duration = args.duration ?? 0.45
352
- const easing = args.easing ?? easeOut()
353
- return this.tweenPops(() => 0, duration, easing)
354
- }
355
-
356
- /**
357
- * Ease every slice's pop from where it is now to what `pop` asks for.
358
- *
359
- * Consumes its full duration even when nothing moves: in an editor a
360
- * command's duration is what the timeline lays out and what its parallel
361
- * siblings are timed against, so a no-op that took no frames would pull
362
- * everything after it forward.
363
- *
364
- * `from` and the targets are read when the command is *built*, which for a
365
- * driven scene is with the node in the state its own start time implies — so a
366
- * select that follows another starts from where that one left the ring.
367
- */
368
- private tweenPops(
369
- pop: (index: number) => number,
370
- duration: number,
371
- easing: EasingFunction
372
- ): Command<SlicePops> {
373
- const from = this.data.map((_, i) => this.popAt(i))
374
- const targets = this.data.map((_, i) => clampUnit(pop(i)))
375
- if (from.every((value, i) => value === targets[i])) return this.command<SlicePops>(() => ({}), duration)
192
+ slices,
193
+ ring: () => theme().ring,
194
+ label: () => theme().label,
195
+ centerStyle: () => theme().center,
196
+ labelMode: state.labelMode,
197
+ labelPlacement: state.labelPlacement,
198
+ pops: () => state.pops,
199
+ dimOpacity: () => theme().spotlight.dimOpacity,
200
+ centerLabel: state.centerLabel,
201
+ centerCaption: state.centerCaption,
202
+ centerReveal: state.showCenter === false ? 0 : 1,
203
+ }) as unknown as Node2D,
204
+ ]
205
+ if (state.showLegend !== false) {
206
+ parts.push(
207
+ new PieLegend({
208
+ slices,
209
+ legend: () => theme().legend,
210
+ dims: () => sliceDims(state),
211
+ // The column's spacing is the theme's as the chart was built, like the
212
+ // entries' type and chips.
213
+ gap: state.theme.legend.itemGap / 3,
214
+ padding: state.theme.legend.padding,
215
+ }) as unknown as Node2D
216
+ )
217
+ }
218
+ const row = new Rect({ flow: "horizontal", width: "fill", height: "fill", gap: state.gap, align: "center", children: parts })
219
+ return asParts([...(state.children as unknown as Node2D[]), row as unknown as Node2D])
220
+ },
221
+ commands: {
222
+ /**
223
+ * Animate the whole chart in: the wedges sweep in clockwise from the theme's
224
+ * `startAngle`, then the labels fade up and the legend staggers in together.
225
+ * Its first frame arms the wedges, the labels and the legend; the hole's
226
+ * summary keeps its own signal. `duration` is the **whole** entrance.
227
+ */
228
+ enter: {
229
+ args: {},
230
+ easing: easeOut(),
231
+ run: (timeline, _state, children) => {
232
+ const parts = partsOf(children)
233
+ if (!parts) return timeline.wait(timeline.duration)
234
+ const { ring, legend } = parts
235
+ const sweep = timeline.duration * SWEEP_SHARE
236
+ const labels = timeline.duration * LABEL_SHARE
237
+ const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
238
+ return beats(timeline, [
239
+ at0(ring.to({ growth: 0, labelReveal: 0 })),
240
+ ...(legend ? [at0(legend.run("arm"))] : []),
241
+ { at: 0, duration: sweep, piece: ring.run("sweep") },
242
+ { at: sweep, duration: labels, piece: ring.run("revealLabels") },
243
+ ...(legend ? [{ at: sweep, duration: labels, piece: legend.run("enter") }] : []),
244
+ ])
245
+ },
246
+ },
247
+ /**
248
+ * Fade the hole's summary up. Held out of `enter` on purpose: what sits in a
249
+ * donut's hole is usually the chart's conclusion, so the scene lands it on
250
+ * its own beat. Pair it with `showCenter: false`.
251
+ */
252
+ revealCenter: {
253
+ args: {},
254
+ easing: easeOut(),
255
+ run: (timeline, _state, children) => partsOf(children)?.ring.run("revealCenter") ?? timeline.wait(timeline.duration),
256
+ },
257
+ /** Grow slice `index` out past the ring's rim and dim the rest, leaving any other selected slice where it is. */
258
+ selectSlice: {
259
+ args: { sliceIndex: SLICE_ARG },
260
+ easing: easeOut(),
261
+ run: (timeline, state) => {
262
+ const index = timeline.args.sliceIndex
263
+ return popping(timeline, state, (i) => (i === index ? 1 : popAt(state, i)))
264
+ },
265
+ },
266
+ /** Settle slice `index` back flush with the ring. */
267
+ deselectSlice: {
268
+ args: { sliceIndex: SLICE_ARG },
269
+ easing: easeOut(),
270
+ run: (timeline, state) => {
271
+ const index = timeline.args.sliceIndex
272
+ return popping(timeline, state, (i) => (i === index ? 0 : popAt(state, i)))
273
+ },
274
+ },
275
+ /** Move the focus to slice `index` alone, settling whatever else was out. */
276
+ selectOnlySlice: {
277
+ args: { sliceIndex: SLICE_ARG },
278
+ easing: easeOut(),
279
+ run: (timeline, state) => {
280
+ const index = timeline.args.sliceIndex
281
+ return popping(timeline, state, (i) => (i === index ? 1 : 0))
282
+ },
283
+ },
284
+ /** Settle every slice flush and bring the whole chart back to full. */
285
+ clearSelection: {
286
+ args: {},
287
+ easing: easeOut(),
288
+ run: (timeline, state) => popping(timeline, state, () => 0),
289
+ },
290
+ },
291
+ })
376
292
 
377
- return this.command<SlicePops>(
378
- (t) => ({ pops: from.map((value, i) => value + (targets[i] - value) * t) }),
379
- duration,
380
- easing
381
- )
382
- }
383
- }
293
+ export type PieChart = NodeOf<typeof PieChart>