@motionscript/charts 0.1.0 → 0.1.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +98 -28
  3. package/dist/bar-chart/bar-chart.d.ts +242 -119
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +231 -260
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +16 -38
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +59 -68
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +60 -91
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +88 -125
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +5 -13
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -9
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +40 -132
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +70 -158
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-R3Q5EE6E.js +2 -0
  24. package/dist/browser/chunks/chunk-R3Q5EE6E.js.map +7 -0
  25. package/dist/browser/index.js +1 -1
  26. package/dist/browser/index.js.map +4 -4
  27. package/dist/browser/kit.js +1 -1
  28. package/dist/browser/manifest.json +1 -1
  29. package/dist/engine.d.ts +8 -17
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +8 -17
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kit/cartesian.d.ts +49 -35
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +80 -42
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +37 -40
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +53 -47
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +16 -59
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +38 -76
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/kit/entries.d.ts +2 -0
  50. package/dist/kit/entries.d.ts.map +1 -1
  51. package/dist/kit/entries.js +2 -0
  52. package/dist/kit/entries.js.map +1 -1
  53. package/dist/kit/horizontal-axis.d.ts +77 -0
  54. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  55. package/dist/kit/horizontal-axis.js +99 -0
  56. package/dist/kit/horizontal-axis.js.map +1 -0
  57. package/dist/kit/motion.d.ts +16 -3
  58. package/dist/kit/motion.d.ts.map +1 -1
  59. package/dist/kit/motion.js +15 -1
  60. package/dist/kit/motion.js.map +1 -1
  61. package/dist/{pie-chart → kit}/pie-legend.d.ts +14 -29
  62. package/dist/kit/pie-legend.d.ts.map +1 -0
  63. package/dist/kit/pie-legend.js +93 -0
  64. package/dist/kit/pie-legend.js.map +1 -0
  65. package/dist/kit/polar.d.ts +31 -0
  66. package/dist/kit/polar.d.ts.map +1 -0
  67. package/dist/kit/polar.js +33 -0
  68. package/dist/kit/polar.js.map +1 -0
  69. package/dist/kit/shared.d.ts +60 -214
  70. package/dist/kit/shared.d.ts.map +1 -1
  71. package/dist/kit/shared.js +91 -291
  72. package/dist/kit/shared.js.map +1 -1
  73. package/dist/kit/style.d.ts +254 -0
  74. package/dist/kit/style.d.ts.map +1 -0
  75. package/dist/kit/style.js +197 -0
  76. package/dist/kit/style.js.map +1 -0
  77. package/dist/kit/vertical-axis.d.ts +36 -45
  78. package/dist/kit/vertical-axis.d.ts.map +1 -1
  79. package/dist/kit/vertical-axis.js +69 -81
  80. package/dist/kit/vertical-axis.js.map +1 -1
  81. package/dist/line-chart/chart-line.d.ts +107 -40
  82. package/dist/line-chart/chart-line.d.ts.map +1 -1
  83. package/dist/line-chart/chart-line.js +172 -89
  84. package/dist/line-chart/chart-line.js.map +1 -1
  85. package/dist/line-chart/chart-region.d.ts +14 -27
  86. package/dist/line-chart/chart-region.d.ts.map +1 -1
  87. package/dist/line-chart/chart-region.js +13 -28
  88. package/dist/line-chart/chart-region.js.map +1 -1
  89. package/dist/line-chart/index.d.ts +5 -18
  90. package/dist/line-chart/index.d.ts.map +1 -1
  91. package/dist/line-chart/index.js +3 -16
  92. package/dist/line-chart/index.js.map +1 -1
  93. package/dist/line-chart/line-chart.d.ts +272 -119
  94. package/dist/line-chart/line-chart.d.ts.map +1 -1
  95. package/dist/line-chart/line-chart.js +309 -257
  96. package/dist/line-chart/line-chart.js.map +1 -1
  97. package/dist/line-chart/shared.d.ts +73 -130
  98. package/dist/line-chart/shared.d.ts.map +1 -1
  99. package/dist/line-chart/shared.js +195 -156
  100. package/dist/line-chart/shared.js.map +1 -1
  101. package/dist/line-chart/x-scale.d.ts +16 -55
  102. package/dist/line-chart/x-scale.d.ts.map +1 -1
  103. package/dist/line-chart/x-scale.js +44 -111
  104. package/dist/line-chart/x-scale.js.map +1 -1
  105. package/dist/nodes.d.ts +3 -13
  106. package/dist/nodes.d.ts.map +1 -1
  107. package/dist/nodes.js +9 -13
  108. package/dist/nodes.js.map +1 -1
  109. package/dist/pie-chart/index.d.ts +3 -15
  110. package/dist/pie-chart/index.d.ts.map +1 -1
  111. package/dist/pie-chart/index.js +1 -11
  112. package/dist/pie-chart/index.js.map +1 -1
  113. package/dist/pie-chart/pie-chart.d.ts +147 -85
  114. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-chart.js +65 -86
  116. package/dist/pie-chart/pie-chart.js.map +1 -1
  117. package/dist/pie-chart/pie-ring.d.ts +65 -196
  118. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  119. package/dist/pie-chart/pie-ring.js +154 -292
  120. package/dist/pie-chart/pie-ring.js.map +1 -1
  121. package/dist/pie-chart/shared.d.ts +78 -172
  122. package/dist/pie-chart/shared.d.ts.map +1 -1
  123. package/dist/pie-chart/shared.js +54 -177
  124. package/dist/pie-chart/shared.js.map +1 -1
  125. package/dist/radar-chart/index.d.ts +5 -0
  126. package/dist/radar-chart/index.d.ts.map +1 -0
  127. package/dist/radar-chart/index.js +5 -0
  128. package/dist/radar-chart/index.js.map +1 -0
  129. package/dist/radar-chart/radar-chart.d.ts +218 -0
  130. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-chart.js +224 -0
  132. package/dist/radar-chart/radar-chart.js.map +1 -0
  133. package/dist/radar-chart/radar-grid.d.ts +43 -0
  134. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-grid.js +61 -0
  136. package/dist/radar-chart/radar-grid.js.map +1 -0
  137. package/dist/radar-chart/radar-series.d.ts +59 -0
  138. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  139. package/dist/radar-chart/radar-series.js +71 -0
  140. package/dist/radar-chart/radar-series.js.map +1 -0
  141. package/dist/radar-chart/shared.d.ts +73 -0
  142. package/dist/radar-chart/shared.d.ts.map +1 -0
  143. package/dist/radar-chart/shared.js +123 -0
  144. package/dist/radar-chart/shared.js.map +1 -0
  145. package/dist/radial-chart/index.d.ts +6 -0
  146. package/dist/radial-chart/index.d.ts.map +1 -0
  147. package/dist/radial-chart/index.js +4 -0
  148. package/dist/radial-chart/index.js.map +1 -0
  149. package/dist/radial-chart/radial-chart.d.ts +216 -0
  150. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-chart.js +174 -0
  152. package/dist/radial-chart/radial-chart.js.map +1 -0
  153. package/dist/radial-chart/radial-rings.d.ts +121 -0
  154. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  155. package/dist/radial-chart/radial-rings.js +273 -0
  156. package/dist/radial-chart/radial-rings.js.map +1 -0
  157. package/dist/radial-chart/shared.d.ts +73 -0
  158. package/dist/radial-chart/shared.d.ts.map +1 -0
  159. package/dist/radial-chart/shared.js +79 -0
  160. package/dist/radial-chart/shared.js.map +1 -0
  161. package/package.json +4 -4
  162. package/registry.json +21 -6
  163. package/src/bar-chart/bar-chart.ts +274 -315
  164. package/src/bar-chart/category-axis.ts +53 -88
  165. package/src/bar-chart/chart-bars.ts +108 -205
  166. package/src/bar-chart/index.ts +8 -19
  167. package/src/bar-chart/shared.ts +81 -309
  168. package/src/engine.ts +8 -17
  169. package/src/index.ts +2 -0
  170. package/src/kit/cartesian.ts +118 -55
  171. package/src/kit/chart-canvas.ts +50 -80
  172. package/src/kit/chart-legend.ts +36 -97
  173. package/src/kit/entries.ts +4 -1
  174. package/src/kit/horizontal-axis.ts +114 -0
  175. package/src/kit/motion.ts +18 -2
  176. package/src/{pie-chart → kit}/pie-legend.ts +25 -65
  177. package/src/kit/polar.ts +54 -0
  178. package/src/kit/shared.ts +104 -487
  179. package/src/kit/style.ts +257 -0
  180. package/src/kit/vertical-axis.ts +81 -115
  181. package/src/line-chart/chart-line.ts +185 -91
  182. package/src/line-chart/chart-region.ts +14 -31
  183. package/src/line-chart/index.ts +14 -22
  184. package/src/line-chart/line-chart.ts +377 -315
  185. package/src/line-chart/shared.ts +229 -304
  186. package/src/line-chart/x-scale.ts +44 -121
  187. package/src/nodes.ts +9 -13
  188. package/src/pie-chart/index.ts +3 -35
  189. package/src/pie-chart/pie-chart.ts +83 -134
  190. package/src/pie-chart/pie-ring.ts +162 -368
  191. package/src/pie-chart/shared.ts +92 -379
  192. package/src/radar-chart/index.ts +4 -0
  193. package/src/radar-chart/radar-chart.ts +287 -0
  194. package/src/radar-chart/radar-grid.ts +78 -0
  195. package/src/radar-chart/radar-series.ts +88 -0
  196. package/src/radar-chart/shared.ts +146 -0
  197. package/src/radial-chart/index.ts +5 -0
  198. package/src/radial-chart/radial-chart.ts +210 -0
  199. package/src/radial-chart/radial-rings.ts +328 -0
  200. package/src/radial-chart/shared.ts +114 -0
  201. package/dist/browser/chunks/chunk-TJNHLQGV.js +0 -2
  202. package/dist/browser/chunks/chunk-TJNHLQGV.js.map +0 -7
  203. package/dist/kit/chart-border.d.ts +0 -27
  204. package/dist/kit/chart-border.d.ts.map +0 -1
  205. package/dist/kit/chart-border.js +0 -28
  206. package/dist/kit/chart-border.js.map +0 -1
  207. package/dist/line-chart/horizontal-axis.d.ts +0 -79
  208. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  209. package/dist/line-chart/horizontal-axis.js +0 -129
  210. package/dist/line-chart/horizontal-axis.js.map +0 -1
  211. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  212. package/dist/pie-chart/pie-legend.js +0 -117
  213. package/dist/pie-chart/pie-legend.js.map +0 -1
  214. package/src/kit/chart-border.ts +0 -50
  215. package/src/line-chart/horizontal-axis.ts +0 -151
