@motionscript/charts 0.1.1-alpha.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 (207) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +98 -28
  3. package/dist/bar-chart/bar-chart.d.ts +233 -39
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +215 -171
  6. package/dist/bar-chart/bar-chart.js.map +1 -1
  7. package/dist/bar-chart/category-axis.d.ts +12 -7
  8. package/dist/bar-chart/category-axis.d.ts.map +1 -1
  9. package/dist/bar-chart/category-axis.js +56 -42
  10. package/dist/bar-chart/category-axis.js.map +1 -1
  11. package/dist/bar-chart/chart-bars.d.ts +52 -52
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +80 -89
  14. package/dist/bar-chart/chart-bars.js.map +1 -1
  15. package/dist/bar-chart/index.d.ts +4 -4
  16. package/dist/bar-chart/index.d.ts.map +1 -1
  17. package/dist/bar-chart/index.js +2 -2
  18. package/dist/bar-chart/index.js.map +1 -1
  19. package/dist/bar-chart/shared.d.ts +36 -65
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +68 -117
  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 +5 -2
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +5 -2
  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 -15
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +79 -29
  40. package/dist/kit/cartesian.js.map +1 -1
  41. package/dist/kit/chart-canvas.d.ts +32 -24
  42. package/dist/kit/chart-canvas.d.ts.map +1 -1
  43. package/dist/kit/chart-canvas.js +52 -46
  44. package/dist/kit/chart-canvas.js.map +1 -1
  45. package/dist/kit/chart-legend.d.ts +8 -4
  46. package/dist/kit/chart-legend.d.ts.map +1 -1
  47. package/dist/kit/chart-legend.js +30 -20
  48. package/dist/kit/chart-legend.js.map +1 -1
  49. package/dist/{line-chart → kit}/horizontal-axis.d.ts +31 -6
  50. package/dist/kit/horizontal-axis.d.ts.map +1 -0
  51. package/dist/{line-chart → kit}/horizontal-axis.js +23 -24
  52. package/dist/kit/horizontal-axis.js.map +1 -0
  53. package/dist/kit/motion.d.ts +5 -0
  54. package/dist/kit/motion.d.ts.map +1 -1
  55. package/dist/kit/motion.js +8 -0
  56. package/dist/kit/motion.js.map +1 -1
  57. package/dist/{pie-chart → kit}/pie-legend.d.ts +3 -3
  58. package/dist/kit/pie-legend.d.ts.map +1 -0
  59. package/dist/{pie-chart → kit}/pie-legend.js +11 -10
  60. package/dist/kit/pie-legend.js.map +1 -0
  61. package/dist/kit/polar.d.ts +31 -0
  62. package/dist/kit/polar.d.ts.map +1 -0
  63. package/dist/kit/polar.js +33 -0
  64. package/dist/kit/polar.js.map +1 -0
  65. package/dist/kit/shared.d.ts +11 -50
  66. package/dist/kit/shared.d.ts.map +1 -1
  67. package/dist/kit/shared.js +58 -141
  68. package/dist/kit/shared.js.map +1 -1
  69. package/dist/kit/style.d.ts +254 -0
  70. package/dist/kit/style.d.ts.map +1 -0
  71. package/dist/kit/style.js +197 -0
  72. package/dist/kit/style.js.map +1 -0
  73. package/dist/kit/vertical-axis.d.ts +22 -2
  74. package/dist/kit/vertical-axis.d.ts.map +1 -1
  75. package/dist/kit/vertical-axis.js +56 -43
  76. package/dist/kit/vertical-axis.js.map +1 -1
  77. package/dist/line-chart/chart-line.d.ts +95 -3
  78. package/dist/line-chart/chart-line.d.ts.map +1 -1
  79. package/dist/line-chart/chart-line.js +165 -53
  80. package/dist/line-chart/chart-line.js.map +1 -1
  81. package/dist/line-chart/chart-region.d.ts +1 -1
  82. package/dist/line-chart/chart-region.d.ts.map +1 -1
  83. package/dist/line-chart/chart-region.js +7 -8
  84. package/dist/line-chart/chart-region.js.map +1 -1
  85. package/dist/line-chart/index.d.ts +5 -6
  86. package/dist/line-chart/index.d.ts.map +1 -1
  87. package/dist/line-chart/index.js +3 -4
  88. package/dist/line-chart/index.js.map +1 -1
  89. package/dist/line-chart/line-chart.d.ts +257 -18
  90. package/dist/line-chart/line-chart.d.ts.map +1 -1
  91. package/dist/line-chart/line-chart.js +282 -134
  92. package/dist/line-chart/line-chart.js.map +1 -1
  93. package/dist/line-chart/shared.d.ts +51 -34
  94. package/dist/line-chart/shared.d.ts.map +1 -1
  95. package/dist/line-chart/shared.js +175 -83
  96. package/dist/line-chart/shared.js.map +1 -1
  97. package/dist/line-chart/x-scale.d.ts +2 -0
  98. package/dist/line-chart/x-scale.d.ts.map +1 -1
  99. package/dist/line-chart/x-scale.js +15 -0
  100. package/dist/line-chart/x-scale.js.map +1 -1
  101. package/dist/nodes.d.ts +3 -1
  102. package/dist/nodes.d.ts.map +1 -1
  103. package/dist/nodes.js +9 -1
  104. package/dist/nodes.js.map +1 -1
  105. package/dist/pie-chart/index.d.ts +2 -3
  106. package/dist/pie-chart/index.d.ts.map +1 -1
  107. package/dist/pie-chart/index.js +1 -2
  108. package/dist/pie-chart/index.js.map +1 -1
  109. package/dist/pie-chart/pie-chart.d.ts +114 -18
  110. package/dist/pie-chart/pie-chart.d.ts.map +1 -1
  111. package/dist/pie-chart/pie-chart.js +37 -24
  112. package/dist/pie-chart/pie-chart.js.map +1 -1
  113. package/dist/pie-chart/pie-ring.d.ts +16 -5
  114. package/dist/pie-chart/pie-ring.d.ts.map +1 -1
  115. package/dist/pie-chart/pie-ring.js +89 -55
  116. package/dist/pie-chart/pie-ring.js.map +1 -1
  117. package/dist/pie-chart/shared.d.ts +53 -61
  118. package/dist/pie-chart/shared.d.ts.map +1 -1
  119. package/dist/pie-chart/shared.js +41 -121
  120. package/dist/pie-chart/shared.js.map +1 -1
  121. package/dist/radar-chart/index.d.ts +5 -0
  122. package/dist/radar-chart/index.d.ts.map +1 -0
  123. package/dist/radar-chart/index.js +5 -0
  124. package/dist/radar-chart/index.js.map +1 -0
  125. package/dist/radar-chart/radar-chart.d.ts +218 -0
  126. package/dist/radar-chart/radar-chart.d.ts.map +1 -0
  127. package/dist/radar-chart/radar-chart.js +224 -0
  128. package/dist/radar-chart/radar-chart.js.map +1 -0
  129. package/dist/radar-chart/radar-grid.d.ts +43 -0
  130. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-grid.js +61 -0
  132. package/dist/radar-chart/radar-grid.js.map +1 -0
  133. package/dist/radar-chart/radar-series.d.ts +59 -0
  134. package/dist/radar-chart/radar-series.d.ts.map +1 -0
  135. package/dist/radar-chart/radar-series.js +71 -0
  136. package/dist/radar-chart/radar-series.js.map +1 -0
  137. package/dist/radar-chart/shared.d.ts +73 -0
  138. package/dist/radar-chart/shared.d.ts.map +1 -0
  139. package/dist/radar-chart/shared.js +123 -0
  140. package/dist/radar-chart/shared.js.map +1 -0
  141. package/dist/radial-chart/index.d.ts +6 -0
  142. package/dist/radial-chart/index.d.ts.map +1 -0
  143. package/dist/radial-chart/index.js +4 -0
  144. package/dist/radial-chart/index.js.map +1 -0
  145. package/dist/radial-chart/radial-chart.d.ts +216 -0
  146. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  147. package/dist/radial-chart/radial-chart.js +174 -0
  148. package/dist/radial-chart/radial-chart.js.map +1 -0
  149. package/dist/radial-chart/radial-rings.d.ts +121 -0
  150. package/dist/radial-chart/radial-rings.d.ts.map +1 -0
  151. package/dist/radial-chart/radial-rings.js +273 -0
  152. package/dist/radial-chart/radial-rings.js.map +1 -0
  153. package/dist/radial-chart/shared.d.ts +73 -0
  154. package/dist/radial-chart/shared.d.ts.map +1 -0
  155. package/dist/radial-chart/shared.js +79 -0
  156. package/dist/radial-chart/shared.js.map +1 -0
  157. package/package.json +4 -4
  158. package/registry.json +21 -6
  159. package/src/bar-chart/bar-chart.ts +252 -179
  160. package/src/bar-chart/category-axis.ts +46 -40
  161. package/src/bar-chart/chart-bars.ts +97 -117
  162. package/src/bar-chart/index.ts +7 -10
  163. package/src/bar-chart/shared.ts +78 -238
  164. package/src/engine.ts +5 -2
  165. package/src/index.ts +2 -0
  166. package/src/kit/cartesian.ts +118 -40
  167. package/src/kit/chart-canvas.ts +49 -54
  168. package/src/kit/chart-legend.ts +28 -20
  169. package/src/kit/horizontal-axis.ts +114 -0
  170. package/src/kit/motion.ts +7 -0
  171. package/src/{pie-chart → kit}/pie-legend.ts +12 -11
  172. package/src/kit/polar.ts +54 -0
  173. package/src/kit/shared.ts +55 -277
  174. package/src/kit/style.ts +257 -0
  175. package/src/kit/vertical-axis.ts +67 -48
  176. package/src/line-chart/chart-line.ts +176 -54
  177. package/src/line-chart/chart-region.ts +6 -10
  178. package/src/line-chart/index.ts +14 -10
  179. package/src/line-chart/line-chart.ts +347 -151
  180. package/src/line-chart/shared.ts +193 -174
  181. package/src/line-chart/x-scale.ts +13 -0
  182. package/src/nodes.ts +9 -1
  183. package/src/pie-chart/index.ts +2 -23
  184. package/src/pie-chart/pie-chart.ts +52 -35
  185. package/src/pie-chart/pie-ring.ts +92 -71
  186. package/src/pie-chart/shared.ts +68 -266
  187. package/src/radar-chart/index.ts +4 -0
  188. package/src/radar-chart/radar-chart.ts +287 -0
  189. package/src/radar-chart/radar-grid.ts +78 -0
  190. package/src/radar-chart/radar-series.ts +88 -0
  191. package/src/radar-chart/shared.ts +146 -0
  192. package/src/radial-chart/index.ts +5 -0
  193. package/src/radial-chart/radial-chart.ts +210 -0
  194. package/src/radial-chart/radial-rings.ts +328 -0
  195. package/src/radial-chart/shared.ts +114 -0
  196. package/dist/browser/chunks/chunk-4SMRCU24.js +0 -2
  197. package/dist/browser/chunks/chunk-4SMRCU24.js.map +0 -7
  198. package/dist/kit/chart-border.d.ts +0 -18
  199. package/dist/kit/chart-border.d.ts.map +0 -1
  200. package/dist/kit/chart-border.js +0 -23
  201. package/dist/kit/chart-border.js.map +0 -1
  202. package/dist/line-chart/horizontal-axis.d.ts.map +0 -1
  203. package/dist/line-chart/horizontal-axis.js.map +0 -1
  204. package/dist/pie-chart/pie-legend.d.ts.map +0 -1
  205. package/dist/pie-chart/pie-legend.js.map +0 -1
  206. package/src/kit/chart-border.ts +0 -36
  207. package/src/line-chart/horizontal-axis.ts +0 -122
