@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,271 +1,231 @@
1
1
  import {
2
2
  Rect,
3
3
  Types,
4
+ clamp,
4
5
  component2d,
5
6
  easeOut,
6
7
  type ChildView,
7
8
  type DataRecord,
8
- type EntryOptions,
9
9
  type NodeOf,
10
- // Named so this component's emitted type reaches `Node2D` through the sdk, not core.
10
+ type OutOf,
11
+ // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
11
12
  type Node2D,
12
- type Node2DProps,
13
+ type Node2DProps as _Node2DProps,
14
+ type Fill as _Fill,
15
+ type FillResolved as _FillResolved,
16
+ type Stroke as _Stroke,
17
+ type StrokeResolved as _StrokeResolved,
13
18
  type Size2D,
14
- type Timeline,
19
+ type StyleInput as _StyleInput,
20
+ type StyleValues as _StyleValues,
15
21
  } from "@motionscript/sdk"
16
22
  import { formatNumber } from "@motionscript/sdk/component"
17
23
 
18
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
19
- import { ChartBorder } from "../kit/chart-border"
24
+ import { cartesianFrame, chartViews, columnGhost, frameGap, headroom, laidOutSize, overhang } from "../kit/cartesian"
20
25
  import { ChartCanvas } from "../kit/chart-canvas"
21
26
  import { ChartLegend } from "../kit/chart-legend"
22
- import { asParts, fresh } from "../kit/entries"
23
- import { beats, type Beat } from "../kit/motion"
24
- import { VerticalAxis } from "../kit/vertical-axis"
27
+ import { SERIES_ARG, asParts, fresh } from "../kit/entries"
28
+ import { HorizontalAxis } from "../kit/horizontal-axis"
29
+ import { DEFAULT_MARKERS, ENTER_SHARE, beats, legendAway, type AnyTimeline, type Beat } from "../kit/motion"
25
30
  import {
26
- clampUnit,
31
+ SPOTLIGHT_DIM,
27
32
  formatMarker,
28
33
  lerpAxisDomain,
29
34
  lerpAxisEntry,
30
35
  markerStep,
31
36
  markerValues,
37
+ niceTicks,
32
38
  num,
33
39
  resolveAxisDomain,
34
40
  settleAxisDomain,
35
41
  type AxisDomainConfig,
36
42
  type Range,
43
+ type ResolvedAxisDomain,
37
44
  } from "../kit/shared"
38
- import { CategoryAxis } from "./category-axis"
45
+ import {
46
+ CARTESIAN_GRID,
47
+ CHART_COLORS,
48
+ CHART_LEGEND,
49
+ CHART_STYLE_FAMILY,
50
+ CHART_STYLE_PRESETS,
51
+ CHART_TEXT,
52
+ axisConfigOf,
53
+ legendConfigOf,
54
+ seriesFill,
55
+ valueText,
56
+ } from "../kit/style"
57
+ import { VerticalAxis, axisPads } from "../kit/vertical-axis"
58
+ import { CategoryAxis, slotCentre } from "./category-axis"
39
59
  import { ChartBars, type BarGeometry } from "./chart-bars"
40
60
  import {
41
- DEFAULT_BAR_CHART_THEME,
61
+ BAR_LABELS,
62
+ BAR_MARKS,
42
63
  barCategories,
43
64
  barSpans,
44
- lerpBarChartTheme,
45
- resolveBarChartTheme,
46
- resolveBarFill,
47
65
  resolveBarScale,
48
- stackCapIndex,
49
- type BarChartConfig,
50
- type BarChartTheme,
66
+ type BarOrientation,
51
67
  type BarSeries,
68
+ type BarStack,
52
69
  } from "./shared"
53
70
 
54
- export interface BarChartProps extends Node2DProps {
55
- /** One row per category — a plain record of field name to value. */
56
- data: DataRecord[]
57
- /**
58
- * Key into each row for its category caption, drawn beneath the baseline.
59
- * Named `categoryField` rather than `category` for the same reason the line
60
- * chart's is `xField`: it names a *column*, not a value.
61
- */
62
- categoryField: string
63
- /** One entry per bar series: which column it plots, its `label`, its `fill`. */
64
- series: BarSeries[]
65
- /**
66
- * When `true` every category's series stack into one column; when `false`
67
- * (the default) each series gets its own bar, grouped side by side within the
68
- * category's slot.
69
- */
70
- stacked?: boolean
71
- /** The value axis's domain: caption, scale range, and tick placement. */
72
- valueAxis?: AxisDomainConfig
73
- /** The category axis's domain. Only its `label` is read — the slots are the rows. */
74
- categoryAxis?: AxisDomainConfig
75
- /**
76
- * The chart's visual theme — bar palette and geometry, axis furniture, plot
77
- * panel paint, legend, value figures. Every branch is optional and deep-merged
78
- * onto {@link DEFAULT_BAR_CHART_THEME}.
79
- */
80
- theme?: BarChartTheme
81
- /** Whether the legend row below the plot is drawn at all. */
82
- showLegend?: boolean
83
- /** Whether each column prints the figure it plots. */
84
- showValues?: boolean
85
- }
71
+ const BAR_STYLE = Types.style(
72
+ CHART_STYLE_FAMILY,
73
+ { colors: CHART_COLORS, text: CHART_TEXT, grid: CARTESIAN_GRID, marks: BAR_MARKS, labels: BAR_LABELS, legend: CHART_LEGEND },
74
+ { presets: CHART_STYLE_PRESETS }
75
+ )
76
+
77
+ /** A bar chart's resolved style. */
78
+ export type BarChartStyle = OutOf<typeof BAR_STYLE>
86
79
 
