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