@@ -7,26 +7,34 @@ import {
7
7
  type ChildView,
8
8
  type DataRecord,
9
9
  type NodeOf,
10
+ type OutOf,
10
11
  // Named so this component's emitted type reaches `Node2D` and its props through the sdk, not core.
11
12
  type Node2D,
12
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,
19
+ type StyleInput as _StyleInput,
20
+ type StyleValues as _StyleValues,
14
21
  } from "@motionscript/sdk"
15
22
  import { formatNumber } from "@motionscript/sdk/component"
16
23
 
17
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
18
- import { ChartBorder } from "../kit/chart-border"
24
+ import { cartesianFrame, chartViews, columnGhost, frameGap, headroom, laidOutSize, overhang } from "../kit/cartesian"
19
25
  import { ChartCanvas } from "../kit/chart-canvas"
20
26
  import { ChartLegend } from "../kit/chart-legend"
21
27
  import { SERIES_ARG, asParts, fresh } from "../kit/entries"
22
- import { DEFAULT_MARKERS, ENTER_SHARE, LEGEND_SLIDE, beats, type AnyTimeline, type Beat } from "../kit/motion"
23
- import { VerticalAxis } from "../kit/vertical-axis"
28
+ import { HorizontalAxis } from "../kit/horizontal-axis"
29
+ import { DEFAULT_MARKERS, ENTER_SHARE, beats, legendAway, type AnyTimeline, type Beat } from "../kit/motion"
24
30
  import {
31
+ SPOTLIGHT_DIM,
25
32
  formatMarker,
26
33
  lerpAxisDomain,
27
34
  lerpAxisEntry,
28
35
  markerStep,
29
36
  markerValues,
37
+ niceTicks,
30
38
  num,
31
39
  resolveAxisDomain,
32
40
  settleAxisDomain,
@@ -34,27 +42,46 @@ import {
34
42
  type Range,
35
43
  type ResolvedAxisDomain,
36
44
  } from "../kit/shared"
37
- 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"
38
59
  import { ChartBars, type BarGeometry } from "./chart-bars"
39
60
  import {
40
- DEFAULT_BAR_CHART_THEME,
61
+ BAR_LABELS,
62
+ BAR_MARKS,
41
63
  barCategories,
42
64
  barSpans,
43
- lerpBarChartTheme,
44
- resolveBarChartTheme,
45
- resolveBarFill,
46
65
  resolveBarScale,
47
- stackCapIndex,
48
- type BarChartConfig,
49
- type BarChartTheme,
66
+ type BarOrientation,
50
67
  type BarSeries,
68
+ type BarStack,
51
69
  } from "./shared"
52
70
 
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>
79
+
53
80
  /** What a bar chart plots, as its entries hold it. */
54
81
  export interface BarChartValues {
55
82
  readonly data: DataRecord[]
56
83
  readonly series: BarSeries[]
57
- readonly stacked: boolean
84
+ readonly stack: BarStack
58
85
  readonly valueAxis: AxisDomainConfig
59
86
  }
60
87
 
@@ -62,138 +89,143 @@ export interface BarChartValues {
62
89
  export type BarChartDomain = ResolvedAxisDomain
63
90
 
64
91
  interface BarState extends BarChartValues {
65
- readonly theme: BarChartConfig
92
+ readonly categoryField: string
93
+ readonly orientation: BarOrientation
94
+ readonly style: BarChartStyle
66
95
  readonly weights: number[]
96
+ readonly showing: boolean[]
67
97
  readonly spotlight: number | null
68
98
  readonly spotlightReveal: number
69
99
  }
70
100
 
71
101
  function drawnValue(s: BarChartValues, domain: AxisDomainConfig): AxisDomainConfig {
72
102
  const divisions = (domain.ticks?.length ?? DEFAULT_MARKERS) - 1
73
- const range = resolveBarScale(s.data, s.series, s.stacked, domain.range, divisions)
74
- 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)) }
75
106
  }
