@motionscript/charts 0.1.0-alpha.3 → 0.1.0-alpha.5

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