@motionscript/charts 0.1.1-alpha.0 → 0.1.1-alpha.2

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 +34 -0
  2. package/README.md +109 -28
  3. package/dist/bar-chart/bar-chart.d.ts +265 -39
  4. package/dist/bar-chart/bar-chart.d.ts.map +1 -1
  5. package/dist/bar-chart/bar-chart.js +219 -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 +76 -52
  12. package/dist/bar-chart/chart-bars.d.ts.map +1 -1
  13. package/dist/bar-chart/chart-bars.js +105 -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 +41 -65
  20. package/dist/bar-chart/shared.d.ts.map +1 -1
  21. package/dist/bar-chart/shared.js +73 -117
  22. package/dist/bar-chart/shared.js.map +1 -1
  23. package/dist/browser/chunks/chunk-7XCRX6GE.js +2 -0
  24. package/dist/browser/chunks/chunk-7XCRX6GE.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 +51 -15
  38. package/dist/kit/cartesian.d.ts.map +1 -1
  39. package/dist/kit/cartesian.js +81 -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 +517 -0
  70. package/dist/kit/style.d.ts.map +1 -0
  71. package/dist/kit/style.js +292 -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 +281 -18
  90. package/dist/line-chart/line-chart.d.ts.map +1 -1
  91. package/dist/line-chart/line-chart.js +284 -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 +38 -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 +61 -62
  118. package/dist/pie-chart/shared.d.ts.map +1 -1
  119. package/dist/pie-chart/shared.js +44 -123
  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 +212 -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 +49 -0
  130. package/dist/radar-chart/radar-grid.d.ts.map +1 -0
  131. package/dist/radar-chart/radar-grid.js +63 -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 +78 -0
  138. package/dist/radar-chart/shared.d.ts.map +1 -0
  139. package/dist/radar-chart/shared.js +129 -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 +210 -0
  146. package/dist/radial-chart/radial-chart.d.ts.map +1 -0
  147. package/dist/radial-chart/radial-chart.js +175 -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 +77 -0
  154. package/dist/radial-chart/shared.d.ts.map +1 -0
  155. package/dist/radial-chart/shared.js +78 -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 +263 -179
  160. package/src/bar-chart/category-axis.ts +46 -40
  161. package/src/bar-chart/chart-bars.ts +136 -117
  162. package/src/bar-chart/index.ts +7 -10
  163. package/src/bar-chart/shared.ts +88 -233
  164. package/src/engine.ts +5 -2
  165. package/src/index.ts +2 -0
  166. package/src/kit/cartesian.ts +121 -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 +353 -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 +353 -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 +54 -35
  185. package/src/pie-chart/pie-ring.ts +92 -71
  186. package/src/pie-chart/shared.ts +80 -270
  187. package/src/radar-chart/index.ts +4 -0
  188. package/src/radar-chart/radar-chart.ts +290 -0
  189. package/src/radar-chart/radar-grid.ts +83 -0
  190. package/src/radar-chart/radar-series.ts +88 -0
  191. package/src/radar-chart/shared.ts +153 -0
  192. package/src/radial-chart/index.ts +5 -0
  193. package/src/radial-chart/radial-chart.ts +212 -0
  194. package/src/radial-chart/radial-rings.ts +328 -0
  195. package/src/radial-chart/shared.ts +126 -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,39 @@ 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 TextStyle as _TextStyle,
15
+ type CornerRadiusResolved as _CornerRadiusResolved,
16
+ type CornerStyleResolved as _CornerStyleResolved,
17
+ type RectCornerRadius as _RectCornerRadius,
18
+ type RectCornerStyle as _RectCornerStyle,
19
+ type Fill as _Fill,
20
+ type FillResolved as _FillResolved,
21
+ type Stroke as _Stroke,
22
+ type StrokeResolved as _StrokeResolved,
13
23
  type Size2D,
24
+ type StyleInput as _StyleInput,
25
+ type StyleValues as _StyleValues,
14
26
  } from "@motionscript/sdk"
15
27
  import { formatNumber } from "@motionscript/sdk/component"
16
28
 
17
- import { cartesianFrame, cartesianViews, laidOutSize } from "../kit/cartesian"
18
- import { ChartBorder } from "../kit/chart-border"
29
+ import { cartesianFrame, chartViews, columnGhost, frameGap, headroom, laidOutSize, overhang } from "../kit/cartesian"
19
30
  import { ChartCanvas } from "../kit/chart-canvas"
