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

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