76
107
 
77
108
  /** The last domain worked out from each data array, and what it was read from. */
78
- const domainsDrawn = new WeakMap<DataRecord[], Omit<BarChartValues, "data"> & { readonly domain: BarChartDomain }>()
109
+ const domainsDrawn = new WeakMap<DataRecord[], { readonly key: readonly unknown[]; readonly domain: BarChartDomain }>()
79
110
 
80
- /** The value axis as drawn now. */
111
+ /** The value axis as drawn now. A series being hidden keeps its room, so the ticks hold still while it goes. */
81
112
  export function barChartDomain(s: BarChartValues): BarChartDomain {
82
- const { data, series, stacked, valueAxis } = s
83
- const kept = domainsDrawn.get(data)
84
- if (kept && kept.series === series && kept.stacked === stacked && kept.valueAxis === valueAxis) return kept.domain
85
- const domain = settleAxisDomain(valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
86
- 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 })
87
118
  return domain
88
119
  }
89
120
 
90
- /** Ticks and the figures printed on columns both read through here, so they cannot disagree about how a number looks. */
121
+ /** Ticks and the figures printed by bars both read through here, so they cannot disagree about how a number looks. */
91
122
  function valueLabel(s: BarChartValues, domain: BarChartDomain, value: number): string {
92
123
  const pattern = s.valueAxis.format
93
- 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
94
127
  }