20
31
  import { ChartLegend } from "../kit/chart-legend"
21
32
  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"
33
+ import { HorizontalAxis } from "../kit/horizontal-axis"
34
+ import { DEFAULT_MARKERS, ENTER_SHARE, beats, legendAway, type AnyTimeline, type Beat } from "../kit/motion"
24
35
  import {
36
+ SPOTLIGHT_DIM,
25
37
  formatMarker,
26
38
  lerpAxisDomain,
27
39
  lerpAxisEntry,
28
40
  markerStep,
29
41
  markerValues,
42
+ niceTicks,
30
43
  num,
31
44
  resolveAxisDomain,
32
45
  settleAxisDomain,
@@ -34,27 +47,48 @@ import {
34
47
  type Range,
35
48
  type ResolvedAxisDomain,
36
49
  } from "../kit/shared"
37
- import { CategoryAxis } from "./category-axis"
50
+ import {
51
+ CARTESIAN_GRID,
52
+ CARTESIAN_TEXT,
53
+ CHART_LEGEND,
54
+ CHART_SERIES,
55
+ CHART_STYLE_FAMILY,
56
+ CHART_STYLE_PRESETS,
57
+ GRID_BACKGROUND,
58
+ axisConfigOf,
59
+ migrateChartStyle,
60
+ legendConfigOf,
61
+ seriesFill,
62
+ valueText,
63
+ } from "../kit/style"
64
+ import { VerticalAxis, axisPads } from "../kit/vertical-axis"
65
+ import { CategoryAxis, slotCentre } from "./category-axis"
38
66
  import { ChartBars, type BarGeometry } from "./chart-bars"
39
67
  import {
40
- DEFAULT_BAR_CHART_THEME,
68
+ BAR_LABELS,
69
+ BAR_MARKS,
41
70
  barCategories,
42
71
  barSpans,
43
- lerpBarChartTheme,
44
- resolveBarChartTheme,
45
- resolveBarFill,
46
72
  resolveBarScale,
47
- stackCapIndex,
48
- type BarChartConfig,
49
- type BarChartTheme,
73
+ type BarOrientation,
50
74
  type BarSeries,
75
+ type BarStack,
51
76
  } from "./shared"
52
77
 
78
+ const BAR_STYLE = Types.style(
79
+ CHART_STYLE_FAMILY,
80
+ { series: CHART_SERIES, background: GRID_BACKGROUND, text: CARTESIAN_TEXT, grid: CARTESIAN_GRID, marks: BAR_MARKS, labels: BAR_LABELS, legend: CHART_LEGEND },
81
+ { presets: CHART_STYLE_PRESETS }
82
+ )
83
+
84
+ /** A bar chart's resolved style. */
85
+ export type BarChartStyle = OutOf<typeof BAR_STYLE>
86
+
53
87
  /** What a bar chart plots, as its entries hold it. */
54
88
  export interface BarChartValues {
55
89
  readonly data: DataRecord[]
56
90
  readonly series: BarSeries[]
57
- readonly stacked: boolean
91
+ readonly stack: BarStack
58
92
  readonly valueAxis: AxisDomainConfig
59
93
  }
60
94
 
@@ -62,138 +96,144 @@ export interface BarChartValues {
62
96
  export type BarChartDomain = ResolvedAxisDomain
63
97
 
64
98
  interface BarState extends BarChartValues {
65
- readonly theme: BarChartConfig
99
+ readonly categoryField: string
100
+ readonly orientation: BarOrientation
101
+ readonly style: BarChartStyle
66
102
  readonly weights: number[]
103
+ readonly showing: boolean[]
67
104
  readonly spotlight: number | null
68
105
  readonly spotlightReveal: number
69
106
  }
70
107
 
71
108
  function drawnValue(s: BarChartValues, domain: AxisDomainConfig): AxisDomainConfig {
72
109
  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) }
110
+ const range = resolveBarScale(s.data, s.series, s.stack, domain.range, divisions)
111
+ const even = domain.range !== undefined || s.stack === "percent"
112
+ return { ...domain, range, ticks: domain.ticks ?? (even ? markerValues(range, DEFAULT_MARKERS - 1) : niceTicks(range, DEFAULT_MARKERS - 1)) }
75
113
  }
