@motionscript/charts 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +30 -16
- package/dist/bar-chart/bar-chart.d.ts +133 -242
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +378 -569
- package/dist/bar-chart/bar-chart.js.map +1 -1
- package/dist/bar-chart/category-axis.d.ts +35 -41
- package/dist/bar-chart/category-axis.d.ts.map +1 -1
- package/dist/bar-chart/category-axis.js +93 -115
- package/dist/bar-chart/category-axis.js.map +1 -1
- package/dist/bar-chart/chart-bars.d.ts +50 -70
- package/dist/bar-chart/chart-bars.d.ts.map +1 -1
- package/dist/bar-chart/chart-bars.js +156 -210
- package/dist/bar-chart/chart-bars.js.map +1 -1
- package/dist/bar-chart/index.d.ts +2 -2
- package/dist/bar-chart/index.d.ts.map +1 -1
- package/dist/bar-chart/index.js +1 -1
- package/dist/bar-chart/index.js.map +1 -1
- package/dist/bar-chart/shared.d.ts +3 -3
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +3 -3
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-XROBIRQK.js +2 -0
- package/dist/browser/chunks/chunk-XROBIRQK.js.map +7 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/kit.js +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/engine.d.ts +8 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +8 -6
- package/dist/engine.js.map +1 -1
- package/dist/kit/cartesian.d.ts +44 -0
- package/dist/kit/cartesian.d.ts.map +1 -0
- package/dist/kit/cartesian.js +59 -0
- package/dist/kit/cartesian.js.map +1 -0
- package/dist/kit/chart-border.d.ts +10 -11
- package/dist/kit/chart-border.d.ts.map +1 -1
- package/dist/kit/chart-border.js +15 -32
- package/dist/kit/chart-border.js.map +1 -1
- package/dist/kit/chart-canvas.d.ts +32 -21
- package/dist/kit/chart-canvas.d.ts.map +1 -1
- package/dist/kit/chart-canvas.js +33 -90
- package/dist/kit/chart-canvas.js.map +1 -1
- package/dist/kit/chart-legend.d.ts +123 -64
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +129 -154
- package/dist/kit/chart-legend.js.map +1 -1
- package/dist/kit/entries.d.ts +17 -0
- package/dist/kit/entries.d.ts.map +1 -0
- package/dist/kit/entries.js +19 -0
- package/dist/kit/entries.js.map +1 -0
- package/dist/kit/motion.d.ts +48 -0
- package/dist/kit/motion.d.ts.map +1 -0
- package/dist/kit/motion.js +63 -0
- package/dist/kit/motion.js.map +1 -0
- package/dist/kit/paints.d.ts +10 -0
- package/dist/kit/paints.d.ts.map +1 -0
- package/dist/kit/paints.js +12 -0
- package/dist/kit/paints.js.map +1 -0
- package/dist/kit/shared.d.ts +31 -3
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +42 -3
- package/dist/kit/shared.js.map +1 -1
- package/dist/kit/vertical-axis.d.ts +48 -61
- package/dist/kit/vertical-axis.d.ts.map +1 -1
- package/dist/kit/vertical-axis.js +97 -132
- package/dist/kit/vertical-axis.js.map +1 -1
- package/dist/line-chart/chart-line.d.ts +60 -77
- package/dist/line-chart/chart-line.d.ts.map +1 -1
- package/dist/line-chart/chart-line.js +100 -121
- package/dist/line-chart/chart-line.js.map +1 -1
- package/dist/line-chart/chart-region.d.ts +32 -33
- package/dist/line-chart/chart-region.d.ts.map +1 -1
- package/dist/line-chart/chart-region.js +31 -65
- package/dist/line-chart/chart-region.js.map +1 -1
- package/dist/line-chart/horizontal-axis.d.ts +58 -64
- package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
- package/dist/line-chart/horizontal-axis.js +94 -123
- package/dist/line-chart/horizontal-axis.js.map +1 -1
- package/dist/line-chart/index.d.ts +2 -2
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +1 -1
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +156 -319
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +403 -663
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +3 -3
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +3 -3
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/nodes.d.ts +6 -9
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +7 -9
- package/dist/nodes.js.map +1 -1
- package/dist/pie-chart/index.d.ts +2 -2
- package/dist/pie-chart/index.d.ts.map +1 -1
- package/dist/pie-chart/index.js +1 -1
- package/dist/pie-chart/index.js.map +1 -1
- package/dist/pie-chart/pie-chart.d.ts +108 -132
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +200 -271
- package/dist/pie-chart/pie-chart.js.map +1 -1
- package/dist/pie-chart/pie-legend.d.ts +84 -31
- package/dist/pie-chart/pie-legend.d.ts.map +1 -1
- package/dist/pie-chart/pie-legend.js +93 -107
- package/dist/pie-chart/pie-legend.js.map +1 -1
- package/dist/pie-chart/pie-ring.d.ts +160 -131
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +333 -391
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +2 -2
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +2 -2
- package/dist/pie-chart/shared.js.map +1 -1
- package/package.json +4 -4
- package/src/bar-chart/bar-chart.ts +415 -701
- package/src/bar-chart/category-axis.ts +95 -110
- package/src/bar-chart/chart-bars.ts +178 -200
- package/src/bar-chart/index.ts +2 -2
- package/src/bar-chart/shared.ts +3 -3
- package/src/engine.ts +8 -6
- package/src/kit/cartesian.ts +81 -0
- package/src/kit/chart-border.ts +22 -33
- package/src/kit/chart-canvas.ts +44 -79
- package/src/kit/chart-legend.ts +173 -189
- package/src/kit/entries.ts +20 -0
- package/src/kit/motion.ts +90 -0
- package/src/kit/paints.ts +15 -0
- package/src/kit/shared.ts +52 -3
- package/src/kit/vertical-axis.ts +110 -140
- package/src/line-chart/chart-line.ts +102 -154
- package/src/line-chart/chart-region.ts +35 -80
- package/src/line-chart/horizontal-axis.ts +101 -152
- package/src/line-chart/index.ts +2 -2
- package/src/line-chart/line-chart.ts +449 -825
- package/src/line-chart/shared.ts +3 -3
- package/src/nodes.ts +8 -10
- package/src/pie-chart/index.ts +2 -2
- package/src/pie-chart/pie-chart.ts +221 -311
- package/src/pie-chart/pie-legend.ts +124 -114
- package/src/pie-chart/pie-ring.ts +386 -473
- package/src/pie-chart/shared.ts +2 -2
- package/dist/browser/chunks/chunk-5ZPBV6BU.js +0 -2
- package/dist/browser/chunks/chunk-5ZPBV6BU.js.map +0 -7
|
@@ -1,43 +1,40 @@
|
|
|
1
1
|
import {
|
|
2
|
-
command,
|
|
3
|
-
node,
|
|
4
|
-
Node2D,
|
|
5
2
|
Rect,
|
|
3
|
+
Types,
|
|
4
|
+
component2d,
|
|
6
5
|
easeOut,
|
|
7
|
-
|
|
8
|
-
type CommandArgs,
|
|
6
|
+
type ChildView,
|
|
9
7
|
type DataRecord,
|
|
10
|
-
type
|
|
11
|
-
type
|
|
12
|
-
type
|
|
8
|
+
type EntryOptions,
|
|
9
|
+
type NodeOf,
|
|
10
|
+
// Named so this component's emitted type reaches `Node2D` through the sdk, not core.
|
|
11
|
+
type Node2D,
|
|
13
12
|
type Node2DProps,
|
|
13
|
+
type Size2D,
|
|
14
|
+
type Timeline,
|
|
14
15
|
} from "@motionscript/sdk"
|
|
16
|
+
import { formatNumber } from "@motionscript/sdk/component"
|
|
15
17
|
|
|
18
|
+
import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
|
|
16
19
|
import { ChartBorder } from "../kit/chart-border"
|
|
17
20
|
import { ChartCanvas } from "../kit/chart-canvas"
|
|
18
21
|
import { ChartLegend } from "../kit/chart-legend"
|
|
22
|
+
import { asParts, fresh } from "../kit/entries"
|
|
23
|
+
import { beats, type Beat } from "../kit/motion"
|
|
19
24
|
import { VerticalAxis } from "../kit/vertical-axis"
|
|
20
25
|
import {
|
|
21
26
|
clampUnit,
|
|
22
27
|
formatMarker,
|
|
23
|
-
markerStep,
|
|
24
|
-
axisTweenTarget,
|
|
25
28
|
lerpAxisDomain,
|
|
29
|
+
lerpAxisEntry,
|
|
30
|
+
markerStep,
|
|
26
31
|
markerValues,
|
|
27
32
|
num,
|
|
28
33
|
resolveAxisDomain,
|
|
34
|
+
settleAxisDomain,
|
|
29
35
|
type AxisDomainConfig,
|
|
30
36
|
type Range,
|
|
31
37
|
} 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
38
|
import { CategoryAxis } from "./category-axis"
|
|
42
39
|
import { ChartBars, type BarGeometry } from "./chart-bars"
|
|
43
40
|
import {
|
|
@@ -87,734 +84,451 @@ export interface BarChartProps extends Node2DProps {
|
|
|
87
84
|
showValues?: boolean
|
|
88
85
|
}
|
|
89
86
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 })
|
|
97
100
|
|
|
98
101
|
/** How far the legend starts below its slot when it enters, in px. */
|
|
99
102
|
const LEGEND_SLIDE = 20
|
|
100
103
|
|
|
104
|
+
/** Fallback marker count when the value axis states no ticks. */
|
|
105
|
+
const DEFAULT_MARKERS = 5
|
|
106
|
+
|
|
101
107
|
/**
|
|
102
|
-
* How
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
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.
|
|
114
111
|
*/
|
|
115
|
-
type CategorySpotlight = { spotlight: number | null; spotlightReveal: number }
|
|
116
|
-
type BarGrowth = { growth: number }
|
|
117
|
-
|
|
118
112
|
const AXES_SHARE = 0.35
|
|
119
113
|
const BARS_SHARE = 0.5
|
|
120
114
|
const LEGEND_SHARE = 0.15
|
|
121
115
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
338
123
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|
|
343
130
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
131
|
+
/** The value axis as a bar chart draws it now, an auto range or tick row read back as itself. */
|
|
132
|
+
export function barChartDomain(s: BarChartValues): BarChartDomain {
|
|
133
|
+
return settleAxisDomain(s.valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
|
|
134
|
+
}
|
|
348
135
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
136
|
+
/**
|
|
137
|
+
* A value written down as this chart's value axis says: the ticks and the
|
|
138
|
+
* figures printed on the columns both read through here, so the two cannot
|
|
139
|
+
* disagree about what a number looks like.
|
|
140
|
+
*/
|
|
141
|
+
function valueLabel(s: BarChartValues, domain: BarChartDomain, value: number): string {
|
|
142
|
+
const pattern = s.valueAxis.format
|
|
143
|
+
return pattern === undefined ? formatMarker(value, markerStep(domain.ticks)) : formatNumber(value, pattern)
|
|
144
|
+
}
|
|
355
145
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
146
|
+
/** A value on the plot's **centred** pixel space (origin at its centre, y-up). */
|
|
147
|
+
function valueToY(domain: BarChartDomain, plot: Size2D, value: number): number {
|
|
148
|
+
const [min, max] = domain.range
|
|
149
|
+
if (max <= min) return -plot.height / 2
|
|
150
|
+
return ((value - min) / (max - min) - 0.5) * plot.height
|
|
151
|
+
}
|
|
362
152
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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)
|
|
153
|
+
/**
|
|
154
|
+
* Pixel y of the line every column grows from: zero clamped onto the scale, so
|
|
155
|
+
* a cropped axis grows its columns from the plot floor.
|
|
156
|
+
*/
|
|
157
|
+
function baselinePx(domain: BarChartDomain, plot: Size2D): number {
|
|
158
|
+
const [min, max] = domain.range
|
|
159
|
+
return valueToY(domain, plot, Math.min(Math.max(0, min), max))
|
|
160
|
+
}
|
|
380
161
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
162
|
+
/** Series `i`'s live share of the slot; 0 once it has been hidden. */
|
|
163
|
+
function weightAt(s: BarState, index: number): number {
|
|
164
|
+
return Math.max(0, s.weights[index] ?? 1)
|
|
165
|
+
}
|
|
393
166
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
167
|
+
/** `row` with each series' value scaled by its live weight: what the stacked spans are measured from. */
|
|
168
|
+
function weightedRow(s: BarState, row: DataRecord): DataRecord {
|
|
169
|
+
const scaled: DataRecord = { ...row }
|
|
170
|
+
s.series.forEach((series, i) => {
|
|
171
|
+
const value = row[series.field]
|
|
172
|
+
if (typeof value === "number") scaled[series.field] = value * weightAt(s, i)
|
|
173
|
+
})
|
|
174
|
+
return scaled
|
|
175
|
+
}
|
|
401
176
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Series `index`'s columns, mapped into the plot's centred pixel space. A
|
|
179
|
+
* grouped series divides the slot with its siblings by their live weights, a
|
|
180
|
+
* stacked one takes the whole slot and runs from the running total below it,
|
|
181
|
+
* so hiding a series reflows rather than punching a hole.
|
|
182
|
+
*/
|
|
183
|
+
function seriesBars(s: BarState, index: number, plot: Size2D, captioned: boolean): BarGeometry[] {
|
|
184
|
+
const series = s.series[index]
|
|
185
|
+
if (!series) return []
|
|
186
|
+
const domain = barChartDomain(s)
|
|
187
|
+
const n = s.data.length
|
|
188
|
+
const slotWidth = n > 0 ? plot.width / n : 0
|
|
189
|
+
const slotCenter = (i: number) => -plot.width / 2 + slotWidth * (i + 0.5)
|
|
190
|
+
const inner = slotWidth * clampUnit(s.theme.bar.slotFill)
|
|
191
|
+
const gap = s.theme.bar.groupGap
|
|
192
|
+
let total = 0
|
|
193
|
+
for (let i = 0; i < s.series.length; i++) total += weightAt(s, i)
|
|
194
|
+
const unit = inner / (total > 0 ? total : 1)
|
|
195
|
+
const weight = weightAt(s, index)
|
|
196
|
+
let before = 0
|
|
197
|
+
for (let i = 0; i < index; i++) before += weightAt(s, i)
|
|
198
|
+
|
|
199
|
+
return s.data.map((row, rowIndex) => {
|
|
200
|
+
// The stack is built from the *live* contributions, so a series fading out
|
|
201
|
+
// lowers the segments above it as it goes.
|
|
202
|
+
const spans = barSpans(s.stacked ? weightedRow(s, row) : row, s.series, s.stacked)
|
|
203
|
+
const span = spans[index] ?? { from: 0, to: 0, value: 0 }
|
|
204
|
+
const cap = s.stacked ? stackCapIndex(row, s.series) : index
|
|
205
|
+
const slotLeft = slotCenter(rowIndex) - inner / 2
|
|
206
|
+
const x = s.stacked ? slotCenter(rowIndex) : slotLeft + unit * before + (unit * weight) / 2
|
|
207
|
+
const width = s.stacked ? inner : Math.max(0, unit * weight - (s.series.length > 1 ? gap : 0))
|
|
208
|
+
// The figure printed is the row's own value, not the weighted one.
|
|
209
|
+
const value = num(row, series.field)
|
|
210
|
+
return {
|
|
211
|
+
x,
|
|
212
|
+
width,
|
|
213
|
+
from: valueToY(domain, plot, span.from),
|
|
214
|
+
to: valueToY(domain, plot, span.to),
|
|
215
|
+
round: cap === index,
|
|
216
|
+
value,
|
|
217
|
+
...(captioned && { caption: valueLabel(s, domain, value) }),
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
414
221
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
222
|
+
/**
|
|
223
|
+
* How far each category is dimmed by the spotlight, one entry per row: 1 while
|
|
224
|
+
* nothing is spotlit, falling to the theme's `dimOpacity` for every category but
|
|
225
|
+
* the chosen one as the reveal climbs.
|
|
226
|
+
*/
|
|
227
|
+
function categoryDims(s: BarState): number[] {
|
|
228
|
+
const focus = clampUnit(s.spotlightReveal)
|
|
229
|
+
const dim = s.theme.spotlight.dimOpacity
|
|
230
|
+
const chosen = s.spotlight
|
|
231
|
+
return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - dim) * focus))
|
|
232
|
+
}
|
|
427
233
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
234
|
+
type AnyTimeline = Timeline<any, any>
|
|
235
|
+
|
|
236
|
+
/** The parts a bar chart's commands drive, found through its children. */
|
|
237
|
+
function partsOf(children: readonly ChildView[]) {
|
|
238
|
+
const frame = cartesianViews(children)
|
|
239
|
+
if (!frame) return null
|
|
240
|
+
const cAxis = frame.below.find(CategoryAxis.is)
|
|
241
|
+
if (!VerticalAxis.is(frame.axis) || !cAxis) return null
|
|
242
|
+
return {
|
|
243
|
+
vAxis: frame.axis,
|
|
244
|
+
cAxis,
|
|
245
|
+
bars: frame.plot.filter(ChartBars.is),
|
|
246
|
+
legend: frame.below.find(ChartLegend.is) ?? null,
|
|
440
247
|
}
|
|
248
|
+
}
|
|
441
249
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Move series `index`'s slot share to `target` with its legend entry, the group
|
|
252
|
+
* reflowing. Its columns are drawn for every frame the move covers, and switch
|
|
253
|
+
* off only once a hide has landed, so arriving at a time backwards shows what
|
|
254
|
+
* arriving forwards does.
|
|
255
|
+
*/
|
|
256
|
+
function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: boolean[] }, children: readonly ChildView[], index: number, target: number) {
|
|
257
|
+
const parts = partsOf(children)
|
|
258
|
+
if (!parts || !parts.bars[index]) return timeline.wait(timeline.duration)
|
|
259
|
+
// Read through `weightAt`, so a chart whose stored weights are short still
|
|
260
|
+
// tweens from the 1 it is drawing at.
|
|
261
|
+
const from = state.series.map((_, i) => weightAt(state, i))
|
|
262
|
+
const start = from[index] ?? 1
|
|
263
|
+
const showing = state.series.map((s, i) => state.showing[i] ?? (s.enabled ?? true))
|
|
264
|
+
return timeline.together([
|
|
265
|
+
timeline.animate((t) => {
|
|
266
|
+
const weights = [...from]
|
|
267
|
+
weights[index] = start + (target - start) * timeline.ease(t)
|
|
268
|
+
const shown = [...showing]
|
|
269
|
+
shown[index] = target > 0 || t < 1
|
|
270
|
+
return { weights, showing: shown }
|
|
271
|
+
}, { eased: false }),
|
|
272
|
+
...(parts.legend ? [parts.legend.run(target > 0 ? "showSeries" : "hideSeries", { index })] : []),
|
|
273
|
+
])
|
|
274
|
+
}
|
|
457
275
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
276
|
+
/**
|
|
277
|
+
* A grouped or stacked bar chart, the sibling of the line chart and built the
|
|
278
|
+
* same way, out of several of the same pieces:
|
|
279
|
+
*
|
|
280
|
+
* - `ChartCanvas` — the plot panel with a horizontal grid, under everything.
|
|
281
|
+
* - `VerticalAxis` — the numeric value markers, staggering in bottom-to-top.
|
|
282
|
+
* - {@link CategoryAxis} — the row of names beneath the baseline, one per row.
|
|
283
|
+
* - {@link ChartBars} (one per series) — that series' column in every category,
|
|
284
|
+
* plus the figures printed on them.
|
|
285
|
+
* - `ChartLegend` — a row of entries below the chart, one per series.
|
|
286
|
+
*
|
|
287
|
+
* Every part reads the chart's entries through bindings, and the columns map
|
|
288
|
+
* across the plot panel's own laid-out box, so a column's end lands exactly on
|
|
289
|
+
* its marker. **Zero is always on the scale**, and the columns grow about it, so
|
|
290
|
+
* a negative value draws downward out of the same `growth` signal.
|
|
291
|
+
*
|
|
292
|
+
* **Resting state is the finished chart**; `enter` arms every part on its first
|
|
293
|
+
* frame. `showSeries`/`hideSeries`/`spotlightSeries` pick out a *series* (hiding
|
|
294
|
+
* one reflows the group), `spotlightCategory`/`clearCategory` a *column of the
|
|
295
|
+
* table*. A `to` of the value axis's `range` or `ticks` tweens from the domain
|
|
296
|
+
* as drawn, so an auto axis rescales the columns continuously.
|
|
297
|
+
*/
|
|
298
|
+
export const BarChart = component2d({
|
|
299
|
+
key: "barChart",
|
|
300
|
+
state: {
|
|
301
|
+
data: fresh<DataRecord[]>(() => []),
|
|
302
|
+
categoryField: "",
|
|
303
|
+
series: fresh<BarSeries[]>(() => []),
|
|
304
|
+
stacked: false,
|
|
305
|
+
valueAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
|
|
306
|
+
categoryAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisDomain }),
|
|
307
|
+
theme: Types.custom<BarChartTheme, BarChartConfig>(DEFAULT_BAR_CHART_THEME, { resolve: resolveBarChartTheme, lerp: lerpBarChartTheme }),
|
|
308
|
+
/**
|
|
309
|
+
* Global rise of every column in `[0, 1]`, measured about the baseline: 0
|
|
310
|
+
* collapses them all onto it, 1 draws them at full length.
|
|
311
|
+
*/
|
|
312
|
+
growth: 1,
|
|
313
|
+
/**
|
|
314
|
+
* How much of the slot each series claims, one entry per series: a weight
|
|
315
|
+
* rather than a switch, so hiding one is the others widening into the space.
|
|
316
|
+
* Seeded from each series' `enabled`.
|
|
317
|
+
*/
|
|
318
|
+
weights: fresh<number[]>(() => []),
|
|
319
|
+
/** The category spotlit, or `null` for none. The choice snaps; `spotlightReveal` is the animation. */
|
|
320
|
+
spotlight: Types.custom<number | null>(null),
|
|
321
|
+
/** How far the category spotlight is drawn in, `[0, 1]`. */
|
|
322
|
+
spotlightReveal: 0,
|
|
323
|
+
/** Read as the chart is built: the legend is a part the chart builds or doesn't. */
|
|
324
|
+
showLegend: Types.boolean(true, { animatable: false }),
|
|
325
|
+
/** Read as the chart is built: whether each column prints the figure it plots. */
|
|
326
|
+
showValues: Types.boolean(false, { animatable: false }),
|
|
327
|
+
/** Whether each series' columns are drawn: on for every frame a show or hide covers, off once a hide lands. */
|
|
328
|
+
showing: Types.custom<boolean[]>([], { private: true }),
|
|
329
|
+
},
|
|
330
|
+
defaults: {
|
|
331
|
+
// Its parts fill it; a node made of them would hug them.
|
|
332
|
+
width: "fill",
|
|
333
|
+
height: "fill",
|
|
334
|
+
weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
|
|
335
|
+
showing: (state) => state.series.map((s) => s.enabled ?? true),
|
|
336
|
+
},
|
|
337
|
+
children: (state) => {
|
|
338
|
+
const theme = () => state.theme
|
|
339
|
+
const domain = () => barChartDomain(state)
|
|
340
|
+
let plot: Node2D | undefined
|
|
341
|
+
// Before the panel is built or laid out it has no size, and the columns map onto nothing.
|
|
342
|
+
const plotSize = () => laidOutSize(plot)
|
|
343
|
+
const captioned = state.showValues === true
|
|
344
|
+
|
|
345
|
+
const vAxis = new VerticalAxis({
|
|
346
|
+
range: () => domain().range,
|
|
347
|
+
markers: () => domain().ticks,
|
|
348
|
+
captions: () => {
|
|
349
|
+
const d = domain()
|
|
350
|
+
return d.ticks.map((value) => valueLabel(state, d, value))
|
|
351
|
+
},
|
|
352
|
+
axis: () => theme().yAxis,
|
|
353
|
+
label: state.valueAxis.label ?? "",
|
|
463
354
|
width: "hug",
|
|
464
355
|
height: "fill",
|
|
465
356
|
})
|
|
466
|
-
this.vAxis.labelFor = (value) => this.valueLabel(value)
|
|
467
357
|
|
|
468
|
-
|
|
469
|
-
categories: barCategories(
|
|
470
|
-
axis: () =>
|
|
471
|
-
label:
|
|
472
|
-
dims: () =>
|
|
358
|
+
const cAxis = new CategoryAxis({
|
|
359
|
+
categories: barCategories(state.data, state.categoryField),
|
|
360
|
+
axis: () => theme().xAxis,
|
|
361
|
+
label: state.categoryAxis.label ?? "",
|
|
362
|
+
dims: () => categoryDims(state),
|
|
473
363
|
width: "fill",
|
|
474
364
|
height: "hug",
|
|
475
|
-
})
|
|
365
|
+
}) as unknown as Node2D
|
|
476
366
|
|
|
477
|
-
|
|
367
|
+
const barSets = state.series.map(
|
|
478
368
|
(s, i) =>
|
|
479
369
|
new ChartBars({
|
|
480
|
-
bars: () =>
|
|
481
|
-
baseline: () =>
|
|
482
|
-
fill: () => resolveBarFill(
|
|
483
|
-
barStroke: () =>
|
|
484
|
-
barShadow: () =>
|
|
485
|
-
cornerRadius: () =>
|
|
486
|
-
growth: () =>
|
|
487
|
-
dims: () =>
|
|
488
|
-
showValues,
|
|
489
|
-
valueStyle:
|
|
490
|
-
enabled:
|
|
491
|
-
})
|
|
370
|
+
bars: () => seriesBars(state, i, plotSize(), captioned),
|
|
371
|
+
baseline: () => baselinePx(domain(), plotSize()),
|
|
372
|
+
fill: () => resolveBarFill(theme().seriesFills, s, i),
|
|
373
|
+
barStroke: () => theme().bar.stroke,
|
|
374
|
+
barShadow: () => theme().bar.shadow,
|
|
375
|
+
cornerRadius: () => theme().bar.cornerRadius,
|
|
376
|
+
growth: () => state.growth,
|
|
377
|
+
dims: () => categoryDims(state),
|
|
378
|
+
showValues: captioned,
|
|
379
|
+
valueStyle: state.theme.valueStyle,
|
|
380
|
+
enabled: () => state.showing[i] ?? (s.enabled ?? true),
|
|
381
|
+
}) as unknown as Node2D
|
|
492
382
|
)
|
|
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
383
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
),
|
|
504
|
-
|
|
505
|
-
|
|
384
|
+
const below: Node2D[] = [cAxis]
|
|
385
|
+
if (state.showLegend !== false) {
|
|
386
|
+
below.push(
|
|
387
|
+
new ChartLegend({
|
|
388
|
+
series: state.series,
|
|
389
|
+
swatches: () => state.series.map((s, i) => resolveBarFill(theme().seriesFills, s, i)),
|
|
390
|
+
legend: () => theme().legend,
|
|
391
|
+
// The row's spacing is the theme's as the chart was built, like the
|
|
392
|
+
// entries' type and chips: a theme tween re-paints the swatches only.
|
|
393
|
+
gap: state.theme.legend.itemGap,
|
|
394
|
+
padding: state.theme.legend.padding,
|
|
395
|
+
}) as unknown as Node2D
|
|
396
|
+
)
|
|
506
397
|
}
|
|
507
398
|
|
|
508
|
-
|
|
399
|
+
plot = new Rect({
|
|
509
400
|
clip: true,
|
|
510
401
|
children: [
|
|
511
402
|
new ChartCanvas({
|
|
512
|
-
fill: () =>
|
|
513
|
-
shadow: () =>
|
|
514
|
-
xGridStroke: () =>
|
|
515
|
-
yGridStroke: () =>
|
|
516
|
-
// No vertical rules: the horizontal axis is categorical, so there is
|
|
517
|
-
//
|
|
518
|
-
// between slots does that job, and better.
|
|
403
|
+
fill: () => theme().plotArea.fill,
|
|
404
|
+
shadow: () => theme().plotArea.shadow,
|
|
405
|
+
xGridStroke: () => theme().xAxis.lineStyle,
|
|
406
|
+
yGridStroke: () => theme().yAxis.lineStyle,
|
|
407
|
+
// No vertical rules: the horizontal axis is categorical, so there is no
|
|
408
|
+
// numeric position along it for a grid line to mark.
|
|
519
409
|
xMarkers: [],
|
|
520
|
-
yMarkers: () =>
|
|
410
|
+
yMarkers: () => domain().ticks,
|
|
521
411
|
xExtent: () => [0, 1] as Range,
|
|
522
|
-
yExtent: () =>
|
|
523
|
-
}),
|
|
524
|
-
...this.barSets,
|
|
525
|
-
new ChartBorder({
|
|
526
|
-
stroke: () => this.resolvedTheme.plotArea.stroke,
|
|
412
|
+
yExtent: () => domain().range,
|
|
527
413
|
}),
|
|
414
|
+
...barSets,
|
|
415
|
+
new ChartBorder({ stroke: () => theme().plotArea.stroke }),
|
|
528
416
|
],
|
|
529
|
-
})
|
|
417
|
+
}) as unknown as Node2D
|
|
530
418
|
|
|
531
|
-
|
|
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({
|
|
419
|
+
const gutter = new VerticalAxis({
|
|
535
420
|
opacity: 0,
|
|
536
|
-
range: () =>
|
|
537
|
-
markers: () =>
|
|
538
|
-
axis: () =>
|
|
539
|
-
label:
|
|
421
|
+
range: () => domain().range,
|
|
422
|
+
markers: () => domain().ticks,
|
|
423
|
+
axis: () => theme().yAxis,
|
|
424
|
+
label: state.valueAxis.label ?? "",
|
|
540
425
|
width: "hug",
|
|
541
426
|
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
|
-
}
|
|
427
|
+
}) as unknown as Node2D
|
|
718
428
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
429
|
+
return asParts([
|
|
430
|
+
...(state.children as unknown as Node2D[]),
|
|
431
|
+
cartesianFrame({ axis: vAxis as unknown as Node2D, plot, gutter, below }),
|
|
432
|
+
])
|
|
433
|
+
},
|
|
434
|
+
commands: {
|
|
435
|
+
/**
|
|
436
|
+
* Animate the whole chart in: the two axes stagger their markers and
|
|
437
|
+
* captions in, the columns rise from the baseline, and the legend fades up
|
|
438
|
+
* last. Its first frame arms every part: markers and captions shifted and
|
|
439
|
+
* transparent, columns collapsed, the legend dropped and transparent.
|
|
440
|
+
* `duration` is the **whole** entrance; the beats are shares of it.
|
|
441
|
+
*/
|
|
442
|
+
enter: {
|
|
443
|
+
args: {},
|
|
444
|
+
easing: easeOut(),
|
|
445
|
+
run: (timeline, _state, children) => {
|
|
446
|
+
const parts = partsOf(children)
|
|
447
|
+
if (!parts) return timeline.wait(timeline.duration)
|
|
448
|
+
const duration = timeline.duration
|
|
449
|
+
const axes = duration * AXES_SHARE
|
|
450
|
+
const rise = duration * BARS_SHARE
|
|
451
|
+
const { legend } = parts
|
|
452
|
+
const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
|
|
453
|
+
// Without a legend the beat is still its share of the whole.
|
|
454
|
+
return beats(timeline, [
|
|
455
|
+
at0(timeline.animate(() => ({ growth: 0 }))),
|
|
456
|
+
...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
|
|
457
|
+
{ at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
|
|
458
|
+
{ at: 0, duration: axes, piece: parts.cAxis.run("enter", { slide: 24 }) },
|
|
459
|
+
// No priming to 0 first: the value at `t` is a function of `t`.
|
|
460
|
+
{ at: axes, duration: rise, piece: timeline.animate((t) => ({ growth: t })) },
|
|
461
|
+
...(legend ? [{ at: axes + rise, duration: duration * LEGEND_SHARE, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
|
|
462
|
+
])
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
/** Show series `index`: its columns come back, the group reflowing to make room, with its legend entry. */
|
|
466
|
+
showSeries: {
|
|
467
|
+
args: { seriesIndex: SERIES_ARG },
|
|
468
|
+
easing: easeOut(),
|
|
469
|
+
run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 1),
|
|
470
|
+
},
|
|
471
|
+
/**
|
|
472
|
+
* Hide series `index`: its columns narrow away (or, stacked, sink out of the
|
|
473
|
+
* stack) while the rest of the group widens into the space — the mirror of
|
|
474
|
+
* `showSeries`.
|
|
475
|
+
*/
|
|
476
|
+
hideSeries: {
|
|
477
|
+
args: { seriesIndex: SERIES_ARG },
|
|
478
|
+
easing: easeOut(),
|
|
479
|
+
run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 0),
|
|
480
|
+
},
|
|
481
|
+
/**
|
|
482
|
+
* Fade every other series — its columns **and its legend entry** — to focus
|
|
483
|
+
* on `index`, which comes back to full. Nothing moves, so the group doesn't
|
|
484
|
+
* reflow.
|
|
485
|
+
*/
|
|
486
|
+
spotlightSeries: {
|
|
487
|
+
args: { seriesIndex: SERIES_ARG },
|
|
488
|
+
easing: easeOut(),
|
|
489
|
+
run: (timeline, state, children) => {
|
|
490
|
+
const parts = partsOf(children)
|
|
491
|
+
if (!parts) return timeline.wait(timeline.duration)
|
|
492
|
+
const index = timeline.args.seriesIndex
|
|
493
|
+
const dim = state.theme.spotlight.dimOpacity
|
|
494
|
+
return timeline.together([
|
|
495
|
+
...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : dim })),
|
|
496
|
+
...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
|
|
497
|
+
])
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
/**
|
|
501
|
+
* Spotlight one **category**: every other category's columns and caption
|
|
502
|
+
* fade to the theme's `dimOpacity`. Called while another is lit, the focus
|
|
503
|
+
* moves directly: only the dimming changes, so nothing retracts first. A
|
|
504
|
+
* category that is not there takes its slot and changes nothing.
|
|
505
|
+
*/
|
|
506
|
+
spotlightCategory: {
|
|
507
|
+
args: { categoryIndex: Types.number(0, { min: 0, step: 1 }) },
|
|
508
|
+
easing: easeOut(),
|
|
509
|
+
run: (timeline, state) => {
|
|
510
|
+
const index = timeline.args.categoryIndex
|
|
511
|
+
if (index < 0 || index >= state.data.length) return timeline.wait(timeline.duration)
|
|
512
|
+
const from = state.spotlightReveal
|
|
513
|
+
return timeline.animate((t) => ({ spotlight: index, spotlightReveal: from + (1 - from) * t }))
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
/**
|
|
517
|
+
* Drop the category spotlight, every category back to full. The lit
|
|
518
|
+
* category is held until the fade lands; a no-op when nothing is lit, which
|
|
519
|
+
* still takes its slot.
|
|
520
|
+
*/
|
|
521
|
+
clearCategory: {
|
|
522
|
+
args: {},
|
|
523
|
+
easing: easeOut(),
|
|
524
|
+
run: (timeline, state) => {
|
|
525
|
+
const from = state.spotlightReveal
|
|
526
|
+
if (from === 0) return timeline.wait(timeline.duration)
|
|
527
|
+
const lit = state.spotlight
|
|
528
|
+
return timeline.animate((t) => (t >= 1 ? { spotlight: null, spotlightReveal: 0 } : { spotlight: lit, spotlightReveal: from * (1 - t) }))
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
})
|
|
815
533
|
|
|
816
|
-
|
|
817
|
-
private holdSpotlight(duration: number): Command<CategorySpotlight> {
|
|
818
|
-
return this.command<CategorySpotlight>(() => ({}), duration)
|
|
819
|
-
}
|
|
820
|
-
}
|
|
534
|
+
export type BarChart = NodeOf<typeof BarChart>
|