@@ -1,23 +1,19 @@
1
- import { Rect, Types, component2d, easeOut, } from "@motionscript/sdk";
1
+ import { Rect, Text, Types, clamp, component2d, easeOut, } from "@motionscript/sdk";
2
2
  import { formatNumber } from "@motionscript/sdk/component";
3
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian.js";
4
- import { ChartBorder } from "../kit/chart-border.js";
3
+ import { boundText, cartesianFrame, chartViews, columnGhost, fade, frameGap, headroom, laidOutSize, overhang } from "../kit/cartesian.js";
5
4
  import { ChartCanvas } from "../kit/chart-canvas.js";
6
5
  import { ChartLegend } from "../kit/chart-legend.js";
7
- import { asParts, fresh } from "../kit/entries.js";
8
- import { beats } from "../kit/motion.js";
9
- import { VerticalAxis } from "../kit/vertical-axis.js";
10
- import { SPOTLIGHT_DIM, formatMarker, lerpAxisDomain, lerpAxisEntry, markerStep, markerValues, resolveAxisDomain, resolveSeriesFill, resolveSeriesStroke, settleAxisDomain, } from "../kit/shared.js";
11
- import { ChartLine } from "./chart-line.js";
6
+ import { SERIES_ARG, asParts, fresh } from "../kit/entries.js";
7
+ import { HorizontalAxis } from "../kit/horizontal-axis.js";
8
+ import { DEFAULT_MARKERS, ENTER_SHARE, beats, legendAway } from "../kit/motion.js";
9
+ import { SPOTLIGHT_DIM, formatMarker, lerpAxisDomain, lerpAxisEntry, markerStep, markerValues, niceRange, niceTicks, resolveAxisDomain, settleAxisDomain, } from "../kit/shared.js";
10
+ import { CARTESIAN_GRID, CHART_COLORS, CHART_LEGEND, CHART_STYLE_FAMILY, CHART_STYLE_PRESETS, CHART_TEXT, axisConfigOf, legendConfigOf, seriesFill, valueText, } from "../kit/style.js";
11
+ import { VerticalAxis, axisPads } from "../kit/vertical-axis.js";
12
+ import { ChartArea, ChartLine, traceX } from "./chart-line.js";
12
13
  import { ChartRegion } from "./chart-region.js";
