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