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