13
- import { HorizontalAxis } from "./horizontal-axis.js";
14
- import { DEFAULT_CHART_THEME, lerpChartTheme, resolveChartTheme, resolveRegionBox, resolveYScale, seriesPoints, } from "./shared.js";
14
+ import { LINE_LABELS, LINE_MARKS, resolveRegionBox, stackExtent, stackSeries, } from "./shared.js";
15
15
  import { resolveXScale } from "./x-scale.js";
16
- /**
17
- * The argument every per-series command takes: an index into `series`. Its own
18
- * kind rather than `number` so a host can offer the series by name.
19
- */
20
- const SERIES_ARG = Types.custom(0, { kind: "series", min: 0, step: 1 });
16
+ const LINE_STYLE = Types.style(CHART_STYLE_FAMILY, { colors: CHART_COLORS, text: CHART_TEXT, grid: CARTESIAN_GRID, marks: LINE_MARKS, labels: LINE_LABELS, legend: CHART_LEGEND }, { presets: CHART_STYLE_PRESETS });
21
17
  /** One argument, two controls: the commands take a `PlotRegion`, so the x and y spans a host draws are declared inside it. */
22
18
  const REGION_ARG = Types.custom(undefined, {
23
19
  kind: "region",
@@ -26,355 +22,424 @@ const REGION_ARG = Types.custom(undefined, {
26
22
  y: { kind: "range", default: false, defaultMin: 0, defaultMax: 100 },
27
23
  },
28
24
  });
29
- /** How far the legend starts below its slot when it enters, in px. */
30
- const LEGEND_SLIDE = 20;
31
- /** Fallback marker count when neither axis's ticks are given. */
32
- const DEFAULT_MARKERS = 5;
33
- /**
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.
37
- */
38
- const AXES_SHARE = 0.35;
39
- const LINES_SHARE = 0.5;
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
- */
25
+ /** The share of the y axis a percent stack reads to. */
26
+ const PERCENT = [0, 100];
27
+ /** What the x column is (a number, an instant, or a place in the file), and so how rows are positioned, ticked and captioned. */
45
28
  function columnOf(s, format = s.xAxis.format) {
46
29
  return resolveXScale(s.data, s.xField, format);
47
30
  }
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
- */
31
+ /** Every series as drawn; `weighted` false reads each at full share, as the scale does so the ticks hold still while a series sinks out. */
32
+ function stacksOf(s, weighted = true) {
33
+ return stackSeries(s.data, columnOf(s), s.series, s.stack, weighted ? s.weights : undefined);
34
+ }
35
+ /** The explicit range and ticks, else the column's extent and ticks: only a numeric column has round numbers between its samples. */
53
36
  function drawnX(s, domain) {
54
37
  const column = columnOf(s, domain.format);
55
38
  const range = domain.range ?? column.extent;
56
39
  return { ...domain, range, ticks: domain.ticks ?? column.ticks(range, DEFAULT_MARKERS - 1) };
57
40
  }
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
- */
41
+ /** The y range the data asks for: a percent stack reads to 100; areas and stacks keep zero on the scale. */
42
+ function autoY(s, divisions) {
43
+ const [lo, hi] = stackExtent(stacksOf(s, false));
44
+ if (s.stack === "percent" && lo >= 0)
45
+ return PERCENT;
46
+ const zero = s.stack !== "none" || (s.style?.area ?? "none") !== "none";
47
+ return niceRange(zero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi], divisions);
48
+ }
49
+ /** Auto-ranging steps into one fewer segment than the marker count, so the tidy bounds frame the ticks drawn. */
63
50
  function drawnY(s, domain) {
64
51
  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) };
52
+ const range = domain.range ?? autoY(s, divisions);
53
+ const even = domain.range !== undefined || (s.stack === "percent" && range === PERCENT);
54
+ return { ...domain, range, ticks: domain.ticks ?? (even ? markerValues(range, DEFAULT_MARKERS - 1) : niceTicks(range, DEFAULT_MARKERS - 1)) };
67
55
  }
68
56
  /** The last domains worked out from each data array, and what else they were read from. */
69
57
  const domainsDrawn = new WeakMap();
70
- /** Both axes as a line chart draws them now, an auto range or tick row read back as itself. */
58
+ /** Both axes as drawn now. */
71
59
  export function lineChartDomains(s) {
72
- const { data, xField, xAxis, yAxis, series } = s;
73
- const kept = domainsDrawn.get(data);
74
- if (kept && kept.xField === xField && kept.xAxis === xAxis && kept.yAxis === yAxis && kept.series === series) {
60
+ const key = [s.xField, s.xAxis, s.yAxis, s.series, s.stack, s.style?.area];
61
+ const kept = domainsDrawn.get(s.data);
62
+ if (kept && kept.key.every((value, i) => value === key[i]))
75
63
  return kept.domains;
76
- }
77
64
  const domains = {
78
- x: settleAxisDomain(xAxis, (d) => drawnX(s, d)),
79
- y: settleAxisDomain(yAxis, (d) => drawnY(s, d)),
65
+ x: settleAxisDomain(s.xAxis, (d) => drawnX(s, d)),
66
+ y: settleAxisDomain(s.yAxis, (d) => drawnY(s, d)),
80
67
  };
81
- domainsDrawn.set(data, { xField, xAxis, yAxis, series, domains });
68
+ domainsDrawn.set(s.data, { key, domains });
82
69
  return domains;
83
70
  }