76
114
 
77
115
  /** 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 }>()
116
+ const domainsDrawn = new WeakMap<DataRecord[], { readonly key: readonly unknown[]; readonly domain: BarChartDomain }>()
79
117
 
80
- /** The value axis as drawn now. */
118
+ /** The value axis as drawn now. A series being hidden keeps its room, so the ticks hold still while it goes. */
81
119
  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 })
120
+ const key = [s.series, s.stack, s.valueAxis]
121
+ const kept = domainsDrawn.get(s.data)
122
+ if (kept && kept.key.every((value, i) => value === key[i])) return kept.domain
123
+ const domain = settleAxisDomain(s.valueAxis, (d) => drawnValue(s, d)) as BarChartDomain
124
+ domainsDrawn.set(s.data, { key, domain })
87
125
  return domain
88
126
  }
89
127
 
90
- /** Ticks and the figures printed on columns both read through here, so they cannot disagree about how a number looks. */
128
+ /** Ticks and the figures printed by bars both read through here, so they cannot disagree about how a number looks. */
91
129
  function valueLabel(s: BarChartValues, domain: BarChartDomain, value: number): string {
92
130
  const pattern = s.valueAxis.format
93
- return pattern === undefined ? formatMarker(value, markerStep(domain.ticks)) : formatNumber(value, pattern)
131
+ if (pattern !== undefined) return formatNumber(value, pattern)
132
+ const text = formatMarker(value, markerStep(domain.ticks))
133
+ return s.stack === "percent" ? `${text}%` : text
94
134
  }
95
135
 
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 {
136
+ /** A value along the value axis, a run of `length` px centred on 0. */
137
+ function valueToPx(domain: BarChartDomain, length: number, value: number): number {
98
138
  const [min, max] = domain.range
99
- if (max <= min) return -plot.height / 2
100
- return ((value - min) / (max - min) - 0.5) * plot.height
139
+ if (max === min) return -length / 2
140
+ return ((value - min) / (max - min) - 0.5) * length
101
141
  }
102
142
 
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))
143
+ /** Where bars grow from: zero held on the scale, so a cropped axis grows them from the plot's edge. */
144
+ function baselineValue(domain: BarChartDomain): number {
145
+ const [lo, hi] = domain.range
146
+ return clamp(0, Math.min(lo, hi), Math.max(lo, hi))
107
147
  }
108
148
 
109
- function weightAt(s: BarState, index: number): number {
149
+ function weightAt(s: { readonly weights: readonly number[] }, index: number): number {
110
150
  return Math.max(0, s.weights[index] ?? 1)
111
151
  }
112
152
 
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
153
+ /** The plot's run along the value axis and across the categories. */
154
+ function runs(s: BarState, plot: Size2D): { along: number; across: number } {
155
+ return s.orientation === "horizontal" ? { along: plot.width, across: plot.height } : { along: plot.height, across: plot.width }
156
+ }
157
+
158
+ /** Category `row`'s slot centre across the plot: left to right, or top to bottom. */
159
+ function slotAt(s: BarState, row: number, across: number): number {
160
+ const centre = slotCentre(row, s.data.length, across)
161
+ return s.orientation === "horizontal" ? -centre : centre
121
162
  }
122
163
 
123
164
  /**
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.
165
+ * Series `index`'s bars in centred pixel space. Side by side, a series divides
166
+ * the slot with its siblings by live weight; stacked, it takes the whole slot
167
+ * and its weight scales its length, so hiding a series reflows rather than
168
+ * punching a hole.
127
169
  */
128
- function seriesBars(s: BarState, index: number, plot: Size2D, captioned: boolean): BarGeometry[] {
170
+ function seriesBars(s: BarState, index: number, plot: Size2D): BarGeometry[] {
129
171
  const series = s.series[index]
130
172
  if (!series) return []
131
173
  const domain = barChartDomain(s)
174
+ const { along, across } = runs(s, plot)
132
175
  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
176
+ const slot = n > 0 ? across / n : 0
177
+ const inner = slot * clamp(s.style.barWidth, 0, 1)
178
+ const stacked = s.stack !== "none"
137
179
  let total = 0
138
180
  for (let i = 0; i < s.series.length; i++) total += weightAt(s, i)
139
181
  const unit = inner / (total > 0 ? total : 1)
140
182
  const weight = weightAt(s, index)
141
183
  let before = 0
142
184
  for (let i = 0; i < index; i++) before += weightAt(s, i)
185
+ const gap = s.series.length > 1 ? s.style.barGap : 0
186
+ const values = s.style.values
143
187
 
144
188
  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)
189
+ // Built from live contributions, so a fading series lowers the segments beyond it.
190
+ const spans = barSpans(row, s.series, s.stack, s.weights)
191
+ const span = spans[index] ?? { from: 0, to: 0, value: 0, outer: false }
192
+ const centre = slotAt(s, rowIndex, across)
193
+ const offset = -inner / 2 + unit * before + (unit * weight) / 2
194
+ let caption = ""
195
+ if (values !== "none" && span.value !== 0) {
196
+ if (!stacked || values === "inside" || s.stack === "percent") caption = valueLabel(s, domain, stacked ? span.value : num(row, series.field))
197
+ else if (span.outer) caption = valueLabel(s, domain, span.to)
198
+ }
154
199
  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) }),
