panelui-native 0.22.5 → 0.25.0

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