@motionscript/charts 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (152) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/NOTICE +49 -0
  3. package/README.md +99 -0
  4. package/dist/bar-chart/bar-chart.d.ts +133 -228
  5. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  6. package/dist/bar-chart/bar-chart.js +386 -565
  7. package/dist/bar-chart/bar-chart.js.map +1 -1
  8. package/dist/bar-chart/category-axis.d.ts +35 -41
  9. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  10. package/dist/bar-chart/category-axis.js +93 -115
  11. package/dist/bar-chart/category-axis.js.map +1 -1
  12. package/dist/bar-chart/chart-bars.d.ts +50 -70
  13. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  14. package/dist/bar-chart/chart-bars.js +155 -210
  15. package/dist/bar-chart/chart-bars.js.map +1 -1
  16. package/dist/bar-chart/index.d.ts +2 -2
  17. package/dist/bar-chart/index.d.ts.map +1 -1
  18. package/dist/bar-chart/index.js +1 -1
  19. package/dist/bar-chart/index.js.map +1 -1
  20. package/dist/bar-chart/shared.d.ts +7 -7
  21. package/dist/bar-chart/shared.d.ts.map +1 -1
  22. package/dist/bar-chart/shared.js +7 -7
  23. package/dist/bar-chart/shared.js.map +1 -1
  24. package/dist/browser/chunks/chunk-TJNHLQGV.js +2 -0
  25. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  26. package/dist/browser/index.js +1 -1
  27. package/dist/browser/index.js.map +3 -3
  28. package/dist/browser/kit.js +1 -1
  29. package/dist/browser/manifest.json +4 -3
  30. package/dist/engine.d.ts +10 -8
  31. package/dist/engine.d.ts.map +1 -1
  32. package/dist/engine.js +10 -8
  33. package/dist/engine.js.map +1 -1
  34. package/dist/kit/cartesian.d.ts +44 -0
  35. package/dist/kit/cartesian.d.ts.map +1 -0
  36. package/dist/kit/cartesian.js +59 -0
  37. package/dist/kit/cartesian.js.map +1 -0
  38. package/dist/kit/chart-border.d.ts +10 -11
  39. package/dist/kit/chart-border.d.ts.map +1 -1
  40. package/dist/kit/chart-border.js +15 -32
  41. package/dist/kit/chart-border.js.map +1 -1
  42. package/dist/kit/chart-canvas.d.ts +32 -21
  43. package/dist/kit/chart-canvas.d.ts.map +1 -1
  44. package/dist/kit/chart-canvas.js +33 -90
  45. package/dist/kit/chart-canvas.js.map +1 -1
  46. package/dist/kit/chart-legend.d.ts +123 -85
  47. package/dist/kit/chart-legend.d.ts.map +1 -1
  48. package/dist/kit/chart-legend.js +137 -186
  49. package/dist/kit/chart-legend.js.map +1 -1
  50. package/dist/kit/entries.d.ts +17 -0
  51. package/dist/kit/entries.d.ts.map +1 -0
  52. package/dist/kit/entries.js +19 -0
  53. package/dist/kit/entries.js.map +1 -0
  54. package/dist/kit/motion.d.ts +48 -0
  55. package/dist/kit/motion.d.ts.map +1 -0
  56. package/dist/kit/motion.js +63 -0
  57. package/dist/kit/motion.js.map +1 -0
  58. package/dist/kit/paints.d.ts +10 -0
  59. package/dist/kit/paints.d.ts.map +1 -0
  60. package/dist/kit/paints.js +12 -0
  61. package/dist/kit/paints.js.map +1 -0
  62. package/dist/kit/shared.d.ts +61 -10
  63. package/dist/kit/shared.d.ts.map +1 -1
  64. package/dist/kit/shared.js +108 -13
  65. package/dist/kit/shared.js.map +1 -1
  66. package/dist/kit/vertical-axis.d.ts +48 -61
  67. package/dist/kit/vertical-axis.d.ts.map +1 -1
  68. package/dist/kit/vertical-axis.js +97 -132
  69. package/dist/kit/vertical-axis.js.map +1 -1
  70. package/dist/line-chart/chart-line.d.ts +60 -77
  71. package/dist/line-chart/chart-line.d.ts.map +1 -1
  72. package/dist/line-chart/chart-line.js +100 -121
  73. package/dist/line-chart/chart-line.js.map +1 -1
  74. package/dist/line-chart/chart-region.d.ts +32 -33
  75. package/dist/line-chart/chart-region.d.ts.map +1 -1
  76. package/dist/line-chart/chart-region.js +31 -65
  77. package/dist/line-chart/chart-region.js.map +1 -1
  78. package/dist/line-chart/horizontal-axis.d.ts +58 -64
  79. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  80. package/dist/line-chart/horizontal-axis.js +94 -123
  81. package/dist/line-chart/horizontal-axis.js.map +1 -1
  82. package/dist/line-chart/index.d.ts +3 -3
  83. package/dist/line-chart/index.d.ts.map +1 -1
  84. package/dist/line-chart/index.js +2 -2
  85. package/dist/line-chart/index.js.map +1 -1
  86. package/dist/line-chart/line-chart.d.ts +156 -303
  87. package/dist/line-chart/line-chart.d.ts.map +1 -1
  88. package/dist/line-chart/line-chart.js +410 -638
  89. package/dist/line-chart/line-chart.js.map +1 -1
  90. package/dist/line-chart/shared.d.ts +7 -7
  91. package/dist/line-chart/shared.d.ts.map +1 -1
  92. package/dist/line-chart/shared.js +23 -8
  93. package/dist/line-chart/shared.js.map +1 -1
  94. package/dist/line-chart/x-scale.d.ts +4 -11
  95. package/dist/line-chart/x-scale.d.ts.map +1 -1
  96. package/dist/line-chart/x-scale.js +16 -5
  97. package/dist/line-chart/x-scale.js.map +1 -1
  98. package/dist/nodes.d.ts +6 -9
  99. package/dist/nodes.d.ts.map +1 -1
  100. package/dist/nodes.js +7 -9
  101. package/dist/nodes.js.map +1 -1
  102. package/dist/pie-chart/index.d.ts +2 -2
  103. package/dist/pie-chart/index.d.ts.map +1 -1
  104. package/dist/pie-chart/index.js +1 -1
  105. package/dist/pie-chart/index.js.map +1 -1
  106. package/dist/pie-chart/pie-chart.d.ts +108 -126
  107. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  108. package/dist/pie-chart/pie-chart.js +202 -271
  109. package/dist/pie-chart/pie-chart.js.map +1 -1
  110. package/dist/pie-chart/pie-legend.d.ts +84 -31
  111. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  112. package/dist/pie-chart/pie-legend.js +93 -107
  113. package/dist/pie-chart/pie-legend.js.map +1 -1
  114. package/dist/pie-chart/pie-ring.d.ts +160 -122
  115. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  116. package/dist/pie-chart/pie-ring.js +333 -361
  117. package/dist/pie-chart/pie-ring.js.map +1 -1
  118. package/dist/pie-chart/shared.d.ts +12 -6
  119. package/dist/pie-chart/shared.d.ts.map +1 -1
  120. package/dist/pie-chart/shared.js +14 -8
  121. package/dist/pie-chart/shared.js.map +1 -1
  122. package/package.json +7 -4
  123. package/src/bar-chart/bar-chart.ts +424 -686
  124. package/src/bar-chart/category-axis.ts +96 -111
  125. package/src/bar-chart/chart-bars.ts +178 -201
  126. package/src/bar-chart/index.ts +2 -2
  127. package/src/bar-chart/shared.ts +7 -7
  128. package/src/engine.ts +10 -8
  129. package/src/kit/cartesian.ts +81 -0
  130. package/src/kit/chart-border.ts +23 -34
  131. package/src/kit/chart-canvas.ts +45 -80
  132. package/src/kit/chart-legend.ts +185 -206
  133. package/src/kit/entries.ts +20 -0
  134. package/src/kit/motion.ts +90 -0
  135. package/src/kit/paints.ts +15 -0
  136. package/src/kit/shared.ts +120 -13
  137. package/src/kit/vertical-axis.ts +111 -141
  138. package/src/line-chart/chart-line.ts +103 -155
  139. package/src/line-chart/chart-region.ts +36 -81
  140. package/src/line-chart/horizontal-axis.ts +102 -153
  141. package/src/line-chart/index.ts +3 -3
  142. package/src/line-chart/line-chart.ts +463 -759
  143. package/src/line-chart/shared.ts +22 -8
  144. package/src/line-chart/x-scale.ts +20 -5
  145. package/src/nodes.ts +8 -10
  146. package/src/pie-chart/index.ts +2 -2
  147. package/src/pie-chart/pie-chart.ts +225 -303
  148. package/src/pie-chart/pie-legend.ts +125 -115
  149. package/src/pie-chart/pie-ring.ts +388 -443
  150. package/src/pie-chart/shared.ts +14 -8
  151. package/dist/browser/chunks/chunk-ZMBOBHYL.js +0 -2
  152. package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +0 -7