200
+ center: stacked ? centre : centre + (s.orientation === "horizontal" ? -offset : offset),
201
+ thickness: stacked ? inner : Math.max(0, unit * weight - gap),
202
+ from: valueToPx(domain, along, span.from),
203
+ to: valueToPx(domain, along, span.to),
204
+ round: span.outer,
205
+ grounded: !stacked || span.from === 0,
206
+ caption,
162
207
  }
163
208
  })
164
209
  }
165
210
 
166
- /** Per-row opacity: 1 while nothing is spotlit, falling to the theme's `dimOpacity` for all but the chosen category. */
211
+ /** Per-row opacity: 1 while nothing is spotlit, falling to {@link SPOTLIGHT_DIM} for all but the chosen category. */
167
212
  function categoryDims(s: BarState): number[] {
168
213
  const focus = clamp(s.spotlightReveal, 0, 1)
169
- const dim = s.theme.spotlight.dimOpacity
170
214
  const chosen = s.spotlight
171
- return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - dim) * focus))
215
+ return s.data.map((_, i) => (focus <= 0 || chosen === i ? 1 : 1 - (1 - SPOTLIGHT_DIM) * focus))
172
216
  }
173
217
 
174
218
  /** The parts a bar chart's commands drive, found through its children. */
175
219
  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
220
+ const views = chartViews(children)
180
221
  return {
181
- vAxis: frame.axis,
182
- cAxis,
183
- bars: frame.plot.filter(ChartBars.is),
184
- legend: frame.below.find(ChartLegend.is) ?? null,
222
+ valueAxis: views.find((view) => VerticalAxis.is(view) || HorizontalAxis.is(view)) ?? null,
223
+ categoryAxis: views.find(CategoryAxis.is) ?? null,
224
+ bars: views.filter(ChartBars.is),
225
+ legend: views.find(ChartLegend.is) ?? null,
185
226
  }
186
227
  }
187
228
 
188
229
  /**
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.
230
+ * Move series `index`'s share to `target` with its legend entry. Its bars stay
231
+ * drawn for every frame the move covers and switch off only once a hide has
232
+ * landed, so arriving at a time backwards matches arriving forwards.
192
233
  */