95
128
 
96
- /** A value in the plot's centred pixel space (origin at its centre, y-up). */
97
- 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 {
98
131
  const [min, max] = domain.range
99
- if (max <= min) return -plot.height / 2
100
- 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
101
134
  }
102
135
 
103
- /** Pixel y columns grow from: zero clamped onto the scale, so a cropped axis grows them from the plot floor. */
104
- function baselinePx(domain: BarChartDomain, plot: Size2D): number {
105
- const [min, max] = domain.range
106
- 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))
107
140
  }
108
141
 
109
- function weightAt(s: BarState, index: number): number {
142
+ function weightAt(s: { readonly weights: readonly number[] }, index: number): number {
110
143
  return Math.max(0, s.weights[index] ?? 1)
111
144
  }
112
145
 
113
- /** `row` with each series' value scaled by its live weight, which stacked spans are measured from. */
114
- function weightedRow(s: BarState, row: DataRecord): DataRecord {
115
- const scaled: DataRecord = { ...row }
116
- s.series.forEach((series, i) => {
117
- const value = row[series.field]
118
- if (typeof value === "number") scaled[series.field] = value * weightAt(s, i)
119
- })
120
- 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
121
155
  }
122
156
 
123
157
  /**
124
- * Series `index`'s columns in centred pixel space. A grouped series divides the
125
- * slot with its siblings by live weight, a stacked one takes the whole slot, so
126
- * 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.
127
162
  */
128
- function seriesBars(s: BarState, index: number, plot: Size2D, captioned: boolean): BarGeometry[] {
163
+ function seriesBars(s: BarState, index: number, plot: Size2D): BarGeometry[] {
129
164
  const series = s.series[index]
130
165
  if (!series) return []
131
166
  const domain = barChartDomain(s)
167
+ const { along, across } = runs(s, plot)
132
168
  const n = s.data.length
133
- const slotWidth = n > 0 ? plot.width / n : 0
134
- const slotCenter = (i: number) => -plot.width / 2 + slotWidth * (i + 0.5)
135
- const inner = slotWidth * clamp(s.theme.bar.slotFill, 0, 1)
136
- 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"
137
172
  let total = 0
138
173
  for (let i = 0; i < s.series.length; i++) total += weightAt(s, i)
139
174
  const unit = inner / (total > 0 ? total : 1)
140
175
  const weight = weightAt(s, index)
141
176
  let before = 0
142
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
143
180
 
144
181
  return s.data.map((row, rowIndex) => {
145
- // Built from live contributions, so a fading series lowers the segments above it.
146
- const spans = barSpans(s.stacked ? weightedRow(s, row) : row, s.series, s.stacked)
147
- const span = spans[index] ?? { from: 0, to: 0, value: 0 }
148
- const cap = s.stacked ? stackCapIndex(row, s.series) : index
149
- const slotLeft = slotCenter(rowIndex) - inner / 2
150
- const x = s.stacked ? slotCenter(rowIndex) : slotLeft + unit * before + (unit * weight) / 2
151
- const width = s.stacked ? inner : Math.max(0, unit * weight - (s.series.length > 1 ? gap : 0))
152
- // The printed figure is the row's own value, not the weighted one.
153
- 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
+ }
154
192
  return {
155
- x,
156
- width,
157
- from: valueToY(domain, plot, span.from),
158
- to: valueToY(domain, plot, span.to),
159
- round: cap === index,
160
- value,
161
- ...(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,
162
199
  }
163
200
  })
164
201
  }
165
202
 
166
- /** Per-row opacity: 1 while nothing is spotlit, falling to the theme's `dimOpacity` for all but the chosen category. */
203
+ /** Per-row opacity: 1 while nothing is spotlit, falling to {@link SPOTLIGHT_DIM} for all but the chosen category. */
167
204
  function categoryDims(s: BarState): number[] {
168
205
  const focus = clamp(s.spotlightReveal, 0, 1)
169
- const dim = s.theme.spotlight.dimOpacity
170
206
  const chosen = s.spotlight
171
- 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))
172
208
  }