87
80
  /** What a bar chart plots, as its entries hold it. */
88
81
  export interface BarChartValues {
89
82
  readonly data: DataRecord[]
90
83
  readonly series: BarSeries[]
91
- readonly stacked: boolean
84
+ readonly stack: BarStack
92
85
  readonly valueAxis: AxisDomainConfig
93
86
  }
94
87
 
95
88
  /** The value axis's domain with its range and ticks resolved. */
96
- export type BarChartDomain = AxisDomainConfig & { readonly range: Range; readonly ticks: number[] }
97
-
98
- /** The argument every per-series command takes: an index into `series`, offered by name. */
99
- const SERIES_ARG = Types.custom<number, number, EntryOptions & { readonly min: number; readonly step: number }>(0, { kind: "series", min: 0, step: 1 })
89
+ export type BarChartDomain = ResolvedAxisDomain
100
90
 
101
- /** How far the legend starts below its slot when it enters, in px. */
102
- const LEGEND_SLIDE = 20
103
-
104
- /** Fallback marker count when the value axis states no ticks. */
105
- const DEFAULT_MARKERS = 5
106
-
107
- /**
108
- * How `enter` divides its duration between its three beats: the axes arriving,
109
- * the columns rising, and the legend coming up. Shares of the whole, so they
110
- * must sum to 1.
111
- */
112
- const AXES_SHARE = 0.35
113
- const BARS_SHARE = 0.5
114
- const LEGEND_SHARE = 0.15
115
-
116
- /** What the geometry reads: what the chart plots, its theme and the signals its commands move. */
117
91
  interface BarState extends BarChartValues {
118
- readonly theme: BarChartConfig
92
+ readonly categoryField: string
93
+ readonly orientation: BarOrientation
94
+ readonly style: BarChartStyle
119
95
  readonly weights: number[]
96
+ readonly showing: boolean[]
120
97
  readonly spotlight: number | null
121
98
  readonly spotlightReveal: number
122
99
  }
123
100
 
124
- /** The value axis with its range and ticks resolved: the explicit ones, else five across the scale. */
125
101
  function drawnValue(s: BarChartValues, domain: AxisDomainConfig): AxisDomainConfig {
126
102
  const divisions = (domain.ticks?.length ?? DEFAULT_MARKERS) - 1
127
- const range = resolveBarScale(s.data, s.series, s.stacked, domain.range, divisions)
128
- return { ...domain, range, ticks: domain.ticks ?? markerValues(range, DEFAULT_MARKERS - 1) }
103
+ const range = resolveBarScale(s.data, s.series, s.stack, domain.range, divisions)
104
+ const even = domain.range !== undefined || s.stack === "percent"
105
+ return { ...domain, range, ticks: domain.ticks ?? (even ? markerValues(range, DEFAULT_MARKERS - 1) : niceTicks(range, DEFAULT_MARKERS - 1)) }
129
106
  }
130
107
 
131
- /** The last domain worked out from each data array, and what else it was read from. */
132
- const domainsDrawn = new WeakMap<DataRecord[], Omit<BarChartValues, "data"> & { readonly domain: BarChartDomain }>()
108
+ /** The last domain worked out from each data array, and what it was read from. */
109
+ const domainsDrawn = new WeakMap<DataRecord[], { readonly key: readonly unknown[]; readonly domain: BarChartDomain }>()
133
110
 