193
- function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: boolean[] }, children: readonly ChildView[], index: number, target: number) {
234
+ function toggling(timeline: AnyTimeline, state: BarState, children: readonly ChildView[], index: number, target: number) {
194
235
  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.
236
+ if (!parts.bars[index]) return timeline.wait(timeline.duration)
197
237
  const from = state.series.map((_, i) => weightAt(state, i))
198
238
  const start = from[index] ?? 1
199
239
  const showing = state.series.map((s, i) => state.showing[i] ?? (s.enabled ?? true))
@@ -210,38 +250,38 @@ function toggling(timeline: AnyTimeline, state: BarState & { readonly showing: b
210
250
  }
211
251
 
212
252
  /**
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.
253
+ * A bar chart, vertical or horizontal, its series side by side, stacked, or
254
+ * stacked as shares of 100. Built like the line chart from `ChartCanvas`, a
255
+ * value axis, a {@link CategoryAxis}, one {@link ChartBars} per series and
256
+ * `ChartLegend`. Zero is always on the scale and bars grow about it, so a
257
+ * negative value draws the other way from the same `growth` signal.
217
258
  *
218
259
  * Resting state is the finished chart; `enter` arms every part on its first
219
260
  * 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.
261
+ * drawn, so an auto axis rescales the bars continuously.
221
262
  */
222
263
  export const BarChart = component2d({
223
264
  key: "barChart",
224
265
  state: {
225
- data: fresh<DataRecord[]>(() => []),
266
+ data: fresh<DataRecord[]>(() => [], { kind: "dataset" }),
226
267
  // Names a column, not a value, like the line chart's `xField`.
227
268
  categoryField: "",
228
- series: fresh<BarSeries[]>(() => []),
229
- stacked: false,
269
+ series: fresh<BarSeries[]>(() => [], { kind: "series" }),
270
+ /** `vertical`: columns rise from a row of categories; `horizontal`: bars grow right from a column of them. */
271
+ orientation: Types.oneOf(["vertical", "horizontal"] as const, { animatable: false }),
272
+ /** `stacked`: each series sits on the sum of those before it; `percent`: every category reads as shares of 100. */
273
+ stack: Types.oneOf(["none", "stacked", "percent"] as const),
230
274
  valueAxis: Types.custom<AxisDomainConfig>({}, { resolve: resolveAxisDomain, lerp: lerpAxisEntry }),
231
275
  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. */
276
+ style: BAR_STYLE,
277
+ /** Rise of every bar about the baseline, `[0, 1]`: 0 collapses them onto it. */
234
278
  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
279
  /** The category spotlit, or `null`. The choice snaps; `spotlightReveal` animates it. */
238
280
  spotlight: Types.custom<number | null>(null),
239
281
  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. */
282
+ /** Each series' share of the slot, seeded from `enabled`: a weight rather than a switch, so hiding one widens the others. */
283
+ weights: Types.custom<number[]>([], { private: true }),
284
+ /** Whether each series' bars are drawn: on for every frame a show or hide covers, off once a hide lands. */
245
285
  showing: Types.custom<boolean[]>([], { private: true }),
246
286
  },
247
287
  defaults: {
@@ -251,108 +291,153 @@ export const BarChart = component2d({
251
291
  weights: (state) => state.series.map((s) => ((s.enabled ?? true) ? 1 : 0)),
252
292
  showing: (state) => state.series.map((s) => s.enabled ?? true),
253
293
  },
294
+ migrate: { style: migrateChartStyle("cartesian") },
254
295
  allowedChildren: [],
255
296
  children: (state) => {
256
- const theme = () => state.theme
297
+ const style = () => state.style
257
298
  const domain = () => barChartDomain(state)
258
299
  let plot: Node2D | undefined
259
300
  const plotSize = () => laidOutSize(plot)
260
- const captioned = state.showValues === true
301
+ const built = state.style
302
+ const horizontal = state.orientation === "horizontal"
303
+ const categories = barCategories(state.data, state.categoryField)
304
+ const valueConfig = () => axisConfigOf(style(), horizontal ? "x" : "y")
305
+ const categoryConfig = () => axisConfigOf(style(), horizontal ? "y" : "x")
306
+ const valueCaptions = () => {
307
+ const d = domain()
308
+ return d.ticks.map((value) => valueLabel(state, d, value))
309
+ }
310
+ const showValue = horizontal ? built.showXAxis : built.showYAxis
311
+ const showCategory = horizontal ? built.showYAxis : built.showXAxis
261
312
 
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
- })
313
+ const valueAxis = showValue
314
+ ? (horizontal
315
+ ? new HorizontalAxis({
316
+ range: () => domain().range,
317
+ markers: () => domain().ticks,
318
+ captions: valueCaptions,
319
+ axis: valueConfig,
320
+ label: state.valueAxis.label ?? "",
321
+ overhang: () => ({ left: overhang(style()), right: overhang(style()) }),
322
+ width: "fill",
323
+ height: "hug",
324
+ })
325
+ : new VerticalAxis({
326
+ range: () => domain().range,
327
+ markers: () => domain().ticks,
328
+ captions: valueCaptions,
329
+ axis: valueConfig,
330
+ label: state.valueAxis.label ?? "",
331
+ width: "hug",
332
+ height: "fill",
333
+ })) as unknown as Node2D
334
+ : undefined
274
335
 
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
336
+ const categoryAxis = showCategory
337
+ ? (new CategoryAxis({
338
+ categories,
339
+ axis: categoryConfig,
340
+ label: state.categoryAxis.label ?? "",
341
+ orientation: horizontal ? "vertical" : "horizontal",
342
+ dims: () => categoryDims(state),
343
+ width: horizontal ? "hug" : "fill",
344
+ height: horizontal ? "fill" : "hug",
345
+ }) as unknown as Node2D)
346
+ : undefined
347
+
348
+ const left = horizontal ? categoryAxis : valueAxis
349
+ const bottom = horizontal ? valueAxis : categoryAxis
350
+ const leftConfig = horizontal ? categoryConfig : valueConfig
351
+ const ghost = left && bottom
352
+ ? columnGhost({
353
+ captions: horizontal ? () => categories : valueCaptions,
354
+ count: horizontal ? categories.length : barChartDomain(state).ticks.length,
355
+ text: () => leftConfig().labelStyle,
356
+ title: (horizontal ? state.categoryAxis.label : state.valueAxis.label) ?? "",
357
+ titleText: () => leftConfig().titleStyle,
358
+ tickPad: () => axisPads(leftConfig(), "right").tick,
359
+ titlePad: () => axisPads(leftConfig(), "right").title,
360
+ })
361
+ : undefined
283
362
 
284
363
  const barSets = state.series.map(
285
364
  (s, i) =>
286
365
  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,
366
+ bars: () => seriesBars(state, i, plotSize()),
367
+ baseline: () => valueToPx(domain(), runs(state, plotSize()).along, baselineValue(domain())),
368
+ horizontal,
369
+ fill: () => seriesFill(style(), s, i),
370
+ cornerRadius: () => style().barCornerRadius,
371
+ cornerStyle: () => style().barCornerStyle,
293
372
  growth: () => state.growth,
294
373
  dims: () => categoryDims(state),
295
- showValues: captioned,
296
- valueStyle: state.theme.valueStyle,
374
+ // A figure past a stack's end prints the stack's total; a percent stack has none worth printing, so prints its shares inside.
375
+ values: () => (built.values === "end" && state.stack === "percent" ? "inside" : built.values),
376
+ valueStyle: () => valueText(style()),
297
377
  enabled: () => state.showing[i] ?? (s.enabled ?? true),
298
378
  }) as unknown as Node2D
299
379
  )
300
380
 
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
- }
381
+ const legendAt = built.legend
382
+ const legend = legendAt !== "none"
383
+ ? (new ChartLegend({
384
+ series: state.series,
385
+ swatches: () => state.series.map((s, i) => seriesFill(style(), s, i)),
386
+ legend: () => legendConfigOf(style()),
387
+ flow: legendAt === "right" ? "vertical" : "horizontal",
388
+ }) as unknown as Node2D)
389
+ : undefined
314
390
 
391
+ const n = state.data.length
392
+ // Category lines fall between the slots, never through a bar.
393
+ const boundaries = () => Array.from({ length: Math.max(0, n - 1) }, (_, i) => i + 1)
394
+ const slots = (): Range => [0, Math.max(1, n)]
315
395
  plot = new Rect({
316
- clip: true,
317
396
  children: [
318
397
  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,
398
+ fill: () => style().gridBackground,
399
+ xGridStroke: () => style().verticalGrid,
400
+ yGridStroke: () => style().horizontalGrid,
401
+ xMarkers: horizontal ? () => domain().ticks : boundaries,
402
+ yMarkers: horizontal ? boundaries : () => domain().ticks,
403
+ xExtent: horizontal ? () => domain().range : slots,
404
+ yExtent: horizontal ? slots : () => domain().range,
405
+ axisStroke: () => style().axisStroke,
406
+ xAxisAt: () => (horizontal ? 0 : baselineValue(domain())),
407
+ yAxisAt: () => (horizontal ? baselineValue(domain()) : 0),
328
408
  }),
329
409
  ...barSets,
330
- new ChartBorder({ stroke: () => theme().plotArea.stroke }),
331
410
  ],
332
411
  }) as unknown as Node2D
333
412
 
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 })])
413
+ return asParts([
414
+ cartesianFrame({
415
+ background: () => style().background,
416
+ left,
417
+ ghost,
418
+ plot,
419
+ bottom,
420
+ legend,
421
+ legendAt,
422
+ gap: () => frameGap(style()),
423
+ padding: () => ({
424
+ top: headroom(style()),
425
+ right: horizontal && bottom ? overhang(style()) : 0,
426
+ left: horizontal && bottom && !left ? overhang(style()) : 0,
427
+ bottom: 0,
428
+ }),
429
+ }),
430
+ ])
345
431
  },