173
209
 
174
210
  /** The parts a bar chart's commands drive, found through its children. */
175
211
  function partsOf(children: readonly ChildView[]) {
176
- const frame = cartesianViews(children)
177
- if (!frame) return null
178
- const cAxis = frame.below.find(CategoryAxis.is)
179
- if (!VerticalAxis.is(frame.axis) || !cAxis) return null
212
+ const views = chartViews(children)
180
213
  return {
181
- vAxis: frame.axis,
182
- cAxis,
183
- bars: frame.plot.filter(ChartBars.is),
184
- 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,
185
218
  }
186
219
  }
187
220
 
188
221
  /**
189
- * Move series `index`'s slot share to `target` with its legend entry. Its
190
- * columns stay drawn for every frame the move covers and switch off only once a
191
- * hide has landed, so arriving at a time backwards matches arriving forwards.
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.
192
225
  */
193
- 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) {
194
227
  const parts = partsOf(children)
195
- if (!parts || !parts.bars[index]) return timeline.wait(timeline.duration)
196
- // Through `weightAt`, so short stored weights still tween from the 1 drawn.
228
+ if (!parts.bars[index]) return timeline.wait(timeline.duration)
197
229
  const from = state.series.map((_, i) => weightAt(state, i))
198
230
  const start = from[index] ?? 1
199
231
  const showing = state.series.map((s, i) => state.showing[i] ?? (s.enabled ?? true))
@@ -210,38 +242,38 @@ function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: b
210
242
  }
