@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/NOTICE +49 -0
  3. package/README.md +99 -0
  4. package/dist/bar-chart/bar-chart.d.ts +133 -228
  5. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  6. package/dist/bar-chart/bar-chart.js +386 -565
  7. package/dist/bar-chart/bar-chart.js.map +1 -1
  8. package/dist/bar-chart/category-axis.d.ts +35 -41
  9. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  10. package/dist/bar-chart/category-axis.js +93 -115
  11. package/dist/bar-chart/category-axis.js.map +1 -1
  12. package/dist/bar-chart/chart-bars.d.ts +50 -70
  13. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  14. package/dist/bar-chart/chart-bars.js +155 -210
  15. package/dist/bar-chart/chart-bars.js.map +1 -1
  16. package/dist/bar-chart/index.d.ts +2 -2
  17. package/dist/bar-chart/index.d.ts.map +1 -1
  18. package/dist/bar-chart/index.js +1 -1
  19. package/dist/bar-chart/index.js.map +1 -1
  20. package/dist/bar-chart/shared.d.ts +7 -7
  21. package/dist/bar-chart/shared.d.ts.map +1 -1
  22. package/dist/bar-chart/shared.js +7 -7
  23. package/dist/bar-chart/shared.js.map +1 -1
  24. package/dist/browser/chunks/chunk-TJNHLQGV.js +2 -0
  25. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  26. package/dist/browser/index.js +1 -1
  27. package/dist/browser/index.js.map +3 -3
  28. package/dist/browser/kit.js +1 -1
  29. package/dist/browser/manifest.json +4 -3
  30. package/dist/engine.d.ts +10 -8
  31. package/dist/engine.d.ts.map +1 -1
  32. package/dist/engine.js +10 -8
  33. package/dist/engine.js.map +1 -1
  34. package/dist/kit/cartesian.d.ts +44 -0
  35. package/dist/kit/cartesian.d.ts.map +1 -0
  36. package/dist/kit/cartesian.js +59 -0
  37. package/dist/kit/cartesian.js.map +1 -0
  38. package/dist/kit/chart-border.d.ts +10 -11
  39. package/dist/kit/chart-border.d.ts.map +1 -1
  40. package/dist/kit/chart-border.js +15 -32
  41. package/dist/kit/chart-border.js.map +1 -1
  42. package/dist/kit/chart-canvas.d.ts +32 -21
  43. package/dist/kit/chart-canvas.d.ts.map +1 -1
  44. package/dist/kit/chart-canvas.js +33 -90
  45. package/dist/kit/chart-canvas.js.map +1 -1
  46. package/dist/kit/chart-legend.d.ts +123 -85
  47. package/dist/kit/chart-legend.d.ts.map +1 -1
  48. package/dist/kit/chart-legend.js +137 -186
  49. package/dist/kit/chart-legend.js.map +1 -1
  50. package/dist/kit/entries.d.ts +17 -0
  51. package/dist/kit/entries.d.ts.map +1 -0
  52. package/dist/kit/entries.js +19 -0
  53. package/dist/kit/entries.js.map +1 -0
  54. package/dist/kit/motion.d.ts +48 -0
  55. package/dist/kit/motion.d.ts.map +1 -0
  56. package/dist/kit/motion.js +63 -0
  57. package/dist/kit/motion.js.map +1 -0
  58. package/dist/kit/paints.d.ts +10 -0
  59. package/dist/kit/paints.d.ts.map +1 -0
  60. package/dist/kit/paints.js +12 -0
  61. package/dist/kit/paints.js.map +1 -0
  62. package/dist/kit/shared.d.ts +61 -10
  63. package/dist/kit/shared.d.ts.map +1 -1
  64. package/dist/kit/shared.js +108 -13
  65. package/dist/kit/shared.js.map +1 -1
  66. package/dist/kit/vertical-axis.d.ts +48 -61
  67. package/dist/kit/vertical-axis.d.ts.map +1 -1
  68. package/dist/kit/vertical-axis.js +97 -132
  69. package/dist/kit/vertical-axis.js.map +1 -1
  70. package/dist/line-chart/chart-line.d.ts +60 -77
  71. package/dist/line-chart/chart-line.d.ts.map +1 -1
  72. package/dist/line-chart/chart-line.js +100 -121
  73. package/dist/line-chart/chart-line.js.map +1 -1
  74. package/dist/line-chart/chart-region.d.ts +32 -33
  75. package/dist/line-chart/chart-region.d.ts.map +1 -1
  76. package/dist/line-chart/chart-region.js +31 -65
  77. package/dist/line-chart/chart-region.js.map +1 -1
  78. package/dist/line-chart/horizontal-axis.d.ts +58 -64
  79. package/dist/line-chart/horizontal-axis.d.ts.map +1 -1
  80. package/dist/line-chart/horizontal-axis.js +94 -123
  81. package/dist/line-chart/horizontal-axis.js.map +1 -1
  82. package/dist/line-chart/index.d.ts +3 -3
  83. package/dist/line-chart/index.d.ts.map +1 -1
  84. package/dist/line-chart/index.js +2 -2
  85. package/dist/line-chart/index.js.map +1 -1
  86. package/dist/line-chart/line-chart.d.ts +156 -303
  87. package/dist/line-chart/line-chart.d.ts.map +1 -1
  88. package/dist/line-chart/line-chart.js +410 -638
  89. package/dist/line-chart/line-chart.js.map +1 -1
  90. package/dist/line-chart/shared.d.ts +7 -7
  91. package/dist/line-chart/shared.d.ts.map +1 -1
  92. package/dist/line-chart/shared.js +23 -8
  93. package/dist/line-chart/shared.js.map +1 -1
  94. package/dist/line-chart/x-scale.d.ts +4 -11
  95. package/dist/line-chart/x-scale.d.ts.map +1 -1
  96. package/dist/line-chart/x-scale.js +16 -5
  97. package/dist/line-chart/x-scale.js.map +1 -1
  98. package/dist/nodes.d.ts +6 -9
  99. package/dist/nodes.d.ts.map +1 -1
  100. package/dist/nodes.js +7 -9
  101. package/dist/nodes.js.map +1 -1
  102. package/dist/pie-chart/index.d.ts +2 -2
  103. package/dist/pie-chart/index.d.ts.map +1 -1
  104. package/dist/pie-chart/index.js +1 -1
  105. package/dist/pie-chart/index.js.map +1 -1
  106. package/dist/pie-chart/pie-chart.d.ts +108 -126
  107. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  108. package/dist/pie-chart/pie-chart.js +202 -271
  109. package/dist/pie-chart/pie-chart.js.map +1 -1
  110. package/dist/pie-chart/pie-legend.d.ts +84 -31
  111. package/dist/pie-chart/pie-legend.d.ts.map +1 -1
  112. package/dist/pie-chart/pie-legend.js +93 -107
  113. package/dist/pie-chart/pie-legend.js.map +1 -1
  114. package/dist/pie-chart/pie-ring.d.ts +160 -122
  115. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  116. package/dist/pie-chart/pie-ring.js +333 -361
  117. package/dist/pie-chart/pie-ring.js.map +1 -1
  118. package/dist/pie-chart/shared.d.ts +12 -6
  119. package/dist/pie-chart/shared.d.ts.map +1 -1
  120. package/dist/pie-chart/shared.js +14 -8
  121. package/dist/pie-chart/shared.js.map +1 -1
  122. package/package.json +7 -4
  123. package/src/bar-chart/bar-chart.ts +424 -686
  124. package/src/bar-chart/category-axis.ts +96 -111
  125. package/src/bar-chart/chart-bars.ts +178 -201
  126. package/src/bar-chart/index.ts +2 -2
  127. package/src/bar-chart/shared.ts +7 -7
  128. package/src/engine.ts +10 -8
  129. package/src/kit/cartesian.ts +81 -0
  130. package/src/kit/chart-border.ts +23 -34
  131. package/src/kit/chart-canvas.ts +45 -80
  132. package/src/kit/chart-legend.ts +185 -206
  133. package/src/kit/entries.ts +20 -0
  134. package/src/kit/motion.ts +90 -0
  135. package/src/kit/paints.ts +15 -0
  136. package/src/kit/shared.ts +120 -13
  137. package/src/kit/vertical-axis.ts +111 -141
  138. package/src/line-chart/chart-line.ts +103 -155
  139. package/src/line-chart/chart-region.ts +36 -81
  140. package/src/line-chart/horizontal-axis.ts +102 -153
  141. package/src/line-chart/index.ts +3 -3
  142. package/src/line-chart/line-chart.ts +463 -759
  143. package/src/line-chart/shared.ts +22 -8
  144. package/src/line-chart/x-scale.ts +20 -5
  145. package/src/nodes.ts +8 -10
  146. package/src/pie-chart/index.ts +2 -2
  147. package/src/pie-chart/pie-chart.ts +225 -303
  148. package/src/pie-chart/pie-legend.ts +125 -115
  149. package/src/pie-chart/pie-ring.ts +388 -443
  150. package/src/pie-chart/shared.ts +14 -8
  151. package/dist/browser/chunks/chunk-ZMBOBHYL.js +0 -2
  152. 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