346
432
  commands: {
347
- /** Axes stagger in, columns rise from the baseline, the legend fades up last. `duration` is the whole entrance. */
433
+ /** Axes stagger in, bars rise from the baseline, the legend fades up last. `duration` is the whole entrance. */
348
434
  enter: {
349
435
  args: {},
350
436
  // Held from arrival, so it never shows finished ahead of its draw.
351
437
  entrance: true,
352
438
  easing: easeOut(),
353
- run: (timeline, _state, children) => {
439
+ run: (timeline, state, children) => {
354
440
  const parts = partsOf(children)
355
- if (!parts) return timeline.wait(timeline.duration)
356
441
  const duration = timeline.duration
357
442
  const axes = duration * ENTER_SHARE.axes
358
443
  const rise = duration * ENTER_SHARE.marks
@@ -360,12 +445,12 @@ export const BarChart = component2d({
360
445
  const at0 = (piece: Beat["piece"]): Beat => ({ at: 0, duration: 0, piece })
361
446
  return beats(timeline, [
362
447
  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 }) },
448
+ ...(legend ? [at0(legend.to({ opacity: 0, ...legendAway(state.style.legend) }))] : []),
449
+ ...(parts.valueAxis ? [{ at: 0, duration: axes, piece: parts.valueAxis.run("enter", { slide: 24 }) }] : []),
450
+ ...(parts.categoryAxis ? [{ at: 0, duration: axes, piece: parts.categoryAxis.run("enter", { slide: 24 }) }] : []),
366
451
  // No priming to 0 first: the value at `t` is a function of `t`.
367
452
  { 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 }) }] : []),
453
+ ...(legend ? [{ at: axes + rise, duration: duration * ENTER_SHARE.legend, piece: legend.to({ opacity: 1, x: 0, y: 0 }) }] : []),
369
454
  ])
