panelui-native 0.44.0 → 0.49.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 (108) hide show
  1. package/README.md +6 -1
  2. package/lib/module/components/accordion/index.js +32 -4
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +83 -15
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/button-group/index.js +186 -0
  7. package/lib/module/components/button-group/index.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +110 -1
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/combobox/index.js +7 -1
  11. package/lib/module/components/combobox/index.js.map +1 -1
  12. package/lib/module/components/date-time-picker/index.js +272 -0
  13. package/lib/module/components/date-time-picker/index.js.map +1 -0
  14. package/lib/module/components/fab/index.js +514 -0
  15. package/lib/module/components/fab/index.js.map +1 -0
  16. package/lib/module/components/grid-item/index.js +486 -0
  17. package/lib/module/components/grid-item/index.js.map +1 -0
  18. package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
  19. package/lib/module/components/kpi/index.js.map +1 -0
  20. package/lib/module/components/line-chart/index.js +92 -10
  21. package/lib/module/components/line-chart/index.js.map +1 -1
  22. package/lib/module/components/markdown-editor/index.js +406 -0
  23. package/lib/module/components/markdown-editor/index.js.map +1 -0
  24. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  25. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  26. package/lib/module/components/pie-chart/index.js +625 -0
  27. package/lib/module/components/pie-chart/index.js.map +1 -0
  28. package/lib/module/components/questionnaire/index.js +1312 -0
  29. package/lib/module/components/questionnaire/index.js.map +1 -0
  30. package/lib/module/components/scatter-chart/index.js +1173 -0
  31. package/lib/module/components/scatter-chart/index.js.map +1 -0
  32. package/lib/module/components/tabs/index.js +359 -41
  33. package/lib/module/components/tabs/index.js.map +1 -1
  34. package/lib/module/components/time-picker/index.js +34 -6
  35. package/lib/module/components/time-picker/index.js.map +1 -1
  36. package/lib/module/components/tree/index.js +500 -0
  37. package/lib/module/components/tree/index.js.map +1 -0
  38. package/lib/module/icons/index.js +217 -0
  39. package/lib/module/icons/index.js.map +1 -1
  40. package/lib/module/index.js +11 -2
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/utils/chart.js +81 -0
  43. package/lib/module/utils/chart.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/button/index.d.ts +21 -0
  47. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  49. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  51. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
  54. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  56. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
  58. package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
  60. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
  62. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  64. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  66. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  67. package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
  68. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  70. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
  72. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/tabs/index.d.ts +29 -2
  74. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  76. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  78. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  79. package/lib/typescript/src/icons/index.d.ts +22 -0
  80. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  81. package/lib/typescript/src/index.d.ts +14 -5
  82. package/lib/typescript/src/index.d.ts.map +1 -1
  83. package/lib/typescript/src/utils/chart.d.ts +34 -0
  84. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/components/accordion/index.tsx +48 -6
  87. package/src/components/button/index.tsx +97 -15
  88. package/src/components/button-group/index.tsx +199 -0
  89. package/src/components/color-picker/index.tsx +140 -3
  90. package/src/components/combobox/index.tsx +7 -1
  91. package/src/components/date-time-picker/index.tsx +411 -0
  92. package/src/components/fab/index.tsx +583 -0
  93. package/src/components/grid-item/index.tsx +515 -0
  94. package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
  95. package/src/components/line-chart/index.tsx +98 -8
  96. package/src/components/markdown-editor/index.tsx +526 -0
  97. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  98. package/src/components/pie-chart/index.tsx +863 -0
  99. package/src/components/questionnaire/index.tsx +1615 -0
  100. package/src/components/scatter-chart/index.tsx +1401 -0
  101. package/src/components/tabs/index.tsx +392 -50
  102. package/src/components/time-picker/index.tsx +42 -6
  103. package/src/components/tree/index.tsx +564 -0
  104. package/src/icons/index.tsx +154 -0
  105. package/src/index.ts +142 -18
  106. package/src/utils/chart.ts +110 -0
  107. package/lib/module/components/kpi-chart/index.js.map +0 -1
  108. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +0 -1