@@ -1,40 +1,40 @@
1
1
  import {
2
- command,
3
- node,
4
- Node2D,
5
2
  Rect,
3
+ Types,
4
+ component2d,
6
5
  easeOut,
7
- property,
8
- type CommandArgs,
6
+ type ChildView,
9
7
  type DataRecord,
10
- type Command,
11
- type EasingFunction,
12
- type NodeConfig,
8
+ type EntryOptions,
9
+ type NodeOf,
10
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
11
+ type Node2D,
13
12
  type Node2DProps,
14
- } from "@motionscript/core"
13
+ type Size2D,
14
+ type Timeline,
15
+ } from "@motionscript/sdk"
16
+ import { formatNumber } from "@motionscript/sdk/component"
15
17
 
18
+ import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
16
19
  import { ChartBorder } from "../kit/chart-border"
17
20
  import { ChartCanvas } from "../kit/chart-canvas"
18
21
  import { ChartLegend } from "../kit/chart-legend"
22
+ import { asParts, fresh } from "../kit/entries"
23
+ import { beats, type Beat } from "../kit/motion"
19
24
  import { VerticalAxis } from "../kit/vertical-axis"
20
25
  import {
21
26
  clampUnit,
22
27
  formatMarker,
23
28
  lerpAxisDomain,
29
+ lerpAxisEntry,
30
+ markerStep,
24
31
  markerValues,
25
32
  num,
26
33
  resolveAxisDomain,
34
+ settleAxisDomain,
27
35
  type AxisDomainConfig,
28
36
  type Range,
29
37
  } from "../kit/shared"
30
- import { formatNumber } from "@motionscript/core/component"
31
- import {
32
- drive,
33
- hold,
34
- sequence as sequenceOf,
35
- together,
36
- type Seekable,
37
- } from "@motionscript/core/component"
38
38
  import { CategoryAxis } from "./category-axis"
39
39
  import { ChartBars, type BarGeometry } from "./chart-bars"