370
455
  },
371
456
  },
@@ -375,29 +460,28 @@ export const BarChart = component2d({
375
460
  easing: easeOut(),
376
461
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 1),
377
462
  },
378
- /** The mirror of `showSeries`: columns narrow away (stacked, sink out) while the rest widen into the space. */
463
+ /** The mirror of `showSeries`: bars narrow away (stacked, sink out) while the rest widen into the space. */
379
464
  hideSeries: {
380
465
  args: { seriesIndex: SERIES_ARG },
381
466
  easing: easeOut(),
382
467
  run: (timeline, state, children) => toggling(timeline, state, children, timeline.args.seriesIndex, 0),
383
468
  },
384
- /** Fade every other series, columns and legend entry, to focus on `index`. Nothing moves, so the group does not reflow. */
469
+ /** Fade every other series, bars and legend entry, to focus on `index`. Nothing moves, so the group does not reflow. */
385
470
  spotlightSeries: {
386
471
  args: { seriesIndex: SERIES_ARG },
387
472
  easing: easeOut(),
388
- run: (timeline, state, children) => {
473
+ run: (timeline, _state, children) => {
389
474
  const parts = partsOf(children)
390
- if (!parts) return timeline.wait(timeline.duration)
391
475
  const index = timeline.args.seriesIndex
392
- const dim = state.theme.spotlight.dimOpacity
393
476
  return timeline.together([
394
- ...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : dim })),
477
+ ...parts.bars.map((bars, i) => bars.to({ opacity: i === index ? 1 : SPOTLIGHT_DIM })),
395
478
  ...(parts.legend ? [parts.legend.run("spotlightSeries", { index })] : []),
479
+ timeline.wait(timeline.duration),
396
480
  ])
397
481
  },
398
482
  },
399
483
  /**
400
- * Fade every other category's columns and caption to `dimOpacity`. While
484
+ * Fade every other category's bars and caption to {@link SPOTLIGHT_DIM}. While
401
485
  * another is lit the focus moves directly. A missing category takes its slot
402
486
  * and changes nothing.
403
487
  */