@motionscript/charts 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +49 -49
  4. package/README.md +99 -99
  5. package/dist/bar-chart/bar-chart.js.map +1 -1
  6. package/dist/bar-chart/category-axis.js.map +1 -1
  7. package/dist/bar-chart/chart-bars.js.map +1 -1
  8. package/dist/bar-chart/index.js.map +1 -1
  9. package/dist/bar-chart/shared.js.map +1 -1
  10. package/dist/browser/chunks/{chunk-XROBIRQK.js → chunk-TJNHLQGV.js} +1 -1
  11. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +7 -0
  12. package/dist/browser/index.js +1 -1
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/kit.js +1 -1
  15. package/dist/browser/manifest.json +1 -1
  16. package/dist/engine.d.ts +1 -1
  17. package/dist/engine.js +1 -1
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/kit/cartesian.js.map +1 -1
  21. package/dist/kit/chart-border.js.map +1 -1
  22. package/dist/kit/chart-canvas.js.map +1 -1
  23. package/dist/kit/chart-legend.js.map +1 -1
  24. package/dist/kit/entries.js.map +1 -1
  25. package/dist/kit/motion.js.map +1 -1
  26. package/dist/kit/paints.js.map +1 -1
  27. package/dist/kit/shared.js.map +1 -1
  28. package/dist/kit/vertical-axis.js.map +1 -1
  29. package/dist/line-chart/chart-line.js.map +1 -1
  30. package/dist/line-chart/chart-region.js.map +1 -1
  31. package/dist/line-chart/horizontal-axis.js.map +1 -1
  32. package/dist/line-chart/index.d.ts +1 -1
  33. package/dist/line-chart/index.js +1 -1
  34. package/dist/line-chart/index.js.map +1 -1
  35. package/dist/line-chart/line-chart.js.map +1 -1
  36. package/dist/line-chart/shared.js.map +1 -1
  37. package/dist/line-chart/x-scale.js.map +1 -1
  38. package/dist/nodes.js.map +1 -1
  39. package/dist/pie-chart/index.js.map +1 -1
  40. package/dist/pie-chart/pie-chart.js.map +1 -1
  41. package/dist/pie-chart/pie-legend.js.map +1 -1
  42. package/dist/pie-chart/pie-ring.js.map +1 -1
  43. package/dist/pie-chart/shared.js.map +1 -1
  44. package/package.json +4 -4
  45. package/registry.json +23 -23
  46. package/src/bar-chart/bar-chart.ts +542 -542
  47. package/src/bar-chart/category-axis.ts +162 -162
  48. package/src/bar-chart/chart-bars.ts +287 -287
  49. package/src/bar-chart/index.ts +31 -31
  50. package/src/bar-chart/shared.ts +354 -354
  51. package/src/engine.ts +28 -28
  52. package/src/index.ts +5 -5
  53. package/src/kit/cartesian.ts +81 -81
  54. package/src/kit/chart-border.ts +50 -50
  55. package/src/kit/chart-canvas.ts +122 -122
  56. package/src/kit/chart-legend.ts +229 -229
  57. package/src/kit/entries.ts +20 -20
  58. package/src/kit/motion.ts +90 -90
  59. package/src/kit/paints.ts +15 -15
  60. package/src/kit/shared.ts +782 -782
  61. package/src/kit/vertical-axis.ts +194 -194
  62. package/src/line-chart/chart-line.ts +156 -156
  63. package/src/line-chart/chart-region.ts +90 -90
  64. package/src/line-chart/horizontal-axis.ts +151 -151
  65. package/src/line-chart/index.ts +39 -39
  66. package/src/line-chart/line-chart.ts +598 -598
  67. package/src/line-chart/shared.ts +473 -473
  68. package/src/line-chart/x-scale.ts +382 -382
  69. package/src/nodes.ts +18 -18
  70. package/src/pie-chart/index.ts +37 -37
  71. package/src/pie-chart/pie-chart.ts +296 -296
  72. package/src/pie-chart/pie-legend.ts +169 -169
  73. package/src/pie-chart/pie-ring.ts +848 -848
  74. package/src/pie-chart/shared.ts +496 -496
  75. package/dist/browser/chunks/chunk-XROBIRQK.js.map +0 -7
  76. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,598 +1,598 @@