40
40
  import {
@@ -84,721 +84,459 @@ export interface BarChartProps extends Node2DProps {
84
84
  showValues?: boolean
85
85
  }
86
86
 
87
- /**
88
- * Layout constants for the composition, all in px — the line chart's, so the two
89
- * sit at the same size in the same box and a scene can swap one for the other
90
- * without re-tuning its layout.
91
- */
92
- /** Left gutter reserved for the value axis markers. */
93
- const Y_AXIS_GUTTER = 64
94
- /** Bottom gutter (within the chart area) reserved for the category captions. */
95
- const X_AXIS_GUTTER = 40
96
- /** Height reserved below the chart area for the legend row. */
97
- const LEGEND_GUTTER = 56
98
- /** Extra left/bottom room when an axis caption is shown. */
99
- const Y_LABEL_GUTTER = 34
100
- const X_LABEL_GUTTER = 30
101
- /** Headroom above the plot, so a column reaching the top marker isn't flush to it. */
102
- const TOP_HEADROOM = 24
103
- /** Right gutter so the last slot isn't flush with the box edge. */
104
- const RIGHT_GUTTER = 20
87
+ /** What a bar chart plots, as its entries hold it. */
88
+ export interface BarChartValues {
89
+ readonly data: DataRecord[]
90
+ readonly series: BarSeries[]
91
+ readonly stacked: boolean
92
+ readonly valueAxis: AxisDomainConfig
93
+ }
94
+
95
+ /** The value axis's domain with its range and ticks resolved. */
96
+ export type BarChartDomain = AxisDomainConfig & { readonly range: Range; readonly ticks: number[] }
97
+
98
+ /** The argument every per-series command takes: an index into `series`, offered by name. */
99
+ const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
105
100
 
106
101
  /** How far the legend starts below its slot when it enters, in px. */
107
102
  const LEGEND_SLIDE = 20
108
103
 
104
+ /** Fallback marker count when the value axis states no ticks. */
105
+ const DEFAULT_MARKERS = 5
106
+
109
107
  /**
110
- * How {@link BarChart.enter} divides its duration between its three beats: the
111
- * axes arriving, the columns rising, and the legend coming up. Shares of the
112
- * whole, so they must sum to 1 — the entrance takes exactly the time it was
113
- * given, for the reason spelt out on `LineChart.enter`.
114
- */
115
- /**
116
- * The signals the bar chart's own commands move, as props types.
117
- *
118
- * Written out rather than reusing `BarChartProps` because these are signals of
119
- * the chart's own — how far the columns have risen, and which category is lit —
120
- * rather than part of what an author states about it. Same reason the line chart
121
- * types its `RegionState` separately.
108
+ * How `enter` divides its duration between its three beats: the axes arriving,
109
+ * the columns rising, and the legend coming up. Shares of the whole, so they
110
+ * must sum to 1.
122
111
  */
123
- type CategorySpotlight = { spotlight: number | null; spotlightReveal: number }
124
- type BarGrowth = { growth: number }
125
-
126
112
  const AXES_SHARE = 0.35
127
113
  const BARS_SHARE = 0.5
128
114
  const LEGEND_SHARE = 0.15
129
115
 
130
- /**
131
- * A grouped or stacked bar chart, composed from independent parts rather than
132
- * one monolithic draw — the sibling of the {@link LineChart} family, built the
133
- * same way and out of several of the same pieces:
134
- *
135
- * - `ChartCanvas` — the plot panel with a horizontal grid, under everything.
136
- * - `VerticalAxis` — the numeric value markers, staggering in bottom-to-top.
137
- * - {@link CategoryAxis} — the row of names beneath the baseline, one per row of
138
- * the data, staggering in left-to-right.
139
- * - {@link ChartBars} (one per series) — that series' column in every category,
140
- * plus the figures printed on them.
141
- * - `ChartLegend` — a row of entries below the chart, one per series.
142
- *
143
- * The parts are laid out from this node's `layoutBounds` exactly as the line
144
- * chart's are: a left gutter for the value axis, a bottom gutter for the
145
- * captions, the plot filling what's left, and the legend row beneath the whole
146
- * thing. Every child's position and size is bound reactively, so the chart
147
- * re-flows if its box changes, and the bars read the same resolved scale the
148
- * axis does, so a column's end lands exactly on its marker.
149
- *
150
- * **Resting state is the finished chart** — the same bargain the line chart
151
- * makes: the studio paints a node's Initial state on a paused canvas, so a chart
152
- * that only appeared once a command had run would be edited blind. {@link enter}
153
- * arms every part back to its pre-entrance state before animating.
154
- *
155
- * **Zero is always on the scale** (see `barValueExtent`), and the columns
156
- * grow about it rather than about the plot floor, so a negative value draws
157
- * downward out of the same `growth` signal that draws a positive one up.
158
- *
159
- * Past the entrance, two things move the chart:
160
- * {@link showSeries}/{@link hideSeries}/{@link spotlightSeries} pick out a
161
- * *series* — and hiding one **reflows the group**, the remaining columns
162
- * widening into the space as it goes, rather than leaving a hole where it was —
163
- * while {@link spotlightCategory}/{@link clearCategory} pick out a *column of
164
- * the table*, dimming every other category and its caption with it.
165
- */
166
- @node({
167
- key: "barChart",
168
- parentKey: "node",
169
- forkable: true,
170
- layout: {
171
- children: "freeform",
172
- defaultWidthMode: "fixed",
173
- defaultHeightMode: "fixed",
174
- acceptsChildren: true,
175
- },
176
- seed: {
177
- width: 760,
178
- height: 460,
179
- },
180
- })
181
- export class BarChart extends Node2D<BarChartProps> {
182
- @property({ default: () => [] as DataRecord[] }) declare data: DataRecord[]
183
- @property({ default: "" }) declare categoryField: string
184
- @property({ default: () => [] as BarSeries[] }) declare series: BarSeries[]
185
- @property({ default: false }) declare stacked: boolean
186
- /**
187
- * The value axis's domain (label/range/ticks), tweenable and mappable exactly
188
- * as the line chart's axes are: the mapper merges a partial input onto the
189
- * previous resolved domain and the tween interpolates numerically, so
190
- * `chart.to({ data: { valueAxis: { range: [0, 10] } }, duration: 1 })` smoothly
191
- * rescales every column. The axis *label* text is captured once at construction.
192
- */
193
- @property({
194
- default: {},
195
- mapper: resolveAxisDomain,
196
- tween: lerpAxisDomain,
197
- })
198
- declare valueAxis: AxisDomainConfig
199
- @property({
200
- default: {},
201
- mapper: resolveAxisDomain,
202
- tween: lerpAxisDomain,
203
- })
204
- declare categoryAxis: AxisDomainConfig
205
- @property({
206
- default: DEFAULT_BAR_CHART_THEME,
207
- mapper: resolveBarChartTheme,
208
- tween: lerpBarChartTheme,
209
- })
210
- declare theme: BarChartTheme
211
-
212
- /**
213
- * Global rise of every column in `[0, 1]`, measured about the baseline: 0
214
- * collapses them all onto it, 1 draws them at full length. Each
215
- * {@link ChartBars}' own `growth` is bound to this, so tweening this one
216
- * signal raises the whole chart in proportion.
217
- */
218
- @property({ default: 1 }) declare growth: number
219
-
220
- /**
221
- * How much of the slot each series currently claims, one entry per series.
222
- *
223
- * A *weight* rather than a boolean, because that is what makes hiding a series
224
- * an animation instead of a jump: the grouped columns divide the slot in
225
- * proportion to these, so tweening one to 0 has the remaining columns widen
226
- * into the space as it shrinks away, and a stacked segment's contribution to
227
- * the stack fades out with it. Seeded from each series' `enabled` field.
228
- */
229
- @property({ default: () => [] as number[] }) declare weights: number[]
230
-
231
- /**
232
- * The category currently spotlit, or `null` for none. Held as a plain reactive
233
- * value rather than a tweened one: the *choice* snaps, and the animation is
234
- * {@link spotlightReveal} dimming everything else.
235
- */
236
- @property({ default: null }) declare spotlight: number | null
237
-
238
- /** How far the category spotlight is drawn in, `[0, 1]`. */
239
- @property({ default: 0 }) declare spotlightReveal: number
240
-
241
- // ---- Part handles (built in the constructor) --------------------------
242
- private vAxis: VerticalAxis
243
- private cAxis: CategoryAxis
244
- private legend: ChartLegend | null = null
245
- private barSets: ChartBars[] = []
246
-
247
- /**
248
- * `this.theme` read back through its *resolved* shape — the same
249
- * loose-declared/resolved-stored idiom as a shape's `fill`/`stroke`, and as
250
- * the line chart's own theme.
251
- */
252
- private get resolvedTheme(): BarChartConfig {
253
- return this.theme as unknown as BarChartConfig
254
- }
255
-
256
- // ---- Derived scale ----------------------------------------------------
257
-
258
- /** Fallback marker count when the value axis states no ticks. */
259
- private static readonly DEFAULT_MARKERS = 5
260
-
261
- private get valueScale(): Range {
262
- const divisions =
263
- (this.valueAxis.ticks?.length ?? BarChart.DEFAULT_MARKERS) - 1
264
- return resolveBarScale(
265
- this.data,
266
- this.series,
267
- this.stacked,
268
- this.valueAxis.range,
269
- divisions
270
- )
271
- }
272
-
273
- /**
274
- * A value written down as this chart's value axis says — the axis ticks and
275
- * the figures printed on the columns both read through here, so the two
276
- * cannot disagree about what a number looks like.
277
- */
278
- private valueLabel(value: number): string {
279
- const pattern = this.valueAxis.format
280
- return pattern === undefined
281
- ? formatMarker(value)
282
- : formatNumber(value, pattern)
283
- }
284
-
285
- /** The value axis ticks: the explicit ones, else five across the scale. */
286
- private get resolvedMarkers(): number[] {
287
- return (
288
- this.valueAxis.ticks ??
289
- markerValues(this.valueScale, BarChart.DEFAULT_MARKERS - 1)
290
- )
291
- }
292
-
293
- // ---- Plot geometry, all derived from layoutBounds -----------------------
294
-
295
- private get hasValueLabel(): boolean {
296
- return this.valueAxis.label !== ""
297
- }
298
- private get hasCategoryLabel(): boolean {
299
- return this.categoryAxis.label !== ""
300
- }
301
-
302
- private get leftInset(): number {
303
- return Y_AXIS_GUTTER + (this.hasValueLabel ? Y_LABEL_GUTTER : 0)
304
- }
305
- private get bottomInset(): number {
306
- return X_AXIS_GUTTER + (this.hasCategoryLabel ? X_LABEL_GUTTER : 0)
307
- }
308
-
309
- /** The plot rectangle in node space: `{ left, right, top, bottom }` (y-up). */
310
- private plotBox(): {
311
- left: number
312
- right: number
313
- top: number
314
- bottom: number
315
- } {
316
- const W = this.layoutBounds.width
317
- const H = this.layoutBounds.height
318
- const areaTop = H / 2
319
- const areaBottom = -H / 2 + (this.legend ? LEGEND_GUTTER : 0)
320
- const left = -W / 2 + this.leftInset
321
- const right = W / 2 - RIGHT_GUTTER
322
- const top = areaTop - TOP_HEADROOM
323
- const bottom = areaBottom + this.bottomInset
324
- return { left, right, top, bottom }
325
- }
326
-
327
- /** Width and height of the plot, in px. */
328
- private plotSize(): { w: number; h: number } {
329
- const { left, right, top, bottom } = this.plotBox()
330
- return { w: right - left, h: top - bottom }
331
- }
116
+ /** What the geometry reads: what the chart plots, its theme and the signals its commands move. */
117
+ interface BarState extends BarChartValues {
118
+ readonly theme: BarChartConfig
119
+ readonly weights: number[]
120
+ readonly spotlight: number | null
121
+ readonly spotlightReveal: number
122
+ }
332
123
 
333
- /** Map a value onto the plot's **centred** pixel space (origin at its centre, y-up). */
334
- private valueToY(value: number): number {
335
- const [min, max] = this.valueScale
336
- const { h } = this.plotSize()
337
- if (max <= min) return -h / 2
338
- return ((value - min) / (max - min) - 0.5) * h
339
- }
124
+ /** The value axis with its range and ticks resolved: the explicit ones, else five across the scale. */
125
+ function drawnValue(s: BarChartValues, domain: AxisDomainConfig): AxisDomainConfig {
126
+ const divisions = (domain.ticks?.length ?? DEFAULT_MARKERS) - 1
127
+ const range = resolveBarScale(s.data, s.series, s.stacked, domain.range, divisions)
128
+ return { ...domain, range, ticks: domain.ticks ?? markerValues(range, DEFAULT_MARKERS - 1) }
129
+ }
340
130
 
341
- /**
342
- * Pixel y of the line every column grows from.
343
- *
344
- * Zero, clamped onto the scale — so an author who *did* crop the axis to
345
- * `[40, 100]` gets columns rising from the plot floor rather than from a
346
- * baseline off the bottom of the panel.
347
- */
348
- private get baselinePx(): number {
349
- const [min, max] = this.valueScale
350
- return this.valueToY(Math.min(Math.max(0, min), max))
351
- }
131
+ /** The last domain worked out from each data array, and what else it was read from. */
132
+ const domainsDrawn = new WeakMap<DataRecord[], Omit<BarChartValues, "data"> & { readonly domain: BarChartDomain }>()
133
+
134
+ /** The value axis as a bar chart draws it now, an auto range or tick row read back as itself. */
135
+ export function barChartDomain(s: BarChartValues): BarChartDomain {
136
+ const { data, series, stacked, valueAxis } = s
137
+ const kept = domainsDrawn.get(data)
138
+ if (kept && kept.series === series && kept.stacked === stacked && kept.valueAxis === valueAxis) return kept.domain
139
+ const domain = settleAxisDomain(valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
140
+ domainsDrawn.set(data, { series, stacked, valueAxis, domain })
141
+ return domain
142
+ }
352
143
 
353
- // ---- Slot geometry ----------------------------------------------------
144
+ /**
145
+ * A value written down as this chart's value axis says: the ticks and the
146
+ * figures printed on the columns both read through here, so the two cannot
147
+ * disagree about what a number looks like.
148
+ */
149
+ function valueLabel(s: BarChartValues, domain: BarChartDomain, value: number): string {
150
+ const pattern = s.valueAxis.format
151
+ return pattern === undefined ? formatMarker(value, markerStep(domain.ticks)) : formatNumber(value, pattern)
152
+ }
354
153
 
355
- /** Width of one category's slot across the plot. */
356
- private get slotWidth(): number {
357
- const n = this.data.length
358
- return n > 0 ? this.plotSize().w / n : 0
359
- }
154
+ /** A value on the plot's **centred** pixel space (origin at its centre, y-up). */
155
+ function valueToY(domain: BarChartDomain, plot: Size2D, value: number): number {
156
+ const [min, max] = domain.range
157
+ if (max <= min) return -plot.height / 2
158
+ return ((value - min) / (max - min) - 0.5) * plot.height
159
+ }
360
160
 
361
- /** Centre x of category `i`'s slot, in the plot's centred space. */
362
- private slotCenter(index: number): number {
363
- return -this.plotSize().w / 2 + this.slotWidth * (index + 0.5)
364
- }
161
+ /**
162
+ * Pixel y of the line every column grows from: zero clamped onto the scale, so
163
+ * a cropped axis grows its columns from the plot floor.
164
+ */
165
+ function baselinePx(domain: BarChartDomain, plot: Size2D): number {
166
+ const [min, max] = domain.range
167
+ return valueToY(domain, plot, Math.min(Math.max(0, min), max))
168
+ }
365
169
 
366
- /** The part of a slot the columns fill — the rest is the channel between categories. */
367
- private get slotInner(): number {
368
- return this.slotWidth * clampUnit(this.resolvedTheme.bar.slotFill)
369
- }
170
+ /** Series `i`'s live share of the slot; 0 once it has been hidden. */
171
+ function weightAt(s: BarState, index: number): number {
172
+ return Math.max(0, s.weights[index] ?? 1)
173
+ }
370
174
 
371
- /** Series `i`'s live share of the slot; 0 once it has been hidden. */
372
- private weightAt(index: number): number {
373
- return Math.max(0, this.weights[index] ?? 1)
374
- }
175
+ /** `row` with each series' value scaled by its live weight: what the stacked spans are measured from. */
176
+ function weightedRow(s: BarState, row: DataRecord): DataRecord {
177
+ const scaled: DataRecord = { ...row }
178
+ s.series.forEach((series, i) => {
179
+ const value = row[series.field]
180
+ if (typeof value === "number") scaled[series.field] = value * weightAt(s, i)
181
+ })
182
+ return scaled
183
+ }
375
184
 
376
- /** Total live share across the series — the slot's inner width divides by this. */
377
- private get totalWeight(): number {
378
- let sum = 0
379
- for (let i = 0; i < this.series.length; i++) sum += this.weightAt(i)
380
- return sum > 0 ? sum : 1
381
- }
185
+ /**
186
+ * Series `index`'s columns, mapped into the plot's centred pixel space. A
187
+ * grouped series divides the slot with its siblings by their live weights, a
188
+ * stacked one takes the whole slot and runs from the running total below it,
189
+ * so hiding a series reflows rather than punching a hole.
190
+ */
191
+ function seriesBars(s: BarState, index: number, plot: Size2D, captioned: boolean): BarGeometry[] {
192
+ const series = s.series[index]
193
+ if (!series) return []
194
+ const domain = barChartDomain(s)
195
+ const n = s.data.length
196
+ const slotWidth = n > 0 ? plot.width / n : 0
197
+ const slotCenter = (i: number) => -plot.width / 2 + slotWidth * (i + 0.5)
198
+ const inner = slotWidth * clampUnit(s.theme.bar.slotFill)
199
+ const gap = s.theme.bar.groupGap
200
+ let total = 0
201
+ for (let i = 0; i < s.series.length; i++) total += weightAt(s, i)
202
+ const unit = inner / (total > 0 ? total : 1)
203
+ const weight = weightAt(s, index)
204
+ let before = 0
205
+ for (let i = 0; i < index; i++) before += weightAt(s, i)
206
+
207
+ return s.data.map((row, rowIndex) => {
208
+ // The stack is built from the *live* contributions, so a series fading out
209
+ // lowers the segments above it as it goes.
210
+ const spans = barSpans(s.stacked ? weightedRow(s, row) : row, s.series, s.stacked)
211
+ const span = spans[index] ?? { from: 0, to: 0, value: 0 }
212
+ const cap = s.stacked ? stackCapIndex(row, s.series) : index
213
+ const slotLeft = slotCenter(rowIndex) - inner / 2
214
+ const x = s.stacked ? slotCenter(rowIndex) : slotLeft + unit * before + (unit * weight) / 2
215
+ const width = s.stacked ? inner : Math.max(0, unit * weight - (s.series.length > 1 ? gap : 0))
216
+ // The figure printed is the row's own value, not the weighted one.
217
+ const value = num(row, series.field)
218
+ return {
219
+ x,
220
+ width,
221
+ from: valueToY(domain, plot, span.from),
222
+ to: valueToY(domain, plot, span.to),
223
+ round: cap === index,
224
+ value,
225
+ ...(captioned && { caption: valueLabel(s, domain, value) }),
226
+ }
227
+ })
228
+ }
382
229
 
383
- /** Live share of every series *before* `index`, for the grouped offset. */
384
- private weightBefore(index: number): number {
385
- let sum = 0
386
- for (let i = 0; i < index; i++) sum += this.weightAt(i)
387
- return sum
388
- }
230
+ /**
231
+ * How far each category is dimmed by the spotlight, one entry per row: 1 while
232
+ * nothing is spotlit, falling to the theme's `dimOpacity` for every category but
233
+ * the chosen one as the reveal climbs.
234
+ */
235
+ function categoryDims(s: BarState): number[] {
236
+ const focus = clampUnit(s.spotlightReveal)
237
+ const dim = s.theme.spotlight.dimOpacity
238
+ const chosen = s.spotlight
239
+ return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - dim) * focus))
240
+ }
389
241
 
