@motionscript/charts 0.1.0-alpha.0 → 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/NOTICE +49 -0
- package/README.md +99 -0
- package/dist/bar-chart/bar-chart.d.ts +133 -228
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +386 -565
- 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 +155 -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 +7 -7
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +7 -7
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-TJNHLQGV.js +2 -0
- package/dist/browser/chunks/chunk-TJNHLQGV.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 +4 -3
- package/dist/engine.d.ts +10 -8
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +10 -8
- 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 -85
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +137 -186
- 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 +61 -10
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +108 -13
- 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 +3 -3
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +2 -2
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +156 -303
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +410 -638
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +7 -7
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +23 -8
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/line-chart/x-scale.d.ts +4 -11
- package/dist/line-chart/x-scale.d.ts.map +1 -1
- package/dist/line-chart/x-scale.js +16 -5
- package/dist/line-chart/x-scale.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 -126
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +202 -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 -122
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +333 -361
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +12 -6
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +14 -8
- package/dist/pie-chart/shared.js.map +1 -1
- package/package.json +7 -4
- package/src/bar-chart/bar-chart.ts +424 -686
- package/src/bar-chart/category-axis.ts +96 -111
- package/src/bar-chart/chart-bars.ts +178 -201
- package/src/bar-chart/index.ts +2 -2
- package/src/bar-chart/shared.ts +7 -7
- package/src/engine.ts +10 -8
- package/src/kit/cartesian.ts +81 -0
- package/src/kit/chart-border.ts +23 -34
- package/src/kit/chart-canvas.ts +45 -80
- package/src/kit/chart-legend.ts +185 -206
- 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 +120 -13
- package/src/kit/vertical-axis.ts +111 -141
- package/src/line-chart/chart-line.ts +103 -155
- package/src/line-chart/chart-region.ts +36 -81
- package/src/line-chart/horizontal-axis.ts +102 -153
- package/src/line-chart/index.ts +3 -3
- package/src/line-chart/line-chart.ts +463 -759
- package/src/line-chart/shared.ts +22 -8
- package/src/line-chart/x-scale.ts +20 -5
- package/src/nodes.ts +8 -10
- package/src/pie-chart/index.ts +2 -2
- package/src/pie-chart/pie-chart.ts +225 -303
- package/src/pie-chart/pie-legend.ts +125 -115
- package/src/pie-chart/pie-ring.ts +388 -443
- package/src/pie-chart/shared.ts +14 -8
- package/dist/browser/chunks/chunk-ZMBOBHYL.js +0 -2
- package/dist/browser/chunks/chunk-ZMBOBHYL.js.map +0 -7
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
import {
|
|
2
|
-
command,
|
|
3
|
-
node,
|
|
4
|
-
Node2D,
|
|
5
2
|
Rect,
|
|
3
|
+
Types,
|
|
4
|
+
component2d,
|
|
6
5
|
easeOut,
|
|
7
|
-
|
|
8
|
-
type Command,
|
|
9
|
-
type CommandArgs,
|
|
6
|
+
type ChildView,
|
|
10
7
|
type DataRecord,
|
|
11
|
-
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,
|
|
12
12
|
type Node2DProps,
|
|
13
|
+
type Size2D,
|
|
14
|
+
type Timeline,
|
|
13
15
|
type Vector2,
|
|
14
|
-
} from "@motionscript/
|
|
16
|
+
} from "@motionscript/sdk"
|
|
17
|
+
import { formatNumber } from "@motionscript/sdk/component"
|
|
15
18
|
|
|
19
|
+
import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
|
|
16
20
|
import { ChartBorder } from "../kit/chart-border"
|
|
17
21
|
import { ChartCanvas } from "../kit/chart-canvas"
|
|
18
22
|
import { ChartLegend } from "../kit/chart-legend"
|
|
23
|
+
import { asParts, fresh } from "../kit/entries"
|
|
24
|
+
import { beats, type Beat } from "../kit/motion"
|
|
19
25
|
import { VerticalAxis } from "../kit/vertical-axis"
|
|
20
26
|
import {
|
|
21
27
|
SPOTLIGHT_DIM,
|
|
22
28
|
formatMarker,
|
|
23
29
|
lerpAxisDomain,
|
|
30
|
+
lerpAxisEntry,
|
|
31
|
+
markerStep,
|
|
24
32
|
markerValues,
|
|
25
33
|
resolveAxisDomain,
|
|
26
34
|
resolveSeriesFill,
|
|
27
35
|
resolveSeriesStroke,
|
|
36
|
+
settleAxisDomain,
|
|
28
37
|
type AxisDomainConfig,
|
|
29
38
|
type Range,
|
|
30
39
|
} from "../kit/shared"
|
|
31
|
-
import { formatNumber } from "@motionscript/core/component"
|
|
32
40
|
import { ChartLine } from "./chart-line"
|
|
33
41
|
import { ChartRegion } from "./chart-region"
|
|
34
42
|
import { HorizontalAxis } from "./horizontal-axis"
|
|
@@ -46,7 +54,6 @@ import {
|
|
|
46
54
|
type RegionBox,
|
|
47
55
|
} from "./shared"
|
|
48
56
|
import { resolveXScale, type XScale } from "./x-scale"
|
|
49
|
-
import { hold, sequence as sequenceOf, together, type Seekable } from "@motionscript/core/component"
|
|
50
57
|
|
|
51
58
|
export interface LineChartProps extends Node2DProps {
|
|
52
59
|
/** One row per sample — a plain record of field name to value. */
|
|
@@ -65,8 +72,8 @@ export interface LineChartProps extends Node2DProps {
|
|
|
65
72
|
/**
|
|
66
73
|
* A slice of the plot to spotlight, in **data units**. Dashed rules mark the
|
|
67
74
|
* bounded edges and every series fades outside the box — see
|
|
68
|
-
*
|
|
69
|
-
*
|
|
75
|
+
* `spotlightRegion`, which animates the same thing. `null` (the default) is no
|
|
76
|
+
* region.
|
|
70
77
|
*/
|
|
71
78
|
region?: PlotRegion | null
|
|
72
79
|
/**
|
|
@@ -79,816 +86,513 @@ export interface LineChartProps extends Node2DProps {
|
|
|
79
86
|
showLegend?: boolean
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
/** Both axes' domains with their ranges and ticks resolved. */
|
|
90
|
+
export interface LineChartDomains {
|
|
91
|
+
readonly x: AxisDomainConfig & { readonly range: Range; readonly ticks: number[] }
|
|
92
|
+
readonly y: AxisDomainConfig & { readonly range: Range; readonly ticks: number[] }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What a line chart plots, as its entries hold it. */
|
|
96
|
+
export interface LineChartValues {
|
|
97
|
+
readonly data: DataRecord[]
|
|
98
|
+
readonly xField: string
|
|
99
|
+
readonly series: LineSeries[]
|
|
100
|
+
readonly xAxis: AxisDomainConfig
|
|
101
|
+
readonly yAxis: AxisDomainConfig
|
|
102
|
+
}
|
|
93
103
|
|
|
94
104
|
/**
|
|
95
105
|
* The argument every per-series command takes: an index into `series`. Its own
|
|
96
106
|
* kind rather than `number` so a host can offer the series by name.
|
|
97
107
|
*/
|
|
98
|
-
const SERIES_ARG = {
|
|
108
|
+
const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
|
|
109
|
+
|
|
110
|
+
/** One argument, two controls: the commands take a `PlotRegion`, so the x and y spans a host draws are declared inside it. */
|
|
111
|
+
const REGION_ARG = Types.custom<PlotRegion | undefined>(undefined, {
|
|
112
|
+
kind: "region",
|
|
113
|
+
properties: {
|
|
114
|
+
x: { kind: "range", default: true, defaultMin: 0, defaultMax: 100 },
|
|
115
|
+
y: { kind: "range", default: false, defaultMin: 0, defaultMax: 100 },
|
|
116
|
+
},
|
|
117
|
+
})
|
|
99
118
|
|
|
100
119
|
/** How far the legend starts below its slot when it enters, in px. */
|
|
101
120
|
const LEGEND_SLIDE = 20
|
|
102
121
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
* commands that move them.
|
|
106
|
-
*
|
|
107
|
-
* Written out rather than reusing `LineChartProps` because the pair is what
|
|
108
|
-
* those commands actually touch, and because `regionReveal` is a signal of the
|
|
109
|
-
* chart's own rather than part of its authored props.
|
|
110
|
-
*/
|
|
111
|
-
type RegionState = { region: PlotRegion | null; regionReveal: number }
|
|
122
|
+
/** Fallback marker count when neither axis's ticks are given. */
|
|
123
|
+
const DEFAULT_MARKERS = 5
|
|
112
124
|
|
|
113
125
|
/**
|
|
114
|
-
* How
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* given (see the note on `enter`).
|
|
126
|
+
* How `enter` divides its duration between its three beats: the axes arriving,
|
|
127
|
+
* the lines tracing, and the legend coming up. Shares of the whole, so they must
|
|
128
|
+
* sum to 1 — the entrance takes exactly the time it was given.
|
|
118
129
|
*/
|
|
119
130
|
const AXES_SHARE = 0.35
|
|
120
131
|
const LINES_SHARE = 0.5
|
|
121
132
|
const LEGEND_SHARE = 0.15
|
|
122
133
|
|
|
134
|
+
/** What the chart's own commands read: its entries, and the private `home` a zoom remembers. */
|
|
135
|
+
interface LineState extends LineChartValues {
|
|
136
|
+
readonly theme: ChartConfig
|
|
137
|
+
readonly region: PlotRegion | null
|
|
138
|
+
readonly regionReveal: number
|
|
139
|
+
readonly home: LineChartDomains | null
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* - {@link ChartCanvas} — the plot panel with a two-way grid, under everything.
|
|
128
|
-
* - {@link VerticalAxis} / {@link HorizontalAxis} — columns/rows of real text
|
|
129
|
-
* markers that stagger in, each carrying its own axis caption.
|
|
130
|
-
* - {@link ChartRegion} — the optional call-out marking a spotlit slice of the
|
|
131
|
-
* plot: dashed rules on its bounded edges, and every series faded outside it.
|
|
132
|
-
* - {@link ChartLine} (one per series) — the traced polylines over the canvas.
|
|
133
|
-
* - {@link ChartLegend} — a row of entries below the chart, one per series.
|
|
134
|
-
*
|
|
135
|
-
* The parts are laid out from this node's `layoutBounds`: a left gutter for the
|
|
136
|
-
* vertical axis, a bottom gutter for the horizontal axis, the plot filling
|
|
137
|
-
* what's left, and the legend row beneath the whole chart. Every child's
|
|
138
|
-
* position/size is bound reactively, so the chart re-flows if its box changes,
|
|
139
|
-
* and the series lines read the same resolved scales as the axes, so points land
|
|
140
|
-
* exactly on their markers.
|
|
141
|
-
*
|
|
142
|
-
* **Resting state is the finished chart.** Unlike the standalone component this
|
|
143
|
-
* was ported from, nothing is built hidden: the studio paints a node's Initial
|
|
144
|
-
* state on a paused canvas, and a chart that only appeared once a command had
|
|
145
|
-
* run would be edited blind. {@link enter} arms every part back to its
|
|
146
|
-
* pre-entrance state before animating, so the entrance itself is unchanged.
|
|
147
|
-
*
|
|
148
|
-
* Past the entrance, three things move the chart around:
|
|
149
|
-
* {@link showSeries}/{@link hideSeries}/{@link spotlightSeries} pick out a
|
|
150
|
-
* *series*, {@link spotlightRegion} marks a *slice of the plot* without changing
|
|
151
|
-
* the scale, and {@link zoomTo} rescales the axes so a slice fills the plot. The
|
|
152
|
-
* last two take the same {@link PlotRegion} and compose — a spotlit region
|
|
153
|
-
* resolves against the live scales, so it travels with a zoom.
|
|
143
|
+
* What the x column *is* — a number, an instant, or a place in the file — and
|
|
144
|
+
* with it how each row is positioned, ticked and captioned. See `x-scale.ts`.
|
|
154
145
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
forkable: true,
|
|
159
|
-
layout: {
|
|
160
|
-
children: "freeform",
|
|
161
|
-
defaultWidthMode: "fixed",
|
|
162
|
-
defaultHeightMode: "fixed",
|
|
163
|
-
acceptsChildren: true,
|
|
164
|
-
},
|
|
165
|
-
seed: {
|
|
166
|
-
width: 760,
|
|
167
|
-
height: 460,
|
|
168
|
-
},
|
|
169
|
-
})
|
|
170
|
-
export class LineChart extends Node2D<LineChartProps> {
|
|
171
|
-
@property({ default: () => [] as DataRecord[], kind: "dataset" }) declare data: DataRecord[]
|
|
172
|
-
@property({ default: "" }) declare xField: string
|
|
173
|
-
@property({ default: () => [] as LineSeries[], kind: "series" }) declare series: LineSeries[]
|
|
174
|
-
/**
|
|
175
|
-
* X/Y-axis data domain (label/range/ticks), tweenable and mappable like
|
|
176
|
-
* `theme`: the mapper merges a partial input onto the previous resolved
|
|
177
|
-
* domain (leaving `range`/`ticks` `undefined` — the auto-derive signal — on
|
|
178
|
-
* first resolution), and the tween interpolates them numerically, so
|
|
179
|
-
* `chart.to({ yAxis: { range: [0, 10] } }, 1)` smoothly rescales the plotted
|
|
180
|
-
* lines. Like `theme`, the axis *label* text is captured once at construction.
|
|
181
|
-
*/
|
|
182
|
-
@property({
|
|
183
|
-
default: {},
|
|
184
|
-
mapper: resolveAxisDomain,
|
|
185
|
-
tween: lerpAxisDomain,
|
|
186
|
-
})
|
|
187
|
-
declare xAxis: AxisDomainConfig
|
|
188
|
-
@property({
|
|
189
|
-
default: {},
|
|
190
|
-
mapper: resolveAxisDomain,
|
|
191
|
-
tween: lerpAxisDomain,
|
|
192
|
-
})
|
|
193
|
-
declare yAxis: AxisDomainConfig
|
|
194
|
-
/**
|
|
195
|
-
* The chart's visual theme, tweenable and mappable like a `stroke`/`fill`
|
|
196
|
-
* prop: the mapper merges the author's partial input onto the previous
|
|
197
|
-
* resolved theme (or {@link DEFAULT_CHART_THEME} the first time) and resolves
|
|
198
|
-
* every leaf; the tween interpolates two resolved themes leaf-by-leaf, so
|
|
199
|
-
* `chart.to({ theme: next }, 1)` animates smoothly.
|
|
200
|
-
*/
|
|
201
|
-
@property({
|
|
202
|
-
default: DEFAULT_CHART_THEME,
|
|
203
|
-
mapper: resolveChartTheme,
|
|
204
|
-
tween: lerpChartTheme,
|
|
205
|
-
})
|
|
206
|
-
declare theme: ChartTheme
|
|
207
|
-
/**
|
|
208
|
-
* The spotlit slice of the plot in data units, or `null` for none. Held as a
|
|
209
|
-
* plain reactive value rather than a tweened one: the box itself snaps, and
|
|
210
|
-
* the *animation* is {@link regionReveal} drawing the rules on and the lines
|
|
211
|
-
* dimming outside it.
|
|
212
|
-
*/
|
|
213
|
-
@property({ default: null }) declare region: PlotRegion | null
|
|
214
|
-
/**
|
|
215
|
-
* How far the {@link region} call-out is drawn in, `[0, 1]` — the single
|
|
216
|
-
* signal behind both halves of the effect: the rules grow out and the wash
|
|
217
|
-
* fades up as it climbs, while every line's out-of-region stretch fades from
|
|
218
|
-
* full down to the theme's `region.dimOpacity`. At 0 a chart with a `region`
|
|
219
|
-
* set looks exactly like one without.
|
|
220
|
-
*/
|
|
221
|
-
@property({ default: 0 }) declare regionReveal: number
|
|
222
|
-
/**
|
|
223
|
-
* Read once, in the constructor, like `series`: the legend is a part the
|
|
224
|
-
* chart builds or doesn't, so a change reaches it by rebuilding the node.
|
|
225
|
-
*/
|
|
226
|
-
@property({ default: true, animatable: false }) declare showLegend: boolean
|
|
227
|
-
|
|
228
|
-
// ---- Part handles (built in the constructor) --------------------------
|
|
229
|
-
private vAxis: VerticalAxis
|
|
230
|
-
private hAxis: HorizontalAxis
|
|
231
|
-
/** The plot panel — the box the grid, the lines and the region all share. */
|
|
232
|
-
private plotRect: Rect
|
|
233
|
-
private legend: ChartLegend | null = null
|
|
234
|
-
private lines: ChartLine[] = []
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* `this.theme` read back through its *resolved* shape: the property's declared
|
|
238
|
-
* type is the loose author-facing {@link ChartTheme} (so a partial theme is
|
|
239
|
-
* accepted), but the mapper always stores the fully-resolved
|
|
240
|
-
* {@link ChartConfig} at runtime — the same loose-declared/resolved-stored
|
|
241
|
-
* idiom as a shape's `fill`/`stroke`.
|
|
242
|
-
*/
|
|
243
|
-
private get resolvedTheme(): ChartConfig {
|
|
244
|
-
return this.theme as unknown as ChartConfig
|
|
245
|
-
}
|
|
146
|
+
function columnOf(s: LineChartValues, format = s.xAxis.format): XScale {
|
|
147
|
+
return resolveXScale(s.data, s.xField, format)
|
|
148
|
+
}
|
|
246
149
|
|
|
247
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The horizontal axis with its range and ticks resolved: the explicit ones, else
|
|
152
|
+
* the column's extent and five ticks across it, asked of the column because only
|
|
153
|
+
* a numeric one has round numbers between its samples.
|
|
154
|
+
*/
|
|
155
|
+
function drawnX(s: LineChartValues, domain: AxisDomainConfig): AxisDomainConfig {
|
|
156
|
+
const column = columnOf(s, domain.format)
|
|
157
|
+
const range = domain.range ?? column.extent
|
|
158
|
+
return { ...domain, range, ticks: domain.ticks ?? column.ticks(range, DEFAULT_MARKERS - 1) }
|
|
159
|
+
}
|
|
248
160
|
|
|
249
|
-
|
|
250
|
-
|
|
161
|
+
/**
|
|
162
|
+
* The vertical axis with its range and ticks resolved. Auto-ranging steps into
|
|
163
|
+
* one fewer segment than the intended marker count, so the tidy bounds frame the
|
|
164
|
+
* ticks the axis draws.
|
|
165
|
+
*/
|
|
166
|
+
function drawnY(s: LineChartValues, domain: AxisDomainConfig): AxisDomainConfig {
|
|
167
|
+
const divisions = (domain.ticks?.length ?? DEFAULT_MARKERS) - 1
|
|
168
|
+
const range = resolveYScale(s.data, s.series, domain.range, divisions)
|
|
169
|
+
return { ...domain, range, ticks: domain.ticks ?? markerValues(range, DEFAULT_MARKERS - 1) }
|
|
170
|
+
}
|
|
251
171
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
172
|
+
/** The last domains worked out from each data array, and what else they were read from. */
|
|
173
|
+
const domainsDrawn = new WeakMap<
|
|
174
|
+
DataRecord[],
|
|
175
|
+
Pick<LineChartValues, "xField" | "xAxis" | "yAxis" | "series"> & { readonly domains: LineChartDomains }
|
|
176
|
+
>()
|
|
177
|
+
|
|
178
|
+
/** Both axes as a line chart draws them now, an auto range or tick row read back as itself. */
|
|
179
|
+
export function lineChartDomains(s: LineChartValues): LineChartDomains {
|
|
180
|
+
const { data, xField, xAxis, yAxis, series } = s
|
|
181
|
+
const kept = domainsDrawn.get(data)
|
|
182
|
+
if (kept && kept.xField === xField && kept.xAxis === xAxis && kept.yAxis === yAxis && kept.series === series) {
|
|
183
|
+
return kept.domains
|
|
258
184
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
185
|
+
const domains = {
|
|
186
|
+
x: settleAxisDomain(xAxis, (d) => drawnX(s, d)) as LineChartDomains["x"],
|
|
187
|
+
y: settleAxisDomain(yAxis, (d) => drawnY(s, d)) as LineChartDomains["y"],
|
|
262
188
|
}
|
|
189
|
+
domainsDrawn.set(data, { xField, xAxis, yAxis, series, domains })
|
|
190
|
+
return domains
|
|
191
|
+
}
|
|
263
192
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Map one series' data points into the plot's **centred** pixel space (origin at
|
|
195
|
+
* the plot centre, y-up): x runs `[-w/2, +w/2]`, y runs `[-h/2, +h/2]`.
|
|
196
|
+
*/
|
|
197
|
+
function seriesPixels(s: LineChartValues, domains: LineChartDomains, index: number, plot: Size2D): Vector2[] {
|
|
198
|
+
const series = s.series[index]
|
|
199
|
+
if (!series) return []
|
|
200
|
+
const points = seriesPoints(s.data, columnOf(s), series)
|
|
201
|
+
if (points.length === 0) return []
|
|
202
|
+
const { width: w, height: h } = plot
|
|
203
|
+
const [xMin, xMax] = domains.x.range
|
|
204
|
+
const [yMin, yMax] = domains.y.range
|
|
205
|
+
const xToPx = (x: number) => (xMax <= xMin ? 0 : ((x - xMin) / (xMax - xMin) - 0.5) * w)
|
|
206
|
+
const yToPx = (y: number) => (yMax <= yMin ? 0 : ((y - yMin) / (yMax - yMin) - 0.5) * h)
|
|
207
|
+
// `seriesPoints` already sorted them along x.
|
|
208
|
+
return points.map(([x, y]) => ({ x: xToPx(x), y: yToPx(y) }))
|
|
209
|
+
}
|
|
272
210
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
}
|
|
211
|
+
/** `region` mapped into the plot's centred pixel space, against the live scales, so it travels with a zoom. */
|
|
212
|
+
function regionBox(s: LineChartValues & { readonly region: PlotRegion | null }, domains: LineChartDomains, plot: Size2D): RegionBox {
|
|
213
|
+
const { width: w, height: h } = plot
|
|
214
|
+
return resolveRegionBox(s.region, { left: -w / 2, right: w / 2, bottom: -h / 2, top: h / 2 }, domains.x.range, domains.y.range)
|
|
215
|
+
}
|
|
280
216
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return (
|
|
291
|
-
this.xAxis.ticks ??
|
|
292
|
-
this.xColumn.ticks(this.xScale, LineChart.DEFAULT_MARKERS - 1)
|
|
293
|
-
)
|
|
294
|
-
}
|
|
217
|
+
/**
|
|
218
|
+
* Whether a call-out is authored at all. Not whether it resolves to pixels: a
|
|
219
|
+
* command is built before the first layout, and a region on a chart not yet
|
|
220
|
+
* measured is a real one. A region bounding neither axis is none, as `null` is.
|
|
221
|
+
*/
|
|
222
|
+
function hasCallout(s: { readonly region: PlotRegion | null }): boolean {
|
|
223
|
+
const region = s.region
|
|
224
|
+
return region != null && (region.x !== undefined || region.y !== undefined)
|
|
225
|
+
}
|
|
295
226
|
|
|
296
|
-
|
|
227
|
+
/** The `region` a document handed a command, or `{}`: a command is added before its arguments are filled in. */
|
|
228
|
+
function regionArg(region: PlotRegion | null | undefined): PlotRegion {
|
|
229
|
+
return region != null && typeof region === "object" ? region : {}
|
|
230
|
+
}
|
|
297
231
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
* the legend row would take, subtracted from the node's box. The estimate was
|
|
303
|
-
* never the layout — the vertical axis is `width: "hug"`, so its real width is
|
|
304
|
-
* whatever its widest marker needs — and everything downstream inherited the
|
|
305
|
-
* gap. The grid is drawn by {@link ChartCanvas} across its *own* allocation,
|
|
306
|
-
* while the lines were mapped across the estimate, so a series sat a few
|
|
307
|
-
* percent off the grid line it was supposed to touch, silently and by an
|
|
308
|
-
* amount that changed with the theme.
|
|
309
|
-
*
|
|
310
|
-
* Asking the panel is exact by construction and cannot drift: the lines, the
|
|
311
|
-
* region rules and the grid all resolve against one box, and it is the box
|
|
312
|
-
* they are drawn in. Zero until the first layout pass, like any other
|
|
313
|
-
* `layoutBounds` read — the bindings that call this re-run on the next frame.
|
|
314
|
-
*/
|
|
315
|
-
private plotSize(): { width: number; height: number } {
|
|
316
|
-
const { width, height } = this.plotRect.layoutBounds
|
|
317
|
-
return { width, height }
|
|
318
|
-
}
|
|
232
|
+
/** A range read as a span, whichever way round the author wrote it. */
|
|
233
|
+
function span([a, b]: Range): Range {
|
|
234
|
+
return a <= b ? [a, b] : [b, a]
|
|
235
|
+
}
|
|
319
236
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const points = seriesPoints(this.data, this.xColumn, s)
|
|
329
|
-
if (points.length === 0) return []
|
|
330
|
-
const { width: w, height: h } = this.plotSize()
|
|
331
|
-
const [xMin, xMax] = this.xScale
|
|
332
|
-
const [yMin, yMax] = this.yScale
|
|
333
|
-
// Plot centre is the origin; map the data fraction to [-half, +half].
|
|
334
|
-
const xToPx = (x: number) =>
|
|
335
|
-
xMax <= xMin ? 0 : ((x - xMin) / (xMax - xMin) - 0.5) * w
|
|
336
|
-
const yToPx = (y: number) =>
|
|
337
|
-
yMax <= yMin ? 0 : ((y - yMin) / (yMax - yMin) - 0.5) * h
|
|
338
|
-
// `seriesPoints` already sorted them along x.
|
|
339
|
-
return points.map(([x, y]) => ({ x: xToPx(x), y: yToPx(y) }))
|
|
340
|
-
}
|
|
237
|
+
/**
|
|
238
|
+
* Rescale an axis to `range`, keeping the tick *count* it already has, so each
|
|
239
|
+
* marker slides from its old value to its new one instead of the row popping.
|
|
240
|
+
*/
|
|
241
|
+
function rescale(range: Range, ticks: readonly number[]): AxisDomainConfig {
|
|
242
|
+
const to = span(range)
|
|
243
|
+
return { range: to, ticks: markerValues(to, Math.max(2, ticks.length) - 1) }
|
|
244
|
+
}
|
|
341
245
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
*/
|
|
348
|
-
/**
|
|
349
|
-
* Whether a call-out is authored at all — the question {@link enter} and
|
|
350
|
-
* {@link spotlightRegion} branch on.
|
|
351
|
-
*
|
|
352
|
-
* Deliberately **not** `hasRegion(this.regionBox())`, which they used to ask.
|
|
353
|
-
* That resolves the region into pixels, so it answers "and has the chart been
|
|
354
|
-
* laid out yet" as well, and comes back false for a perfectly real region on a
|
|
355
|
-
* chart that has not been measured. A generator never noticed, because it only
|
|
356
|
-
* ever ran mid-playback; a command is *built* before the first layout pass, and
|
|
357
|
-
* would have quietly dropped the region beat every time.
|
|
358
|
-
*
|
|
359
|
-
* What is left once the pixels are taken out is the same test
|
|
360
|
-
* {@link resolveRegionBox} applies before it maps anything: a region bounding
|
|
361
|
-
* *neither* axis is not a call-out, so `{}` counts as none exactly as `null`
|
|
362
|
-
* does.
|
|
363
|
-
*/
|
|
364
|
-
private hasCallout(): boolean {
|
|
365
|
-
const region = this.region
|
|
366
|
-
return region != null && (region.x !== undefined || region.y !== undefined)
|
|
246
|
+
/** The axis writes of a move from `from` toward `to` at `t`. */
|
|
247
|
+
function axesAt(from: LineChartDomains, to: Partial<Record<"x" | "y", AxisDomainConfig>>, t: number) {
|
|
248
|
+
return {
|
|
249
|
+
...(to.x && { xAxis: lerpAxisDomain(from.x, to.x, t) }),
|
|
250
|
+
...(to.y && { yAxis: lerpAxisDomain(from.y, to.y, t) }),
|
|
367
251
|
}
|
|
252
|
+
}
|
|
368
253
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
254
|
+
type AnyTimeline = Timeline<any, any>
|
|
255
|
+
|
|
256
|
+
/** The parts a line chart's commands drive, found through its children. */
|
|
257
|
+
function partsOf(children: readonly ChildView[]) {
|
|
258
|
+
const frame = cartesianViews(children)
|
|
259
|
+
if (!frame) return null
|
|
260
|
+
const hAxis = frame.below.find(HorizontalAxis.is)
|
|
261
|
+
if (!VerticalAxis.is(frame.axis) || !hAxis) return null
|
|
262
|
+
return {
|
|
263
|
+
vAxis: frame.axis,
|
|
264
|
+
hAxis,
|
|
265
|
+
lines: frame.plot.filter(ChartLine.is),
|
|
266
|
+
legend: frame.below.find(ChartLegend.is) ?? null,
|
|
379
267
|
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Drop the call-out: the rules retract, the wash fades, and every series comes back to full. */
|
|
271
|
+
function clearing(timeline: AnyTimeline, state: LineState) {
|
|
272
|
+
const from = state.regionReveal
|
|
273
|
+
const region = state.region
|
|
274
|
+
// Nothing to retract, but the call still takes its slot on the timeline.
|
|
275
|
+
if (from === 0) return timeline.animate(() => ({ region: null, regionReveal: 0 }), { eased: false })
|
|
276
|
+
// `region` is held until the retraction lands, or there would be nothing left to retract.
|
|
277
|
+
return timeline.animate((t) => (t >= 1 ? { region: null, regionReveal: 0 } : { region, regionReveal: from * (1 - t) }))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Trace series `index`'s line in (`show`) or back out, in lockstep with its legend entry. */
|
|
281
|
+
function toggling(timeline: AnyTimeline, children: readonly ChildView[], index: number, show: boolean) {
|
|
282
|
+
const parts = partsOf(children)
|
|
283
|
+
const line = parts?.lines[index]
|
|
284
|
+
if (!parts || !line) return timeline.wait(timeline.duration)
|
|
285
|
+
return timeline.together([
|
|
286
|
+
line.run("enter", show ? { from: 0, to: 1 } : { from: 1, to: 0 }),
|
|
287
|
+
...(parts.legend ? [parts.legend.run(show ? "showSeries" : "hideSeries", { index })] : []),
|
|
288
|
+
])
|
|
289
|
+
}
|
|
380
290
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
291
|
+
/**
|
|
292
|
+
* A multi-series line chart, composed from five independent parts rather than
|
|
293
|
+
* one monolithic draw:
|
|
294
|
+
*
|
|
295
|
+
* - {@link ChartCanvas} — the plot panel with a two-way grid, under everything.
|
|
296
|
+
* - {@link VerticalAxis} / {@link HorizontalAxis} — columns/rows of real text
|
|
297
|
+
* markers that stagger in, each carrying its own axis caption.
|
|
298
|
+
* - {@link ChartRegion} — the optional call-out marking a spotlit slice of the
|
|
299
|
+
* plot: dashed rules on its bounded edges, and every series faded outside it.
|
|
300
|
+
* - {@link ChartLine} (one per series) — the traced polylines over the canvas.
|
|
301
|
+
* - {@link ChartLegend} — a row of entries below the chart, one per series.
|
|
302
|
+
*
|
|
303
|
+
* Every part reads the chart's entries through bindings, and the lines, the
|
|
304
|
+
* region and the grid all map against the plot panel's own laid-out box, so
|
|
305
|
+
* points land exactly on their markers and the chart re-flows if its box
|
|
306
|
+
* changes.
|
|
307
|
+
*
|
|
308
|
+
* **Resting state is the finished chart**: an editor paints a node's Initial
|
|
309
|
+
* state on a paused canvas. `enter` arms every part on its first frame.
|
|
310
|
+
*
|
|
311
|
+
* Past the entrance, `showSeries`/`hideSeries`/`spotlightSeries` pick out a
|
|
312
|
+
* *series*, `spotlightRegion` marks a *slice of the plot* without changing the
|
|
313
|
+
* scale, and `zoomTo` rescales the axes so a slice fills the plot. A spotlit
|
|
314
|
+
* region resolves against the live scales, so it travels with a zoom. A `to` of
|
|
315
|
+
* an axis `range` or `ticks` tweens from the domain as drawn (see
|
|
316
|
+
* {@link lerpAxisEntry}), so an auto axis rescales continuously.
|
|
317
|
+
*/
|
|
318
|
+
export const LineChart = component2d({
|
|
319
|
+
key: "lineChart",
|
|
320
|
+
state: {
|
|
321
|
+
data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
|
|
322
|
+
xField: "",
|
|
323
|
+
series: fresh<LineSeries[]>(() => [], { kind: "series" }),
|
|
324
|
+
xAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
|
|
325
|
+
yAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
|
|
326
|
+
theme: Types.custom<ChartTheme, ChartConfig>(DEFAULT_CHART_THEME, { resolve: resolveChartTheme, lerp: lerpChartTheme }),
|
|
327
|
+
/**
|
|
328
|
+
* The spotlit slice of the plot in data units, or `null` for none. The box
|
|
329
|
+
* snaps; the animation is {@link regionReveal} drawing the rules on.
|
|
330
|
+
*/
|
|
331
|
+
region: Types.custom<PlotRegion | null>(null),
|
|
332
|
+
/**
|
|
333
|
+
* How far the `region` call-out is drawn in, `[0, 1]`: the rules grow out and
|
|
334
|
+
* the wash fades up as it climbs, while every line's out-of-region stretch
|
|
335
|
+
* fades to the theme's `region.dimOpacity`.
|
|
336
|
+
*/
|
|
337
|
+
regionReveal: 0,
|
|
338
|
+
/** Read as the chart is built: the legend is a part the chart builds or doesn't. */
|
|
339
|
+
showLegend: Types.boolean(true, { animatable: false }),
|
|
340
|
+
/**
|
|
341
|
+
* The domain `zoomTo` first left, so `resetZoom` restores the chart as it
|
|
342
|
+
* stood, auto-derived ticks included. Cleared by `resetZoom`, so the next zoom
|
|
343
|
+
* remembers wherever the chart rests then.
|
|
344
|
+
*/
|
|
345
|
+
home: Types.custom<LineChartDomains | null>(null, { private: true }),
|
|
346
|
+
},
|
|
347
|
+
// Its parts fill it; a node made of them would hug them.
|
|
348
|
+
defaults: { width: "fill", height: "fill" },
|
|
349
|
+
allowedChildren: [],
|
|
350
|
+
children: (state) => {
|
|
351
|
+
const theme = () => state.theme
|
|
352
|
+
const domains = () => lineChartDomains(state)
|
|
353
|
+
let plot: Node2D | undefined
|
|
354
|
+
const plotSize = () => laidOutSize(plot)
|
|
355
|
+
const enabled = state.series.map((s) => s.enabled ?? true)
|
|
356
|
+
|
|
357
|
+
// Bound, not snapshotted, so a rescale re-labels and re-places the row as it
|
|
358
|
+
// tweens. The marker *count* is read once, which is why `zoomTo` keeps it.
|
|
359
|
+
const vAxis = new VerticalAxis({
|
|
360
|
+
range: () => domains().y.range,
|
|
361
|
+
markers: () => domains().y.ticks,
|
|
362
|
+
// The y axis is always numbers, so its format is simply the pattern.
|
|
363
|
+
captions: () => {
|
|
364
|
+
const markers = domains().y.ticks
|
|
365
|
+
const format = state.yAxis.format
|
|
366
|
+
return markers.map((value) => (format === undefined ? formatMarker(value, markerStep(markers)) : formatNumber(value, format)))
|
|
367
|
+
},
|
|
368
|
+
axis: () => theme().yAxis,
|
|
369
|
+
label: state.yAxis.label ?? "",
|
|
408
370
|
width: "hug",
|
|
409
371
|
height: "fill",
|
|
410
372
|
})
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
373
|
+
|
|
374
|
+
const hAxis = new HorizontalAxis({
|
|
375
|
+
range: () => domains().x.range,
|
|
376
|
+
markers: () => domains().x.ticks,
|
|
377
|
+
// A tick on a month or category column is captioned with the cell it came
|
|
378
|
+
// out of — `Jan`, not the instant it parsed to.
|
|
379
|
+
captions: () => {
|
|
380
|
+
const column = columnOf(state)
|
|
381
|
+
return domains().x.ticks.map((value) => column.label(value))
|
|
382
|
+
},
|
|
383
|
+
axis: () => theme().xAxis,
|
|
384
|
+
label: state.xAxis.label ?? "",
|
|
422
385
|
width: "fill",
|
|
423
386
|
height: "hug",
|
|
424
387
|
})
|
|
425
|
-
// A tick on a month or category column is captioned with the cell it came
|
|
426
|
-
// out of — `Jan`, not the instant it parsed to. Assigned rather than passed:
|
|
427
|
-
// see the note on the field.
|
|
428
|
-
this.hAxis.labelFor = (value) => this.xColumn.label(value)
|
|
429
388
|
|
|
430
|
-
|
|
389
|
+
const lines = state.series.map(
|
|
431
390
|
(s, i) =>
|
|
432
391
|
new ChartLine({
|
|
433
|
-
points: () =>
|
|
434
|
-
stroke: () => resolveSeriesStroke(
|
|
392
|
+
points: () => seriesPixels(state, domains(), i, plotSize()),
|
|
393
|
+
stroke: () => resolveSeriesStroke(theme().seriesStyles, s, i),
|
|
435
394
|
enabled: enabled[i],
|
|
436
|
-
// Each line clips itself against the
|
|
437
|
-
//
|
|
438
|
-
spotlight: () =>
|
|
439
|
-
// Ramped by the
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
1 - (1 - this.resolvedTheme.region.dimOpacity) * this.regionReveal,
|
|
443
|
-
})
|
|
395
|
+
// Each line clips itself against the box the rules are drawn from, so
|
|
396
|
+
// the dim/bright boundary can't drift from the dashes.
|
|
397
|
+
spotlight: () => regionBox(state, domains(), plotSize()),
|
|
398
|
+
// Ramped by the reveal that draws the rules: one signal, both halves.
|
|
399
|
+
dimOpacity: () => 1 - (1 - theme().region.dimOpacity) * state.regionReveal,
|
|
400
|
+
}) as unknown as Node2D
|
|
444
401
|
)
|
|
445
402
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
403
|
+
const below: Node2D[] = [hAxis as unknown as Node2D]
|
|
404
|
+
if (state.showLegend !== false) {
|
|
405
|
+
below.push(
|
|
406
|
+
new ChartLegend({
|
|
407
|
+
series: state.series,
|
|
408
|
+
// The legend paints a swatch, not a line, so it is handed the colour each
|
|
409
|
+
// stroke resolves to rather than the stroke itself.
|
|
410
|
+
swatches: () => state.series.map((s, i) => resolveSeriesFill(theme().seriesStyles, s, i)),
|
|
411
|
+
legend: () => theme().legend,
|
|
412
|
+
// The row's spacing is the theme's as the chart was built, like the
|
|
413
|
+
// entries' type and chips: a theme tween re-paints the swatches only.
|
|
414
|
+
gap: state.theme.legend.itemGap,
|
|
415
|
+
padding: state.theme.legend.padding,
|
|
416
|
+
}) as unknown as Node2D
|
|
417
|
+
)
|
|
458
418
|
}
|
|
459
419
|
|
|
460
|
-
|
|
420
|
+
plot = new Rect({
|
|
461
421
|
clip: true,
|
|
462
422
|
children: [
|
|
463
423
|
new ChartCanvas({
|
|
464
|
-
fill: () =>
|
|
465
|
-
shadow: () =>
|
|
466
|
-
xGridStroke: () =>
|
|
467
|
-
yGridStroke: () =>
|
|
468
|
-
// A grid line under every axis marker; the canvas
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
yExtent: () => this.yScale,
|
|
424
|
+
fill: () => theme().plotArea.fill,
|
|
425
|
+
shadow: () => theme().plotArea.shadow,
|
|
426
|
+
xGridStroke: () => theme().xAxis.lineStyle,
|
|
427
|
+
yGridStroke: () => theme().yAxis.lineStyle,
|
|
428
|
+
// A grid line under every axis marker; the canvas skips the edges.
|
|
429
|
+
xMarkers: () => domains().x.ticks,
|
|
430
|
+
yMarkers: () => domains().y.ticks,
|
|
431
|
+
xExtent: () => domains().x.range,
|
|
432
|
+
yExtent: () => domains().y.range,
|
|
474
433
|
}),
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// pick it out.
|
|
434
|
+
// Over the grid, under the lines, so a spotlit stretch of a series isn't
|
|
435
|
+
// tinted by the highlight meant to pick it out.
|
|
478
436
|
new ChartRegion({
|
|
479
|
-
box: () =>
|
|
480
|
-
stroke: () =>
|
|
481
|
-
fill: () =>
|
|
482
|
-
reveal: () =>
|
|
483
|
-
}),
|
|
484
|
-
...this.lines,
|
|
485
|
-
new ChartBorder({
|
|
486
|
-
stroke: () => this.resolvedTheme.plotArea.stroke,
|
|
437
|
+
box: () => regionBox(state, domains(), plotSize()),
|
|
438
|
+
stroke: () => theme().region.stroke,
|
|
439
|
+
fill: () => theme().region.fill,
|
|
440
|
+
reveal: () => state.regionReveal,
|
|
487
441
|
}),
|
|
442
|
+
...lines,
|
|
443
|
+
new ChartBorder({ stroke: () => theme().plotArea.stroke }),
|
|
488
444
|
],
|
|
489
|
-
})
|
|
445
|
+
}) as unknown as Node2D
|
|
490
446
|
|
|
491
|
-
|
|
492
|
-
// the plot so the horizontal axis starts at the plot's left edge rather than
|
|
493
|
-
// the chart's.
|
|
494
|
-
const gutterSpacer = new VerticalAxis({
|
|
447
|
+
const gutter = new VerticalAxis({
|
|
495
448
|
opacity: 0,
|
|
496
|
-
range: () =>
|
|
497
|
-
markers: () =>
|
|
498
|
-
axis: () =>
|
|
499
|
-
label:
|
|
449
|
+
range: () => domains().y.range,
|
|
450
|
+
markers: () => domains().y.ticks,
|
|
451
|
+
axis: () => theme().yAxis,
|
|
452
|
+
label: state.yAxis.label ?? "",
|
|
500
453
|
width: "hug",
|
|
501
454
|
height: 0,
|
|
502
|
-
})
|
|
455
|
+
}) as unknown as Node2D
|
|
503
456
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
args: CommandArgs<Record<string, never>> & { duration: number }
|
|
569
|
-
): Seekable {
|
|
570
|
-
const duration = args.duration
|
|
571
|
-
const easing = args.easing ?? easeOut()
|
|
572
|
-
this.arm()
|
|
573
|
-
|
|
574
|
-
const revealing = this.hasCallout() && this.regionReveal === 0
|
|
575
|
-
// The region call-out, when there is one, takes the last quarter.
|
|
576
|
-
const body = revealing ? duration * 0.75 : duration
|
|
577
|
-
|
|
578
|
-
// The same three phases, in the same order and with the same shares — placed
|
|
579
|
-
// at their offsets rather than sequenced by a generator, so the whole
|
|
580
|
-
// entrance can be asked what it looks like at a time. Each part writes to its
|
|
581
|
-
// own node (the axes, the lines, the legend), which is why these compose as
|
|
582
|
-
// steppers rather than as merged props.
|
|
583
|
-
const axes = together(
|
|
584
|
-
this.vAxis.enter({ data: { slide: 24 }, duration: body * AXES_SHARE, easing }),
|
|
585
|
-
this.hAxis.enter({ data: { slide: 24 }, duration: body * AXES_SHARE, easing })
|
|
586
|
-
)
|
|
587
|
-
const lines = together(
|
|
588
|
-
...this.lines.map((line) => line.enter({ duration: body * LINES_SHARE, easing }))
|
|
589
|
-
)
|
|
590
|
-
const legend = this.legend
|
|
591
|
-
? [this.legend.to({ data: { opacity: 1, y: 0 }, duration: body * LEGEND_SHARE, easing })]
|
|
592
|
-
: []
|
|
593
|
-
|
|
594
|
-
const phases = sequenceOf(axes, lines, ...legend)
|
|
595
|
-
|
|
596
|
-
return revealing
|
|
597
|
-
? sequenceOf(
|
|
598
|
-
phases,
|
|
599
|
-
// Typed to what it writes rather than to `LineChartProps`: `regionReveal`
|
|
600
|
-
// is a signal of the chart's own, not part of its authored props.
|
|
601
|
-
this.command<{ regionReveal: number }>(
|
|
602
|
-
(t) => ({ regionReveal: t }),
|
|
603
|
-
duration * 0.25,
|
|
604
|
-
easing
|
|
457
|
+
return asParts([cartesianFrame({ axis: vAxis as unknown as Node2D, plot, gutter, below })])
|
|
458
|
+
},
|
|
459
|
+
commands: {
|
|
460
|
+
/**
|
|
461
|
+
* Animate the whole chart in: the two axes stagger their markers in, the
|
|
462
|
+
* series lines trace left to right, and the legend fades up last. Its first
|
|
463
|
+
* frame arms every part: markers shifted and transparent, lines untraced,
|
|
464
|
+
* the legend dropped and transparent.
|
|
465
|
+
*
|
|
466
|
+
* `duration` is the **whole** entrance; the beats are shares of it. A chart
|
|
467
|
+
* authored with a `region` lands its call-out last, in the final quarter.
|
|
468
|
+
*/
|
|
469
|
+
enter: {
|
|
470
|
+
args: {},
|
|
471
|
+
// Held from the chart's arrival, so it never shows finished ahead of its draw.
|
|
472
|
+
entrance: true,
|
|
473
|
+
easing: easeOut(),
|
|
474
|
+
run: (timeline, state, children) => {
|
|
475
|
+
const parts = partsOf(children)
|
|
476
|
+
if (!parts) return timeline.wait(timeline.duration)
|
|
477
|
+
const duration = timeline.duration
|
|
478
|
+
const revealing = hasCallout(state) && state.regionReveal === 0
|
|
479
|
+
const body = revealing ? duration * 0.75 : duration
|
|
480
|
+
const axes = body * AXES_SHARE
|
|
481
|
+
const traced = body * LINES_SHARE
|
|
482
|
+
const { legend } = parts
|
|
483
|
+
const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
|
|
484
|
+
// The first frame arms what every later beat moves: a beat ahead of the
|
|
485
|
+
// playhead writes nothing. A beat with nothing to do still takes its share.
|
|
486
|
+
return beats(timeline, [
|
|
487
|
+
...(revealing ? [at0(timeline.animate(() => ({ regionReveal: 0 })))] : []),
|
|
488
|
+
...parts.lines.map((line) => at0(line.to({ progress: 0 }))),
|
|
489
|
+
...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
|
|
490
|
+
{ at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
|
|
491
|
+
{ at: 0, duration: axes, piece: parts.hAxis.run("enter", { slide: 24 }) },
|
|
492
|
+
...parts.lines.map((line) => ({ at: axes, duration: traced, piece: line.run("enter") })),
|
|
493
|
+
...(legend ? [{ at: axes + traced, duration: body * LEGEND_SHARE, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
|
|
494
|
+
...(revealing ? [{ at: body, duration: duration * 0.25, piece: timeline.animate((t) => ({ regionReveal: t })) }] : []),
|
|
495
|
+
])
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
/**
|
|
499
|
+
* Spotlight a rectangular slice of the plot given in **data units**: dashed
|
|
500
|
+
* rules draw onto each bounded edge while every series fades outside the box.
|
|
501
|
+
* Either axis alone is a band; both are a box. Called while another region is
|
|
502
|
+
* up, the old call-out retracts over the first half and the new one draws in
|
|
503
|
+
* over the second, each half eased on its own.
|
|
504
|
+
*/
|
|
505
|
+
spotlightRegion: {
|
|
506
|
+
args: { region: REGION_ARG },
|
|
507
|
+
easing: easeOut(),
|
|
508
|
+
run: (timeline, state) => {
|
|
509
|
+
const region = regionArg(timeline.args.region)
|
|
510
|
+
const from = state.regionReveal
|
|
511
|
+
const previous = state.region
|
|
512
|
+
// A region bounding neither axis is no call-out: spotlighting it is a clear.
|
|
513
|
+
if (region.x === undefined && region.y === undefined) return clearing(timeline, state)
|
|
514
|
+
// Nothing up yet: the region is set from the first frame and just draws on.
|
|
515
|
+
if (from <= 0 || !hasCallout(state)) return timeline.animate((t) => ({ region, regionReveal: from + (1 - from) * t }))
|
|
516
|
+
return timeline.animate(
|
|
517
|
+
(t) => (t < 0.5
|
|
518
|
+
? { region: previous, regionReveal: from * (1 - timeline.ease(t * 2)) }
|
|
519
|
+
: { region, regionReveal: timeline.ease(t * 2 - 1) }),
|
|
520
|
+
{ eased: false }
|
|
605
521
|
)
|
|
606
|
-
)
|
|
607
|
-
: phases
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ---- Region spotlight --------------------------------------------------
|
|
611
|
-
|
|
612
|
-
/**
|
|
613
|
-
* Spotlight a rectangular slice of the plot given in **data units**: dashed
|
|
614
|
-
* rules draw themselves onto each bounded edge while every series fades
|
|
615
|
-
* outside the box and stays full inside it.
|
|
616
|
-
*
|
|
617
|
-
* Either axis alone is a band — `{ x: [80, 100] }` picks out the last fifth
|
|
618
|
-
* across all series, `{ y: [8, 10] }` a value band across the whole width —
|
|
619
|
-
* and both together a box. The unbounded axis spans the plot and draws no
|
|
620
|
-
* rule, since there's no data bound there to mark.
|
|
621
|
-
*
|
|
622
|
-
* Calling it while another region is already up cross-fades: the old call-out
|
|
623
|
-
* retracts, then the new one draws in, each over half `duration`.
|
|
624
|
-
*
|
|
625
|
-
* Both the call-out's shape and its draw-on are plain signals — `region` and
|
|
626
|
-
* {@link regionReveal} — so the whole thing is a function of `t`, including
|
|
627
|
-
* the swap: which region is up is a step at the halfway point rather than an
|
|
628
|
-
* effect that has to have happened.
|
|
629
|
-
*/
|
|
630
|
-
@command({
|
|
631
|
-
args: [
|
|
632
|
-
// One argument, two controls: the method takes a `PlotRegion`, so the x
|
|
633
|
-
// and y spans a host draws are declared inside it.
|
|
634
|
-
{
|
|
635
|
-
key: "region", kind: "region", properties: {
|
|
636
|
-
x: { kind: "range", default: true, defaultMin: 0, defaultMax: 100 },
|
|
637
|
-
y: { kind: "range", default: false, defaultMin: 0, defaultMax: 100 },
|
|
638
|
-
},
|
|
639
522
|
},
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// plot mid-draw. Each half eases on its own — which is why the easing is
|
|
667
|
-
// applied here rather than handed to `animate`, whose easing would shape the
|
|
668
|
-
// pair as one curve and leave both halves crawling past the seam.
|
|
669
|
-
return this.command<RegionState>(
|
|
670
|
-
(t) =>
|
|
671
|
-
t < 0.5
|
|
672
|
-
? { region: previous, regionReveal: from * (1 - easing(t * 2)) }
|
|
673
|
-
: { region, regionReveal: easing(t * 2 - 1) },
|
|
674
|
-
duration
|
|
675
|
-
)
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Drop the region call-out — the rules retract, the wash fades, and every
|
|
680
|
-
* series comes back to full across its whole length. A no-op when nothing is
|
|
681
|
-
* spotlit.
|
|
682
|
-
*/
|
|
683
|
-
@command()
|
|
684
|
-
clearRegion(
|
|
685
|
-
args: CommandArgs<Record<string, never>> & { duration?: number } = {}
|
|
686
|
-
): Command<RegionState> {
|
|
687
|
-
const duration = args.duration ?? 0.5
|
|
688
|
-
const easing = args.easing ?? easeOut()
|
|
689
|
-
const from = this.regionReveal
|
|
690
|
-
|
|
691
|
-
if (from === 0) {
|
|
692
|
-
// Nothing to retract, but the call still takes its slot: in the studio a
|
|
693
|
-
// command's duration is what the timeline lays out and what parallel
|
|
694
|
-
// siblings are timed against, so a no-op that took no frames would pull
|
|
695
|
-
// everything after it forward. Same reason for `zoomTo`/`resetZoom` below.
|
|
696
|
-
return this.command<RegionState>(
|
|
697
|
-
() => ({ region: null, regionReveal: 0 }),
|
|
698
|
-
duration
|
|
699
|
-
)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// `region` is left alone until the retraction lands — cleared at `t === 1`
|
|
703
|
-
// rather than up front, or there would be nothing left to retract.
|
|
704
|
-
return this.command<RegionState>(
|
|
705
|
-
(t) => (t >= 1 ? { region: null, regionReveal: 0 } : { regionReveal: from * (1 - t) }),
|
|
706
|
-
duration,
|
|
707
|
-
easing
|
|
708
|
-
)
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// ---- Zoom --------------------------------------------------------------
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* The domain to come back to, captured the first time {@link zoomTo} runs so a
|
|
715
|
-
* later {@link resetZoom} restores the chart as authored — including ticks
|
|
716
|
-
* that were auto-derived rather than given, which the props alone don't
|
|
717
|
-
* record. Cleared by {@link resetZoom}, so a second zoom captures wherever the
|
|
718
|
-
* chart genuinely rests then.
|
|
719
|
-
*/
|
|
720
|
-
private home: { x: AxisDomainConfig; y: AxisDomainConfig } | null = null
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* The `region` a document handed a command, or `{}`. A command is added
|
|
724
|
-
* before its arguments are filled in, so an unstated region is ordinary input
|
|
725
|
-
* rather than a malformed one.
|
|
726
|
-
*/
|
|
727
|
-
private static regionArg(data: { region?: PlotRegion | null } | undefined): PlotRegion {
|
|
728
|
-
const region = data?.region
|
|
729
|
-
return region != null && typeof region === "object" ? region : {}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** A range read as a span, whichever way round the author wrote it. */
|
|
733
|
-
private static span([a, b]: Range): Range {
|
|
734
|
-
return a <= b ? [a, b] : [b, a]
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Rescale an axis to `range`, keeping the tick *count* it already has — the
|
|
739
|
-
* axis tween interpolates ticks pairwise by index, so a constant count lets
|
|
740
|
-
* each marker slide from its old value to its new one instead of the row
|
|
741
|
-
* popping.
|
|
742
|
-
*/
|
|
743
|
-
private static rescale(range: Range, ticks: number[]): AxisDomainConfig {
|
|
744
|
-
const span = LineChart.span(range)
|
|
745
|
-
const count = Math.max(2, ticks.length)
|
|
746
|
-
return { range: span, ticks: markerValues(span, count - 1) }
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Zoom the plot to a slice of itself, given in **data units**: the named axes
|
|
751
|
-
* rescale so that slice fills the plot, the grid and both marker rows
|
|
752
|
-
* following the new scale, and every series re-tracing itself into the new
|
|
753
|
-
* frame. All of it tweens, so the move reads as a camera push rather than a
|
|
754
|
-
* cut.
|
|
755
|
-
*
|
|
756
|
-
* An **omitted axis is left as it is** rather than being fitted to what's
|
|
757
|
-
* visible — so a zoom is exactly what was asked for, and two calls compose
|
|
758
|
-
* predictably. A spotlit region, if one is up, follows the zoom: it resolves
|
|
759
|
-
* against the same live scales, so its rules stay on their data bounds.
|
|
760
|
-
*/
|
|
761
|
-
@command({
|
|
762
|
-
args: [
|
|
763
|
-
// One argument, two controls: the method takes a `PlotRegion`, so the x
|
|
764
|
-
// and y spans a host draws are declared inside it.
|
|
765
|
-
{
|
|
766
|
-
key: "region", kind: "region", properties: {
|
|
767
|
-
x: { kind: "range", default: true, defaultMin: 0, defaultMax: 100 },
|
|
768
|
-
y: { kind: "range", default: false, defaultMin: 0, defaultMax: 100 },
|
|
769
|
-
},
|
|
523
|
+
},
|
|
524
|
+
/** Drop the region call-out. A no-op when nothing is spotlit, which still takes its slot. */
|
|
525
|
+
clearRegion: {
|
|
526
|
+
args: {},
|
|
527
|
+
easing: easeOut(),
|
|
528
|
+
run: (timeline, state) => clearing(timeline, state),
|
|
529
|
+
},
|
|
530
|
+
/**
|
|
531
|
+
* Zoom the plot to a slice of itself, given in **data units**: the named axes
|
|
532
|
+
* rescale so that slice fills the plot, the grid, both marker rows and every
|
|
533
|
+
* series following. An omitted axis is left as it is. The first zoom
|
|
534
|
+
* remembers the domain it left, for `resetZoom`.
|
|
535
|
+
*/
|
|
536
|
+
zoomTo: {
|
|
537
|
+
args: { region: REGION_ARG },
|
|
538
|
+
easing: easeOut(),
|
|
539
|
+
run: (timeline, state) => {
|
|
540
|
+
const region = regionArg(timeline.args.region)
|
|
541
|
+
if (!region.x && !region.y) return timeline.wait(timeline.duration)
|
|
542
|
+
const from = lineChartDomains(state)
|
|
543
|
+
const home = state.home ?? from
|
|
544
|
+
const to = {
|
|
545
|
+
...(region.x && { x: { ...from.x, ...rescale(region.x, from.x.ticks) } }),
|
|
546
|
+
...(region.y && { y: { ...from.y, ...rescale(region.y, from.y.ticks) } }),
|
|
547
|
+
}
|
|
548
|
+
return timeline.animate((t) => ({ home, ...axesAt(from, to, t) }))
|
|
770
549
|
},
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
// Capture the resting domain *before* the first zoom moves it. Ticks are
|
|
782
|
-
// read through the resolved getters rather than off `xAxis`/`yAxis`, so an
|
|
783
|
-
// auto-derived tick row comes back as itself.
|
|
784
|
-
this.home ??= {
|
|
785
|
-
x: {
|
|
786
|
-
label: this.xAxis.label,
|
|
787
|
-
range: this.xScale,
|
|
788
|
-
ticks: this.resolvedXMarkers,
|
|
550
|
+
},
|
|
551
|
+
/** Pull back out to the domain the first zoom left — the mirror of `zoomTo`, and a hold if it never zoomed. */
|
|
552
|
+
resetZoom: {
|
|
553
|
+
args: {},
|
|
554
|
+
easing: easeOut(),
|
|
555
|
+
run: (timeline, state) => {
|
|
556
|
+
const home = state.home
|
|
557
|
+
if (!home) return timeline.wait(timeline.duration)
|
|
558
|
+
const from = lineChartDomains(state)
|
|
559
|
+
return timeline.animate((t) => ({ home: null, ...axesAt(from, home, t) }))
|
|
789
560
|
},
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
561
|
+
},
|
|
562
|
+
/**
|
|
563
|
+
* Show series `index`: **trace** its line in left to right, in lockstep with
|
|
564
|
+
* its legend entry growing in.
|
|
565
|
+
*/
|
|
566
|
+
showSeries: {
|
|
567
|
+
args: { seriesIndex: SERIES_ARG },
|
|
568
|
+
easing: easeOut(),
|
|
569
|
+
run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, true),
|
|
570
|
+
},
|
|
571
|
+
/** Hide series `index`: **retrace** its line back out as its legend entry shrinks away. */
|
|
572
|
+
hideSeries: {
|
|
573
|
+
args: { seriesIndex: SERIES_ARG },
|
|
574
|
+
easing: easeOut(),
|
|
575
|
+
run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, false),
|
|
576
|
+
},
|
|
577
|
+
/**
|
|
578
|
+
* Fade every other series — each line **and its legend entry** — to focus on
|
|
579
|
+
* `index`, which comes back to full. The legend dims in place, so the row
|
|
580
|
+
* doesn't reflow.
|
|
581
|
+
*/
|
|
582
|
+
spotlightSeries: {
|
|
583
|
+
args: { seriesIndex: SERIES_ARG },
|
|
584
|
+
easing: easeOut(),
|
|
585
|
+
run: (timeline, _state, children) => {
|
|
586
|
+
const parts = partsOf(children)
|
|
587
|
+
if (!parts) return timeline.wait(timeline.duration)
|
|
588
|
+
const index = timeline.args.seriesIndex
|
|
589
|
+
return timeline.together([
|
|
590
|
+
...parts.lines.map((line, i) => line.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
|
|
591
|
+
...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
|
|
592
|
+
])
|
|
794
593
|
},
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (region.x) next.xAxis = LineChart.rescale(region.x, this.resolvedXMarkers)
|
|
799
|
-
if (region.y) next.yAxis = LineChart.rescale(region.y, this.resolvedYMarkers)
|
|
800
|
-
|
|
801
|
-
return this.to({ data: next, duration, easing })
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/**
|
|
805
|
-
* Pull back out to the domain the chart was authored with — the mirror of
|
|
806
|
-
* {@link zoomTo}, and a no-op if it was never zoomed.
|
|
807
|
-
*/
|
|
808
|
-
@command()
|
|
809
|
-
resetZoom(
|
|
810
|
-
args: CommandArgs<Record<string, never>> & { duration?: number } = {}
|
|
811
|
-
): Seekable {
|
|
812
|
-
const duration = args.duration ?? 0.8
|
|
813
|
-
const easing = args.easing ?? easeOut()
|
|
814
|
-
const home = this.home
|
|
815
|
-
if (!home) return hold(duration)
|
|
816
|
-
|
|
817
|
-
// Cleared here rather than once the move lands. Nothing reads `home` while
|
|
818
|
-
// it runs, and a value has no "after" to clear it in — what matters is that
|
|
819
|
-
// the *next* `zoomTo` captures wherever the chart genuinely rests by then,
|
|
820
|
-
// and both this and its capture happen as the command list is walked.
|
|
821
|
-
this.home = null
|
|
822
|
-
return this.to({ data: { xAxis: home.x, yAxis: home.y }, duration, easing })
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// ---- Per-series -------------------------------------------------------
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Show series `index`: **trace** its line in left-to-right — the same draw as
|
|
829
|
-
* {@link enter}, not an opacity fade — in lockstep with its legend entry
|
|
830
|
-
* growing in.
|
|
831
|
-
*
|
|
832
|
-
* `enabled` is set as part of the command's own value rather than before it,
|
|
833
|
-
* so the line is drawable for every `t` the trace covers and undrawn outside
|
|
834
|
-
* it — which is what makes arriving here backwards show the same thing as
|
|
835
|
-
* arriving forwards.
|
|
836
|
-
*/
|
|
837
|
-
@command({ args: [SERIES_ARG] })
|
|
838
|
-
showSeries(
|
|
839
|
-
args: CommandArgs<{ seriesIndex: number }> & { duration?: number }
|
|
840
|
-
): Seekable {
|
|
841
|
-
const index = args.data?.seriesIndex ?? 0
|
|
842
|
-
const duration = args.duration ?? 0.35
|
|
843
|
-
const easing = args.easing ?? easeOut()
|
|
844
|
-
const line = this.lines[index]
|
|
845
|
-
if (!line) return hold(duration)
|
|
846
|
-
|
|
847
|
-
return together(
|
|
848
|
-
line.enterFrom(0, duration, easing),
|
|
849
|
-
...(this.legend ? [this.legend.showSeries({ data: { index }, duration, easing })] : [])
|
|
850
|
-
)
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Hide series `index`: **retrace** its line back out (`progress` 1 → 0) as its
|
|
855
|
-
* legend entry shrinks away — the mirror of {@link showSeries}.
|
|
856
|
-
*/
|
|
857
|
-
@command({ args: [SERIES_ARG] })
|
|
858
|
-
hideSeries(
|
|
859
|
-
args: CommandArgs<{ seriesIndex: number }> & { duration?: number }
|
|
860
|
-
): Seekable {
|
|
861
|
-
const index = args.data?.seriesIndex ?? 0
|
|
862
|
-
const duration = args.duration ?? 0.35
|
|
863
|
-
const easing = args.easing ?? easeOut()
|
|
864
|
-
const line = this.lines[index]
|
|
865
|
-
if (!line) return hold(duration)
|
|
866
|
-
|
|
867
|
-
return together(
|
|
868
|
-
line.enterFrom(1, duration, easing, 0),
|
|
869
|
-
...(this.legend ? [this.legend.hideSeries({ data: { index }, duration, easing })] : [])
|
|
870
|
-
)
|
|
871
|
-
}
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
})
|
|
872
597
|
|
|
873
|
-
|
|
874
|
-
* Fade out all other series — each line **and its legend entry** — to focus
|
|
875
|
-
* attention on `index`, which comes back to full. The legend dims in place
|
|
876
|
-
* (opacity only), so no entry moves and the row doesn't reflow.
|
|
877
|
-
*/
|
|
878
|
-
@command({ args: [SERIES_ARG] })
|
|
879
|
-
spotlightSeries(
|
|
880
|
-
args: CommandArgs<{ seriesIndex: number }> & { duration?: number }
|
|
881
|
-
): Seekable {
|
|
882
|
-
const index = args.data?.seriesIndex ?? 0
|
|
883
|
-
const duration = args.duration ?? 0.35
|
|
884
|
-
const easing = args.easing ?? easeOut()
|
|
885
|
-
return together(
|
|
886
|
-
...this.lines.map((line, i) =>
|
|
887
|
-
line.to({ data: { opacity: i === index ? 1 : SPOTLIGHT_DIM }, duration, easing })
|
|
888
|
-
),
|
|
889
|
-
...(this.legend
|
|
890
|
-
? [this.legend.spotlightSeries({ data: { index }, duration, easing })]
|
|
891
|
-
: [])
|
|
892
|
-
)
|
|
893
|
-
}
|
|
894
|
-
}
|
|
598
|
+
export type LineChart = NodeOf<typeof LineChart>
|