211
243
 
212
244
  /**
213
- * A grouped or stacked bar chart, built like the line chart from `ChartCanvas`,
214
- * `VerticalAxis`, {@link CategoryAxis}, one {@link ChartBars} per series and
215
- * `ChartLegend`. Zero is always on the scale and columns grow about it, so a
216
- * negative value draws downward from the same `growth` signal.
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.
217
250
  *
218
251
  * Resting state is the finished chart; `enter` arms every part on its first
219
252
  * frame. A `to` of the value axis's `range` or `ticks` tweens from the domain as
220
- * drawn, so an auto axis rescales the columns continuously.
253
+ * drawn, so an auto axis rescales the bars continuously.
221
254
  */
222
255
  export const BarChart = component2d({
223
256
  key: "barChart",
224
257
  state: {
225
- data: fresh<DataRecord[]>(() => []),
258
+ data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
226
259
  // Names a column, not a value, like the line chart's `xField`.
227
260
  categoryField: "",
228
- series: fresh<BarSeries[]>(() => []),
229
- 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),
230
266
  valueAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
231
267
  categoryAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisDomain }),
232
- theme: Types.custom<BarChartTheme, BarChartConfig>(DEFAULT_BAR_CHART_THEME, { resolve: resolveBarChartTheme, lerp: lerpBarChartTheme }),
233
- /** Rise of every column about the baseline, `[0, 1]`: 0 collapses them onto it. */
268
+ style: BAR_STYLE,
269
+ /** Rise of every bar about the baseline, `[0, 1]`: 0 collapses them onto it. */
234
270
  growth: 1,
