@motionscript/charts 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +201 -0
- package/dist/bar-chart/bar-chart.d.ts +286 -0
- package/dist/bar-chart/bar-chart.d.ts.map +1 -0
- package/dist/bar-chart/bar-chart.js +610 -0
- package/dist/bar-chart/bar-chart.js.map +1 -0
- package/dist/bar-chart/category-axis.d.ts +77 -0
- package/dist/bar-chart/category-axis.d.ts.map +1 -0
- package/dist/bar-chart/category-axis.js +141 -0
- package/dist/bar-chart/category-axis.js.map +1 -0
- package/dist/bar-chart/chart-bars.d.ts +149 -0
- package/dist/bar-chart/chart-bars.d.ts.map +1 -0
- package/dist/bar-chart/chart-bars.js +236 -0
- package/dist/bar-chart/chart-bars.js.map +1 -0
- package/dist/bar-chart/index.d.ts +16 -0
- package/dist/bar-chart/index.d.ts.map +1 -0
- package/dist/bar-chart/index.js +12 -0
- package/dist/bar-chart/index.js.map +1 -0
- package/dist/bar-chart/shared.d.ts +146 -0
- package/dist/bar-chart/shared.d.ts.map +1 -0
- package/dist/bar-chart/shared.js +182 -0
- package/dist/bar-chart/shared.js.map +1 -0
- package/dist/browser/chunks/chunk-ZMBOBHYL.js +2 -0
- package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +7 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/kit.js +2 -0
- package/dist/browser/kit.js.map +7 -0
- package/dist/browser/manifest.json +12 -0
- package/dist/engine.d.ts +27 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +27 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/chart-border.d.ts +28 -0
- package/dist/kit/chart-border.d.ts.map +1 -0
- package/dist/kit/chart-border.js +45 -0
- package/dist/kit/chart-border.js.map +1 -0
- package/dist/kit/chart-canvas.d.ts +56 -0
- package/dist/kit/chart-canvas.d.ts.map +1 -0
- package/dist/kit/chart-canvas.js +125 -0
- package/dist/kit/chart-canvas.js.map +1 -0
- package/dist/kit/chart-legend.d.ts +126 -0
- package/dist/kit/chart-legend.d.ts.map +1 -0
- package/dist/kit/chart-legend.js +212 -0
- package/dist/kit/chart-legend.js.map +1 -0
- package/dist/kit/shared.d.ts +251 -0
- package/dist/kit/shared.d.ts.map +1 -0
- package/dist/kit/shared.js +401 -0
- package/dist/kit/shared.js.map +1 -0
- package/dist/kit/vertical-axis.d.ts +99 -0
- package/dist/kit/vertical-axis.d.ts.map +1 -0
- package/dist/kit/vertical-axis.js +176 -0
- package/dist/kit/vertical-axis.js.map +1 -0
- package/dist/line-chart/chart-line.d.ts +91 -0
- package/dist/line-chart/chart-line.d.ts.map +1 -0
- package/dist/line-chart/chart-line.js +156 -0
- package/dist/line-chart/chart-line.js.map +1 -0
- package/dist/line-chart/chart-region.d.ts +47 -0
- package/dist/line-chart/chart-region.d.ts.map +1 -0
- package/dist/line-chart/chart-region.js +101 -0
- package/dist/line-chart/chart-region.js.map +1 -0
- package/dist/line-chart/horizontal-axis.d.ts +85 -0
- package/dist/line-chart/horizontal-axis.d.ts.map +1 -0
- package/dist/line-chart/horizontal-axis.js +158 -0
- package/dist/line-chart/horizontal-axis.js.map +1 -0
- package/dist/line-chart/index.d.ts +22 -0
- package/dist/line-chart/index.d.ts.map +1 -0
- package/dist/line-chart/index.js +19 -0
- package/dist/line-chart/index.js.map +1 -0
- package/dist/line-chart/line-chart.d.ts +347 -0
- package/dist/line-chart/line-chart.d.ts.map +1 -0
- package/dist/line-chart/line-chart.js +701 -0
- package/dist/line-chart/line-chart.js.map +1 -0
- package/dist/line-chart/shared.d.ts +171 -0
- package/dist/line-chart/shared.d.ts.map +1 -0
- package/dist/line-chart/shared.js +270 -0
- package/dist/line-chart/shared.js.map +1 -0
- package/dist/line-chart/x-scale.d.ts +84 -0
- package/dist/line-chart/x-scale.d.ts.map +1 -0
- package/dist/line-chart/x-scale.js +302 -0
- package/dist/line-chart/x-scale.js.map +1 -0
- package/dist/nodes.d.ts +20 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +20 -0
- package/dist/nodes.js.map +1 -0
- package/dist/pie-chart/index.d.ts +18 -0
- package/dist/pie-chart/index.d.ts.map +1 -0
- package/dist/pie-chart/index.js +14 -0
- package/dist/pie-chart/index.js.map +1 -0
- package/dist/pie-chart/pie-chart.d.ts +170 -0
- package/dist/pie-chart/pie-chart.d.ts.map +1 -0
- package/dist/pie-chart/pie-chart.js +291 -0
- package/dist/pie-chart/pie-chart.js.map +1 -0
- package/dist/pie-chart/pie-legend.d.ts +60 -0
- package/dist/pie-chart/pie-legend.d.ts.map +1 -0
- package/dist/pie-chart/pie-legend.js +131 -0
- package/dist/pie-chart/pie-legend.js.map +1 -0
- package/dist/pie-chart/pie-ring.d.ts +266 -0
- package/dist/pie-chart/pie-ring.d.ts.map +1 -0
- package/dist/pie-chart/pie-ring.js +708 -0
- package/dist/pie-chart/pie-ring.js.map +1 -0
- package/dist/pie-chart/shared.d.ts +210 -0
- package/dist/pie-chart/shared.d.ts.map +1 -0
- package/dist/pie-chart/shared.js +240 -0
- package/dist/pie-chart/shared.js.map +1 -0
- package/package.json +69 -3
- package/registry.json +23 -0
- package/src/bar-chart/bar-chart.ts +804 -0
- package/src/bar-chart/category-axis.ts +177 -0
- package/src/bar-chart/chart-bars.ts +310 -0
- package/src/bar-chart/index.ts +31 -0
- package/src/bar-chart/shared.ts +354 -0
- package/src/engine.ts +26 -0
- package/src/index.ts +5 -0
- package/src/kit/chart-border.ts +61 -0
- package/src/kit/chart-canvas.ts +157 -0
- package/src/kit/chart-legend.ts +250 -0
- package/src/kit/shared.ts +675 -0
- package/src/kit/vertical-axis.ts +224 -0
- package/src/line-chart/chart-line.ts +208 -0
- package/src/line-chart/chart-region.ts +135 -0
- package/src/line-chart/horizontal-axis.ts +202 -0
- package/src/line-chart/index.ts +39 -0
- package/src/line-chart/line-chart.ts +894 -0
- package/src/line-chart/shared.ts +459 -0
- package/src/line-chart/x-scale.ts +367 -0
- package/src/nodes.ts +20 -0
- package/src/pie-chart/index.ts +37 -0
- package/src/pie-chart/pie-chart.ts +374 -0
- package/src/pie-chart/pie-legend.ts +159 -0
- package/src/pie-chart/pie-ring.ts +903 -0
- package/src/pie-chart/shared.ts +490 -0
- package/README.md +0 -4
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Rect,
|
|
3
|
+
command,
|
|
4
|
+
node,
|
|
5
|
+
Node2D,
|
|
6
|
+
easeOut,
|
|
7
|
+
property,
|
|
8
|
+
type CommandArgs,
|
|
9
|
+
type DataRecord,
|
|
10
|
+
type EasingFunction,
|
|
11
|
+
type NodeConfig,
|
|
12
|
+
type Node2DProps,
|
|
13
|
+
} from "@motionscript/core"
|
|
14
|
+
|
|
15
|
+
import { clampUnit } from "../kit/shared"
|
|
16
|
+
import { PieLegend } from "./pie-legend"
|
|
17
|
+
import { PieRing } from "./pie-ring"
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_PIE_CHART_THEME,
|
|
20
|
+
lerpPieChartTheme,
|
|
21
|
+
resolvePieChartTheme,
|
|
22
|
+
resolvePieSlices,
|
|
23
|
+
type PieChartConfig,
|
|
24
|
+
type PieChartTheme,
|
|
25
|
+
type PieLabelMode,
|
|
26
|
+
type PieLabelPlacement,
|
|
27
|
+
type PieSlice,
|
|
28
|
+
} from "./shared"
|
|
29
|
+
import {
|
|
30
|
+
drive,
|
|
31
|
+
hold,
|
|
32
|
+
sequence as sequenceOf,
|
|
33
|
+
together,
|
|
34
|
+
type Seekable,
|
|
35
|
+
} from "@motionscript/core/component"
|
|
36
|
+
|
|
37
|
+
export interface PieChartProps extends Node2DProps {
|
|
38
|
+
/** One row per slice — a plain record of field name to value. */
|
|
39
|
+
data: DataRecord[]
|
|
40
|
+
/** Key into each row for its slice caption, used on the labels and the legend. */
|
|
41
|
+
categoryField: string
|
|
42
|
+
/** Key into each row for its slice magnitude — the wedge's share of the ring. */
|
|
43
|
+
valueField: string
|
|
44
|
+
/**
|
|
45
|
+
* The chart's visual theme — slice palette, ring geometry, label and centre
|
|
46
|
+
* type, legend. Every branch is optional and deep-merged onto
|
|
47
|
+
* {@link DEFAULT_PIE_CHART_THEME}.
|
|
48
|
+
*/
|
|
49
|
+
theme?: PieChartTheme
|
|
50
|
+
/** What the ring's labels say. */
|
|
51
|
+
labelMode?: PieLabelMode
|
|
52
|
+
/** Where they sit: inside their wedge, outside on a leader, or nowhere. */
|
|
53
|
+
labelPlacement?: PieLabelPlacement
|
|
54
|
+
/** Whether the legend column beside the ring is drawn at all. */
|
|
55
|
+
showLegend?: boolean
|
|
56
|
+
/** Gap between the ring and the legend, in px. */
|
|
57
|
+
gap?: number
|
|
58
|
+
/** Text set in the ring's hole — see {@link revealCenter}. */
|
|
59
|
+
centerLabel?: string
|
|
60
|
+
/** Smaller second line under {@link centerLabel}. */
|
|
61
|
+
centerCaption?: string
|
|
62
|
+
/**
|
|
63
|
+
* Whether the hole's summary is drawn at rest. `true` (the default) is the
|
|
64
|
+
* plain answer; a scene that wants the conclusion to land on a later beat sets
|
|
65
|
+
* this `false` and calls {@link revealCenter} when it gets there.
|
|
66
|
+
*/
|
|
67
|
+
showCenter?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How {@link PieChart.enter} divides its duration: the wedges sweeping in, then
|
|
72
|
+
* the labels and the legend arriving together. Shares of the whole, so they sum
|
|
73
|
+
* to 1 — the entrance takes exactly the time it was given, for the reason spelt
|
|
74
|
+
* out on `LineChart.enter`.
|
|
75
|
+
*/
|
|
76
|
+
const SWEEP_SHARE = 0.65
|
|
77
|
+
const LABEL_SHARE = 0.35
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A pie or donut chart, composed from two independent parts rather than one
|
|
81
|
+
* monolithic draw — the third of the chart family, built the same way as the
|
|
82
|
+
* line and bar charts beside it:
|
|
83
|
+
*
|
|
84
|
+
* - {@link PieRing} — the wedges, each sized by its share of the total, with its
|
|
85
|
+
* label set inside its own band or pinned outside on a leader line, and the
|
|
86
|
+
* summary in the hole. The only part of the family that draws a path.
|
|
87
|
+
* - {@link PieLegend} — a column of swatch-and-caption rows naming each slice.
|
|
88
|
+
*
|
|
89
|
+
* The two are laid out by a plain horizontal-flow {@link Rect}: the ring fills whatever the
|
|
90
|
+
* hugging legend leaves, both centred, so the chart re-flows if its box changes.
|
|
91
|
+
*
|
|
92
|
+
* **One mark per row, not per column** — which is what makes this the odd one of
|
|
93
|
+
* the three. A line or a bar chart plots *series*, so its colours belong to
|
|
94
|
+
* columns and hiding one is a sensible thing to ask for. A pie plots rows, so its
|
|
95
|
+
* palette is indexed by row and there is nothing to hide: dropping a slice would
|
|
96
|
+
* silently re-cut every other wedge's share, which is a different chart rather
|
|
97
|
+
* than the same chart with a series switched off. {@link selectSlice} and its
|
|
98
|
+
* siblings are what a pie has instead — a slice swells out of the ring, its hole
|
|
99
|
+
* edge staying put, and everything else dims behind it.
|
|
100
|
+
*
|
|
101
|
+
* **Resting state is the finished chart** — the same bargain the other two make:
|
|
102
|
+
* the studio paints a node's Initial state on a paused canvas, so a chart that
|
|
103
|
+
* only appeared once a command had run would be edited blind. {@link enter} arms
|
|
104
|
+
* every part back to its pre-entrance state before animating. The one exception
|
|
105
|
+
* is the hole's summary, which a scene can hold back with `showCenter: false`
|
|
106
|
+
* because it is usually the chart's *conclusion* rather than part of its setup.
|
|
107
|
+
*/
|
|
108
|
+
@node({
|
|
109
|
+
key: "pieChart",
|
|
110
|
+
parentKey: "node",
|
|
111
|
+
forkable: true,
|
|
112
|
+
layout: {
|
|
113
|
+
children: "freeform",
|
|
114
|
+
defaultWidthMode: "fixed",
|
|
115
|
+
defaultHeightMode: "fixed",
|
|
116
|
+
acceptsChildren: true,
|
|
117
|
+
},
|
|
118
|
+
seed: {
|
|
119
|
+
width: 680,
|
|
120
|
+
height: 480,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
export class PieChart extends Node2D<PieChartProps> {
|
|
124
|
+
@property({ default: () => [] as DataRecord[] }) declare data: DataRecord[]
|
|
125
|
+
@property({ default: "" }) declare categoryField: string
|
|
126
|
+
@property({ default: "" }) declare valueField: string
|
|
127
|
+
@property({
|
|
128
|
+
default: DEFAULT_PIE_CHART_THEME,
|
|
129
|
+
mapper: resolvePieChartTheme,
|
|
130
|
+
tween: lerpPieChartTheme,
|
|
131
|
+
})
|
|
132
|
+
declare theme: PieChartTheme
|
|
133
|
+
@property({ default: "percent" }) declare labelMode: PieLabelMode
|
|
134
|
+
@property({ default: "leader" }) declare labelPlacement: PieLabelPlacement
|
|
135
|
+
@property({ default: 40 }) declare gap: number
|
|
136
|
+
@property({ default: "" }) declare centerLabel: string
|
|
137
|
+
@property({ default: "" }) declare centerCaption: string
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How far each slice is currently pushed out of the ring, `0`–`1` per index.
|
|
141
|
+
*
|
|
142
|
+
* Held here rather than on the ring because two parts read it: the wedges grow
|
|
143
|
+
* out by it, and the legend entries dim by it. One signal, one tween, and the
|
|
144
|
+
* two can't drift.
|
|
145
|
+
*/
|
|
146
|
+
@property({ default: () => [] as number[] }) declare pops: number[]
|
|
147
|
+
|
|
148
|
+
// ---- Part handles (built in the constructor) --------------------------
|
|
149
|
+
private ring: PieRing
|
|
150
|
+
private legend: PieLegend | null = null
|
|
151
|
+
|
|
152
|
+
/** `this.theme` read back through its *resolved* shape — see `LineChart.theme`. */
|
|
153
|
+
private get resolvedTheme(): PieChartConfig {
|
|
154
|
+
return this.theme as unknown as PieChartConfig
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The slices the ring and the legend draw, resolved from the rows and the palette. */
|
|
158
|
+
private get slices(): PieSlice[] {
|
|
159
|
+
return resolvePieSlices(
|
|
160
|
+
this.data,
|
|
161
|
+
this.categoryField,
|
|
162
|
+
this.valueField,
|
|
163
|
+
this.resolvedTheme.sliceFills
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How far each slice is dimmed by the current selection, one entry per slice —
|
|
169
|
+
* 1 while nothing is selected, and for the selected slice itself; falling
|
|
170
|
+
* toward the theme's `dimOpacity` for the rest in proportion to how far the
|
|
171
|
+
* selection has grown.
|
|
172
|
+
*/
|
|
173
|
+
private sliceDims(): number[] {
|
|
174
|
+
let focus = 0
|
|
175
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
176
|
+
focus = Math.max(focus, this.popAt(i))
|
|
177
|
+
}
|
|
178
|
+
const dim = this.resolvedTheme.spotlight.dimOpacity
|
|
179
|
+
if (focus <= 0) return this.data.map(() => 1)
|
|
180
|
+
return this.data.map(
|
|
181
|
+
(_, i) => 1 - (1 - clampUnit(dim)) * focus * (1 - this.popAt(i))
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How far slice `index` is currently pushed out, in `[0, 1]`. */
|
|
186
|
+
private popAt(index: number): number {
|
|
187
|
+
return clampUnit(this.pops[index] ?? 0)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
constructor(props?: NodeConfig<PieChart, PieChartProps>) {
|
|
191
|
+
super(props)
|
|
192
|
+
|
|
193
|
+
// The whole chart is composed from plain props/`data` — no context needed,
|
|
194
|
+
// so the parts are built here (super() applied the props above). The slice
|
|
195
|
+
// *structure* (one wedge and one legend row each) is read once, like every
|
|
196
|
+
// other chart's series list; everything the parts draw with is passed as a
|
|
197
|
+
// closure, so `chart.to({ theme })` genuinely re-paints the ring.
|
|
198
|
+
const slices = this.slices
|
|
199
|
+
const showLegend = props?.showLegend !== false
|
|
200
|
+
this.pops = slices.map(() => 0)
|
|
201
|
+
|
|
202
|
+
this.ring = new PieRing({
|
|
203
|
+
width: "fill",
|
|
204
|
+
height: "fill",
|
|
205
|
+
slices,
|
|
206
|
+
ring: () => this.resolvedTheme.ring,
|
|
207
|
+
label: () => this.resolvedTheme.label,
|
|
208
|
+
centerStyle: () => this.resolvedTheme.center,
|
|
209
|
+
labelMode: this.labelMode,
|
|
210
|
+
labelPlacement: this.labelPlacement,
|
|
211
|
+
pops: () => this.pops,
|
|
212
|
+
dimOpacity: () => this.resolvedTheme.spotlight.dimOpacity,
|
|
213
|
+
centerLabel: this.centerLabel,
|
|
214
|
+
centerCaption: this.centerCaption,
|
|
215
|
+
centerReveal: props?.showCenter === false ? 0 : 1,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const children: Node2D[] = [this.ring]
|
|
219
|
+
if (showLegend) {
|
|
220
|
+
this.legend = new PieLegend({
|
|
221
|
+
slices,
|
|
222
|
+
legend: () => this.resolvedTheme.legend,
|
|
223
|
+
dims: () => this.sliceDims(),
|
|
224
|
+
})
|
|
225
|
+
children.push(this.legend)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.add(
|
|
229
|
+
new Rect({ flow: "horizontal",
|
|
230
|
+
width: "fill",
|
|
231
|
+
height: "fill",
|
|
232
|
+
gap: this.gap,
|
|
233
|
+
align: "center",
|
|
234
|
+
children,
|
|
235
|
+
})
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- Orchestrated entrance --------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Prime every part at its pre-entrance state — no wedges swept, no labels, the
|
|
243
|
+
* legend shifted and transparent.
|
|
244
|
+
*
|
|
245
|
+
* Called by {@link enter} before its first frame so nothing flashes ahead of
|
|
246
|
+
* its beat. Idempotent, and public so a scene can hide a chart ahead of time
|
|
247
|
+
* without starting the entrance. The hole's summary is deliberately untouched:
|
|
248
|
+
* it has its own signal, so a scene that set it up to land later keeps that.
|
|
249
|
+
*/
|
|
250
|
+
arm(): void {
|
|
251
|
+
this.ring.set({ growth: 0, labelReveal: 0 })
|
|
252
|
+
this.legend?.arm()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Animate the whole chart in: the wedges sweep in clockwise from the theme's
|
|
257
|
+
* `startAngle`, then the labels fade up and the legend staggers in together.
|
|
258
|
+
*
|
|
259
|
+
* `duration` is the **whole** entrance; the phases are shares of it.
|
|
260
|
+
*/
|
|
261
|
+
@command()
|
|
262
|
+
enter(
|
|
263
|
+
args: CommandArgs<Record<string, never>> & { duration: number }
|
|
264
|
+
): Seekable {
|
|
265
|
+
const duration = args.duration
|
|
266
|
+
const easing = args.easing ?? easeOut()
|
|
267
|
+
this.arm()
|
|
268
|
+
|
|
269
|
+
return sequenceOf(
|
|
270
|
+
this.ring.sweep({ duration: duration * SWEEP_SHARE, easing }),
|
|
271
|
+
together(
|
|
272
|
+
this.ring.revealLabels({ duration: duration * LABEL_SHARE, easing }),
|
|
273
|
+
this.legend
|
|
274
|
+
? this.legend.enter({ duration: duration * LABEL_SHARE, easing })
|
|
275
|
+
: hold(duration * LABEL_SHARE)
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Fade the hole's summary up.
|
|
282
|
+
*
|
|
283
|
+
* Held out of {@link enter} on purpose: what sits in a donut's hole is usually
|
|
284
|
+
* the chart's conclusion, so the scene lands it on the beat it belongs to
|
|
285
|
+
* rather than giving it away with the first frame. Pair it with
|
|
286
|
+
* `showCenter: false`, which is what leaves the hole clear to begin with.
|
|
287
|
+
*/
|
|
288
|
+
@command()
|
|
289
|
+
revealCenter(
|
|
290
|
+
args: CommandArgs<Record<string, never>> & { duration?: number } = {}
|
|
291
|
+
): Seekable {
|
|
292
|
+
return this.ring.revealCenter({ duration: args.duration ?? 0.6, easing: args.easing ?? easeOut() })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- Selection ---------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/** True when slice `index` is currently grown out past the ring's rim. */
|
|
298
|
+
isSelected(index: number): boolean {
|
|
299
|
+
return this.popAt(index) > 0.5
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Grow slice `index` out past the ring's rim and dim the rest of the chart
|
|
304
|
+
* behind it, leaving any other selected slice where it is.
|
|
305
|
+
*/
|
|
306
|
+
@command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
|
|
307
|
+
selectSlice(
|
|
308
|
+
args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
|
|
309
|
+
): Seekable {
|
|
310
|
+
const { sliceIndex: index } = args.data as { sliceIndex: number }
|
|
311
|
+
const duration = args.duration ?? 0.45
|
|
312
|
+
const easing = args.easing ?? easeOut()
|
|
313
|
+
return this.tweenPops((i) => (i === index ? 1 : this.popAt(i)), duration, easing)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Settle slice `index` back flush with the ring. */
|
|
317
|
+
@command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
|
|
318
|
+
deselectSlice(
|
|
319
|
+
args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
|
|
320
|
+
): Seekable {
|
|
321
|
+
const { sliceIndex: index } = args.data as { sliceIndex: number }
|
|
322
|
+
const duration = args.duration ?? 0.45
|
|
323
|
+
const easing = args.easing ?? easeOut()
|
|
324
|
+
return this.tweenPops((i) => (i === index ? 0 : this.popAt(i)), duration, easing)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Move the focus to slice `index` alone, settling whatever else was out. */
|
|
328
|
+
@command({ args: [{ key: "sliceIndex", kind: "number", default: 0, min: 0, step: 1 }] })
|
|
329
|
+
selectOnlySlice(
|
|
330
|
+
args: CommandArgs<{ sliceIndex: number }> & { duration?: number }
|
|
331
|
+
): Seekable {
|
|
332
|
+
const { sliceIndex: index } = args.data as { sliceIndex: number }
|
|
333
|
+
const duration = args.duration ?? 0.45
|
|
334
|
+
const easing = args.easing ?? easeOut()
|
|
335
|
+
return this.tweenPops((i) => (i === index ? 1 : 0), duration, easing)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Settle every slice flush and bring the whole chart back to full. */
|
|
339
|
+
@command()
|
|
340
|
+
clearSelection(
|
|
341
|
+
args: CommandArgs<Record<string, never>> & { duration?: number } = {}
|
|
342
|
+
): Seekable {
|
|
343
|
+
const duration = args.duration ?? 0.45
|
|
344
|
+
const easing = args.easing ?? easeOut()
|
|
345
|
+
return this.tweenPops(() => 0, duration, easing)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Ease every slice's pop from where it is now to what `pop` asks for.
|
|
350
|
+
*
|
|
351
|
+
* Consumes its full duration even when nothing moves: in the studio a
|
|
352
|
+
* command's duration is what the timeline lays out and what its parallel
|
|
353
|
+
* siblings are timed against, so a no-op that took no frames would pull
|
|
354
|
+
* everything after it forward.
|
|
355
|
+
*
|
|
356
|
+
* `from` and the targets are read when the command is *built*, which for a
|
|
357
|
+
* driven scene is with the node in the state its own start time implies — so a
|
|
358
|
+
* select that follows another starts from where that one left the ring.
|
|
359
|
+
*/
|
|
360
|
+
private tweenPops(
|
|
361
|
+
pop: (index: number) => number,
|
|
362
|
+
duration: number,
|
|
363
|
+
easing: EasingFunction
|
|
364
|
+
): Seekable {
|
|
365
|
+
const from = this.data.map((_, i) => this.popAt(i))
|
|
366
|
+
const targets = this.data.map((_, i) => clampUnit(pop(i)))
|
|
367
|
+
if (from.every((value, i) => value === targets[i])) return hold(duration)
|
|
368
|
+
|
|
369
|
+
return drive(duration, (t) => {
|
|
370
|
+
const e = easing(t)
|
|
371
|
+
this.pops = from.map((value, i) => value + (targets[i] - value) * e)
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
command,
|
|
3
|
+
Rect,
|
|
4
|
+
Text,
|
|
5
|
+
easeOut,
|
|
6
|
+
property,
|
|
7
|
+
textOps,
|
|
8
|
+
type CommandArgs,
|
|
9
|
+
type NodeConfig,
|
|
10
|
+
type RectProps,
|
|
11
|
+
} from "@motionscript/core"
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_LEGEND,
|
|
15
|
+
clampUnit,
|
|
16
|
+
type LegendConfig,
|
|
17
|
+
} from "../kit/shared"
|
|
18
|
+
import { at, compose, hold, type Seekable } from "@motionscript/core/component"
|
|
19
|
+
import type { PieSlice } from "./shared"
|
|
20
|
+
|
|
21
|
+
export interface PieLegendProps extends RectProps {
|
|
22
|
+
/** One entry per slice, in ring order. */
|
|
23
|
+
slices: PieSlice[]
|
|
24
|
+
/** Padding, caption style, gaps, and swatch geometry — see {@link LegendConfig}. */
|
|
25
|
+
legend: LegendConfig
|
|
26
|
+
/**
|
|
27
|
+
* Per-slice opacity multiplier, by index — the same signal that dims the ring,
|
|
28
|
+
* so an entry fades in step with the wedge it names. Missing entries read as 1.
|
|
29
|
+
*/
|
|
30
|
+
dims: number[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How far an entry starts to the right of its slot when it enters, in px. */
|
|
34
|
+
const SLIDE = 16
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The legend of a {@link PieChart}: a **vertical-flow column** of entries, one per
|
|
38
|
+
* slice, each pairing the slice's caption with its colour swatch.
|
|
39
|
+
*
|
|
40
|
+
* A column rather than the cartesian charts' row, because it sits *beside* the
|
|
41
|
+
* ring rather than beneath the plot — a pie is as tall as it is wide, so the
|
|
42
|
+
* space it leaves over is a vertical strip. It is otherwise the same entry: the
|
|
43
|
+
* swatch carries the colour, the caption reads in the legend's neutral ink, and
|
|
44
|
+
* both take the shared {@link LegendConfig}.
|
|
45
|
+
*
|
|
46
|
+
* There is no show/hide here. A pie's marks are its *rows*, and a row that
|
|
47
|
+
* shouldn't be in the chart shouldn't be in the table — removing one would
|
|
48
|
+
* silently re-cut every other wedge's share, which is a different chart rather
|
|
49
|
+
* than the same chart with something hidden. Selection is what a pie has
|
|
50
|
+
* instead, and it arrives through {@link dims}.
|
|
51
|
+
*/
|
|
52
|
+
export class PieLegend extends Rect {
|
|
53
|
+
@property({ default: () => [] as PieSlice[] }) declare slices: PieSlice[]
|
|
54
|
+
@property({ default: DEFAULT_LEGEND }) declare legend: LegendConfig
|
|
55
|
+
@property({ default: () => [] as number[] }) declare dims: number[]
|
|
56
|
+
|
|
57
|
+
/** One entry per slice, ordered top → bottom, for the staggered entrance. */
|
|
58
|
+
private items: Rect[] = []
|
|
59
|
+
|
|
60
|
+
constructor(props?: NodeConfig<PieLegend, PieLegendProps>) {
|
|
61
|
+
// `flow` with the rest of the fixed geometry, and for the reason
|
|
62
|
+
// {@link ChartLegend} spells out: a `Rect` given none lays its children out
|
|
63
|
+
// `freeform`, so an unstated column is every caption drawn on top of the
|
|
64
|
+
// last one.
|
|
65
|
+
super({
|
|
66
|
+
...(props as RectProps),
|
|
67
|
+
flow: "vertical",
|
|
68
|
+
align: "centerLeft",
|
|
69
|
+
width: "hug",
|
|
70
|
+
height: "hug",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Fixed column geometry last, so it wins over what super() applied.
|
|
74
|
+
this.set({ gap: this.legend.itemGap / 3, padding: this.legend.padding })
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < this.slices.length; i++) {
|
|
77
|
+
const item = this.buildItem(this.slices[i])
|
|
78
|
+
this.items.push(item)
|
|
79
|
+
// The selection dim rides a wrapper rather than the entry itself, so the
|
|
80
|
+
// entrance can animate the entry's own opacity without the two fighting
|
|
81
|
+
// over one value — opacity multiplies down the tree, which is exactly the
|
|
82
|
+
// composition wanted here.
|
|
83
|
+
this.add(
|
|
84
|
+
new Rect({
|
|
85
|
+
width: "hug",
|
|
86
|
+
height: "hug",
|
|
87
|
+
flow: "freeform",
|
|
88
|
+
opacity: () => clampUnit(this.dims[i] ?? 1),
|
|
89
|
+
children: [item],
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One entry: the slice's colour swatch beside its caption. */
|
|
96
|
+
private buildItem(slice: PieSlice): Rect {
|
|
97
|
+
const marker = this.legend.markerStyle
|
|
98
|
+
return new Rect({ flow: "horizontal",
|
|
99
|
+
gap: 12,
|
|
100
|
+
align: "center",
|
|
101
|
+
width: "hug",
|
|
102
|
+
height: "hug",
|
|
103
|
+
children: [
|
|
104
|
+
new Rect({
|
|
105
|
+
width: marker.height,
|
|
106
|
+
height: marker.height,
|
|
107
|
+
cornerRadius: marker.borderRadius,
|
|
108
|
+
fill: slice.fill,
|
|
109
|
+
stroke: marker.stroke,
|
|
110
|
+
shadow: marker.shadow,
|
|
111
|
+
}),
|
|
112
|
+
new Text({
|
|
113
|
+
...textOps.styleProps(this.legend.textStyle),
|
|
114
|
+
text: slice.category,
|
|
115
|
+
}),
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prime every entry at its pre-entrance state: shifted right and transparent.
|
|
122
|
+
*
|
|
123
|
+
* Idempotent, and separate from {@link enter} so the chart can arm its whole
|
|
124
|
+
* composition before the first frame is drawn — the resting state is the
|
|
125
|
+
* finished legend, so there is nothing hidden to reveal unless a scene asks.
|
|
126
|
+
*/
|
|
127
|
+
arm(slide = SLIDE): void {
|
|
128
|
+
for (const item of this.items) item.set({ opacity: 0, x: slide })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Slide + fade every entry in from the right, staggered top-to-bottom.
|
|
133
|
+
*
|
|
134
|
+
* `duration` is the **whole** entrance: the stagger is carved out of it rather
|
|
135
|
+
* than added on top, so the command takes exactly the time the timeline laid
|
|
136
|
+
* out for it.
|
|
137
|
+
*/
|
|
138
|
+
@command()
|
|
139
|
+
enter(
|
|
140
|
+
args: CommandArgs<Record<string, never>> & { duration: number }
|
|
141
|
+
): Seekable {
|
|
142
|
+
const duration = args.duration
|
|
143
|
+
const easing = args.easing ?? easeOut()
|
|
144
|
+
const items = this.items
|
|
145
|
+
if (items.length === 0) return hold(duration)
|
|
146
|
+
this.arm()
|
|
147
|
+
|
|
148
|
+
// Each gap is a fifth of an entry's own move, the rhythm the axes use;
|
|
149
|
+
// solved for from the budget rather than added to it. The offsets were
|
|
150
|
+
// always `i * step` — written as `wait` inside a generator they merely only
|
|
151
|
+
// existed while it ran.
|
|
152
|
+
const per = duration / (1 + Math.max(0, items.length - 1) / 5)
|
|
153
|
+
const step = per / 5
|
|
154
|
+
|
|
155
|
+
return compose(
|
|
156
|
+
items.map((item, i) => at(i * step, item.to({ data: { opacity: 1, x: 0 }, duration: per, easing })))
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|