390
- /**
391
- * Series `index`'s columns, mapped into the plot's centred pixel space.
392
- *
393
- * Both modes come out of one walk over the rows: a grouped series divides the
394
- * slot with its siblings and every column runs from zero, a stacked one takes
395
- * the whole slot and each column runs from the running total below it. The
396
- * live {@link weights} scale the grouped widths and the stacked contributions
397
- * alike, which is what lets `hideSeries` reflow rather than punch a hole.
398
- */
399
- private seriesBars(index: number): BarGeometry[] {
400
- const series = this.series[index]
401
- if (!series) return []
402
- const stacked = this.stacked
403
- const inner = this.slotInner
404
- const gap = this.resolvedTheme.bar.groupGap
405
- const unit = inner / this.totalWeight
406
- const weight = this.weightAt(index)
407
-
408
- return this.data.map((row, rowIndex) => {
409
- // The stack is built from the *live* contributions, so a series fading out
410
- // lowers the segments above it as it goes instead of dropping them by a
411
- // step when it finally switches off. A grouped row needs no such scaling —
412
- // there its weight moves the column's width, not its length.
413
- const spans = barSpans(
414
- stacked ? this.weightedRow(row) : row,
415
- this.series,
416
- stacked
417
- )
418
- const span = spans[index] ?? { from: 0, to: 0, value: 0 }
419
- const cap = stacked ? stackCapIndex(row, this.series) : index
420
-
421
- const slotLeft = this.slotCenter(rowIndex) - inner / 2
422
- const x = stacked
423
- ? this.slotCenter(rowIndex)
424
- : slotLeft + unit * this.weightBefore(index) + (unit * weight) / 2
425
- const width = stacked
426
- ? inner
427
- : Math.max(0, unit * weight - (this.series.length > 1 ? gap : 0))
428
-
429
- return {
430
- x,
431
- width,
432
- from: this.valueToY(span.from),
433
- to: this.valueToY(span.to),
434
- round: cap === index,
435
- // The figure printed is the row's own value, not the weighted one — a
436
- // series on its way out should not be seen counting down.
437
- value: num(row, series.field),
438
- }
439
- })
440
- }
242
+ type AnyTimeline = Timeline<any, any>
441
243
 
