@motionscript/charts 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +49 -49
  4. package/README.md +99 -99
  5. package/dist/bar-chart/bar-chart.js.map +1 -1
  6. package/dist/bar-chart/category-axis.js.map +1 -1
  7. package/dist/bar-chart/chart-bars.js.map +1 -1
  8. package/dist/bar-chart/index.js.map +1 -1
  9. package/dist/bar-chart/shared.js.map +1 -1
  10. package/dist/browser/chunks/{chunk-XROBIRQK.js → chunk-TJNHLQGV.js} +1 -1
  11. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  12. package/dist/browser/index.js +1 -1
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/kit.js +1 -1
  15. package/dist/browser/manifest.json +1 -1
  16. package/dist/engine.d.ts +1 -1
  17. package/dist/engine.js +1 -1
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/kit/cartesian.js.map +1 -1
  21. package/dist/kit/chart-border.js.map +1 -1
  22. package/dist/kit/chart-canvas.js.map +1 -1
  23. package/dist/kit/chart-legend.js.map +1 -1
  24. package/dist/kit/entries.js.map +1 -1
  25. package/dist/kit/motion.js.map +1 -1
  26. package/dist/kit/paints.js.map +1 -1
  27. package/dist/kit/shared.js.map +1 -1
  28. package/dist/kit/vertical-axis.js.map +1 -1
  29. package/dist/line-chart/chart-line.js.map +1 -1
  30. package/dist/line-chart/chart-region.js.map +1 -1
  31. package/dist/line-chart/horizontal-axis.js.map +1 -1
  32. package/dist/line-chart/index.d.ts +1 -1
  33. package/dist/line-chart/index.js +1 -1
  34. package/dist/line-chart/index.js.map +1 -1
  35. package/dist/line-chart/line-chart.js.map +1 -1
  36. package/dist/line-chart/shared.js.map +1 -1
  37. package/dist/line-chart/x-scale.js.map +1 -1
  38. package/dist/nodes.js.map +1 -1
  39. package/dist/pie-chart/index.js.map +1 -1
  40. package/dist/pie-chart/pie-chart.js.map +1 -1
  41. package/dist/pie-chart/pie-legend.js.map +1 -1
  42. package/dist/pie-chart/pie-ring.js.map +1 -1
  43. package/dist/pie-chart/shared.js.map +1 -1
  44. package/package.json +4 -4
  45. package/registry.json +23 -23
  46. package/src/bar-chart/bar-chart.ts +542 -542
  47. package/src/bar-chart/category-axis.ts +162 -162
  48. package/src/bar-chart/chart-bars.ts +287 -287
  49. package/src/bar-chart/index.ts +31 -31
  50. package/src/bar-chart/shared.ts +354 -354
  51. package/src/engine.ts +28 -28
  52. package/src/index.ts +5 -5
  53. package/src/kit/cartesian.ts +81 -81
  54. package/src/kit/chart-border.ts +50 -50
  55. package/src/kit/chart-canvas.ts +122 -122
  56. package/src/kit/chart-legend.ts +229 -229
  57. package/src/kit/entries.ts +20 -20
  58. package/src/kit/motion.ts +90 -90
  59. package/src/kit/paints.ts +15 -15
  60. package/src/kit/shared.ts +782 -782
  61. package/src/kit/vertical-axis.ts +194 -194
  62. package/src/line-chart/chart-line.ts +156 -156
  63. package/src/line-chart/chart-region.ts +90 -90
  64. package/src/line-chart/horizontal-axis.ts +151 -151
  65. package/src/line-chart/index.ts +39 -39
  66. package/src/line-chart/line-chart.ts +598 -598
  67. package/src/line-chart/shared.ts +473 -473
  68. package/src/line-chart/x-scale.ts +382 -382
  69. package/src/nodes.ts +18 -18
  70. package/src/pie-chart/index.ts +37 -37
  71. package/src/pie-chart/pie-chart.ts +296 -296
  72. package/src/pie-chart/pie-legend.ts +169 -169
  73. package/src/pie-chart/pie-ring.ts +848 -848
  74. package/src/pie-chart/shared.ts +496 -496
  75. package/dist/browser/chunks/chunk-XROBIRQK.js.map +0 -7
  76. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,542 +1,542 @@