235
- /** Each series' share of the slot, seeded from `enabled`: a weight rather than a switch, so hiding one widens the others. */
236
- weights: fresh<number[]>(() => []),
237
271
  /** The category spotlit, or `null`. The choice snaps; `spotlightReveal` animates it. */
238
272
  spotlight: Types.custom<number | null>(null),
239
273
  spotlightReveal: 0,
240
- /** Read as the chart is built: the legend is a part the chart builds or doesn't. */
241
- showLegend: Types.boolean(true, { animatable: false }),
242
- /** Read as the chart is built: whether each column prints the figure it plots. */
243
- showValues: Types.boolean(false, { animatable: false }),
244
- /** 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. */
245
277
  showing: Types.custom<boolean[]>([], { private: true }),
246
278
  },
247
279
  defaults: {
@@ -253,106 +285,148 @@ export const BarChart = component2d({
253
285
  },
254
286
  allowedChildren: [],
255
287
  children: (state) => {
256
- const theme = () => state.theme
288
+ const style = () => state.style
257
289
  const domain = () => barChartDomain(state)
258
290
  let plot: Node2D | undefined
259
291
  const plotSize = () => laidOutSize(plot)
260
- const captioned = state.showValues === true
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
261
303
 
262
- const vAxis = new VerticalAxis({
263
- range: () => domain().range,
264
- markers: () => domain().ticks,
265
- captions: () => {
266
- const d = domain()
267
- return d.ticks.map((value) => valueLabel(state, d, value))
268
- },
269
- axis: () => theme().yAxis,
270
- label: state.valueAxis.label ?? "",
271
- width: "hug",
272
- height: "fill",
273
- })
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
274
326
 
275
- const cAxis = new CategoryAxis({
276
- categories: barCategories(state.data, state.categoryField),
277
- axis: () => theme().xAxis,
278
- label: state.categoryAxis.label ?? "",
279
- dims: () => categoryDims(state),
280
- width: "fill",
281
- height: "hug",
282
- }) as unknown as Node2D
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
283
353
 
284
354
  const barSets = state.series.map(
285
355
  (s, i) =>
286
356
  new ChartBars({
287
- bars: () => seriesBars(state, i, plotSize(), captioned),
288
- baseline: () => baselinePx(domain(), plotSize()),
289
- fill: () => resolveBarFill(theme().seriesFills, s, i),
290
- barStroke: () => theme().bar.stroke,
291
- barShadow: () => theme().bar.shadow,
292
- 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,
293
362
  growth: () => state.growth,
294
363
  dims: () => categoryDims(state),
295
- showValues: captioned,
296
- 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) }),
297
367
  enabled: () => state.showing[i] ?? (s.enabled ?? true),
298
368
  }) as unknown as Node2D
299
369
  )
300
370
 
301
- const below: Node2D[] = [cAxis]
302
- if (state.showLegend !== false) {
303
- below.push(
304
- new ChartLegend({
305
- series: state.series,
306
- swatches: () => state.series.map((s, i) => resolveBarFill(theme().seriesFills, s, i)),
307
- legend: () => theme().legend,
308
- // Spacing is fixed as built; a theme tween re-paints the swatches only.
309
- gap: state.theme.legend.itemGap,
310
- padding: state.theme.legend.padding,
311
- }) as unknown as Node2D
312
- )
313
- }
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
314
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)]
315
385
  plot = new Rect({
316
- clip: true,
317
386
  children: [
318
387
  new ChartCanvas({
319
- fill: () => theme().plotArea.fill,
320
- shadow: () => theme().plotArea.shadow,
321
- xGridStroke: () => theme().xAxis.lineStyle,
322
- yGridStroke: () => theme().yAxis.lineStyle,
323
- // Categorical x axis: no numeric position for a vertical rule to mark.
324
- xMarkers: [],
325
- yMarkers: () => domain().ticks,
326
- xExtent: () => [0, 1] as Range,
327
- 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),
328
398
  }),
329
399
  ...barSets,
330
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
331
400
  ],
332
401
  }) as unknown as Node2D