1
- import {
2
- Rect,
3
- Types,
4
- component2d,
5
- easeOut,
6
- type ChildView,
7
- type DataRecord,
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
- type Node2DProps,
13
- type Size2D,
14
- type Timeline,
15
- type Vector2,
16
- } from "@motionscript/sdk"
17
- import { formatNumber } from "@motionscript/sdk/component"
18
-
19
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
20
- import { ChartBorder } from "../kit/chart-border"
21
- import { ChartCanvas } from "../kit/chart-canvas"
22
- import { ChartLegend } from "../kit/chart-legend"
23
- import { asParts, fresh } from "../kit/entries"
24
- import { beats, type Beat } from "../kit/motion"
25
- import { VerticalAxis } from "../kit/vertical-axis"
26
- import {
27
- SPOTLIGHT_DIM,
28
- formatMarker,
29
- lerpAxisDomain,
30
- lerpAxisEntry,
31
- markerStep,
32
- markerValues,
33
- resolveAxisDomain,
34
- resolveSeriesFill,
35
- resolveSeriesStroke,
36
- settleAxisDomain,
37
- type AxisDomainConfig,
38
- type Range,
39
- } from "../kit/shared"
40
- import { ChartLine } from "./chart-line"
41
- import { ChartRegion } from "./chart-region"
42
- import { HorizontalAxis } from "./horizontal-axis"
43
- import {
44
- DEFAULT_CHART_THEME,
45
- lerpChartTheme,
46
- resolveChartTheme,
47
- resolveRegionBox,
48
- resolveYScale,
49
- seriesPoints,
50
- type ChartConfig,
51
- type ChartTheme,
52
- type LineSeries,
53
- type PlotRegion,
54
- type RegionBox,
55
- } from "./shared"
56
- import { resolveXScale, type XScale } from "./x-scale"
57
-
58
- export interface LineChartProps extends Node2DProps {
59
- /** One row per sample — a plain record of field name to value. */
60
- data: DataRecord[]
61
- /**
62
- * Key into each row for its x value, shared by every series. Named `xField`
63
- * rather than `x` because every node already has an `x` — its position.
64
- */
65
- xField: string
66
- /** One polyline per entry: which field it plots, its legend `label`, its `fill`. */
67
- series: LineSeries[]
68
- /** X-axis data domain: caption, scale range, and tick placement. */
69
- xAxis?: AxisDomainConfig
70
- /** Y-axis data domain: caption, scale range, and tick placement. */
71
- yAxis?: AxisDomainConfig
72
- /**
73
- * A slice of the plot to spotlight, in **data units**. Dashed rules mark the
74
- * bounded edges and every series fades outside the box — see
75
- * `spotlightRegion`, which animates the same thing. `null` (the default) is no
76
- * region.
77
- */
78
- region?: PlotRegion | null
79
- /**
80
- * The chart's visual theme — series palette, axis text/grid/gutters, plot
81
- * panel paint, and legend styling. Every branch is optional and deep-merged
82
- * onto {@link DEFAULT_CHART_THEME}, so pass only what you want to override.
83
- */
84
- theme?: ChartTheme
85
- /** Whether the legend row below the plot is drawn at all. */
86
- showLegend?: boolean
87
- }
88
-
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
- }
103
-
104
- /**
105
- * The argument every per-series command takes: an index into `series`. Its own
106
- * kind rather than `number` so a host can offer the series by name.
107
- */
108
- const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
109
-
110
- /** 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
- })
118
-
119
- /** How far the legend starts below its slot when it enters, in px. */
120
- const LEGEND_SLIDE = 20
121
-
122
- /** Fallback marker count when neither axis's ticks are given. */
123
- const DEFAULT_MARKERS = 5
124
-
125
- /**
126
- * How `enter` divides its duration between its three beats: the axes arriving,
127
- * the lines tracing, and the legend coming up. Shares of the whole, so they must
128
- * sum to 1 — the entrance takes exactly the time it was given.
129
- */
130
- const AXES_SHARE = 0.35
131
- const LINES_SHARE = 0.5
132
- const LEGEND_SHARE = 0.15
133
-
134
- /** What the chart's own commands read: its entries, and the private `home` a zoom remembers. */
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
-
142
- /**
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`.
145
- */
146
- function columnOf(s: LineChartValues, format = s.xAxis.format): XScale {
147
- return resolveXScale(s.data, s.xField, format)
148
- }
149
-
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
- }
160
-
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
- }
171
-
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
184
- }
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"],
188
- }
189
- domainsDrawn.set(data, { xField, xAxis, yAxis, series, domains })
190
- return domains
191
- }
192
-
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
- }
210
-
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
- }
216
-
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
- }
226
-
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
- }
231
-
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
- }
236
-
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
- }
245
-
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) }),
251
- }
252
- }
253
-
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,
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
- }
290
-
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 ?? "",
370
- width: "hug",
371
- height: "fill",
372
- })
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 ?? "",
385
- width: "fill",
386
- height: "hug",
387
- })
388
-
389
- const lines = state.series.map(
390
- (s, i) =>
391
- new ChartLine({
392
- points: () => seriesPixels(state, domains(), i, plotSize()),
393
- stroke: () => resolveSeriesStroke(theme().seriesStyles, s, i),
394
- enabled: enabled[i],
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
401
- )
402
-
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
- )
418
- }
419
-
420
- plot = new Rect({
421
- clip: true,
422
- children: [
423
- new ChartCanvas({
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,
433
- }),
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.
436
- new ChartRegion({
437
- box: () => regionBox(state, domains(), plotSize()),
438
- stroke: () => theme().region.stroke,
439
- fill: () => theme().region.fill,
440
- reveal: () => state.regionReveal,
441
- }),
442
- ...lines,
443
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
444
- ],
445
- }) as unknown as Node2D
446
-
447
- const gutter = new VerticalAxis({
448
- opacity: 0,
449
- range: () => domains().y.range,
450
- markers: () => domains().y.ticks,
451
- axis: () => theme().yAxis,
452
- label: state.yAxis.label ?? "",
453
- width: "hug",
454
- height: 0,
455
- }) as unknown as Node2D
456
-
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 }
521
- )
522
- },
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) }))
549
- },
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) }))
560
- },
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
- ])
593
- },
594
- },
595
- },
596
- })
597
-
598
- export type LineChart = NodeOf<typeof LineChart>
1
+ import {
2
+ Rect,
3
+ Types,
4
+ component2d,
5
+ easeOut,
6
+ type ChildView,
7
+ type DataRecord,
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
+ type Node2DProps,
13
+ type Size2D,
14
+ type Timeline,
15
+ type Vector2,
16
+ } from "@motionscript/sdk"
17
+ import { formatNumber } from "@motionscript/sdk/component"
18
+
19
+ import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
20
+ import { ChartBorder } from "../kit/chart-border"
21
+ import { ChartCanvas } from "../kit/chart-canvas"
22
+ import { ChartLegend } from "../kit/chart-legend"
23
+ import { asParts, fresh } from "../kit/entries"
24
+ import { beats, type Beat } from "../kit/motion"
25
+ import { VerticalAxis } from "../kit/vertical-axis"
26
+ import {
27
+ SPOTLIGHT_DIM,
28
+ formatMarker,
29
+ lerpAxisDomain,
30
+ lerpAxisEntry,
31
+ markerStep,
32
+ markerValues,
33
+ resolveAxisDomain,
34
+ resolveSeriesFill,
35
+ resolveSeriesStroke,
36
+ settleAxisDomain,
37
+ type AxisDomainConfig,
38
+ type Range,
39
+ } from "../kit/shared"
40
+ import { ChartLine } from "./chart-line"
41
+ import { ChartRegion } from "./chart-region"
42
+ import { HorizontalAxis } from "./horizontal-axis"
43
+ import {
44
+ DEFAULT_CHART_THEME,
45
+ lerpChartTheme,
46
+ resolveChartTheme,
47
+ resolveRegionBox,
48
+ resolveYScale,
49
+ seriesPoints,
50
+ type ChartConfig,
51
+ type ChartTheme,
52
+ type LineSeries,
53
+ type PlotRegion,
54
+ type RegionBox,
55
+ } from "./shared"
56
+ import { resolveXScale, type XScale } from "./x-scale"
57
+
58
+ export interface LineChartProps extends Node2DProps {
59
+ /** One row per sample — a plain record of field name to value. */
60
+ data: DataRecord[]
61
+ /**
62
+ * Key into each row for its x value, shared by every series. Named `xField`
63
+ * rather than `x` because every node already has an `x` — its position.
64
+ */
65
+ xField: string
66
+ /** One polyline per entry: which field it plots, its legend `label`, its `fill`. */
67
+ series: LineSeries[]
68
+ /** X-axis data domain: caption, scale range, and tick placement. */
69
+ xAxis?: AxisDomainConfig
70
+ /** Y-axis data domain: caption, scale range, and tick placement. */
71
+ yAxis?: AxisDomainConfig
72
+ /**
73
+ * A slice of the plot to spotlight, in **data units**. Dashed rules mark the
74
+ * bounded edges and every series fades outside the box — see
75
+ * `spotlightRegion`, which animates the same thing. `null` (the default) is no
76
+ * region.
77
+ */
78
+ region?: PlotRegion | null
79
+ /**
80
+ * The chart's visual theme — series palette, axis text/grid/gutters, plot
81
+ * panel paint, and legend styling. Every branch is optional and deep-merged
82
+ * onto {@link DEFAULT_CHART_THEME}, so pass only what you want to override.
83
+ */
84
+ theme?: ChartTheme
85
+ /** Whether the legend row below the plot is drawn at all. */
86
+ showLegend?: boolean
87
+ }
88
+
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
+ }
103
+
104
+ /**
105
+ * The argument every per-series command takes: an index into `series`. Its own
106
+ * kind rather than `number` so a host can offer the series by name.
107
+ */
108
+ const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
109
+
110
+ /** 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
+ })
118
+
119
+ /** How far the legend starts below its slot when it enters, in px. */
120
+ const LEGEND_SLIDE = 20
121
+
122
+ /** Fallback marker count when neither axis's ticks are given. */
123
+ const DEFAULT_MARKERS = 5
124
+
125
+ /**
126
+ * How `enter` divides its duration between its three beats: the axes arriving,
127
+ * the lines tracing, and the legend coming up. Shares of the whole, so they must
128
+ * sum to 1 — the entrance takes exactly the time it was given.
129
+ */
130
+ const AXES_SHARE = 0.35
131
+ const LINES_SHARE = 0.5
132
+ const LEGEND_SHARE = 0.15
133
+
134
+ /** What the chart's own commands read: its entries, and the private `home` a zoom remembers. */
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
+
142
+ /**
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`.
145
+ */
146
+ function columnOf(s: LineChartValues, format = s.xAxis.format): XScale {
147
+ return resolveXScale(s.data, s.xField, format)
148
+ }
149
+
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
+ }
160
+
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
+ }
171
+
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
184
+ }
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"],
188
+ }
189
+ domainsDrawn.set(data, { xField, xAxis, yAxis, series, domains })
190
+ return domains
191
+ }
192
+
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
+ }
210
+
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
+ }
216
+
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
+ }
226
+
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
+ }
231
+
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
+ }
236
+
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
+ }
245
+
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) }),
251
+ }
252
+ }
253
+
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,
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
+ }
290
+
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 ?? "",
370
+ width: "hug",
371
+ height: "fill",
372
+ })
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 ?? "",
385
+ width: "fill",
386
+ height: "hug",
387
+ })
388
+
389
+ const lines = state.series.map(
390
+ (s, i) =>
391
+ new ChartLine({
392
+ points: () => seriesPixels(state, domains(), i, plotSize()),
393
+ stroke: () => resolveSeriesStroke(theme().seriesStyles, s, i),
394
+ enabled: enabled[i],
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
401
+ )
402
+
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
+ )
418
+ }
419
+
420
+ plot = new Rect({
421
+ clip: true,
422
+ children: [
423
+ new ChartCanvas({
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,
433
+ }),
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.
436
+ new ChartRegion({
437
+ box: () => regionBox(state, domains(), plotSize()),
438
+ stroke: () => theme().region.stroke,
439
+ fill: () => theme().region.fill,
440
+ reveal: () => state.regionReveal,
441
+ }),
442
+ ...lines,
443
+ new ChartBorder({ stroke: () => theme().plotArea.stroke }),
444
+ ],
445
+ }) as unknown as Node2D
446
+
447
+ const gutter = new VerticalAxis({
448
+ opacity: 0,
449
+ range: () => domains().y.range,
450
+ markers: () => domains().y.ticks,
451
+ axis: () => theme().yAxis,
452
+ label: state.yAxis.label ?? "",
453
+ width: "hug",
454
+ height: 0,
455
+ }) as unknown as Node2D
456
+
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 }
521
+ )
522
+ },
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) }))
549
+ },
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) }))
560
+ },
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
+ ])
593
+ },
594
+ },
595
+ },
596
+ })
597
+
598
+ export type LineChart = NodeOf<typeof LineChart>