84
- /**
85
- * Map one series' data points into the plot's **centred** pixel space (origin at
86
- * the plot centre, y-up): x runs `[-w/2, +w/2]`, y runs `[-h/2, +h/2]`.
87
- */
88
- function seriesPixels(s, domains, index, plot) {
89
- const series = s.series[index];
90
- if (!series)
91
- return [];
92
- const points = seriesPoints(s.data, columnOf(s), series);
93
- if (points.length === 0)
94
- return [];
95
- const { width: w, height: h } = plot;
71
+ /** Data to the plot's centred pixel space (origin at its centre, y-up). */
72
+ function mapper(domains, plot) {
96
73
  const [xMin, xMax] = domains.x.range;
97
74
  const [yMin, yMax] = domains.y.range;
98
- const xToPx = (x) => (xMax <= xMin ? 0 : ((x - xMin) / (xMax - xMin) - 0.5) * w);
99
- const yToPx = (y) => (yMax <= yMin ? 0 : ((y - yMin) / (yMax - yMin) - 0.5) * h);
100
- // `seriesPoints` already sorted them along x.
101
- return points.map(([x, y]) => ({ x: xToPx(x), y: yToPx(y) }));
75
+ return {
76
+ x: (x) => (xMax <= xMin ? 0 : ((x - xMin) / (xMax - xMin) - 0.5) * plot.width),
77
+ y: (y) => (yMax === yMin ? 0 : ((y - yMin) / (yMax - yMin) - 0.5) * plot.height),
78
+ };
79
+ }
80
+ const NO_PIXELS = { points: [], base: [], values: [] };
81
+ /** Series `index` in the plot's centred pixel space. */
82
+ function seriesPixels(s, domains, index, plot) {
83
+ const stack = stacksOf(s)[index];
84
+ if (!stack)
85
+ return NO_PIXELS;
86
+ const map = mapper(domains, plot);
87
+ const toPx = ([x, y]) => ({ x: map.x(x), y: map.y(y) });
88
+ return {
89
+ points: stack.points.map(toPx),
90
+ base: stack.base ? stack.base.map(toPx) : [],
91
+ values: stack.points.map(([, y], k) => y - (stack.base?.[k]?.[1] ?? 0)),
92
+ };
93
+ }
94
+ /** Where the areas fill down to: zero, held inside the scale. */
95
+ function baselineValue(domains) {
96
+ const [lo, hi] = domains.y.range;
97
+ return clamp(0, Math.min(lo, hi), Math.max(lo, hi));
102
98
  }
103
- /** `region` mapped into the plot's centred pixel space, against the live scales, so it travels with a zoom. */
99
+ /** Whether anything plotted runs past the scale, so the plot clips it: a zoom, or an author's range narrower than the data. */
100
+ function overflows(s, domains) {
101
+ const [ylo, yhi] = stackExtent(stacksOf(s, false));
102
+ const [xlo, xhi] = columnOf(s).extent;
103
+ const within = (v, [a, b]) => v >= Math.min(a, b) - 1e-9 && v <= Math.max(a, b) + 1e-9;
104
+ return !(within(ylo, domains.y.range) && within(yhi, domains.y.range) && within(xlo, domains.x.range) && within(xhi, domains.x.range));
105
+ }
106
+ /** `region` in centred pixel space against the live scales, so it travels with a zoom. */
104
107
  function regionBox(s, domains, plot) {
105
108
  const { width: w, height: h } = plot;
106
109
  return resolveRegionBox(s.region, { left: -w / 2, right: w / 2, bottom: -h / 2, top: h / 2 }, domains.x.range, domains.y.range);
107
110
  }
108
- /**
109
- * Whether a call-out is authored at all. Not whether it resolves to pixels: a
110
- * command is built before the first layout, and a region on a chart not yet
111
- * measured is a real one. A region bounding neither axis is none, as `null` is.
112
- */
111
+ /** Whether a call-out is authored, not whether it resolves to pixels: a command is built before the first layout. A region bounding neither axis is none. */
113
112
  function hasCallout(s) {
114
113
  const region = s.region;
115
114
  return region != null && (region.x !== undefined || region.y !== undefined);
116
115
  }
117
- /** The `region` a document handed a command, or `{}`: a command is added before its arguments are filled in. */
116
+ /** The `region` argument, or `{}`: a command is added before its arguments are filled in. */
118
117
  function regionArg(region) {
119
118
  return region != null && typeof region === "object" ? region : {};
120
119
  }
121
- /** A range read as a span, whichever way round the author wrote it. */
120
+ /** A range ordered low to high. */
122
121
  function span([a, b]) {
123
122
  return a <= b ? [a, b] : [b, a];
124
123
  }
125
- /**
126
- * Rescale an axis to `range`, keeping the tick *count* it already has, so each
127
- * marker slides from its old value to its new one instead of the row popping.
128
- */
124
+ /** Rescale to `range` keeping the tick count, so each marker slides rather than the row popping. */
129
125
  function rescale(range, ticks) {
130
126
  const to = span(range);
131
127
  return { range: to, ticks: markerValues(to, Math.max(2, ticks.length) - 1) };
132
128
  }
133
- /** The axis writes of a move from `from` toward `to` at `t`. */
134
129
  function axesAt(from, to, t) {
135
130
  return {
136
131
  ...(to.x && { xAxis: lerpAxisDomain(from.x, to.x, t) }),
137
132
  ...(to.y && { yAxis: lerpAxisDomain(from.y, to.y, t) }),
138
133
  };
139
134
  }
135
+ /** A value as the y axis writes it: the author's pattern, a share in a percent stack, else the bare reading at the ticks' precision. */
136
+ function yLabel(s, value, ticks) {
137
+ const format = s.yAxis.format;
138
+ if (format !== undefined)
139
+ return formatNumber(value, format);
140
+ const text = formatMarker(value, markerStep(ticks));
141
+ return s.stack === "percent" ? `${text}%` : text;
142
+ }
143
+ function weightOf(s, index) {
144
+ return Math.max(0, s.weights[index] ?? 1);
145
+ }
140
146
  /** The parts a line chart's commands drive, found through its children. */
141
147
  function partsOf(children) {
142
- const frame = cartesianViews(children);
143
- if (!frame)
144
- return null;
145
- const hAxis = frame.below.find(HorizontalAxis.is);
146
- if (!VerticalAxis.is(frame.axis) || !hAxis)
147
- return null;
148
+ const views = chartViews(children);
148
149
  return {
149
- vAxis: frame.axis,
150
- hAxis,
151
- lines: frame.plot.filter(ChartLine.is),
152
- legend: frame.below.find(ChartLegend.is) ?? null,
150
+ vAxis: views.find(VerticalAxis.is) ?? null,
151
+ hAxis: views.find(HorizontalAxis.is) ?? null,
152
+ lines: views.filter(ChartLine.is),
153
+ legend: views.find(ChartLegend.is) ?? null,
153
154
  };
154
155
  }
155
- /** Drop the call-out: the rules retract, the wash fades, and every series comes back to full. */
156
156
  function clearing(timeline, state) {
157
157
  const from = state.regionReveal;
158
158
  const region = state.region;
159
- // Nothing to retract, but the call still takes its slot on the timeline.
159
+ // Nothing to retract, but the call still takes its slot.
160
160
  if (from === 0)
161
161
  return timeline.animate(() => ({ region: null, regionReveal: 0 }), { eased: false });
162
162
  // `region` is held until the retraction lands, or there would be nothing left to retract.
163
163
  return timeline.animate((t) => (t >= 1 ? { region: null, regionReveal: 0 } : { region, regionReveal: from * (1 - t) }));
164
164
  }
165
- /** Trace series `index`'s line in (`show`) or back out, in lockstep with its legend entry. */
166
- function toggling(timeline, children, index, show) {
165
+ /**
166
+ * Show or hide series `index` with its legend entry. Apart, the line retraces;
167
+ * in a stack it sinks into the series below as its share falls to nothing, so
168
+ * the ones above settle rather than leaving a gap.
169
+ */
170
+ function toggling(timeline, state, children, index, show) {
167
171
  const parts = partsOf(children);
168
- const line = parts?.lines[index];
169
- if (!parts || !line)
172
+ const line = parts.lines[index];
173
+ if (!line)
170
174
  return timeline.wait(timeline.duration);
175
+ const legend = parts.legend ? [parts.legend.run(show ? "showSeries" : "hideSeries", { index })] : [];
176
+ if (state.stack === "none") {
177
+ return timeline.together([line.run("enter", show ? { from: 0, to: 1 } : { from: 1, to: 0 }), ...legend]);
178
+ }
179
+ const from = state.series.map((_, i) => weightOf(state, i));
180
+ const start = from[index] ?? 1;
181
+ const target = show ? 1 : 0;
171
182
  return timeline.together([
172
- line.run("enter", show ? { from: 0, to: 1 } : { from: 1, to: 0 }),
173
- ...(parts.legend ? [parts.legend.run(show ? "showSeries" : "hideSeries", { index })] : []),
183
+ timeline.animate((t) => {
184
+ const weights = [...from];
185
+ weights[index] = start + (target - start) * timeline.ease(t);
186
+ return { weights };
187
+ }, { eased: false }),
188
+ line.run("hold", { shown: show }),
189
+ ...legend,
174
190
  ]);
175
191
  }
176
192
  /**
177
- * A multi-series line chart, composed from five independent parts rather than
178
- * one monolithic draw:
179
- *
180
- * - {@link ChartCanvas} — the plot panel with a two-way grid, under everything.
181
- * - {@link VerticalAxis} / {@link HorizontalAxis} — columns/rows of real text
182
- * markers that stagger in, each carrying its own axis caption.
183
- * - {@link ChartRegion} — the optional call-out marking a spotlit slice of the
184
- * plot: dashed rules on its bounded edges, and every series faded outside it.
185
- * - {@link ChartLine} (one per series) — the traced polylines over the canvas.
186
- * - {@link ChartLegend} — a row of entries below the chart, one per series.
193
+ * A multi-series line or area chart composed of {@link ChartCanvas}, the value
194
+ * and x axes, {@link ChartRegion}, a {@link ChartArea} and a {@link ChartLine}
195
+ * per series, and {@link ChartLegend}. Every part reads the chart's entries
196
+ * through bindings and maps against the plot panel's own laid-out box.
187
197
  *
188
- * Every part reads the chart's entries through bindings, and the lines, the
189
- * region and the grid all map against the plot panel's own laid-out box, so
190
- * points land exactly on their markers and the chart re-flows if its box
191
- * changes.
192
- *
193
- * **Resting state is the finished chart**: an editor paints a node's Initial
194
- * state on a paused canvas. `enter` arms every part on its first frame.
195
- *
196
- * Past the entrance, `showSeries`/`hideSeries`/`spotlightSeries` pick out a
197
- * *series*, `spotlightRegion` marks a *slice of the plot* without changing the
198
- * scale, and `zoomTo` rescales the axes so a slice fills the plot. A spotlit
199
- * region resolves against the live scales, so it travels with a zoom. A `to` of
200
- * an axis `range` or `ticks` tweens from the domain as drawn (see
201
- * {@link lerpAxisEntry}), so an auto axis rescales continuously.
198
+ * Resting state is the finished chart; `enter` arms every part on its first
199
+ * frame. A `to` of an axis `range` or `ticks` tweens from the domain as drawn
200
+ * (see {@link lerpAxisEntry}), so an auto axis rescales continuously.
202
201
  */
203
202
  export const LineChart = component2d({
204
203
  key: "lineChart",
205
204
  state: {
206
205
  data: fresh(() => [], { kind: "dataset" }),
206
+ // Not `x`: every node already has an `x`, its position.
207
207
  xField: "",
208
208
  series: fresh(() => [], { kind: "series" }),
209
+ /** `stacked`: each series sits on the sum of those before it; `percent`: every row reads as shares of 100. */
210
+ stack: Types.oneOf(["none", "stacked", "percent"]),
209
211
  xAxis: Types.custom({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
210
212
  yAxis: Types.custom({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
211
- theme: Types.custom(DEFAULT_CHART_THEME, { resolve: resolveChartTheme, lerp: lerpChartTheme }),
212
- /**
213
- * The spotlit slice of the plot in data units, or `null` for none. The box
214
- * snaps; the animation is {@link regionReveal} drawing the rules on.
215
- */
213
+ style: LINE_STYLE,
214
+ /** The spotlit slice in data units, or `null`. The box snaps; {@link regionReveal} animates it. */
216
215
  region: Types.custom(null),
217
- /**
218
- * How far the `region` call-out is drawn in, `[0, 1]`: the rules grow out and
219
- * the wash fades up as it climbs, while every line's out-of-region stretch
220
- * fades to the theme's `region.dimOpacity`.
221
- */
216
+ /** How far the `region` call-out is drawn in, `[0, 1]`: rules grow, wash fades up, out-of-region stretches fade. */
222
217
  regionReveal: 0,
223
- /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
224
- showLegend: Types.boolean(true, { animatable: false }),
225
- /**
226
- * The domain `zoomTo` first left, so `resetZoom` restores the chart as it
227
- * stood, auto-derived ticks included. Cleared by `resetZoom`, so the next zoom
228
- * remembers wherever the chart rests then.
229
- */
218
+ /** The domain `zoomTo` first left, so `resetZoom` restores it, auto-derived ticks included. */
230
219
  home: Types.custom(null, { private: true }),
220
+ /** Each series' share of a stack, seeded from `enabled`: a weight rather than a switch, so a hidden series sinks out. */
221
+ weights: Types.custom([], { private: true }),
222
+ /** Whether the plot clips its marks, from a zoom's first frame until a reset lands. */
223
+ clipped: Types.boolean(false, { private: true }),
224
+ },
225
+ // A node made of parts that fill would otherwise hug them.
226
+ defaults: {
227
+ width: "fill",
228
+ height: "fill",
229
+ weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
231
230
  },
232
- // Its parts fill it; a node made of them would hug them.
233
- defaults: { width: "fill", height: "fill" },
234
231
  allowedChildren: [],
235
232
  children: (state) => {
236
- const theme = () => state.theme;
233
+ const style = () => state.style;
237
234
  const domains = () => lineChartDomains(state);
238
235
  let plot;
239
236
  const plotSize = () => laidOutSize(plot);
237
+ const pixels = (i) => seriesPixels(state, domains(), i, plotSize());
240
238
  const enabled = state.series.map((s) => s.enabled ?? true);
239
+ const built = state.style;
240
+ const yConfig = () => axisConfigOf(style(), "y");
241
+ const xConfig = () => axisConfigOf(style(), "x");
242
+ const yCaptions = () => {
243
+ const ticks = domains().y.ticks;
244
+ return ticks.map((value) => yLabel(state, value, ticks));
245
+ };
241
246
  // Bound, not snapshotted, so a rescale re-labels and re-places the row as it
242
247
  // tweens. The marker *count* is read once, which is why `zoomTo` keeps it.
243
- const vAxis = new VerticalAxis({
244
- range: () => domains().y.range,
245
- markers: () => domains().y.ticks,
246
- // The y axis is always numbers, so its format is simply the pattern.
247
- captions: () => {
248
- const markers = domains().y.ticks;
249
- const format = state.yAxis.format;
250
- return markers.map((value) => (format === undefined ? formatMarker(value, markerStep(markers)) : formatNumber(value, format)));
251
- },
252
- axis: () => theme().yAxis,
253
- label: state.yAxis.label ?? "",
254
- width: "hug",
255
- height: "fill",
256
- });
257
- const hAxis = new HorizontalAxis({
258
- range: () => domains().x.range,
259
- markers: () => domains().x.ticks,
260
- // A tick on a month or category column is captioned with the cell it came
261
- // out of — `Jan`, not the instant it parsed to.
262
- captions: () => {
263
- const column = columnOf(state);
264
- return domains().x.ticks.map((value) => column.label(value));
265
- },
266
- axis: () => theme().xAxis,
267
- label: state.xAxis.label ?? "",
268
- width: "fill",
269
- height: "hug",
270
- });
248
+ const vAxis = built.showYAxis
249
+ ? new VerticalAxis({
250
+ range: () => domains().y.range,
251
+ markers: () => domains().y.ticks,
252
+ captions: yCaptions,
253
+ axis: yConfig,
254
+ label: state.yAxis.label ?? "",
255
+ width: "hug",
256
+ height: "fill",
257
+ })
258
+ : undefined;
259
+ const hAxis = built.showXAxis
260
+ ? new HorizontalAxis({
261
+ range: () => domains().x.range,
262
+ markers: () => domains().x.ticks,
263
+ // A month or category tick is captioned with its cell (`Jan`), not the instant it parsed to.
264
+ captions: () => {
265
+ const column = columnOf(state);
266
+ return domains().x.ticks.map((value) => column.label(value));
267
+ },
268
+ axis: xConfig,
269
+ label: state.xAxis.label ?? "",
270
+ overhang: () => ({ left: overhang(style()), right: overhang(style()) }),
271
+ width: "fill",
272
+ height: "hug",
273
+ })
274
+ : undefined;
275
+ const ghost = vAxis && hAxis
276
+ ? columnGhost({
277
+ captions: yCaptions,
278
+ count: lineChartDomains(state).y.ticks.length,
279
+ text: () => yConfig().labelStyle,
280
+ title: state.yAxis.label ?? "",
281
+ titleText: () => yConfig().titleStyle,
282
+ tickPad: () => axisPads(yConfig(), "right").tick,
283
+ titlePad: () => axisPads(yConfig(), "right").title,
284
+ })
285
+ : undefined;
286
+ const spotlight = () => regionBox(state, domains(), plotSize());
287
+ const dimOpacity = () => 1 - (1 - SPOTLIGHT_DIM) * state.regionReveal;
271
288
  const lines = state.series.map((s, i) => new ChartLine({
272
- points: () => seriesPixels(state, domains(), i, plotSize()),
273
- stroke: () => resolveSeriesStroke(theme().seriesStyles, s, i),
289
+ points: () => pixels(i).points,
290
+ curve: () => style().curve,
291
+ stroke: () => ({ weight: style().strokeWidth, fill: seriesFill(style(), s, i), align: "center", cap: "round", join: "round" }),
292
+ dotSize: () => (style().dots ? style().dotSize : 0),
293
+ dotFill: () => seriesFill(style(), s, i),
274
294
  enabled: enabled[i],
275
- // Each line clips itself against the box the rules are drawn from, so
276
- // the dim/bright boundary can't drift from the dashes.
277
- spotlight: () => regionBox(state, domains(), plotSize()),
278
- // Ramped by the reveal that draws the rules: one signal, both halves.
279
- dimOpacity: () => 1 - (1 - theme().region.dimOpacity) * state.regionReveal,
295
+ spotlight,
296
+ dimOpacity,
280
297
  }));
281
- const below = [hAxis];
282
- if (state.showLegend !== false) {
283
- below.push(new ChartLegend({
284
- series: state.series,
285
- // The legend paints a swatch, not a line, so it is handed the colour each
286
- // stroke resolves to rather than the stroke itself.
287
- swatches: () => state.series.map((s, i) => resolveSeriesFill(theme().seriesStyles, s, i)),
288
- legend: () => theme().legend,
289
- // The row's spacing is the theme's as the chart was built, like the
290
- // entries' type and chips: a theme tween re-paints the swatches only.
291
- gap: state.theme.legend.itemGap,
292
- padding: state.theme.legend.padding,
293
- }));
298
+ const areas = state.series.map((s, i) => new ChartArea({
299
+ points: () => pixels(i).points,
300
+ base: () => pixels(i).base,
301
+ baseline: () => mapper(domains(), plotSize()).y(baselineValue(domains())),
302
+ curve: () => style().curve,
303
+ area: () => style().area,
304
+ fill: () => seriesFill(style(), s, i),
305
+ areaOpacity: () => style().areaOpacity,
306
+ // The line's own trace, switch and fade, so the two arrive, leave and dim as one.
307
+ progress: () => lines[i].progress,
308
+ enabled: () => lines[i].enabled,
309
+ opacity: () => lines[i].opacity,
310
+ spotlight,
311
+ dimOpacity,
312
+ }));
313
+ const labels = [];
314
+ if (built.values !== "none") {
315
+ const below = built.values === "below";
316
+ state.series.forEach((_, i) => {
317
+ state.data.forEach((_row, k) => {
318
+ const line = lines[i];
319
+ const label = new Text({
320
+ ...boundText(() => ({ ...valueText(style()), fontSize: Math.round(style().fontSize * 0.8) })),
321
+ text: () => {
322
+ const value = pixels(i).values[k];
323
+ return value === undefined ? "" : yLabel(state, value, domains().y.ticks);
324
+ },
325
+ // Held inside the plot, so a figure on an end point does not run into the axis beside it.
326
+ x: () => {
327
+ const half = (plotSize().width - label.layoutBounds.width) / 2;
328
+ return clamp(pixels(i).points[k]?.x ?? 0, -Math.max(0, half), Math.max(0, half));
329
+ },
330
+ y: () => {
331
+ const point = pixels(i).points[k];
332
+ const clear = label.layoutBounds.height / 2 + (style().dots ? style().dotSize / 2 : style().strokeWidth / 2) + Math.round(style().fontSize * 0.25);
333
+ return (point?.y ?? 0) + (below ? -clear : clear);
334
+ },
335
+ // In once the trace has passed its point.
336
+ opacity: () => {
337
+ const point = pixels(i).points[k];
338
+ if (!line.enabled || !point)
339
+ return 0;
340
+ const reach = traceX(pixels(i).points, line.progress);
341
+ return line.opacity * (reach === Infinity ? 1 : clamp((reach - point.x) / 24, 0, 1));
342
+ },
343
+ });
344
+ labels.push(label);
345
+ });
346
+ });
294
347
  }
348
+ const legendAt = built.legend;
349
+ const legend = legendAt !== "none"
350
+ ? new ChartLegend({
351
+ series: state.series,
352
+ swatches: () => state.series.map((s, i) => seriesFill(style(), s, i)),
353
+ legend: () => legendConfigOf(style()),
354
+ flow: legendAt === "right" ? "vertical" : "horizontal",
355
+ })
356
+ : undefined;
295
357
  plot = new Rect({
296
- clip: true,
358
+ clip: () => state.clipped || overflows(state, domains()),
297
359
  children: [
298
360
  new ChartCanvas({
299
- fill: () => theme().plotArea.fill,
300
- shadow: () => theme().plotArea.shadow,
301
- xGridStroke: () => theme().xAxis.lineStyle,
302
- yGridStroke: () => theme().yAxis.lineStyle,
303
- // A grid line under every axis marker; the canvas skips the edges.
361
+ fill: () => style().background,
362
+ xGridStroke: () => style().verticalGrid,
363
+ yGridStroke: () => style().horizontalGrid,
304
364
  xMarkers: () => domains().x.ticks,
305
365
  yMarkers: () => domains().y.ticks,
306
366
  xExtent: () => domains().x.range,
307
367
  yExtent: () => domains().y.range,
368
+ axisStroke: () => style().axisStroke,
369
+ xAxisAt: () => baselineValue(domains()),
370
+ yAxisAt: () => domains().x.range[0],
308
371
  }),
309
- // Over the grid, under the lines, so a spotlit stretch of a series isn't
310
- // tinted by the highlight meant to pick it out.
372
+ // Under the marks, so the highlight does not tint the stretch it picks out.
311
373
  new ChartRegion({
312
- box: () => regionBox(state, domains(), plotSize()),
313
- stroke: () => theme().region.stroke,
314
- fill: () => theme().region.fill,
374
+ box: spotlight,
375
+ stroke: () => ({ weight: 2, fill: fade(style().foreground, 0.7), dash: [14, 12], cap: "butt", align: "center" }),
376
+ fill: () => fade(style().foreground, 0.06),
315
377
  reveal: () => state.regionReveal,
316
378
  }),
379
+ ...areas,
317
380
  ...lines,
318
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
381
+ ...labels,
319
382
  ],
320
383
  });
321
- const gutter = new VerticalAxis({
322
- opacity: 0,
323
- range: () => domains().y.range,
324
- markers: () => domains().y.ticks,
325
- axis: () => theme().yAxis,
326
- label: state.yAxis.label ?? "",
327
- width: "hug",
328
- height: 0,
329
- });
330
- return asParts([cartesianFrame({ axis: vAxis, plot, gutter, below })]);
384
+ return asParts([
385
+ cartesianFrame({
386
+ left: vAxis,
387
+ ghost,
388
+ plot,
389
+ bottom: hAxis,
390
+ legend,
391
+ legendAt,
392
+ gap: () => frameGap(style()),
393
+ padding: () => ({
394
+ top: headroom(style()),
395
+ right: hAxis ? overhang(style()) : 0,
396
+ left: hAxis && !vAxis ? overhang(style()) : 0,
397
+ bottom: 0,
398
+ }),
399
+ }),
400
+ ]);
331
401
  },
332
402
  commands: {
333
403
  /**
334
- * Animate the whole chart in: the two axes stagger their markers in, the
335
- * series lines trace left to right, and the legend fades up last. Its first
336
- * frame arms every part: markers shifted and transparent, lines untraced,
337
- * the legend dropped and transparent.
338
- *
339
- * `duration` is the **whole** entrance; the beats are shares of it. A chart
340
- * authored with a `region` lands its call-out last, in the final quarter.
404
+ * Axes stagger in, lines (and their areas) trace left to right, the legend
405
+ * fades up last. `duration` is the whole entrance; a chart with a `region`
406
+ * lands its call-out in the final quarter.
341
407
  */
342
408
  enter: {
343
409
  args: {},
344
- // Held from the chart's arrival, so it never shows finished ahead of its draw.
410
+ // Held from arrival, so it never shows finished ahead of its draw.
345
411
  entrance: true,
346
412
  easing: easeOut(),
347
413
  run: (timeline, state, children) => {
348
414
  const parts = partsOf(children);
349
- if (!parts)
350
- return timeline.wait(timeline.duration);
351
415
  const duration = timeline.duration;
352
416
  const revealing = hasCallout(state) && state.regionReveal === 0;
353
417
  const body = revealing ? duration * 0.75 : duration;
354
- const axes = body * AXES_SHARE;
355
- const traced = body * LINES_SHARE;
418
+ const axes = body * ENTER_SHARE.axes;
419
+ const traced = body * ENTER_SHARE.marks;
356
420
  const { legend } = parts;
357
421
  const at0 = (piece) => ({ at: 0, duration: 0, piece });
422
+ const away = legendAway(state.style.legend);
358
423
  // The first frame arms what every later beat moves: a beat ahead of the
359
424
  // playhead writes nothing. A beat with nothing to do still takes its share.
360
425
  return beats(timeline, [
361
426
  ...(revealing ? [at0(timeline.animate(() => ({ regionReveal: 0 })))] : []),
362
427
  ...parts.lines.map((line) => at0(line.to({ progress: 0 }))),
363
- ...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
364
- { at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
365
- { at: 0, duration: axes, piece: parts.hAxis.run("enter", { slide: 24 }) },
428
+ ...(legend ? [at0(legend.to({ opacity: 0, ...away }))] : []),
429
+ ...(parts.vAxis ? [{ at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) }] : []),
430
+ ...(parts.hAxis ? [{ at: 0, duration: axes, piece: parts.hAxis.run("enter", { slide: 24 }) }] : []),
366
431
  ...parts.lines.map((line) => ({ at: axes, duration: traced, piece: line.run("enter") })),
367
- ...(legend ? [{ at: axes + traced, duration: body * LEGEND_SHARE, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
432
+ ...(legend ? [{ at: axes + traced, duration: body * ENTER_SHARE.legend, piece: legend.to({ opacity: 1, x: 0, y: 0 }) }] : []),
368
433
  ...(revealing ? [{ at: body, duration: duration * 0.25, piece: timeline.animate((t) => ({ regionReveal: t })) }] : []),
434
+ // Takes the whole slot even with no parts to move.
435
+ { at: 0, duration, piece: timeline.wait(duration) },
369
436
  ]);
370
437
  },
371
438
  },
372
439
  /**
373
- * Spotlight a rectangular slice of the plot given in **data units**: dashed
374
- * rules draw onto each bounded edge while every series fades outside the box.
375
- * Either axis alone is a band; both are a box. Called while another region is
376
- * up, the old call-out retracts over the first half and the new one draws in
377
- * over the second, each half eased on its own.
440
+ * Spotlight a slice of the plot in data units; either axis alone is a band.
441
+ * Over a region already up, the old one retracts in the first half and the
442
+ * new one draws in over the second, each half eased on its own.
378
443
  */
379
444
  spotlightRegion: {
380
445
  args: { region: REGION_ARG },
@@ -394,18 +459,13 @@ export const LineChart = component2d({
394
459
  : { region, regionReveal: timeline.ease(t * 2 - 1) }), { eased: false });
395
460
  },
396
461
  },
397
- /** Drop the region call-out. A no-op when nothing is spotlit, which still takes its slot. */
462
+ /** Drop the call-out. A no-op when nothing is spotlit, which still takes its slot. */
398
463
  clearRegion: {
399
464
  args: {},
400
465
  easing: easeOut(),
401
466
  run: (timeline, state) => clearing(timeline, state),
402
467
  },
403
- /**
404
- * Zoom the plot to a slice of itself, given in **data units**: the named axes
405
- * rescale so that slice fills the plot, the grid, both marker rows and every
406
- * series following. An omitted axis is left as it is. The first zoom
407
- * remembers the domain it left, for `resetZoom`.
408
- */
468
+ /** Rescale the named axes so a slice (data units) fills the plot; an omitted axis is left. The first zoom remembers the domain for `resetZoom`. */
409
469
  zoomTo: {
410
470
  args: { region: REGION_ARG },
411
471
  easing: easeOut(),
@@ -419,10 +479,10 @@ export const LineChart = component2d({
419
479
  ...(region.x && { x: { ...from.x, ...rescale(region.x, from.x.ticks) } }),
420
480
  ...(region.y && { y: { ...from.y, ...rescale(region.y, from.y.ticks) } }),
421
481
  };
422
- return timeline.animate((t) => ({ home, ...axesAt(from, to, t) }));
482
+ return timeline.animate((t) => ({ home, clipped: true, ...axesAt(from, to, t) }));
423
483
  },
424
484
  },
425
- /** Pull back out to the domain the first zoom left — the mirror of `zoomTo`, and a hold if it never zoomed. */
485
+ /** Back to the domain the first zoom left; a hold if it never zoomed. */
426
486
  resetZoom: {
427
487
  args: {},
428
488
  easing: easeOut(),
@@ -431,40 +491,32 @@ export const LineChart = component2d({
431
491
  if (!home)
432
492
  return timeline.wait(timeline.duration);
433
493
  const from = lineChartDomains(state);
434
- return timeline.animate((t) => ({ home: null, ...axesAt(from, home, t) }));
494
+ return timeline.animate((t) => ({ home: null, clipped: t < 1, ...axesAt(from, home, t) }));
435
495
  },
436
496
  },
437
- /**
438
- * Show series `index`: **trace** its line in left to right, in lockstep with
439
- * its legend entry growing in.
440
- */
497
+ /** Bring series `index` in, with its legend entry growing in: traced, or in a stack risen out of the series below. */
441
498
  showSeries: {
442
499
  args: { seriesIndex: SERIES_ARG },
443
500
  easing: easeOut(),
444
- run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, true),
501
+ run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, true),
445
502
  },
446
- /** Hide series `index`: **retrace** its line back out as its legend entry shrinks away. */
503
+ /** The mirror of `showSeries`. */
447
504
  hideSeries: {
448
505
  args: { seriesIndex: SERIES_ARG },
449
506
  easing: easeOut(),
450
- run: (timeline, _state, children) => toggling(timeline, children, timeline.args.seriesIndex, false),
507
+ run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, false),
451
508
  },
452
- /**
453
- * Fade every other series — each line **and its legend entry** — to focus on
454
- * `index`, which comes back to full. The legend dims in place, so the row
455
- * doesn't reflow.
456
- */
509
+ /** Fade every other series, line, area and legend entry, to focus on `index`. The legend dims in place, so the row does not reflow. */
457
510
  spotlightSeries: {
458
511
  args: { seriesIndex: SERIES_ARG },
459
512
  easing: easeOut(),
460
513
  run: (timeline, _state, children) => {
461
514
  const parts = partsOf(children);
462
- if (!parts)
463
- return timeline.wait(timeline.duration);
464
515
  const index = timeline.args.seriesIndex;
465
516
  return timeline.together([
466
517
  ...parts.lines.map((line, i) => line.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
467
518
  ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
519
+ timeline.wait(timeline.duration),
468
520
  ]);
469
521
  },
470
522
  },