- property,
8
- type Command,
9
- type CommandArgs,
6
+ type ChildView,
10
7
  type DataRecord,
11
- type NodeConfig,
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/core"
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
- * {@link LineChart.spotlightRegion}, which animates the same thing. `null`
69
- * (the default) is no region.
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
- * Headroom above the plot, in px.
84
- *
85
- * Two jobs, and the second is why it is real padding on the composition rather
86
- * than a number the point-mapping math imagines: a series peaking at `yMax`
87
- * isn't flush to the top edge, **and** the top y-marker — which is centred on
88
- * that top grid line — has somewhere to be. Without it the marker's upper half
89
- * is drawn above the chart's own box, outside the rectangle the editor draws
90
- * around the node.
91
- */
92
- const TOP_HEADROOM = 24
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 = { key: "seriesIndex", kind: "series", default: 0, min: 0, step: 1 }
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
- * The two signals the region call-out is made of, as a props type for the
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 {@link LineChart.enter} divides its duration between its three beats: the
115
- * axes arriving, the lines tracing, and the legend coming up. Shares of the
116
- * whole, so they must sum to 1 — the entrance takes exactly the time it was
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
- * A multi-series line chart, composed from five independent parts rather than
125
- * one monolithic draw:
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
- @node({
156
- key: "lineChart",
157
- parentKey: "node",
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
- // ---- Derived scales ---------------------------------------------------
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
- /** Fallback marker count when neither axis's ticks are given. */
250
- private static readonly DEFAULT_MARKERS = 5
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
- * What the x column *is* — a number, an instant, or a place in the file — and
254
- * with it how each row is positioned, ticked and captioned. See `x-scale.ts`.
255
- */
256
- private get xColumn(): XScale {
257
- return resolveXScale(this.data, this.xField, this.xAxis.format)
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
- private get xScale(): Range {
261
- return this.xAxis.range ?? this.xColumn.extent
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
- private get yScale(): Range {
265
- // Auto-ranging steps into one fewer segment than the intended marker count
266
- // — the explicit ticks' count when given, else the default — so the tidy
267
- // bounds still frame the ticks the axis draws.
268
- const divisions =
269
- (this.yAxis.ticks?.length ?? LineChart.DEFAULT_MARKERS) - 1
270
- return resolveYScale(this.data, this.series, this.yAxis.range, divisions)
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
- /** The vertical axis ticks: the explicit ones, else five across the scale. */
274
- private get resolvedYMarkers(): number[] {
275
- return (
276
- this.yAxis.ticks ??
277
- markerValues(this.yScale, LineChart.DEFAULT_MARKERS - 1)
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
- * The horizontal axis ticks: the explicit ones, else five across the scale.
283
- *
284
- * Asked of the column rather than spaced evenly, because only a numeric column
285
- * has round numbers between its samples. On a month column, five evenly spaced
286
- * values land two-thirds of the way through four different months; the ticks
287
- * a reader wants are Jan/Apr/Jul/Oct/Dec, which are rows.
288
- */
289
- private get resolvedXMarkers(): number[] {
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
- // ---- Plot geometry ------------------------------------------------------
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
- * The plot panel's size, **as laid out**.
300
- *
301
- * This used to be computed: six constants estimating what the axis gutters and
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
- * Map one series' data points into the plot's **centred** pixel space (origin
322
- * at the plot centre, y-up) — the local frame of the clip/line box that fills
323
- * the plot. x runs `[-w/2, +w/2]`, y runs `[-h/2, +h/2]`.
324
- */
325
- private seriesPixels(index: number): Vector2[] {
326
- const s = this.series[index]
327
- if (!s) return []
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
- * {@link region} mapped into the plot's **centred** pixel space — the same
344
- * frame {@link seriesPixels} returns, so the region's rules and each line's
345
- * clip resolve against one box from one place. Reads the live scales, so it
346
- * re-maps when `xAxis`/`yAxis` tween.
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
- private regionBox(): RegionBox {
370
- const { width: w, height: h } = this.plotSize()
371
- // The region lives in the plot's own centred frame, the same one
372
- // seriesPixels maps its points into.
373
- return resolveRegionBox(
374
- this.region,
375
- { left: -w / 2, right: w / 2, bottom: -h / 2, top: h / 2 },
376
- this.xScale,
377
- this.yScale
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
- // ---- Build ------------------------------------------------------------
382
-
383
- constructor(props?: NodeConfig<LineChart, LineChartProps>) {
384
- super(props)
385
-
386
- // The whole chart is composed from plain props/`data` — no context needed,
387
- // so the parts are built here (super() applied the props above).
388
- //
389
- // Every theme/axis-derived prop below that a *leaf* drawing part reads live
390
- // in `renderSelf` every frame — the canvas's fill/grid/extent, the border's
391
- // stroke, each line's stroke/points — is passed as a closure, not a one-time
392
- // snapshot, so `chart.to({ theme })` and `chart.to({ yAxis: { range } })`
393
- // genuinely animate the panel, grid, border, and plotted lines. The two axes
394
- // compose their child text nodes *once* (the marker count is fixed at
395
- // construction) but bind each marker's caption and position to the live
396
- // range/ticks, so a rescale re-labels and re-places the row as it tweens.
397
- const enabled = this.series.map((s) => s.enabled ?? true)
398
- const showLegend = this.showLegend !== false
399
-
400
- this.vAxis = new VerticalAxis({
401
- // Bound, not snapshotted — this is the half of a rescale the axis owns.
402
- // The marker *count* is still read once (it fixes how many text nodes get
403
- // built), which is why `zoomTo` keeps the count constant.
404
- range: () => this.yScale,
405
- markers: () => this.resolvedYMarkers,
406
- axis: () => this.resolvedTheme.yAxis,
407
- label: this.yAxis.label ?? "",
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
- // The y axis is always numbers, so its format is simply the pattern.
412
- this.vAxis.labelFor = (value) =>
413
- this.yAxis.format === undefined
414
- ? formatMarker(value)
415
- : formatNumber(value, this.yAxis.format)
416
-
417
- this.hAxis = new HorizontalAxis({
418
- range: () => this.xScale,
419
- markers: () => this.resolvedXMarkers,
420
- axis: () => this.resolvedTheme.xAxis,
421
- label: this.xAxis.label ?? "",
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
- this.lines = this.series.map(
389
+ const lines = state.series.map(
431
390
  (s, i) =>
432
391
  new ChartLine({
433
- points: () => this.seriesPixels(i),
434
- stroke: () => resolveSeriesStroke(this.resolvedTheme.seriesStyles, s, i),
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 same box the rules are drawn
437
- // from, so the dim/bright boundary can't drift from the dashes.
438
- spotlight: () => this.regionBox(),
439
- // Ramped by the same reveal that draws the rules, so at reveal 0 the
440
- // line is uniform and the region invisible — one signal, both halves.
441
- dimOpacity: () =>
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
- if (showLegend) {
447
- this.legend = new ChartLegend({
448
- series: this.series,
449
- // The kit's legend paints a swatch, not a line, so the chart hands it
450
- // the *colour* each stroke resolves to rather than the stroke itself —
451
- // which is also what lets a bar chart's fill palette drive the same row.
452
- swatches: () =>
453
- this.series.map((s, i) =>
454
- resolveSeriesFill(this.resolvedTheme.seriesStyles, s, i)
455
- ),
456
- legend: () => this.resolvedTheme.legend,
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
- this.plotRect = new Rect({
420
+ plot = new Rect({
461
421
  clip: true,
462
422
  children: [
463
423
  new ChartCanvas({
464
- fill: () => this.resolvedTheme.plotArea.fill,
465
- shadow: () => this.resolvedTheme.plotArea.shadow,
466
- xGridStroke: () => this.resolvedTheme.xAxis.lineStyle,
467
- yGridStroke: () => this.resolvedTheme.yAxis.lineStyle,
468
- // A grid line under every axis marker; the canvas maps the marker
469
- // values through the extents and skips the edges.
470
- xMarkers: () => this.resolvedXMarkers,
471
- yMarkers: () => this.resolvedYMarkers,
472
- xExtent: () => this.xScale,
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
- // Region wash + dashed rules: over the grid, under the lines, so a
476
- // spotlit stretch of a series isn't tinted by the highlight meant to
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: () => this.regionBox(),
480
- stroke: () => this.resolvedTheme.region.stroke,
481
- fill: () => this.resolvedTheme.region.fill,
482
- reveal: () => this.regionReveal,
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
- // Invisible twin of the vertical axis, reserving the same left gutter under
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: () => this.yScale,
497
- markers: () => this.resolvedYMarkers,
498
- axis: () => this.resolvedTheme.yAxis,
499
- label: this.yAxis.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
- const below: Node2D[] = [this.hAxis]
505
- if (this.legend) below.push(this.legend)
506
-
507
- this.add(
508
- new Rect({ flow: "vertical",
509
- width: "fill",
510
- height: "fill",
511
- // See {@link TOP_HEADROOM}: the plot's top grid line is the one place a
512
- // marker would otherwise be drawn outside the chart's own box.
513
- padding: { top: TOP_HEADROOM },
514
- children: [
515
- new Rect({ flow: "horizontal",
516
- height: "fill",
517
- width: "fill",
518
- children: [this.vAxis, this.plotRect],
519
- }),
520
- new Rect({ flow: "horizontal",
521
- width: "fill",
522
- children: [
523
- gutterSpacer,
524
- new Rect({ flow: "vertical", width: "fill", gap: 20, children: below }),
525
- ],
526
- }),
527
- ],
528
- })
529
- )
530
- }
531
-
532
- // ---- Orchestrated entrance --------------------------------------------
533
-
534
- /**
535
- * Prime every part at its pre-entrance state — axis markers shifted and
536
- * transparent, lines untraced, legend dropped and transparent.
537
- *
538
- * Called by {@link enter} before its first frame so nothing flashes ahead of
539
- * its beat. Idempotent, and public so a scene can hide a chart ahead of time
540
- * without starting the entrance.
541
- */
542
- arm(): void {
543
- this.vAxis.arm()
544
- this.hAxis.arm()
545
- for (const line of this.lines) line.set({ progress: 0 })
546
- this.legend?.set({ opacity: 0, y: -LEGEND_SLIDE })
547
- }
548
-
549
- /**
550
- * Animate the whole chart in: the two axes stagger their markers in (vertical
551
- * bottom-to-top, horizontal left-to-right), the series lines trace
552
- * left-to-right, and the legend fades up last.
553
- *
554
- * `duration` is the **whole** entrance. The phases below are shares of it, not
555
- * multiples — a command that declares 1.4 seconds has to take 1.4 seconds,
556
- * because that is the slot the timeline lays out for it, the length its
557
- * parallel siblings are timed against, and its contribution to the scene's own
558
- * duration. (The component this was ported from scaled every phase *off* the
559
- * line trace and let the total fall where it may, which came out around 3×.)
560
- *
561
- * A chart authored with a `region` lands its call-out last, over lines that
562
- * have already traced — the same order a scene would get by calling
563
- * {@link spotlightRegion} right after this — and that beat comes out of the
564
- * same budget.
565
- */
566
- @command()
567
- enter(
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
- spotlightRegion(
643
- args: CommandArgs<{ region: PlotRegion }> & { duration?: number }
644
- ): Command<RegionState> {
645
- const region = LineChart.regionArg(args.data)
646
- const duration = args.duration ?? 0.5
647
- const easing = args.easing ?? easeOut()
648
- const from = this.regionReveal
649
- const previous = this.region
650
-
651
- // A region bounding neither axis is no call-out: spotlighting it is a clear.
652
- if (region.x === undefined && region.y === undefined) {
653
- return this.clearRegion({ duration, easing })
654
- }
655
-
656
- // Nothing up yet: the region is set from the first frame and just draws on.
657
- if (from <= 0 || !this.hasCallout()) {
658
- return this.command<RegionState>(
659
- (t) => ({ region, regionReveal: from + (1 - from) * t }),
660
- duration,
661
- easing
662
- )
663
- }
664
-
665
- // Something already up: retract it first so the rules don't jump across the
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
- zoomTo(
774
- args: CommandArgs<{ region: PlotRegion }> & { duration?: number }
775
- ): Seekable {
776
- const region = LineChart.regionArg(args.data)
777
- const duration = args.duration ?? 0.8
778
- const easing = args.easing ?? easeOut()
779
- if (!region.x && !region.y) return hold(duration)
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
- y: {
791
- label: this.yAxis.label,
792
- range: this.yScale,
793
- ticks: this.resolvedYMarkers,
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
- const next: { xAxis?: AxisDomainConfig; yAxis?: AxisDomainConfig } = {}
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>