@@ -0,0 +1,1401 @@
1
+ /**
2
+ * ScatterChart — two quantities against each other, drawn on the UI thread.
3
+ *
4
+ * Every other chart in this library spaces its points evenly along the x-axis,
5
+ * because their x is a position: twelve months are twelve equal steps whatever
6
+ * the gaps between the dates behind them. A scatter plot is the one shape where
7
+ * that is wrong. Both coordinates are *measured*, and the reader is being asked
8
+ * to look for a relationship between them — spread the points evenly and the
9
+ * relationship is the one thing you have thrown away.
10
+ *
11
+ * So this chart carries an x-domain as well as a y-domain, and both are tweened
12
+ * when the data changes.
13
+ *
14
+ * ```tsx
15
+ * <ScatterChart data={sessions} xDataKey="spend">
16
+ * <ScatterChart.Grid />
17
+ * <ScatterChart.Points dataKey="revenue" />
18
+ * <ScatterChart.XAxis />
19
+ * <ScatterChart.YAxis />
20
+ * <ScatterChart.Tooltip />
21
+ * </ScatterChart>
22
+ * ```
23
+ *
24
+ * As elsewhere, there are two layers and the parts sort themselves into the
25
+ * right one: the geometry is SVG, and anything with text or a gesture on it is
26
+ * a React Native view over the top. SVG text ignores the platform's text
27
+ * scaling and the theme's font, and a gesture handler cannot be attached to an
28
+ * SVG node at all.
29
+ *
30
+ * **Finding a point.** A crosshair that snaps to an x index — the way a line
31
+ * chart's does — has nothing to snap to here, because there is no shared x and
32
+ * two points can sit at the same one. Instead the nearest point to the finger
33
+ * is resolved by distance, on the UI thread, and only within a radius: a touch
34
+ * in an empty corner of the plot selects nothing rather than lighting up
35
+ * whichever point happens to be least far away. The radius is generous, because
36
+ * the points are a few pixels across and a fingertip is not.
37
+ *
38
+ * Colours come from the `--color-chart-*` tokens, so a chart follows the active
39
+ * theme. Nothing here hardcodes a hex.
40
+ */
41
+ import {
42
+ Children,
43
+ createContext,
44
+ forwardRef,
45
+ isValidElement,
46
+ useContext,
47
+ useEffect,
48
+ useImperativeHandle,
49
+ useMemo,
50
+ useRef,
51
+ useState,
52
+ type ReactNode,
53
+ } from 'react';
54
+ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
55
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
56
+ import Animated, {
57
+ Easing,
58
+ runOnJS,
59
+ useAnimatedProps,
60
+ useAnimatedStyle,
61
+ useDerivedValue,
62
+ useReducedMotion,
63
+ useSharedValue,
64
+ withTiming,
65
+ type SharedValue,
66
+ } from 'react-native-reanimated';
67
+ import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';
68
+ import { useCSSVariable } from 'uniwind';
69
+ import { Text } from '../../primitives/text';
70
+ import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart';
71
+ import { cn } from '../../utils/cn';
72
+
73
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
74
+ const AnimatedG = Animated.createAnimatedComponent(G);
75
+
76
+ /**
77
+ * How much of the reveal is spent handing out the points' start times. The rest
78
+ * is the window each one gets, so the whole field still lands inside the one
79
+ * duration however many points there are.
80
+ */
81
+ const STAGGER = 0.4;
82
+
83
+ /** Milliseconds for the placeholder field to dissolve once the data arrives. */
84
+ const SKELETON_FADE = 220;
85
+
86
+ /** Milliseconds for a point to swell as it is selected, and settle as it is not. */
87
+ const SELECT_DURATION = 140;
88
+
89
+ /**
90
+ * A point arriving: up past its size and back to it.
91
+ *
92
+ * A dot that simply grows to its radius reads as the chart still loading right
93
+ * up to the last frame. The small overshoot is what makes it read as landing.
94
+ */
95
+ function landing(t: number): number {
96
+ 'worklet';
97
+ const back = 1.3;
98
+ const u = t - 1;
99
+ return 1 + (back + 1) * u * u * u + back * u * u;
100
+ }
101
+
102
+ /** Room left around the plot for the axis labels and the outermost dots. */
103
+ const PADDING = { top: 14, right: 14, bottom: 22, left: 14 };
104
+
105
+ /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
106
+ const Y_AXIS_WIDTH = 44;
107
+
108
+ /** Gap between the value labels and the plot they sit beside. */
109
+ const Y_AXIS_GUTTER = 6;
110
+
111
+ /** Line height of an `xs` label, for centring one on the grid line it names. */
112
+ const AXIS_LABEL_HEIGHT = 16;
113
+
114
+ /** Box each x label is centred in, so a long number is ellipsised not shoved. */
115
+ const POINT_LABEL_WIDTH = 56;
116
+
117
+ /** Width of the readout that floats by the selected point. */
118
+ const LABEL_WIDTH = 132;
119
+
120
+ /** How far the readout is lifted, to clear the point it describes. */
121
+ const LABEL_HEIGHT = 52;
122
+
123
+ /**
124
+ * How far from a point a touch still counts as being on it, in points.
125
+ *
126
+ * Sized for a fingertip rather than for the dot. Apple and Android both put the
127
+ * minimum comfortable target at around 44pt, and a scatter point is nearer 7 —
128
+ * without a hit radius the chart is only usable with a mouse it will never see.
129
+ */
130
+ const HIT_RADIUS = 32;
131
+
132
+ type Layer = 'svg' | 'overlay' | 'header';
133
+
134
+ export type ScatterChartStatus = 'loading' | 'ready';
135
+ export type ScatterChartDatum = Record<string, string | number | null | undefined>;
136
+
137
+ /** One plotted point, resolved back to the row it came from. */
138
+ export interface ScatterChartPoint {
139
+ /** Index into `data`. */
140
+ index: number;
141
+ /** The series key this point belongs to. */
142
+ dataKey: string;
143
+ x: number;
144
+ y: number;
145
+ datum: ScatterChartDatum;
146
+ }
147
+
148
+ interface ScatterChartContextValue {
149
+ data: ScatterChartDatum[];
150
+ xDataKey: string;
151
+ plot: Plot;
152
+ status: ScatterChartStatus;
153
+ series: [string, string][];
154
+ registerSeries: (key: string, color: string) => void;
155
+ unregisterSeries: (key: string) => void;
156
+ /** Tweened domains. Read inside worklets to place the points. */
157
+ xMin: SharedValue<number>;
158
+ xMax: SharedValue<number>;
159
+ yMin: SharedValue<number>;
160
+ yMax: SharedValue<number>;
161
+ /** The domains the tweens are heading for, for the axis labels. */
162
+ xExtent: [number, number];
163
+ yExtent: [number, number];
164
+ /** The selected point, as `"<dataKey>:<index>"`, or `''` for none. */
165
+ activeId: SharedValue<string>;
166
+ activePoint: ScatterChartPoint | null;
167
+ setActivePoint: (point: ScatterChartPoint | null) => void;
168
+ /** 0 to 1 as the field arrives. Each point reads its own slice of it. */
169
+ reveal: SharedValue<number>;
170
+ }
171
+
172
+ const ScatterChartContext = createContext<ScatterChartContextValue | null>(null);
173
+
174
+ function useChart(component: string): ScatterChartContextValue {
175
+ const context = useContext(ScatterChartContext);
176
+ if (!context) {
177
+ throw new Error(`${component} must be used within a <ScatterChart>`);
178
+ }
179
+ return context;
180
+ }
181
+
182
+ /**
183
+ * The selected point, for something rendered *inside* the chart.
184
+ *
185
+ * A readout usually belongs in the card's header, which is outside this
186
+ * provider — use `onActivePointChange` for that. A hook cannot reach up out of
187
+ * the subtree it is called in.
188
+ */
189
+ export function useScatterChart() {
190
+ const { activePoint, xDataKey } = useChart('useScatterChart');
191
+ return { activePoint, xDataKey };
192
+ }
193
+
194
+ export interface ScatterChartProps extends ViewProps {
195
+ className?: string;
196
+ /** The rows. Each one is a point, placed by two of its values. */
197
+ data: ScatterChartDatum[];
198
+ /** Key holding the x value. Unlike the other charts, this must be a number. */
199
+ xDataKey?: string;
200
+ /**
201
+ * `loading` draws a still field of muted dots and settles into the real ones
202
+ * when it turns `ready`. One component throughout, rather than a spinner
203
+ * swapped for a chart — swapping loses the transition.
204
+ */
205
+ status?: ScatterChartStatus;
206
+ /** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */
207
+ aspectRatio?: number;
208
+ /** Milliseconds for the reveal on mount. */
209
+ animationDuration?: number;
210
+ /** Milliseconds for the axes to settle after the data changes. */
211
+ domainDuration?: number;
212
+ /** Fix the x-axis instead of deriving it from the data. */
213
+ xDomain?: [number, number];
214
+ /** Fix the y-axis instead of deriving it from the data. */
215
+ yDomain?: [number, number];
216
+ /**
217
+ * The point under the finger, and `null` when it lifts. This is how a readout
218
+ * in the card's header gets its value — that header is outside the chart, so
219
+ * it cannot use `useScatterChart`.
220
+ *
221
+ * Fires when the selection changes, not per frame.
222
+ */
223
+ onActivePointChange?: (point: ScatterChartPoint | null) => void;
224
+ /** Drop the axis padding so the field reaches the edges, for a thumbnail. */
225
+ compact?: boolean;
226
+ children?: ReactNode;
227
+ }
228
+
229
+ /** Imperative handle: re-run the reveal on demand, for a "replay" control. */
230
+ export interface ScatterChartHandle {
231
+ replay: () => void;
232
+ }
233
+
234
+ const ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps>(
235
+ function ScatterChartRoot(
236
+ {
237
+ className,
238
+ data,
239
+ xDataKey = 'x',
240
+ status = 'ready',
241
+ aspectRatio = 1,
242
+ animationDuration = 900,
243
+ domainDuration = 500,
244
+ xDomain,
245
+ yDomain,
246
+ onActivePointChange,
247
+ compact = false,
248
+ children,
249
+ ...props
250
+ },
251
+ ref
252
+ ) {
253
+ const [size, setSize] = useState({ width: 0, height: 0 });
254
+ const [series, setSeries] = useState<[string, string][]>([]);
255
+ const [activePoint, setActivePointState] = useState<ScatterChartPoint | null>(null);
256
+
257
+ const reveal = useSharedValue(0);
258
+ const xMin = useSharedValue(0);
259
+ const xMax = useSharedValue(0);
260
+ const yMin = useSharedValue(0);
261
+ const yMax = useSharedValue(0);
262
+ const activeId = useSharedValue('');
263
+ const reducedMotion = useReducedMotion();
264
+
265
+ const registerSeries = useMemo(
266
+ () => (key: string, color: string) =>
267
+ setSeries((current) => {
268
+ const existing = current.find(([k]) => k === key);
269
+ if (existing?.[1] === color) return current;
270
+ return [...current.filter(([k]) => k !== key), [key, color]];
271
+ }),
272
+ []
273
+ );
274
+
275
+ const unregisterSeries = useMemo(
276
+ () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
277
+ []
278
+ );
279
+
280
+ const hasYAxis = useMemo(() => {
281
+ let found = false;
282
+ Children.forEach(children, (child) => {
283
+ if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
284
+ found = true;
285
+ }
286
+ });
287
+ return found;
288
+ }, [children]);
289
+
290
+ const pad = compact
291
+ ? { top: 4, right: 4, bottom: 4, left: 4 }
292
+ : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
293
+ const plot: Plot = {
294
+ left: pad.left,
295
+ top: pad.top,
296
+ width: Math.max(size.width - pad.left - pad.right, 0),
297
+ height: Math.max(size.height - pad.top - pad.bottom, 0),
298
+ };
299
+
300
+ const seriesKeys = series.map(([key]) => key).join('|');
301
+
302
+ /*
303
+ * Both extents in one pass. A scatter plot's x is a measured quantity, so
304
+ * unlike the rest of the family it needs a domain of its own rather than a
305
+ * count of positions.
306
+ *
307
+ * Neither axis is floored at zero. An area chart is floored because a
308
+ * filled region floating above the baseline reads as a shape rather than a
309
+ * quantity — but a scatter plot's subject is the *spread*, and forcing a
310
+ * cluster of values between 80 and 90 to share a frame with zero squashes
311
+ * it into a smudge in one corner and hides the very thing being plotted.
312
+ */
313
+ const extents = useMemo<{ x: [number, number]; y: [number, number] }>(() => {
314
+ const keys = seriesKeys ? seriesKeys.split('|') : [];
315
+ let lowX = Infinity;
316
+ let highX = -Infinity;
317
+ let lowY = Infinity;
318
+ let highY = -Infinity;
319
+
320
+ for (const row of data) {
321
+ const x = row[xDataKey];
322
+ if (typeof x !== 'number' || Number.isNaN(x)) continue;
323
+ for (const key of keys) {
324
+ const y = row[key];
325
+ // A row with no reading for this series is not a point at the
326
+ // origin — it is not a point at all, and must not stretch the axes.
327
+ if (typeof y !== 'number' || Number.isNaN(y)) continue;
328
+ if (x < lowX) lowX = x;
329
+ if (x > highX) highX = x;
330
+ if (y < lowY) lowY = y;
331
+ if (y > highY) highY = y;
332
+ }
333
+ }
334
+
335
+ return {
336
+ x: xDomain ?? padExtent(lowX, highX),
337
+ y: yDomain ?? padExtent(lowY, highY),
338
+ };
339
+ }, [data, xDataKey, seriesKeys, xDomain, yDomain]);
340
+
341
+ const loading = status === 'loading';
342
+
343
+ useEffect(() => {
344
+ if (loading) return;
345
+ const [x0, x1] = extents.x;
346
+ const [y0, y1] = extents.y;
347
+ // The first domain lands without a tween: there is no previous scale to
348
+ // move from, and animating up from zero reads as the numbers changing.
349
+ const first = xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;
350
+ if (first || reducedMotion) {
351
+ xMin.value = x0;
352
+ xMax.value = x1;
353
+ yMin.value = y0;
354
+ yMax.value = y1;
355
+ return;
356
+ }
357
+ xMin.value = withTiming(x0, { duration: domainDuration });
358
+ xMax.value = withTiming(x1, { duration: domainDuration });
359
+ yMin.value = withTiming(y0, { duration: domainDuration });
360
+ yMax.value = withTiming(y1, { duration: domainDuration });
361
+ }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);
362
+
363
+ const revealed = useRef(false);
364
+ const playReveal = useMemo(
365
+ () => () => {
366
+ if (reducedMotion) {
367
+ reveal.value = 1;
368
+ return;
369
+ }
370
+ reveal.value = 0;
371
+ /*
372
+ * Eased out rather than in and out. Each point is given a slice of this
373
+ * one clock, so an ease that dawdles at the start spends it on the
374
+ * first few points and leaves the rest to arrive in a rush.
375
+ */
376
+ reveal.value = withTiming(1, {
377
+ duration: animationDuration,
378
+ easing: Easing.out(Easing.cubic),
379
+ });
380
+ },
381
+ [reducedMotion, animationDuration, reveal]
382
+ );
383
+
384
+ /*
385
+ * Armed again every time the chart goes back to loading, so a chart that
386
+ * reloads animates on the second pass as well as the first. Left latched,
387
+ * the guard made "loading" a state the chart could only leave once.
388
+ */
389
+ useEffect(() => {
390
+ if (loading) {
391
+ revealed.current = false;
392
+ reveal.value = 0;
393
+ return;
394
+ }
395
+ if (revealed.current || plot.width <= 0 || !data.length) return;
396
+ revealed.current = true;
397
+ playReveal();
398
+ }, [loading, plot.width, data.length, playReveal, reveal]);
399
+
400
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
401
+
402
+ // One place the selection lands, so the chart's own children and a readout
403
+ // outside it never disagree about which point is active.
404
+ const setActivePoint = useMemo(
405
+ () => (point: ScatterChartPoint | null) => {
406
+ setActivePointState(point);
407
+ onActivePointChange?.(point);
408
+ },
409
+ [onActivePointChange]
410
+ );
411
+
412
+ const onLayout = (event: LayoutChangeEvent) => {
413
+ const { width, height } = event.nativeEvent.layout;
414
+ setSize((current) =>
415
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
416
+ ? current
417
+ : { width, height }
418
+ );
419
+ props.onLayout?.(event);
420
+ };
421
+
422
+ const context = useMemo<ScatterChartContextValue>(
423
+ () => ({
424
+ data,
425
+ xDataKey,
426
+ plot,
427
+ status,
428
+ series,
429
+ registerSeries,
430
+ unregisterSeries,
431
+ xMin,
432
+ xMax,
433
+ yMin,
434
+ yMax,
435
+ xExtent: extents.x,
436
+ yExtent: extents.y,
437
+ activeId,
438
+ activePoint,
439
+ setActivePoint,
440
+ reveal,
441
+ }),
442
+ // `plot` is rebuilt every render from `size`, so it is compared by value.
443
+ // eslint-disable-next-line react-hooks/exhaustive-deps
444
+ [
445
+ data,
446
+ xDataKey,
447
+ plot.width,
448
+ plot.height,
449
+ plot.left,
450
+ plot.top,
451
+ status,
452
+ series,
453
+ registerSeries,
454
+ unregisterSeries,
455
+ xMin,
456
+ xMax,
457
+ yMin,
458
+ yMax,
459
+ extents,
460
+ activeId,
461
+ activePoint,
462
+ setActivePoint,
463
+ reveal,
464
+ ]
465
+ );
466
+
467
+ const { svg, overlay, header } = partition(children);
468
+
469
+ /*
470
+ * Two views, because the header is not part of the plot. `aspectRatio` and
471
+ * the layout measurement belong to the drawing area alone — measured on the
472
+ * outer view they would take in the header too, and the plot would lose as
473
+ * much height as the readout took while still claiming the shape asked for.
474
+ */
475
+ return (
476
+ <ScatterChartContext.Provider value={context}>
477
+ <View {...props} style={props.style} className={cn('w-full', className)}>
478
+ {header}
479
+ <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
480
+ {plot.width > 0 ? (
481
+ <>
482
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
483
+ {svg}
484
+ </Svg>
485
+ {overlay}
486
+ </>
487
+ ) : null}
488
+ </View>
489
+ </View>
490
+ </ScatterChartContext.Provider>
491
+ );
492
+ }
493
+ );
494
+ ScatterChartRoot.displayName = 'ScatterChart';
495
+
496
+ /**
497
+ * An extent with a little air around it, and a usable one for the degenerate
498
+ * cases — no data at all, or every reading identical. A domain of zero width
499
+ * divides by zero and puts every point on the same edge.
500
+ */
501
+ function padExtent(min: number, max: number): [number, number] {
502
+ if (min === Infinity) return [0, 1];
503
+ if (min === max) return [min - 1, max + 1];
504
+ const pad = (max - min) * 0.08;
505
+ return [min - pad, max + pad];
506
+ }
507
+
508
+ /** Sorts the children into the SVG tree and the view layer over it. */
509
+ function partition(children: ReactNode) {
510
+ const svg: ReactNode[] = [];
511
+ const overlay: ReactNode[] = [];
512
+ const header: ReactNode[] = [];
513
+
514
+ Children.forEach(children, (child, index) => {
515
+ if (!isValidElement(child)) return;
516
+ const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
517
+ (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(
518
+ // Children of a `Children.forEach` need keys of their own once they are
519
+ // put into a new array.
520
+ <ChildSlot key={index}>{child}</ChildSlot>
521
+ );
522
+ });
523
+
524
+ return { svg, overlay, header };
525
+ }
526
+
527
+ /** Identity wrapper, purely so the partitioned arrays can carry keys. */
528
+ function ChildSlot({ children }: { children: ReactNode }) {
529
+ return <>{children}</>;
530
+ }
531
+
532
+ /* -------------------------------------------------------------------------- */
533
+ /* SVG layer */
534
+ /* -------------------------------------------------------------------------- */
535
+
536
+ export interface ScatterChartGridProps {
537
+ /** Horizontal rules across the plot. */
538
+ rows?: number;
539
+ /**
540
+ * Vertical rules down it. A scatter plot's x is a quantity, so it earns a
541
+ * grid in both directions — a line chart's does not, because its x is a
542
+ * label and a rule under a label divides nothing.
543
+ */
544
+ columns?: number;
545
+ color?: string;
546
+ /** Dash pattern, e.g. `"4,6"`. Omit for a solid rule. */
547
+ dashArray?: string;
548
+ opacity?: number;
549
+ }
550
+
551
+ /** Reference lines both ways. Drawn under everything, and not part of the reveal. */
552
+ function ScatterChartGrid({
553
+ rows = 4,
554
+ columns = 4,
555
+ color,
556
+ dashArray = '4,6',
557
+ opacity = 1,
558
+ }: ScatterChartGridProps) {
559
+ const { plot } = useChart('ScatterChart.Grid');
560
+ const token = useCSSVariable('--color-border');
561
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
562
+
563
+ return (
564
+ <G opacity={opacity}>
565
+ {Array.from({ length: rows + 1 }, (_unused, index) => {
566
+ const y = plot.top + (plot.height / rows) * index;
567
+ return (
568
+ <SvgLine
569
+ key={`r${index}`}
570
+ x1={plot.left}
571
+ x2={plot.left + plot.width}
572
+ y1={y}
573
+ y2={y}
574
+ stroke={stroke}
575
+ strokeWidth={1}
576
+ strokeDasharray={dashArray}
577
+ />
578
+ );
579
+ })}
580
+ {Array.from({ length: columns + 1 }, (_unused, index) => {
581
+ const x = plot.left + (plot.width / columns) * index;
582
+ return (
583
+ <SvgLine
584
+ key={`c${index}`}
585
+ x1={x}
586
+ x2={x}
587
+ y1={plot.top}
588
+ y2={plot.top + plot.height}
589
+ stroke={stroke}
590
+ strokeWidth={1}
591
+ strokeDasharray={dashArray}
592
+ />
593
+ );
594
+ })}
595
+ </G>
596
+ );
597
+ }
598
+ ScatterChartGrid.displayName = 'ScatterChart.Grid';
599
+ ScatterChartGrid.layer = 'svg' as Layer;
600
+
601
+ export interface ScatterChartPointsProps {
602
+ /** Key holding this series' y values. */
603
+ dataKey: string;
604
+ /**
605
+ * Fill colour. Defaults to the `--color-chart-*` token at `colorIndex`, so a
606
+ * series follows the theme without the call site naming a colour.
607
+ */
608
+ color?: string;
609
+ /** Which `--color-chart-*` token to take when `color` is not given. */
610
+ colorIndex?: 1 | 2 | 3 | 4 | 5;
611
+ /** Radius of a point, in points. Ignored when `sizeKey` is given. */
612
+ size?: number;
613
+ /**
614
+ * Key holding a third quantity, mapped to each point's *area* — a bubble
615
+ * chart. Area rather than radius, because doubling a radius quadruples the
616
+ * ink and the reader sees four times the value that is there.
617
+ */
618
+ sizeKey?: string;
619
+ /** Smallest and largest radius `sizeKey` maps onto. */
620
+ sizeRange?: [number, number];
621
+ /**
622
+ * Fill opacity. Below 1 by default so that overlapping points read as denser
623
+ * rather than hiding each other — in a crowded region that overlap *is* the
624
+ * finding, and opaque dots erase it.
625
+ */
626
+ opacity?: number;
627
+ }
628
+
629
+ /** One series, as a field of dots. */
630
+ function ScatterChartPoints({
631
+ dataKey,
632
+ color,
633
+ colorIndex = 1,
634
+ size = 4.5,
635
+ sizeKey,
636
+ sizeRange = [3, 14],
637
+ opacity = 0.75,
638
+ }: ScatterChartPointsProps) {
639
+ const {
640
+ data,
641
+ xDataKey,
642
+ plot,
643
+ xMin,
644
+ xMax,
645
+ yMin,
646
+ yMax,
647
+ status,
648
+ activeId,
649
+ registerSeries,
650
+ unregisterSeries,
651
+ reveal,
652
+ } = useChart('ScatterChart.Points');
653
+ const fill = useSeriesColor(color, colorIndex);
654
+
655
+ useEffect(() => {
656
+ registerSeries(dataKey, fill);
657
+ return () => unregisterSeries(dataKey);
658
+ }, [dataKey, fill, registerSeries, unregisterSeries]);
659
+
660
+ const loading = status === 'loading';
661
+
662
+ // The size scale is over the whole series, so one row's bubble means the same
663
+ // thing as another's. Recomputed only when the data or the key changes.
664
+ const sizeExtent = useMemo<[number, number] | null>(() => {
665
+ if (!sizeKey) return null;
666
+ let min = Infinity;
667
+ let max = -Infinity;
668
+ for (const row of data) {
669
+ const value = row[sizeKey];
670
+ if (typeof value !== 'number' || Number.isNaN(value)) continue;
671
+ if (value < min) min = value;
672
+ if (value > max) max = value;
673
+ }
674
+ return min === Infinity ? null : [min, max];
675
+ }, [data, sizeKey]);
676
+
677
+ const points = useMemo(
678
+ () =>
679
+ data
680
+ .map((row, index) => {
681
+ const x = row[xDataKey];
682
+ const y = row[dataKey];
683
+ if (typeof x !== 'number' || Number.isNaN(x)) return null;
684
+ if (typeof y !== 'number' || Number.isNaN(y)) return null;
685
+ return { index, x, y, r: radiusFor(row[sizeKey ?? ''], sizeExtent, sizeRange, size) };
686
+ })
687
+ .filter((point): point is { index: number; x: number; y: number; r: number } =>
688
+ point !== null
689
+ ),
690
+ [data, xDataKey, dataKey, sizeKey, sizeExtent, sizeRange, size]
691
+ );
692
+
693
+ if (loading) return null;
694
+
695
+ return (
696
+ <G>
697
+ {points.map((point, order) => (
698
+ <Dot
699
+ key={point.index}
700
+ id={`${dataKey}:${point.index}`}
701
+ x={point.x}
702
+ y={point.y}
703
+ r={point.r}
704
+ plot={plot}
705
+ xMin={xMin}
706
+ xMax={xMax}
707
+ yMin={yMin}
708
+ yMax={yMax}
709
+ fill={fill}
710
+ opacity={opacity}
711
+ activeId={activeId}
712
+ reveal={reveal}
713
+ order={order}
714
+ total={points.length}
715
+ />
716
+ ))}
717
+ </G>
718
+ );
719
+ }
720
+ ScatterChartPoints.displayName = 'ScatterChart.Points';
721
+ ScatterChartPoints.layer = 'svg' as Layer;
722
+
723
+ /**
724
+ * A value's radius on the bubble scale.
725
+ *
726
+ * The value maps to *area* and the radius is taken from it, so a point holding
727
+ * twice the value carries twice the ink rather than four times it.
728
+ */
729
+ function radiusFor(
730
+ value: string | number | null | undefined,
731
+ extent: [number, number] | null,
732
+ range: [number, number],
733
+ fallback: number
734
+ ): number {
735
+ if (!extent || typeof value !== 'number' || Number.isNaN(value)) return fallback;
736
+ const [min, max] = extent;
737
+ const [rMin, rMax] = range;
738
+ const ratio = max === min ? 1 : (value - min) / (max - min);
739
+ const area = rMin * rMin + ratio * (rMax * rMax - rMin * rMin);
740
+ return Math.sqrt(area);
741
+ }
742
+
743
+ /**
744
+ * One point. Its position follows both domain tweens, so a data change moves
745
+ * the whole field to the new scale rather than cutting to it.
746
+ *
747
+ * It arrives by growing in place, on its own slice of the shared reveal. The
748
+ * alternative — sweeping a clip across the plot, which is what the line and
749
+ * area charts do — is right for a series read along the x-axis and wrong here:
750
+ * a wipe gives the reader a direction to read the arrival in, and a scatter
751
+ * plot has none. Position is the whole message, so a point may only ever appear
752
+ * where it belongs.
753
+ */
754
+ function Dot({
755
+ id,
756
+ x,
757
+ y,
758
+ r,
759
+ plot,
760
+ xMin,
761
+ xMax,
762
+ yMin,
763
+ yMax,
764
+ fill,
765
+ opacity,
766
+ activeId,
767
+ reveal,
768
+ order,
769
+ total,
770
+ }: {
771
+ id: string;
772
+ x: number;
773
+ y: number;
774
+ r: number;
775
+ plot: Plot;
776
+ xMin: SharedValue<number>;
777
+ xMax: SharedValue<number>;
778
+ yMin: SharedValue<number>;
779
+ yMax: SharedValue<number>;
780
+ fill: string;
781
+ opacity: number;
782
+ activeId: SharedValue<string>;
783
+ reveal: SharedValue<number>;
784
+ order: number;
785
+ total: number;
786
+ }) {
787
+ /*
788
+ * The selection, as something that moves. It was a hard switch on
789
+ * `activeId`, so the point it named jumped half as big again between one
790
+ * frame and the next while every neighbour stayed put — read as a glitch
791
+ * rather than as a response to the finger.
792
+ */
793
+ const selected = useDerivedValue(() =>
794
+ withTiming(activeId.value === id ? 1 : 0, { duration: SELECT_DURATION })
795
+ );
796
+
797
+ // Where in the reveal this point starts. Spread over `STAGGER`, so the field
798
+ // settles as a field rather than switching on all at once.
799
+ const start = total > 1 ? (order / total) * STAGGER : 0;
800
+
801
+ const animatedProps = useAnimatedProps(() => {
802
+ const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
803
+ // The selected point swells and goes solid. Both, rather than one: a size
804
+ // change alone is easy to miss among neighbours, and an opacity change
805
+ // alone is invisible wherever the points already overlap.
806
+ const swell = 1 + 0.5 * selected.value;
807
+ return {
808
+ cx: xAt(x, plot, xMin.value, xMax.value),
809
+ cy: yOf(y, plot, yMin.value, yMax.value),
810
+ r: r * landing(arrived) * swell,
811
+ // Ahead of the size, so a point is legible by the time it stops moving
812
+ // rather than fading in for the whole of its arrival.
813
+ fillOpacity:
814
+ Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),
815
+ };
816
+ });
817
+
818
+ return <AnimatedCircle animatedProps={animatedProps} fill={fill} />;
819
+ }
820
+
821
+ export interface ScatterChartSkeletonProps {
822
+ /** How many placeholder dots to scatter. */
823
+ count?: number;
824
+ color?: string;
825
+ }
826
+
827
+ /**
828
+ * The loading state: a still field of muted dots where the data will be.
829
+ *
830
+ * Deliberately still. A shimmer over a field of dots reads as the points
831
+ * *moving*, which is the one thing a scatter plot must never appear to do —
832
+ * position is the entire message, and a loading state that implies it is
833
+ * changing is a loading state that lies.
834
+ *
835
+ * The layout is deterministic rather than random, so it does not reshuffle on
836
+ * every render of a component that may re-render several times while waiting.
837
+ *
838
+ * Still is not the same as abrupt, though. It dissolves as the real points grow
839
+ * in, and outlives the status change by exactly that long — cut at the frame the
840
+ * data lands, the placeholder disappears before anything has replaced it and the
841
+ * plot is briefly empty.
842
+ */
843
+ function ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps) {
844
+ const { plot, status } = useChart('ScatterChart.Skeleton');
845
+ const token = useCSSVariable('--color-skeleton');
846
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
847
+ const reducedMotion = useReducedMotion();
848
+
849
+ const loading = status === 'loading';
850
+ const fade = useSharedValue(1);
851
+ // Mounted a beat longer than `loading`, so there is something to fade.
852
+ const [mounted, setMounted] = useState(loading);
853
+
854
+ useEffect(() => {
855
+ if (loading) {
856
+ fade.value = 1;
857
+ setMounted(true);
858
+ return;
859
+ }
860
+ if (reducedMotion) {
861
+ setMounted(false);
862
+ return;
863
+ }
864
+ fade.value = withTiming(0, { duration: SKELETON_FADE }, (finished) => {
865
+ if (finished) runOnJS(setMounted)(false);
866
+ });
867
+ }, [loading, reducedMotion, fade]);
868
+
869
+ const dots = useMemo(() => {
870
+ // A cheap deterministic scatter: two irrational-ish strides that do not
871
+ // share a factor, so the points spread instead of falling into a lattice.
872
+ return Array.from({ length: count }, (_unused, index) => ({
873
+ key: index,
874
+ fx: ((index * 0.618) % 1) * 0.92 + 0.04,
875
+ fy: ((index * 0.379) % 1) * 0.92 + 0.04,
876
+ }));
877
+ }, [count]);
878
+
879
+ const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));
880
+
881
+ if (!mounted) return null;
882
+
883
+ return (
884
+ <AnimatedG animatedProps={animatedProps}>
885
+ {dots.map((dot) => (
886
+ <Circle
887
+ key={dot.key}
888
+ cx={plot.left + dot.fx * plot.width}
889
+ cy={plot.top + dot.fy * plot.height}
890
+ r={4.5}
891
+ fill={fill}
892
+ />
893
+ ))}
894
+ </AnimatedG>
895
+ );
896
+ }
897
+ ScatterChartSkeleton.displayName = 'ScatterChart.Skeleton';
898
+ ScatterChartSkeleton.layer = 'svg' as Layer;
899
+
900
+ /* -------------------------------------------------------------------------- */
901
+ /* Overlay layer */
902
+ /* -------------------------------------------------------------------------- */
903
+
904
+ export interface ScatterChartXAxisProps {
905
+ /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
906
+ ticks?: number;
907
+ /** Turn a value into its label. Defaults to a compact number. */
908
+ format?: (value: number) => string;
909
+ className?: string;
910
+ }
911
+
912
+ /**
913
+ * The x labels, evenly along the axis.
914
+ *
915
+ * Evenly spaced here — unlike a line chart's, where each label sits on the point
916
+ * it names — because this axis is a continuous scale rather than a list of
917
+ * rows. There is no point to sit on.
918
+ */
919
+ function ScatterChartXAxis({ ticks = 4, format, className }: ScatterChartXAxisProps) {
920
+ const { plot, xExtent } = useChart('ScatterChart.XAxis');
921
+
922
+ const labels = useMemo(() => {
923
+ const [min, max] = xExtent;
924
+ if (min === 0 && max === 0) return [];
925
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
926
+ const value = min + ((max - min) * index) / ticks;
927
+ return { key: index, value, text: format ? format(value) : compactNumber(value) };
928
+ });
929
+ }, [xExtent, ticks, format]);
930
+
931
+ return (
932
+ <View style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} className={cn(className)}>
933
+ {labels.map((label) => (
934
+ <Text
935
+ key={label.key}
936
+ size="xs"
937
+ muted
938
+ numberOfLines={1}
939
+ style={{
940
+ position: 'absolute',
941
+ bottom: 0,
942
+ // Centred on its tick, then held inside the chart. The first and
943
+ // last ticks sit on the plot's own edges, so a box centred on them
944
+ // hangs half its width off the side — the clamp slides those two
945
+ // back in rather than letting the numbers leave the frame.
946
+ left: Math.max(
947
+ 0,
948
+ Math.min(
949
+ plot.left + (plot.width / ticks) * label.key - POINT_LABEL_WIDTH / 2,
950
+ plot.left + plot.width + PADDING.right - POINT_LABEL_WIDTH
951
+ )
952
+ ),
953
+ width: POINT_LABEL_WIDTH,
954
+ textAlign: 'center',
955
+ }}
956
+ >
957
+ {label.text}
958
+ </Text>
959
+ ))}
960
+ </View>
961
+ );
962
+ }
963
+ ScatterChartXAxis.displayName = 'ScatterChart.XAxis';
964
+ ScatterChartXAxis.layer = 'overlay' as Layer;
965
+
966
+ export interface ScatterChartYAxisProps {
967
+ /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
968
+ ticks?: number;
969
+ /** Turn a value into its label. Defaults to a compact number. */
970
+ format?: (value: number) => string;
971
+ className?: string;
972
+ }
973
+
974
+ /** Value labels down the side, one per grid line. */
975
+ function ScatterChartYAxis({ ticks = 4, format, className }: ScatterChartYAxisProps) {
976
+ const { plot, yExtent } = useChart('ScatterChart.YAxis');
977
+
978
+ const labels = useMemo(() => {
979
+ const [min, max] = yExtent;
980
+ if (min === 0 && max === 0) return [];
981
+ return Array.from({ length: ticks + 1 }, (_unused, index) => {
982
+ const value = max - ((max - min) * index) / ticks;
983
+ return { key: index, text: format ? format(value) : compactNumber(value) };
984
+ });
985
+ }, [yExtent, ticks, format]);
986
+
987
+ return (
988
+ <View
989
+ pointerEvents="none"
990
+ style={{
991
+ position: 'absolute',
992
+ left: 0,
993
+ // Centred on the grid line each label names: the strip is lifted half a
994
+ // label and grown by a whole one, so `justify-between` lands the text's
995
+ // middle on the line rather than its top edge on the first.
996
+ top: plot.top - AXIS_LABEL_HEIGHT / 2,
997
+ height: plot.height + AXIS_LABEL_HEIGHT,
998
+ width: Math.max(plot.left - Y_AXIS_GUTTER, 0),
999
+ }}
1000
+ className={cn('items-end justify-between', className)}
1001
+ >
1002
+ {labels.map((label) => (
1003
+ <Text key={label.key} size="xs" muted numberOfLines={1}>
1004
+ {label.text}
1005
+ </Text>
1006
+ ))}
1007
+ </View>
1008
+ );
1009
+ }
1010
+ ScatterChartYAxis.displayName = 'ScatterChart.YAxis';
1011
+ ScatterChartYAxis.layer = 'overlay' as Layer;
1012
+ // Read by the root, which has to leave room for the labels before it lays the
1013
+ // plot out.
1014
+ ScatterChartYAxis.axis = 'y' as const;
1015
+
1016
+ export interface ScatterChartTooltipProps {
1017
+ /** Float a small readout beside the selected point. On by default. */
1018
+ showLabel?: boolean;
1019
+ /** Format the x value for the readout. Defaults to a compact number. */
1020
+ formatX?: (value: number) => string;
1021
+ /** Format the y value for the readout. Defaults to a compact number. */
1022
+ formatY?: (value: number, key: string) => string;
1023
+ /** A heading for the readout, from the row — a name, a label, a category. */
1024
+ formatTitle?: (datum: ScatterChartDatum) => string;
1025
+ /** How far from a point a touch still counts as being on it, in points. */
1026
+ hitRadius?: number;
1027
+ }
1028
+
1029
+ /**
1030
+ * The touch target, the selection it drives, and the readout that follows it.
1031
+ *
1032
+ * A line chart's crosshair snaps to an x index. That is not available here:
1033
+ * there is no shared x, and two points can sit on the same one. So the nearest
1034
+ * point is found by distance instead — and only within `hitRadius`, so a touch
1035
+ * in an empty corner selects nothing rather than lighting up whichever point is
1036
+ * least far away.
1037
+ *
1038
+ * The search runs on the UI thread over a flat array of already-projected
1039
+ * coordinates, and only the *identity* of the winner crosses back into JS, and
1040
+ * only when it changes. A drag across the plot therefore costs a handful of
1041
+ * re-renders rather than one per frame.
1042
+ *
1043
+ * Distances are compared squared. The nearest point by distance is the nearest
1044
+ * by distance-squared, and a square root per point per frame buys nothing.
1045
+ */
1046
+ function ScatterChartTooltip({
1047
+ showLabel = true,
1048
+ formatX,
1049
+ formatY,
1050
+ formatTitle,
1051
+ hitRadius = HIT_RADIUS,
1052
+ }: ScatterChartTooltipProps) {
1053
+ const {
1054
+ data,
1055
+ xDataKey,
1056
+ plot,
1057
+ series,
1058
+ xExtent,
1059
+ yExtent,
1060
+ activeId,
1061
+ activePoint,
1062
+ setActivePoint,
1063
+ status,
1064
+ } = useChart('ScatterChart.Tooltip');
1065
+
1066
+ const seriesKeys = series.map(([key]) => key).join('|');
1067
+
1068
+ /*
1069
+ * Every point in the chart, projected once, as parallel arrays.
1070
+ *
1071
+ * Parallel arrays rather than an array of objects because this is read inside
1072
+ * a worklet: Reanimated has to copy whatever the gesture captures across to
1073
+ * the UI thread, and three number arrays cross far more cheaply than a few
1074
+ * hundred small objects.
1075
+ *
1076
+ * Projected against the *settled* extents rather than the tweening shared
1077
+ * values. Hit-testing against a moving scale would mean rebuilding this on
1078
+ * every frame of a domain animation, and a point being half a second stale
1079
+ * during a transition is not something a finger can notice.
1080
+ */
1081
+ const hit = useMemo(() => {
1082
+ const keys = seriesKeys ? seriesKeys.split('|') : [];
1083
+ const xs: number[] = [];
1084
+ const ys: number[] = [];
1085
+ const ids: string[] = [];
1086
+ const indices: number[] = [];
1087
+ const owners: string[] = [];
1088
+
1089
+ for (const key of keys) {
1090
+ for (let index = 0; index < data.length; index += 1) {
1091
+ const row = data[index]!;
1092
+ const x = row[xDataKey];
1093
+ const y = row[key];
1094
+ if (typeof x !== 'number' || Number.isNaN(x)) continue;
1095
+ if (typeof y !== 'number' || Number.isNaN(y)) continue;
1096
+ xs.push(xAt(x, plot, xExtent[0], xExtent[1]));
1097
+ ys.push(yOf(y, plot, yExtent[0], yExtent[1]));
1098
+ ids.push(`${key}:${index}`);
1099
+ indices.push(index);
1100
+ owners.push(key);
1101
+ }
1102
+ }
1103
+
1104
+ return { xs, ys, ids, indices, owners };
1105
+ }, [data, xDataKey, seriesKeys, plot, xExtent, yExtent]);
1106
+
1107
+ // Resolves an id, which is all the worklet can cheaply hand back. JS turns it
1108
+ // into the row it came from.
1109
+ const select = useMemo(
1110
+ () => (id: string) => {
1111
+ if (!id) {
1112
+ setActivePoint(null);
1113
+ return;
1114
+ }
1115
+ const at = hit.ids.indexOf(id);
1116
+ if (at < 0) {
1117
+ setActivePoint(null);
1118
+ return;
1119
+ }
1120
+ const index = hit.indices[at]!;
1121
+ const key = hit.owners[at]!;
1122
+ const datum = data[index];
1123
+ if (!datum) {
1124
+ setActivePoint(null);
1125
+ return;
1126
+ }
1127
+ setActivePoint({
1128
+ index,
1129
+ dataKey: key,
1130
+ x: datum[xDataKey] as number,
1131
+ y: datum[key] as number,
1132
+ datum,
1133
+ });
1134
+ },
1135
+ [hit, data, xDataKey, setActivePoint]
1136
+ );
1137
+
1138
+ /*
1139
+ * Built in one closure, and everything it captures is a plain array, a number
1140
+ * or a shared value. A worklet may only call another worklet, and the rule is
1141
+ * enforced by crashing rather than by warning — so the resolver is declared
1142
+ * here, next to its callers, rather than as a helper elsewhere in the file
1143
+ * where it would be easy to leave un-workletised.
1144
+ */
1145
+ const pan = useMemo(() => {
1146
+ const xs = hit.xs;
1147
+ const ys = hit.ys;
1148
+ const ids = hit.ids;
1149
+ const limit = hitRadius * hitRadius;
1150
+
1151
+ const resolve = (px: number, py: number) => {
1152
+ 'worklet';
1153
+ let bestId = '';
1154
+ let best = limit;
1155
+ for (let i = 0; i < xs.length; i += 1) {
1156
+ const dx = xs[i]! - px;
1157
+ const dy = ys[i]! - py;
1158
+ const distance = dx * dx + dy * dy;
1159
+ if (distance <= best) {
1160
+ best = distance;
1161
+ bestId = ids[i]!;
1162
+ }
1163
+ }
1164
+ if (bestId === activeId.value) return;
1165
+ activeId.value = bestId;
1166
+ runOnJS(select)(bestId);
1167
+ };
1168
+
1169
+ const clear = () => {
1170
+ 'worklet';
1171
+ if (activeId.value === '') return;
1172
+ activeId.value = '';
1173
+ runOnJS(select)('');
1174
+ };
1175
+
1176
+ return Gesture.Pan()
1177
+ .minDistance(0)
1178
+ .onBegin((event) => {
1179
+ 'worklet';
1180
+ resolve(event.x, event.y);
1181
+ })
1182
+ .onUpdate((event) => {
1183
+ 'worklet';
1184
+ resolve(event.x, event.y);
1185
+ })
1186
+ .onFinalize(() => {
1187
+ 'worklet';
1188
+ clear();
1189
+ });
1190
+ }, [hit, hitRadius, activeId, select]);
1191
+
1192
+ // The readout sits above the point and is clamped inside the plot, so it
1193
+ // never runs off an edge at an extreme value.
1194
+ const labelStyle = useAnimatedStyle(() => {
1195
+ const id = activeId.value;
1196
+ if (!id) return { opacity: 0 };
1197
+ const at = hit.ids.indexOf(id);
1198
+ if (at < 0) return { opacity: 0 };
1199
+ const x = hit.xs[at]!;
1200
+ const y = hit.ys[at]!;
1201
+ const half = LABEL_WIDTH / 2;
1202
+ return {
1203
+ opacity: 1,
1204
+ transform: [
1205
+ {
1206
+ translateX: Math.min(
1207
+ plot.left + plot.width - half,
1208
+ Math.max(plot.left + half, x)
1209
+ ) - half,
1210
+ },
1211
+ // Above the point, and pushed below it near the top of the plot where
1212
+ // there is no room above.
1213
+ { translateY: y - plot.top < LABEL_HEIGHT ? y + 16 : y - LABEL_HEIGHT },
1214
+ ],
1215
+ };
1216
+ });
1217
+
1218
+ const fmtX = formatX ?? ((value: number) => compactNumber(value));
1219
+ const fmtY = formatY ?? ((value: number) => compactNumber(value));
1220
+
1221
+ if (status === 'loading') return null;
1222
+
1223
+ return (
1224
+ <GestureDetector gesture={pan}>
1225
+ <View style={StyleSheet.absoluteFill}>
1226
+ {showLabel ? (
1227
+ <Animated.View
1228
+ pointerEvents="none"
1229
+ style={[
1230
+ { position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH },
1231
+ labelStyle,
1232
+ ]}
1233
+ >
1234
+ <View className="items-center rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg">
1235
+ {activePoint ? (
1236
+ <>
1237
+ {formatTitle ? (
1238
+ <Text size="xs" muted numberOfLines={1}>
1239
+ {formatTitle(activePoint.datum)}
1240
+ </Text>
1241
+ ) : null}
1242
+ <View className="flex-row items-center gap-1.5">
1243
+ {series.length > 1 ? (
1244
+ <View
1245
+ style={{
1246
+ backgroundColor:
1247
+ series.find(([key]) => key === activePoint.dataKey)?.[1] ?? undefined,
1248
+ }}
1249
+ className="h-1.5 w-1.5 rounded-full"
1250
+ />
1251
+ ) : null}
1252
+ <Text size="sm" weight="semibold" numberOfLines={1}>
1253
+ {fmtX(activePoint.x)} · {fmtY(activePoint.y, activePoint.dataKey)}
1254
+ </Text>
1255
+ </View>
1256
+ </>
1257
+ ) : null}
1258
+ </View>
1259
+ </Animated.View>
1260
+ ) : null}
1261
+ </View>
1262
+ </GestureDetector>
1263
+ );
1264
+ }
1265
+ ScatterChartTooltip.displayName = 'ScatterChart.Tooltip';
1266
+ ScatterChartTooltip.layer = 'overlay' as Layer;
1267
+
1268
+ export interface ScatterChartLegendProps extends ViewProps {
1269
+ className?: string;
1270
+ /** Label per series key. A key with no label falls back to the key itself. */
1271
+ labels?: Record<string, string>;
1272
+ }
1273
+
1274
+ /**
1275
+ * A swatch and a name per registered series. Sits in the top-left of the plot
1276
+ * by default — move it with `className`.
1277
+ */
1278
+ function ScatterChartLegend({ className, labels, ...props }: ScatterChartLegendProps) {
1279
+ const { series } = useChart('ScatterChart.Legend');
1280
+ if (!series.length) return null;
1281
+
1282
+ return (
1283
+ <View
1284
+ className={cn('absolute left-2.5 top-0 flex-row flex-wrap items-center gap-4', className)}
1285
+ {...props}
1286
+ style={[{ pointerEvents: 'none' }, props.style]}
1287
+ >
1288
+ {series.map(([key, color]) => (
1289
+ <SeriesSwatch key={key} color={color} label={labels?.[key] ?? key} />
1290
+ ))}
1291
+ </View>
1292
+ );
1293
+ }
1294
+ ScatterChartLegend.displayName = 'ScatterChart.Legend';
1295
+ ScatterChartLegend.layer = 'overlay' as Layer;
1296
+
1297
+ /** One series' colour and name. Shared by the legend and the header. */
1298
+ function SeriesSwatch({ color, label }: { color: string; label: string }) {
1299
+ return (
1300
+ <View className="flex-row items-center gap-1.5">
1301
+ <View style={{ backgroundColor: color }} className="h-2 w-2 rounded-full" />
1302
+ <Text size="xs" muted>
1303
+ {label}
1304
+ </Text>
1305
+ </View>
1306
+ );
1307
+ }
1308
+
1309
+ /* -------------------------------------------------------------------------- */
1310
+ /* Header layer */
1311
+ /* -------------------------------------------------------------------------- */
1312
+
1313
+ export interface ScatterChartHeaderProps extends ViewProps {
1314
+ className?: string;
1315
+ /** Small line above the value — what the chart is of. */
1316
+ title?: string;
1317
+ /** The readout. The largest thing on the card, and the first thing read. */
1318
+ value?: string;
1319
+ /** One muted line under the value — a period, a comparison, a total. */
1320
+ caption?: string;
1321
+ /** Prettier names for the series keys, as the legend takes. */
1322
+ labels?: Record<string, string>;
1323
+ /**
1324
+ * Draw a swatch and a name per series along the trailing edge. Prefer this to
1325
+ * `ScatterChart.Legend` on a chart that has a header: the legend floats over
1326
+ * the plot, where it competes with the points for the same corner.
1327
+ */
1328
+ legend?: boolean;
1329
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
1330
+ children?: ReactNode;
1331
+ }
1332
+
1333
+ /**
1334
+ * The strip above the plot: what the chart is of, what it currently reads, and
1335
+ * what the colours mean.
1336
+ *
1337
+ * The value is not derived here. A readout that follows the finger belongs to
1338
+ * whoever owns the data — take it from `onActivePointChange` and pass the
1339
+ * formatted string down, so one header can show a summary when nothing is
1340
+ * pressed and a point's values when something is.
1341
+ */
1342
+ function ScatterChartHeader({
1343
+ className,
1344
+ title,
1345
+ value,
1346
+ caption,
1347
+ labels,
1348
+ legend = false,
1349
+ children,
1350
+ ...props
1351
+ }: ScatterChartHeaderProps) {
1352
+ const { series } = useChart('ScatterChart.Header');
1353
+ const trailing =
1354
+ children ??
1355
+ (legend && series.length ? (
1356
+ <View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
1357
+ {series.map(([key, color]) => (
1358
+ <SeriesSwatch key={key} color={color} label={labels?.[key] ?? key} />
1359
+ ))}
1360
+ </View>
1361
+ ) : null);
1362
+
1363
+ return (
1364
+ <View {...props} className={cn('flex-row items-start justify-between gap-3 pb-3', className)}>
1365
+ <View className="flex-1 gap-0.5">
1366
+ {title ? (
1367
+ <Text size="xs" muted>
1368
+ {title}
1369
+ </Text>
1370
+ ) : null}
1371
+ {value ? (
1372
+ <Text size="xl" weight="bold">
1373
+ {value}
1374
+ </Text>
1375
+ ) : null}
1376
+ {caption ? (
1377
+ <Text size="xs" muted>
1378
+ {caption}
1379
+ </Text>
1380
+ ) : null}
1381
+ </View>
1382
+ {/* Shrinkable, unlike a view's default in React Native. Held rigid, a
1383
+ three-series key takes the width it wants and the caption underneath
1384
+ the value wraps to two lines to make room for it. */}
1385
+ {trailing ? <View className="shrink pt-1">{trailing}</View> : null}
1386
+ </View>
1387
+ );
1388
+ }
1389
+ ScatterChartHeader.displayName = 'ScatterChart.Header';
1390
+ ScatterChartHeader.layer = 'header' as Layer;
1391
+
1392
+ export const ScatterChart = Object.assign(ScatterChartRoot, {
1393
+ Header: ScatterChartHeader,
1394
+ Grid: ScatterChartGrid,
1395
+ Points: ScatterChartPoints,
1396
+ Skeleton: ScatterChartSkeleton,
1397
+ XAxis: ScatterChartXAxis,
1398
+ YAxis: ScatterChartYAxis,
1399
+ Tooltip: ScatterChartTooltip,
1400
+ Legend: ScatterChartLegend,
1401
+ });