panelui-native 0.86.1 → 0.87.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.
- package/README.md +4 -2
- package/lib/module/components/bubble-chart/index.js +1266 -0
- package/lib/module/components/bubble-chart/index.js.map +1 -0
- package/lib/module/components/pyramid-chart/index.js +1143 -0
- package/lib/module/components/pyramid-chart/index.js.map +1 -0
- package/lib/module/components/search-bar/index.js +399 -16
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/bubble-chart/index.d.ts +338 -0
- package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/search-bar/index.d.ts +215 -6
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +14 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/bubble-chart/index.tsx +1527 -0
- package/src/components/pyramid-chart/index.tsx +1360 -0
- package/src/components/search-bar/index.tsx +496 -38
- package/src/index.ts +45 -1
- package/src/utils/chart.ts +27 -0
|
@@ -0,0 +1,1527 @@
|
|
|
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
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How much of the reveal is spent handing out the bubbles' start times. The
|
|
94
|
+
* rest is the window each one gets, so the whole field still lands inside the
|
|
95
|
+
* one duration however many there are.
|
|
96
|
+
*/
|
|
97
|
+
const STAGGER = 0.4;
|
|
98
|
+
|
|
99
|
+
/** Milliseconds for a bubble to swell as it is selected, and settle as it is not. */
|
|
100
|
+
const SELECT_DURATION = 140;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A bubble arriving: up past its size and back to it.
|
|
104
|
+
*
|
|
105
|
+
* A circle that simply grows to its radius reads as the chart still loading
|
|
106
|
+
* right up to the last frame. The small overshoot is what makes it read as
|
|
107
|
+
* landing.
|
|
108
|
+
*/
|
|
109
|
+
function landing(t: number): number {
|
|
110
|
+
'worklet';
|
|
111
|
+
const back = 1.3;
|
|
112
|
+
const u = t - 1;
|
|
113
|
+
return 1 + (back + 1) * u * u * u + back * u * u;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Room left around the plot for the axis labels and the outermost bubbles. */
|
|
117
|
+
const PADDING = { top: 18, right: 18, bottom: 22, left: 18 };
|
|
118
|
+
|
|
119
|
+
/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
|
|
120
|
+
const Y_AXIS_WIDTH = 44;
|
|
121
|
+
|
|
122
|
+
/** Gap between the value labels and the plot they sit beside. */
|
|
123
|
+
const Y_AXIS_GUTTER = 6;
|
|
124
|
+
|
|
125
|
+
/** Line height of an `xs` label, for centring one on the grid line it names. */
|
|
126
|
+
const AXIS_LABEL_HEIGHT = 16;
|
|
127
|
+
|
|
128
|
+
/** Box each axis label is centred in, so a long number is ellipsised not shoved. */
|
|
129
|
+
const AXIS_LABEL_WIDTH = 56;
|
|
130
|
+
|
|
131
|
+
/** Width of the readout that floats by the selected bubble. */
|
|
132
|
+
const LABEL_WIDTH = 132;
|
|
133
|
+
|
|
134
|
+
/** Gap between the readout and the edge of the bubble it describes. */
|
|
135
|
+
const LABEL_GAP = 10;
|
|
136
|
+
|
|
137
|
+
/** Line box a bubble's own label is laid out in. */
|
|
138
|
+
const BUBBLE_LABEL_HEIGHT = 16;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Smallest radius a bubble may have and still be given its label. Below this
|
|
142
|
+
* the name is wider than the circle and reads as text lying on the plot rather
|
|
143
|
+
* than as the bubble's own.
|
|
144
|
+
*
|
|
145
|
+
* Ten points, which is a twenty-point circle: enough for the initial or the
|
|
146
|
+
* short code a bubble chart's labels usually are. Set higher and the smallest
|
|
147
|
+
* bubble in an ordinary set silently loses its name, which reads as a bug
|
|
148
|
+
* rather than as a decision.
|
|
149
|
+
*/
|
|
150
|
+
const LABEL_MIN_RADIUS = 10;
|
|
151
|
+
|
|
152
|
+
/** Floor on the touch target, for a chart whose smallest bubbles are tiny. */
|
|
153
|
+
const HIT_RADIUS = 22;
|
|
154
|
+
|
|
155
|
+
/** How many colours the ramp cycles through. */
|
|
156
|
+
const PALETTE_SIZE = 5;
|
|
157
|
+
|
|
158
|
+
/** Steps each axis is rounded out to. Matches the labels an axis draws. */
|
|
159
|
+
const AXIS_STEPS = 2;
|
|
160
|
+
|
|
161
|
+
type Layer = 'svg' | 'overlay' | 'header' | 'footer';
|
|
162
|
+
|
|
163
|
+
export type BubbleChartStatus = 'loading' | 'ready';
|
|
164
|
+
|
|
165
|
+
export type BubbleChartDatum = Record<string, string | number | null | undefined>;
|
|
166
|
+
|
|
167
|
+
/** One bubble, resolved back to the row it came from. */
|
|
168
|
+
export interface BubbleChartPoint {
|
|
169
|
+
/** Index into `data`. */
|
|
170
|
+
index: number;
|
|
171
|
+
x: number;
|
|
172
|
+
y: number;
|
|
173
|
+
/** The value behind the area, when `sizeKey` is set. */
|
|
174
|
+
size: number | null;
|
|
175
|
+
/** The name written inside the circle, when `labelKey` is set. */
|
|
176
|
+
label: string;
|
|
177
|
+
/** The colour it was drawn in. */
|
|
178
|
+
color: string;
|
|
179
|
+
datum: BubbleChartDatum;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A bubble with its geometry resolved. Shared by every part. */
|
|
183
|
+
interface ResolvedBubble extends BubbleChartPoint {
|
|
184
|
+
/** Radius in points, off the area scale. */
|
|
185
|
+
r: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface BubbleChartContextValue {
|
|
189
|
+
data: BubbleChartDatum[];
|
|
190
|
+
xDataKey: string;
|
|
191
|
+
yDataKey: string;
|
|
192
|
+
labelKey: string | undefined;
|
|
193
|
+
plot: Plot;
|
|
194
|
+
status: BubbleChartStatus;
|
|
195
|
+
bubbles: ResolvedBubble[];
|
|
196
|
+
xMin: SharedValue<number>;
|
|
197
|
+
xMax: SharedValue<number>;
|
|
198
|
+
yMin: SharedValue<number>;
|
|
199
|
+
yMax: SharedValue<number>;
|
|
200
|
+
/** The settled domains, for the parts that draw text rather than geometry. */
|
|
201
|
+
xExtent: [number, number];
|
|
202
|
+
yExtent: [number, number];
|
|
203
|
+
/** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */
|
|
204
|
+
reveal: SharedValue<number>;
|
|
205
|
+
activeIndex: SharedValue<number>;
|
|
206
|
+
activeIndexJS: number;
|
|
207
|
+
setActivePoint: (point: BubbleChartPoint | null) => void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const BubbleChartContext = createContext<BubbleChartContextValue | null>(null);
|
|
211
|
+
|
|
212
|
+
function useChart(component: string): BubbleChartContextValue {
|
|
213
|
+
const context = useContext(BubbleChartContext);
|
|
214
|
+
if (!context) {
|
|
215
|
+
throw new Error(`${component} must be used within a <BubbleChart>`);
|
|
216
|
+
}
|
|
217
|
+
return context;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The bubble under the finger, for something rendered *inside* the chart. A
|
|
222
|
+
* readout in the card's header is outside this provider — use
|
|
223
|
+
* `onActivePointChange` for that.
|
|
224
|
+
*/
|
|
225
|
+
export function useBubbleChart() {
|
|
226
|
+
const { bubbles, activeIndexJS } = useChart('useBubbleChart');
|
|
227
|
+
const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;
|
|
228
|
+
return { activeIndex: activeIndexJS, activePoint: active };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* An extent widened out to round numbers, and a usable one for the degenerate
|
|
233
|
+
* cases — no data at all, or every reading identical. A domain of zero width
|
|
234
|
+
* divides by zero and puts every bubble on the same edge.
|
|
235
|
+
*
|
|
236
|
+
* Rounded rather than padded by a fraction: a fraction of the data's own span
|
|
237
|
+
* ends the axis at 52.7, which is true and which nobody was looking for. Two
|
|
238
|
+
* steps, matching the labels each axis draws by default, so the middle one is
|
|
239
|
+
* round as well as the ends.
|
|
240
|
+
*/
|
|
241
|
+
function padExtent(min: number, max: number): [number, number] {
|
|
242
|
+
if (min === Infinity) return [0, 1];
|
|
243
|
+
if (min === max) return [min - 1, max + 1];
|
|
244
|
+
return niceDomain(min, max, AXIS_STEPS);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface BubbleChartProps
|
|
248
|
+
extends ViewProps,
|
|
249
|
+
ChartAccessibilityProps<BubbleChartDatum> {
|
|
250
|
+
className?: string;
|
|
251
|
+
/** The rows. One bubble each. */
|
|
252
|
+
data: BubbleChartDatum[];
|
|
253
|
+
/** Key holding the horizontal value. */
|
|
254
|
+
xDataKey?: string;
|
|
255
|
+
/** Key holding the vertical value. */
|
|
256
|
+
yDataKey?: string;
|
|
257
|
+
/**
|
|
258
|
+
* Key holding the third quantity, mapped to each bubble's *area*. Without it
|
|
259
|
+
* every bubble is drawn at the middle of `sizeRange` and the chart is a
|
|
260
|
+
* scatter plot with names on it.
|
|
261
|
+
*/
|
|
262
|
+
sizeKey?: string;
|
|
263
|
+
/** Key holding the name written inside the circle. */
|
|
264
|
+
labelKey?: string;
|
|
265
|
+
/**
|
|
266
|
+
* Key holding a colour for the row — either a CSS colour or a number from 1
|
|
267
|
+
* to 5 naming a `--color-chart-*` token. Without it the ramp cycles by row.
|
|
268
|
+
*/
|
|
269
|
+
colorKey?: string;
|
|
270
|
+
/**
|
|
271
|
+
* Smallest and largest radius `sizeKey` maps onto, in points. The largest is
|
|
272
|
+
* also what the plot holds back at every edge, so raising it costs room.
|
|
273
|
+
*/
|
|
274
|
+
sizeRange?: [number, number];
|
|
275
|
+
/**
|
|
276
|
+
* `loading` shows a still field of muted circles and dissolves it as the real
|
|
277
|
+
* bubbles grow in. One component throughout, rather than a spinner swapped
|
|
278
|
+
* for a chart — swapping loses the transition. Add a `BubbleChart.Skeleton`
|
|
279
|
+
* for something to stand in the plot meanwhile.
|
|
280
|
+
*/
|
|
281
|
+
status?: BubbleChartStatus;
|
|
282
|
+
/** Width ÷ height. `1` is the square shape a bubble field reads best in. */
|
|
283
|
+
aspectRatio?: number;
|
|
284
|
+
/** Milliseconds for the bubbles to grow in on mount. */
|
|
285
|
+
animationDuration?: number;
|
|
286
|
+
/** Milliseconds for the axes to settle after the data changes. */
|
|
287
|
+
domainDuration?: number;
|
|
288
|
+
/** Fix the horizontal axis instead of deriving it. */
|
|
289
|
+
xDomain?: [number, number];
|
|
290
|
+
/** Fix the vertical axis instead of deriving it. */
|
|
291
|
+
yDomain?: [number, number];
|
|
292
|
+
/** The bubble under the finger, and `null` when it lifts. */
|
|
293
|
+
onActivePointChange?: (point: BubbleChartPoint | null) => void;
|
|
294
|
+
children?: ReactNode;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Imperative handle: re-run the grow-in, for a "replay" control. */
|
|
298
|
+
export interface BubbleChartHandle {
|
|
299
|
+
replay: () => void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function partition(children: ReactNode) {
|
|
303
|
+
const svg: ReactNode[] = [];
|
|
304
|
+
const overlay: ReactNode[] = [];
|
|
305
|
+
const header: ReactNode[] = [];
|
|
306
|
+
const footer: ReactNode[] = [];
|
|
307
|
+
Children.forEach(children, (child, index) => {
|
|
308
|
+
if (!isValidElement(child)) return;
|
|
309
|
+
const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
|
|
310
|
+
const slot = <ChildSlot key={index}>{child}</ChildSlot>;
|
|
311
|
+
const into =
|
|
312
|
+
layer === 'header' ? header : layer === 'footer' ? footer : layer === 'overlay' ? overlay : svg;
|
|
313
|
+
into.push(slot);
|
|
314
|
+
});
|
|
315
|
+
return { svg, overlay, header, footer };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
319
|
+
return <>{children}</>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps>(
|
|
323
|
+
function BubbleChartRoot(
|
|
324
|
+
{
|
|
325
|
+
className,
|
|
326
|
+
data,
|
|
327
|
+
xDataKey = 'x',
|
|
328
|
+
yDataKey = 'y',
|
|
329
|
+
sizeKey,
|
|
330
|
+
labelKey,
|
|
331
|
+
colorKey,
|
|
332
|
+
sizeRange = [10, 28],
|
|
333
|
+
status = 'ready',
|
|
334
|
+
aspectRatio = 1,
|
|
335
|
+
animationDuration = 800,
|
|
336
|
+
domainDuration = 500,
|
|
337
|
+
xDomain,
|
|
338
|
+
yDomain,
|
|
339
|
+
onActivePointChange,
|
|
340
|
+
accessible,
|
|
341
|
+
accessibilityLabel,
|
|
342
|
+
accessibilityHint,
|
|
343
|
+
accessibilityLabelForDatum,
|
|
344
|
+
onAccessibilityDatumPress,
|
|
345
|
+
children,
|
|
346
|
+
...props
|
|
347
|
+
},
|
|
348
|
+
ref
|
|
349
|
+
) {
|
|
350
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
351
|
+
const [activeIndexJS, setActiveIndexJS] = useState(-1);
|
|
352
|
+
|
|
353
|
+
const reveal = useSharedValue(0);
|
|
354
|
+
const xMin = useSharedValue(0);
|
|
355
|
+
const xMax = useSharedValue(0);
|
|
356
|
+
const yMin = useSharedValue(0);
|
|
357
|
+
const yMax = useSharedValue(0);
|
|
358
|
+
const activeIndex = useSharedValue(-1);
|
|
359
|
+
const reducedMotion = useReducedMotion();
|
|
360
|
+
|
|
361
|
+
/*
|
|
362
|
+
* The whole ramp, resolved once. Every bubble is its own category here
|
|
363
|
+
* rather than a member of a series, so the colour is chosen by row index
|
|
364
|
+
* and there is nothing to register.
|
|
365
|
+
*/
|
|
366
|
+
const chart1 = useSeriesColor(undefined, 1);
|
|
367
|
+
const chart2 = useSeriesColor(undefined, 2);
|
|
368
|
+
const chart3 = useSeriesColor(undefined, 3);
|
|
369
|
+
const chart4 = useSeriesColor(undefined, 4);
|
|
370
|
+
const chart5 = useSeriesColor(undefined, 5);
|
|
371
|
+
const palette = useMemo(
|
|
372
|
+
() => [chart1, chart2, chart3, chart4, chart5],
|
|
373
|
+
[chart1, chart2, chart3, chart4, chart5]
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const hasYAxis = useMemo(() => {
|
|
377
|
+
let found = false;
|
|
378
|
+
Children.forEach(children, (child) => {
|
|
379
|
+
if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
|
|
380
|
+
found = true;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
return found;
|
|
384
|
+
}, [children]);
|
|
385
|
+
|
|
386
|
+
/*
|
|
387
|
+
* A circle is drawn about its centre, so every edge of the plot has to hold
|
|
388
|
+
* back the largest radius or the outermost bubble is cropped by it — and
|
|
389
|
+
* the largest bubble is the one carrying the largest value, which is the
|
|
390
|
+
* last one that should be half missing. `sizeRange` is the ceiling, so this
|
|
391
|
+
* is known before anything is measured.
|
|
392
|
+
*/
|
|
393
|
+
const reach = sizeRange[1];
|
|
394
|
+
const pad = {
|
|
395
|
+
top: Math.max(PADDING.top, reach),
|
|
396
|
+
right: Math.max(PADDING.right, reach),
|
|
397
|
+
bottom: Math.max(PADDING.bottom, reach),
|
|
398
|
+
left: Math.max(hasYAxis ? Y_AXIS_WIDTH : PADDING.left, reach),
|
|
399
|
+
};
|
|
400
|
+
const plot: Plot = {
|
|
401
|
+
left: pad.left,
|
|
402
|
+
top: pad.top,
|
|
403
|
+
width: Math.max(size.width - pad.left - pad.right, 0),
|
|
404
|
+
height: Math.max(size.height - pad.top - pad.bottom, 0),
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const extents = useMemo(() => {
|
|
408
|
+
let lowX = Infinity;
|
|
409
|
+
let highX = -Infinity;
|
|
410
|
+
let lowY = Infinity;
|
|
411
|
+
let highY = -Infinity;
|
|
412
|
+
for (const row of data) {
|
|
413
|
+
const x = row[xDataKey];
|
|
414
|
+
const y = row[yDataKey];
|
|
415
|
+
// A row missing either coordinate is not a bubble at all, and must not
|
|
416
|
+
// stretch the axes towards an origin it never had.
|
|
417
|
+
if (typeof x !== 'number' || Number.isNaN(x)) continue;
|
|
418
|
+
if (typeof y !== 'number' || Number.isNaN(y)) continue;
|
|
419
|
+
if (x < lowX) lowX = x;
|
|
420
|
+
if (x > highX) highX = x;
|
|
421
|
+
if (y < lowY) lowY = y;
|
|
422
|
+
if (y > highY) highY = y;
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
x: xDomain ?? padExtent(lowX, highX),
|
|
426
|
+
y: yDomain ?? padExtent(lowY, highY),
|
|
427
|
+
};
|
|
428
|
+
}, [data, xDataKey, yDataKey, xDomain, yDomain]);
|
|
429
|
+
|
|
430
|
+
/*
|
|
431
|
+
* The size scale runs over the whole data set, so one bubble's area means
|
|
432
|
+
* the same thing as another's. Without a `sizeKey` there is nothing to
|
|
433
|
+
* scale and every bubble takes the middle of the range — which is a
|
|
434
|
+
* scatter plot with names on it, and an honest one.
|
|
435
|
+
*/
|
|
436
|
+
const sizeExtent = useMemo<[number, number] | null>(() => {
|
|
437
|
+
if (!sizeKey) return null;
|
|
438
|
+
let min = Infinity;
|
|
439
|
+
let max = -Infinity;
|
|
440
|
+
for (const row of data) {
|
|
441
|
+
const value = row[sizeKey];
|
|
442
|
+
if (typeof value !== 'number' || Number.isNaN(value)) continue;
|
|
443
|
+
if (value < min) min = value;
|
|
444
|
+
if (value > max) max = value;
|
|
445
|
+
}
|
|
446
|
+
return min === Infinity ? null : [min, max];
|
|
447
|
+
}, [data, sizeKey]);
|
|
448
|
+
|
|
449
|
+
/*
|
|
450
|
+
* Resolved once, in the root, because four parts need exactly this list and
|
|
451
|
+
* three of them would otherwise derive it again: the circles, the labels
|
|
452
|
+
* over them, the hit test under them and the legend beside them all have to
|
|
453
|
+
* agree about where a bubble is and how big it is.
|
|
454
|
+
*/
|
|
455
|
+
const bubbles = useMemo<ResolvedBubble[]>(() => {
|
|
456
|
+
const middle = (sizeRange[0] + sizeRange[1]) / 2;
|
|
457
|
+
const out: ResolvedBubble[] = [];
|
|
458
|
+
data.forEach((datum, index) => {
|
|
459
|
+
const x = datum[xDataKey];
|
|
460
|
+
const y = datum[yDataKey];
|
|
461
|
+
if (typeof x !== 'number' || Number.isNaN(x)) return;
|
|
462
|
+
if (typeof y !== 'number' || Number.isNaN(y)) return;
|
|
463
|
+
|
|
464
|
+
const raw = sizeKey ? datum[sizeKey] : undefined;
|
|
465
|
+
const value = typeof raw === 'number' && !Number.isNaN(raw) ? raw : null;
|
|
466
|
+
const r =
|
|
467
|
+
sizeExtent && value !== null
|
|
468
|
+
? bubbleRadius(value, sizeExtent, sizeRange)
|
|
469
|
+
: middle;
|
|
470
|
+
|
|
471
|
+
const explicit = colorKey ? datum[colorKey] : undefined;
|
|
472
|
+
const color =
|
|
473
|
+
typeof explicit === 'string'
|
|
474
|
+
? explicit
|
|
475
|
+
: typeof explicit === 'number'
|
|
476
|
+
? (palette[(Math.round(explicit) - 1 + PALETTE_SIZE) % PALETTE_SIZE] ??
|
|
477
|
+
palette[0]!)
|
|
478
|
+
: (palette[index % PALETTE_SIZE] ?? palette[0]!);
|
|
479
|
+
|
|
480
|
+
out.push({
|
|
481
|
+
index,
|
|
482
|
+
x,
|
|
483
|
+
y,
|
|
484
|
+
r,
|
|
485
|
+
size: value,
|
|
486
|
+
label: labelKey ? String(datum[labelKey] ?? '') : '',
|
|
487
|
+
color,
|
|
488
|
+
datum,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
return out;
|
|
492
|
+
}, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, sizeExtent, sizeRange, palette]);
|
|
493
|
+
|
|
494
|
+
const loading = status === 'loading';
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (loading) return;
|
|
498
|
+
const [x0, x1] = extents.x;
|
|
499
|
+
const [y0, y1] = extents.y;
|
|
500
|
+
// The first domain lands without a tween: there is no previous scale to
|
|
501
|
+
// move from, and animating up from zero reads as the numbers changing.
|
|
502
|
+
const first =
|
|
503
|
+
xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;
|
|
504
|
+
if (first || reducedMotion) {
|
|
505
|
+
xMin.value = x0;
|
|
506
|
+
xMax.value = x1;
|
|
507
|
+
yMin.value = y0;
|
|
508
|
+
yMax.value = y1;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
xMin.value = withTiming(x0, { duration: domainDuration });
|
|
512
|
+
xMax.value = withTiming(x1, { duration: domainDuration });
|
|
513
|
+
yMin.value = withTiming(y0, { duration: domainDuration });
|
|
514
|
+
yMax.value = withTiming(y1, { duration: domainDuration });
|
|
515
|
+
}, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);
|
|
516
|
+
|
|
517
|
+
const revealed = useRef(false);
|
|
518
|
+
const playReveal = useMemo(
|
|
519
|
+
() => () => {
|
|
520
|
+
if (reducedMotion) {
|
|
521
|
+
reveal.value = 1;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
reveal.value = 0;
|
|
525
|
+
/*
|
|
526
|
+
* Eased out rather than in and out. Each bubble is given a slice of
|
|
527
|
+
* this one clock, so an ease that dawdles at the start spends it on the
|
|
528
|
+
* first few and leaves the rest to arrive in a rush.
|
|
529
|
+
*/
|
|
530
|
+
reveal.value = withTiming(1, {
|
|
531
|
+
duration: animationDuration,
|
|
532
|
+
easing: Easing.out(Easing.cubic),
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
[reducedMotion, animationDuration, reveal]
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (loading) {
|
|
540
|
+
revealed.current = false;
|
|
541
|
+
reveal.value = 0;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (revealed.current || plot.width <= 0 || !bubbles.length) return;
|
|
545
|
+
revealed.current = true;
|
|
546
|
+
playReveal();
|
|
547
|
+
}, [loading, plot.width, bubbles.length, playReveal, reveal]);
|
|
548
|
+
|
|
549
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
550
|
+
|
|
551
|
+
// One place the selection lands, so the chart's own children and a readout
|
|
552
|
+
// outside it never disagree about which bubble is active.
|
|
553
|
+
const setActivePoint = useMemo(
|
|
554
|
+
() => (point: BubbleChartPoint | null) => {
|
|
555
|
+
setActiveIndexJS(point ? point.index : -1);
|
|
556
|
+
onActivePointChange?.(point);
|
|
557
|
+
},
|
|
558
|
+
[onActivePointChange]
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
562
|
+
const { width, height } = event.nativeEvent.layout;
|
|
563
|
+
setSize((current) =>
|
|
564
|
+
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
565
|
+
? current
|
|
566
|
+
: { width, height }
|
|
567
|
+
);
|
|
568
|
+
props.onLayout?.(event);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const context = useMemo<BubbleChartContextValue>(
|
|
572
|
+
() => ({
|
|
573
|
+
data,
|
|
574
|
+
xDataKey,
|
|
575
|
+
yDataKey,
|
|
576
|
+
labelKey,
|
|
577
|
+
plot,
|
|
578
|
+
status,
|
|
579
|
+
bubbles,
|
|
580
|
+
xMin,
|
|
581
|
+
xMax,
|
|
582
|
+
yMin,
|
|
583
|
+
yMax,
|
|
584
|
+
xExtent: extents.x,
|
|
585
|
+
yExtent: extents.y,
|
|
586
|
+
reveal,
|
|
587
|
+
activeIndex,
|
|
588
|
+
activeIndexJS,
|
|
589
|
+
setActivePoint,
|
|
590
|
+
}),
|
|
591
|
+
// `plot` is rebuilt every render from `size`, so it is compared by value.
|
|
592
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
593
|
+
[
|
|
594
|
+
data,
|
|
595
|
+
xDataKey,
|
|
596
|
+
yDataKey,
|
|
597
|
+
labelKey,
|
|
598
|
+
plot.width,
|
|
599
|
+
plot.height,
|
|
600
|
+
plot.left,
|
|
601
|
+
plot.top,
|
|
602
|
+
status,
|
|
603
|
+
bubbles,
|
|
604
|
+
xMin,
|
|
605
|
+
xMax,
|
|
606
|
+
yMin,
|
|
607
|
+
yMax,
|
|
608
|
+
extents,
|
|
609
|
+
reveal,
|
|
610
|
+
activeIndex,
|
|
611
|
+
activeIndexJS,
|
|
612
|
+
setActivePoint,
|
|
613
|
+
]
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const { svg, overlay, header, footer } = partition(children);
|
|
617
|
+
|
|
618
|
+
/*
|
|
619
|
+
* Two views, because the header is not part of the plot. `aspectRatio` and
|
|
620
|
+
* the layout measurement belong to the drawing area alone — measured on the
|
|
621
|
+
* outer view they would take in the header too, and the plot would lose as
|
|
622
|
+
* much height as the readout took while still claiming the shape asked for.
|
|
623
|
+
*/
|
|
624
|
+
return (
|
|
625
|
+
<BubbleChartContext.Provider value={context}>
|
|
626
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
627
|
+
{header}
|
|
628
|
+
<ChartAccessibilityData
|
|
629
|
+
chart="Bubble chart"
|
|
630
|
+
data={data}
|
|
631
|
+
disabled={accessible === false || loading}
|
|
632
|
+
accessibilityLabel={accessibilityLabel}
|
|
633
|
+
accessibilityHint={accessibilityHint}
|
|
634
|
+
accessibilityLabelForDatum={accessibilityLabelForDatum}
|
|
635
|
+
onAccessibilityDatumPress={onAccessibilityDatumPress}
|
|
636
|
+
valueOf={(datum) => {
|
|
637
|
+
const pairs: [string, unknown][] = [
|
|
638
|
+
[xDataKey, datum[xDataKey]],
|
|
639
|
+
[yDataKey, datum[yDataKey]],
|
|
640
|
+
];
|
|
641
|
+
if (labelKey) pairs.unshift([labelKey, datum[labelKey]]);
|
|
642
|
+
if (sizeKey) pairs.push([sizeKey, datum[sizeKey]]);
|
|
643
|
+
return pairs;
|
|
644
|
+
}}
|
|
645
|
+
/>
|
|
646
|
+
<View
|
|
647
|
+
onLayout={onLayout}
|
|
648
|
+
style={{ aspectRatio }}
|
|
649
|
+
className="w-full"
|
|
650
|
+
accessibilityElementsHidden
|
|
651
|
+
importantForAccessibility="no-hide-descendants"
|
|
652
|
+
>
|
|
653
|
+
{plot.width > 0 ? (
|
|
654
|
+
<>
|
|
655
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
656
|
+
{svg}
|
|
657
|
+
</Svg>
|
|
658
|
+
{overlay}
|
|
659
|
+
</>
|
|
660
|
+
) : null}
|
|
661
|
+
</View>
|
|
662
|
+
{footer}
|
|
663
|
+
</View>
|
|
664
|
+
</BubbleChartContext.Provider>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
);
|
|
668
|
+
BubbleChartRoot.displayName = 'BubbleChart';
|
|
669
|
+
|
|
670
|
+
/* -------------------------------------------------------------------------- */
|
|
671
|
+
/* SVG layer */
|
|
672
|
+
/* -------------------------------------------------------------------------- */
|
|
673
|
+
|
|
674
|
+
export interface BubbleChartGridProps {
|
|
675
|
+
/** Horizontal rules across the plot. */
|
|
676
|
+
rows?: number;
|
|
677
|
+
/** Vertical rules up it. Both axes are measured, so both earn lines. */
|
|
678
|
+
columns?: number;
|
|
679
|
+
/*
|
|
680
|
+
* Both default to five. A coarse grid draws a handful of large squares that
|
|
681
|
+
* read as blocks behind the bubbles rather than as reference lines; a finer
|
|
682
|
+
* one recedes and lets the circles be the thing on the chart.
|
|
683
|
+
*/
|
|
684
|
+
color?: string;
|
|
685
|
+
opacity?: number;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Reference lines both ways, so a bubble can be placed against two numbers. */
|
|
689
|
+
function BubbleChartGrid({
|
|
690
|
+
rows = 5,
|
|
691
|
+
columns = 5,
|
|
692
|
+
color,
|
|
693
|
+
opacity = 1,
|
|
694
|
+
}: BubbleChartGridProps) {
|
|
695
|
+
const { plot } = useChart('BubbleChart.Grid');
|
|
696
|
+
const token = useCSSVariable('--color-border');
|
|
697
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
|
|
698
|
+
|
|
699
|
+
const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows);
|
|
700
|
+
const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns);
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
<G opacity={opacity}>
|
|
704
|
+
{horizontals.map((fraction) => (
|
|
705
|
+
<SvgLine
|
|
706
|
+
key={`h${fraction}`}
|
|
707
|
+
x1={plot.left}
|
|
708
|
+
x2={plot.left + plot.width}
|
|
709
|
+
y1={plot.top + plot.height * fraction}
|
|
710
|
+
y2={plot.top + plot.height * fraction}
|
|
711
|
+
stroke={stroke}
|
|
712
|
+
strokeWidth={1}
|
|
713
|
+
/>
|
|
714
|
+
))}
|
|
715
|
+
{verticals.map((fraction) => (
|
|
716
|
+
<SvgLine
|
|
717
|
+
key={`v${fraction}`}
|
|
718
|
+
x1={plot.left + plot.width * fraction}
|
|
719
|
+
x2={plot.left + plot.width * fraction}
|
|
720
|
+
y1={plot.top}
|
|
721
|
+
y2={plot.top + plot.height}
|
|
722
|
+
stroke={stroke}
|
|
723
|
+
strokeWidth={1}
|
|
724
|
+
/>
|
|
725
|
+
))}
|
|
726
|
+
</G>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
BubbleChartGrid.displayName = 'BubbleChart.Grid';
|
|
730
|
+
BubbleChartGrid.layer = 'svg' as Layer;
|
|
731
|
+
|
|
732
|
+
export interface BubbleChartBubblesProps {
|
|
733
|
+
/**
|
|
734
|
+
* Fill opacity. Below 1 by default so that overlapping bubbles read as
|
|
735
|
+
* denser rather than hiding each other — in a crowded corner that overlap
|
|
736
|
+
* *is* the finding, and opaque circles erase it.
|
|
737
|
+
*/
|
|
738
|
+
opacity?: number;
|
|
739
|
+
/** One colour for every bubble, overriding the per-row ramp. */
|
|
740
|
+
color?: string;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The circles. */
|
|
744
|
+
function BubbleChartBubbles({ opacity = 0.9, color }: BubbleChartBubblesProps) {
|
|
745
|
+
const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal, activeIndex } =
|
|
746
|
+
useChart('BubbleChart.Bubbles');
|
|
747
|
+
|
|
748
|
+
if (status === 'loading') return null;
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
<G>
|
|
752
|
+
{bubbles.map((bubble, order) => (
|
|
753
|
+
<Bubble
|
|
754
|
+
key={bubble.index}
|
|
755
|
+
bubble={bubble}
|
|
756
|
+
plot={plot}
|
|
757
|
+
xMin={xMin}
|
|
758
|
+
xMax={xMax}
|
|
759
|
+
yMin={yMin}
|
|
760
|
+
yMax={yMax}
|
|
761
|
+
fill={color ?? bubble.color}
|
|
762
|
+
opacity={opacity}
|
|
763
|
+
activeIndex={activeIndex}
|
|
764
|
+
reveal={reveal}
|
|
765
|
+
order={order}
|
|
766
|
+
total={bubbles.length}
|
|
767
|
+
/>
|
|
768
|
+
))}
|
|
769
|
+
</G>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
BubbleChartBubbles.displayName = 'BubbleChart.Bubbles';
|
|
773
|
+
BubbleChartBubbles.layer = 'svg' as Layer;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* One bubble. Its position follows both domain tweens, so a data change moves
|
|
777
|
+
* the whole field to the new scale rather than cutting to it.
|
|
778
|
+
*
|
|
779
|
+
* It arrives by growing in place, on its own slice of the shared reveal. A wipe
|
|
780
|
+
* across the plot — which is what the line and area charts do — gives the
|
|
781
|
+
* reader a direction to read the arrival in, and a field of bubbles has none:
|
|
782
|
+
* position is the whole message, so a bubble may only ever appear where it
|
|
783
|
+
* belongs.
|
|
784
|
+
*/
|
|
785
|
+
function Bubble({
|
|
786
|
+
bubble,
|
|
787
|
+
plot,
|
|
788
|
+
xMin,
|
|
789
|
+
xMax,
|
|
790
|
+
yMin,
|
|
791
|
+
yMax,
|
|
792
|
+
fill,
|
|
793
|
+
opacity,
|
|
794
|
+
activeIndex,
|
|
795
|
+
reveal,
|
|
796
|
+
order,
|
|
797
|
+
total,
|
|
798
|
+
}: {
|
|
799
|
+
bubble: ResolvedBubble;
|
|
800
|
+
plot: Plot;
|
|
801
|
+
xMin: SharedValue<number>;
|
|
802
|
+
xMax: SharedValue<number>;
|
|
803
|
+
yMin: SharedValue<number>;
|
|
804
|
+
yMax: SharedValue<number>;
|
|
805
|
+
fill: string;
|
|
806
|
+
opacity: number;
|
|
807
|
+
activeIndex: SharedValue<number>;
|
|
808
|
+
reveal: SharedValue<number>;
|
|
809
|
+
order: number;
|
|
810
|
+
total: number;
|
|
811
|
+
}) {
|
|
812
|
+
const { index, x, y, r } = bubble;
|
|
813
|
+
|
|
814
|
+
/*
|
|
815
|
+
* The selection, as something that moves. A hard switch made the bubble it
|
|
816
|
+
* named jump between one frame and the next while every neighbour stayed put,
|
|
817
|
+
* which reads as a glitch rather than as a response to the finger.
|
|
818
|
+
*/
|
|
819
|
+
const selected = useDerivedValue(() =>
|
|
820
|
+
withTiming(activeIndex.value === index ? 1 : 0, { duration: SELECT_DURATION })
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
// Where in the reveal this bubble starts. Spread over `STAGGER`, so the field
|
|
824
|
+
// settles as a field rather than switching on all at once.
|
|
825
|
+
const start = total > 1 ? (order / total) * STAGGER : 0;
|
|
826
|
+
|
|
827
|
+
const animatedProps = useAnimatedProps(() => {
|
|
828
|
+
const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
|
|
829
|
+
// The selected bubble swells and goes solid. Both, rather than one: a size
|
|
830
|
+
// change alone is easy to miss among neighbours, and an opacity change
|
|
831
|
+
// alone is invisible wherever the circles already overlap.
|
|
832
|
+
const swell = 1 + 0.12 * selected.value;
|
|
833
|
+
return {
|
|
834
|
+
cx: xAt(x, plot, xMin.value, xMax.value),
|
|
835
|
+
cy: yOf(y, plot, yMin.value, yMax.value),
|
|
836
|
+
r: r * landing(arrived) * swell,
|
|
837
|
+
// Ahead of the size, so a bubble is legible by the time it stops moving
|
|
838
|
+
// rather than fading in for the whole of its arrival.
|
|
839
|
+
fillOpacity:
|
|
840
|
+
Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
return <AnimatedCircle animatedProps={animatedProps} fill={fill} />;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export interface BubbleChartSkeletonProps {
|
|
848
|
+
/** How many placeholder circles to scatter. */
|
|
849
|
+
count?: number;
|
|
850
|
+
color?: string;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The loading state: a still field of muted circles where the data will be.
|
|
855
|
+
*
|
|
856
|
+
* Deliberately still. A shimmer over a field of circles reads as them *moving*,
|
|
857
|
+
* which is the one thing this chart must never appear to do — position is the
|
|
858
|
+
* entire message, and a loading state that implies it is changing is a loading
|
|
859
|
+
* state that lies.
|
|
860
|
+
*
|
|
861
|
+
* The layout is deterministic rather than random, so it does not reshuffle on
|
|
862
|
+
* every render of a component that may re-render several times while waiting.
|
|
863
|
+
* It dissolves as the real bubbles grow in, and outlives the status change by
|
|
864
|
+
* exactly that long: cut at the frame the data lands, the placeholder would
|
|
865
|
+
* disappear before anything had replaced it.
|
|
866
|
+
*/
|
|
867
|
+
function BubbleChartSkeleton({ count = 7, color }: BubbleChartSkeletonProps) {
|
|
868
|
+
const { plot, status } = useChart('BubbleChart.Skeleton');
|
|
869
|
+
const token = useCSSVariable('--color-skeleton');
|
|
870
|
+
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
871
|
+
|
|
872
|
+
const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');
|
|
873
|
+
|
|
874
|
+
const circles = useMemo(
|
|
875
|
+
() =>
|
|
876
|
+
// Two irrational-ish strides that do not share a factor, so the circles
|
|
877
|
+
// spread instead of falling into a lattice.
|
|
878
|
+
Array.from({ length: count }, (_unused, index) => ({
|
|
879
|
+
key: index,
|
|
880
|
+
fx: ((index * 0.618) % 1) * 0.84 + 0.08,
|
|
881
|
+
fy: ((index * 0.379) % 1) * 0.84 + 0.08,
|
|
882
|
+
r: 16 + ((index * 0.472) % 1) * 12,
|
|
883
|
+
})),
|
|
884
|
+
[count]
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));
|
|
888
|
+
|
|
889
|
+
if (!mounted) return null;
|
|
890
|
+
|
|
891
|
+
return (
|
|
892
|
+
<AnimatedG animatedProps={animatedProps}>
|
|
893
|
+
{circles.map((circle) => (
|
|
894
|
+
<Circle
|
|
895
|
+
key={circle.key}
|
|
896
|
+
cx={plot.left + circle.fx * plot.width}
|
|
897
|
+
cy={plot.top + circle.fy * plot.height}
|
|
898
|
+
r={circle.r}
|
|
899
|
+
fill={fill}
|
|
900
|
+
/>
|
|
901
|
+
))}
|
|
902
|
+
</AnimatedG>
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
BubbleChartSkeleton.displayName = 'BubbleChart.Skeleton';
|
|
906
|
+
BubbleChartSkeleton.layer = 'svg' as Layer;
|
|
907
|
+
|
|
908
|
+
/* -------------------------------------------------------------------------- */
|
|
909
|
+
/* Overlay layer */
|
|
910
|
+
/* -------------------------------------------------------------------------- */
|
|
911
|
+
|
|
912
|
+
export interface BubbleChartLabelsProps {
|
|
913
|
+
/**
|
|
914
|
+
* Smallest radius a bubble may have and still be given its label. Below it
|
|
915
|
+
* the name is wider than the circle it names.
|
|
916
|
+
*/
|
|
917
|
+
minRadius?: number;
|
|
918
|
+
/** Turn a bubble into its label. Defaults to the value at `labelKey`. */
|
|
919
|
+
format?: (point: BubbleChartPoint) => string;
|
|
920
|
+
className?: string;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The names, written inside the circles.
|
|
925
|
+
*
|
|
926
|
+
* Real text over the plot rather than SVG text, so they follow the theme's font
|
|
927
|
+
* and the platform's text scaling. Each one rides the same domain tweens the
|
|
928
|
+
* circle under it does, so a label never lags the bubble it belongs to.
|
|
929
|
+
*
|
|
930
|
+
* A bubble too small to hold its name is left without one. Shrinking the text
|
|
931
|
+
* to fit would make it unreadable on exactly the bubbles the reader is
|
|
932
|
+
* squinting at already; the readout names those instead.
|
|
933
|
+
*/
|
|
934
|
+
function BubbleChartLabels({
|
|
935
|
+
minRadius = LABEL_MIN_RADIUS,
|
|
936
|
+
format,
|
|
937
|
+
className,
|
|
938
|
+
}: BubbleChartLabelsProps) {
|
|
939
|
+
const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =
|
|
940
|
+
useChart('BubbleChart.Labels');
|
|
941
|
+
|
|
942
|
+
if (status === 'loading') return null;
|
|
943
|
+
|
|
944
|
+
return (
|
|
945
|
+
<View style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
|
946
|
+
{bubbles
|
|
947
|
+
.filter((bubble) => bubble.r >= minRadius && (format || bubble.label))
|
|
948
|
+
.map((bubble, order) => (
|
|
949
|
+
<BubbleLabel
|
|
950
|
+
key={bubble.index}
|
|
951
|
+
bubble={bubble}
|
|
952
|
+
text={format ? format(bubble) : bubble.label}
|
|
953
|
+
plot={plot}
|
|
954
|
+
xMin={xMin}
|
|
955
|
+
xMax={xMax}
|
|
956
|
+
yMin={yMin}
|
|
957
|
+
yMax={yMax}
|
|
958
|
+
reveal={reveal}
|
|
959
|
+
order={order}
|
|
960
|
+
total={bubbles.length}
|
|
961
|
+
className={className}
|
|
962
|
+
/>
|
|
963
|
+
))}
|
|
964
|
+
</View>
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
BubbleChartLabels.displayName = 'BubbleChart.Labels';
|
|
968
|
+
BubbleChartLabels.layer = 'overlay' as Layer;
|
|
969
|
+
|
|
970
|
+
function BubbleLabel({
|
|
971
|
+
bubble,
|
|
972
|
+
text,
|
|
973
|
+
plot,
|
|
974
|
+
xMin,
|
|
975
|
+
xMax,
|
|
976
|
+
yMin,
|
|
977
|
+
yMax,
|
|
978
|
+
reveal,
|
|
979
|
+
order,
|
|
980
|
+
total,
|
|
981
|
+
className,
|
|
982
|
+
}: {
|
|
983
|
+
bubble: ResolvedBubble;
|
|
984
|
+
text: string;
|
|
985
|
+
plot: Plot;
|
|
986
|
+
xMin: SharedValue<number>;
|
|
987
|
+
xMax: SharedValue<number>;
|
|
988
|
+
yMin: SharedValue<number>;
|
|
989
|
+
yMax: SharedValue<number>;
|
|
990
|
+
reveal: SharedValue<number>;
|
|
991
|
+
order: number;
|
|
992
|
+
total: number;
|
|
993
|
+
className?: string;
|
|
994
|
+
}) {
|
|
995
|
+
const { x, y, r } = bubble;
|
|
996
|
+
const width = r * 2;
|
|
997
|
+
const start = total > 1 ? (order / total) * STAGGER : 0;
|
|
998
|
+
|
|
999
|
+
const style = useAnimatedStyle(() => {
|
|
1000
|
+
const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
|
|
1001
|
+
return {
|
|
1002
|
+
opacity: Math.max(0, arrived * 2 - 1),
|
|
1003
|
+
transform: [
|
|
1004
|
+
{ translateX: xAt(x, plot, xMin.value, xMax.value) - width / 2 },
|
|
1005
|
+
{ translateY: yOf(y, plot, yMin.value, yMax.value) - BUBBLE_LABEL_HEIGHT / 2 },
|
|
1006
|
+
],
|
|
1007
|
+
};
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
return (
|
|
1011
|
+
<Animated.View
|
|
1012
|
+
pointerEvents="none"
|
|
1013
|
+
style={[
|
|
1014
|
+
{ position: 'absolute', left: 0, top: 0, width, height: BUBBLE_LABEL_HEIGHT },
|
|
1015
|
+
style,
|
|
1016
|
+
]}
|
|
1017
|
+
>
|
|
1018
|
+
<Text
|
|
1019
|
+
size="xs"
|
|
1020
|
+
weight="medium"
|
|
1021
|
+
numberOfLines={1}
|
|
1022
|
+
// White on the fill, which is a chart colour in every theme rather than
|
|
1023
|
+
// a surface — so this is the one place a literal is right: the token
|
|
1024
|
+
// that reads on a card would vanish on the bubble.
|
|
1025
|
+
className={cn('text-center text-white', className)}
|
|
1026
|
+
>
|
|
1027
|
+
{text}
|
|
1028
|
+
</Text>
|
|
1029
|
+
</Animated.View>
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
export interface BubbleChartXAxisProps {
|
|
1034
|
+
/** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
|
|
1035
|
+
ticks?: number;
|
|
1036
|
+
/** Turn a value into its label. Defaults to a compact number. */
|
|
1037
|
+
format?: (value: number) => string;
|
|
1038
|
+
className?: string;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The x labels, evenly along the axis.
|
|
1043
|
+
*
|
|
1044
|
+
* Evenly spaced, because this axis is a continuous scale rather than a list of
|
|
1045
|
+
* rows. There is no bubble for a label to sit under.
|
|
1046
|
+
*/
|
|
1047
|
+
function BubbleChartXAxis({ ticks = 2, format, className }: BubbleChartXAxisProps) {
|
|
1048
|
+
const { plot, xExtent } = useChart('BubbleChart.XAxis');
|
|
1049
|
+
|
|
1050
|
+
const labels = useMemo(() => {
|
|
1051
|
+
const [min, max] = xExtent;
|
|
1052
|
+
if (min === 0 && max === 0) return [];
|
|
1053
|
+
return Array.from({ length: ticks + 1 }, (_unused, index) => {
|
|
1054
|
+
const value = min + ((max - min) * index) / ticks;
|
|
1055
|
+
return { key: index, text: format ? format(value) : compactNumber(value) };
|
|
1056
|
+
});
|
|
1057
|
+
}, [xExtent, ticks, format]);
|
|
1058
|
+
|
|
1059
|
+
return (
|
|
1060
|
+
<View
|
|
1061
|
+
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
|
1062
|
+
className={cn(className)}
|
|
1063
|
+
>
|
|
1064
|
+
{labels.map((label) => (
|
|
1065
|
+
<Text
|
|
1066
|
+
key={label.key}
|
|
1067
|
+
size="xs"
|
|
1068
|
+
muted
|
|
1069
|
+
numberOfLines={1}
|
|
1070
|
+
style={{
|
|
1071
|
+
position: 'absolute',
|
|
1072
|
+
bottom: 0,
|
|
1073
|
+
// Centred on its tick, then held inside the chart. The first and
|
|
1074
|
+
// last ticks sit on the plot's own edges, so a box centred on them
|
|
1075
|
+
// hangs half its width off the side — the clamp slides those two
|
|
1076
|
+
// back in rather than letting the numbers leave the frame.
|
|
1077
|
+
left: Math.max(
|
|
1078
|
+
0,
|
|
1079
|
+
Math.min(
|
|
1080
|
+
plot.left + (plot.width / ticks) * label.key - AXIS_LABEL_WIDTH / 2,
|
|
1081
|
+
plot.left + plot.width + PADDING.right - AXIS_LABEL_WIDTH
|
|
1082
|
+
)
|
|
1083
|
+
),
|
|
1084
|
+
width: AXIS_LABEL_WIDTH,
|
|
1085
|
+
textAlign: 'center',
|
|
1086
|
+
}}
|
|
1087
|
+
>
|
|
1088
|
+
{label.text}
|
|
1089
|
+
</Text>
|
|
1090
|
+
))}
|
|
1091
|
+
</View>
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
BubbleChartXAxis.displayName = 'BubbleChart.XAxis';
|
|
1095
|
+
BubbleChartXAxis.layer = 'overlay' as Layer;
|
|
1096
|
+
|
|
1097
|
+
export interface BubbleChartYAxisProps {
|
|
1098
|
+
/** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
|
|
1099
|
+
ticks?: number;
|
|
1100
|
+
/** Turn a value into its label. Defaults to a compact number. */
|
|
1101
|
+
format?: (value: number) => string;
|
|
1102
|
+
className?: string;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Value labels down the side, one per grid line. Reserves its own gutter. */
|
|
1106
|
+
function BubbleChartYAxis({ ticks = 2, format, className }: BubbleChartYAxisProps) {
|
|
1107
|
+
const { plot, yExtent } = useChart('BubbleChart.YAxis');
|
|
1108
|
+
|
|
1109
|
+
const labels = useMemo(() => {
|
|
1110
|
+
const [min, max] = yExtent;
|
|
1111
|
+
if (min === 0 && max === 0) return [];
|
|
1112
|
+
return Array.from({ length: ticks + 1 }, (_unused, index) => {
|
|
1113
|
+
const value = max - ((max - min) * index) / ticks;
|
|
1114
|
+
return { key: index, text: format ? format(value) : compactNumber(value) };
|
|
1115
|
+
});
|
|
1116
|
+
}, [yExtent, ticks, format]);
|
|
1117
|
+
|
|
1118
|
+
return (
|
|
1119
|
+
<View
|
|
1120
|
+
pointerEvents="none"
|
|
1121
|
+
style={{
|
|
1122
|
+
position: 'absolute',
|
|
1123
|
+
left: 0,
|
|
1124
|
+
// Centred on the grid line each label names: the strip is lifted half a
|
|
1125
|
+
// label and grown by a whole one, so `justify-between` lands the text's
|
|
1126
|
+
// middle on the line rather than its top edge on the first.
|
|
1127
|
+
top: plot.top - AXIS_LABEL_HEIGHT / 2,
|
|
1128
|
+
height: plot.height + AXIS_LABEL_HEIGHT,
|
|
1129
|
+
width: Math.max(plot.left - Y_AXIS_GUTTER, 0),
|
|
1130
|
+
}}
|
|
1131
|
+
className={cn('items-end justify-between', className)}
|
|
1132
|
+
>
|
|
1133
|
+
{labels.map((label) => (
|
|
1134
|
+
<Text key={label.key} size="xs" muted numberOfLines={1}>
|
|
1135
|
+
{label.text}
|
|
1136
|
+
</Text>
|
|
1137
|
+
))}
|
|
1138
|
+
</View>
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
BubbleChartYAxis.displayName = 'BubbleChart.YAxis';
|
|
1142
|
+
BubbleChartYAxis.layer = 'overlay' as Layer;
|
|
1143
|
+
// Read by the root, which has to leave room for the labels before it lays the
|
|
1144
|
+
// plot out.
|
|
1145
|
+
BubbleChartYAxis.axis = 'y' as const;
|
|
1146
|
+
|
|
1147
|
+
export interface BubbleChartTooltipProps {
|
|
1148
|
+
/** Float a small readout beside the selected bubble. On by default. */
|
|
1149
|
+
showLabel?: boolean;
|
|
1150
|
+
/** Format the x value for the readout. Defaults to a compact number. */
|
|
1151
|
+
formatX?: (value: number) => string;
|
|
1152
|
+
/** Format the y value for the readout. Defaults to a compact number. */
|
|
1153
|
+
formatY?: (value: number) => string;
|
|
1154
|
+
/** Format the size value for the readout. Defaults to a compact number. */
|
|
1155
|
+
formatSize?: (value: number) => string;
|
|
1156
|
+
/** Floor on the touch target, for a chart whose smallest bubbles are tiny. */
|
|
1157
|
+
hitRadius?: number;
|
|
1158
|
+
className?: string;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* The touch target, the selection it drives, and the readout that follows it.
|
|
1163
|
+
*
|
|
1164
|
+
* A touch picks the nearest bubble whose own circle — or the `hitRadius` floor,
|
|
1165
|
+
* whichever is larger — reaches the finger. Nearest rather than topmost,
|
|
1166
|
+
* because where bubbles overlap the one drawn last is not the one being aimed
|
|
1167
|
+
* at.
|
|
1168
|
+
*
|
|
1169
|
+
* The search runs on the UI thread over flat arrays of already-projected
|
|
1170
|
+
* coordinates, and only the *index* of the winner crosses back into JS, and
|
|
1171
|
+
* only when it changes. A drag across the plot therefore costs a handful of
|
|
1172
|
+
* re-renders rather than one per frame.
|
|
1173
|
+
*
|
|
1174
|
+
* Distances are compared squared. The nearest bubble by distance is the nearest
|
|
1175
|
+
* by distance-squared, and a square root per bubble per frame buys nothing.
|
|
1176
|
+
*/
|
|
1177
|
+
function BubbleChartTooltip({
|
|
1178
|
+
showLabel = true,
|
|
1179
|
+
formatX,
|
|
1180
|
+
formatY,
|
|
1181
|
+
formatSize,
|
|
1182
|
+
hitRadius = HIT_RADIUS,
|
|
1183
|
+
className,
|
|
1184
|
+
}: BubbleChartTooltipProps) {
|
|
1185
|
+
const {
|
|
1186
|
+
bubbles,
|
|
1187
|
+
plot,
|
|
1188
|
+
xExtent,
|
|
1189
|
+
yExtent,
|
|
1190
|
+
activeIndex,
|
|
1191
|
+
activeIndexJS,
|
|
1192
|
+
setActivePoint,
|
|
1193
|
+
status,
|
|
1194
|
+
} = useChart('BubbleChart.Tooltip');
|
|
1195
|
+
|
|
1196
|
+
/*
|
|
1197
|
+
* Every bubble, projected once, as parallel arrays.
|
|
1198
|
+
*
|
|
1199
|
+
* Parallel arrays rather than an array of objects because this is read inside
|
|
1200
|
+
* a worklet: Reanimated has to copy whatever the gesture captures across to
|
|
1201
|
+
* the UI thread, and four number arrays cross far more cheaply than a few
|
|
1202
|
+
* hundred small objects.
|
|
1203
|
+
*
|
|
1204
|
+
* Projected against the *settled* extents rather than the tweening shared
|
|
1205
|
+
* values. Hit-testing against a moving scale would mean rebuilding this on
|
|
1206
|
+
* every frame of a domain animation, and a bubble being half a second stale
|
|
1207
|
+
* during a transition is not something a finger can notice.
|
|
1208
|
+
*/
|
|
1209
|
+
const hit = useMemo(() => {
|
|
1210
|
+
const xs: number[] = [];
|
|
1211
|
+
const ys: number[] = [];
|
|
1212
|
+
const rs: number[] = [];
|
|
1213
|
+
const limits: number[] = [];
|
|
1214
|
+
const indices: number[] = [];
|
|
1215
|
+
for (const bubble of bubbles) {
|
|
1216
|
+
xs.push(xAt(bubble.x, plot, xExtent[0], xExtent[1]));
|
|
1217
|
+
ys.push(yOf(bubble.y, plot, yExtent[0], yExtent[1]));
|
|
1218
|
+
rs.push(bubble.r);
|
|
1219
|
+
const reach = Math.max(bubble.r, hitRadius);
|
|
1220
|
+
limits.push(reach * reach);
|
|
1221
|
+
indices.push(bubble.index);
|
|
1222
|
+
}
|
|
1223
|
+
return { xs, ys, rs, limits, indices };
|
|
1224
|
+
}, [bubbles, plot, xExtent, yExtent, hitRadius]);
|
|
1225
|
+
|
|
1226
|
+
const select = useMemo(
|
|
1227
|
+
() => (index: number) => {
|
|
1228
|
+
if (index < 0) {
|
|
1229
|
+
setActivePoint(null);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
setActivePoint(bubbles.find((bubble) => bubble.index === index) ?? null);
|
|
1233
|
+
},
|
|
1234
|
+
[bubbles, setActivePoint]
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
/*
|
|
1238
|
+
* Built in one closure, and everything it captures is a plain array, a number
|
|
1239
|
+
* or a shared value. A worklet may only call another worklet, and the rule is
|
|
1240
|
+
* enforced by crashing rather than by warning — so the resolver is declared
|
|
1241
|
+
* here, next to its callers, rather than as a helper elsewhere in the file
|
|
1242
|
+
* where it would be easy to leave un-workletised.
|
|
1243
|
+
*/
|
|
1244
|
+
const pan = useMemo(() => {
|
|
1245
|
+
const xs = hit.xs;
|
|
1246
|
+
const ys = hit.ys;
|
|
1247
|
+
const limits = hit.limits;
|
|
1248
|
+
const indices = hit.indices;
|
|
1249
|
+
|
|
1250
|
+
const resolve = (px: number, py: number) => {
|
|
1251
|
+
'worklet';
|
|
1252
|
+
let bestIndex = -1;
|
|
1253
|
+
let best = Infinity;
|
|
1254
|
+
for (let i = 0; i < xs.length; i += 1) {
|
|
1255
|
+
const dx = xs[i]! - px;
|
|
1256
|
+
const dy = ys[i]! - py;
|
|
1257
|
+
const distance = dx * dx + dy * dy;
|
|
1258
|
+
// Each bubble reaches as far as it is big, so a large one is not
|
|
1259
|
+
// beaten by a small one that happens to be marginally nearer.
|
|
1260
|
+
if (distance <= limits[i]! && distance < best) {
|
|
1261
|
+
best = distance;
|
|
1262
|
+
bestIndex = indices[i]!;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (bestIndex === activeIndex.value) return;
|
|
1266
|
+
activeIndex.value = bestIndex;
|
|
1267
|
+
runOnJS(select)(bestIndex);
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const clear = () => {
|
|
1271
|
+
'worklet';
|
|
1272
|
+
if (activeIndex.value === -1) return;
|
|
1273
|
+
activeIndex.value = -1;
|
|
1274
|
+
runOnJS(select)(-1);
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
return Gesture.Pan()
|
|
1278
|
+
.minDistance(0)
|
|
1279
|
+
.onBegin((event) => {
|
|
1280
|
+
'worklet';
|
|
1281
|
+
resolve(event.x, event.y);
|
|
1282
|
+
})
|
|
1283
|
+
.onUpdate((event) => {
|
|
1284
|
+
'worklet';
|
|
1285
|
+
resolve(event.x, event.y);
|
|
1286
|
+
})
|
|
1287
|
+
.onFinalize(() => {
|
|
1288
|
+
'worklet';
|
|
1289
|
+
clear();
|
|
1290
|
+
});
|
|
1291
|
+
}, [hit, activeIndex, select]);
|
|
1292
|
+
|
|
1293
|
+
/*
|
|
1294
|
+
* The readout's own height, measured rather than assumed. It decides whether
|
|
1295
|
+
* the readout fits above the bubble, and how tall it is depends on whether
|
|
1296
|
+
* the row has a label and a size — a constant would either overlap a
|
|
1297
|
+
* three-line readout or reserve room a one-line one never uses.
|
|
1298
|
+
*/
|
|
1299
|
+
const labelHeight = useSharedValue(0);
|
|
1300
|
+
|
|
1301
|
+
/*
|
|
1302
|
+
* Above the bubble, clear of its edge rather than of its centre, and clamped
|
|
1303
|
+
* inside the plot. Lifted by a constant it landed *on* the larger circles —
|
|
1304
|
+
* which are exactly the ones a finger is most likely to be resting on, so the
|
|
1305
|
+
* readout was hidden under the hand that summoned it.
|
|
1306
|
+
*
|
|
1307
|
+
* Where there is no room above, it goes below instead. Sliding it down to the
|
|
1308
|
+
* top edge of the plot would leave it over the bubble again.
|
|
1309
|
+
*/
|
|
1310
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
1311
|
+
const index = activeIndex.value;
|
|
1312
|
+
if (index < 0) return { opacity: 0 };
|
|
1313
|
+
const at = hit.indices.indexOf(index);
|
|
1314
|
+
if (at < 0) return { opacity: 0 };
|
|
1315
|
+
const x = hit.xs[at]!;
|
|
1316
|
+
const y = hit.ys[at]!;
|
|
1317
|
+
const r = hit.rs[at]!;
|
|
1318
|
+
const half = LABEL_WIDTH / 2;
|
|
1319
|
+
const tall = labelHeight.value;
|
|
1320
|
+
|
|
1321
|
+
const above = y - r - LABEL_GAP - tall;
|
|
1322
|
+
const below = y + r + LABEL_GAP;
|
|
1323
|
+
const top = above >= plot.top ? above : below;
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
opacity: 1,
|
|
1327
|
+
transform: [
|
|
1328
|
+
{
|
|
1329
|
+
translateX:
|
|
1330
|
+
Math.min(plot.left + plot.width - half, Math.max(plot.left + half, x)) - half,
|
|
1331
|
+
},
|
|
1332
|
+
{ translateY: top },
|
|
1333
|
+
],
|
|
1334
|
+
};
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;
|
|
1338
|
+
const fmtX = formatX ?? compactNumber;
|
|
1339
|
+
const fmtY = formatY ?? compactNumber;
|
|
1340
|
+
const fmtSize = formatSize ?? compactNumber;
|
|
1341
|
+
|
|
1342
|
+
if (status === 'loading') return null;
|
|
1343
|
+
|
|
1344
|
+
return (
|
|
1345
|
+
<GestureDetector gesture={pan}>
|
|
1346
|
+
<View style={StyleSheet.absoluteFill}>
|
|
1347
|
+
{showLabel ? (
|
|
1348
|
+
<Animated.View
|
|
1349
|
+
pointerEvents="none"
|
|
1350
|
+
style={[
|
|
1351
|
+
{ position: 'absolute', left: 0, top: 0, width: LABEL_WIDTH },
|
|
1352
|
+
labelStyle,
|
|
1353
|
+
]}
|
|
1354
|
+
>
|
|
1355
|
+
{active ? (
|
|
1356
|
+
<View
|
|
1357
|
+
onLayout={(event) => {
|
|
1358
|
+
labelHeight.value = event.nativeEvent.layout.height;
|
|
1359
|
+
}}
|
|
1360
|
+
className={cn(
|
|
1361
|
+
'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
|
|
1362
|
+
className
|
|
1363
|
+
)}
|
|
1364
|
+
>
|
|
1365
|
+
{active.label ? (
|
|
1366
|
+
<View className="flex-row items-center gap-1.5">
|
|
1367
|
+
<View
|
|
1368
|
+
style={{
|
|
1369
|
+
width: 6,
|
|
1370
|
+
height: 6,
|
|
1371
|
+
borderRadius: 3,
|
|
1372
|
+
backgroundColor: active.color,
|
|
1373
|
+
}}
|
|
1374
|
+
/>
|
|
1375
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1376
|
+
{active.label}
|
|
1377
|
+
</Text>
|
|
1378
|
+
</View>
|
|
1379
|
+
) : null}
|
|
1380
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1381
|
+
{fmtX(active.x)}, {fmtY(active.y)}
|
|
1382
|
+
</Text>
|
|
1383
|
+
{active.size !== null ? (
|
|
1384
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1385
|
+
{fmtSize(active.size)}
|
|
1386
|
+
</Text>
|
|
1387
|
+
) : null}
|
|
1388
|
+
</View>
|
|
1389
|
+
) : null}
|
|
1390
|
+
</Animated.View>
|
|
1391
|
+
) : null}
|
|
1392
|
+
</View>
|
|
1393
|
+
</GestureDetector>
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
BubbleChartTooltip.displayName = 'BubbleChart.Tooltip';
|
|
1397
|
+
BubbleChartTooltip.layer = 'overlay' as Layer;
|
|
1398
|
+
|
|
1399
|
+
export interface BubbleChartLegendProps extends ViewProps {
|
|
1400
|
+
className?: string;
|
|
1401
|
+
/** Cap on how many bubbles are named. The rest are left to the readout. */
|
|
1402
|
+
limit?: number;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* A swatch and a name per bubble, for a chart whose circles are too small to
|
|
1407
|
+
* carry their own labels.
|
|
1408
|
+
*
|
|
1409
|
+
* Drawn **under** the plot rather than floating in a corner of it. A key that
|
|
1410
|
+
* overlays the drawing area competes with the bubbles for the space they are
|
|
1411
|
+
* plotted in, and on a square chart there is no corner that is reliably empty —
|
|
1412
|
+
* the position of a bubble is the data, so nowhere can be reserved for it.
|
|
1413
|
+
*
|
|
1414
|
+
* It lists rows rather than series, because in this chart a row *is* a
|
|
1415
|
+
* category. Use it instead of `BubbleChart.Labels`, not beside it — the same
|
|
1416
|
+
* names twice is the legend telling the reader what the plot already says.
|
|
1417
|
+
*/
|
|
1418
|
+
function BubbleChartLegend({ className, limit = 8, ...props }: BubbleChartLegendProps) {
|
|
1419
|
+
const { bubbles } = useChart('BubbleChart.Legend');
|
|
1420
|
+
const shown = bubbles.filter((bubble) => bubble.label).slice(0, limit);
|
|
1421
|
+
if (!shown.length) return null;
|
|
1422
|
+
|
|
1423
|
+
return (
|
|
1424
|
+
<View
|
|
1425
|
+
{...props}
|
|
1426
|
+
style={[{ pointerEvents: 'none' }, props.style]}
|
|
1427
|
+
className={cn(
|
|
1428
|
+
'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1 pt-3',
|
|
1429
|
+
className
|
|
1430
|
+
)}
|
|
1431
|
+
>
|
|
1432
|
+
{shown.map((bubble) => (
|
|
1433
|
+
<View key={bubble.index} className="flex-row items-center gap-1.5">
|
|
1434
|
+
<View
|
|
1435
|
+
style={{ backgroundColor: bubble.color }}
|
|
1436
|
+
className="h-2 w-2 rounded-full"
|
|
1437
|
+
/>
|
|
1438
|
+
<Text size="xs" muted>
|
|
1439
|
+
{bubble.label}
|
|
1440
|
+
</Text>
|
|
1441
|
+
</View>
|
|
1442
|
+
))}
|
|
1443
|
+
</View>
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
BubbleChartLegend.displayName = 'BubbleChart.Legend';
|
|
1447
|
+
BubbleChartLegend.layer = 'footer' as Layer;
|
|
1448
|
+
|
|
1449
|
+
/* -------------------------------------------------------------------------- */
|
|
1450
|
+
/* Header layer */
|
|
1451
|
+
/* -------------------------------------------------------------------------- */
|
|
1452
|
+
|
|
1453
|
+
export interface BubbleChartHeaderProps extends ViewProps {
|
|
1454
|
+
className?: string;
|
|
1455
|
+
/** Small line above the value — what the chart is of. */
|
|
1456
|
+
title?: string;
|
|
1457
|
+
/** The readout. The largest thing on the card, and the first thing read. */
|
|
1458
|
+
value?: string;
|
|
1459
|
+
/** One muted line under the value — what the area means, usually. */
|
|
1460
|
+
caption?: string;
|
|
1461
|
+
/** Trailing slot — a control, a badge, a range picker. */
|
|
1462
|
+
children?: ReactNode;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* The strip above the plot: what the chart is of, what it currently reads, and
|
|
1467
|
+
* what the size of a circle means.
|
|
1468
|
+
*
|
|
1469
|
+
* The caption earns its place here more than on most charts. Two axes and an
|
|
1470
|
+
* area is three quantities, and a reader who is not told what the area is has
|
|
1471
|
+
* no way to work it out from the picture.
|
|
1472
|
+
*
|
|
1473
|
+
* The value is not derived here. A readout that follows the finger belongs to
|
|
1474
|
+
* whoever owns the data — take it from `onActivePointChange` and pass the
|
|
1475
|
+
* formatted string down.
|
|
1476
|
+
*/
|
|
1477
|
+
function BubbleChartHeader({
|
|
1478
|
+
className,
|
|
1479
|
+
title,
|
|
1480
|
+
value,
|
|
1481
|
+
caption,
|
|
1482
|
+
children,
|
|
1483
|
+
...props
|
|
1484
|
+
}: BubbleChartHeaderProps) {
|
|
1485
|
+
return (
|
|
1486
|
+
<View
|
|
1487
|
+
{...props}
|
|
1488
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1489
|
+
>
|
|
1490
|
+
<View className="flex-1 gap-0.5">
|
|
1491
|
+
{title ? (
|
|
1492
|
+
<Text size="xs" muted>
|
|
1493
|
+
{title}
|
|
1494
|
+
</Text>
|
|
1495
|
+
) : null}
|
|
1496
|
+
{value ? (
|
|
1497
|
+
<Text size="xl" weight="bold">
|
|
1498
|
+
{value}
|
|
1499
|
+
</Text>
|
|
1500
|
+
) : null}
|
|
1501
|
+
{caption ? (
|
|
1502
|
+
<Text size="xs" muted>
|
|
1503
|
+
{caption}
|
|
1504
|
+
</Text>
|
|
1505
|
+
) : null}
|
|
1506
|
+
</View>
|
|
1507
|
+
{/* Shrinkable, unlike a view's default in React Native. Held rigid, a
|
|
1508
|
+
control takes the width it wants and the caption underneath the value
|
|
1509
|
+
wraps to two lines to make room for it. */}
|
|
1510
|
+
{children ? <View className="shrink pt-1">{children}</View> : null}
|
|
1511
|
+
</View>
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
BubbleChartHeader.displayName = 'BubbleChart.Header';
|
|
1515
|
+
BubbleChartHeader.layer = 'header' as Layer;
|
|
1516
|
+
|
|
1517
|
+
export const BubbleChart = Object.assign(BubbleChartRoot, {
|
|
1518
|
+
Header: BubbleChartHeader,
|
|
1519
|
+
Grid: BubbleChartGrid,
|
|
1520
|
+
Bubbles: BubbleChartBubbles,
|
|
1521
|
+
Labels: BubbleChartLabels,
|
|
1522
|
+
Skeleton: BubbleChartSkeleton,
|
|
1523
|
+
XAxis: BubbleChartXAxis,
|
|
1524
|
+
YAxis: BubbleChartYAxis,
|
|
1525
|
+
Tooltip: BubbleChartTooltip,
|
|
1526
|
+
Legend: BubbleChartLegend,
|
|
1527
|
+
});
|