442
- /**
443
- * `row` with each series' value scaled by that series' live {@link weights}
444
- * entry — what the stacked spans are measured from.
445
- */
446
- private weightedRow(row: DataRecord): DataRecord {
447
- const scaled: DataRecord = { ...row }
448
- this.series.forEach((s, i) => {
449
- const value = row[s.field]
450
- if (typeof value === "number") scaled[s.field] = value * this.weightAt(i)
451
- })
452
- return scaled
244
+ /** The parts a bar chart's commands drive, found through its children. */
245
+ function partsOf(children: readonly ChildView[]) {
246
+ const frame = cartesianViews(children)
247
+ if (!frame) return null
248
+ const cAxis = frame.below.find(CategoryAxis.is)
249
+ if (!VerticalAxis.is(frame.axis) || !cAxis) return null
250
+ return {
251
+ vAxis: frame.axis,
252
+ cAxis,
253
+ bars: frame.plot.filter(ChartBars.is),
254
+ legend: frame.below.find(ChartLegend.is) ?? null,
453
255
  }
256
+ }
454
257
 
455
- /**
456
- * How far each category is dimmed by the spotlight, one entry per row —
457
- * 1 while nothing is spotlit, falling to the theme's `dimOpacity` for every
458
- * category but the chosen one as the reveal climbs.
459
- */
460
- private categoryDims(): number[] {
461
- const focus = clampUnit(this.spotlightReveal)
462
- const dim = this.resolvedTheme.spotlight.dimOpacity
463
- const chosen = this.spotlight
464
- return this.data.map((_, i) =>
465
- focus <= 0 || chosen === i ? 1 : 1 - (1 - dim) * focus
466
- )
467
- }
258
+ /**
259
+ * Move series `index`'s slot share to `target` with its legend entry, the group
260
+ * reflowing. Its columns are drawn for every frame the move covers, and switch
261
+ * off only once a hide has landed, so arriving at a time backwards shows what
262
+ * arriving forwards does.
263
+ */
264
+ function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: boolean[] }, children: readonly ChildView[], index: number, target: number) {
265
+ const parts = partsOf(children)
266
+ if (!parts || !parts.bars[index]) return timeline.wait(timeline.duration)
267
+ // Read through `weightAt`, so a chart whose stored weights are short still
268
+ // tweens from the 1 it is drawing at.
269
+ const from = state.series.map((_, i) => weightAt(state, i))
270
+ const start = from[index] ?? 1
271
+ const showing = state.series.map((s, i) => state.showing[i] ?? (s.enabled ?? true))
272
+ return timeline.together([
273
+ timeline.animate((t) => {
274
+ const weights = [...from]
275
+ weights[index] = start + (target - start) * timeline.ease(t)
276
+ const shown = [...showing]
277
+ shown[index] = target > 0 || t < 1
278
+ return { weights, showing: shown }
279
+ }, { eased: false }),
280
+ ...(parts.legend ? [parts.legend.run(target > 0 ? "showSeries" : "hideSeries", { index })] : []),
281
+ ])
282
+ }
468
283
 
