panelui-native 0.86.1 → 0.88.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 (63) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/animated-badge/index.js +451 -0
  3. package/lib/module/components/animated-badge/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +17 -6
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/bubble-chart/index.js +1782 -0
  7. package/lib/module/components/bubble-chart/index.js.map +1 -0
  8. package/lib/module/components/feedback-dialog/index.js +474 -0
  9. package/lib/module/components/feedback-dialog/index.js.map +1 -0
  10. package/lib/module/components/pyramid-chart/index.js +1143 -0
  11. package/lib/module/components/pyramid-chart/index.js.map +1 -0
  12. package/lib/module/components/scroll-blur/index.js +363 -0
  13. package/lib/module/components/scroll-blur/index.js.map +1 -0
  14. package/lib/module/components/scroll-canvas/index.js +2 -0
  15. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  16. package/lib/module/components/scroll-text/index.js +3 -0
  17. package/lib/module/components/scroll-text/index.js.map +1 -1
  18. package/lib/module/components/search-bar/index.js +753 -25
  19. package/lib/module/components/search-bar/index.js.map +1 -1
  20. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  21. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  22. package/lib/module/hooks/use-reveal-progress.js +21 -1
  23. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  24. package/lib/module/index.js +5 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/utils/chart.js +24 -0
  27. package/lib/module/utils/chart.js.map +1 -1
  28. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  29. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  31. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/bubble-chart/index.d.ts +481 -0
  33. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
  34. package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
  35. package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
  37. package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
  38. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  39. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/search-bar/index.d.ts +350 -6
  42. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  43. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  44. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  45. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts +6 -1
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/utils/chart.d.ts +14 -0
  49. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/animated-badge/index.tsx +513 -0
  52. package/src/components/bottom-sheet/index.tsx +23 -8
  53. package/src/components/bubble-chart/index.tsx +2145 -0
  54. package/src/components/feedback-dialog/index.tsx +557 -0
  55. package/src/components/pyramid-chart/index.tsx +1360 -0
  56. package/src/components/scroll-blur/index.tsx +466 -0
  57. package/src/components/scroll-canvas/index.tsx +2 -1
  58. package/src/components/scroll-text/index.tsx +3 -3
  59. package/src/components/search-bar/index.tsx +933 -45
  60. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  61. package/src/hooks/use-reveal-progress.ts +30 -1
  62. package/src/index.ts +72 -1
  63. package/src/utils/chart.ts +27 -0
