panelui-native 0.22.6 → 0.28.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 (258) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/accordion/index.js +8 -8
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +2 -2
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/area-chart/index.js +958 -0
  7. package/lib/module/components/area-chart/index.js.map +1 -0
  8. package/lib/module/components/attachment/index.js +2 -1
  9. package/lib/module/components/attachment/index.js.map +1 -1
  10. package/lib/module/components/avatar/index.js +4 -4
  11. package/lib/module/components/avatar/index.js.map +1 -1
  12. package/lib/module/components/badge/index.js +4 -4
  13. package/lib/module/components/badge/index.js.map +1 -1
  14. package/lib/module/components/bar-chart/index.js +994 -0
  15. package/lib/module/components/bar-chart/index.js.map +1 -0
  16. package/lib/module/components/bottom-sheet/index.js +213 -24
  17. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  18. package/lib/module/components/breadcrumb/index.js +275 -0
  19. package/lib/module/components/breadcrumb/index.js.map +1 -0
  20. package/lib/module/components/button/index.js +11 -6
  21. package/lib/module/components/button/index.js.map +1 -1
  22. package/lib/module/components/calendar/index.js +557 -0
  23. package/lib/module/components/calendar/index.js.map +1 -0
  24. package/lib/module/components/carousel/index.js +680 -0
  25. package/lib/module/components/carousel/index.js.map +1 -0
  26. package/lib/module/components/chip/index.js +6 -6
  27. package/lib/module/components/chip/index.js.map +1 -1
  28. package/lib/module/components/date-picker/index.js +158 -0
  29. package/lib/module/components/date-picker/index.js.map +1 -0
  30. package/lib/module/components/dialog/index.js +7 -2
  31. package/lib/module/components/dialog/index.js.map +1 -1
  32. package/lib/module/components/direction/index.js +15 -20
  33. package/lib/module/components/direction/index.js.map +1 -1
  34. package/lib/module/components/empty-state/index.js +2 -2
  35. package/lib/module/components/empty-state/index.js.map +1 -1
  36. package/lib/module/components/field/index.js +5 -5
  37. package/lib/module/components/field/index.js.map +1 -1
  38. package/lib/module/components/flow/index.js +3 -3
  39. package/lib/module/components/flow/index.js.map +1 -1
  40. package/lib/module/components/frame/index.js +22 -5
  41. package/lib/module/components/frame/index.js.map +1 -1
  42. package/lib/module/components/heatmap-chart/index.js +28 -8
  43. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  44. package/lib/module/components/input-group/index.js +5 -4
  45. package/lib/module/components/input-group/index.js.map +1 -1
  46. package/lib/module/components/item/index.js +9 -9
  47. package/lib/module/components/item/index.js.map +1 -1
  48. package/lib/module/components/label/index.js +4 -4
  49. package/lib/module/components/label/index.js.map +1 -1
  50. package/lib/module/components/line-chart/index.js +165 -184
  51. package/lib/module/components/line-chart/index.js.map +1 -1
  52. package/lib/module/components/loader/index.js +758 -0
  53. package/lib/module/components/loader/index.js.map +1 -0
  54. package/lib/module/components/map/basemap.js +265 -0
  55. package/lib/module/components/map/basemap.js.map +1 -0
  56. package/lib/module/components/map/index.js +850 -0
  57. package/lib/module/components/map/index.js.map +1 -0
  58. package/lib/module/components/map/maplibre.js +87 -0
  59. package/lib/module/components/map/maplibre.js.map +1 -0
  60. package/lib/module/components/marker/index.js +3 -3
  61. package/lib/module/components/marker/index.js.map +1 -1
  62. package/lib/module/components/message/index.js +15 -14
  63. package/lib/module/components/message/index.js.map +1 -1
  64. package/lib/module/components/message-scroller/index.js +5 -4
  65. package/lib/module/components/message-scroller/index.js.map +1 -1
  66. package/lib/module/components/otp-input/index.js +2 -2
  67. package/lib/module/components/popover/index.js +39 -7
  68. package/lib/module/components/popover/index.js.map +1 -1
  69. package/lib/module/components/progress/index.js +8 -2
  70. package/lib/module/components/progress/index.js.map +1 -1
  71. package/lib/module/components/radio-group/index.js +2 -2
  72. package/lib/module/components/radio-group/index.js.map +1 -1
  73. package/lib/module/components/ring-chart/index.js +423 -0
  74. package/lib/module/components/ring-chart/index.js.map +1 -0
  75. package/lib/module/components/section-rail/index.js +6 -6
  76. package/lib/module/components/section-rail/index.js.map +1 -1
  77. package/lib/module/components/select/index.js +10 -4
  78. package/lib/module/components/select/index.js.map +1 -1
  79. package/lib/module/components/separator/index.js +76 -22
  80. package/lib/module/components/separator/index.js.map +1 -1
  81. package/lib/module/components/shimmer/index.js +10 -1
  82. package/lib/module/components/shimmer/index.js.map +1 -1
  83. package/lib/module/components/slider/index.js +19 -5
  84. package/lib/module/components/slider/index.js.map +1 -1
  85. package/lib/module/components/steps/index.js +5 -5
  86. package/lib/module/components/steps/index.js.map +1 -1
  87. package/lib/module/components/switch/index.js +6 -1
  88. package/lib/module/components/switch/index.js.map +1 -1
  89. package/lib/module/components/tabs/index.js +7 -7
  90. package/lib/module/components/tabs/index.js.map +1 -1
  91. package/lib/module/components/timeline/index.js +4 -4
  92. package/lib/module/components/timeline/index.js.map +1 -1
  93. package/lib/module/components/toast/index.js +2 -2
  94. package/lib/module/components/toast/index.js.map +1 -1
  95. package/lib/module/components/toggle-button/index.js +5 -5
  96. package/lib/module/components/toggle-button/index.js.map +1 -1
  97. package/lib/module/components/tooltip/index.js +492 -0
  98. package/lib/module/components/tooltip/index.js.map +1 -0
  99. package/lib/module/components/typography/index.js +7 -2
  100. package/lib/module/components/typography/index.js.map +1 -1
  101. package/lib/module/hooks/index.js +1 -0
  102. package/lib/module/hooks/index.js.map +1 -1
  103. package/lib/module/hooks/use-back-handler.js +37 -0
  104. package/lib/module/hooks/use-back-handler.js.map +1 -0
  105. package/lib/module/hooks/use-direction.js +43 -0
  106. package/lib/module/hooks/use-direction.js.map +1 -0
  107. package/lib/module/icons/index.js +180 -2
  108. package/lib/module/icons/index.js.map +1 -1
  109. package/lib/module/index.js +12 -2
  110. package/lib/module/index.js.map +1 -1
  111. package/lib/module/primitives/keyboard-avoider.js +2 -1
  112. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  113. package/lib/module/primitives/text.js +55 -11
  114. package/lib/module/primitives/text.js.map +1 -1
  115. package/lib/module/utils/chart.js +279 -0
  116. package/lib/module/utils/chart.js.map +1 -0
  117. package/lib/module/utils/date.js +386 -0
  118. package/lib/module/utils/date.js.map +1 -0
  119. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/area-chart/index.d.ts +237 -0
  122. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -0
  123. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/bar-chart/index.d.ts +270 -0
  125. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -0
  126. package/lib/typescript/src/components/bottom-sheet/index.d.ts +75 -4
  127. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/breadcrumb/index.d.ts +100 -0
  129. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -0
  130. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/calendar/index.d.ts +108 -0
  132. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  134. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  135. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  136. package/lib/typescript/src/components/date-picker/index.d.ts +91 -0
  137. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  138. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/direction/index.d.ts +11 -10
  140. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  142. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/frame/index.d.ts +17 -0
  144. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  145. package/lib/typescript/src/components/heatmap-chart/index.d.ts +22 -3
  146. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  148. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  150. package/lib/typescript/src/components/line-chart/index.d.ts +42 -2
  151. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  152. package/lib/typescript/src/components/loader/index.d.ts +38 -0
  153. package/lib/typescript/src/components/loader/index.d.ts.map +1 -0
  154. package/lib/typescript/src/components/map/basemap.d.ts +76 -0
  155. package/lib/typescript/src/components/map/basemap.d.ts.map +1 -0
  156. package/lib/typescript/src/components/map/index.d.ts +367 -0
  157. package/lib/typescript/src/components/map/index.d.ts.map +1 -0
  158. package/lib/typescript/src/components/map/maplibre.d.ts +193 -0
  159. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -0
  160. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  161. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  162. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  163. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  164. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  165. package/lib/typescript/src/components/ring-chart/index.d.ts +156 -0
  166. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -0
  167. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  168. package/lib/typescript/src/components/separator/index.d.ts +36 -1
  169. package/lib/typescript/src/components/separator/index.d.ts.map +1 -1
  170. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  171. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  172. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  173. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  174. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  175. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  176. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  177. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  178. package/lib/typescript/src/components/tooltip/index.d.ts +125 -0
  179. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  180. package/lib/typescript/src/components/typography/index.d.ts +6 -0
  181. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  182. package/lib/typescript/src/hooks/index.d.ts +1 -0
  183. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  184. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  185. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  186. package/lib/typescript/src/hooks/use-direction.d.ts +20 -0
  187. package/lib/typescript/src/hooks/use-direction.d.ts.map +1 -0
  188. package/lib/typescript/src/icons/index.d.ts +8 -0
  189. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  190. package/lib/typescript/src/index.d.ts +14 -4
  191. package/lib/typescript/src/index.d.ts.map +1 -1
  192. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  193. package/lib/typescript/src/primitives/text.d.ts +20 -0
  194. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  195. package/lib/typescript/src/utils/chart.d.ts +96 -0
  196. package/lib/typescript/src/utils/chart.d.ts.map +1 -0
  197. package/lib/typescript/src/utils/date.d.ts +116 -0
  198. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  199. package/package.json +9 -1
  200. package/src/components/accordion/index.tsx +8 -12
  201. package/src/components/alert/index.tsx +2 -2
  202. package/src/components/area-chart/index.tsx +1147 -0
  203. package/src/components/attachment/index.tsx +2 -2
  204. package/src/components/avatar/index.tsx +4 -4
  205. package/src/components/badge/index.tsx +6 -6
  206. package/src/components/bar-chart/index.tsx +1208 -0
  207. package/src/components/bottom-sheet/index.tsx +282 -21
  208. package/src/components/breadcrumb/index.tsx +363 -0
  209. package/src/components/button/index.tsx +12 -8
  210. package/src/components/calendar/index.tsx +703 -0
  211. package/src/components/carousel/index.tsx +788 -0
  212. package/src/components/chip/index.tsx +6 -8
  213. package/src/components/date-picker/index.tsx +237 -0
  214. package/src/components/dialog/index.tsx +7 -2
  215. package/src/components/direction/index.tsx +22 -22
  216. package/src/components/empty-state/index.tsx +2 -2
  217. package/src/components/field/index.tsx +3 -5
  218. package/src/components/flow/index.tsx +3 -3
  219. package/src/components/frame/index.tsx +22 -7
  220. package/src/components/heatmap-chart/index.tsx +58 -11
  221. package/src/components/input-group/index.tsx +5 -4
  222. package/src/components/item/index.tsx +9 -9
  223. package/src/components/label/index.tsx +2 -2
  224. package/src/components/line-chart/index.tsx +194 -223
  225. package/src/components/loader/index.tsx +812 -0
  226. package/src/components/map/basemap.ts +304 -0
  227. package/src/components/map/index.tsx +1116 -0
  228. package/src/components/map/maplibre.ts +201 -0
  229. package/src/components/marker/index.tsx +3 -3
  230. package/src/components/message/index.tsx +15 -14
  231. package/src/components/message-scroller/index.tsx +5 -4
  232. package/src/components/otp-input/index.tsx +2 -2
  233. package/src/components/popover/index.tsx +43 -7
  234. package/src/components/progress/index.tsx +10 -2
  235. package/src/components/radio-group/index.tsx +2 -2
  236. package/src/components/ring-chart/index.tsx +543 -0
  237. package/src/components/section-rail/index.tsx +6 -6
  238. package/src/components/select/index.tsx +10 -4
  239. package/src/components/separator/index.tsx +77 -19
  240. package/src/components/shimmer/index.tsx +10 -1
  241. package/src/components/slider/index.tsx +20 -5
  242. package/src/components/steps/index.tsx +5 -5
  243. package/src/components/switch/index.tsx +6 -1
  244. package/src/components/tabs/index.tsx +7 -9
  245. package/src/components/timeline/index.tsx +4 -4
  246. package/src/components/toast/index.tsx +2 -2
  247. package/src/components/toggle-button/index.tsx +5 -7
  248. package/src/components/tooltip/index.tsx +610 -0
  249. package/src/components/typography/index.tsx +7 -2
  250. package/src/hooks/index.ts +1 -0
  251. package/src/hooks/use-back-handler.ts +37 -0
  252. package/src/hooks/use-direction.ts +45 -0
  253. package/src/icons/index.tsx +111 -0
  254. package/src/index.ts +129 -0
  255. package/src/primitives/keyboard-avoider.tsx +2 -1
  256. package/src/primitives/text.tsx +53 -8
  257. package/src/utils/chart.ts +355 -0
  258. package/src/utils/date.ts +446 -0