469
- // ---- Build ------------------------------------------------------------
470
-
471
- constructor(props?: NodeConfig<BarChart, BarChartProps>) {
472
- super(props)
473
-
474
- // The whole chart is composed from plain props/`data` — no context needed,
475
- // so the parts are built here (super() applied the props above). Everything
476
- // a *leaf* reads live in `renderSelf` — each series' geometry, paint and
477
- // dim, the panel's fill, the border's stroke — is passed as a closure rather
478
- // than a snapshot, so `chart.to({ theme })` and
479
- // `chart.to({ valueAxis: { range } })` genuinely animate the drawn chart.
480
- const enabled = this.series.map((s) => s.enabled ?? true)
481
- this.weights = enabled.map((on) => (on ? 1 : 0))
482
- const showLegend = props?.showLegend !== false
483
- const showValues = props?.showValues === true
484
-
485
- this.vAxis = new VerticalAxis({
486
- range: () => this.valueScale,
487
- markers: () => this.resolvedMarkers,
488
- axis: () => this.resolvedTheme.yAxis,
489
- label: this.valueAxis.label ?? "",
284
+ /**
285
+ * A grouped or stacked bar chart, the sibling of the line chart and built the
286
+ * same way, out of several of the same pieces:
287
+ *
288
+ * - `ChartCanvas` — the plot panel with a horizontal grid, under everything.
289
+ * - `VerticalAxis` — the numeric value markers, staggering in bottom-to-top.
290
+ * - {@link CategoryAxis} — the row of names beneath the baseline, one per row.
291
+ * - {@link ChartBars} (one per series) — that series' column in every category,
292
+ * plus the figures printed on them.
293
+ * - `ChartLegend` — a row of entries below the chart, one per series.
294
+ *
295
+ * Every part reads the chart's entries through bindings, and the columns map
296
+ * across the plot panel's own laid-out box, so a column's end lands exactly on
297
+ * its marker. **Zero is always on the scale**, and the columns grow about it, so
298
+ * a negative value draws downward out of the same `growth` signal.
299
+ *
300
+ * **Resting state is the finished chart**; `enter` arms every part on its first
301
+ * frame. `showSeries`/`hideSeries`/`spotlightSeries` pick out a *series* (hiding
302
+ * one reflows the group), `spotlightCategory`/`clearCategory` a *column of the
303
+ * table*. A `to` of the value axis's `range` or `ticks` tweens from the domain
304
+ * as drawn, so an auto axis rescales the columns continuously.
305
+ */
306
+ export const BarChart = component2d({
307
+ key: "barChart",
308
+ state: {
309
+ data: fresh<DataRecord[]>(() => []),
310
+ categoryField: "",
311
+ series: fresh<BarSeries[]>(() => []),
312
+ stacked: false,
313
+ valueAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
314
+ categoryAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisDomain }),
315
+ theme: Types.custom<BarChartTheme, BarChartConfig>(DEFAULT_BAR_CHART_THEME, { resolve: resolveBarChartTheme, lerp: lerpBarChartTheme }),
316
+ /**
317
+ * Global rise of every column in `[0, 1]`, measured about the baseline: 0
318
+ * collapses them all onto it, 1 draws them at full length.
319
+ */
320
+ growth: 1,
321
+ /**
322
+ * How much of the slot each series claims, one entry per series: a weight
323
+ * rather than a switch, so hiding one is the others widening into the space.
324
+ * Seeded from each series' `enabled`.
325
+ */
326
+ weights: fresh<number[]>(() => []),
327
+ /** The category spotlit, or `null` for none. The choice snaps; `spotlightReveal` is the animation. */
328
+ spotlight: Types.custom<number | null>(null),
329
+ /** How far the category spotlight is drawn in, `[0, 1]`. */
330
+ spotlightReveal: 0,
331
+ /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
332
+ showLegend: Types.boolean(true, { animatable: false }),
333
+ /** Read as the chart is built: whether each column prints the figure it plots. */
334
+ showValues: Types.boolean(false, { animatable: false }),
335
+ /** Whether each series' columns are drawn: on for every frame a show or hide covers, off once a hide lands. */
336
+ showing: Types.custom<boolean[]>([], { private: true }),
337
+ },
338
+ defaults: {
339
+ // Its parts fill it; a node made of them would hug them.
340
+ width: "fill",
341
+ height: "fill",
342
+ weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
343
+ showing: (state) => state.series.map((s) => s.enabled ?? true),
344
+ },
345
+ allowedChildren: [],
346
+ children: (state) => {
347
+ const theme = () => state.theme
348
+ const domain = () => barChartDomain(state)
349
+ let plot: Node2D | undefined
350
+ // Before the panel is built or laid out it has no size, and the columns map onto nothing.
351
+ const plotSize = () => laidOutSize(plot)
352
+ const captioned = state.showValues === true
353
+
354
+ const vAxis = new VerticalAxis({
355
+ range: () => domain().range,
356
+ markers: () => domain().ticks,
357
+ captions: () => {
358
+ const d = domain()
359
+ return d.ticks.map((value) => valueLabel(state, d, value))
360
+ },
361
+ axis: () => theme().yAxis,
362
+ label: state.valueAxis.label ?? "",
490
363
  width: "hug",
491
364
  height: "fill",
492
365
  })
493
- this.vAxis.labelFor = (value) => this.valueLabel(value)
494
366
 
495
- this.cAxis = new CategoryAxis({
496
- categories: barCategories(this.data, this.categoryField),
497
- axis: () => this.resolvedTheme.xAxis,
498
- label: this.categoryAxis.label ?? "",
499
- dims: () => this.categoryDims(),
367
+ const cAxis = new CategoryAxis({
368
+ categories: barCategories(state.data, state.categoryField),
369
+ axis: () => theme().xAxis,
370
+ label: state.categoryAxis.label ?? "",
371
+ dims: () => categoryDims(state),
500
372
  width: "fill",
501
373
  height: "hug",
502
- })
374
+ }) as unknown as Node2D
503
375
 
504
- this.barSets = this.series.map(
376
+ const barSets = state.series.map(
505
377
  (s, i) =>
506
378
  new ChartBars({
507
- bars: () => this.seriesBars(i),
508
- baseline: () => this.baselinePx,
509
- fill: () => resolveBarFill(this.resolvedTheme.seriesFills, s, i),
510
- barStroke: () => this.resolvedTheme.bar.stroke,
511
- barShadow: () => this.resolvedTheme.bar.shadow,
512
- cornerRadius: () => this.resolvedTheme.bar.cornerRadius,
513
- growth: () => this.growth,
514
- dims: () => this.categoryDims(),
515
- showValues,
516
- valueStyle: this.resolvedTheme.valueStyle,
517
- enabled: enabled[i],
518
- })
379
+ bars: () => seriesBars(state, i, plotSize(), captioned),
380
+ baseline: () => baselinePx(domain(), plotSize()),
381
+ fill: () => resolveBarFill(theme().seriesFills, s, i),
382
+ barStroke: () => theme().bar.stroke,
383
+ barShadow: () => theme().bar.shadow,
384
+ cornerRadius: () => theme().bar.cornerRadius,
385
+ growth: () => state.growth,
386
+ dims: () => categoryDims(state),
387
+ showValues: captioned,
388
+ valueStyle: state.theme.valueStyle,
389
+ enabled: () => state.showing[i] ?? (s.enabled ?? true),
390
+ }) as unknown as Node2D
519
391
  )
520
- // The figures on the columns are the same numbers the value axis ticks, so
521
- // they read through the same format.
522
- for (const set of this.barSets) set.labelFor = (value) => this.valueLabel(value)
523
-
524
- if (showLegend) {
525
- this.legend = new ChartLegend({
526
- series: this.series,
527
- swatches: () =>
528
- this.series.map((s, i) =>
529
- resolveBarFill(this.resolvedTheme.seriesFills, s, i)
530
- ),
531
- legend: () => this.resolvedTheme.legend,
532
- })
392
+
393
+ const below: Node2D[] = [cAxis]
394
+ if (state.showLegend !== false) {
395
+ below.push(
396
+ new ChartLegend({
397
+ series: state.series,
398
+ swatches: () => state.series.map((s, i) => resolveBarFill(theme().seriesFills, s, i)),
399
+ legend: () => theme().legend,
400
+ // The row's spacing is the theme's as the chart was built, like the
401
+ // entries' type and chips: a theme tween re-paints the swatches only.
402
+ gap: state.theme.legend.itemGap,
403
+ padding: state.theme.legend.padding,
404
+ }) as unknown as Node2D
405
+ )
533
406
  }
534
407
 
535
- const plot = new Rect({
408
+ plot = new Rect({
536
409
  clip: true,
537
410
  children: [
538
411
  new ChartCanvas({
539
- fill: () => this.resolvedTheme.plotArea.fill,
540
- shadow: () => this.resolvedTheme.plotArea.shadow,
541
- xGridStroke: () => this.resolvedTheme.xAxis.lineStyle,
542
- yGridStroke: () => this.resolvedTheme.yAxis.lineStyle,
543
- // No vertical rules: the horizontal axis is categorical, so there is
544
- // no numeric position along it for a grid line to mark. The channel
545
- // between slots does that job, and better.
412
+ fill: () => theme().plotArea.fill,
413
+ shadow: () => theme().plotArea.shadow,
414
+ xGridStroke: () => theme().xAxis.lineStyle,
415
+ yGridStroke: () => theme().yAxis.lineStyle,
416
+ // No vertical rules: the horizontal axis is categorical, so there is no
417
+ // numeric position along it for a grid line to mark.
546
418
  xMarkers: [],
547
- yMarkers: () => this.resolvedMarkers,
419
+ yMarkers: () => domain().ticks,
548
420
  xExtent: () => [0, 1] as Range,
549
- yExtent: () => this.valueScale,
550
- }),
551
- ...this.barSets,
552
- new ChartBorder({
553
- stroke: () => this.resolvedTheme.plotArea.stroke,
421
+ yExtent: () => domain().range,
554
422
  }),
423
+ ...barSets,
424
+ new ChartBorder({ stroke: () => theme().plotArea.stroke }),
555
425
  ],
556
- })
426
+ }) as unknown as Node2D
557
427
 
558
- // Invisible twin of the value axis, reserving the same left gutter under the
559
- // plot so the category row starts at the plot's left edge rather than the
560
- // chart's — and so a caption lands under the columns it names.
561
- const gutterSpacer = new VerticalAxis({
428
+ const gutter = new VerticalAxis({
562
429
  opacity: 0,
563
- range: () => this.valueScale,
564
- markers: () => this.resolvedMarkers,
565
- axis: () => this.resolvedTheme.yAxis,
566
- label: this.valueAxis.label ?? "",
430
+ range: () => domain().range,
431
+ markers: () => domain().ticks,
432
+ axis: () => theme().yAxis,
433
+ label: state.valueAxis.label ?? "",
567
434
  width: "hug",
568
435
  height: 0,
569
- })
436
+ }) as unknown as Node2D
570
437
 
571
- const below: Node2D[] = [this.cAxis]
572
- if (this.legend) below.push(this.legend)
573
-
574
- this.add(
575
- new Rect({ flow: "vertical",
576
- width: "fill",
577
- height: "fill",
578
- children: [
579
- new Rect({ flow: "horizontal",
580
- height: "fill",
581
- width: "fill",
582
- children: [this.vAxis, plot],
583
- }),
584
- new Rect({ flow: "horizontal",
585
- width: "fill",
586
- children: [
587
- gutterSpacer,
588
- new Rect({ flow: "vertical", width: "fill", gap: 20, children: below }),
589
- ],
590
- }),
591
- ],
592
- })
593
- )
594
- }
595
-
596
- // ---- Orchestrated entrance --------------------------------------------
597
-
598
- /**
599
- * Prime every part at its pre-entrance state — axis markers and captions
600
- * shifted and transparent, columns collapsed onto the baseline, legend dropped
601
- * and transparent.
602
- *
603
- * Called by {@link enter} before its first frame so nothing flashes ahead of
604
- * its beat. Idempotent, and public so a scene can hide a chart ahead of time
605
- * without starting the entrance.
606
- */
607
- arm(): void {
608
- this.vAxis.arm()
609
- this.cAxis.arm()
610
- this.growth = 0
611
- this.legend?.set({ opacity: 0, y: -LEGEND_SLIDE })
612
- }
613
-
614
- /**
615
- * Animate the whole chart in: the two axes stagger their markers and captions
616
- * in, the columns rise from the baseline, and the legend fades up last.
617
- *
618
- * `duration` is the **whole** entrance. The phases are shares of it, not
619
- * multiples — a command that declares 1.4 seconds has to take 1.4 seconds,
620
- * because that is the slot the timeline lays out for it, the length its
621
- * parallel siblings are timed against, and its contribution to the scene's own
622
- * duration.
623
- */
624
- @command()
625
- enter(args: CommandArgs<Record<string, never>> & { duration: number }): Seekable {
626
- const { duration } = args
627
- const easing = args.easing ?? easeOut()
628
- this.arm()
629
-
630
- return sequenceOf(
631
- together(
632
- this.vAxis.enter({ data: { slide: 24 }, duration: duration * AXES_SHARE, easing }),
633
- this.cAxis.enter({ data: { slide: 24 }, duration: duration * AXES_SHARE, easing })
634
- ),
635
- this.riseBars(duration * BARS_SHARE, easing),
636
- ...(this.legend
637
- ? [this.legend.to({ data: { opacity: 1, y: 0 }, duration: duration * LEGEND_SHARE, easing })]
638
- : [])
639
- )
640
- }
641
-
642
- /** Tween {@link growth} `0 → 1`, raising every column about the baseline. */
643
- private riseBars(
644
- duration: number,
645
- easing: EasingFunction
646
- ): Command<BarGrowth> {
647
- // No priming to 0 first: the value at `t` is a function of `t`, so a repeat
648
- // call re-raises by definition rather than by having reset something.
649
- return this.command<BarGrowth>((t) => ({ growth: t }), duration, easing)
650
- }
651
-
652
- /**
653
- * Show series `index`: bring its columns back, the group reflowing to make
654
- * room as they arrive, in lockstep with its legend entry growing in.
655
- */
656
- @command({ args: [{ key: "seriesIndex", kind: "series", default: 0, min: 0, step: 1 }] })
657
- showSeries(args: CommandArgs<{ seriesIndex: number }>): Seekable {
658
- const { seriesIndex = 0 } = args.data ?? {}
659
- return this.toggleSeries(seriesIndex, 1, args.duration ?? 0.35, args.easing ?? easeOut())
660
- }
661
-
662
- /**
663
- * Hide series `index`: its columns narrow away (or, stacked, sink out of the
664
- * stack) while the rest of the group widens into the space, its legend entry
665
- * shrinking beside them — the mirror of {@link showSeries}.
666
- */
667
- @command({ args: [{ key: "seriesIndex", kind: "series", default: 0, min: 0, step: 1 }] })
668
- hideSeries(args: CommandArgs<{ seriesIndex: number }>): Seekable {
669
- const { seriesIndex = 0 } = args.data ?? {}
670
- return this.toggleSeries(seriesIndex, 0, args.duration ?? 0.35, args.easing ?? easeOut())
671
- }
672
-
673
- /**
674
- * Move series `index`'s slot share to `target`, with its legend entry.
675
- *
676
- * One method for both directions, so the pair cannot drift apart. `enabled`
677
- * rides in the same value as the weight rather than being set before or after,
678
- * which is what makes arriving at a time backwards show what arriving forwards
679
- * does.
680
- */
681
- private toggleSeries(
682
- index: number,
683
- target: number,
684
- duration: number,
685
- easing: EasingFunction
686
- ): Seekable {
687
- const bars = this.barSets[index]
688
- if (!bars) return hold(duration)
689
-
690
- return together(
691
- this.tweenWeight(index, target, duration, easing),
692
- drive(duration, (t) => {
693
- const share = t >= 1 ? target : Math.max(target, 1 - t)
694
- bars.set({ enabled: target > 0 || share > 0 })
695
- }),
696
- ...(this.legend
697
- ? [
698
- target > 0
699
- ? this.legend.showSeries({ data: { index }, duration, easing })
700
- : this.legend.hideSeries({ data: { index }, duration, easing }),
701
- ]
702
- : [])
703
- )
704
- }
705
-
706
- /**
707
- * Fade out every other series — its columns **and its legend entry** — to
708
- * focus attention on `index`, which comes back to full. Nothing moves, so the
709
- * group doesn't reflow.
710
- */
711
- @command({ args: [{ key: "seriesIndex", kind: "series", default: 0, min: 0, step: 1 }] })
712
- spotlightSeries(args: CommandArgs<{ seriesIndex: number }>): Seekable {
713
- const { seriesIndex = 0 } = args.data ?? {}
714
- const duration = args.duration ?? 0.35
715
- const easing = args.easing ?? easeOut()
716
- const dim = this.resolvedTheme.spotlight.dimOpacity
717
- return together(
718
- ...this.barSets.map((bars, i) =>
719
- bars.to({ data: { opacity: i === seriesIndex ? 1 : dim }, duration, easing })
720
- ),
721
- ...(this.legend
722
- ? [this.legend.spotlightSeries({ data: { index: seriesIndex }, duration, easing })]
723
- : [])
724
- )
725
- }
726
-
727
- /** Ease series `index`'s slot share toward `target`, reflowing the group. */
728
- private tweenWeight(
729
- index: number,
730
- target: number,
731
- duration: number,
732
- easing: EasingFunction
733
- ): Seekable {
734
- // Read through `weightAt` rather than off the array, so a chart whose stored
735
- // weights are short (or absent) still tweens from the 1 it is drawing at.
736
- const from = this.series.map((_, i) => this.weightAt(i))
737
- const start = from[index] ?? 1
738
- if (start === target) return hold(duration)
739
-
740
- return drive(duration, (t) => {
741
- const next = [...from]
742
- next[index] = start + (target - start) * easing(t)
743
- this.weights = next
744
- })
745
- }
746
-
747
- // ---- Per-category ------------------------------------------------------
748
-
749
- /**
750
- * Spotlight one **category** — one column of the table rather than one series:
751
- * every other category's bars and its caption fade to the theme's
752
- * `dimOpacity`, and the chosen one stays full.
753
- *
754
- * The counterpart of the line chart's `spotlightRegion`, and the same shape of
755
- * thing: it picks out a slice of the plot without changing what is drawn.
756
- * Calling it while another category is already lit moves the focus directly —
757
- * only the dimming changes, so there is nothing to retract first.
758
- */
759
- @command({ args: [{ key: "categoryIndex", kind: "number", default: 0, min: 0, step: 1 }] })
760
- spotlightCategory(args: CommandArgs<{ categoryIndex: number }>): Command<CategorySpotlight> {
761
- const { categoryIndex = 0 } = args.data ?? {}
762
- const duration = args.duration ?? 0.4
763
- const easing = args.easing ?? easeOut()
764
- if (categoryIndex < 0 || categoryIndex >= this.data.length) return this.holdSpotlight(duration)
765
- const from = this.spotlightReveal
766
- // Which category is lit is part of the value, set from the first frame: the
767
- // focus moves directly, so unlike the line chart's region there is nothing to
768
- // retract before it.
769
- return this.command<CategorySpotlight>(
770
- (t) => ({ spotlight: categoryIndex, spotlightReveal: from + (1 - from) * t }),
771
- duration,
772
- easing
773
- )
774
- }
775
-
776
- /**
777
- * Drop the category spotlight, bringing every category back to full. A no-op
778
- * when nothing is lit — but it still takes its slot on the timeline, for the
779
- * reason `LineChart.clearRegion` does.
780
- */
781
- @command()
782
- clearCategory(args: CommandArgs<Record<string, never>>): Command<CategorySpotlight> {
783
- const duration = args.duration ?? 0.4
784
- const easing = args.easing ?? easeOut()
785
- const from = this.spotlightReveal
786
- if (from === 0) return this.holdSpotlight(duration)
787
-
788
- // The lit category is held until the fade lands — cleared at `t === 1` rather
789
- // than up front, or there would be nothing left to fade.
790
- return this.command<CategorySpotlight>(
791
- (t) =>
792
- t >= 1
793
- ? { spotlight: null, spotlightReveal: 0 }
794
- : { spotlightReveal: from * (1 - t) },
795
- duration,
796
- easing
797
- )
798
- }
438
+ return asParts([cartesianFrame({ axis: vAxis as unknown as Node2D, plot, gutter, below })])
439
+ },
440
+ commands: {
441
+ /**
442
+ * Animate the whole chart in: the two axes stagger their markers and
443
+ * captions in, the columns rise from the baseline, and the legend fades up
444
+ * last. Its first frame arms every part: markers and captions shifted and
445
+ * transparent, columns collapsed, the legend dropped and transparent.
446
+ * `duration` is the **whole** entrance; the beats are shares of it.
447
+ */
448
+ enter: {
449
+ args: {},
450
+ // Held from the chart's arrival, so it never shows finished ahead of its draw.
451
+ entrance: true,
452
+ easing: easeOut(),
453
+ run: (timeline, _state, children) => {
454
+ const parts = partsOf(children)
455
+ if (!parts) return timeline.wait(timeline.duration)
456
+ const duration = timeline.duration
457
+ const axes = duration * AXES_SHARE
458
+ const rise = duration * BARS_SHARE
459
+ const { legend } = parts
460
+ const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
461
+ // Without a legend the beat is still its share of the whole.
462
+ return beats(timeline, [
463
+ at0(timeline.animate(() => ({ growth: 0 }))),
464
+ ...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
465
+ { at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
466
+ { at: 0, duration: axes, piece: parts.cAxis.run("enter", { slide: 24 }) },
467
+ // No priming to 0 first: the value at `t` is a function of `t`.
468
+ { at: axes, duration: rise, piece: timeline.animate((t) => ({ growth: t })) },
469
+ ...(legend ? [{ at: axes + rise, duration: duration * LEGEND_SHARE, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
470
+ ])
471
+ },
472
+ },
473
+ /** Show series `index`: its columns come back, the group reflowing to make room, with its legend entry. */
474
+ showSeries: {
475
+ args: { seriesIndex: SERIES_ARG },
476
+ easing: easeOut(),
477
+ run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 1),
478
+ },
479
+ /**
480
+ * Hide series `index`: its columns narrow away (or, stacked, sink out of the
481
+ * stack) while the rest of the group widens into the space — the mirror of
482
+ * `showSeries`.
483
+ */
484
+ hideSeries: {
485
+ args: { seriesIndex: SERIES_ARG },
486
+ easing: easeOut(),
487
+ run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 0),
488
+ },
489
+ /**
490
+ * Fade every other series — its columns **and its legend entry** — to focus
491
+ * on `index`, which comes back to full. Nothing moves, so the group doesn't
492
+ * reflow.
493
+ */
494
+ spotlightSeries: {
495
+ args: { seriesIndex: SERIES_ARG },
496
+ easing: easeOut(),
497
+ run: (timeline, state, children) => {
498
+ const parts = partsOf(children)
499
+ if (!parts) return timeline.wait(timeline.duration)
500
+ const index = timeline.args.seriesIndex
501
+ const dim = state.theme.spotlight.dimOpacity
502
+ return timeline.together([
503
+ ...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : dim })),
504
+ ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
505
+ ])
506
+ },
507
+ },
508
+ /**
509
+ * Spotlight one **category**: every other category's columns and caption
510
+ * fade to the theme's `dimOpacity`. Called while another is lit, the focus
511
+ * moves directly: only the dimming changes, so nothing retracts first. A
512
+ * category that is not there takes its slot and changes nothing.
513
+ */
514
+ spotlightCategory: {
515
+ args: { categoryIndex: Types.number(0, { min: 0, step: 1 }) },
516
+ easing: easeOut(),
517
+ run: (timeline, state) => {
518
+ const index = timeline.args.categoryIndex
519
+ if (index < 0 || index >= state.data.length) return timeline.wait(timeline.duration)
520
+ const from = state.spotlightReveal
521
+ return timeline.animate((t) => ({ spotlight: index, spotlightReveal: from + (1 - from) * t }))
522
+ },
523
+ },
524
+ /**
525
+ * Drop the category spotlight, every category back to full. The lit
526
+ * category is held until the fade lands; a no-op when nothing is lit, which
527
+ * still takes its slot.
528
+ */
529
+ clearCategory: {
530
+ args: {},
531
+ easing: easeOut(),
532
+ run: (timeline, state) => {
533
+ const from = state.spotlightReveal
534
+ if (from === 0) return timeline.wait(timeline.duration)
535
+ const lit = state.spotlight
536
+ return timeline.animate((t) => (t >= 1 ? { spotlight: null, spotlightReveal: 0 } : { spotlight: lit, spotlightReveal: from * (1 - t) }))
537
+ },
538
+ },
539
+ },
540
+ })
799
541
 
800
- /** The no-op both of the above return — see {@link hold}. */
801
- private holdSpotlight(duration: number): Command<CategorySpotlight> {
802
- return this.command<CategorySpotlight>(() => ({}), duration)
803
- }
804
- }
542
+ export type BarChart = NodeOf<typeof BarChart>