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

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