134
- /** The value axis as a bar chart draws it now, an auto range or tick row read back as itself. */
111
+ /** The value axis as drawn now. A series being hidden keeps its room, so the ticks hold still while it goes. */
135
112
  export function barChartDomain(s: BarChartValues): BarChartDomain {
136
- const { data, series, stacked, valueAxis } = s
137
- const kept = domainsDrawn.get(data)
138
- if (kept && kept.series === series && kept.stacked === stacked && kept.valueAxis === valueAxis) return kept.domain
139
- const domain = settleAxisDomain(valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
140
- domainsDrawn.set(data, { series, stacked, valueAxis, domain })
113
+ const key = [s.series, s.stack, s.valueAxis]
114
+ const kept = domainsDrawn.get(s.data)
115
+ if (kept && kept.key.every((value, i) => value === key[i])) return kept.domain
116
+ const domain = settleAxisDomain(s.valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
117
+ domainsDrawn.set(s.data, { key, domain })
141
118
  return domain
142
119
  }
143
120
 
144
- /**
145
- * A value written down as this chart's value axis says: the ticks and the
146
- * figures printed on the columns both read through here, so the two cannot
147
- * disagree about what a number looks like.
148
- */
121
+ /** Ticks and the figures printed by bars both read through here, so they cannot disagree about how a number looks. */
149
122
  function valueLabel(s: BarChartValues, domain: BarChartDomain, value: number): string {
150
123
  const pattern = s.valueAxis.format
151
- return pattern === undefined ? formatMarker(value, markerStep(domain.ticks)) : formatNumber(value, pattern)
124
+ if (pattern !== undefined) return formatNumber(value, pattern)
125
+ const text = formatMarker(value, markerStep(domain.ticks))
126
+ return s.stack === "percent" ? `${text}%` : text
152
127
  }
153
128
 
154
- /** A value on the plot's **centred** pixel space (origin at its centre, y-up). */
155
- function valueToY(domain: BarChartDomain, plot: Size2D, value: number): number {
129
+ /** A value along the value axis, a run of `length` px centred on 0. */
130
+ function valueToPx(domain: BarChartDomain, length: number, value: number): number {
156
131
  const [min, max] = domain.range
157
- if (max <= min) return -plot.height / 2
158
- return ((value - min) / (max - min) - 0.5) * plot.height
132
+ if (max === min) return -length / 2
133
+ return ((value - min) / (max - min) - 0.5) * length
159
134
  }
160
135
 
161
- /**
162
- * Pixel y of the line every column grows from: zero clamped onto the scale, so
163
- * a cropped axis grows its columns from the plot floor.
164
- */
165
- function baselinePx(domain: BarChartDomain, plot: Size2D): number {
166
- const [min, max] = domain.range
167
- return valueToY(domain, plot, Math.min(Math.max(0, min), max))
136
+ /** Where bars grow from: zero held on the scale, so a cropped axis grows them from the plot's edge. */
137
+ function baselineValue(domain: BarChartDomain): number {
138
+ const [lo, hi] = domain.range
139
+ return clamp(0, Math.min(lo, hi), Math.max(lo, hi))
168
140
  }
169
141
 
170
- /** Series `i`'s live share of the slot; 0 once it has been hidden. */
171
- function weightAt(s: BarState, index: number): number {
142
+ function weightAt(s: { readonly weights: readonly number[] }, index: number): number {
172
143
  return Math.max(0, s.weights[index] ?? 1)
173
144
  }
174
145
 
175
- /** `row` with each series' value scaled by its live weight: what the stacked spans are measured from. */
176
- function weightedRow(s: BarState, row: DataRecord): DataRecord {
177
- const scaled: DataRecord = { ...row }
178
- s.series.forEach((series, i) => {
179
- const value = row[series.field]
180
- if (typeof value === "number") scaled[series.field] = value * weightAt(s, i)
181
- })
182
- return scaled
146
+ /** The plot's run along the value axis and across the categories. */
147
+ function runs(s: BarState, plot: Size2D): { along: number; across: number } {
148
+ return s.orientation === "horizontal" ? { along: plot.width, across: plot.height } : { along: plot.height, across: plot.width }
149
+ }
150
+
151
+ /** Category `row`'s slot centre across the plot: left to right, or top to bottom. */
152
+ function slotAt(s: BarState, row: number, across: number): number {
153
+ const centre = slotCentre(row, s.data.length, across)
154
+ return s.orientation === "horizontal" ? -centre : centre
183
155
  }
184
156
 
185
157
  /**
186
- * Series `index`'s columns, mapped into the plot's centred pixel space. A
187
- * grouped series divides the slot with its siblings by their live weights, a
188
- * stacked one takes the whole slot and runs from the running total below it,
189
- * so hiding a series reflows rather than punching a hole.
158
+ * Series `index`'s bars in centred pixel space. Side by side, a series divides
159
+ * the slot with its siblings by live weight; stacked, it takes the whole slot
160
+ * and its weight scales its length, so hiding a series reflows rather than
161
+ * punching a hole.
190
162
  */
191
- function seriesBars(s: BarState, index: number, plot: Size2D, captioned: boolean): BarGeometry[] {
163
+ function seriesBars(s: BarState, index: number, plot: Size2D): BarGeometry[] {
192
164
  const series = s.series[index]
193
165
  if (!series) return []
194
166
  const domain = barChartDomain(s)
167
+ const { along, across } = runs(s, plot)
195
168
  const n = s.data.length
196
- const slotWidth = n > 0 ? plot.width / n : 0
197
- const slotCenter = (i: number) => -plot.width / 2 + slotWidth * (i + 0.5)
198
- const inner = slotWidth * clampUnit(s.theme.bar.slotFill)
199
- const gap = s.theme.bar.groupGap
169
+ const slot = n > 0 ? across / n : 0
170
+ const inner = slot * clamp(s.style.barWidth, 0, 1)
171
+ const stacked = s.stack !== "none"
200
172
  let total = 0
201
173
  for (let i = 0; i < s.series.length; i++) total += weightAt(s, i)
202
174
  const unit = inner / (total > 0 ? total : 1)
203
175
  const weight = weightAt(s, index)
204
176
  let before = 0
205
177
  for (let i = 0; i < index; i++) before += weightAt(s, i)
178
+ const gap = s.series.length > 1 ? s.style.barGap : 0
179
+ const values = s.style.values
206
180
 
207
181
  return s.data.map((row, rowIndex) => {
208
- // The stack is built from the *live* contributions, so a series fading out
209
- // lowers the segments above it as it goes.
210
- const spans = barSpans(s.stacked ? weightedRow(s, row) : row, s.series, s.stacked)
211
- const span = spans[index] ?? { from: 0, to: 0, value: 0 }
212
- const cap = s.stacked ? stackCapIndex(row, s.series) : index
213
- const slotLeft = slotCenter(rowIndex) - inner / 2
214
- const x = s.stacked ? slotCenter(rowIndex) : slotLeft + unit * before + (unit * weight) / 2
215
- const width = s.stacked ? inner : Math.max(0, unit * weight - (s.series.length > 1 ? gap : 0))
216
- // The figure printed is the row's own value, not the weighted one.
217
- const value = num(row, series.field)
182
+ // Built from live contributions, so a fading series lowers the segments beyond it.
183
+ const spans = barSpans(row, s.series, s.stack, s.weights)
184
+ const span = spans[index] ?? { from: 0, to: 0, value: 0, outer: false }
185
+ const centre = slotAt(s, rowIndex, across)
186
+ const offset = -inner / 2 + unit * before + (unit * weight) / 2
187
+ let caption = ""
188
+ if (values !== "none" && span.value !== 0) {
189
+ if (!stacked || values === "inside" || s.stack === "percent") caption = valueLabel(s, domain, stacked ? span.value : num(row, series.field))
190
+ else if (span.outer) caption = valueLabel(s, domain, span.to)
191
+ }
218
192
  return {
219
- x,
220
- width,
221
- from: valueToY(domain, plot, span.from),
222
- to: valueToY(domain, plot, span.to),
223
- round: cap === index,
224
- value,
225
- ...(captioned && { caption: valueLabel(s, domain, value) }),
193
+ center: stacked ? centre : centre + (s.orientation === "horizontal" ? -offset : offset),
194
+ thickness: stacked ? inner : Math.max(0, unit * weight - gap),
195
+ from: valueToPx(domain, along, span.from),
196
+ to: valueToPx(domain, along, span.to),
197
+ round: span.outer,
198
+ caption,
226
199
  }
227
200
  })
228
201
  }
229
202
 
230
- /**
231
- * How far each category is dimmed by the spotlight, one entry per row: 1 while
232
- * nothing is spotlit, falling to the theme's `dimOpacity` for every category but
233
- * the chosen one as the reveal climbs.
234
- */
203
+ /** Per-row opacity: 1 while nothing is spotlit, falling to {@link SPOTLIGHT_DIM} for all but the chosen category. */
235
204
  function categoryDims(s: BarState): number[] {
236
- const focus = clampUnit(s.spotlightReveal)
237
- const dim = s.theme.spotlight.dimOpacity
205
+ const focus = clamp(s.spotlightReveal, 0, 1)
238
206
  const chosen = s.spotlight
239
- return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - dim) * focus))
207
+ return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - SPOTLIGHT_DIM) * focus))
240
208
  }
241
209
 
242
- type AnyTimeline = Timeline<any, any>
243
-
244
210
  /** The parts a bar chart's commands drive, found through its children. */
245
211
  function partsOf(children: readonly ChildView[]) {
246
- const frame = cartesianViews(children)
247
- if (!frame) return null
248
- const cAxis = frame.below.find(CategoryAxis.is)
249
- if (!VerticalAxis.is(frame.axis) || !cAxis) return null
212
+ const views = chartViews(children)
250
213
  return {
251
- vAxis: frame.axis,
252
- cAxis,
253
- bars: frame.plot.filter(ChartBars.is),
254
- legend: frame.below.find(ChartLegend.is) ?? null,
214
+ valueAxis: views.find((view) => VerticalAxis.is(view) || HorizontalAxis.is(view)) ?? null,
215
+ categoryAxis: views.find(CategoryAxis.is) ?? null,
216
+ bars: views.filter(ChartBars.is),
217
+ legend: views.find(ChartLegend.is) ?? null,
255
218
  }
256
219
  }
257
220
 
258
221
  /**
259
- * Move series `index`'s slot share to `target` with its legend entry, the group
260
- * reflowing. Its columns are drawn for every frame the move covers, and switch
261
- * off only once a hide has landed, so arriving at a time backwards shows what
262
- * arriving forwards does.
222
+ * Move series `index`'s share to `target` with its legend entry. Its bars stay
223
+ * drawn for every frame the move covers and switch off only once a hide has
224
+ * landed, so arriving at a time backwards matches arriving forwards.
263
225
  */
264
- function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: boolean[] }, children: readonly ChildView[], index: number, target: number) {
226
+ function toggling(timeline: AnyTimeline, state: BarState, children: readonly ChildView[], index: number, target: number) {
265
227
  const parts = partsOf(children)
266
- if (!parts || !parts.bars[index]) return timeline.wait(timeline.duration)
267
- // Read through `weightAt`, so a chart whose stored weights are short still
268
- // tweens from the 1 it is drawing at.
228
+ if (!parts.bars[index]) return timeline.wait(timeline.duration)
269
229
  const from = state.series.map((_, i) => weightAt(state, i))
270
230
  const start = from[index] ?? 1
271
231
  const showing = state.series.map((s, i) => state.showing[i] ?? (s.enabled ?? true))
@@ -282,61 +242,42 @@ function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: b
282
242
  }
283
243
 
284
244
  /**
285
- * A grouped or stacked bar chart, the sibling of the line chart and built the
286
- * same way, out of several of the same pieces:
287
- *
288
- * - `ChartCanvas` — the plot panel with a horizontal grid, under everything.
289
- * - `VerticalAxis` — the numeric value markers, staggering in bottom-to-top.
290
- * - {@link CategoryAxis} — the row of names beneath the baseline, one per row.
291
- * - {@link ChartBars} (one per series) — that series' column in every category,
292
- * plus the figures printed on them.
293
- * - `ChartLegend` — a row of entries below the chart, one per series.
245
+ * A bar chart, vertical or horizontal, its series side by side, stacked, or
246
+ * stacked as shares of 100. Built like the line chart from `ChartCanvas`, a
247
+ * value axis, a {@link CategoryAxis}, one {@link ChartBars} per series and
248
+ * `ChartLegend`. Zero is always on the scale and bars grow about it, so a
249
+ * negative value draws the other way from the same `growth` signal.
294
250
  *
295
- * Every part reads the chart's entries through bindings, and the columns map
296
- * across the plot panel's own laid-out box, so a column's end lands exactly on
297
- * its marker. **Zero is always on the scale**, and the columns grow about it, so
298
- * a negative value draws downward out of the same `growth` signal.
299
- *
300
- * **Resting state is the finished chart**; `enter` arms every part on its first
301
- * frame. `showSeries`/`hideSeries`/`spotlightSeries` pick out a *series* (hiding
302
- * one reflows the group), `spotlightCategory`/`clearCategory` a *column of the
303
- * table*. A `to` of the value axis's `range` or `ticks` tweens from the domain
304
- * as drawn, so an auto axis rescales the columns continuously.
251
+ * Resting state is the finished chart; `enter` arms every part on its first
252
+ * frame. A `to` of the value axis's `range` or `ticks` tweens from the domain as
253
+ * drawn, so an auto axis rescales the bars continuously.
305
254
  */
306
255
  export const BarChart = component2d({
307
256
  key: "barChart",
308
257
  state: {
309
- data: fresh<DataRecord[]>(() => []),
258
+ data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
259
+ // Names a column, not a value, like the line chart's `xField`.
310
260
  categoryField: "",
311
- series: fresh<BarSeries[]>(() => []),
312
- stacked: false,
261
+ series: fresh<BarSeries[]>(() => [], { kind: "series" }),
262
+ /** `vertical`: columns rise from a row of categories; `horizontal`: bars grow right from a column of them. */
263
+ orientation: Types.oneOf(["vertical", "horizontal"] as const, { animatable: false }),
264
+ /** `stacked`: each series sits on the sum of those before it; `percent`: every category reads as shares of 100. */
265
+ stack: Types.oneOf(["none", "stacked", "percent"] as const),
313
266
  valueAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
314
267
  categoryAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisDomain }),
315
- theme: Types.custom<BarChartTheme, BarChartConfig>(DEFAULT_BAR_CHART_THEME, { resolve: resolveBarChartTheme, lerp: lerpBarChartTheme }),
316
- /**
317
- * Global rise of every column in `[0, 1]`, measured about the baseline: 0
318
- * collapses them all onto it, 1 draws them at full length.
319
- */
268
+ style: BAR_STYLE,
269
+ /** Rise of every bar about the baseline, `[0, 1]`: 0 collapses them onto it. */
320
270
  growth: 1,
321
- /**
322
- * How much of the slot each series claims, one entry per series: a weight
323
- * rather than a switch, so hiding one is the others widening into the space.
324
- * Seeded from each series' `enabled`.
325
- */
326
- weights: fresh<number[]>(() => []),
327
- /** The category spotlit, or `null` for none. The choice snaps; `spotlightReveal` is the animation. */
271
+ /** The category spotlit, or `null`. The choice snaps; `spotlightReveal` animates it. */
328
272
  spotlight: Types.custom<number | null>(null),
329
- /** How far the category spotlight is drawn in, `[0, 1]`. */
330
273
  spotlightReveal: 0,
331
- /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
332
- showLegend: Types.boolean(true, { animatable: false }),
333
- /** Read as the chart is built: whether each column prints the figure it plots. */
334
- showValues: Types.boolean(false, { animatable: false }),
335
- /** Whether each series' columns are drawn: on for every frame a show or hide covers, off once a hide lands. */
274
+ /** Each series' share of the slot, seeded from `enabled`: a weight rather than a switch, so hiding one widens the others. */
275
+ weights: Types.custom<number[]>([], { private: true }),
276
+ /** Whether each series' bars are drawn: on for every frame a show or hide covers, off once a hide lands. */
336
277
  showing: Types.custom<boolean[]>([], { private: true }),
337
278
  },
338
279
  defaults: {
339
- // Its parts fill it; a node made of them would hug them.
280
+ // A node made of parts that fill would otherwise hug them.
340
281
  width: "fill",
341
282
  height: "fill",
342
283
  weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
@@ -344,172 +285,194 @@ export const BarChart = component2d({
344
285
  },
345
286
  allowedChildren: [],
346
287
  children: (state) => {
347
- const theme = () => state.theme
288
+ const style = () => state.style
348
289
  const domain = () => barChartDomain(state)
349
290
  let plot: Node2D | undefined
350
- // Before the panel is built or laid out it has no size, and the columns map onto nothing.
351
291
  const plotSize = () => laidOutSize(plot)
352
- const captioned = state.showValues === true
353
-
354
- const vAxis = new VerticalAxis({
355
- range: () => domain().range,
356
- markers: () => domain().ticks,
357
- captions: () => {
358
- const d = domain()
359
- return d.ticks.map((value) => valueLabel(state, d, value))
360
- },
361
- axis: () => theme().yAxis,
362
- label: state.valueAxis.label ?? "",
363
- width: "hug",
364
- height: "fill",
365
- })
366
-
367
- const cAxis = new CategoryAxis({
368
- categories: barCategories(state.data, state.categoryField),
369
- axis: () => theme().xAxis,
370
- label: state.categoryAxis.label ?? "",
371
- dims: () => categoryDims(state),
372
- width: "fill",
373
- height: "hug",
374
- }) as unknown as Node2D
292
+ const built = state.style
293
+ const horizontal = state.orientation === "horizontal"
294
+ const categories = barCategories(state.data, state.categoryField)
295
+ const valueConfig = () => axisConfigOf(style(), horizontal ? "x" : "y")
296
+ const categoryConfig = () => axisConfigOf(style(), horizontal ? "y" : "x")
297
+ const valueCaptions = () => {
298
+ const d = domain()
299
+ return d.ticks.map((value) => valueLabel(state, d, value))
300
+ }
301
+ const showValue = horizontal ? built.showXAxis : built.showYAxis
302
+ const showCategory = horizontal ? built.showYAxis : built.showXAxis
303
+
304
+ const valueAxis = showValue
305
+ ? (horizontal
306
+ ? new HorizontalAxis({
307
+ range: () => domain().range,
308
+ markers: () => domain().ticks,
309
+ captions: valueCaptions,
310
+ axis: valueConfig,
311
+ label: state.valueAxis.label ?? "",
312
+ overhang: () => ({ left: overhang(style()), right: overhang(style()) }),
313
+ width: "fill",
314
+ height: "hug",
315
+ })
316
+ : new VerticalAxis({
317
+ range: () => domain().range,
318
+ markers: () => domain().ticks,
319
+ captions: valueCaptions,
320
+ axis: valueConfig,
321
+ label: state.valueAxis.label ?? "",
322
+ width: "hug",
323
+ height: "fill",
324
+ })) as unknown as Node2D
325
+ : undefined
326
+
327
+ const categoryAxis = showCategory
328
+ ? (new CategoryAxis({
329
+ categories,
330
+ axis: categoryConfig,
331
+ label: state.categoryAxis.label ?? "",
332
+ orientation: horizontal ? "vertical" : "horizontal",
333
+ dims: () => categoryDims(state),
334
+ width: horizontal ? "hug" : "fill",
335
+ height: horizontal ? "fill" : "hug",
336
+ }) as unknown as Node2D)
337
+ : undefined
338
+
339
+ const left = horizontal ? categoryAxis : valueAxis
340
+ const bottom = horizontal ? valueAxis : categoryAxis
341
+ const leftConfig = horizontal ? categoryConfig : valueConfig
342
+ const ghost = left && bottom
343
+ ? columnGhost({
344
+ captions: horizontal ? () => categories : valueCaptions,
345
+ count: horizontal ? categories.length : barChartDomain(state).ticks.length,
346
+ text: () => leftConfig().labelStyle,
347
+ title: (horizontal ? state.categoryAxis.label : state.valueAxis.label) ?? "",
348
+ titleText: () => leftConfig().titleStyle,
349
+ tickPad: () => axisPads(leftConfig(), "right").tick,
350
+ titlePad: () => axisPads(leftConfig(), "right").title,
351
+ })
352
+ : undefined
375
353
 
376
354
  const barSets = state.series.map(
377
355
  (s, i) =>
378
356
  new ChartBars({
379
- bars: () => seriesBars(state, i, plotSize(), captioned),
380
- baseline: () => baselinePx(domain(), plotSize()),
381
- fill: () => resolveBarFill(theme().seriesFills, s, i),
382
- barStroke: () => theme().bar.stroke,
383
- barShadow: () => theme().bar.shadow,
384
- cornerRadius: () => theme().bar.cornerRadius,
357
+ bars: () => seriesBars(state, i, plotSize()),
358
+ baseline: () => valueToPx(domain(), runs(state, plotSize()).along, baselineValue(domain())),
359
+ horizontal,
360
+ fill: () => seriesFill(style(), s, i),
361
+ cornerRadius: () => style().barRadius,
385
362
  growth: () => state.growth,
386
363
  dims: () => categoryDims(state),
387
- showValues: captioned,
388
- valueStyle: state.theme.valueStyle,
364
+ // A figure past a stack's end prints the stack's total; a percent stack has none worth printing, so prints its shares inside.
365
+ values: () => (built.values === "end" && state.stack === "percent" ? "inside" : built.values),
366
+ valueStyle: () => ({ ...valueText(style()), fontSize: Math.round(style().fontSize * 0.85) }),
389
367
  enabled: () => state.showing[i] ?? (s.enabled ?? true),
390
368
  }) as unknown as Node2D
391
369
  )
392
370
 
393
- const below: Node2D[] = [cAxis]
394
- if (state.showLegend !== false) {
395
- below.push(
396
- new ChartLegend({
397
- series: state.series,
398
- swatches: () => state.series.map((s, i) => resolveBarFill(theme().seriesFills, s, i)),
399
- legend: () => theme().legend,
400
- // The row's spacing is the theme's as the chart was built, like the
401
- // entries' type and chips: a theme tween re-paints the swatches only.
402
- gap: state.theme.legend.itemGap,
403
- padding: state.theme.legend.padding,
404
- }) as unknown as Node2D
405
- )
406
- }
407
-
371
+ const legendAt = built.legend
372
+ const legend = legendAt !== "none"
373
+ ? (new ChartLegend({
374
+ series: state.series,
375
+ swatches: () => state.series.map((s, i) => seriesFill(style(), s, i)),
376
+ legend: () => legendConfigOf(style()),
377
+ flow: legendAt === "right" ? "vertical" : "horizontal",
378
+ }) as unknown as Node2D)
379
+ : undefined
380
+
381
+ const n = state.data.length
382
+ // Category lines fall between the slots, never through a bar.
383
+ const boundaries = () => Array.from({ length: Math.max(0, n - 1) }, (_, i) => i + 1)
384
+ const slots = (): Range => [0, Math.max(1, n)]
408
385
  plot = new Rect({
409
- clip: true,
410
386
  children: [
411
387
  new ChartCanvas({
412
- fill: () => theme().plotArea.fill,
413
- shadow: () => theme().plotArea.shadow,
414
- xGridStroke: () => theme().xAxis.lineStyle,
415
- yGridStroke: () => theme().yAxis.lineStyle,
416
- // No vertical rules: the horizontal axis is categorical, so there is no
417
- // numeric position along it for a grid line to mark.
418
- xMarkers: [],
419
- yMarkers: () => domain().ticks,
420
- xExtent: () => [0, 1] as Range,
421
- yExtent: () => domain().range,
388
+ fill: () => style().background,
389
+ xGridStroke: () => style().verticalGrid,
390
+ yGridStroke: () => style().horizontalGrid,
391
+ xMarkers: horizontal ? () => domain().ticks : boundaries,
392
+ yMarkers: horizontal ? boundaries : () => domain().ticks,
393
+ xExtent: horizontal ? () => domain().range : slots,
394
+ yExtent: horizontal ? slots : () => domain().range,
395
+ axisStroke: () => style().axisStroke,
396
+ xAxisAt: () => (horizontal ? 0 : baselineValue(domain())),
397
+ yAxisAt: () => (horizontal ? baselineValue(domain()) : 0),
422
398
  }),
423
399
  ...barSets,
424
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
425
400
  ],
426
401
  }) as unknown as Node2D
427
402
 
428
- const gutter = new VerticalAxis({
429
- opacity: 0,
430
- range: () => domain().range,
431
- markers: () => domain().ticks,
432
- axis: () => theme().yAxis,
433
- label: state.valueAxis.label ?? "",
434
- width: "hug",
435
- height: 0,
436
- }) as unknown as Node2D
437
-
438
- return asParts([cartesianFrame({ axis: vAxis as unknown as Node2D, plot, gutter, below })])
403
+ return asParts([
404
+ cartesianFrame({
405
+ left,
406
+ ghost,
407
+ plot,
408
+ bottom,
409
+ legend,
410
+ legendAt,
411
+ gap: () => frameGap(style()),
412
+ padding: () => ({
413
+ top: headroom(style()),
414
+ right: horizontal && bottom ? overhang(style()) : 0,
415
+ left: horizontal && bottom && !left ? overhang(style()) : 0,
416
+ bottom: 0,
417
+ }),
418
+ }),
419
+ ])
439
420
  },
440
421
  commands: {
441
- /**
442
- * Animate the whole chart in: the two axes stagger their markers and
443
- * captions in, the columns rise from the baseline, and the legend fades up
444
- * last. Its first frame arms every part: markers and captions shifted and
445
- * transparent, columns collapsed, the legend dropped and transparent.
446
- * `duration` is the **whole** entrance; the beats are shares of it.
447
- */
422
+ /** Axes stagger in, bars rise from the baseline, the legend fades up last. `duration` is the whole entrance. */
448
423
  enter: {
449
424
  args: {},
450
- // Held from the chart's arrival, so it never shows finished ahead of its draw.
425
+ // Held from arrival, so it never shows finished ahead of its draw.
451
426
  entrance: true,
452
427
  easing: easeOut(),
453
- run: (timeline, _state, children) => {
428
+ run: (timeline, state, children) => {
454
429
  const parts = partsOf(children)
455
- if (!parts) return timeline.wait(timeline.duration)
456
430
  const duration = timeline.duration
457
- const axes = duration * AXES_SHARE
458
- const rise = duration * BARS_SHARE
431
+ const axes = duration * ENTER_SHARE.axes
432
+ const rise = duration * ENTER_SHARE.marks
459
433
  const { legend } = parts
460
434
  const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
461
- // Without a legend the beat is still its share of the whole.
462
435
  return beats(timeline, [
463
436
  at0(timeline.animate(() => ({ growth: 0 }))),
464
- ...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
465
- { at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
466
- { at: 0, duration: axes, piece: parts.cAxis.run("enter", { slide: 24 }) },
437
+ ...(legend ? [at0(legend.to({ opacity: 0, ...legendAway(state.style.legend) }))] : []),
438
+ ...(parts.valueAxis ? [{ at: 0, duration: axes, piece: parts.valueAxis.run("enter", { slide: 24 }) }] : []),
439
+ ...(parts.categoryAxis ? [{ at: 0, duration: axes, piece: parts.categoryAxis.run("enter", { slide: 24 }) }] : []),
467
440
  // No priming to 0 first: the value at `t` is a function of `t`.
468
441
  { at: axes, duration: rise, piece: timeline.animate((t) => ({ growth: t })) },
469
- ...(legend ? [{ at: axes + rise, duration: duration * LEGEND_SHARE, piece: legend.to({ opacity: 1, y: 0 }) }] : []),
442
+ ...(legend ? [{ at: axes + rise, duration: duration * ENTER_SHARE.legend, piece: legend.to({ opacity: 1, x: 0, y: 0 }) }] : []),
470
443
  ])
471
444
  },
472
445
  },
473
- /** Show series `index`: its columns come back, the group reflowing to make room, with its legend entry. */
446
+ /** Bring series `index` back, the group reflowing to make room, with its legend entry. */
474
447
  showSeries: {
475
448
  args: { seriesIndex: SERIES_ARG },
476
449
  easing: easeOut(),
477
450
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 1),
478
451
  },
479
- /**
480
- * Hide series `index`: its columns narrow away (or, stacked, sink out of the
481
- * stack) while the rest of the group widens into the space — the mirror of
482
- * `showSeries`.
483
- */
452
+ /** The mirror of `showSeries`: bars narrow away (stacked, sink out) while the rest widen into the space. */
484
453
  hideSeries: {
485
454
  args: { seriesIndex: SERIES_ARG },
486
455
  easing: easeOut(),
487
456
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 0),
488
457
  },
489
- /**
490
- * Fade every other series — its columns **and its legend entry** — to focus
491
- * on `index`, which comes back to full. Nothing moves, so the group doesn't
492
- * reflow.
493
- */
458
+ /** Fade every other series, bars and legend entry, to focus on `index`. Nothing moves, so the group does not reflow. */
494
459
  spotlightSeries: {
495
460
  args: { seriesIndex: SERIES_ARG },
496
461
  easing: easeOut(),
497
- run: (timeline, state, children) => {
462
+ run: (timeline, _state, children) => {
498
463
  const parts = partsOf(children)
499
- if (!parts) return timeline.wait(timeline.duration)
500
464
  const index = timeline.args.seriesIndex
501
- const dim = state.theme.spotlight.dimOpacity
502
465
  return timeline.together([
503
- ...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : dim })),
466
+ ...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
504
467
  ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
468
+ timeline.wait(timeline.duration),
505
469
  ])
506
470
  },
507
471
  },
508
472
  /**
509
- * Spotlight one **category**: every other category's columns and caption
510
- * fade to the theme's `dimOpacity`. Called while another is lit, the focus
511
- * moves directly: only the dimming changes, so nothing retracts first. A
512
- * category that is not there takes its slot and changes nothing.
473
+ * Fade every other category's bars and caption to {@link SPOTLIGHT_DIM}. While
474
+ * another is lit the focus moves directly. A missing category takes its slot
475
+ * and changes nothing.
513
476
  */
514
477
  spotlightCategory: {
515
478
  args: { categoryIndex: Types.number(0, { min: 0, step: 1 }) },
@@ -521,11 +484,7 @@ export const BarChart = component2d({
521
484
  return timeline.animate((t) => ({ spotlight: index, spotlightReveal: from + (1 - from) * t }))
522
485
  },
523
486
  },
524
- /**
525
- * Drop the category spotlight, every category back to full. The lit
526
- * category is held until the fade lands; a no-op when nothing is lit, which
527
- * still takes its slot.
528
- */
487
+ /** Every category back to full; the lit one is held until the fade lands. A no-op when nothing is lit, which still takes its slot. */
529
488
  clearCategory: {
530
489
  args: {},
531
490
  easing: easeOut(),