333
402
 
334
- const gutter = new VerticalAxis({
335
- opacity: 0,
336
- range: () => domain().range,
337
- markers: () => domain().ticks,
338
- axis: () => theme().yAxis,
339
- label: state.valueAxis.label ?? "",
340
- width: "hug",
341
- height: 0,
342
- }) as unknown as Node2D
343
-
344
- 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
+ ])
345
420
  },
346
421
  commands: {
347
- /** Axes stagger in, columns rise from the baseline, the legend fades up last. `duration` is the whole entrance. */
422
+ /** Axes stagger in, bars rise from the baseline, the legend fades up last. `duration` is the whole entrance. */
348
423
  enter: {
349
424
  args: {},
350
425
  // Held from arrival, so it never shows finished ahead of its draw.
351
426
  entrance: true,
352
427
  easing: easeOut(),
353
- run: (timeline, _state, children) => {
428
+ run: (timeline, state, children) => {
354
429
  const parts = partsOf(children)
355
- if (!parts) return timeline.wait(timeline.duration)
356
430
  const duration = timeline.duration
357
431
  const axes = duration * ENTER_SHARE.axes
358
432
  const rise = duration * ENTER_SHARE.marks
@@ -360,12 +434,12 @@ export const BarChart = component2d({
360
434
  const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
361
435
  return beats(timeline, [
362
436
  at0(timeline.animate(() => ({ growth: 0 }))),
363
- ...(legend ? [at0(legend.to({ opacity: 0, y: -LEGEND_SLIDE }))] : []),
364
- { at: 0, duration: axes, piece: parts.vAxis.run("enter", { slide: 24 }) },
365
- { at: 0, duration: axes, piece: parts.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 }) }] : []),
366
440
  // No priming to 0 first: the value at `t` is a function of `t`.
367
441
  { at: axes, duration: rise, piece: timeline.animate((t) => ({ growth: t })) },
368
- ...(legend ? [{ at: axes + rise, duration: duration * ENTER_SHARE.legend, 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 }) }] : []),
369
443
  ])
370
444
  },
371
445
  },
@@ -375,29 +449,28 @@ export const BarChart = component2d({
375
449
  easing: easeOut(),
376
450
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 1),
377
451
  },
378
- /** The mirror of `showSeries`: columns narrow away (stacked, sink out) while the rest widen into the space. */
452
+ /** The mirror of `showSeries`: bars narrow away (stacked, sink out) while the rest widen into the space. */
379
453
  hideSeries: {
380
454
  args: { seriesIndex: SERIES_ARG },
381
455
  easing: easeOut(),
382
456
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 0),
383
457
  },
384
- /** Fade every other series, columns and legend entry, to focus on `index`. Nothing moves, so the group does not reflow. */
458
+ /** Fade every other series, bars and legend entry, to focus on `index`. Nothing moves, so the group does not reflow. */
385
459
  spotlightSeries: {
386
460
  args: { seriesIndex: SERIES_ARG },
387
461
  easing: easeOut(),
388
- run: (timeline, state, children) => {
462
+ run: (timeline, _state, children) => {
389
463
  const parts = partsOf(children)
390
- if (!parts) return timeline.wait(timeline.duration)
391
464
  const index = timeline.args.seriesIndex
392
- const dim = state.theme.spotlight.dimOpacity
393
465
  return timeline.together([
394
- ...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 })),
395
467
  ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
468
+ timeline.wait(timeline.duration),
396
469
  ])
397
470
  },
398
471
  },
399
472
  /**
400
- * Fade every other category's columns and caption to `dimOpacity`. While
473
+ * Fade every other category's bars and caption to {@link SPOTLIGHT_DIM}. While
401
474
  * another is lit the focus moves directly. A missing category takes its slot
402
475
  * and changes nothing.
403
476
  */