@@ -0,0 +1,1208 @@
1
+ /**
2
+ * BarChart — categories compared by length, drawn and animated on the UI thread.
3
+ *
4
+ * Composed the same way `LineChart` is: the grid, each series, the axes and the
5
+ * readout are separate children, so a chart that wants no grid simply does not
6
+ * have one.
7
+ *
8
+ * ```tsx
9
+ * <BarChart data={revenue} xDataKey="month">
10
+ * <BarChart.Grid />
11
+ * <BarChart.Bar dataKey="revenue" />
12
+ * <BarChart.XAxis />
13
+ * <BarChart.Tooltip />
14
+ * </BarChart>
15
+ * ```
16
+ *
17
+ * ## What is different from a line
18
+ *
19
+ * **The baseline is zero, and not negotiable.** A line's job is to show change,
20
+ * so it may crop its axis to the range the data actually occupies. A bar's job
21
+ * is to compare *lengths*, and a bar cropped at the bottom is a length that
22
+ * lies — twice as tall no longer means twice as much. So the domain always
23
+ * reaches zero unless `yDomain` says otherwise, and saying otherwise is opting
24
+ * into a chart that misreads.
25
+ *
26
+ * **Bands, not points.** A line has a point at each x; a bar owns a slice of
27
+ * width around it. `barGap` is the fraction of that slice left empty, so bars
28
+ * stay proportional at any width instead of needing a pixel gap that is wrong
29
+ * on half of them.
30
+ *
31
+ * **Every series is one path.** A `Bar` draws all its rectangles as subpaths of
32
+ * a single animated path, split in two so the band under the finger keeps full
33
+ * ink while the rest fade. Fifty bars is two animated props a frame rather
34
+ * than fifty, and the corners are drawn as a path because a bar is rounded on
35
+ * the end it grows towards and square on the end it grows from — which `rx`
36
+ * cannot express, since it rounds all four corners or none.
37
+ */
38
+ import {
39
+ Children,
40
+ createContext,
41
+ forwardRef,
42
+ isValidElement,
43
+ useContext,
44
+ useEffect,
45
+ useImperativeHandle,
46
+ useMemo,
47
+ useRef,
48
+ useState,
49
+ type ReactNode,
50
+ } from 'react';
51
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
52
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
53
+ import Animated, {
54
+ Easing,
55
+ runOnJS,
56
+ useAnimatedProps,
57
+ useAnimatedStyle,
58
+ useReducedMotion,
59
+ useSharedValue,
60
+ withTiming,
61
+ type SharedValue,
62
+ } from 'react-native-reanimated';
63
+ import Svg, { G, Line as SvgLine, Path } from 'react-native-svg';
64
+ import { useCSSVariable } from 'uniwind';
65
+ import { Text } from '../../primitives/text';
66
+ import {
67
+ bandOf,
68
+ barPath,
69
+ columnValues,
70
+ compactNumber,
71
+ useSeriesColor,
72
+ type Plot,
73
+ type SeriesColorIndex,
74
+ } from '../../utils/chart';
75
+ import { cn } from '../../utils/cn';
76
+
77
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
78
+
79
+ /** Room left around the plot for the axis labels. */
80
+ const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
81
+
82
+ /**
83
+ * Sideways, the category names run down the left instead of along the bottom,
84
+ * so the room has to come off that side rather than off the bottom. Reserved
85
+ * rather than overlaid: a name drawn on top of the bars is unreadable against
86
+ * them and makes the bars unreadable too.
87
+ */
88
+ const PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 68 };
89
+
90
+ /** Width the readout is laid out at, so it can be clamped inside the plot. */
91
+ const LABEL_WIDTH = 132;
92
+
93
+ /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
94
+ const Y_AXIS_WIDTH = 44;
95
+
96
+ /** Gap between the value labels and the plot they sit beside. */
97
+ const Y_AXIS_GUTTER = 6;
98
+
99
+ /** Line height of an `xs` label, for centring one on the grid line it names. */
100
+ const AXIS_LABEL_HEIGHT = 16;
101
+
102
+ type Layer = 'svg' | 'overlay' | 'header';
103
+
104
+ export type BarChartStatus = 'loading' | 'ready';
105
+ export type BarChartOrientation = 'vertical' | 'horizontal';
106
+ export type BarChartDatum = Record<string, string | number | null | undefined>;
107
+
108
+ interface BarChartContextValue {
109
+ data: BarChartDatum[];
110
+ xDataKey: string;
111
+ plot: Plot;
112
+ status: BarChartStatus;
113
+ orientation: BarChartOrientation;
114
+ stacked: boolean;
115
+ barGap: number;
116
+ barWidth: number | undefined;
117
+ stackGap: number;
118
+ cornerRadius: number;
119
+ minBarLength: number;
120
+ fadedOpacity: number;
121
+ series: [string, string][];
122
+ registerSeries: (key: string, color: string) => void;
123
+ unregisterSeries: (key: string) => void;
124
+ domainMin: SharedValue<number>;
125
+ domainMax: SharedValue<number>;
126
+ /** The settled domain, for the parts that draw text rather than geometry. */
127
+ extent: [number, number];
128
+ /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */
129
+ reveal: SharedValue<number>;
130
+ activeIndex: SharedValue<number>;
131
+ activeIndexJS: number;
132
+ setActiveIndexJS: (index: number) => void;
133
+ }
134
+
135
+ const BarChartContext = createContext<BarChartContextValue | null>(null);
136
+
137
+ function useChart(component: string): BarChartContextValue {
138
+ const context = useContext(BarChartContext);
139
+ if (!context) {
140
+ throw new Error(`${component} must be used within a <BarChart>`);
141
+ }
142
+ return context;
143
+ }
144
+
145
+ /**
146
+ * The band under the finger, for something rendered *inside* the chart. A
147
+ * readout in the card's header is outside this provider — use
148
+ * `onActiveIndexChange` for that.
149
+ */
150
+ export function useBarChart() {
151
+ const { data, activeIndexJS, xDataKey } = useChart('useBarChart');
152
+ return {
153
+ activeIndex: activeIndexJS,
154
+ activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
155
+ xDataKey,
156
+ };
157
+ }
158
+
159
+ export interface BarChartProps extends ViewProps {
160
+ className?: string;
161
+ /** The rows. Each one is a band along the category axis. */
162
+ data: BarChartDatum[];
163
+ /** Key holding the category label. Used by the axis and the readout. */
164
+ xDataKey?: string;
165
+ /**
166
+ * `loading` draws a row of flat placeholder bars and grows them into the real
167
+ * ones when it turns `ready`. One component throughout, rather than a spinner
168
+ * swapped for a chart — swapping loses the transition.
169
+ */
170
+ status?: BarChartStatus;
171
+ /** Width ÷ height. `2` is the wide card shape. */
172
+ aspectRatio?: number;
173
+ /** Milliseconds for the bars to grow in on mount. */
174
+ animationDuration?: number;
175
+ /** Milliseconds for the value axis to settle after the data changes. */
176
+ domainDuration?: number;
177
+ /**
178
+ * Fix the value axis instead of deriving it. Note that the derived domain
179
+ * always includes zero, and a domain that does not is a bar chart whose
180
+ * lengths cannot be compared — pass this only when you mean it.
181
+ */
182
+ yDomain?: [number, number];
183
+ /** `vertical` grows the bars upward; `horizontal` grows them rightward. */
184
+ orientation?: BarChartOrientation;
185
+ /** Stack the series on each other instead of standing them side by side. */
186
+ stacked?: boolean;
187
+ /**
188
+ * Fraction of each band left empty, `0` to `1`. A fraction rather than a
189
+ * pixel gap so the proportions hold at any width.
190
+ */
191
+ barGap?: number;
192
+ /** Fixed bar thickness in points. Derived from the band when omitted. */
193
+ barWidth?: number;
194
+ /** Points between the segments of a stack. */
195
+ stackGap?: number;
196
+ /** Corner radius on the growing end of a bar. */
197
+ cornerRadius?: number;
198
+ /**
199
+ * Smallest length a non-zero bar is drawn at, in points. A value that rounds
200
+ * to nothing still happened, and a bar of zero height says it did not.
201
+ */
202
+ minBarLength?: number;
203
+ /** Opacity of the bars that are not under the finger. */
204
+ fadedOpacity?: number;
205
+ /**
206
+ * The band under the finger as it moves, and `-1`/`null` when it lifts.
207
+ * Fires when the index changes, not per frame.
208
+ */
209
+ onActiveIndexChange?: (index: number, datum: BarChartDatum | null) => void;
210
+ /** Drop the axis padding, for a bar sparkline with no axis or readout. */
211
+ compact?: boolean;
212
+ children?: ReactNode;
213
+ }
214
+
215
+ /** Imperative handle: re-run the grow-in, for a "replay" control. */
216
+ export interface BarChartHandle {
217
+ replay: () => void;
218
+ }
219
+
220
+ function partition(children: ReactNode) {
221
+ const svg: ReactNode[] = [];
222
+ const overlay: ReactNode[] = [];
223
+ const header: ReactNode[] = [];
224
+ Children.forEach(children, (child, index) => {
225
+ if (!isValidElement(child)) return;
226
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
227
+ const slot = <ChildSlot key={index}>{child}</ChildSlot>;
228
+ (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
229
+ });
230
+ return { svg, overlay, header };
231
+ }
232
+
233
+ function ChildSlot({ children }: { children: ReactNode }) {
234
+ return <>{children}</>;
235
+ }
236
+
237
+ const BarChartRoot = forwardRef<BarChartHandle, BarChartProps>(function BarChartRoot(
238
+ {
239
+ className,
240
+ data,
241
+ xDataKey = 'name',
242
+ status = 'ready',
243
+ aspectRatio = 2,
244
+ animationDuration = 1100,
245
+ domainDuration = 500,
246
+ yDomain,
247
+ orientation = 'vertical',
248
+ stacked = false,
249
+ barGap = 0.2,
250
+ barWidth,
251
+ stackGap = 0,
252
+ cornerRadius = 4,
253
+ minBarLength = 0,
254
+ fadedOpacity = 0.3,
255
+ onActiveIndexChange,
256
+ compact = false,
257
+ children,
258
+ ...props
259
+ },
260
+ ref
261
+ ) {
262
+ const [size, setSize] = useState({ width: 0, height: 0 });
263
+ const [series, setSeries] = useState<[string, string][]>([]);
264
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
265
+
266
+ const reveal = useSharedValue(0);
267
+ const domainMin = useSharedValue(0);
268
+ const domainMax = useSharedValue(0);
269
+ const activeIndex = useSharedValue(-1);
270
+ const reducedMotion = useReducedMotion();
271
+
272
+ const registerSeries = useMemo(
273
+ () => (key: string, color: string) =>
274
+ setSeries((current) => {
275
+ const existing = current.find(([k]) => k === key);
276
+ if (existing?.[1] === color) return current;
277
+ return [...current.filter(([k]) => k !== key), [key, color]];
278
+ }),
279
+ []
280
+ );
281
+
282
+ const unregisterSeries = useMemo(
283
+ () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
284
+ []
285
+ );
286
+
287
+ /*
288
+ * Whether an axis is asking for room. It has to be known before the plot is
289
+ * laid out, and only the root sees the children early enough to ask — the
290
+ * axis itself renders into a box that has already been decided.
291
+ */
292
+ const hasYAxis = useMemo(() => {
293
+ let found = false;
294
+ Children.forEach(children, (child) => {
295
+ if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
296
+ found = true;
297
+ }
298
+ });
299
+ return found;
300
+ }, [children]);
301
+
302
+ const pad = compact
303
+ ? { top: 2, right: 1, bottom: 2, left: 1 }
304
+ : orientation === 'horizontal'
305
+ ? PADDING_SIDEWAYS
306
+ : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
307
+ const plot: Plot = {
308
+ left: pad.left,
309
+ top: pad.top,
310
+ width: Math.max(size.width - pad.left - pad.right, 0),
311
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
312
+ };
313
+
314
+ const seriesKeys = series.map(([key]) => key).join('|');
315
+ const extent = useMemo<[number, number]>(() => {
316
+ if (yDomain) return yDomain;
317
+ const keys = seriesKeys ? seriesKeys.split('|') : [];
318
+ let min = 0;
319
+ let max = 0;
320
+
321
+ for (const row of data) {
322
+ // Stacked, the tall thing is the total; grouped, it is the tallest bar.
323
+ // Reading the same number for both would crop a stack at its largest
324
+ // segment and clip everything above it.
325
+ let positive = 0;
326
+ let negative = 0;
327
+ for (const key of keys) {
328
+ const value = row[key];
329
+ if (typeof value !== 'number' || Number.isNaN(value)) continue;
330
+ if (stacked) {
331
+ if (value >= 0) positive += value;
332
+ else negative += value;
333
+ } else {
334
+ if (value > positive) positive = value;
335
+ if (value < negative) negative = value;
336
+ }
337
+ }
338
+ if (positive > max) max = positive;
339
+ if (negative < min) min = negative;
340
+ }
341
+
342
+ if (min === 0 && max === 0) return [0, 1];
343
+ // Headroom above the tallest bar only. The zero end is left exactly where
344
+ // it is: padding it would lift the bars off their own baseline.
345
+ return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];
346
+ }, [data, yDomain, seriesKeys, stacked]);
347
+
348
+ const loading = status === 'loading';
349
+
350
+ useEffect(() => {
351
+ if (loading) return;
352
+ const [min, max] = extent;
353
+ const first = domainMin.value === 0 && domainMax.value === 0;
354
+ if (first || reducedMotion) {
355
+ domainMin.value = min;
356
+ domainMax.value = max;
357
+ return;
358
+ }
359
+ domainMin.value = withTiming(min, { duration: domainDuration });
360
+ domainMax.value = withTiming(max, { duration: domainDuration });
361
+ }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
362
+
363
+ const revealed = useRef(false);
364
+ const playReveal = useMemo(
365
+ () => () => {
366
+ if (reducedMotion) {
367
+ reveal.value = 1;
368
+ return;
369
+ }
370
+ reveal.value = 0;
371
+ reveal.value = withTiming(1, {
372
+ duration: animationDuration,
373
+ easing: Easing.bezier(0.85, 0, 0.15, 1),
374
+ });
375
+ },
376
+ [reducedMotion, animationDuration, reveal]
377
+ );
378
+
379
+ useEffect(() => {
380
+ if (revealed.current || loading || plot.width <= 0 || !data.length) return;
381
+ revealed.current = true;
382
+ playReveal();
383
+ }, [loading, plot.width, data.length, playReveal]);
384
+
385
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
386
+
387
+ const handleActiveIndex = useMemo(
388
+ () => (index: number) => {
389
+ setActiveIndexJS(index);
390
+ onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
391
+ },
392
+ [onActiveIndexChange, data]
393
+ );
394
+
395
+ const onLayout = (event: LayoutChangeEvent) => {
396
+ const { width, height } = event.nativeEvent.layout;
397
+ setSize((current) =>
398
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
399
+ ? current
400
+ : { width, height }
401
+ );
402
+ props.onLayout?.(event);
403
+ };
404
+
405
+ const context = useMemo<BarChartContextValue>(
406
+ () => ({
407
+ data,
408
+ xDataKey,
409
+ plot,
410
+ status,
411
+ orientation,
412
+ stacked,
413
+ barGap,
414
+ barWidth,
415
+ stackGap,
416
+ cornerRadius,
417
+ minBarLength,
418
+ fadedOpacity,
419
+ series,
420
+ registerSeries,
421
+ unregisterSeries,
422
+ domainMin,
423
+ domainMax,
424
+ extent,
425
+ reveal,
426
+ activeIndex,
427
+ activeIndexJS,
428
+ setActiveIndexJS: handleActiveIndex,
429
+ }),
430
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
431
+ // eslint-disable-next-line react-hooks/exhaustive-deps
432
+ [
433
+ data,
434
+ xDataKey,
435
+ plot.width,
436
+ plot.height,
437
+ plot.left,
438
+ plot.top,
439
+ status,
440
+ orientation,
441
+ stacked,
442
+ barGap,
443
+ barWidth,
444
+ stackGap,
445
+ cornerRadius,
446
+ minBarLength,
447
+ fadedOpacity,
448
+ series,
449
+ registerSeries,
450
+ unregisterSeries,
451
+ domainMin,
452
+ domainMax,
453
+ extent,
454
+ reveal,
455
+ activeIndex,
456
+ activeIndexJS,
457
+ handleActiveIndex,
458
+ ]
459
+ );
460
+
461
+ const { svg, overlay, header } = partition(children);
462
+
463
+ /*
464
+ * Two views, because the header is not part of the plot. `aspectRatio` and
465
+ * the layout measurement belong to the drawing area alone — measured on the
466
+ * outer view they would take in the header too, and the plot would lose as
467
+ * much height as the readout took while still claiming the shape asked for.
468
+ */
469
+ return (
470
+ <BarChartContext.Provider value={context}>
471
+ <View {...props} style={props.style} className={cn('w-full', className)}>
472
+ {header}
473
+ <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
474
+ {plot.width > 0 ? (
475
+ <>
476
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
477
+ {svg}
478
+ </Svg>
479
+ {overlay}
480
+ </>
481
+ ) : null}
482
+ </View>
483
+ </View>
484
+ </BarChartContext.Provider>
485
+ );
486
+ });
487
+ BarChartRoot.displayName = 'BarChart';
488
+
489
+ /* -------------------------------------------------------------------------- */
490
+ /* SVG layer */
491
+ /* -------------------------------------------------------------------------- */
492
+
493
+ export interface BarChartGridProps {
494
+ /** How many lines to draw across the value axis. */
495
+ rows?: number;
496
+ color?: string;
497
+ dashArray?: string;
498
+ opacity?: number;
499
+ }
500
+
501
+ /**
502
+ * Lines across the value axis, so a bar can be read against a number rather
503
+ * than only against the bar beside it.
504
+ */
505
+ function BarChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: BarChartGridProps) {
506
+ const { plot, orientation } = useChart('BarChart.Grid');
507
+ const token = useCSSVariable('--color-border');
508
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
509
+
510
+ const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);
511
+
512
+ return (
513
+ <G opacity={opacity}>
514
+ {lines.map((fraction) =>
515
+ orientation === 'vertical' ? (
516
+ <SvgLine
517
+ key={fraction}
518
+ x1={plot.left}
519
+ x2={plot.left + plot.width}
520
+ y1={plot.top + plot.height * fraction}
521
+ y2={plot.top + plot.height * fraction}
522
+ stroke={stroke}
523
+ strokeDasharray={dashArray}
524
+ strokeWidth={1}
525
+ />
526
+ ) : (
527
+ <SvgLine
528
+ key={fraction}
529
+ x1={plot.left + plot.width * fraction}
530
+ x2={plot.left + plot.width * fraction}
531
+ y1={plot.top}
532
+ y2={plot.top + plot.height}
533
+ stroke={stroke}
534
+ strokeDasharray={dashArray}
535
+ strokeWidth={1}
536
+ />
537
+ )
538
+ )}
539
+ </G>
540
+ );
541
+ }
542
+ BarChartGrid.displayName = 'BarChart.Grid';
543
+ BarChartGrid.layer = 'svg' as Layer;
544
+
545
+ export interface BarChartBarProps {
546
+ /** Column in the data holding this series' values. */
547
+ dataKey: string;
548
+ /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */
549
+ color?: string;
550
+ /** Which of the five chart tokens to take. */
551
+ colorIndex?: SeriesColorIndex;
552
+ /** Corner radius, overriding the chart's. */
553
+ cornerRadius?: number;
554
+ }
555
+
556
+ /**
557
+ * One series of bars.
558
+ *
559
+ * Drawn as two paths rather than one rectangle per band: the band under the
560
+ * finger, and everything else. That is the fewest animated props that can
561
+ * still dim the rest — one path could not, since a path has one opacity, and
562
+ * a view per bar would be one animated prop per bar for the same picture.
563
+ */
564
+ function BarChartBar({ dataKey, color, colorIndex = 1, cornerRadius }: BarChartBarProps) {
565
+ const {
566
+ data,
567
+ plot,
568
+ status,
569
+ orientation,
570
+ stacked,
571
+ barGap,
572
+ barWidth,
573
+ stackGap,
574
+ cornerRadius: chartRadius,
575
+ minBarLength,
576
+ fadedOpacity,
577
+ series,
578
+ registerSeries,
579
+ unregisterSeries,
580
+ domainMin,
581
+ domainMax,
582
+ reveal,
583
+ activeIndex,
584
+ } = useChart('BarChart.Bar');
585
+
586
+ const fill = useSeriesColor(color, colorIndex);
587
+ const radius = cornerRadius ?? chartRadius;
588
+
589
+ useEffect(() => {
590
+ registerSeries(dataKey, fill);
591
+ return () => unregisterSeries(dataKey);
592
+ }, [dataKey, fill, registerSeries, unregisterSeries]);
593
+
594
+ const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
595
+
596
+ /*
597
+ * What each bar in this series sits on. Grouped, that is nothing — every bar
598
+ * starts at zero. Stacked, it is the running total of the series registered
599
+ * *before* this one, which is why registration order is the stacking order.
600
+ */
601
+ const baselines = useMemo(() => {
602
+ if (!stacked) return null;
603
+ const below = series.slice(0, series.findIndex(([key]) => key === dataKey));
604
+ if (!below.length) return null;
605
+ return data.map((row) => {
606
+ let total = 0;
607
+ for (const [key] of below) {
608
+ const value = row[key];
609
+ if (typeof value === 'number' && !Number.isNaN(value)) total += value;
610
+ }
611
+ return total;
612
+ });
613
+ }, [stacked, series, dataKey, data]);
614
+
615
+ const seriesIndex = Math.max(
616
+ 0,
617
+ series.findIndex(([key]) => key === dataKey)
618
+ );
619
+ const seriesCount = Math.max(1, series.length);
620
+ const loading = status === 'loading';
621
+ const total = data.length;
622
+ const horizontal = orientation === 'horizontal';
623
+
624
+ /*
625
+ * Both paths come out of one builder, filtered by whether the band is the
626
+ * active one. Two passes over the data a frame is still cheaper than the
627
+ * bookkeeping needed to build both at once, and it keeps the geometry in
628
+ * exactly one place.
629
+ */
630
+ const build = (wantActive: boolean) => () => {
631
+ 'worklet';
632
+ if (!total || plot.width <= 0) {
633
+ return { d: '', opacity: 1 };
634
+ }
635
+
636
+ // The category axis runs across the plot when the bars grow up, and down
637
+ // it when they grow right. `along` is that axis; `across` is the value one.
638
+ const along = horizontal ? plot.height : plot.width;
639
+ const across = horizontal ? plot.width : plot.height;
640
+ const alongStart = horizontal ? plot.top : plot.left;
641
+ const acrossStart = horizontal ? plot.left : plot.top;
642
+
643
+ const band = along / total;
644
+ // The slot a single bar gets: the band, less the gap, divided between the
645
+ // series when they stand side by side.
646
+ const usable = band * (1 - barGap);
647
+ const slot = stacked ? usable : usable / seriesCount;
648
+ const thickness = Math.min(barWidth ?? slot, slot);
649
+
650
+ const min = domainMin.value;
651
+ const max = domainMax.value;
652
+ const range = max - min || 1;
653
+ const grow = reveal.value;
654
+ const active = activeIndex.value;
655
+
656
+ /*
657
+ * Where a value sits along the value axis. Vertical counts down from the
658
+ * top, horizontal counts up from the left — the same scale read in
659
+ * opposite directions, which is the only thing orientation changes.
660
+ */
661
+ const project = (value: number) => {
662
+ 'worklet';
663
+ const fraction = (value - min) / range;
664
+ return horizontal
665
+ ? acrossStart + fraction * across
666
+ : acrossStart + across - fraction * across;
667
+ };
668
+
669
+ let d = '';
670
+
671
+ for (let i = 0; i < total; i++) {
672
+ if ((i === active) !== wantActive) continue;
673
+
674
+ const value = values[i];
675
+ if (value === null || value === undefined) continue;
676
+
677
+ const base = baselines?.[i] ?? 0;
678
+ /*
679
+ * Staggered by band, but every bar still finishes inside the one
680
+ * duration: the window each gets is what is left after the stagger, so
681
+ * a chart of fifty bands does not take fifty times as long to arrive.
682
+ */
683
+ const start = total > 1 ? (i / total) * 0.45 : 0;
684
+ const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));
685
+ const shown = loading ? 0 : value * eased;
686
+
687
+ const zero = project(base);
688
+ const tip = project(base + shown);
689
+ const shrink = stacked && stackGap > 0 ? stackGap : 0;
690
+ let length = Math.abs(tip - zero) - shrink;
691
+ // A value that rounds to nothing still happened, and a bar of zero
692
+ // length says it did not.
693
+ if (minBarLength > 0 && shown !== 0 && length < minBarLength) {
694
+ length = minBarLength;
695
+ }
696
+ if (length <= 0) continue;
697
+
698
+ const offset =
699
+ (stacked ? 0 : seriesIndex * slot) + (band - usable) / 2 + (slot - thickness) / 2;
700
+ const lead = alongStart + i * band + offset;
701
+ const positive = shown >= 0;
702
+
703
+ d += horizontal
704
+ ? barPath(
705
+ positive ? zero : zero - length,
706
+ lead,
707
+ length,
708
+ thickness,
709
+ radius,
710
+ positive ? 'right' : 'left'
711
+ )
712
+ : barPath(
713
+ lead,
714
+ positive ? zero - length : zero,
715
+ thickness,
716
+ length,
717
+ radius,
718
+ positive ? 'up' : 'down'
719
+ );
720
+ }
721
+
722
+ // Dimming only happens while something *is* active; with nothing under the
723
+ // finger every bar is at full ink, which is the resting state.
724
+ const dim = !wantActive && active >= 0 ? fadedOpacity : 1;
725
+ return { d, opacity: dim };
726
+ };
727
+
728
+ const restProps = useAnimatedProps(build(false));
729
+ const activeProps = useAnimatedProps(build(true));
730
+
731
+ return (
732
+ <G>
733
+ <AnimatedPath animatedProps={restProps} fill={fill} />
734
+ <AnimatedPath animatedProps={activeProps} fill={fill} />
735
+ </G>
736
+ );
737
+ }
738
+ BarChartBar.displayName = 'BarChart.Bar';
739
+ BarChartBar.layer = 'svg' as Layer;
740
+
741
+ /* -------------------------------------------------------------------------- */
742
+ /* Overlay layer */
743
+ /* -------------------------------------------------------------------------- */
744
+
745
+ /**
746
+ * Narrowest a category label may be drawn before the axis starts dropping
747
+ * some. Roughly three characters at `xs`, which is what a month abbreviation
748
+ * or a short name needs.
749
+ */
750
+ const MIN_BAND_LABEL = 34;
751
+
752
+ export interface BarChartXAxisProps {
753
+ /**
754
+ * How many labels to show. Every category by default, thinned only when the
755
+ * bands get too narrow to read — pass a number to force it lower.
756
+ */
757
+ ticks?: number;
758
+ /** Turn a row into its label. Defaults to the value at `xDataKey`. */
759
+ format?: (datum: BarChartDatum, index: number) => string;
760
+ className?: string;
761
+ }
762
+
763
+ /**
764
+ * The category labels, one under each band it has room for. Real text rather
765
+ * than SVG text, so they follow the theme's font and the platform's text
766
+ * scaling — SVG text does neither.
767
+ */
768
+ function BarChartXAxis({ ticks, format, className }: BarChartXAxisProps) {
769
+ const { data, xDataKey, plot, orientation } = useChart('BarChart.XAxis');
770
+
771
+ const labels = useMemo(() => {
772
+ if (!data.length) return [];
773
+
774
+ /*
775
+ * Every category, unless they will not fit. A fixed tick count dropped
776
+ * names that had room to be drawn — eight months at six ticks lost March
777
+ * and June for no reason anyone looking at the chart could see. The axis
778
+ * asks the plot how much room there is instead, and only thins when the
779
+ * answer is not enough.
780
+ */
781
+ const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));
782
+ const count = Math.max(1, Math.min(ticks ?? room, data.length));
783
+
784
+ // Every nth band, rather than a fractional step rounded to the nearest
785
+ // index — rounding lands on the same band twice and skips its neighbour.
786
+ const stride = Math.ceil(data.length / count);
787
+ const picked: { key: number; text: string }[] = [];
788
+ for (let index = 0; index < data.length; index += stride) {
789
+ const datum = data[index];
790
+ if (!datum) continue;
791
+ picked.push({
792
+ key: index,
793
+ text: format ? format(datum, index) : String(datum[xDataKey] ?? ''),
794
+ });
795
+ }
796
+ return picked;
797
+ }, [data, ticks, format, xDataKey, plot.width]);
798
+
799
+ if (orientation === 'horizontal') return null;
800
+
801
+ /*
802
+ * Each label is placed on its own band's centre, not spaced evenly along the
803
+ * axis. Two things went wrong when they were spread with `justify-between`:
804
+ * a bar owns a *band* rather than sitting on a point, so the first and last
805
+ * labels were half a band out at the edges; and the indices `ticks` picks
806
+ * are not evenly spaced (eight months shown six at a time gives 0,1,3,4,6,7),
807
+ * so the ones between were spread evenly over gaps that are not.
808
+ *
809
+ * The box is backed off by half its own width rather than translated by
810
+ * `-50%`, which is not reliable across React Native versions.
811
+ */
812
+ /*
813
+ * One box per band, exactly the band's width. Tiling them rather than giving
814
+ * each a fixed width means they can never overlap each other and the first
815
+ * and last can never hang off the ends of the plot — the row of labels
816
+ * occupies precisely the space the bars do.
817
+ */
818
+ const bandWidth = data.length > 0 ? plot.width / data.length : 0;
819
+
820
+ return (
821
+ <View
822
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
823
+ className={cn(className)}
824
+ >
825
+ {labels.map((label) => (
826
+ <Text
827
+ key={label.key}
828
+ size="xs"
829
+ muted
830
+ numberOfLines={1}
831
+ style={{
832
+ position: 'absolute',
833
+ bottom: 0,
834
+ left: bandOf(label.key, data.length, plot) - bandWidth / 2,
835
+ width: bandWidth,
836
+ textAlign: 'center',
837
+ }}
838
+ >
839
+ {label.text}
840
+ </Text>
841
+ ))}
842
+ </View>
843
+ );
844
+ }
845
+ BarChartXAxis.displayName = 'BarChart.XAxis';
846
+ BarChartXAxis.layer = 'overlay' as Layer;
847
+
848
+ export interface BarChartYAxisProps {
849
+ /** How many labels to show along the value axis. */
850
+ ticks?: number;
851
+ /** Format a value for its label. Defaults to a compact number. */
852
+ format?: (value: number) => string;
853
+ className?: string;
854
+ }
855
+
856
+ /** Value labels down the side, aligned to the grid lines. */
857
+ function BarChartYAxis({ ticks = 4, format, className }: BarChartYAxisProps) {
858
+ const { plot, data, xDataKey, orientation, extent } = useChart('BarChart.YAxis');
859
+
860
+ /*
861
+ * Read off the settled domain rather than the shared values the paths use.
862
+ * A label is text, and text is JS — following the tween would re-render on
863
+ * every frame of it to redraw a number nobody can read while it moves.
864
+ */
865
+ const horizontal = orientation === 'horizontal';
866
+
867
+ const labels = useMemo(() => {
868
+ if (horizontal) {
869
+ // Sideways, the side of the chart is the category axis.
870
+ return data.map((row, index) => ({
871
+ key: index,
872
+ text: String(row[xDataKey] ?? ''),
873
+ }));
874
+ }
875
+ const [min, max] = extent;
876
+ if (min === 0 && max === 0) return [];
877
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
878
+ const value = max - ((max - min) * index) / ticks;
879
+ return { key: index, text: format ? format(value) : compactNumber(value) };
880
+ });
881
+ }, [extent, ticks, format, horizontal, data, xDataKey]);
882
+
883
+ /*
884
+ * Sideways the labels sit in the gutter the plot already left for them, one
885
+ * band each — `flex-1` per row rather than spacing them edge to edge, so
886
+ * every name lands beside its own bar instead of only the first and last
887
+ * doing so. Upright there are no bands to line up with, so the ticks space
888
+ * themselves against the grid.
889
+ */
890
+ return (
891
+ <View
892
+ pointerEvents="none"
893
+ style={{
894
+ position: 'absolute',
895
+ left: 0,
896
+ // Upright, each label is centred on the grid line it names: the strip
897
+ // is lifted half a label and grown by a whole one, so `justify-between`
898
+ // lands the text's middle on the line rather than its top edge on the
899
+ // first and its bottom edge on the last.
900
+ top: horizontal ? plot.top : plot.top - AXIS_LABEL_HEIGHT / 2,
901
+ height: horizontal ? plot.height : plot.height + AXIS_LABEL_HEIGHT,
902
+ width: horizontal ? Math.max(plot.left - 8, 0) : undefined,
903
+ }}
904
+ className={cn(horizontal ? 'items-end' : 'justify-between', className)}
905
+ >
906
+ {labels.map((label) => (
907
+ <View
908
+ key={label.key}
909
+ className={horizontal ? 'flex-1 justify-center' : undefined}
910
+ >
911
+ <Text size="xs" muted numberOfLines={1}>
912
+ {label.text}
913
+ </Text>
914
+ </View>
915
+ ))}
916
+ </View>
917
+ );
918
+ }
919
+ BarChartYAxis.displayName = 'BarChart.YAxis';
920
+ BarChartYAxis.layer = 'overlay' as Layer;
921
+ // Read by the root, which has to leave room for the labels before it
922
+ // lays the plot out — an axis drawn over the plot is unreadable, and
923
+ // makes what it is drawn over unreadable too.
924
+ BarChartYAxis.axis = 'y' as const;
925
+
926
+ export interface BarChartTooltipProps {
927
+ /** Format one series' value. Defaults to a compact number. */
928
+ formatValue?: (value: number, key: string) => string;
929
+ /** Format the readout's heading from the row. Defaults to the value at xDataKey. */
930
+ formatX?: (datum: BarChartDatum) => string;
931
+ className?: string;
932
+ }
933
+
934
+ /**
935
+ * The readout, and the gesture that drives it.
936
+ *
937
+ * There is no crosshair. A line needs one because a point on a line has no
938
+ * width of its own to point at; a bar is already the thing being pointed at,
939
+ * so highlighting it and dimming the rest says the same thing without drawing
940
+ * a line through the chart.
941
+ *
942
+ * The hit area is the whole plot. A readout you have to land on the bar to
943
+ * summon is a readout nobody finds — and the thinner the bars, the truer that
944
+ * gets.
945
+ */
946
+ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipProps) {
947
+ const {
948
+ data,
949
+ xDataKey,
950
+ plot,
951
+ series,
952
+ orientation,
953
+ activeIndex,
954
+ activeIndexJS,
955
+ setActiveIndexJS,
956
+ status,
957
+ } = useChart('BarChart.Tooltip');
958
+
959
+ const total = data.length;
960
+ const horizontal = orientation === 'horizontal';
961
+ const left = plot.left;
962
+ const top = plot.top;
963
+ const width = plot.width;
964
+ const height = plot.height;
965
+
966
+ /*
967
+ * Declared inside the memo, next to its callers: a worklet may only call
968
+ * another worklet, and the rule is enforced by crashing rather than warning.
969
+ */
970
+ const pan = useMemo(() => {
971
+ const resolve = (x: number, y: number) => {
972
+ 'worklet';
973
+ if (!total) return;
974
+ const span = horizontal ? height : width;
975
+ const offset = (horizontal ? y - top : x - left) / (span || 1);
976
+ // Bands, not points: the finger is inside whichever slice it lands on,
977
+ // which is a floor rather than a round to the nearest centre.
978
+ const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));
979
+ if (next === activeIndex.value) return;
980
+ activeIndex.value = next;
981
+ runOnJS(setActiveIndexJS)(next);
982
+ };
983
+
984
+ return Gesture.Pan()
985
+ .minDistance(0)
986
+ .onBegin((event) => {
987
+ 'worklet';
988
+ resolve(event.x, event.y);
989
+ })
990
+ .onUpdate((event) => {
991
+ 'worklet';
992
+ resolve(event.x, event.y);
993
+ })
994
+ .onFinalize(() => {
995
+ 'worklet';
996
+ activeIndex.value = -1;
997
+ runOnJS(setActiveIndexJS)(-1);
998
+ });
999
+ }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);
1000
+
1001
+ // The readout centres over its band and is clamped inside the plot, so it
1002
+ // never runs off the edge at the first or last one.
1003
+ const labelStyle = useAnimatedStyle(() => {
1004
+ const index = activeIndex.value;
1005
+ if (index < 0 || !total) return { opacity: 0 };
1006
+ const band = (horizontal ? plot.height : plot.width) / total;
1007
+ const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);
1008
+ const half = LABEL_WIDTH / 2;
1009
+ const clamped = Math.min(
1010
+ plot.left + plot.width - half,
1011
+ Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)
1012
+ );
1013
+ return { opacity: 1, transform: [{ translateX: clamped - half }] };
1014
+ });
1015
+
1016
+ const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
1017
+ const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
1018
+ const fmtX = formatX ?? ((datum: BarChartDatum) => String(datum[xDataKey] ?? ''));
1019
+
1020
+ if (status === 'loading') return null;
1021
+
1022
+ return (
1023
+ <GestureDetector gesture={pan}>
1024
+ <View style={StyleSheet.absoluteFill}>
1025
+ <Animated.View
1026
+ pointerEvents="none"
1027
+ style={[
1028
+ { position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH },
1029
+ labelStyle,
1030
+ ]}
1031
+ >
1032
+ {active ? (
1033
+ <View
1034
+ className={cn(
1035
+ 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1036
+ className
1037
+ )}
1038
+ >
1039
+ <Text size="xs" muted numberOfLines={1}>
1040
+ {fmtX(active)}
1041
+ </Text>
1042
+ {series.map(([key, color]) => {
1043
+ const value = active[key];
1044
+ if (typeof value !== 'number') return null;
1045
+ return (
1046
+ <View key={key} className="flex-row items-center gap-1.5">
1047
+ <View
1048
+ style={{
1049
+ width: 6,
1050
+ height: 6,
1051
+ borderRadius: 3,
1052
+ backgroundColor: color,
1053
+ }}
1054
+ />
1055
+ <Text size="xs" weight="medium">
1056
+ {fmtValue(value, key)}
1057
+ </Text>
1058
+ </View>
1059
+ );
1060
+ })}
1061
+ </View>
1062
+ ) : null}
1063
+ </Animated.View>
1064
+ </View>
1065
+ </GestureDetector>
1066
+ );
1067
+ }
1068
+ BarChartTooltip.displayName = 'BarChart.Tooltip';
1069
+ BarChartTooltip.layer = 'overlay' as Layer;
1070
+
1071
+ export interface BarChartLegendProps extends ViewProps {
1072
+ className?: string;
1073
+ /** Prettier names for the series keys. */
1074
+ labels?: Record<string, string>;
1075
+ }
1076
+
1077
+ /** One series' colour and name. Shared by the legend and the header. */
1078
+ function SeriesSwatch({ color, label }: { color: string; label: string }) {
1079
+ return (
1080
+ <View className="flex-row items-center gap-1.5">
1081
+ <View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
1082
+ <Text size="xs" muted>
1083
+ {label}
1084
+ </Text>
1085
+ </View>
1086
+ );
1087
+ }
1088
+
1089
+ /** A swatch and a name per series, in the order the series were declared. */
1090
+ function BarChartLegend({ className, labels, ...props }: BarChartLegendProps) {
1091
+ const { series } = useChart('BarChart.Legend');
1092
+ if (!series.length) return null;
1093
+
1094
+ return (
1095
+ <View
1096
+ {...props}
1097
+ style={[{ pointerEvents: 'none' }, props.style]}
1098
+ className={cn('absolute right-2 top-1 flex-row gap-3', className)}
1099
+ >
1100
+ {series.map(([key, color]) => (
1101
+ <SeriesSwatch key={key} color={color} label={labels?.[key] ?? key} />
1102
+ ))}
1103
+ </View>
1104
+ );
1105
+ }
1106
+ BarChartLegend.displayName = 'BarChart.Legend';
1107
+ BarChartLegend.layer = 'overlay' as Layer;
1108
+
1109
+ /* -------------------------------------------------------------------------- */
1110
+ /* Header layer */
1111
+ /* -------------------------------------------------------------------------- */
1112
+
1113
+ export interface BarChartHeaderProps extends ViewProps {
1114
+ className?: string;
1115
+ /** Small line above the value — what the chart is of. */
1116
+ title?: string;
1117
+ /** The readout. The largest thing on the card, and the first thing read. */
1118
+ value?: string;
1119
+ /** One muted line under the value — a period, a comparison, a total. */
1120
+ caption?: string;
1121
+ /** Prettier names for the series keys, as the legend takes. */
1122
+ labels?: Record<string, string>;
1123
+ /**
1124
+ * Draw a swatch and a name per series along the trailing edge. Prefer this to
1125
+ * `BarChart.Legend` on a chart that has a header: the legend floats over the
1126
+ * plot, where it competes with the bars for the same corner.
1127
+ */
1128
+ legend?: boolean;
1129
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
1130
+ children?: ReactNode;
1131
+ }
1132
+
1133
+ /**
1134
+ * The strip above the plot: what the chart is of, what it currently reads, and
1135
+ * what the colours mean.
1136
+ *
1137
+ * It belongs to the chart rather than to the card around it because it is about
1138
+ * the *plot* — the number changes as a finger moves along the bars, and the
1139
+ * legend is the series list the chart itself is holding. The card's header is a
1140
+ * caption on the tray the chart sits in; this is the chart introducing itself.
1141
+ *
1142
+ * The value is not derived here. A readout that follows the finger belongs to
1143
+ * whoever owns the data — take it from `onActiveIndexChange` and pass the
1144
+ * formatted string down, so one header can show a total when nothing is pressed
1145
+ * and a band's value when something is.
1146
+ */
1147
+ function BarChartHeader({
1148
+ className,
1149
+ title,
1150
+ value,
1151
+ caption,
1152
+ labels,
1153
+ legend = false,
1154
+ children,
1155
+ ...props
1156
+ }: BarChartHeaderProps) {
1157
+ const { series } = useChart('BarChart.Header');
1158
+ const trailing =
1159
+ children ??
1160
+ (legend && series.length ? (
1161
+ <View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
1162
+ {series.map(([key, color]) => (
1163
+ <SeriesSwatch key={key} color={color} label={labels?.[key] ?? key} />
1164
+ ))}
1165
+ </View>
1166
+ ) : null);
1167
+
1168
+ return (
1169
+ <View
1170
+ {...props}
1171
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1172
+ >
1173
+ <View className="flex-1 gap-0.5">
1174
+ {title ? (
1175
+ <Text size="xs" muted>
1176
+ {title}
1177
+ </Text>
1178
+ ) : null}
1179
+ {value ? (
1180
+ <Text size="xl" weight="bold">
1181
+ {value}
1182
+ </Text>
1183
+ ) : null}
1184
+ {caption ? (
1185
+ <Text size="xs" muted>
1186
+ {caption}
1187
+ </Text>
1188
+ ) : null}
1189
+ </View>
1190
+ {/* Shrinkable, unlike a view's default in React Native. Held rigid, a
1191
+ three-series key takes the width it wants and the caption underneath
1192
+ the value wraps to two lines to make room for it. */}
1193
+ {trailing ? <View className="shrink pt-1">{trailing}</View> : null}
1194
+ </View>
1195
+ );
1196
+ }
1197
+ BarChartHeader.displayName = 'BarChart.Header';
1198
+ BarChartHeader.layer = 'header' as Layer;
1199
+
1200
+ export const BarChart = Object.assign(BarChartRoot, {
1201
+ Header: BarChartHeader,
1202
+ Grid: BarChartGrid,
1203
+ Bar: BarChartBar,
1204
+ XAxis: BarChartXAxis,
1205
+ YAxis: BarChartYAxis,
1206
+ Tooltip: BarChartTooltip,
1207
+ Legend: BarChartLegend,
1208
+ });