@motionscript/charts 0.1.0 → 0.1.1-alpha.1
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 +38 -0
- package/README.md +98 -28
- package/dist/bar-chart/bar-chart.d.ts +242 -119
- package/dist/bar-chart/bar-chart.d.ts.map +1 -1
- package/dist/bar-chart/bar-chart.js +231 -260
- package/dist/bar-chart/bar-chart.js.map +1 -1
- package/dist/bar-chart/category-axis.d.ts +16 -38
- package/dist/bar-chart/category-axis.d.ts.map +1 -1
- package/dist/bar-chart/category-axis.js +59 -68
- package/dist/bar-chart/category-axis.js.map +1 -1
- package/dist/bar-chart/chart-bars.d.ts +60 -91
- package/dist/bar-chart/chart-bars.d.ts.map +1 -1
- package/dist/bar-chart/chart-bars.js +88 -125
- package/dist/bar-chart/chart-bars.js.map +1 -1
- package/dist/bar-chart/index.d.ts +5 -13
- package/dist/bar-chart/index.d.ts.map +1 -1
- package/dist/bar-chart/index.js +2 -9
- package/dist/bar-chart/index.js.map +1 -1
- package/dist/bar-chart/shared.d.ts +40 -132
- package/dist/bar-chart/shared.d.ts.map +1 -1
- package/dist/bar-chart/shared.js +70 -158
- package/dist/bar-chart/shared.js.map +1 -1
- package/dist/browser/chunks/chunk-R3Q5EE6E.js +2 -0
- package/dist/browser/chunks/chunk-R3Q5EE6E.js.map +7 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +4 -4
- package/dist/browser/kit.js +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/engine.d.ts +8 -17
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +8 -17
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kit/cartesian.d.ts +49 -35
- package/dist/kit/cartesian.d.ts.map +1 -1
- package/dist/kit/cartesian.js +80 -42
- package/dist/kit/cartesian.js.map +1 -1
- package/dist/kit/chart-canvas.d.ts +37 -40
- package/dist/kit/chart-canvas.d.ts.map +1 -1
- package/dist/kit/chart-canvas.js +53 -47
- package/dist/kit/chart-canvas.js.map +1 -1
- package/dist/kit/chart-legend.d.ts +16 -59
- package/dist/kit/chart-legend.d.ts.map +1 -1
- package/dist/kit/chart-legend.js +38 -76
- package/dist/kit/chart-legend.js.map +1 -1
- package/dist/kit/entries.d.ts +2 -0
- package/dist/kit/entries.d.ts.map +1 -1
- package/dist/kit/entries.js +2 -0
- package/dist/kit/entries.js.map +1 -1
- package/dist/kit/horizontal-axis.d.ts +77 -0
- package/dist/kit/horizontal-axis.d.ts.map +1 -0
- package/dist/kit/horizontal-axis.js +99 -0
- package/dist/kit/horizontal-axis.js.map +1 -0
- package/dist/kit/motion.d.ts +16 -3
- package/dist/kit/motion.d.ts.map +1 -1
- package/dist/kit/motion.js +15 -1
- package/dist/kit/motion.js.map +1 -1
- package/dist/{pie-chart → kit}/pie-legend.d.ts +14 -29
- package/dist/kit/pie-legend.d.ts.map +1 -0
- package/dist/kit/pie-legend.js +93 -0
- package/dist/kit/pie-legend.js.map +1 -0
- package/dist/kit/polar.d.ts +31 -0
- package/dist/kit/polar.d.ts.map +1 -0
- package/dist/kit/polar.js +33 -0
- package/dist/kit/polar.js.map +1 -0
- package/dist/kit/shared.d.ts +60 -214
- package/dist/kit/shared.d.ts.map +1 -1
- package/dist/kit/shared.js +91 -291
- package/dist/kit/shared.js.map +1 -1
- package/dist/kit/style.d.ts +254 -0
- package/dist/kit/style.d.ts.map +1 -0
- package/dist/kit/style.js +197 -0
- package/dist/kit/style.js.map +1 -0
- package/dist/kit/vertical-axis.d.ts +36 -45
- package/dist/kit/vertical-axis.d.ts.map +1 -1
- package/dist/kit/vertical-axis.js +69 -81
- package/dist/kit/vertical-axis.js.map +1 -1
- package/dist/line-chart/chart-line.d.ts +107 -40
- package/dist/line-chart/chart-line.d.ts.map +1 -1
- package/dist/line-chart/chart-line.js +172 -89
- package/dist/line-chart/chart-line.js.map +1 -1
- package/dist/line-chart/chart-region.d.ts +14 -27
- package/dist/line-chart/chart-region.d.ts.map +1 -1
- package/dist/line-chart/chart-region.js +13 -28
- package/dist/line-chart/chart-region.js.map +1 -1
- package/dist/line-chart/index.d.ts +5 -18
- package/dist/line-chart/index.d.ts.map +1 -1
- package/dist/line-chart/index.js +3 -16
- package/dist/line-chart/index.js.map +1 -1
- package/dist/line-chart/line-chart.d.ts +272 -119
- package/dist/line-chart/line-chart.d.ts.map +1 -1
- package/dist/line-chart/line-chart.js +309 -257
- package/dist/line-chart/line-chart.js.map +1 -1
- package/dist/line-chart/shared.d.ts +73 -130
- package/dist/line-chart/shared.d.ts.map +1 -1
- package/dist/line-chart/shared.js +195 -156
- package/dist/line-chart/shared.js.map +1 -1
- package/dist/line-chart/x-scale.d.ts +16 -55
- package/dist/line-chart/x-scale.d.ts.map +1 -1
- package/dist/line-chart/x-scale.js +44 -111
- package/dist/line-chart/x-scale.js.map +1 -1
- package/dist/nodes.d.ts +3 -13
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +9 -13
- package/dist/nodes.js.map +1 -1
- package/dist/pie-chart/index.d.ts +3 -15
- package/dist/pie-chart/index.d.ts.map +1 -1
- package/dist/pie-chart/index.js +1 -11
- package/dist/pie-chart/index.js.map +1 -1
- package/dist/pie-chart/pie-chart.d.ts +147 -85
- package/dist/pie-chart/pie-chart.d.ts.map +1 -1
- package/dist/pie-chart/pie-chart.js +65 -86
- package/dist/pie-chart/pie-chart.js.map +1 -1
- package/dist/pie-chart/pie-ring.d.ts +65 -196
- package/dist/pie-chart/pie-ring.d.ts.map +1 -1
- package/dist/pie-chart/pie-ring.js +154 -292
- package/dist/pie-chart/pie-ring.js.map +1 -1
- package/dist/pie-chart/shared.d.ts +78 -172
- package/dist/pie-chart/shared.d.ts.map +1 -1
- package/dist/pie-chart/shared.js +54 -177
- package/dist/pie-chart/shared.js.map +1 -1
- package/dist/radar-chart/index.d.ts +5 -0
- package/dist/radar-chart/index.d.ts.map +1 -0
- package/dist/radar-chart/index.js +5 -0
- package/dist/radar-chart/index.js.map +1 -0
- package/dist/radar-chart/radar-chart.d.ts +218 -0
- package/dist/radar-chart/radar-chart.d.ts.map +1 -0
- package/dist/radar-chart/radar-chart.js +224 -0
- package/dist/radar-chart/radar-chart.js.map +1 -0
- package/dist/radar-chart/radar-grid.d.ts +43 -0
- package/dist/radar-chart/radar-grid.d.ts.map +1 -0
- package/dist/radar-chart/radar-grid.js +61 -0
- package/dist/radar-chart/radar-grid.js.map +1 -0
- package/dist/radar-chart/radar-series.d.ts +59 -0
- package/dist/radar-chart/radar-series.d.ts.map +1 -0
- package/dist/radar-chart/radar-series.js +71 -0
- package/dist/radar-chart/radar-series.js.map +1 -0
- package/dist/radar-chart/shared.d.ts +73 -0
- package/dist/radar-chart/shared.d.ts.map +1 -0
- package/dist/radar-chart/shared.js +123 -0
- package/dist/radar-chart/shared.js.map +1 -0
- package/dist/radial-chart/index.d.ts +6 -0
- package/dist/radial-chart/index.d.ts.map +1 -0
- package/dist/radial-chart/index.js +4 -0
- package/dist/radial-chart/index.js.map +1 -0
- package/dist/radial-chart/radial-chart.d.ts +216 -0
- package/dist/radial-chart/radial-chart.d.ts.map +1 -0
- package/dist/radial-chart/radial-chart.js +174 -0
- package/dist/radial-chart/radial-chart.js.map +1 -0
- package/dist/radial-chart/radial-rings.d.ts +121 -0
- package/dist/radial-chart/radial-rings.d.ts.map +1 -0
- package/dist/radial-chart/radial-rings.js +273 -0
- package/dist/radial-chart/radial-rings.js.map +1 -0
- package/dist/radial-chart/shared.d.ts +73 -0
- package/dist/radial-chart/shared.d.ts.map +1 -0
- package/dist/radial-chart/shared.js +79 -0
- package/dist/radial-chart/shared.js.map +1 -0
- package/package.json +4 -4
- package/registry.json +21 -6
- package/src/bar-chart/bar-chart.ts +274 -315
- package/src/bar-chart/category-axis.ts +53 -88
- package/src/bar-chart/chart-bars.ts +108 -205
- package/src/bar-chart/index.ts +8 -19
- package/src/bar-chart/shared.ts +81 -309
- package/src/engine.ts +8 -17
- package/src/index.ts +2 -0
- package/src/kit/cartesian.ts +118 -55
- package/src/kit/chart-canvas.ts +50 -80
- package/src/kit/chart-legend.ts +36 -97
- package/src/kit/entries.ts +4 -1
- package/src/kit/horizontal-axis.ts +114 -0
- package/src/kit/motion.ts +18 -2
- package/src/{pie-chart → kit}/pie-legend.ts +25 -65
- package/src/kit/polar.ts +54 -0
- package/src/kit/shared.ts +104 -487
- package/src/kit/style.ts +257 -0
- package/src/kit/vertical-axis.ts +81 -115
- package/src/line-chart/chart-line.ts +185 -91
- package/src/line-chart/chart-region.ts +14 -31
- package/src/line-chart/index.ts +14 -22
- package/src/line-chart/line-chart.ts +377 -315
- package/src/line-chart/shared.ts +229 -304
- package/src/line-chart/x-scale.ts +44 -121
- package/src/nodes.ts +9 -13
- package/src/pie-chart/index.ts +3 -35
- package/src/pie-chart/pie-chart.ts +83 -134
- package/src/pie-chart/pie-ring.ts +162 -368
- package/src/pie-chart/shared.ts +92 -379
- package/src/radar-chart/index.ts +4 -0
- package/src/radar-chart/radar-chart.ts +287 -0
- package/src/radar-chart/radar-grid.ts +78 -0
- package/src/radar-chart/radar-series.ts +88 -0
- package/src/radar-chart/shared.ts +146 -0
- package/src/radial-chart/index.ts +5 -0
- package/src/radial-chart/radial-chart.ts +210 -0
- package/src/radial-chart/radial-rings.ts +328 -0
- package/src/radial-chart/shared.ts +114 -0
- package/dist/browser/chunks/chunk-TJNHLQGV.js +0 -2
- package/dist/browser/chunks/chunk-TJNHLQGV.js.map +0 -7
- package/dist/kit/chart-border.d.ts +0 -27
- package/dist/kit/chart-border.d.ts.map +0 -1
- package/dist/kit/chart-border.js +0 -28
- package/dist/kit/chart-border.js.map +0 -1
- package/dist/line-chart/horizontal-axis.d.ts +0 -79
- package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
- package/dist/line-chart/horizontal-axis.js +0 -129
- package/dist/line-chart/horizontal-axis.js.map +0 -1
- package/dist/pie-chart/pie-legend.d.ts.map +0 -1
- package/dist/pie-chart/pie-legend.js +0 -117
- package/dist/pie-chart/pie-legend.js.map +0 -1
- package/src/kit/chart-border.ts +0 -50
- package/src/line-chart/horizontal-axis.ts +0 -151
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Rect,
|
|
3
|
+
Text,
|
|
3
4
|
Types,
|
|
5
|
+
clamp,
|
|
4
6
|
component2d,
|
|
5
7
|
easeOut,
|
|
6
8
|
type ChildView,
|
|
7
9
|
type DataRecord,
|
|
8
|
-
type EntryOptions,
|
|
9
10
|
type NodeOf,
|
|
10
|
-
|
|
11
|
+
type OutOf,
|
|
12
|
+
// Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
|
|
11
13
|
type Node2D,
|
|
12
|
-
type Node2DProps,
|
|
14
|
+
type Node2DProps as _Node2DProps,
|
|
15
|
+
type Fill as _Fill,
|
|
16
|
+
type FillResolved as _FillResolved,
|
|
17
|
+
type Stroke as _Stroke,
|
|
18
|
+
type StrokeResolved as _StrokeResolved,
|
|
13
19
|
type Size2D,
|
|
14
|
-
type
|
|
20
|
+
type StyleInput as _StyleInput,
|
|
21
|
+
type StyleValues as _StyleValues,
|
|
15
22
|
type Vector2,
|
|
16
23
|
} from "@motionscript/sdk"
|
|
17
24
|
import { formatNumber } from "@motionscript/sdk/component"
|
|
18
25
|
|
|
19
|
-
import { cartesianFrame,
|
|
20
|
-
import { ChartBorder } from "../kit/chart-border"
|
|
26
|
+
import { boundText, cartesianFrame, chartViews, columnGhost, fade, frameGap, headroom, laidOutSize, overhang } from "../kit/cartesian"
|
|
21
27
|
import { ChartCanvas } from "../kit/chart-canvas"
|
|
22
28
|
import { ChartLegend } from "../kit/chart-legend"
|
|
23
|
-
import { asParts, fresh } from "../kit/entries"
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
29
|
+
import { SERIES_ARG, asParts, fresh } from "../kit/entries"
|
|
30
|
+
import { HorizontalAxis } from "../kit/horizontal-axis"
|
|
31
|
+
import { DEFAULT_MARKERS, ENTER_SHARE, beats, legendAway, type AnyTimeline, type Beat } from "../kit/motion"
|
|
26
32
|
import {
|
|
27
33
|
SPOTLIGHT_DIM,
|
|
28
34
|
formatMarker,
|
|
@@ -30,66 +36,56 @@ import {
|
|
|
30
36
|
lerpAxisEntry,
|
|
31
37
|
markerStep,
|
|
32
38
|
markerValues,
|
|
39
|
+
niceRange,
|
|
40
|
+
niceTicks,
|
|
33
41
|
resolveAxisDomain,
|
|
34
|
-
resolveSeriesFill,
|
|
35
|
-
resolveSeriesStroke,
|
|
36
42
|
settleAxisDomain,
|
|
37
43
|
type AxisDomainConfig,
|
|
38
44
|
type Range,
|
|
45
|
+
type ResolvedAxisDomain,
|
|
39
46
|
} from "../kit/shared"
|
|
40
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
CARTESIAN_GRID,
|
|
49
|
+
CHART_COLORS,
|
|
50
|
+
CHART_LEGEND,
|
|
51
|
+
CHART_STYLE_FAMILY,
|
|
52
|
+
CHART_STYLE_PRESETS,
|
|
53
|
+
CHART_TEXT,
|
|
54
|
+
axisConfigOf,
|
|
55
|
+
legendConfigOf,
|
|
56
|
+
seriesFill,
|
|
57
|
+
valueText,
|
|
58
|
+
} from "../kit/style"
|
|
59
|
+
import { VerticalAxis, axisPads } from "../kit/vertical-axis"
|
|
60
|
+
import { ChartArea, ChartLine, traceX } from "./chart-line"
|
|
41
61
|
import { ChartRegion } from "./chart-region"
|
|
42
|
-
import { HorizontalAxis } from "./horizontal-axis"
|
|
43
62
|
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
resolveChartTheme,
|
|
63
|
+
LINE_LABELS,
|
|
64
|
+
LINE_MARKS,
|
|
47
65
|
resolveRegionBox,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
type ChartConfig,
|
|
51
|
-
type ChartTheme,
|
|
66
|
+
stackExtent,
|
|
67
|
+
stackSeries,
|
|
52
68
|
type LineSeries,
|
|
69
|
+
type LineStack,
|
|
53
70
|
type PlotRegion,
|
|
54
71
|
type RegionBox,
|
|
72
|
+
type StackedSeries,
|
|
55
73
|
} from "./shared"
|
|
56
74
|
import { resolveXScale, type XScale } from "./x-scale"
|
|
57
75
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** One polyline per entry: which field it plots, its legend `label`, its `fill`. */
|
|
67
|
-
series: LineSeries[]
|
|
68
|
-
/** X-axis data domain: caption, scale range, and tick placement. */
|
|
69
|
-
xAxis?: AxisDomainConfig
|
|
70
|
-
/** Y-axis data domain: caption, scale range, and tick placement. */
|
|
71
|
-
yAxis?: AxisDomainConfig
|
|
72
|
-
/**
|
|
73
|
-
* A slice of the plot to spotlight, in **data units**. Dashed rules mark the
|
|
74
|
-
* bounded edges and every series fades outside the box — see
|
|
75
|
-
* `spotlightRegion`, which animates the same thing. `null` (the default) is no
|
|
76
|
-
* region.
|
|
77
|
-
*/
|
|
78
|
-
region?: PlotRegion | null
|
|
79
|
-
/**
|
|
80
|
-
* The chart's visual theme — series palette, axis text/grid/gutters, plot
|
|
81
|
-
* panel paint, and legend styling. Every branch is optional and deep-merged
|
|
82
|
-
* onto {@link DEFAULT_CHART_THEME}, so pass only what you want to override.
|
|
83
|
-
*/
|
|
84
|
-
theme?: ChartTheme
|
|
85
|
-
/** Whether the legend row below the plot is drawn at all. */
|
|
86
|
-
showLegend?: boolean
|
|
87
|
-
}
|
|
76
|
+
const LINE_STYLE = Types.style(
|
|
77
|
+
CHART_STYLE_FAMILY,
|
|
78
|
+
{ colors: CHART_COLORS, text: CHART_TEXT, grid: CARTESIAN_GRID, marks: LINE_MARKS, labels: LINE_LABELS, legend: CHART_LEGEND },
|
|
79
|
+
{ presets: CHART_STYLE_PRESETS }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
/** A line chart's resolved style. */
|
|
83
|
+
export type LineChartStyle = OutOf<typeof LINE_STYLE>
|
|
88
84
|
|
|
89
85
|
/** Both axes' domains with their ranges and ticks resolved. */
|
|
90
86
|
export interface LineChartDomains {
|
|
91
|
-
readonly x:
|
|
92
|
-
readonly y:
|
|
87
|
+
readonly x: ResolvedAxisDomain
|
|
88
|
+
readonly y: ResolvedAxisDomain
|
|
93
89
|
}
|
|
94
90
|
|
|
95
91
|
/** What a line chart plots, as its entries hold it. */
|
|
@@ -97,16 +93,15 @@ export interface LineChartValues {
|
|
|
97
93
|
readonly data: DataRecord[]
|
|
98
94
|
readonly xField: string
|
|
99
95
|
readonly series: LineSeries[]
|
|
96
|
+
readonly stack: LineStack
|
|
100
97
|
readonly xAxis: AxisDomainConfig
|
|
101
98
|
readonly yAxis: AxisDomainConfig
|
|
99
|
+
/** Each series' share of a stack, `1` when missing. */
|
|
100
|
+
readonly weights?: readonly number[]
|
|
101
|
+
/** An area must reach its baseline, so a chart with areas keeps zero on the scale. */
|
|
102
|
+
readonly style?: { readonly area: string }
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
/**
|
|
105
|
-
* The argument every per-series command takes: an index into `series`. Its own
|
|
106
|
-
* kind rather than `number` so a host can offer the series by name.
|
|
107
|
-
*/
|
|
108
|
-
const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
|
|
109
|
-
|
|
110
105
|
/** One argument, two controls: the commands take a `PlotRegion`, so the x and y spans a host draws are declared inside it. */
|
|
111
106
|
const REGION_ARG = Types.custom<PlotRegion | undefined>(undefined, {
|
|
112
107
|
kind: "region",
|
|
@@ -116,134 +111,142 @@ const REGION_ARG = Types.custom<PlotRegion | undefined>(undefined, {
|
|
|
116
111
|
},
|
|
117
112
|
})
|
|
118
113
|
|
|
119
|
-
/**
|
|
120
|
-
const LEGEND_SLIDE = 20
|
|
121
|
-
|
|
122
|
-
/** Fallback marker count when neither axis's ticks are given. */
|
|
123
|
-
const DEFAULT_MARKERS = 5
|
|
124
|
-
|
|
125
|
-
/**
|
|
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.
|
|
129
|
-
*/
|
|
130
|
-
const AXES_SHARE = 0.35
|
|
131
|
-
const LINES_SHARE = 0.5
|
|
132
|
-
const LEGEND_SHARE = 0.15
|
|
133
|
-
|
|
134
|
-
/** What the chart's own commands read: its entries, and the private `home` a zoom remembers. */
|
|
114
|
+
/** What the chart's own commands read: its entries, and the private state they write. */
|
|
135
115
|
interface LineState extends LineChartValues {
|
|
136
|
-
readonly
|
|
116
|
+
readonly style: LineChartStyle
|
|
137
117
|
readonly region: PlotRegion | null
|
|
138
118
|
readonly regionReveal: number
|
|
139
119
|
readonly home: LineChartDomains | null
|
|
120
|
+
readonly weights: number[]
|
|
140
121
|
}
|
|
141
122
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*/
|
|
123
|
+
/** The share of the y axis a percent stack reads to. */
|
|
124
|
+
const PERCENT: Range = [0, 100]
|
|
125
|
+
|
|
126
|
+
/** What the x column is (a number, an instant, or a place in the file), and so how rows are positioned, ticked and captioned. */
|
|
146
127
|
function columnOf(s: LineChartValues, format = s.xAxis.format): XScale {
|
|
147
128
|
return resolveXScale(s.data, s.xField, format)
|
|
148
129
|
}
|
|
149
130
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
131
|
+
/** Every series as drawn; `weighted` false reads each at full share, as the scale does so the ticks hold still while a series sinks out. */
|
|
132
|
+
function stacksOf(s: LineChartValues, weighted = true): StackedSeries[] {
|
|
133
|
+
return stackSeries(s.data, columnOf(s), s.series, s.stack, weighted ? s.weights : undefined)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The explicit range and ticks, else the column's extent and ticks: only a numeric column has round numbers between its samples. */
|
|
155
137
|
function drawnX(s: LineChartValues, domain: AxisDomainConfig): AxisDomainConfig {
|
|
156
138
|
const column = columnOf(s, domain.format)
|
|
157
139
|
const range = domain.range ?? column.extent
|
|
158
140
|
return { ...domain, range, ticks: domain.ticks ?? column.ticks(range, DEFAULT_MARKERS - 1) }
|
|
159
141
|
}
|
|
160
142
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
143
|
+
/** The y range the data asks for: a percent stack reads to 100; areas and stacks keep zero on the scale. */
|
|
144
|
+
function autoY(s: LineChartValues, divisions: number): Range {
|
|
145
|
+
const [lo, hi] = stackExtent(stacksOf(s, false))
|
|
146
|
+
if (s.stack === "percent" && lo >= 0) return PERCENT
|
|
147
|
+
const zero = s.stack !== "none" || (s.style?.area ?? "none") !== "none"
|
|
148
|
+
return niceRange(zero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi], divisions)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Auto-ranging steps into one fewer segment than the marker count, so the tidy bounds frame the ticks drawn. */
|
|
166
152
|
function drawnY(s: LineChartValues, domain: AxisDomainConfig): AxisDomainConfig {
|
|
167
153
|
const divisions = (domain.ticks?.length ?? DEFAULT_MARKERS) - 1
|
|
168
|
-
const range =
|
|
169
|
-
|
|
154
|
+
const range = domain.range ?? autoY(s, divisions)
|
|
155
|
+
const even = domain.range !== undefined || (s.stack === "percent" && range === PERCENT)
|
|
156
|
+
return { ...domain, range, ticks: domain.ticks ?? (even ? markerValues(range, DEFAULT_MARKERS - 1) : niceTicks(range, DEFAULT_MARKERS - 1)) }
|
|
170
157
|
}
|
|
171
158
|
|
|
172
159
|
/** 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
|
-
>()
|
|
160
|
+
const domainsDrawn = new WeakMap<DataRecord[], { readonly key: readonly unknown[]; readonly domains: LineChartDomains }>()
|
|
177
161
|
|
|
178
|
-
/** Both axes as
|
|
162
|
+
/** Both axes as drawn now. */
|
|
179
163
|
export function lineChartDomains(s: LineChartValues): LineChartDomains {
|
|
180
|
-
const
|
|
181
|
-
const kept = domainsDrawn.get(data)
|
|
182
|
-
if (kept && kept.
|
|
183
|
-
return kept.domains
|
|
184
|
-
}
|
|
164
|
+
const key = [s.xField, s.xAxis, s.yAxis, s.series, s.stack, s.style?.area]
|
|
165
|
+
const kept = domainsDrawn.get(s.data)
|
|
166
|
+
if (kept && kept.key.every((value, i) => value === key[i])) return kept.domains
|
|
185
167
|
const domains = {
|
|
186
|
-
x: settleAxisDomain(xAxis, (d) => drawnX(s, d)) as LineChartDomains["x"],
|
|
187
|
-
y: settleAxisDomain(yAxis, (d) => drawnY(s, d)) as LineChartDomains["y"],
|
|
168
|
+
x: settleAxisDomain(s.xAxis, (d) => drawnX(s, d)) as LineChartDomains["x"],
|
|
169
|
+
y: settleAxisDomain(s.yAxis, (d) => drawnY(s, d)) as LineChartDomains["y"],
|
|
188
170
|
}
|
|
189
|
-
domainsDrawn.set(data, {
|
|
171
|
+
domainsDrawn.set(s.data, { key, domains })
|
|
190
172
|
return domains
|
|
191
173
|
}
|
|
192
174
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
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
|
|
175
|
+
/** Data to the plot's centred pixel space (origin at its centre, y-up). */
|
|
176
|
+
function mapper(domains: LineChartDomains, plot: Size2D) {
|
|
203
177
|
const [xMin, xMax] = domains.x.range
|
|
204
178
|
const [yMin, yMax] = domains.y.range
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
179
|
+
return {
|
|
180
|
+
x: (x: number) => (xMax <= xMin ? 0 : ((x - xMin) / (xMax - xMin) - 0.5) * plot.width),
|
|
181
|
+
y: (y: number) => (yMax === yMin ? 0 : ((y - yMin) / (yMax - yMin) - 0.5) * plot.height),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface SeriesPixels {
|
|
186
|
+
readonly points: Vector2[]
|
|
187
|
+
/** The line under the series in a stack; empty when it fills to the baseline. */
|
|
188
|
+
readonly base: Vector2[]
|
|
189
|
+
/** What each point reads: its own value, or its share in a percent stack. */
|
|
190
|
+
readonly values: number[]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const NO_PIXELS: SeriesPixels = { points: [], base: [], values: [] }
|
|
194
|
+
|
|
195
|
+
/** Series `index` in the plot's centred pixel space. */
|
|
196
|
+
function seriesPixels(s: LineChartValues, domains: LineChartDomains, index: number, plot: Size2D): SeriesPixels {
|
|
197
|
+
const stack = stacksOf(s)[index]
|
|
198
|
+
if (!stack) return NO_PIXELS
|
|
199
|
+
const map = mapper(domains, plot)
|
|
200
|
+
const toPx = ([x, y]: [number, number]): Vector2 => ({ x: map.x(x), y: map.y(y) })
|
|
201
|
+
return {
|
|
202
|
+
points: stack.points.map(toPx),
|
|
203
|
+
base: stack.base ? stack.base.map(toPx) : [],
|
|
204
|
+
values: stack.points.map(([, y], k) => y - (stack.base?.[k]?.[1] ?? 0)),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Where the areas fill down to: zero, held inside the scale. */
|
|
209
|
+
function baselineValue(domains: LineChartDomains): number {
|
|
210
|
+
const [lo, hi] = domains.y.range
|
|
211
|
+
return clamp(0, Math.min(lo, hi), Math.max(lo, hi))
|
|
209
212
|
}
|
|
210
213
|
|
|
211
|
-
/**
|
|
214
|
+
/** Whether anything plotted runs past the scale, so the plot clips it: a zoom, or an author's range narrower than the data. */
|
|
215
|
+
function overflows(s: LineChartValues, domains: LineChartDomains): boolean {
|
|
216
|
+
const [ylo, yhi] = stackExtent(stacksOf(s, false))
|
|
217
|
+
const [xlo, xhi] = columnOf(s).extent
|
|
218
|
+
const within = (v: number, [a, b]: Range) => v >= Math.min(a, b) - 1e-9 && v <= Math.max(a, b) + 1e-9
|
|
219
|
+
return !(within(ylo, domains.y.range) && within(yhi, domains.y.range) && within(xlo, domains.x.range) && within(xhi, domains.x.range))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `region` in centred pixel space against the live scales, so it travels with a zoom. */
|
|
212
223
|
function regionBox(s: LineChartValues & { readonly region: PlotRegion | null }, domains: LineChartDomains, plot: Size2D): RegionBox {
|
|
213
224
|
const { width: w, height: h } = plot
|
|
214
225
|
return resolveRegionBox(s.region, { left: -w / 2, right: w / 2, bottom: -h / 2, top: h / 2 }, domains.x.range, domains.y.range)
|
|
215
226
|
}
|
|
216
227
|
|
|
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
|
-
*/
|
|
228
|
+
/** Whether a call-out is authored, not whether it resolves to pixels: a command is built before the first layout. A region bounding neither axis is none. */
|
|
222
229
|
function hasCallout(s: { readonly region: PlotRegion | null }): boolean {
|
|
223
230
|
const region = s.region
|
|
224
231
|
return region != null && (region.x !== undefined || region.y !== undefined)
|
|
225
232
|
}
|
|
226
233
|
|
|
227
|
-
/** The `region`
|
|
234
|
+
/** The `region` argument, or `{}`: a command is added before its arguments are filled in. */
|
|
228
235
|
function regionArg(region: PlotRegion | null | undefined): PlotRegion {
|
|
229
236
|
return region != null && typeof region === "object" ? region : {}
|
|
230
237
|
}
|
|
231
238
|
|
|
232
|
-
/** A range
|
|
239
|
+
/** A range ordered low to high. */
|
|
233
240
|
function span([a, b]: Range): Range {
|
|
234
241
|
return a <= b ? [a, b] : [b, a]
|
|
235
242
|
}
|
|
236
243
|
|
|
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
|
-
*/
|
|
244
|
+
/** Rescale to `range` keeping the tick count, so each marker slides rather than the row popping. */
|
|
241
245
|
function rescale(range: Range, ticks: readonly number[]): AxisDomainConfig {
|
|
242
246
|
const to = span(range)
|
|
243
247
|
return { range: to, ticks: markerValues(to, Math.max(2, ticks.length) - 1) }
|
|
244
248
|
}
|
|
245
249
|
|
|
246
|
-
/** The axis writes of a move from `from` toward `to` at `t`. */
|
|
247
250
|
function axesAt(from: LineChartDomains, to: Partial<Record<"x" | "y", AxisDomainConfig>>, t: number) {
|
|
248
251
|
return {
|
|
249
252
|
...(to.x && { xAxis: lerpAxisDomain(from.x, to.x, t) }),
|
|
@@ -251,256 +254,327 @@ function axesAt(from: LineChartDomains, to: Partial<Record<"x" | "y", AxisDomain
|
|
|
251
254
|
}
|
|
252
255
|
}
|
|
253
256
|
|
|
254
|
-
|
|
257
|
+
/** A value as the y axis writes it: the author's pattern, a share in a percent stack, else the bare reading at the ticks' precision. */
|
|
258
|
+
function yLabel(s: LineChartValues, value: number, ticks: readonly number[]): string {
|
|
259
|
+
const format = s.yAxis.format
|
|
260
|
+
if (format !== undefined) return formatNumber(value, format)
|
|
261
|
+
const text = formatMarker(value, markerStep(ticks))
|
|
262
|
+
return s.stack === "percent" ? `${text}%` : text
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function weightOf(s: { readonly weights: readonly number[] }, index: number): number {
|
|
266
|
+
return Math.max(0, s.weights[index] ?? 1)
|
|
267
|
+
}
|
|
255
268
|
|
|
256
269
|
/** The parts a line chart's commands drive, found through its children. */
|
|
257
270
|
function partsOf(children: readonly ChildView[]) {
|
|
258
|
-
const
|
|
259
|
-
if (!frame) return null
|
|
260
|
-
const hAxis = frame.below.find(HorizontalAxis.is)
|
|
261
|
-
if (!VerticalAxis.is(frame.axis) || !hAxis) return null
|
|
271
|
+
const views = chartViews(children)
|
|
262
272
|
return {
|
|
263
|
-
vAxis:
|
|
264
|
-
hAxis,
|
|
265
|
-
lines:
|
|
266
|
-
legend:
|
|
273
|
+
vAxis: views.find(VerticalAxis.is) ?? null,
|
|
274
|
+
hAxis: views.find(HorizontalAxis.is) ?? null,
|
|
275
|
+
lines: views.filter(ChartLine.is),
|
|
276
|
+
legend: views.find(ChartLegend.is) ?? null,
|
|
267
277
|
}
|
|
268
278
|
}
|
|
269
279
|
|
|
270
|
-
/** Drop the call-out: the rules retract, the wash fades, and every series comes back to full. */
|
|
271
280
|
function clearing(timeline: AnyTimeline, state: LineState) {
|
|
272
281
|
const from = state.regionReveal
|
|
273
282
|
const region = state.region
|
|
274
|
-
// Nothing to retract, but the call still takes its slot
|
|
283
|
+
// Nothing to retract, but the call still takes its slot.
|
|
275
284
|
if (from === 0) return timeline.animate(() => ({ region: null, regionReveal: 0 }), { eased: false })
|
|
276
285
|
// `region` is held until the retraction lands, or there would be nothing left to retract.
|
|
277
286
|
return timeline.animate((t) => (t >= 1 ? { region: null, regionReveal: 0 } : { region, regionReveal: from * (1 - t) }))
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Show or hide series `index` with its legend entry. Apart, the line retraces;
|
|
291
|
+
* in a stack it sinks into the series below as its share falls to nothing, so
|
|
292
|
+
* the ones above settle rather than leaving a gap.
|
|
293
|
+
*/
|
|
294
|
+
function toggling(timeline: AnyTimeline, state: LineState, children: readonly ChildView[], index: number, show: boolean) {
|
|
282
295
|
const parts = partsOf(children)
|
|
283
|
-
const line = parts
|
|
284
|
-
if (!
|
|
296
|
+
const line = parts.lines[index]
|
|
297
|
+
if (!line) return timeline.wait(timeline.duration)
|
|
298
|
+
const legend = parts.legend ? [parts.legend.run(show ? "showSeries" : "hideSeries", { index })] : []
|
|
299
|
+
if (state.stack === "none") {
|
|
300
|
+
return timeline.together([line.run("enter", show ? { from: 0, to: 1 } : { from: 1, to: 0 }), ...legend])
|
|
301
|
+
}
|
|
302
|
+
const from = state.series.map((_, i) => weightOf(state, i))
|
|
303
|
+
const start = from[index] ?? 1
|
|
304
|
+
const target = show ? 1 : 0
|
|
285
305
|
return timeline.together([
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
timeline.animate((t) => {
|
|
307
|
+
const weights = [...from]
|
|
308
|
+
weights[index] = start + (target - start) * timeline.ease(t)
|
|
309
|
+
return { weights }
|
|
310
|
+
}, { eased: false }),
|
|
311
|
+
line.run("hold", { shown: show }),
|
|
312
|
+
...legend,
|
|
288
313
|
])
|
|
289
314
|
}
|
|
290
315
|
|
|
291
316
|
/**
|
|
292
|
-
* A multi-series line chart
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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.
|
|
317
|
+
* A multi-series line or area chart composed of {@link ChartCanvas}, the value
|
|
318
|
+
* and x axes, {@link ChartRegion}, a {@link ChartArea} and a {@link ChartLine}
|
|
319
|
+
* per series, and {@link ChartLegend}. Every part reads the chart's entries
|
|
320
|
+
* through bindings and maps against the plot panel's own laid-out box.
|
|
307
321
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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.
|
|
322
|
+
* Resting state is the finished chart; `enter` arms every part on its first
|
|
323
|
+
* frame. A `to` of an axis `range` or `ticks` tweens from the domain as drawn
|
|
324
|
+
* (see {@link lerpAxisEntry}), so an auto axis rescales continuously.
|
|
317
325
|
*/
|
|
318
326
|
export const LineChart = component2d({
|
|
319
327
|
key: "lineChart",
|
|
320
328
|
state: {
|
|
321
329
|
data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
|
|
330
|
+
// Not `x`: every node already has an `x`, its position.
|
|
322
331
|
xField: "",
|
|
323
332
|
series: fresh<LineSeries[]>(() => [], { kind: "series" }),
|
|
333
|
+
/** `stacked`: each series sits on the sum of those before it; `percent`: every row reads as shares of 100. */
|
|
334
|
+
stack: Types.oneOf(["none", "stacked", "percent"] as const),
|
|
324
335
|
xAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
|
|
325
336
|
yAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
|
|
326
|
-
|
|
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
|
-
*/
|
|
337
|
+
style: LINE_STYLE,
|
|
338
|
+
/** The spotlit slice in data units, or `null`. The box snaps; {@link regionReveal} animates it. */
|
|
331
339
|
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
|
-
*/
|
|
340
|
+
/** How far the `region` call-out is drawn in, `[0, 1]`: rules grow, wash fades up, out-of-region stretches fade. */
|
|
337
341
|
regionReveal: 0,
|
|
338
|
-
/**
|
|
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
|
-
*/
|
|
342
|
+
/** The domain `zoomTo` first left, so `resetZoom` restores it, auto-derived ticks included. */
|
|
345
343
|
home: Types.custom<LineChartDomains | null>(null, { private: true }),
|
|
344
|
+
/** Each series' share of a stack, seeded from `enabled`: a weight rather than a switch, so a hidden series sinks out. */
|
|
345
|
+
weights: Types.custom<number[]>([], { private: true }),
|
|
346
|
+
/** Whether the plot clips its marks, from a zoom's first frame until a reset lands. */
|
|
347
|
+
clipped: Types.boolean(false, { private: true }),
|
|
348
|
+
},
|
|
349
|
+
// A node made of parts that fill would otherwise hug them.
|
|
350
|
+
defaults: {
|
|
351
|
+
width: "fill",
|
|
352
|
+
height: "fill",
|
|
353
|
+
weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
|
|
346
354
|
},
|
|
347
|
-
// Its parts fill it; a node made of them would hug them.
|
|
348
|
-
defaults: { width: "fill", height: "fill" },
|
|
349
355
|
allowedChildren: [],
|
|
350
356
|
children: (state) => {
|
|
351
|
-
const
|
|
357
|
+
const style = () => state.style
|
|
352
358
|
const domains = () => lineChartDomains(state)
|
|
353
359
|
let plot: Node2D | undefined
|
|
354
360
|
const plotSize = () => laidOutSize(plot)
|
|
361
|
+
const pixels = (i: number) => seriesPixels(state, domains(), i, plotSize())
|
|
355
362
|
const enabled = state.series.map((s) => s.enabled ?? true)
|
|
363
|
+
const built = state.style
|
|
364
|
+
const yConfig = () => axisConfigOf(style(), "y")
|
|
365
|
+
const xConfig = () => axisConfigOf(style(), "x")
|
|
366
|
+
const yCaptions = () => {
|
|
367
|
+
const ticks = domains().y.ticks
|
|
368
|
+
return ticks.map((value) => yLabel(state, value, ticks))
|
|
369
|
+
}
|
|
356
370
|
|
|
357
371
|
// Bound, not snapshotted, so a rescale re-labels and re-places the row as it
|
|
358
372
|
// tweens. The marker *count* is read once, which is why `zoomTo` keeps it.
|
|
359
|
-
const vAxis =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
373
|
+
const vAxis = built.showYAxis
|
|
374
|
+
? (new VerticalAxis({
|
|
375
|
+
range: () => domains().y.range,
|
|
376
|
+
markers: () => domains().y.ticks,
|
|
377
|
+
captions: yCaptions,
|
|
378
|
+
axis: yConfig,
|
|
379
|
+
label: state.yAxis.label ?? "",
|
|
380
|
+
width: "hug",
|
|
381
|
+
height: "fill",
|
|
382
|
+
}) as unknown as Node2D)
|
|
383
|
+
: undefined
|
|
384
|
+
|
|
385
|
+
const hAxis = built.showXAxis
|
|
386
|
+
? (new HorizontalAxis({
|
|
387
|
+
range: () => domains().x.range,
|
|
388
|
+
markers: () => domains().x.ticks,
|
|
389
|
+
// A month or category tick is captioned with its cell (`Jan`), not the instant it parsed to.
|
|
390
|
+
captions: () => {
|
|
391
|
+
const column = columnOf(state)
|
|
392
|
+
return domains().x.ticks.map((value) => column.label(value))
|
|
393
|
+
},
|
|
394
|
+
axis: xConfig,
|
|
395
|
+
label: state.xAxis.label ?? "",
|
|
396
|
+
overhang: () => ({ left: overhang(style()), right: overhang(style()) }),
|
|
397
|
+
width: "fill",
|
|
398
|
+
height: "hug",
|
|
399
|
+
}) as unknown as Node2D)
|
|
400
|
+
: undefined
|
|
401
|
+
|
|
402
|
+
const ghost = vAxis && hAxis
|
|
403
|
+
? columnGhost({
|
|
404
|
+
captions: yCaptions,
|
|
405
|
+
count: lineChartDomains(state).y.ticks.length,
|
|
406
|
+
text: () => yConfig().labelStyle,
|
|
407
|
+
title: state.yAxis.label ?? "",
|
|
408
|
+
titleText: () => yConfig().titleStyle,
|
|
409
|
+
tickPad: () => axisPads(yConfig(), "right").tick,
|
|
410
|
+
titlePad: () => axisPads(yConfig(), "right").title,
|
|
411
|
+
})
|
|
412
|
+
: undefined
|
|
413
|
+
|
|
414
|
+
const spotlight = () => regionBox(state, domains(), plotSize())
|
|
415
|
+
const dimOpacity = () => 1 - (1 - SPOTLIGHT_DIM) * state.regionReveal
|
|
416
|
+
const lines = state.series.map((s, i) =>
|
|
417
|
+
new ChartLine({
|
|
418
|
+
points: () => pixels(i).points,
|
|
419
|
+
curve: () => style().curve,
|
|
420
|
+
stroke: () => ({ weight: style().strokeWidth, fill: seriesFill(style(), s, i), align: "center", cap: "round", join: "round" }),
|
|
421
|
+
dotSize: () => (style().dots ? style().dotSize : 0),
|
|
422
|
+
dotFill: () => seriesFill(style(), s, i),
|
|
423
|
+
enabled: enabled[i],
|
|
424
|
+
spotlight,
|
|
425
|
+
dimOpacity,
|
|
426
|
+
})
|
|
427
|
+
)
|
|
428
|
+
const areas = state.series.map((s, i) =>
|
|
429
|
+
new ChartArea({
|
|
430
|
+
points: () => pixels(i).points,
|
|
431
|
+
base: () => pixels(i).base,
|
|
432
|
+
baseline: () => mapper(domains(), plotSize()).y(baselineValue(domains())),
|
|
433
|
+
curve: () => style().curve,
|
|
434
|
+
area: () => style().area,
|
|
435
|
+
fill: () => seriesFill(style(), s, i),
|
|
436
|
+
areaOpacity: () => style().areaOpacity,
|
|
437
|
+
// The line's own trace, switch and fade, so the two arrive, leave and dim as one.
|
|
438
|
+
progress: () => lines[i].progress,
|
|
439
|
+
enabled: () => lines[i].enabled,
|
|
440
|
+
opacity: () => lines[i].opacity,
|
|
441
|
+
spotlight,
|
|
442
|
+
dimOpacity,
|
|
443
|
+
})
|
|
401
444
|
)
|
|
402
445
|
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
405
|
-
below.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
446
|
+
const labels: Node2D[] = []
|
|
447
|
+
if (built.values !== "none") {
|
|
448
|
+
const below = built.values === "below"
|
|
449
|
+
state.series.forEach((_, i) => {
|
|
450
|
+
state.data.forEach((_row, k) => {
|
|
451
|
+
const line = lines[i]
|
|
452
|
+
const label: Node2D = new Text({
|
|
453
|
+
...boundText(() => ({ ...valueText(style()), fontSize: Math.round(style().fontSize * 0.8) })),
|
|
454
|
+
text: () => {
|
|
455
|
+
const value = pixels(i).values[k]
|
|
456
|
+
return value === undefined ? "" : yLabel(state, value, domains().y.ticks)
|
|
457
|
+
},
|
|
458
|
+
// Held inside the plot, so a figure on an end point does not run into the axis beside it.
|
|
459
|
+
x: () => {
|
|
460
|
+
const half = (plotSize().width - label.layoutBounds.width) / 2
|
|
461
|
+
return clamp(pixels(i).points[k]?.x ?? 0, -Math.max(0, half), Math.max(0, half))
|
|
462
|
+
},
|
|
463
|
+
y: () => {
|
|
464
|
+
const point = pixels(i).points[k]
|
|
465
|
+
const clear = label.layoutBounds.height / 2 + (style().dots ? style().dotSize / 2 : style().strokeWidth / 2) + Math.round(style().fontSize * 0.25)
|
|
466
|
+
return (point?.y ?? 0) + (below ? -clear : clear)
|
|
467
|
+
},
|
|
468
|
+
// In once the trace has passed its point.
|
|
469
|
+
opacity: () => {
|
|
470
|
+
const point = pixels(i).points[k]
|
|
471
|
+
if (!line.enabled || !point) return 0
|
|
472
|
+
const reach = traceX(pixels(i).points, line.progress)
|
|
473
|
+
return line.opacity * (reach === Infinity ? 1 : clamp((reach - point.x) / 24, 0, 1))
|
|
474
|
+
},
|
|
475
|
+
}) as unknown as Node2D
|
|
476
|
+
labels.push(label)
|
|
477
|
+
})
|
|
478
|
+
})
|
|
418
479
|
}
|
|
419
480
|
|
|
481
|
+
const legendAt = built.legend
|
|
482
|
+
const legend = legendAt !== "none"
|
|
483
|
+
? (new ChartLegend({
|
|
484
|
+
series: state.series,
|
|
485
|
+
swatches: () => state.series.map((s, i) => seriesFill(style(), s, i)),
|
|
486
|
+
legend: () => legendConfigOf(style()),
|
|
487
|
+
flow: legendAt === "right" ? "vertical" : "horizontal",
|
|
488
|
+
}) as unknown as Node2D)
|
|
489
|
+
: undefined
|
|
490
|
+
|
|
420
491
|
plot = new Rect({
|
|
421
|
-
clip:
|
|
492
|
+
clip: () => state.clipped || overflows(state, domains()),
|
|
422
493
|
children: [
|
|
423
494
|
new ChartCanvas({
|
|
424
|
-
fill: () =>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
yGridStroke: () => theme().yAxis.lineStyle,
|
|
428
|
-
// A grid line under every axis marker; the canvas skips the edges.
|
|
495
|
+
fill: () => style().background,
|
|
496
|
+
xGridStroke: () => style().verticalGrid,
|
|
497
|
+
yGridStroke: () => style().horizontalGrid,
|
|
429
498
|
xMarkers: () => domains().x.ticks,
|
|
430
499
|
yMarkers: () => domains().y.ticks,
|
|
431
500
|
xExtent: () => domains().x.range,
|
|
432
501
|
yExtent: () => domains().y.range,
|
|
502
|
+
axisStroke: () => style().axisStroke,
|
|
503
|
+
xAxisAt: () => baselineValue(domains()),
|
|
504
|
+
yAxisAt: () => domains().x.range[0],
|
|
433
505
|
}),
|
|
434
|
-
//
|
|
435
|
-
// tinted by the highlight meant to pick it out.
|
|
506
|
+
// Under the marks, so the highlight does not tint the stretch it picks out.
|
|
436
507
|
new ChartRegion({
|
|
437
|
-
box:
|
|
438
|
-
stroke: () =>
|
|
439
|
-
fill: () =>
|
|
508
|
+
box: spotlight,
|
|
509
|
+
stroke: () => ({ weight: 2, fill: fade(style().foreground, 0.7), dash: [14, 12], cap: "butt", align: "center" }),
|
|
510
|
+
fill: () => fade(style().foreground, 0.06),
|
|
440
511
|
reveal: () => state.regionReveal,
|
|
441
512
|
}),
|
|
513
|
+
...areas,
|
|
442
514
|
...lines,
|
|
443
|
-
|
|
515
|
+
...labels,
|
|
444
516
|
],
|
|
445
517
|
}) as unknown as Node2D
|
|
446
518
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
519
|
+
return asParts([
|
|
520
|
+
cartesianFrame({
|
|
521
|
+
left: vAxis,
|
|
522
|
+
ghost,
|
|
523
|
+
plot,
|
|
524
|
+
bottom: hAxis,
|
|
525
|
+
legend,
|
|
526
|
+
legendAt,
|
|
527
|
+
gap: () => frameGap(style()),
|
|
528
|
+
padding: () => ({
|
|
529
|
+
top: headroom(style()),
|
|
530
|
+
right: hAxis ? overhang(style()) : 0,
|
|
531
|
+
left: hAxis && !vAxis ? overhang(style()) : 0,
|
|
532
|
+
bottom: 0,
|
|
533
|
+
}),
|
|
534
|
+
}),
|
|
535
|
+
])
|
|
458
536
|
},
|
|
459
537
|
commands: {
|
|
460
538
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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.
|
|
539
|
+
* Axes stagger in, lines (and their areas) trace left to right, the legend
|
|
540
|
+
* fades up last. `duration` is the whole entrance; a chart with a `region`
|
|
541
|
+
* lands its call-out in the final quarter.
|
|
468
542
|
*/
|
|
469
543
|
enter: {
|
|
470
544
|
args: {},
|
|
471
|
-
// Held from
|
|
545
|
+
// Held from arrival, so it never shows finished ahead of its draw.
|
|
472
546
|
entrance: true,
|
|
473
547
|
easing: easeOut(),
|
|
474
548
|
run: (timeline, state, children) => {
|
|
475
549
|
const parts = partsOf(children)
|
|
476
|
-
if (!parts) return timeline.wait(timeline.duration)
|
|
477
550
|
const duration = timeline.duration
|
|
478
551
|
const revealing = hasCallout(state) && state.regionReveal === 0
|
|
479
552
|
const body = revealing ? duration * 0.75 : duration
|
|
480
|
-
const axes = body *
|
|
481
|
-
const traced = body *
|
|
553
|
+
const axes = body * ENTER_SHARE.axes
|
|
554
|
+
const traced = body * ENTER_SHARE.marks
|
|
482
555
|
const { legend } = parts
|
|
483
556
|
const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
|
|
557
|
+
const away = legendAway(state.style.legend)
|
|
484
558
|
// The first frame arms what every later beat moves: a beat ahead of the
|
|
485
559
|
// playhead writes nothing. A beat with nothing to do still takes its share.
|
|
486
560
|
return beats(timeline, [
|
|
487
561
|
...(revealing ? [at0(timeline.animate(() => ({ regionReveal: 0 })))] : []),
|
|
488
562
|
...parts.lines.map((line) => at0(line.to({ progress: 0 }))),
|
|
489
|
-
...(legend ? [at0(legend.to({ opacity: 0,
|
|
490
|
-
{ at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
|
|
491
|
-
{ at: 0, duration: axes, piece: parts.hAxis.run("enter", { slide: 24 }) },
|
|
563
|
+
...(legend ? [at0(legend.to({ opacity: 0, ...away }))] : []),
|
|
564
|
+
...(parts.vAxis ? [{ at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) }] : []),
|
|
565
|
+
...(parts.hAxis ? [{ at: 0, duration: axes, piece: parts.hAxis.run("enter", { slide: 24 }) }] : []),
|
|
492
566
|
...parts.lines.map((line) => ({ at: axes, duration: traced, piece: line.run("enter") })),
|
|
493
|
-
...(legend ? [{ at: axes + traced, duration: body *
|
|
567
|
+
...(legend ? [{ at: axes + traced, duration: body * ENTER_SHARE.legend, piece: legend.to({ opacity: 1, x: 0, y: 0 }) }] : []),
|
|
494
568
|
...(revealing ? [{ at: body, duration: duration * 0.25, piece: timeline.animate((t) => ({ regionReveal: t })) }] : []),
|
|
569
|
+
// Takes the whole slot even with no parts to move.
|
|
570
|
+
{ at: 0, duration, piece: timeline.wait(duration) },
|
|
495
571
|
])
|
|
496
572
|
},
|
|
497
573
|
},
|
|
498
574
|
/**
|
|
499
|
-
* Spotlight a
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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.
|
|
575
|
+
* Spotlight a slice of the plot in data units; either axis alone is a band.
|
|
576
|
+
* Over a region already up, the old one retracts in the first half and the
|
|
577
|
+
* new one draws in over the second, each half eased on its own.
|
|
504
578
|
*/
|
|
505
579
|
spotlightRegion: {
|
|
506
580
|
args: { region: REGION_ARG },
|
|
@@ -521,18 +595,13 @@ export const LineChart = component2d({
|
|
|
521
595
|
)
|
|
522
596
|
},
|
|
523
597
|
},
|
|
524
|
-
/** Drop the
|
|
598
|
+
/** Drop the call-out. A no-op when nothing is spotlit, which still takes its slot. */
|
|
525
599
|
clearRegion: {
|
|
526
600
|
args: {},
|
|
527
601
|
easing: easeOut(),
|
|
528
602
|
run: (timeline, state) => clearing(timeline, state),
|
|
529
603
|
},
|
|
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
|
-
*/
|
|
604
|
+
/** Rescale the named axes so a slice (data units) fills the plot; an omitted axis is left. The first zoom remembers the domain for `resetZoom`. */
|
|
536
605
|
zoomTo: {
|
|
537
606
|
args: { region: REGION_ARG },
|
|
538
607
|
easing: easeOut(),
|
|
@@ -545,10 +614,10 @@ export const LineChart = component2d({
|
|
|
545
614
|
...(region.x && { x: { ...from.x, ...rescale(region.x, from.x.ticks) } }),
|
|
546
615
|
...(region.y && { y: { ...from.y, ...rescale(region.y, from.y.ticks) } }),
|
|
547
616
|
}
|
|
548
|
-
return timeline.animate((t) => ({ home, ...axesAt(from, to, t) }))
|
|
617
|
+
return timeline.animate((t) => ({ home, clipped: true, ...axesAt(from, to, t) }))
|
|
549
618
|
},
|
|
550
619
|
},
|
|
551
|
-
/**
|
|
620
|
+
/** Back to the domain the first zoom left; a hold if it never zoomed. */
|
|
552
621
|
resetZoom: {
|
|
553
622
|
args: {},
|
|
554
623
|
easing: easeOut(),
|
|
@@ -556,39 +625,32 @@ export const LineChart = component2d({
|
|
|
556
625
|
const home = state.home
|
|
557
626
|
if (!home) return timeline.wait(timeline.duration)
|
|
558
627
|
const from = lineChartDomains(state)
|
|
559
|
-
return timeline.animate((t) => ({ home: null, ...axesAt(from, home, t) }))
|
|
628
|
+
return timeline.animate((t) => ({ home: null, clipped: t < 1, ...axesAt(from, home, t) }))
|
|
560
629
|
},
|
|
561
630
|
},
|
|
562
|
-
/**
|
|
563
|
-
* Show series `index`: **trace** its line in left to right, in lockstep with
|
|
564
|
-
* its legend entry growing in.
|
|
565
|
-
*/
|
|
631
|
+
/** Bring series `index` in, with its legend entry growing in: traced, or in a stack risen out of the series below. */
|
|
566
632
|
showSeries: {
|
|
567
633
|
args: { seriesIndex: SERIES_ARG },
|
|
568
634
|
easing: easeOut(),
|
|
569
|
-
run: (timeline,
|
|
635
|
+
run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, true),
|
|
570
636
|
},
|
|
571
|
-
/**
|
|
637
|
+
/** The mirror of `showSeries`. */
|
|
572
638
|
hideSeries: {
|
|
573
639
|
args: { seriesIndex: SERIES_ARG },
|
|
574
640
|
easing: easeOut(),
|
|
575
|
-
run: (timeline,
|
|
641
|
+
run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, false),
|
|
576
642
|
},
|
|
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
|
-
*/
|
|
643
|
+
/** Fade every other series, line, area and legend entry, to focus on `index`. The legend dims in place, so the row does not reflow. */
|
|
582
644
|
spotlightSeries: {
|
|
583
645
|
args: { seriesIndex: SERIES_ARG },
|
|
584
646
|
easing: easeOut(),
|
|
585
647
|
run: (timeline, _state, children) => {
|
|
586
648
|
const parts = partsOf(children)
|
|
587
|
-
if (!parts) return timeline.wait(timeline.duration)
|
|
588
649
|
const index = timeline.args.seriesIndex
|
|
589
650
|
return timeline.together([
|
|
590
651
|
...parts.lines.map((line, i) => line.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
|
|
591
652
|
...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
|
|
653
|
+
timeline.wait(timeline.duration),
|
|
592
654
|
])
|
|
593
655
|
},
|
|
594
656
|
},
|