1
- import {
2
- Rect,
3
- Types,
4
- component2d,
5
- easeOut,
6
- type ChildView,
7
- type DataRecord,
8
- type EntryOptions,
9
- type NodeOf,
10
- // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
11
- type Node2D,
12
- type Node2DProps,
13
- type Size2D,
14
- type Timeline,
15
- } from "@motionscript/sdk"
16
- import { formatNumber } from "@motionscript/sdk/component"
17
-
18
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
19
- import { ChartBorder } from "../kit/chart-border"
20
- import { ChartCanvas } from "../kit/chart-canvas"
21
- import { ChartLegend } from "../kit/chart-legend"
22
- import { asParts, fresh } from "../kit/entries"
23
- import { beats, type Beat } from "../kit/motion"
24
- import { VerticalAxis } from "../kit/vertical-axis"
25
- import {
26
- clampUnit,
27
- formatMarker,
28
- lerpAxisDomain,
29
- lerpAxisEntry,
30
- markerStep,
31
- markerValues,
32
- num,
33
- resolveAxisDomain,
34
- settleAxisDomain,
35
- type AxisDomainConfig,
36
- type Range,
37
- } from "../kit/shared"
38
- import { CategoryAxis } from "./category-axis"
39
- import { ChartBars, type BarGeometry } from "./chart-bars"
40
- import {
41
- DEFAULT_BAR_CHART_THEME,
42
- barCategories,
43
- barSpans,
44
- lerpBarChartTheme,
45
- resolveBarChartTheme,
46
- resolveBarFill,
47
- resolveBarScale,
48
- stackCapIndex,
49
- type BarChartConfig,
50
- type BarChartTheme,
51
- type BarSeries,
52
- } from "./shared"
53
-
54
- export interface BarChartProps extends Node2DProps {
55
- /** One row per category — a plain record of field name to value. */
56
- data: DataRecord[]
57
- /**
58
- * Key into each row for its category caption, drawn beneath the baseline.
59
- * Named `categoryField` rather than `category` for the same reason the line
60
- * chart's is `xField`: it names a *column*, not a value.
61
- */
62
- categoryField: string
63
- /** One entry per bar series: which column it plots, its `label`, its `fill`. */
64
- series: BarSeries[]
65
- /**
66
- * When `true` every category's series stack into one column; when `false`
67
- * (the default) each series gets its own bar, grouped side by side within the
68
- * category's slot.
69
- */
70
- stacked?: boolean
71
- /** The value axis's domain: caption, scale range, and tick placement. */
72
- valueAxis?: AxisDomainConfig
73
- /** The category axis's domain. Only its `label` is read — the slots are the rows. */
74
- categoryAxis?: AxisDomainConfig
75
- /**
76
- * The chart's visual theme — bar palette and geometry, axis furniture, plot
77
- * panel paint, legend, value figures. Every branch is optional and deep-merged
78
- * onto {@link DEFAULT_BAR_CHART_THEME}.
79
- */
80
- theme?: BarChartTheme
81
- /** Whether the legend row below the plot is drawn at all. */
82
- showLegend?: boolean
83
- /** Whether each column prints the figure it plots. */
84
- showValues?: boolean
85
- }
86
-
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 })
100
-
101
- /** How far the legend starts below its slot when it enters, in px. */
102
- const LEGEND_SLIDE = 20
103
-
104
- /** Fallback marker count when the value axis states no ticks. */
105
- const DEFAULT_MARKERS = 5
106
-
107
- /**
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.
111
- */
112
- const AXES_SHARE = 0.35
113
- const BARS_SHARE = 0.5
114
- const LEGEND_SHARE = 0.15
115
-
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
- }
123
-
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
- }
130
-
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
- }
143
-
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
- }
153
-
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
- }
160
-
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
- }
169
-
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
- }
174
-
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
- }
184
-
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
- }
229
-
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
- }
241
-
242
- type AnyTimeline = Timeline<any, any>
243
-
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,
255
- }
256
- }
257
-
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
- }
283
-
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 ?? "",
363
- width: "hug",
364
- height: "fill",
365
- })
366
-
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),
372
- width: "fill",
373
- height: "hug",
374
- }) as unknown as Node2D
375
-
376
- const barSets = state.series.map(
377
- (s, i) =>
378
- new ChartBars({
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
391
- )
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
- )
406
- }
407
-
408
- plot = new Rect({
409
- clip: true,
410
- children: [
411
- new ChartCanvas({
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.
418
- xMarkers: [],
419
- yMarkers: () => domain().ticks,
420
- xExtent: () => [0, 1] as Range,
421
- yExtent: () => domain().range,
422
- }),
423
- ...barSets,
424
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
425
- ],
426
- }) as unknown as Node2D
427
-
428
- const gutter = new VerticalAxis({
429
- opacity: 0,
430
- range: () => domain().range,
431
- markers: () => domain().ticks,
432
- axis: () => theme().yAxis,
433
- label: state.valueAxis.label ?? "",
434
- width: "hug",
435
- height: 0,
436
- }) as unknown as Node2D
437
-
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
- })
541
-
542
- export type BarChart = NodeOf<typeof BarChart>
1
+ import {
2
+ Rect,
3
+ Types,
4
+ component2d,
5
+ easeOut,
6
+ type ChildView,
7
+ type DataRecord,
8
+ type EntryOptions,
9
+ type NodeOf,
10
+ // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
11
+ type Node2D,
12
+ type Node2DProps,
13
+ type Size2D,
14
+ type Timeline,
15
+ } from "@motionscript/sdk"
16
+ import { formatNumber } from "@motionscript/sdk/component"
17
+
18
+ import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
19
+ import { ChartBorder } from "../kit/chart-border"
20
+ import { ChartCanvas } from "../kit/chart-canvas"
21
+ import { ChartLegend } from "../kit/chart-legend"
22
+ import { asParts, fresh } from "../kit/entries"
23
+ import { beats, type Beat } from "../kit/motion"
24
+ import { VerticalAxis } from "../kit/vertical-axis"
25
+ import {
26
+ clampUnit,
27
+ formatMarker,
28
+ lerpAxisDomain,
29
+ lerpAxisEntry,
30
+ markerStep,
31
+ markerValues,
32
+ num,
33
+ resolveAxisDomain,
34
+ settleAxisDomain,
35
+ type AxisDomainConfig,
36
+ type Range,
37
+ } from "../kit/shared"
38
+ import { CategoryAxis } from "./category-axis"
39
+ import { ChartBars, type BarGeometry } from "./chart-bars"
40
+ import {
41
+ DEFAULT_BAR_CHART_THEME,
42
+ barCategories,
43
+ barSpans,
44
+ lerpBarChartTheme,
45
+ resolveBarChartTheme,
46
+ resolveBarFill,
47
+ resolveBarScale,
48
+ stackCapIndex,
49
+ type BarChartConfig,
50
+ type BarChartTheme,
51
+ type BarSeries,
52
+ } from "./shared"
53
+
54
+ export interface BarChartProps extends Node2DProps {
55
+ /** One row per category — a plain record of field name to value. */
56
+ data: DataRecord[]
57
+ /**
58
+ * Key into each row for its category caption, drawn beneath the baseline.
59
+ * Named `categoryField` rather than `category` for the same reason the line
60
+ * chart's is `xField`: it names a *column*, not a value.
61
+ */
62
+ categoryField: string
63
+ /** One entry per bar series: which column it plots, its `label`, its `fill`. */
64
+ series: BarSeries[]
65
+ /**
66
+ * When `true` every category's series stack into one column; when `false`
67
+ * (the default) each series gets its own bar, grouped side by side within the
68
+ * category's slot.
69
+ */
70
+ stacked?: boolean
71
+ /** The value axis's domain: caption, scale range, and tick placement. */
72
+ valueAxis?: AxisDomainConfig
73
+ /** The category axis's domain. Only its `label` is read — the slots are the rows. */
74
+ categoryAxis?: AxisDomainConfig
75
+ /**
76
+ * The chart's visual theme — bar palette and geometry, axis furniture, plot
77
+ * panel paint, legend, value figures. Every branch is optional and deep-merged
78
+ * onto {@link DEFAULT_BAR_CHART_THEME}.
79
+ */
80
+ theme?: BarChartTheme
81
+ /** Whether the legend row below the plot is drawn at all. */
82
+ showLegend?: boolean
83
+ /** Whether each column prints the figure it plots. */
84
+ showValues?: boolean
85
+ }
86
+
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 })
100
+
101
+ /** How far the legend starts below its slot when it enters, in px. */
102
+ const LEGEND_SLIDE = 20
103
+
104
+ /** Fallback marker count when the value axis states no ticks. */
105
+ const DEFAULT_MARKERS = 5
106
+
107
+ /**
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.
111
+ */
112
+ const AXES_SHARE = 0.35
113
+ const BARS_SHARE = 0.5
114
+ const LEGEND_SHARE = 0.15
115
+
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
+ }
123
+
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
+ }
130
+
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
+ }
143
+
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
+ }
153
+
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
+ }
160
+
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
+ }
169
+
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
+ }
174
+
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
+ }
184
+
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
+ }
229
+
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
+ }
241
+
242
+ type AnyTimeline = Timeline<any, any>
243
+
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,
255
+ }
256
+ }
257
+
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
+ }
283
+
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 ?? "",
363
+ width: "hug",
364
+ height: "fill",
365
+ })
366
+
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),
372
+ width: "fill",
373
+ height: "hug",
374
+ }) as unknown as Node2D
375
+
376
+ const barSets = state.series.map(
377
+ (s, i) =>
378
+ new ChartBars({
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
391
+ )
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
+ )
406
+ }
407
+
408
+ plot = new Rect({
409
+ clip: true,
410
+ children: [
411
+ new ChartCanvas({
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.
418
+ xMarkers: [],
419
+ yMarkers: () => domain().ticks,
420
+ xExtent: () => [0, 1] as Range,
421
+ yExtent: () => domain().range,
422
+ }),
423
+ ...barSets,
424
+ new ChartBorder({ stroke: () => theme().plotArea.stroke }),
425
+ ],
426
+ }) as unknown as Node2D
427
+
428
+ const gutter = new VerticalAxis({
429
+ opacity: 0,
430
+ range: () => domain().range,
431
+ markers: () => domain().ticks,
432
+ axis: () => theme().yAxis,
433
+ label: state.valueAxis.label ?? "",
434
+ width: "hug",
435
+ height: 0,
436
+ }) as unknown as Node2D
437
+
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
+ })
541
+
542
+ export type BarChart = NodeOf<typeof BarChart>