@@ -0,0 +1,2145 @@
1
+ /**
2
+ * BubbleChart — one labelled circle per row, on two measured axes, with a third
3
+ * quantity on each circle's area.
4
+ *
5
+ * ```tsx
6
+ * <BubbleChart data={teams} xDataKey="efficiency" yDataKey="performance"
7
+ * sizeKey="headcount" labelKey="team">
8
+ * <BubbleChart.Grid />
9
+ * <BubbleChart.Bubbles />
10
+ * <BubbleChart.Labels />
11
+ * <BubbleChart.XAxis />
12
+ * <BubbleChart.YAxis />
13
+ * <BubbleChart.Tooltip />
14
+ * </BubbleChart>
15
+ * ```
16
+ *
17
+ * ## When this and not a scatter plot
18
+ *
19
+ * `ScatterChart` also maps a third quantity onto point area, through `sizeKey`,
20
+ * and it is the right component for a *series* of observations: many points of
21
+ * one colour, where the shape of the cloud is the finding and no single dot
22
+ * needs a name.
23
+ *
24
+ * This one is for a handful of named things. Each row is its own circle with
25
+ * its own colour and its own label written inside it, so the chart can be read
26
+ * entity by entity rather than as a distribution. Eight teams, twelve products,
27
+ * six regions — where the reader wants to find one of them and see where it
28
+ * sits.
29
+ *
30
+ * ## Area, not radius
31
+ *
32
+ * `sizeKey` maps to a circle's area. Doubling a radius quadruples the ink, so a
33
+ * chart that scaled the radius would show a doubled value as four times the
34
+ * size, and the reader would believe the picture. The scale runs over the whole
35
+ * data set, so one bubble's size means the same thing as another's.
36
+ *
37
+ * ## Labels are text, not SVG
38
+ *
39
+ * The names inside the bubbles are React Native `Text` in a layer over the
40
+ * plot, so they follow the theme's font and the platform's text scaling — SVG
41
+ * text does neither. A bubble too small to hold its own label is left without
42
+ * one rather than given an unreadable one; the readout still names it.
43
+ */
44
+ import {
45
+ Children,
46
+ createContext,
47
+ forwardRef,
48
+ isValidElement,
49
+ useContext,
50
+ useEffect,
51
+ useImperativeHandle,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ type ReactNode,
56
+ } from 'react';
57
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
58
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
59
+ import Animated, {
60
+ Easing,
61
+ runOnJS,
62
+ useAnimatedProps,
63
+ useAnimatedStyle,
64
+ useDerivedValue,
65
+ useReducedMotion,
66
+ useSharedValue,
67
+ withTiming,
68
+ type SharedValue,
69
+ } from 'react-native-reanimated';
70
+ import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';
71
+ import { useCSSVariable } from 'uniwind';
72
+ import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
73
+ import {
74
+ ChartAccessibilityData,
75
+ type ChartAccessibilityProps,
76
+ } from '../../primitives/chart-accessibility';
77
+ import { Text } from '../../primitives/text';
78
+ import {
79
+ bubbleRadius,
80
+ compactNumber,
81
+ niceDomain,
82
+ useSeriesColor,
83
+ xAt,
84
+ yOf,
85
+ type Plot,
86
+ } from '../../utils/chart';
87
+ import { cn } from '../../utils/cn';
88
+
89
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
90
+ const AnimatedG = Animated.createAnimatedComponent(G);
91
+ const AnimatedLine = Animated.createAnimatedComponent(SvgLine);
92
+
93
+ /**
94
+ * How much of the reveal is spent handing out the bubbles' start times. The
95
+ * rest is the window each one gets, so the whole field still lands inside the
96
+ * one duration however many there are.
97
+ */
98
+ const STAGGER = 0.4;
99
+
100
+ /** Milliseconds for a bubble to swell as it is selected, and settle as it is not. */
101
+ const SELECT_DURATION = 140;
102
+
103
+ /**
104
+ * A bubble arriving: up past its size and back to it.
105
+ *
106
+ * A circle that simply grows to its radius reads as the chart still loading
107
+ * right up to the last frame. The small overshoot is what makes it read as
108
+ * landing.
109
+ */
110
+ function landing(t: number): number {
111
+ 'worklet';
112
+ const back = 1.3;
113
+ const u = t - 1;
114
+ return 1 + (back + 1) * u * u * u + back * u * u;
115
+ }
116
+
117
+ /** Room left around the plot for the axis labels and the outermost bubbles. */
118
+ const PADDING = { top: 18, right: 18, bottom: 22, left: 18 };
119
+
120
+ /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
121
+ const Y_AXIS_WIDTH = 44;
122
+
123
+ /** Gap between the value labels and the plot they sit beside. */
124
+ const Y_AXIS_GUTTER = 6;
125
+
126
+ /** Line height of an `xs` label, for centring one on the grid line it names. */
127
+ const AXIS_LABEL_HEIGHT = 16;
128
+
129
+ /** Box each axis label is centred in, so a long number is ellipsised not shoved. */
130
+ const AXIS_LABEL_WIDTH = 56;
131
+
132
+ /** Width of the readout that floats by the selected bubble. */
133
+ const LABEL_WIDTH = 132;
134
+
135
+ /** Gap between the readout and the edge of the bubble it describes. */
136
+ const LABEL_GAP = 10;
137
+
138
+ /** Line box a bubble's own label is laid out in. */
139
+ const BUBBLE_LABEL_HEIGHT = 16;
140
+
141
+ /**
142
+ * Smallest radius a bubble may have and still be given its label. Below this
143
+ * the name is wider than the circle and reads as text lying on the plot rather
144
+ * than as the bubble's own.
145
+ *
146
+ * Ten points, which is a twenty-point circle: enough for the initial or the
147
+ * short code a bubble chart's labels usually are. Set higher and the smallest
148
+ * bubble in an ordinary set silently loses its name, which reads as a bug
149
+ * rather than as a decision.
150
+ */
151
+ const LABEL_MIN_RADIUS = 10;
152
+
153
+ /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */
154
+ const HIT_RADIUS = 22;
155
+
156
+ /** How many colours the ramp cycles through. */
157
+ const PALETTE_SIZE = 5;
158
+
159
+ /** Steps each axis is rounded out to. Matches the labels an axis draws. */
160
+ const AXIS_STEPS = 4;
161
+
162
+ /** Room under the numbers for an axis's own name, when it is given one. */
163
+ const AXIS_TITLE_HEIGHT = 18;
164
+
165
+ /** Gap between a quadrant's caption and the corner it is written in. */
166
+ const QUADRANT_LABEL_INSET = 6;
167
+
168
+ /** Column the size key's values are written in, beside its circles. */
169
+ const SIZE_KEY_LABEL_WIDTH = 44;
170
+
171
+ /** Gap between the size key's circles and the values naming them. */
172
+ const SIZE_KEY_GAP = 6;
173
+
174
+ /** Room beside the numbers for the y axis's name, which is turned on its side. */
175
+ const AXIS_TITLE_WIDTH = 18;
176
+
177
+ type Layer = 'svg' | 'overlay' | 'header' | 'footer';
178
+
179
+ export type BubbleChartStatus = 'loading' | 'ready';
180
+
181
+ export type BubbleChartDatum = Record<string, string | number | null | undefined>;
182
+
183
+ /** One bubble, resolved back to the row it came from. */
184
+ export interface BubbleChartPoint {
185
+ /** Index into `data`. */
186
+ index: number;
187
+ x: number;
188
+ y: number;
189
+ /** The value behind the area, when `sizeKey` is set. */
190
+ size: number | null;
191
+ /** The name written inside the circle, when `labelKey` is set. */
192
+ label: string;
193
+ /** The colour it was drawn in. */
194
+ color: string;
195
+ datum: BubbleChartDatum;
196
+ }
197
+
198
+ /** A bubble with its geometry resolved. Shared by every part. */
199
+ interface ResolvedBubble extends BubbleChartPoint {
200
+ /** Radius in points, off the area scale. */
201
+ r: number;
202
+ }
203
+
204
+ interface BubbleChartContextValue {
205
+ data: BubbleChartDatum[];
206
+ xDataKey: string;
207
+ yDataKey: string;
208
+ labelKey: string | undefined;
209
+ plot: Plot;
210
+ status: BubbleChartStatus;
211
+ bubbles: ResolvedBubble[];
212
+ xMin: SharedValue<number>;
213
+ xMax: SharedValue<number>;
214
+ yMin: SharedValue<number>;
215
+ yMax: SharedValue<number>;
216
+ /** The settled domains, for the parts that draw text rather than geometry. */
217
+ xExtent: [number, number];
218
+ yExtent: [number, number];
219
+ /** Lowest and highest value behind the areas, or null without a `sizeKey`. */
220
+ sizeExtent: [number, number] | null;
221
+ /** The radii those two values map onto. */
222
+ sizeRange: [number, number];
223
+ /** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */
224
+ reveal: SharedValue<number>;
225
+ activeIndex: SharedValue<number>;
226
+ activeIndexJS: number;
227
+ setActivePoint: (point: BubbleChartPoint | null) => void;
228
+ }
229
+
230
+ const BubbleChartContext = createContext<BubbleChartContextValue | null>(null);
231
+
232
+ function useChart(component: string): BubbleChartContextValue {
233
+ const context = useContext(BubbleChartContext);
234
+ if (!context) {
235
+ throw new Error(`${component} must be used within a <BubbleChart>`);
236
+ }
237
+ return context;
238
+ }
239
+
240
+ /**
241
+ * The bubble under the finger, for something rendered *inside* the chart. A
242
+ * readout in the card's header is outside this provider — use
243
+ * `onActivePointChange` for that.
244
+ */
245
+ export function useBubbleChart() {
246
+ const { bubbles, activeIndexJS } = useChart('useBubbleChart');
247
+ const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;
248
+ return { activeIndex: activeIndexJS, activePoint: active };
249
+ }
250
+
251
+ /**
252
+ * An extent widened out to round numbers, and a usable one for the degenerate
253
+ * cases — no data at all, or every reading identical. A domain of zero width
254
+ * divides by zero and puts every bubble on the same edge.
255
+ *
256
+ * Rounded rather than padded by a fraction: a fraction of the data's own span
257
+ * ends the axis at 52.7, which is true and which nobody was looking for. Two
258
+ * steps, matching the labels each axis draws by default, so the middle one is
259
+ * round as well as the ends.
260
+ */
261
+ function padExtent(min: number, max: number): [number, number] {
262
+ if (min === Infinity) return [0, 1];
263
+ if (min === max) return [min - 1, max + 1];
264
+ return niceDomain(min, max, AXIS_STEPS);
265
+ }
266
+
267
+ export interface BubbleChartProps
268
+ extends ViewProps,
269
+ ChartAccessibilityProps<BubbleChartDatum> {
270
+ className?: string;
271
+ /** The rows. One bubble each. */
272
+ data: BubbleChartDatum[];
273
+ /** Key holding the horizontal value. */
274
+ xDataKey?: string;
275
+ /** Key holding the vertical value. */
276
+ yDataKey?: string;
277
+ /**
278
+ * Key holding the third quantity, mapped to each bubble's *area*. Without it
279
+ * every bubble is drawn at the middle of `sizeRange` and the chart is a
280
+ * scatter plot with names on it.
281
+ */
282
+ sizeKey?: string;
283
+ /** Key holding the name written inside the circle. */
284
+ labelKey?: string;
285
+ /**
286
+ * Key holding a colour for the row — either a CSS colour or a number from 1
287
+ * to 5 naming a `--color-chart-*` token. Without it the ramp cycles by row.
288
+ */
289
+ colorKey?: string;
290
+ /**
291
+ * Smallest and largest radius `sizeKey` maps onto, in points. The largest is
292
+ * also what the plot holds back at every edge, so raising it costs room.
293
+ */
294
+ sizeRange?: [number, number];
295
+ /**
296
+ * `loading` shows a still field of muted circles and dissolves it as the real
297
+ * bubbles grow in. One component throughout, rather than a spinner swapped
298
+ * for a chart — swapping loses the transition. Add a `BubbleChart.Skeleton`
299
+ * for something to stand in the plot meanwhile.
300
+ */
301
+ status?: BubbleChartStatus;
302
+ /** Width ÷ height. `1` is the square shape a bubble field reads best in. */
303
+ aspectRatio?: number;
304
+ /** Milliseconds for the bubbles to grow in on mount. */
305
+ animationDuration?: number;
306
+ /** Milliseconds for the axes to settle after the data changes. */
307
+ domainDuration?: number;
308
+ /** Fix the horizontal axis instead of deriving it. */
309
+ xDomain?: [number, number];
310
+ /** Fix the vertical axis instead of deriving it. */
311
+ yDomain?: [number, number];
312
+ /** The bubble under the finger, and `null` when it lifts. */
313
+ onActivePointChange?: (point: BubbleChartPoint | null) => void;
314
+ children?: ReactNode;
315
+ }
316
+
317
+ /** Imperative handle: re-run the grow-in, for a "replay" control. */
318
+ export interface BubbleChartHandle {
319
+ replay: () => void;
320
+ }
321
+
322
+ function partition(children: ReactNode) {
323
+ const svg: ReactNode[] = [];
324
+ const overlay: ReactNode[] = [];
325
+ const header: ReactNode[] = [];
326
+ const footer: ReactNode[] = [];
327
+ Children.forEach(children, (child, index) => {
328
+ if (!isValidElement(child)) return;
329
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
330
+ const slot = <ChildSlot key={index}>{child}</ChildSlot>;
331
+ const into =
332
+ layer === 'header' ? header : layer === 'footer' ? footer : layer === 'overlay' ? overlay : svg;
333
+ into.push(slot);
334
+ });
335
+ return { svg, overlay, header, footer };
336
+ }
337
+
338
+ function ChildSlot({ children }: { children: ReactNode }) {
339
+ return <>{children}</>;
340
+ }
341
+
342
+ const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
343
+ function BubbleChartRoot(
344
+ {
345
+ className,
346
+ data,
347
+ xDataKey = 'x',
348
+ yDataKey = 'y',
349
+ sizeKey,
350
+ labelKey,
351
+ colorKey,
352
+ sizeRange = [10, 28],
353
+ status = 'ready',
354
+ aspectRatio = 1,
355
+ animationDuration = 800,
356
+ domainDuration = 500,
357
+ xDomain,
358
+ yDomain,
359
+ onActivePointChange,
360
+ accessible,
361
+ accessibilityLabel,
362
+ accessibilityHint,
363
+ accessibilityLabelForDatum,
364
+ onAccessibilityDatumPress,
365
+ children,
366
+ ...props
367
+ },
368
+ ref
369
+ ) {
370
+ const [size, setSize] = useState({ width: 0, height: 0 });
371
+ const [activeIndexJS, setActiveIndexJS] = useState(-1);
372
+
373
+ const reveal = useSharedValue(0);
374
+ const xMin = useSharedValue(0);
375
+ const xMax = useSharedValue(0);
376
+ const yMin = useSharedValue(0);
377
+ const yMax = useSharedValue(0);
378
+ const activeIndex = useSharedValue(-1);
379
+ const reducedMotion = useReducedMotion();
380
+
381
+ /*
382
+ * The whole ramp, resolved once. Every bubble is its own category here
383
+ * rather than a member of a series, so the colour is chosen by row index
384
+ * and there is nothing to register.
385
+ */
386
+ const chart1 = useSeriesColor(undefined, 1);
387
+ const chart2 = useSeriesColor(undefined, 2);
388
+ const chart3 = useSeriesColor(undefined, 3);
389
+ const chart4 = useSeriesColor(undefined, 4);
390
+ const chart5 = useSeriesColor(undefined, 5);
391
+ const palette = useMemo(
392
+ () => [chart1, chart2, chart3, chart4, chart5],
393
+ [chart1, chart2, chart3, chart4, chart5]
394
+ );
395
+
396
+ /*
397
+ * What the axes are going to need before anything has been laid out. The
398
+ * gutter for the y labels and the strip under the x ones are the plot's
399
+ * padding, so they have to be known here rather than by the parts that draw
400
+ * them — a part that reserved its own room would be positioned against a
401
+ * plot that had already been sized without it.
402
+ */
403
+ const axes = useMemo(() => {
404
+ let y = false;
405
+ let yTitle = false;
406
+ let xTitle = false;
407
+ Children.forEach(children, (child) => {
408
+ if (!isValidElement(child)) return;
409
+ const axis = (child.type as { axis?: string }).axis;
410
+ const labelled = Boolean((child.props as { label?: string }).label);
411
+ if (axis === 'y') {
412
+ y = true;
413
+ if (labelled) yTitle = true;
414
+ }
415
+ if (axis === 'x' && labelled) xTitle = true;
416
+ });
417
+ return { y, yTitle, xTitle };
418
+ }, [children]);
419
+
420
+ /*
421
+ * A circle is drawn about its centre, so every edge of the plot has to hold
422
+ * back the largest radius or the outermost bubble is cropped by it — and
423
+ * the largest bubble is the one carrying the largest value, which is the
424
+ * last one that should be half missing. `sizeRange` is the ceiling, so this
425
+ * is known before anything is measured.
426
+ */
427
+ const reach = sizeRange[1];
428
+ const pad = {
429
+ top: Math.max(PADDING.top, reach),
430
+ right: Math.max(PADDING.right, reach),
431
+ bottom:
432
+ Math.max(PADDING.bottom, reach) + (axes.xTitle ? AXIS_TITLE_HEIGHT : 0),
433
+ left: Math.max(
434
+ axes.y ? Y_AXIS_WIDTH + (axes.yTitle ? AXIS_TITLE_WIDTH : 0) : PADDING.left,
435
+ reach
436
+ ),
437
+ };
438
+ const plot: Plot = {
439
+ left: pad.left,
440
+ top: pad.top,
441
+ width: Math.max(size.width - pad.left - pad.right, 0),
442
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
443
+ };
444
+
445
+ const extents = useMemo(() => {
446
+ let lowX = Infinity;
447
+ let highX = -Infinity;
448
+ let lowY = Infinity;
449
+ let highY = -Infinity;
450
+ for (const row of data) {
451
+ const x = row[xDataKey];
452
+ const y = row[yDataKey];
453
+ // A row missing either coordinate is not a bubble at all, and must not
454
+ // stretch the axes towards an origin it never had.
455
+ if (typeof x !== 'number' || Number.isNaN(x)) continue;
456
+ if (typeof y !== 'number' || Number.isNaN(y)) continue;
457
+ if (x < lowX) lowX = x;
458
+ if (x > highX) highX = x;
459
+ if (y < lowY) lowY = y;
460
+ if (y > highY) highY = y;
461
+ }
462
+ return {
463
+ x: xDomain ?? padExtent(lowX, highX),
464
+ y: yDomain ?? padExtent(lowY, highY),
465
+ };
466
+ }, [data, xDataKey, yDataKey, xDomain, yDomain]);
467
+
468
+ /*
469
+ * The size scale runs over the whole data set, so one bubble's area means
470
+ * the same thing as another's. Without a `sizeKey` there is nothing to
471
+ * scale and every bubble takes the middle of the range — which is a
472
+ * scatter plot with names on it, and an honest one.
473
+ */
474
+ const sizeExtent = useMemo<[number, number] | null>(() => {
475
+ if (!sizeKey) return null;
476
+ let min = Infinity;
477
+ let max = -Infinity;
478
+ for (const row of data) {
479
+ const value = row[sizeKey];
480
+ if (typeof value !== 'number' || Number.isNaN(value)) continue;
481
+ if (value < min) min = value;
482
+ if (value > max) max = value;
483
+ }
484
+ return min === Infinity ? null : [min, max];
485
+ }, [data, sizeKey]);
486
+
487
+ /*
488
+ * Resolved once, in the root, because four parts need exactly this list and
489
+ * three of them would otherwise derive it again: the circles, the labels
490
+ * over them, the hit test under them and the legend beside them all have to
491
+ * agree about where a bubble is and how big it is.
492
+ */
493
+ const bubbles = useMemo<ResolvedBubble[]>(() => {
494
+ const middle = (sizeRange[0] + sizeRange[1]) / 2;
495
+ const out: ResolvedBubble[] = [];
496
+ data.forEach((datum, index) => {
497
+ const x = datum[xDataKey];
498
+ const y = datum[yDataKey];
499
+ if (typeof x !== 'number' || Number.isNaN(x)) return;
500
+ if (typeof y !== 'number' || Number.isNaN(y)) return;
501
+
502
+ const raw = sizeKey ? datum[sizeKey] : undefined;
503
+ const value = typeof raw === 'number' && !Number.isNaN(raw) ? raw : null;
504
+ const r =
505
+ sizeExtent && value !== null
506
+ ? bubbleRadius(value, sizeExtent, sizeRange)
507
+ : middle;
508
+
509
+ const explicit = colorKey ? datum[colorKey] : undefined;
510
+ const color =
511
+ typeof explicit === 'string'
512
+ ? explicit
513
+ : typeof explicit === 'number'
514
+ ? (palette[(Math.round(explicit) - 1 + PALETTE_SIZE) % PALETTE_SIZE] ??
515
+ palette[0]!)
516
+ : (palette[index % PALETTE_SIZE] ?? palette[0]!);
517
+
518
+ out.push({
519
+ index,
520
+ x,
521
+ y,
522
+ r,
523
+ size: value,
524
+ label: labelKey ? String(datum[labelKey] ?? '') : '',
525
+ color,
526
+ datum,
527
+ });
528
+ });
529
+ return out;
530
+ }, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, sizeExtent, sizeRange, palette]);
531
+
532
+ const loading = status === 'loading';
533
+
534
+ useEffect(() => {
535
+ if (loading) return;
536
+ const [x0, x1] = extents.x;
537
+ const [y0, y1] = extents.y;
538
+ // The first domain lands without a tween: there is no previous scale to
539
+ // move from, and animating up from zero reads as the numbers changing.
540
+ const first =
541
+ xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;
542
+ if (first || reducedMotion) {
543
+ xMin.value = x0;
544
+ xMax.value = x1;
545
+ yMin.value = y0;
546
+ yMax.value = y1;
547
+ return;
548
+ }
549
+ xMin.value = withTiming(x0, { duration: domainDuration });
550
+ xMax.value = withTiming(x1, { duration: domainDuration });
551
+ yMin.value = withTiming(y0, { duration: domainDuration });
552
+ yMax.value = withTiming(y1, { duration: domainDuration });
553
+ }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);
554
+
555
+ const revealed = useRef(false);
556
+ const playReveal = useMemo(
557
+ () => () => {
558
+ if (reducedMotion) {
559
+ reveal.value = 1;
560
+ return;
561
+ }
562
+ reveal.value = 0;
563
+ /*
564
+ * Eased out rather than in and out. Each bubble is given a slice of
565
+ * this one clock, so an ease that dawdles at the start spends it on the
566
+ * first few and leaves the rest to arrive in a rush.
567
+ */
568
+ reveal.value = withTiming(1, {
569
+ duration: animationDuration,
570
+ easing: Easing.out(Easing.cubic),
571
+ });
572
+ },
573
+ [reducedMotion, animationDuration, reveal]
574
+ );
575
+
576
+ useEffect(() => {
577
+ if (loading) {
578
+ revealed.current = false;
579
+ reveal.value = 0;
580
+ return;
581
+ }
582
+ if (revealed.current || plot.width <= 0 || !bubbles.length) return;
583
+ revealed.current = true;
584
+ playReveal();
585
+ }, [loading, plot.width, bubbles.length, playReveal, reveal]);
586
+
587
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
588
+
589
+ // One place the selection lands, so the chart's own children and a readout
590
+ // outside it never disagree about which bubble is active.
591
+ const setActivePoint = useMemo(
592
+ () => (point: BubbleChartPoint | null) => {
593
+ setActiveIndexJS(point ? point.index : -1);
594
+ onActivePointChange?.(point);
595
+ },
596
+ [onActivePointChange]
597
+ );
598
+
599
+ const onLayout = (event: LayoutChangeEvent) => {
600
+ const { width, height } = event.nativeEvent.layout;
601
+ setSize((current) =>
602
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
603
+ ? current
604
+ : { width, height }
605
+ );
606
+ props.onLayout?.(event);
607
+ };
608
+
609
+ const context = useMemo<BubbleChartContextValue>(
610
+ () => ({
611
+ data,
612
+ xDataKey,
613
+ yDataKey,
614
+ labelKey,
615
+ plot,
616
+ status,
617
+ bubbles,
618
+ xMin,
619
+ xMax,
620
+ yMin,
621
+ yMax,
622
+ xExtent: extents.x,
623
+ yExtent: extents.y,
624
+ sizeExtent,
625
+ sizeRange,
626
+ reveal,
627
+ activeIndex,
628
+ activeIndexJS,
629
+ setActivePoint,
630
+ }),
631
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
632
+ // eslint-disable-next-line react-hooks/exhaustive-deps
633
+ [
634
+ data,
635
+ xDataKey,
636
+ yDataKey,
637
+ labelKey,
638
+ plot.width,
639
+ plot.height,
640
+ plot.left,
641
+ plot.top,
642
+ status,
643
+ bubbles,
644
+ xMin,
645
+ xMax,
646
+ yMin,
647
+ yMax,
648
+ extents,
649
+ sizeExtent,
650
+ sizeRange,
651
+ reveal,
652
+ activeIndex,
653
+ activeIndexJS,
654
+ setActivePoint,
655
+ ]
656
+ );
657
+
658
+ const { svg, overlay, header, footer } = partition(children);
659
+
660
+ /*
661
+ * Two views, because the header is not part of the plot. `aspectRatio` and
662
+ * the layout measurement belong to the drawing area alone — measured on the
663
+ * outer view they would take in the header too, and the plot would lose as
664
+ * much height as the readout took while still claiming the shape asked for.
665
+ */
666
+ return (
667
+ <BubbleChartContext.Provider value={context}>
668
+ <View {...props} style={props.style} className={cn('w-full', className)}>
669
+ {header}
670
+ <ChartAccessibilityData
671
+ chart="Bubble chart"
672
+ data={data}
673
+ disabled={accessible === false || loading}
674
+ accessibilityLabel={accessibilityLabel}
675
+ accessibilityHint={accessibilityHint}
676
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
677
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
678
+ valueOf={(datum) => {
679
+ const pairs: [string, unknown][] = [
680
+ [xDataKey, datum[xDataKey]],
681
+ [yDataKey, datum[yDataKey]],
682
+ ];
683
+ if (labelKey) pairs.unshift([labelKey, datum[labelKey]]);
684
+ if (sizeKey) pairs.push([sizeKey, datum[sizeKey]]);
685
+ return pairs;
686
+ }}
687
+ />
688
+ <View
689
+ onLayout={onLayout}
690
+ style={{ aspectRatio }}
691
+ className="w-full"
692
+ accessibilityElementsHidden
693
+ importantForAccessibility="no-hide-descendants"
694
+ >
695
+ {plot.width > 0 ? (
696
+ <>
697
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
698
+ {svg}
699
+ </Svg>
700
+ {overlay}
701
+ </>
702
+ ) : null}
703
+ </View>
704
+ {footer}
705
+ </View>
706
+ </BubbleChartContext.Provider>
707
+ );
708
+ }
709
+ );
710
+ BubbleChartRoot.displayName = 'BubbleChart';
711
+
712
+ /* -------------------------------------------------------------------------- */
713
+ /* SVG layer */
714
+ /* -------------------------------------------------------------------------- */
715
+
716
+ export interface BubbleChartGridProps {
717
+ /**
718
+ * Horizontal rules across the plot.
719
+ *
720
+ * Eight, which is twice the four intervals an axis is divided into by
721
+ * default, so every second line carries a number and the ones between it are
722
+ * halves of a labelled step rather than an unrelated rhythm. Squares this
723
+ * size recede behind the circles; the coarse grid a smaller number draws
724
+ * reads as blocks laid over the plot.
725
+ */
726
+ rows?: number;
727
+ /** Vertical rules up it. Both axes are measured, so both earn lines. */
728
+ columns?: number;
729
+ /** Dash pattern for the rules. Pass `undefined` for solid ones. */
730
+ dashArray?: string;
731
+ color?: string;
732
+ opacity?: number;
733
+ }
734
+
735
+ /** Reference lines both ways, so a bubble can be placed against two numbers. */
736
+ function BubbleChartGrid({
737
+ rows = 8,
738
+ columns = 8,
739
+ dashArray = '4,6',
740
+ color,
741
+ opacity = 1,
742
+ }: BubbleChartGridProps) {
743
+ const { plot } = useChart('BubbleChart.Grid');
744
+ const token = useCSSVariable('--color-border');
745
+ // The fallback is grey rather than black: it stands in when the theme cannot
746
+ // be read, and a black hairline is invisible on a dark background.
747
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
748
+
749
+ const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows);
750
+ const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns);
751
+
752
+ return (
753
+ <G opacity={opacity}>
754
+ {horizontals.map((fraction) => (
755
+ <SvgLine
756
+ key={`h${fraction}`}
757
+ x1={plot.left}
758
+ x2={plot.left + plot.width}
759
+ y1={plot.top + plot.height * fraction}
760
+ y2={plot.top + plot.height * fraction}
761
+ stroke={stroke}
762
+ strokeWidth={1}
763
+ strokeDasharray={dashArray}
764
+ />
765
+ ))}
766
+ {verticals.map((fraction) => (
767
+ <SvgLine
768
+ key={`v${fraction}`}
769
+ x1={plot.left + plot.width * fraction}
770
+ x2={plot.left + plot.width * fraction}
771
+ y1={plot.top}
772
+ y2={plot.top + plot.height}
773
+ stroke={stroke}
774
+ strokeWidth={1}
775
+ strokeDasharray={dashArray}
776
+ />
777
+ ))}
778
+ </G>
779
+ );
780
+ }
781
+ BubbleChartGrid.displayName = 'BubbleChart.Grid';
782
+ BubbleChartGrid.layer = 'svg' as Layer;
783
+
784
+ export interface BubbleChartTrendProps {
785
+ /**
786
+ * The line's slope and intercept, and how tightly the cloud sits on it, once
787
+ * they have been computed. `r` runs 0 to 1: 1 is every bubble on the line,
788
+ * 0 is a cloud with no direction at all.
789
+ *
790
+ * Given here rather than left for the caller to work out, because the fit is
791
+ * already being computed to draw the line and doing it twice invites the two
792
+ * answers to disagree.
793
+ *
794
+ * It fires when the numbers change, not on every render that produced the
795
+ * same ones, so putting the fit straight into state is safe.
796
+ */
797
+ onFit?: (fit: { slope: number; intercept: number; r: number }) => void;
798
+ color?: string;
799
+ strokeWidth?: number;
800
+ /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */
801
+ dashArray?: string;
802
+ opacity?: number;
803
+ }
804
+
805
+ /**
806
+ * The straight line that fits the cloud best, drawn across the plot.
807
+ *
808
+ * It is dashed and drawn under the circles, because it is not data — it is a
809
+ * summary of the data, and a solid rule through the middle of a field of
810
+ * bubbles reads as a value somebody plotted.
811
+ *
812
+ * The fit is least squares on the raw values, so it moves with the data rather
813
+ * than with the frame: resizing the chart never changes the line's meaning.
814
+ * Fewer than two bubbles, or every bubble on one vertical, has no line to draw
815
+ * and none is drawn.
816
+ */
817
+ function BubbleChartTrend({
818
+ onFit,
819
+ color,
820
+ strokeWidth = 1.5,
821
+ dashArray = '6,5',
822
+ opacity = 0.7,
823
+ }: BubbleChartTrendProps) {
824
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =
825
+ useChart('BubbleChart.Trend');
826
+ const token = useCSSVariable('--color-muted-foreground');
827
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');
828
+
829
+ const fit = useMemo(() => {
830
+ const n = bubbles.length;
831
+ if (n < 2) return null;
832
+ let sumX = 0;
833
+ let sumY = 0;
834
+ for (const bubble of bubbles) {
835
+ sumX += bubble.x;
836
+ sumY += bubble.y;
837
+ }
838
+ const meanX = sumX / n;
839
+ const meanY = sumY / n;
840
+ let sxy = 0;
841
+ let sxx = 0;
842
+ let syy = 0;
843
+ for (const bubble of bubbles) {
844
+ const dx = bubble.x - meanX;
845
+ const dy = bubble.y - meanY;
846
+ sxy += dx * dy;
847
+ sxx += dx * dx;
848
+ syy += dy * dy;
849
+ }
850
+ // Every bubble on one vertical: the best fit is that vertical, which has no
851
+ // slope and nothing useful to draw.
852
+ if (sxx === 0) return null;
853
+ const slope = sxy / sxx;
854
+ const denominator = Math.sqrt(sxx * syy);
855
+ return {
856
+ slope,
857
+ intercept: meanY - slope * meanX,
858
+ r: denominator === 0 ? 0 : Math.abs(sxy / denominator),
859
+ };
860
+ }, [bubbles]);
861
+
862
+ const fitRef = useRef(onFit);
863
+ useEffect(() => {
864
+ fitRef.current = onFit;
865
+ });
866
+
867
+ /*
868
+ * Reported when the numbers change, not when the object does.
869
+ *
870
+ * `bubbles` is rebuilt whenever any of its inputs changes identity, and
871
+ * `sizeRange={[14, 30]}` written at a call site is a new array every render —
872
+ * so the fit is a new object every render even when the data has not moved.
873
+ * Handing that to a caller who puts it in state is a render loop, and the
874
+ * caller has no way to see that coming.
875
+ */
876
+ const reported = useRef<{ slope: number; intercept: number; r: number } | null>(null);
877
+ useEffect(() => {
878
+ if (!fit) return;
879
+ const last = reported.current;
880
+ if (
881
+ last &&
882
+ last.slope === fit.slope &&
883
+ last.intercept === fit.intercept &&
884
+ last.r === fit.r
885
+ ) {
886
+ return;
887
+ }
888
+ reported.current = fit;
889
+ fitRef.current?.(fit);
890
+ }, [fit]);
891
+
892
+ const slope = fit?.slope ?? 0;
893
+ const intercept = fit?.intercept ?? 0;
894
+
895
+ const animatedProps = useAnimatedProps(() => {
896
+ const x0 = xMin.value;
897
+ const x1 = xMax.value;
898
+ const lowY = yMin.value;
899
+ const highY = yMax.value;
900
+
901
+ /*
902
+ * Solved in data space and then clipped there, rather than drawn across the
903
+ * plot and clipped by the frame: a line that leaves the top of the chart
904
+ * has to stop where it leaves it, and the x of that point is only knowable
905
+ * from the equation.
906
+ */
907
+ let ax = x0;
908
+ let bx = x1;
909
+ if (slope !== 0) {
910
+ const atLow = (lowY - intercept) / slope;
911
+ const atHigh = (highY - intercept) / slope;
912
+ const enter = Math.min(atLow, atHigh);
913
+ const exit = Math.max(atLow, atHigh);
914
+ ax = Math.max(ax, enter);
915
+ bx = Math.min(bx, exit);
916
+ }
917
+ if (bx < ax) {
918
+ // The line never crosses the visible box.
919
+ return { x1: 0, x2: 0, y1: 0, y2: 0, opacity: 0 };
920
+ }
921
+
922
+ // Drawn out from the middle as the bubbles land, so the line arrives with
923
+ // the field rather than being there waiting for it.
924
+ const grown = Math.max(0, Math.min(1, reveal.value));
925
+ const midpoint = (ax + bx) / 2;
926
+ const half = ((bx - ax) / 2) * grown;
927
+
928
+ const startX = midpoint - half;
929
+ const endX = midpoint + half;
930
+ return {
931
+ x1: xAt(startX, plot, x0, x1),
932
+ x2: xAt(endX, plot, x0, x1),
933
+ y1: yOf(intercept + slope * startX, plot, lowY, highY),
934
+ y2: yOf(intercept + slope * endX, plot, lowY, highY),
935
+ opacity: opacity * grown,
936
+ };
937
+ });
938
+
939
+ if (status === 'loading' || !fit) return null;
940
+
941
+ return (
942
+ <AnimatedLine
943
+ animatedProps={animatedProps}
944
+ stroke={stroke}
945
+ strokeWidth={strokeWidth}
946
+ strokeDasharray={dashArray}
947
+ strokeLinecap="round"
948
+ />
949
+ );
950
+ }
951
+ BubbleChartTrend.displayName = 'BubbleChart.Trend';
952
+ BubbleChartTrend.layer = 'svg' as Layer;
953
+
954
+ export interface BubbleChartBubblesProps {
955
+ /**
956
+ * Fill opacity. Below 1 by default so that overlapping bubbles read as
957
+ * denser rather than hiding each other — in a crowded corner that overlap
958
+ * *is* the finding, and opaque circles erase it.
959
+ */
960
+ opacity?: number;
961
+ /** One colour for every bubble, overriding the per-row ramp. */
962
+ color?: string;
963
+ }
964
+
965
+ /** The circles. */
966
+ function BubbleChartBubbles({ opacity = 0.9, color }: BubbleChartBubblesProps) {
967
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal, activeIndex } =
968
+ useChart('BubbleChart.Bubbles');
969
+
970
+ if (status === 'loading') return null;
971
+
972
+ return (
973
+ <G>
974
+ {bubbles.map((bubble, order) => (
975
+ <Bubble
976
+ key={bubble.index}
977
+ bubble={bubble}
978
+ plot={plot}
979
+ xMin={xMin}
980
+ xMax={xMax}
981
+ yMin={yMin}
982
+ yMax={yMax}
983
+ fill={color ?? bubble.color}
984
+ opacity={opacity}
985
+ activeIndex={activeIndex}
986
+ reveal={reveal}
987
+ order={order}
988
+ total={bubbles.length}
989
+ />
990
+ ))}
991
+ </G>
992
+ );
993
+ }
994
+ BubbleChartBubbles.displayName = 'BubbleChart.Bubbles';
995
+ BubbleChartBubbles.layer = 'svg' as Layer;
996
+
997
+ /**
998
+ * One bubble. Its position follows both domain tweens, so a data change moves
999
+ * the whole field to the new scale rather than cutting to it.
1000
+ *
1001
+ * It arrives by growing in place, on its own slice of the shared reveal. A wipe
1002
+ * across the plot — which is what the line and area charts do — gives the
1003
+ * reader a direction to read the arrival in, and a field of bubbles has none:
1004
+ * position is the whole message, so a bubble may only ever appear where it
1005
+ * belongs.
1006
+ */
1007
+ function Bubble({
1008
+ bubble,
1009
+ plot,
1010
+ xMin,
1011
+ xMax,
1012
+ yMin,
1013
+ yMax,
1014
+ fill,
1015
+ opacity,
1016
+ activeIndex,
1017
+ reveal,
1018
+ order,
1019
+ total,
1020
+ }: {
1021
+ bubble: ResolvedBubble;
1022
+ plot: Plot;
1023
+ xMin: SharedValue<number>;
1024
+ xMax: SharedValue<number>;
1025
+ yMin: SharedValue<number>;
1026
+ yMax: SharedValue<number>;
1027
+ fill: string;
1028
+ opacity: number;
1029
+ activeIndex: SharedValue<number>;
1030
+ reveal: SharedValue<number>;
1031
+ order: number;
1032
+ total: number;
1033
+ }) {
1034
+ const { index, x, y, r } = bubble;
1035
+
1036
+ /*
1037
+ * The selection, as something that moves. A hard switch made the bubble it
1038
+ * named jump between one frame and the next while every neighbour stayed put,
1039
+ * which reads as a glitch rather than as a response to the finger.
1040
+ */
1041
+ const selected = useDerivedValue(() =>
1042
+ withTiming(activeIndex.value === index ? 1 : 0, { duration: SELECT_DURATION })
1043
+ );
1044
+
1045
+ // Where in the reveal this bubble starts. Spread over `STAGGER`, so the field
1046
+ // settles as a field rather than switching on all at once.
1047
+ const start = total > 1 ? (order / total) * STAGGER : 0;
1048
+
1049
+ const animatedProps = useAnimatedProps(() => {
1050
+ const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
1051
+ // The selected bubble swells and goes solid. Both, rather than one: a size
1052
+ // change alone is easy to miss among neighbours, and an opacity change
1053
+ // alone is invisible wherever the circles already overlap.
1054
+ const swell = 1 + 0.12 * selected.value;
1055
+ return {
1056
+ cx: xAt(x, plot, xMin.value, xMax.value),
1057
+ cy: yOf(y, plot, yMin.value, yMax.value),
1058
+ r: r * landing(arrived) * swell,
1059
+ // Ahead of the size, so a bubble is legible by the time it stops moving
1060
+ // rather than fading in for the whole of its arrival.
1061
+ fillOpacity:
1062
+ Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),
1063
+ };
1064
+ });
1065
+
1066
+ return <AnimatedCircle animatedProps={animatedProps} fill={fill} />;
1067
+ }
1068
+
1069
+ export interface BubbleChartSkeletonProps {
1070
+ /** How many placeholder circles to scatter. */
1071
+ count?: number;
1072
+ color?: string;
1073
+ }
1074
+
1075
+ /**
1076
+ * The loading state: a still field of muted circles where the data will be.
1077
+ *
1078
+ * Deliberately still. A shimmer over a field of circles reads as them *moving*,
1079
+ * which is the one thing this chart must never appear to do — position is the
1080
+ * entire message, and a loading state that implies it is changing is a loading
1081
+ * state that lies.
1082
+ *
1083
+ * The layout is deterministic rather than random, so it does not reshuffle on
1084
+ * every render of a component that may re-render several times while waiting.
1085
+ * It dissolves as the real bubbles grow in, and outlives the status change by
1086
+ * exactly that long: cut at the frame the data lands, the placeholder would
1087
+ * disappear before anything had replaced it.
1088
+ */
1089
+ function BubbleChartSkeleton({ count = 7, color }: BubbleChartSkeletonProps) {
1090
+ const { plot, status } = useChart('BubbleChart.Skeleton');
1091
+ const token = useCSSVariable('--color-skeleton');
1092
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
1093
+
1094
+ const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');
1095
+
1096
+ const circles = useMemo(
1097
+ () =>
1098
+ // Two irrational-ish strides that do not share a factor, so the circles
1099
+ // spread instead of falling into a lattice.
1100
+ Array.from({ length: count }, (_unused, index) => ({
1101
+ key: index,
1102
+ fx: ((index * 0.618) % 1) * 0.84 + 0.08,
1103
+ fy: ((index * 0.379) % 1) * 0.84 + 0.08,
1104
+ r: 16 + ((index * 0.472) % 1) * 12,
1105
+ })),
1106
+ [count]
1107
+ );
1108
+
1109
+ const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));
1110
+
1111
+ if (!mounted) return null;
1112
+
1113
+ return (
1114
+ <AnimatedG animatedProps={animatedProps}>
1115
+ {circles.map((circle) => (
1116
+ <Circle
1117
+ key={circle.key}
1118
+ cx={plot.left + circle.fx * plot.width}
1119
+ cy={plot.top + circle.fy * plot.height}
1120
+ r={circle.r}
1121
+ fill={fill}
1122
+ />
1123
+ ))}
1124
+ </AnimatedG>
1125
+ );
1126
+ }
1127
+ BubbleChartSkeleton.displayName = 'BubbleChart.Skeleton';
1128
+ BubbleChartSkeleton.layer = 'svg' as Layer;
1129
+
1130
+ /* -------------------------------------------------------------------------- */
1131
+ /* Overlay layer */
1132
+ /* -------------------------------------------------------------------------- */
1133
+
1134
+ export interface BubbleChartLabelsProps {
1135
+ /**
1136
+ * Smallest radius a bubble may have and still be given its label. Below it
1137
+ * the name is wider than the circle it names.
1138
+ */
1139
+ minRadius?: number;
1140
+ /** Turn a bubble into its label. Defaults to the value at `labelKey`. */
1141
+ format?: (point: BubbleChartPoint) => string;
1142
+ className?: string;
1143
+ }
1144
+
1145
+ /**
1146
+ * The names, written inside the circles.
1147
+ *
1148
+ * Real text over the plot rather than SVG text, so they follow the theme's font
1149
+ * and the platform's text scaling. Each one rides the same domain tweens the
1150
+ * circle under it does, so a label never lags the bubble it belongs to.
1151
+ *
1152
+ * A bubble too small to hold its name is left without one. Shrinking the text
1153
+ * to fit would make it unreadable on exactly the bubbles the reader is
1154
+ * squinting at already; the readout names those instead.
1155
+ */
1156
+ function BubbleChartLabels({
1157
+ minRadius = LABEL_MIN_RADIUS,
1158
+ format,
1159
+ className,
1160
+ }: BubbleChartLabelsProps) {
1161
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =
1162
+ useChart('BubbleChart.Labels');
1163
+
1164
+ if (status === 'loading') return null;
1165
+
1166
+ return (
1167
+ <View style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
1168
+ {bubbles
1169
+ .filter((bubble) => bubble.r >= minRadius && (format || bubble.label))
1170
+ .map((bubble, order) => (
1171
+ <BubbleLabel
1172
+ key={bubble.index}
1173
+ bubble={bubble}
1174
+ text={format ? format(bubble) : bubble.label}
1175
+ plot={plot}
1176
+ xMin={xMin}
1177
+ xMax={xMax}
1178
+ yMin={yMin}
1179
+ yMax={yMax}
1180
+ reveal={reveal}
1181
+ order={order}
1182
+ total={bubbles.length}
1183
+ className={className}
1184
+ />
1185
+ ))}
1186
+ </View>
1187
+ );
1188
+ }
1189
+ BubbleChartLabels.displayName = 'BubbleChart.Labels';
1190
+ BubbleChartLabels.layer = 'overlay' as Layer;
1191
+
1192
+ export interface BubbleChartQuadrantsProps {
1193
+ /** Where the vertical rule stands. Defaults to the mean of the x values. */
1194
+ x?: number;
1195
+ /** Where the horizontal rule lies. Defaults to the mean of the y values. */
1196
+ y?: number;
1197
+ /** A word for each corner, written in the corner it belongs to. */
1198
+ labels?: {
1199
+ topLeft?: string;
1200
+ topRight?: string;
1201
+ bottomLeft?: string;
1202
+ bottomRight?: string;
1203
+ };
1204
+ /** Tint the high-high and low-low corners. On by default. */
1205
+ tint?: boolean;
1206
+ color?: string;
1207
+ className?: string;
1208
+ }
1209
+
1210
+ /**
1211
+ * A crosshair splitting the plot into four, with a name for each corner.
1212
+ *
1213
+ * A field of bubbles is usually read as four groups rather than as a cloud —
1214
+ * which of these is doing well on both counts, which on neither — and without
1215
+ * a divider the reader draws that line by eye, in a different place each time.
1216
+ * Putting it on the chart makes it one line everybody sees.
1217
+ *
1218
+ * It stands at the mean of each axis by default, because that is the split the
1219
+ * data itself argues for. Pass `x` and `y` for a target, a budget or last
1220
+ * year's number — a threshold somebody decided rather than one the data
1221
+ * produced.
1222
+ *
1223
+ * The tint marks the two corners a reading usually ends at. Turn it off where
1224
+ * all four corners matter equally.
1225
+ */
1226
+ function BubbleChartQuadrants({
1227
+ x,
1228
+ y,
1229
+ labels,
1230
+ tint = true,
1231
+ color,
1232
+ className,
1233
+ }: BubbleChartQuadrantsProps) {
1234
+ const { bubbles, plot, status, xMin, xMax, yMin, yMax } =
1235
+ useChart('BubbleChart.Quadrants');
1236
+ const token = useCSSVariable('--color-muted-foreground');
1237
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');
1238
+
1239
+ const centre = useMemo(() => {
1240
+ if (!bubbles.length) return null;
1241
+ let sumX = 0;
1242
+ let sumY = 0;
1243
+ for (const bubble of bubbles) {
1244
+ sumX += bubble.x;
1245
+ sumY += bubble.y;
1246
+ }
1247
+ return { x: x ?? sumX / bubbles.length, y: y ?? sumY / bubbles.length };
1248
+ }, [bubbles, x, y]);
1249
+
1250
+ const atX = centre?.x ?? 0;
1251
+ const atY = centre?.y ?? 0;
1252
+
1253
+ const verticalStyle = useAnimatedStyle(() => ({
1254
+ left: xAt(atX, plot, xMin.value, xMax.value),
1255
+ }));
1256
+ const horizontalStyle = useAnimatedStyle(() => ({
1257
+ top: yOf(atY, plot, yMin.value, yMax.value),
1258
+ }));
1259
+ /*
1260
+ * Two rectangles rather than four: the pair that is tinted is the pair the
1261
+ * reader is being pointed at, and shading all four would only be a checked
1262
+ * background.
1263
+ */
1264
+ const highStyle = useAnimatedStyle(() => {
1265
+ const cx = xAt(atX, plot, xMin.value, xMax.value);
1266
+ const cy = yOf(atY, plot, yMin.value, yMax.value);
1267
+ return {
1268
+ left: cx,
1269
+ top: plot.top,
1270
+ width: Math.max(plot.left + plot.width - cx, 0),
1271
+ height: Math.max(cy - plot.top, 0),
1272
+ };
1273
+ });
1274
+ const lowStyle = useAnimatedStyle(() => {
1275
+ const cx = xAt(atX, plot, xMin.value, xMax.value);
1276
+ const cy = yOf(atY, plot, yMin.value, yMax.value);
1277
+ return {
1278
+ left: plot.left,
1279
+ top: cy,
1280
+ width: Math.max(cx - plot.left, 0),
1281
+ height: Math.max(plot.top + plot.height - cy, 0),
1282
+ };
1283
+ });
1284
+
1285
+ if (status === 'loading' || !centre) return null;
1286
+
1287
+ const corner = {
1288
+ position: 'absolute' as const,
1289
+ width: plot.width / 2 - QUADRANT_LABEL_INSET,
1290
+ };
1291
+
1292
+ return (
1293
+ <View
1294
+ pointerEvents="none"
1295
+ style={{ position: 'absolute', inset: 0 }}
1296
+ className={cn(className)}
1297
+ >
1298
+ {tint ? (
1299
+ <>
1300
+ <Animated.View
1301
+ style={[{ position: 'absolute' }, highStyle]}
1302
+ className="bg-foreground/[0.04]"
1303
+ />
1304
+ <Animated.View
1305
+ style={[{ position: 'absolute' }, lowStyle]}
1306
+ className="bg-foreground/[0.04]"
1307
+ />
1308
+ </>
1309
+ ) : null}
1310
+ <Animated.View
1311
+ style={[
1312
+ {
1313
+ position: 'absolute',
1314
+ top: plot.top,
1315
+ height: plot.height,
1316
+ width: 1,
1317
+ backgroundColor: stroke,
1318
+ opacity: 0.4,
1319
+ },
1320
+ verticalStyle,
1321
+ ]}
1322
+ />
1323
+ <Animated.View
1324
+ style={[
1325
+ {
1326
+ position: 'absolute',
1327
+ left: plot.left,
1328
+ width: plot.width,
1329
+ height: 1,
1330
+ backgroundColor: stroke,
1331
+ opacity: 0.4,
1332
+ },
1333
+ horizontalStyle,
1334
+ ]}
1335
+ />
1336
+ {/*
1337
+ Pinned to the plot's corners rather than to the crosshair. A caption
1338
+ names the region, and a region's name belongs at the far end of it —
1339
+ following the rules it would crowd them as the split moved.
1340
+ */}
1341
+ {labels?.topLeft ? (
1342
+ <Text
1343
+ size="xs"
1344
+ muted
1345
+ numberOfLines={1}
1346
+ style={{
1347
+ ...corner,
1348
+ left: plot.left + QUADRANT_LABEL_INSET,
1349
+ top: plot.top + QUADRANT_LABEL_INSET,
1350
+ }}
1351
+ >
1352
+ {labels.topLeft}
1353
+ </Text>
1354
+ ) : null}
1355
+ {labels?.topRight ? (
1356
+ <Text
1357
+ size="xs"
1358
+ muted
1359
+ numberOfLines={1}
1360
+ style={{
1361
+ ...corner,
1362
+ left: plot.left + plot.width / 2,
1363
+ top: plot.top + QUADRANT_LABEL_INSET,
1364
+ textAlign: 'right',
1365
+ }}
1366
+ >
1367
+ {labels.topRight}
1368
+ </Text>
1369
+ ) : null}
1370
+ {labels?.bottomLeft ? (
1371
+ <Text
1372
+ size="xs"
1373
+ muted
1374
+ numberOfLines={1}
1375
+ style={{
1376
+ ...corner,
1377
+ left: plot.left + QUADRANT_LABEL_INSET,
1378
+ top: plot.top + plot.height - QUADRANT_LABEL_INSET - AXIS_LABEL_HEIGHT,
1379
+ }}
1380
+ >
1381
+ {labels.bottomLeft}
1382
+ </Text>
1383
+ ) : null}
1384
+ {labels?.bottomRight ? (
1385
+ <Text
1386
+ size="xs"
1387
+ muted
1388
+ numberOfLines={1}
1389
+ style={{
1390
+ ...corner,
1391
+ left: plot.left + plot.width / 2,
1392
+ top: plot.top + plot.height - QUADRANT_LABEL_INSET - AXIS_LABEL_HEIGHT,
1393
+ textAlign: 'right',
1394
+ }}
1395
+ >
1396
+ {labels.bottomRight}
1397
+ </Text>
1398
+ ) : null}
1399
+ </View>
1400
+ );
1401
+ }
1402
+ BubbleChartQuadrants.displayName = 'BubbleChart.Quadrants';
1403
+ BubbleChartQuadrants.layer = 'overlay' as Layer;
1404
+
1405
+ export interface BubbleChartSizeKeyProps {
1406
+ /** Which corner of the plot it sits in. */
1407
+ placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
1408
+ /** Turn a value into its label. Defaults to a compact number. */
1409
+ format?: (value: number) => string;
1410
+ /** A word for what the area means — "people", "revenue". */
1411
+ label?: string;
1412
+ className?: string;
1413
+ }
1414
+
1415
+ /**
1416
+ * Three nested circles saying what a bubble's area is worth.
1417
+ *
1418
+ * A bubble chart's third quantity is the one it cannot state: position can be
1419
+ * read off the axes, but area has no axis, so a reader can see that one circle
1420
+ * is bigger than another and has no way to know by how much. This is the only
1421
+ * part of the chart that answers that.
1422
+ *
1423
+ * Nested and sharing a baseline, which is how a difference in area is compared
1424
+ * — three circles in a row are three sizes, three circles inside one another
1425
+ * are one scale.
1426
+ *
1427
+ * It needs a `sizeKey` on the chart. Without one every bubble is the same size
1428
+ * and there is no scale to key.
1429
+ */
1430
+ function BubbleChartSizeKey({
1431
+ placement = 'bottom-right',
1432
+ format,
1433
+ label,
1434
+ className,
1435
+ }: BubbleChartSizeKeyProps) {
1436
+ const { plot, status, sizeExtent, sizeRange } = useChart('BubbleChart.SizeKey');
1437
+ const token = useCSSVariable('--color-muted-foreground');
1438
+ const stroke = typeof token === 'string' ? token : 'rgba(128,128,128,0.8)';
1439
+
1440
+ const steps = useMemo(() => {
1441
+ if (!sizeExtent) return null;
1442
+ const [min, max] = sizeExtent;
1443
+ const middle = (min + max) / 2;
1444
+ // Largest first, so the smallest is drawn last and stays visible inside it.
1445
+ return [max, middle, min].map((value) => ({
1446
+ value,
1447
+ r: bubbleRadius(value, sizeExtent, sizeRange),
1448
+ }));
1449
+ }, [sizeExtent, sizeRange]);
1450
+
1451
+ if (status === 'loading' || !steps) return null;
1452
+
1453
+ const outer = steps[0]!.r;
1454
+ const width = outer * 2 + SIZE_KEY_LABEL_WIDTH;
1455
+ const height = outer * 2 + (label ? AXIS_LABEL_HEIGHT : 0);
1456
+ const top = placement.startsWith('top')
1457
+ ? plot.top
1458
+ : plot.top + plot.height - height;
1459
+ const left = placement.endsWith('left')
1460
+ ? plot.left
1461
+ : plot.left + plot.width - width;
1462
+
1463
+ return (
1464
+ <View
1465
+ pointerEvents="none"
1466
+ style={{ position: 'absolute', left, top, width, height }}
1467
+ className={cn(className)}
1468
+ >
1469
+ {label ? (
1470
+ <Text size="xs" muted numberOfLines={1}>
1471
+ {label}
1472
+ </Text>
1473
+ ) : null}
1474
+ <View style={{ height: outer * 2, width }}>
1475
+ {steps.map((step) => (
1476
+ <View
1477
+ key={step.value}
1478
+ style={{
1479
+ position: 'absolute',
1480
+ // Shared bottom edge and shared centre line: the circles nest
1481
+ // rather than stack, so the areas are laid over one another.
1482
+ bottom: 0,
1483
+ left: outer - step.r,
1484
+ width: step.r * 2,
1485
+ height: step.r * 2,
1486
+ borderRadius: step.r,
1487
+ borderWidth: 1,
1488
+ borderColor: stroke,
1489
+ opacity: 0.6,
1490
+ }}
1491
+ />
1492
+ ))}
1493
+ {steps.map((step) => (
1494
+ <Text
1495
+ key={`v${step.value}`}
1496
+ size="xs"
1497
+ muted
1498
+ numberOfLines={1}
1499
+ style={{
1500
+ position: 'absolute',
1501
+ left: outer * 2 + SIZE_KEY_GAP,
1502
+ // Level with the top of the circle it names, which is the only
1503
+ // edge the three do not share.
1504
+ top: outer * 2 - step.r * 2 - AXIS_LABEL_HEIGHT / 2,
1505
+ width: SIZE_KEY_LABEL_WIDTH - SIZE_KEY_GAP,
1506
+ }}
1507
+ >
1508
+ {format ? format(step.value) : compactNumber(step.value)}
1509
+ </Text>
1510
+ ))}
1511
+ </View>
1512
+ </View>
1513
+ );
1514
+ }
1515
+ BubbleChartSizeKey.displayName = 'BubbleChart.SizeKey';
1516
+ BubbleChartSizeKey.layer = 'overlay' as Layer;
1517
+
1518
+ function BubbleLabel({
1519
+ bubble,
1520
+ text,
1521
+ plot,
1522
+ xMin,
1523
+ xMax,
1524
+ yMin,
1525
+ yMax,
1526
+ reveal,
1527
+ order,
1528
+ total,
1529
+ className,
1530
+ }: {
1531
+ bubble: ResolvedBubble;
1532
+ text: string;
1533
+ plot: Plot;
1534
+ xMin: SharedValue<number>;
1535
+ xMax: SharedValue<number>;
1536
+ yMin: SharedValue<number>;
1537
+ yMax: SharedValue<number>;
1538
+ reveal: SharedValue<number>;
1539
+ order: number;
1540
+ total: number;
1541
+ className?: string;
1542
+ }) {
1543
+ const { x, y, r } = bubble;
1544
+ const width = r * 2;
1545
+ const start = total > 1 ? (order / total) * STAGGER : 0;
1546
+
1547
+ const style = useAnimatedStyle(() => {
1548
+ const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
1549
+ return {
1550
+ opacity: Math.max(0, arrived * 2 - 1),
1551
+ transform: [
1552
+ { translateX: xAt(x, plot, xMin.value, xMax.value) - width / 2 },
1553
+ { translateY: yOf(y, plot, yMin.value, yMax.value) - BUBBLE_LABEL_HEIGHT / 2 },
1554
+ ],
1555
+ };
1556
+ });
1557
+
1558
+ return (
1559
+ <Animated.View
1560
+ pointerEvents="none"
1561
+ style={[
1562
+ { position: 'absolute', left: 0, top: 0, width, height: BUBBLE_LABEL_HEIGHT },
1563
+ style,
1564
+ ]}
1565
+ >
1566
+ <Text
1567
+ size="xs"
1568
+ weight="medium"
1569
+ numberOfLines={1}
1570
+ // White on the fill, which is a chart colour in every theme rather than
1571
+ // a surface — so this is the one place a literal is right: the token
1572
+ // that reads on a card would vanish on the bubble.
1573
+ className={cn('text-center text-white', className)}
1574
+ >
1575
+ {text}
1576
+ </Text>
1577
+ </Animated.View>
1578
+ );
1579
+ }
1580
+
1581
+ export interface BubbleChartXAxisProps {
1582
+ /**
1583
+ * How many intervals to divide the axis into. Yields `ticks + 1` labels.
1584
+ *
1585
+ * Four, and the domain is rounded out to four steps to match, so the numbers
1586
+ * come out round. Fewer leaves most of the grid unnamed — a line with nothing
1587
+ * beside it is a line the reader has to count their way to.
1588
+ */
1589
+ ticks?: number;
1590
+ /** Turn a value into its label. Defaults to a compact number. */
1591
+ format?: (value: number) => string;
1592
+ /** What the axis measures, written under the numbers. */
1593
+ label?: string;
1594
+ className?: string;
1595
+ }
1596
+
1597
+ /**
1598
+ * The x labels, evenly along the axis.
1599
+ *
1600
+ * Evenly spaced, because this axis is a continuous scale rather than a list of
1601
+ * rows. There is no bubble for a label to sit under.
1602
+ */
1603
+ function BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXAxisProps) {
1604
+ const { plot, xExtent } = useChart('BubbleChart.XAxis');
1605
+
1606
+ const labels = useMemo(() => {
1607
+ const [min, max] = xExtent;
1608
+ if (min === 0 && max === 0) return [];
1609
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
1610
+ const value = min + ((max - min) * index) / ticks;
1611
+ return { key: index, text: format ? format(value) : compactNumber(value) };
1612
+ });
1613
+ }, [xExtent, ticks, format]);
1614
+
1615
+ return (
1616
+ <View
1617
+ style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
1618
+ className={cn(className)}
1619
+ >
1620
+ {label ? (
1621
+ <Text
1622
+ size="xs"
1623
+ muted
1624
+ numberOfLines={1}
1625
+ style={{
1626
+ position: 'absolute',
1627
+ bottom: 0,
1628
+ left: plot.left,
1629
+ width: plot.width,
1630
+ textAlign: 'center',
1631
+ }}
1632
+ >
1633
+ {label}
1634
+ </Text>
1635
+ ) : null}
1636
+ {labels.map((tick) => (
1637
+ <Text
1638
+ key={tick.key}
1639
+ size="xs"
1640
+ muted
1641
+ numberOfLines={1}
1642
+ style={{
1643
+ position: 'absolute',
1644
+ bottom: label ? AXIS_TITLE_HEIGHT : 0,
1645
+ // Centred on its tick, then held inside the chart. The first and
1646
+ // last ticks sit on the plot's own edges, so a box centred on them
1647
+ // hangs half its width off the side — the clamp slides those two
1648
+ // back in rather than letting the numbers leave the frame.
1649
+ left: Math.max(
1650
+ 0,
1651
+ Math.min(
1652
+ plot.left + (plot.width / ticks) * tick.key - AXIS_LABEL_WIDTH / 2,
1653
+ plot.left + plot.width + PADDING.right - AXIS_LABEL_WIDTH
1654
+ )
1655
+ ),
1656
+ width: AXIS_LABEL_WIDTH,
1657
+ textAlign: 'center',
1658
+ }}
1659
+ >
1660
+ {tick.text}
1661
+ </Text>
1662
+ ))}
1663
+ </View>
1664
+ );
1665
+ }
1666
+ BubbleChartXAxis.displayName = 'BubbleChart.XAxis';
1667
+ BubbleChartXAxis.layer = 'overlay' as Layer;
1668
+ // Read by the root, which has to leave room under the numbers before it lays
1669
+ // the plot out.
1670
+ BubbleChartXAxis.axis = 'x' as const;
1671
+
1672
+ export interface BubbleChartYAxisProps {
1673
+ /**
1674
+ * How many intervals to divide the axis into. Yields `ticks + 1` labels.
1675
+ *
1676
+ * Four, matching the four steps the domain is rounded out to and every second
1677
+ * line of the default grid.
1678
+ */
1679
+ ticks?: number;
1680
+ /** Turn a value into its label. Defaults to a compact number. */
1681
+ format?: (value: number) => string;
1682
+ /** What the axis measures, written up the side of it. */
1683
+ label?: string;
1684
+ className?: string;
1685
+ }
1686
+
1687
+ /**
1688
+ * Value labels down the side, evenly over the axis, and the gutter they sit in.
1689
+ *
1690
+ * They land on every second line of the default grid rather than on all of
1691
+ * them: a number beside every line of a grid fine enough to read against is a
1692
+ * column of numbers, and the reader stops seeing the chart.
1693
+ */
1694
+ function BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYAxisProps) {
1695
+ const { plot, yExtent } = useChart('BubbleChart.YAxis');
1696
+
1697
+ const labels = useMemo(() => {
1698
+ const [min, max] = yExtent;
1699
+ if (min === 0 && max === 0) return [];
1700
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
1701
+ const value = max - ((max - min) * index) / ticks;
1702
+ return { key: index, text: format ? format(value) : compactNumber(value) };
1703
+ });
1704
+ }, [yExtent, ticks, format]);
1705
+
1706
+ const titleWidth = label ? AXIS_TITLE_WIDTH : 0;
1707
+
1708
+ return (
1709
+ <>
1710
+ {label ? (
1711
+ /*
1712
+ * Turned on its side, which is the only way a word fits in a gutter
1713
+ * sized for numbers. The box is laid out as tall as the plot and then
1714
+ * rotated about its own centre, so the text runs the length of the axis
1715
+ * it names rather than of whatever it happens to say.
1716
+ */
1717
+ <View
1718
+ pointerEvents="none"
1719
+ style={{
1720
+ position: 'absolute',
1721
+ left: titleWidth / 2 - plot.height / 2,
1722
+ top: plot.top + plot.height / 2 - AXIS_LABEL_HEIGHT / 2,
1723
+ width: plot.height,
1724
+ height: AXIS_LABEL_HEIGHT,
1725
+ transform: [{ rotate: '-90deg' }],
1726
+ }}
1727
+ >
1728
+ <Text size="xs" muted numberOfLines={1} style={{ textAlign: 'center' }}>
1729
+ {label}
1730
+ </Text>
1731
+ </View>
1732
+ ) : null}
1733
+ <View
1734
+ pointerEvents="none"
1735
+ style={{
1736
+ position: 'absolute',
1737
+ left: titleWidth,
1738
+ // Centred on the grid line each label names: the strip is lifted half
1739
+ // a label and grown by a whole one, so `justify-between` lands the
1740
+ // text's middle on the line rather than its top edge on the first.
1741
+ top: plot.top - AXIS_LABEL_HEIGHT / 2,
1742
+ height: plot.height + AXIS_LABEL_HEIGHT,
1743
+ width: Math.max(plot.left - Y_AXIS_GUTTER - titleWidth, 0),
1744
+ }}
1745
+ className={cn('items-end justify-between', className)}
1746
+ >
1747
+ {labels.map((tick) => (
1748
+ <Text key={tick.key} size="xs" muted numberOfLines={1}>
1749
+ {tick.text}
1750
+ </Text>
1751
+ ))}
1752
+ </View>
1753
+ </>
1754
+ );
1755
+ }
1756
+ BubbleChartYAxis.displayName = 'BubbleChart.YAxis';
1757
+ BubbleChartYAxis.layer = 'overlay' as Layer;
1758
+ // Read by the root, which has to leave room for the labels before it lays the
1759
+ // plot out.
1760
+ BubbleChartYAxis.axis = 'y' as const;
1761
+
1762
+ export interface BubbleChartTooltipProps {
1763
+ /** Float a small readout beside the selected bubble. On by default. */
1764
+ showLabel?: boolean;
1765
+ /** Format the x value for the readout. Defaults to a compact number. */
1766
+ formatX?: (value: number) => string;
1767
+ /** Format the y value for the readout. Defaults to a compact number. */
1768
+ formatY?: (value: number) => string;
1769
+ /** Format the size value for the readout. Defaults to a compact number. */
1770
+ formatSize?: (value: number) => string;
1771
+ /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */
1772
+ hitRadius?: number;
1773
+ className?: string;
1774
+ }
1775
+
1776
+ /**
1777
+ * The touch target, the selection it drives, and the readout that follows it.
1778
+ *
1779
+ * A touch picks the nearest bubble whose own circle — or the `hitRadius` floor,
1780
+ * whichever is larger — reaches the finger. Nearest rather than topmost,
1781
+ * because where bubbles overlap the one drawn last is not the one being aimed
1782
+ * at.
1783
+ *
1784
+ * The search runs on the UI thread over flat arrays of already-projected
1785
+ * coordinates, and only the *index* of the winner crosses back into JS, and
1786
+ * only when it changes. A drag across the plot therefore costs a handful of
1787
+ * re-renders rather than one per frame.
1788
+ *
1789
+ * Distances are compared squared. The nearest bubble by distance is the nearest
1790
+ * by distance-squared, and a square root per bubble per frame buys nothing.
1791
+ */
1792
+ function BubbleChartTooltip({
1793
+ showLabel = true,
1794
+ formatX,
1795
+ formatY,
1796
+ formatSize,
1797
+ hitRadius = HIT_RADIUS,
1798
+ className,
1799
+ }: BubbleChartTooltipProps) {
1800
+ const {
1801
+ bubbles,
1802
+ plot,
1803
+ xExtent,
1804
+ yExtent,
1805
+ activeIndex,
1806
+ activeIndexJS,
1807
+ setActivePoint,
1808
+ status,
1809
+ } = useChart('BubbleChart.Tooltip');
1810
+
1811
+ /*
1812
+ * Every bubble, projected once, as parallel arrays.
1813
+ *
1814
+ * Parallel arrays rather than an array of objects because this is read inside
1815
+ * a worklet: Reanimated has to copy whatever the gesture captures across to
1816
+ * the UI thread, and four number arrays cross far more cheaply than a few
1817
+ * hundred small objects.
1818
+ *
1819
+ * Projected against the *settled* extents rather than the tweening shared
1820
+ * values. Hit-testing against a moving scale would mean rebuilding this on
1821
+ * every frame of a domain animation, and a bubble being half a second stale
1822
+ * during a transition is not something a finger can notice.
1823
+ */
1824
+ const hit = useMemo(() => {
1825
+ const xs: number[] = [];
1826
+ const ys: number[] = [];
1827
+ const rs: number[] = [];
1828
+ const limits: number[] = [];
1829
+ const indices: number[] = [];
1830
+ for (const bubble of bubbles) {
1831
+ xs.push(xAt(bubble.x, plot, xExtent[0], xExtent[1]));
1832
+ ys.push(yOf(bubble.y, plot, yExtent[0], yExtent[1]));
1833
+ rs.push(bubble.r);
1834
+ const reach = Math.max(bubble.r, hitRadius);
1835
+ limits.push(reach * reach);
1836
+ indices.push(bubble.index);
1837
+ }
1838
+ return { xs, ys, rs, limits, indices };
1839
+ }, [bubbles, plot, xExtent, yExtent, hitRadius]);
1840
+
1841
+ const select = useMemo(
1842
+ () => (index: number) => {
1843
+ if (index < 0) {
1844
+ setActivePoint(null);
1845
+ return;
1846
+ }
1847
+ setActivePoint(bubbles.find((bubble) => bubble.index === index) ?? null);
1848
+ },
1849
+ [bubbles, setActivePoint]
1850
+ );
1851
+
1852
+ /*
1853
+ * Built in one closure, and everything it captures is a plain array, a number
1854
+ * or a shared value. A worklet may only call another worklet, and the rule is
1855
+ * enforced by crashing rather than by warning — so the resolver is declared
1856
+ * here, next to its callers, rather than as a helper elsewhere in the file
1857
+ * where it would be easy to leave un-workletised.
1858
+ */
1859
+ const pan = useMemo(() => {
1860
+ const xs = hit.xs;
1861
+ const ys = hit.ys;
1862
+ const limits = hit.limits;
1863
+ const indices = hit.indices;
1864
+
1865
+ const resolve = (px: number, py: number) => {
1866
+ 'worklet';
1867
+ let bestIndex = -1;
1868
+ let best = Infinity;
1869
+ for (let i = 0; i < xs.length; i += 1) {
1870
+ const dx = xs[i]! - px;
1871
+ const dy = ys[i]! - py;
1872
+ const distance = dx * dx + dy * dy;
1873
+ // Each bubble reaches as far as it is big, so a large one is not
1874
+ // beaten by a small one that happens to be marginally nearer.
1875
+ if (distance <= limits[i]! && distance < best) {
1876
+ best = distance;
1877
+ bestIndex = indices[i]!;
1878
+ }
1879
+ }
1880
+ if (bestIndex === activeIndex.value) return;
1881
+ activeIndex.value = bestIndex;
1882
+ runOnJS(select)(bestIndex);
1883
+ };
1884
+
1885
+ const clear = () => {
1886
+ 'worklet';
1887
+ if (activeIndex.value === -1) return;
1888
+ activeIndex.value = -1;
1889
+ runOnJS(select)(-1);
1890
+ };
1891
+
1892
+ return Gesture.Pan()
1893
+ .minDistance(0)
1894
+ .onBegin((event) => {
1895
+ 'worklet';
1896
+ resolve(event.x, event.y);
1897
+ })
1898
+ .onUpdate((event) => {
1899
+ 'worklet';
1900
+ resolve(event.x, event.y);
1901
+ })
1902
+ .onFinalize(() => {
1903
+ 'worklet';
1904
+ clear();
1905
+ });
1906
+ }, [hit, activeIndex, select]);
1907
+
1908
+ /*
1909
+ * The readout's own height, measured rather than assumed. It decides whether
1910
+ * the readout fits above the bubble, and how tall it is depends on whether
1911
+ * the row has a label and a size — a constant would either overlap a
1912
+ * three-line readout or reserve room a one-line one never uses.
1913
+ */
1914
+ const labelHeight = useSharedValue(0);
1915
+
1916
+ /*
1917
+ * Above the bubble, clear of its edge rather than of its centre, and clamped
1918
+ * inside the plot. Lifted by a constant it landed *on* the larger circles —
1919
+ * which are exactly the ones a finger is most likely to be resting on, so the
1920
+ * readout was hidden under the hand that summoned it.
1921
+ *
1922
+ * Where there is no room above, it goes below instead. Sliding it down to the
1923
+ * top edge of the plot would leave it over the bubble again.
1924
+ */
1925
+ const labelStyle = useAnimatedStyle(() => {
1926
+ const index = activeIndex.value;
1927
+ if (index < 0) return { opacity: 0 };
1928
+ const at = hit.indices.indexOf(index);
1929
+ if (at < 0) return { opacity: 0 };
1930
+ const x = hit.xs[at]!;
1931
+ const y = hit.ys[at]!;
1932
+ const r = hit.rs[at]!;
1933
+ const half = LABEL_WIDTH / 2;
1934
+ const tall = labelHeight.value;
1935
+
1936
+ const above = y - r - LABEL_GAP - tall;
1937
+ const below = y + r + LABEL_GAP;
1938
+ const top = above >= plot.top ? above : below;
1939
+
1940
+ return {
1941
+ opacity: 1,
1942
+ transform: [
1943
+ {
1944
+ translateX:
1945
+ Math.min(plot.left + plot.width - half, Math.max(plot.left + half, x)) - half,
1946
+ },
1947
+ { translateY: top },
1948
+ ],
1949
+ };
1950
+ });
1951
+
1952
+ const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;
1953
+ const fmtX = formatX ?? compactNumber;
1954
+ const fmtY = formatY ?? compactNumber;
1955
+ const fmtSize = formatSize ?? compactNumber;
1956
+
1957
+ if (status === 'loading') return null;
1958
+
1959
+ return (
1960
+ <GestureDetector gesture={pan}>
1961
+ <View style={StyleSheet.absoluteFill}>
1962
+ {showLabel ? (
1963
+ <Animated.View
1964
+ pointerEvents="none"
1965
+ style={[
1966
+ { position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH },
1967
+ labelStyle,
1968
+ ]}
1969
+ >
1970
+ {active ? (
1971
+ <View
1972
+ onLayout={(event) => {
1973
+ labelHeight.value = event.nativeEvent.layout.height;
1974
+ }}
1975
+ className={cn(
1976
+ 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1977
+ className
1978
+ )}
1979
+ >
1980
+ {active.label ? (
1981
+ <View className="flex-row items-center gap-1.5">
1982
+ <View
1983
+ style={{
1984
+ width: 6,
1985
+ height: 6,
1986
+ borderRadius: 3,
1987
+ backgroundColor: active.color,
1988
+ }}
1989
+ />
1990
+ <Text size="xs" weight="medium" numberOfLines={1}>
1991
+ {active.label}
1992
+ </Text>
1993
+ </View>
1994
+ ) : null}
1995
+ <Text size="xs" muted numberOfLines={1}>
1996
+ {fmtX(active.x)}, {fmtY(active.y)}
1997
+ </Text>
1998
+ {active.size !== null ? (
1999
+ <Text size="xs" muted numberOfLines={1}>
2000
+ {fmtSize(active.size)}
2001
+ </Text>
2002
+ ) : null}
2003
+ </View>
2004
+ ) : null}
2005
+ </Animated.View>
2006
+ ) : null}
2007
+ </View>
2008
+ </GestureDetector>
2009
+ );
2010
+ }
2011
+ BubbleChartTooltip.displayName = 'BubbleChart.Tooltip';
2012
+ BubbleChartTooltip.layer = 'overlay' as Layer;
2013
+
2014
+ export interface BubbleChartLegendProps extends ViewProps {
2015
+ className?: string;
2016
+ /** Cap on how many bubbles are named. The rest are left to the readout. */
2017
+ limit?: number;
2018
+ }
2019
+
2020
+ /**
2021
+ * A swatch and a name per bubble, for a chart whose circles are too small to
2022
+ * carry their own labels.
2023
+ *
2024
+ * Drawn **under** the plot rather than floating in a corner of it. A key that
2025
+ * overlays the drawing area competes with the bubbles for the space they are
2026
+ * plotted in, and on a square chart there is no corner that is reliably empty —
2027
+ * the position of a bubble is the data, so nowhere can be reserved for it.
2028
+ *
2029
+ * It lists rows rather than series, because in this chart a row *is* a
2030
+ * category. Use it instead of `BubbleChart.Labels`, not beside it — the same
2031
+ * names twice is the legend telling the reader what the plot already says.
2032
+ */
2033
+ function BubbleChartLegend({ className, limit = 8, ...props }: BubbleChartLegendProps) {
2034
+ const { bubbles } = useChart('BubbleChart.Legend');
2035
+ const shown = bubbles.filter((bubble) => bubble.label).slice(0, limit);
2036
+ if (!shown.length) return null;
2037
+
2038
+ return (
2039
+ <View
2040
+ {...props}
2041
+ style={[{ pointerEvents: 'none' }, props.style]}
2042
+ className={cn(
2043
+ 'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1 pt-3',
2044
+ className
2045
+ )}
2046
+ >
2047
+ {shown.map((bubble) => (
2048
+ <View key={bubble.index} className="flex-row items-center gap-1.5">
2049
+ <View
2050
+ style={{ backgroundColor: bubble.color }}
2051
+ className="h-2 w-2 rounded-full"
2052
+ />
2053
+ <Text size="xs" muted>
2054
+ {bubble.label}
2055
+ </Text>
2056
+ </View>
2057
+ ))}
2058
+ </View>
2059
+ );
2060
+ }
2061
+ BubbleChartLegend.displayName = 'BubbleChart.Legend';
2062
+ BubbleChartLegend.layer = 'footer' as Layer;
2063
+
2064
+ /* -------------------------------------------------------------------------- */
2065
+ /* Header layer */
2066
+ /* -------------------------------------------------------------------------- */
2067
+
2068
+ export interface BubbleChartHeaderProps extends ViewProps {
2069
+ className?: string;
2070
+ /** Small line above the value — what the chart is of. */
2071
+ title?: string;
2072
+ /** The readout. The largest thing on the card, and the first thing read. */
2073
+ value?: string;
2074
+ /** One muted line under the value — what the area means, usually. */
2075
+ caption?: string;
2076
+ /** Trailing slot — a control, a badge, a range picker. */
2077
+ children?: ReactNode;
2078
+ }
2079
+
2080
+ /**
2081
+ * The strip above the plot: what the chart is of, what it currently reads, and
2082
+ * what the size of a circle means.
2083
+ *
2084
+ * The caption earns its place here more than on most charts. Two axes and an
2085
+ * area is three quantities, and a reader who is not told what the area is has
2086
+ * no way to work it out from the picture.
2087
+ *
2088
+ * The value is not derived here. A readout that follows the finger belongs to
2089
+ * whoever owns the data — take it from `onActivePointChange` and pass the
2090
+ * formatted string down.
2091
+ */
2092
+ function BubbleChartHeader({
2093
+ className,
2094
+ title,
2095
+ value,
2096
+ caption,
2097
+ children,
2098
+ ...props
2099
+ }: BubbleChartHeaderProps) {
2100
+ return (
2101
+ <View
2102
+ {...props}
2103
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
2104
+ >
2105
+ <View className="flex-1 gap-0.5">
2106
+ {title ? (
2107
+ <Text size="xs" muted>
2108
+ {title}
2109
+ </Text>
2110
+ ) : null}
2111
+ {value ? (
2112
+ <Text size="xl" weight="bold">
2113
+ {value}
2114
+ </Text>
2115
+ ) : null}
2116
+ {caption ? (
2117
+ <Text size="xs" muted>
2118
+ {caption}
2119
+ </Text>
2120
+ ) : null}
2121
+ </View>
2122
+ {/* Shrinkable, unlike a view's default in React Native. Held rigid, a
2123
+ control takes the width it wants and the caption underneath the value
2124
+ wraps to two lines to make room for it. */}
2125
+ {children ? <View className="shrink pt-1">{children}</View> : null}
2126
+ </View>
2127
+ );
2128
+ }
2129
+ BubbleChartHeader.displayName = 'BubbleChart.Header';
2130
+ BubbleChartHeader.layer = 'header' as Layer;
2131
+
2132
+ export const BubbleChart = Object.assign(BubbleChartRoot, {
2133
+ Header: BubbleChartHeader,
2134
+ Grid: BubbleChartGrid,
2135
+ Quadrants: BubbleChartQuadrants,
2136
+ Trend: BubbleChartTrend,
2137
+ Bubbles: BubbleChartBubbles,
2138
+ Labels: BubbleChartLabels,
2139
+ SizeKey: BubbleChartSizeKey,
2140
+ Skeleton: BubbleChartSkeleton,
2141
+ XAxis: BubbleChartXAxis,
2142
+ YAxis: BubbleChartYAxis,
2143
+ Tooltip: BubbleChartTooltip,
2144
+ Legend: BubbleChartLegend,
2145
+ });