panelui-native 0.43.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/card/index.js +8 -154
- package/lib/module/components/card/index.js.map +1 -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/card/index.d.ts +0 -20
- package/lib/typescript/src/components/card/index.d.ts.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 +7 -3
- 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/card/index.tsx +7 -209
- 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 +66 -18
- 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,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PieChart — one whole, divided between its parts.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <PieChart data={spend} innerRadius={0.58}>
|
|
6
|
+
* <PieChart.Header title="This month" value="$18,420" legend />
|
|
7
|
+
* <PieChart.Slices />
|
|
8
|
+
* <PieChart.Center />
|
|
9
|
+
* </PieChart>
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* ## What it is, against the ring beside it
|
|
13
|
+
*
|
|
14
|
+
* A ring chart draws a value against *its own* target, so three rings can all
|
|
15
|
+
* sit at ninety percent of three unrelated numbers and nothing has to add up.
|
|
16
|
+
* A pie is the opposite claim: every slice is a share of one total, the angles
|
|
17
|
+
* must come to a full turn, and a slice only means anything next to the others.
|
|
18
|
+
* That is why nothing here takes a `maxValue` and why every value is normalised
|
|
19
|
+
* against the sum — the sum *is* the subject.
|
|
20
|
+
*
|
|
21
|
+
* It follows that a pie is the wrong shape for a great many things. Two numbers
|
|
22
|
+
* that do not belong to one whole, a series over time, anything a reader has to
|
|
23
|
+
* compare precisely: all of those are a bar chart, because an angle is the
|
|
24
|
+
* hardest quantity to read off a page and the fifth-largest slice of eleven is
|
|
25
|
+
* not a fact anybody is going to extract. Use it for a handful of parts of one
|
|
26
|
+
* obvious total, and put the number in the middle.
|
|
27
|
+
*
|
|
28
|
+
* ## Drawing
|
|
29
|
+
*
|
|
30
|
+
* Each slice is a filled path rather than a stroked arc, because a slice is a
|
|
31
|
+
* *region* — two arcs and two radial edges — and a stroke is a band of even
|
|
32
|
+
* thickness with no ends of its own. `wedgePath` builds it, and rebuilds it on
|
|
33
|
+
* the UI thread on every frame of the reveal.
|
|
34
|
+
*
|
|
35
|
+
* That is what makes the reveal an unroll rather than a fade: one angle sweeps
|
|
36
|
+
* clockwise from the start and each slice is drawn only as far as it has got
|
|
37
|
+
* to. The pie fills the way it would be drawn by hand, and the slices arrive in
|
|
38
|
+
* the order they are listed rather than all at once.
|
|
39
|
+
*
|
|
40
|
+
* Touch, not hover: a slice is selected by pressing it, and pressing the same
|
|
41
|
+
* one again clears the selection. There is no equivalent of a pointer resting
|
|
42
|
+
* somewhere without committing, so a chart that only revealed its numbers on
|
|
43
|
+
* hover would never reveal them at all.
|
|
44
|
+
*/
|
|
45
|
+
import {
|
|
46
|
+
Children,
|
|
47
|
+
createContext,
|
|
48
|
+
forwardRef,
|
|
49
|
+
isValidElement,
|
|
50
|
+
useContext,
|
|
51
|
+
useEffect,
|
|
52
|
+
useImperativeHandle,
|
|
53
|
+
useMemo,
|
|
54
|
+
useRef,
|
|
55
|
+
useState,
|
|
56
|
+
type ReactNode,
|
|
57
|
+
} from 'react';
|
|
58
|
+
import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
59
|
+
import Animated, {
|
|
60
|
+
Easing,
|
|
61
|
+
useAnimatedProps,
|
|
62
|
+
useDerivedValue,
|
|
63
|
+
useReducedMotion,
|
|
64
|
+
useSharedValue,
|
|
65
|
+
withTiming,
|
|
66
|
+
type SharedValue,
|
|
67
|
+
} from 'react-native-reanimated';
|
|
68
|
+
import Svg, { G, Path } from 'react-native-svg';
|
|
69
|
+
import { useCSSVariable } from 'uniwind';
|
|
70
|
+
import { Text } from '../../primitives/text';
|
|
71
|
+
import { compactNumber, useSeriesColor, wedgePath } from '../../utils/chart';
|
|
72
|
+
import { cn } from '../../utils/cn';
|
|
73
|
+
|
|
74
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
75
|
+
|
|
76
|
+
/** Milliseconds for a slice to lift out and settle back as it is selected. */
|
|
77
|
+
const SELECT_DURATION = 180;
|
|
78
|
+
|
|
79
|
+
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
|
|
80
|
+
type Slot = 'svg' | 'overlay' | 'header' | 'footer';
|
|
81
|
+
|
|
82
|
+
/** Whether the chart is showing data or waiting for it. */
|
|
83
|
+
export type PieChartStatus = 'loading' | 'ready';
|
|
84
|
+
|
|
85
|
+
/** One slice. Its share is worked out from the others, so there is no maximum. */
|
|
86
|
+
export interface PieDatum {
|
|
87
|
+
/** Name for the legend, the centre readout and the accessibility label. */
|
|
88
|
+
label: string;
|
|
89
|
+
/** How much of the whole this slice is. Negatives are treated as zero. */
|
|
90
|
+
value: number;
|
|
91
|
+
/** Explicit colour, overriding the `--color-chart-*` token. */
|
|
92
|
+
color?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A slice's place on the dial, in turns clockwise from twelve o'clock. */
|
|
96
|
+
interface SliceAngles {
|
|
97
|
+
from: number;
|
|
98
|
+
to: number;
|
|
99
|
+
/** Halfway along it, which is the direction it lifts out in. */
|
|
100
|
+
mid: number;
|
|
101
|
+
/** Its share of the total, 0 to 1. */
|
|
102
|
+
fraction: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface PieChartContextValue {
|
|
106
|
+
data: PieDatum[];
|
|
107
|
+
/** Everything the values add up to. Zero when there is nothing to show. */
|
|
108
|
+
total: number;
|
|
109
|
+
size: number;
|
|
110
|
+
/** Outer radius in points, after the room for lifting a slice out is taken. */
|
|
111
|
+
radius: number;
|
|
112
|
+
/** Inner radius in points. Zero for a pie, above it for a donut. */
|
|
113
|
+
hole: number;
|
|
114
|
+
colors: string[];
|
|
115
|
+
slices: SliceAngles[];
|
|
116
|
+
/** Where the dial starts and how far it runs, both in turns. */
|
|
117
|
+
origin: number;
|
|
118
|
+
span: number;
|
|
119
|
+
/** 0 to 1 as the pie unrolls. */
|
|
120
|
+
reveal: SharedValue<number>;
|
|
121
|
+
status: PieChartStatus;
|
|
122
|
+
activeIndex: number;
|
|
123
|
+
setActiveIndex: (index: number) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PieChartContext = createContext<PieChartContextValue | null>(null);
|
|
127
|
+
|
|
128
|
+
function useChart(component: string): PieChartContextValue {
|
|
129
|
+
const context = useContext(PieChartContext);
|
|
130
|
+
if (!context) {
|
|
131
|
+
throw new Error(`${component} must be used within a <PieChart>`);
|
|
132
|
+
}
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The selected slice and its share, for something rendered inside the chart. */
|
|
137
|
+
export function usePieChart() {
|
|
138
|
+
const { data, slices, activeIndex } = useChart('usePieChart');
|
|
139
|
+
return {
|
|
140
|
+
activeIndex,
|
|
141
|
+
activeSlice: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,
|
|
142
|
+
/** The selected slice's share of the whole, 0 to 1. */
|
|
143
|
+
activeFraction: activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PieChartProps extends ViewProps {
|
|
148
|
+
className?: string;
|
|
149
|
+
/** One entry per slice, in the order they are drawn clockwise. */
|
|
150
|
+
data: PieDatum[];
|
|
151
|
+
/** Fixed diameter in points. Measured from the container when omitted. */
|
|
152
|
+
size?: number;
|
|
153
|
+
/**
|
|
154
|
+
* The hole, as a share of the radius. `0` is a pie; anything above it is a
|
|
155
|
+
* donut, and `0.55`–`0.65` is the range that leaves room for a readout in the
|
|
156
|
+
* middle without the band getting thin enough to be hard to hit.
|
|
157
|
+
*
|
|
158
|
+
* Given as a share rather than in points so a chart keeps its proportions at
|
|
159
|
+
* whatever size it is measured at.
|
|
160
|
+
*/
|
|
161
|
+
innerRadius?: number;
|
|
162
|
+
/** Where the first slice begins, in degrees clockwise from twelve o'clock. */
|
|
163
|
+
startAngle?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Where the last one ends, on the same clock. Leaving a turn's worth between
|
|
166
|
+
* the two gives a closed pie; anything less leaves a gap and reads as a dial.
|
|
167
|
+
*/
|
|
168
|
+
endAngle?: number;
|
|
169
|
+
/** Gap between one slice and the next, in degrees. */
|
|
170
|
+
padAngle?: number;
|
|
171
|
+
/**
|
|
172
|
+
* The smallest angle any non-zero slice is drawn at, in degrees.
|
|
173
|
+
*
|
|
174
|
+
* A slice worth a fifth of a percent is a hairline nobody can see and nobody
|
|
175
|
+
* can press, so it reads as missing rather than as small — and "missing" is a
|
|
176
|
+
* different claim from "nearly none". The angle it borrows comes off the
|
|
177
|
+
* others in proportion, so the turn still closes.
|
|
178
|
+
*/
|
|
179
|
+
minAngle?: number;
|
|
180
|
+
/** Milliseconds for the pie to unroll. */
|
|
181
|
+
animationDuration?: number;
|
|
182
|
+
/** `loading` draws a plain muted ring until the data arrives. */
|
|
183
|
+
status?: PieChartStatus;
|
|
184
|
+
/** Selected slice. Leave unset to let the chart track it. */
|
|
185
|
+
activeIndex?: number;
|
|
186
|
+
/** Fires with the selected slice, or `-1` when the selection is cleared. */
|
|
187
|
+
onActiveIndexChange?: (index: number) => void;
|
|
188
|
+
children?: ReactNode;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Imperative handle: re-run the unroll, for a "replay" control. */
|
|
192
|
+
export interface PieChartHandle {
|
|
193
|
+
replay: () => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Room kept outside the pie so a selected slice has somewhere to lift into. */
|
|
197
|
+
const LIFT = 8;
|
|
198
|
+
|
|
199
|
+
const PieChartRoot = forwardRef<PieChartHandle, PieChartProps>(function PieChartRoot(
|
|
200
|
+
{
|
|
201
|
+
className,
|
|
202
|
+
data,
|
|
203
|
+
size,
|
|
204
|
+
innerRadius = 0,
|
|
205
|
+
startAngle = 0,
|
|
206
|
+
endAngle = 360,
|
|
207
|
+
padAngle = 0,
|
|
208
|
+
minAngle = 0,
|
|
209
|
+
animationDuration = 900,
|
|
210
|
+
status = 'ready',
|
|
211
|
+
activeIndex: activeIndexProp,
|
|
212
|
+
onActiveIndexChange,
|
|
213
|
+
children,
|
|
214
|
+
...props
|
|
215
|
+
},
|
|
216
|
+
ref
|
|
217
|
+
) {
|
|
218
|
+
const [measured, setMeasured] = useState(0);
|
|
219
|
+
const [internalActive, setInternalActive] = useState(-1);
|
|
220
|
+
const reveal = useSharedValue(0);
|
|
221
|
+
const reducedMotion = useReducedMotion();
|
|
222
|
+
|
|
223
|
+
const controlled = activeIndexProp !== undefined;
|
|
224
|
+
const activeIndex = controlled ? activeIndexProp : internalActive;
|
|
225
|
+
|
|
226
|
+
const setActiveIndex = useMemo(
|
|
227
|
+
() => (index: number) => {
|
|
228
|
+
if (!controlled) setInternalActive(index);
|
|
229
|
+
onActiveIndexChange?.(index);
|
|
230
|
+
},
|
|
231
|
+
[controlled, onActiveIndexChange]
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const box = size ?? measured;
|
|
235
|
+
const radius = Math.max(box / 2 - LIFT, 0);
|
|
236
|
+
const hole = radius * Math.max(0, Math.min(innerRadius, 0.95));
|
|
237
|
+
|
|
238
|
+
const origin = startAngle / 360;
|
|
239
|
+
// Clamped to one turn, because a pie drawn past 360° laps itself and the
|
|
240
|
+
// slice underneath is simply gone.
|
|
241
|
+
const span = Math.min(Math.max(endAngle - startAngle, 0), 360) / 360;
|
|
242
|
+
|
|
243
|
+
const total = useMemo(
|
|
244
|
+
() => data.reduce((sum, slice) => sum + Math.max(0, slice.value), 0),
|
|
245
|
+
[data]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const slices = useMemo<SliceAngles[]>(() => {
|
|
249
|
+
const count = data.length;
|
|
250
|
+
if (!count || total <= 0 || span <= 0) return [];
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
* The gaps and the floors are taken out of the turn *first*, and what is
|
|
254
|
+
* left is shared by value. Adding them instead would push the last slice
|
|
255
|
+
* past the end of the dial, which on a closed pie means over the first one.
|
|
256
|
+
*/
|
|
257
|
+
const pad = Math.min(padAngle / 360, span / (count * 2));
|
|
258
|
+
const gaps = count > 1 || span < 1 ? pad * count : 0;
|
|
259
|
+
const floor = Math.min(minAngle / 360, (span - gaps) / (count * 2));
|
|
260
|
+
|
|
261
|
+
const drawn = data.filter((slice) => Math.max(0, slice.value) > 0).length;
|
|
262
|
+
const floors = floor * drawn;
|
|
263
|
+
const free = Math.max(span - gaps - floors, 0);
|
|
264
|
+
|
|
265
|
+
const result: SliceAngles[] = [];
|
|
266
|
+
let cursor = origin + pad / 2;
|
|
267
|
+
|
|
268
|
+
for (const slice of data) {
|
|
269
|
+
const value = Math.max(0, slice.value);
|
|
270
|
+
const fraction = value / total;
|
|
271
|
+
const sweep = value > 0 ? floor + free * fraction : 0;
|
|
272
|
+
result.push({
|
|
273
|
+
from: cursor,
|
|
274
|
+
to: cursor + sweep,
|
|
275
|
+
mid: cursor + sweep / 2,
|
|
276
|
+
fraction,
|
|
277
|
+
});
|
|
278
|
+
cursor += sweep + pad;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}, [data, total, origin, span, padAngle, minAngle]);
|
|
283
|
+
|
|
284
|
+
const playReveal = useMemo(
|
|
285
|
+
() => () => {
|
|
286
|
+
if (reducedMotion) {
|
|
287
|
+
reveal.value = 1;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
reveal.value = 0;
|
|
291
|
+
reveal.value = withTiming(1, {
|
|
292
|
+
duration: animationDuration,
|
|
293
|
+
easing: Easing.bezier(0.85, 0, 0.15, 1),
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
[reducedMotion, animationDuration, reveal]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const loading = status === 'loading';
|
|
300
|
+
const revealed = useRef(false);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (loading) {
|
|
304
|
+
revealed.current = false;
|
|
305
|
+
reveal.value = 0;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (revealed.current || box <= 0 || !slices.length) return;
|
|
309
|
+
revealed.current = true;
|
|
310
|
+
playReveal();
|
|
311
|
+
}, [loading, box, slices.length, playReveal, reveal]);
|
|
312
|
+
|
|
313
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
314
|
+
|
|
315
|
+
// Resolved here rather than inside the slices, so the legend, the header and
|
|
316
|
+
// the centre readout can name a slice's colour without drawing one.
|
|
317
|
+
const c1 = useSeriesColor(undefined, 1);
|
|
318
|
+
const c2 = useSeriesColor(undefined, 2);
|
|
319
|
+
const c3 = useSeriesColor(undefined, 3);
|
|
320
|
+
const c4 = useSeriesColor(undefined, 4);
|
|
321
|
+
const c5 = useSeriesColor(undefined, 5);
|
|
322
|
+
const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);
|
|
323
|
+
const colors = useMemo(
|
|
324
|
+
() => data.map((slice, index) => slice.color ?? palette[index % palette.length]!),
|
|
325
|
+
[data, palette]
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
329
|
+
const { width, height } = event.nativeEvent.layout;
|
|
330
|
+
const next = Math.round(Math.min(width, height));
|
|
331
|
+
if (next !== measured) setMeasured(next);
|
|
332
|
+
props.onLayout?.(event);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const context = useMemo<PieChartContextValue>(
|
|
336
|
+
() => ({
|
|
337
|
+
data,
|
|
338
|
+
total,
|
|
339
|
+
size: box,
|
|
340
|
+
radius,
|
|
341
|
+
hole,
|
|
342
|
+
colors,
|
|
343
|
+
slices,
|
|
344
|
+
origin,
|
|
345
|
+
span,
|
|
346
|
+
reveal,
|
|
347
|
+
status,
|
|
348
|
+
activeIndex,
|
|
349
|
+
setActiveIndex,
|
|
350
|
+
}),
|
|
351
|
+
[
|
|
352
|
+
data,
|
|
353
|
+
total,
|
|
354
|
+
box,
|
|
355
|
+
radius,
|
|
356
|
+
hole,
|
|
357
|
+
colors,
|
|
358
|
+
slices,
|
|
359
|
+
origin,
|
|
360
|
+
span,
|
|
361
|
+
reveal,
|
|
362
|
+
status,
|
|
363
|
+
activeIndex,
|
|
364
|
+
setActiveIndex,
|
|
365
|
+
]
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const slots: Record<Slot, ReactNode[]> = {
|
|
369
|
+
svg: [],
|
|
370
|
+
overlay: [],
|
|
371
|
+
header: [],
|
|
372
|
+
footer: [],
|
|
373
|
+
};
|
|
374
|
+
Children.forEach(children, (child, index) => {
|
|
375
|
+
if (!isValidElement(child)) return;
|
|
376
|
+
const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
|
|
377
|
+
slots[slot in slots ? slot : 'overlay'].push(
|
|
378
|
+
<ChildSlot key={index}>{child}</ChildSlot>
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<PieChartContext.Provider value={context}>
|
|
384
|
+
{/*
|
|
385
|
+
* Two views, because the header is not part of the dial. The square and
|
|
386
|
+
* the layout measurement belong to the drawing area alone — measured on
|
|
387
|
+
* the outer view they would take in the header too, and the pie would be
|
|
388
|
+
* laid out inside a box taller than the one it is drawn in.
|
|
389
|
+
*/}
|
|
390
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
391
|
+
{slots.header}
|
|
392
|
+
<View
|
|
393
|
+
onLayout={onLayout}
|
|
394
|
+
style={size ? { width: size, height: size } : { aspectRatio: 1 }}
|
|
395
|
+
className={cn('items-center justify-center', size ? 'self-center' : 'w-full')}
|
|
396
|
+
>
|
|
397
|
+
{box > 0 ? (
|
|
398
|
+
<>
|
|
399
|
+
<Svg width={box} height={box}>
|
|
400
|
+
{slots.svg}
|
|
401
|
+
</Svg>
|
|
402
|
+
{/*
|
|
403
|
+
* The centre sits over the SVG rather than inside it: it is text,
|
|
404
|
+
* and SVG text ignores the platform's text scaling and the
|
|
405
|
+
* theme's font.
|
|
406
|
+
*/}
|
|
407
|
+
<View
|
|
408
|
+
pointerEvents="box-none"
|
|
409
|
+
style={{ position: 'absolute', width: box, height: box }}
|
|
410
|
+
className="items-center justify-center"
|
|
411
|
+
>
|
|
412
|
+
{slots.overlay}
|
|
413
|
+
</View>
|
|
414
|
+
</>
|
|
415
|
+
) : null}
|
|
416
|
+
</View>
|
|
417
|
+
{/*
|
|
418
|
+
* The key goes *under* the square, in flow, rather than in the corners
|
|
419
|
+
* left over inside it. A ring chart can get away with the corners
|
|
420
|
+
* because three arcs is a long key; a pie is routinely five or six
|
|
421
|
+
* slices with names like "Everything else", and a key of that size laid
|
|
422
|
+
* over the drawing either covers it or is squeezed to one letter a line.
|
|
423
|
+
*/}
|
|
424
|
+
{slots.footer}
|
|
425
|
+
</View>
|
|
426
|
+
</PieChartContext.Provider>
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
PieChartRoot.displayName = 'PieChart';
|
|
430
|
+
|
|
431
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
432
|
+
return <>{children}</>;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface PieChartSlicesProps {
|
|
436
|
+
/** Rounds the four turns of each slice, in points. */
|
|
437
|
+
cornerRadius?: number;
|
|
438
|
+
/** How far a selected slice lifts out of the pie, in points. */
|
|
439
|
+
popOut?: number;
|
|
440
|
+
/** Opacity of the slices that are not selected, once one is. */
|
|
441
|
+
dimOpacity?: number;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Every slice, drawn in the order the data lists them.
|
|
446
|
+
*
|
|
447
|
+
* One part rather than one per datum, unlike the rings next door. A ring is
|
|
448
|
+
* configured on its own — its own thickness, its own cap, its own segment count
|
|
449
|
+
* — because it is its own measurement. Slices of a pie are not: they share a
|
|
450
|
+
* radius, a hole and a dial by definition, and a chart where one of them could
|
|
451
|
+
* be given a different radius would be a chart drawing a lie.
|
|
452
|
+
*/
|
|
453
|
+
function PieChartSlices({
|
|
454
|
+
cornerRadius = 0,
|
|
455
|
+
popOut = 6,
|
|
456
|
+
dimOpacity = 0.35,
|
|
457
|
+
}: PieChartSlicesProps) {
|
|
458
|
+
const { data, slices, colors, radius, hole, size, reveal, origin, span, status, activeIndex, setActiveIndex } =
|
|
459
|
+
useChart('PieChart.Slices');
|
|
460
|
+
|
|
461
|
+
if (status === 'loading' || radius <= 0) return null;
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<G>
|
|
465
|
+
{slices.map((angles, index) => {
|
|
466
|
+
const datum = data[index];
|
|
467
|
+
if (!datum || angles.to <= angles.from) return null;
|
|
468
|
+
return (
|
|
469
|
+
<Slice
|
|
470
|
+
key={datum.label}
|
|
471
|
+
angles={angles}
|
|
472
|
+
fill={colors[index] ?? colors[0]!}
|
|
473
|
+
centre={size / 2}
|
|
474
|
+
radius={radius}
|
|
475
|
+
hole={hole}
|
|
476
|
+
cornerRadius={cornerRadius}
|
|
477
|
+
popOut={popOut}
|
|
478
|
+
reveal={reveal}
|
|
479
|
+
origin={origin}
|
|
480
|
+
span={span}
|
|
481
|
+
selected={activeIndex === index}
|
|
482
|
+
dimmed={activeIndex >= 0 && activeIndex !== index}
|
|
483
|
+
dimOpacity={dimOpacity}
|
|
484
|
+
label={datum.label}
|
|
485
|
+
percent={Math.round(angles.fraction * 100)}
|
|
486
|
+
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
|
|
487
|
+
/>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
</G>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
PieChartSlices.displayName = 'PieChart.Slices';
|
|
494
|
+
PieChartSlices.slot = 'svg' as const;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One slice, rebuilt on the UI thread every frame it is moving.
|
|
498
|
+
*
|
|
499
|
+
* Both the unroll and the lift are folded into the path rather than laid on top
|
|
500
|
+
* of it as a transform. The unroll has to be: a slice half drawn is a shorter
|
|
501
|
+
* slice, not a smaller one, and no scale expresses that. The lift could be a
|
|
502
|
+
* translation, but the path is already being rebuilt, so moving the centre it
|
|
503
|
+
* is drawn around costs nothing and keeps the geometry in one place.
|
|
504
|
+
*/
|
|
505
|
+
function Slice({
|
|
506
|
+
angles,
|
|
507
|
+
fill,
|
|
508
|
+
centre,
|
|
509
|
+
radius,
|
|
510
|
+
hole,
|
|
511
|
+
cornerRadius,
|
|
512
|
+
popOut,
|
|
513
|
+
reveal,
|
|
514
|
+
origin,
|
|
515
|
+
span,
|
|
516
|
+
selected,
|
|
517
|
+
dimmed,
|
|
518
|
+
dimOpacity,
|
|
519
|
+
label,
|
|
520
|
+
percent,
|
|
521
|
+
onPress,
|
|
522
|
+
}: {
|
|
523
|
+
angles: SliceAngles;
|
|
524
|
+
fill: string;
|
|
525
|
+
centre: number;
|
|
526
|
+
radius: number;
|
|
527
|
+
hole: number;
|
|
528
|
+
cornerRadius: number;
|
|
529
|
+
popOut: number;
|
|
530
|
+
reveal: SharedValue<number>;
|
|
531
|
+
origin: number;
|
|
532
|
+
span: number;
|
|
533
|
+
selected: boolean;
|
|
534
|
+
dimmed: boolean;
|
|
535
|
+
dimOpacity: number;
|
|
536
|
+
label: string;
|
|
537
|
+
percent: number;
|
|
538
|
+
onPress: () => void;
|
|
539
|
+
}) {
|
|
540
|
+
const lift = useDerivedValue(() =>
|
|
541
|
+
withTiming(selected ? 1 : 0, { duration: SELECT_DURATION })
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const { from, to, mid } = angles;
|
|
545
|
+
// The direction the slice lifts in, resolved once: the middle of it, as a
|
|
546
|
+
// unit vector from the centre of the dial.
|
|
547
|
+
const angle = (mid - 0.25) * Math.PI * 2;
|
|
548
|
+
const dx = Math.cos(angle);
|
|
549
|
+
const dy = Math.sin(angle);
|
|
550
|
+
|
|
551
|
+
const animatedProps = useAnimatedProps(() => {
|
|
552
|
+
// One hand sweeping clockwise from the start of the dial. A slice is drawn
|
|
553
|
+
// as far as the hand has reached and no further, so the pie fills the way
|
|
554
|
+
// it would be drawn by hand rather than every slice growing at once.
|
|
555
|
+
const hand = origin + span * reveal.value;
|
|
556
|
+
const out = popOut * lift.value;
|
|
557
|
+
return {
|
|
558
|
+
d: wedgePath(
|
|
559
|
+
centre + dx * out,
|
|
560
|
+
centre + dy * out,
|
|
561
|
+
radius,
|
|
562
|
+
hole,
|
|
563
|
+
from,
|
|
564
|
+
Math.min(to, hand),
|
|
565
|
+
cornerRadius
|
|
566
|
+
),
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<AnimatedPath
|
|
572
|
+
animatedProps={animatedProps}
|
|
573
|
+
fill={fill}
|
|
574
|
+
fillOpacity={dimmed ? dimOpacity : 1}
|
|
575
|
+
onPress={onPress}
|
|
576
|
+
// An SVG node takes a label but not a role, so the slices are reachable
|
|
577
|
+
// and named without being announced as buttons. `PieChart.Legend` is the
|
|
578
|
+
// properly wired way through the same selection, and the easier target.
|
|
579
|
+
accessibilityLabel={`${label}, ${percent} percent`}
|
|
580
|
+
/>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface PieChartSkeletonProps {
|
|
585
|
+
color?: string;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The loading state: the dial as one plain band, with nothing divided up yet.
|
|
590
|
+
*
|
|
591
|
+
* Deliberately undivided. Placeholder slices would be a made-up split, and a
|
|
592
|
+
* reader has no way to tell an invented one from a real one until it changes
|
|
593
|
+
* under them — which is worse than showing nothing, because it is showing
|
|
594
|
+
* something wrong.
|
|
595
|
+
*/
|
|
596
|
+
function PieChartSkeleton({ color }: PieChartSkeletonProps) {
|
|
597
|
+
const { size, radius, hole, origin, span, status } = useChart('PieChart.Skeleton');
|
|
598
|
+
const token = useCSSVariable('--color-skeleton');
|
|
599
|
+
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
600
|
+
|
|
601
|
+
if (status !== 'loading' || radius <= 0) return null;
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<Path
|
|
605
|
+
d={wedgePath(size / 2, size / 2, radius, hole, origin, origin + span, 0)}
|
|
606
|
+
fill={fill}
|
|
607
|
+
/>
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
PieChartSkeleton.displayName = 'PieChart.Skeleton';
|
|
611
|
+
PieChartSkeleton.slot = 'svg' as const;
|
|
612
|
+
|
|
613
|
+
export interface PieChartCenterProps {
|
|
614
|
+
/** Heading shown when no slice is selected. */
|
|
615
|
+
defaultLabel?: string;
|
|
616
|
+
/** Format the number under the label. Defaults to a compact number. */
|
|
617
|
+
formatValue?: (value: number, slice: PieDatum | null) => string;
|
|
618
|
+
/**
|
|
619
|
+
* Draw the middle yourself. Given the selected slice, or `null` when nothing
|
|
620
|
+
* is selected.
|
|
621
|
+
*/
|
|
622
|
+
children?: (slice: PieDatum | null) => ReactNode;
|
|
623
|
+
className?: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The hole's readout: the total, and the selected slice's own figures once one
|
|
628
|
+
* is picked.
|
|
629
|
+
*
|
|
630
|
+
* Unlike the ring chart's centre, the default here *is* an aggregate, and it is
|
|
631
|
+
* the honest one — the whole point of a pie is that its parts belong to a total,
|
|
632
|
+
* so the total is the number the chart is about. Selecting a slice swaps it for
|
|
633
|
+
* that slice's value and its share.
|
|
634
|
+
*/
|
|
635
|
+
function PieChartCenter({
|
|
636
|
+
defaultLabel = 'Total',
|
|
637
|
+
formatValue,
|
|
638
|
+
children,
|
|
639
|
+
className,
|
|
640
|
+
}: PieChartCenterProps) {
|
|
641
|
+
const { data, slices, total, hole, activeIndex } = useChart('PieChart.Center');
|
|
642
|
+
|
|
643
|
+
const slice = activeIndex >= 0 ? (data[activeIndex] ?? null) : null;
|
|
644
|
+
const share = activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0;
|
|
645
|
+
const format = formatValue ?? ((amount: number) => compactNumber(amount));
|
|
646
|
+
|
|
647
|
+
// A square inside a circle, not across it: the corners of a box as wide as
|
|
648
|
+
// the diameter fall outside the hole.
|
|
649
|
+
const room = hole * Math.SQRT2;
|
|
650
|
+
|
|
651
|
+
if (room <= 0) return null;
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<View
|
|
655
|
+
pointerEvents="none"
|
|
656
|
+
style={{ maxWidth: room }}
|
|
657
|
+
className={cn('items-center', className)}
|
|
658
|
+
>
|
|
659
|
+
{children ? (
|
|
660
|
+
children(slice)
|
|
661
|
+
) : (
|
|
662
|
+
<>
|
|
663
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
664
|
+
{slice ? slice.label : defaultLabel}
|
|
665
|
+
</Text>
|
|
666
|
+
{/* Below about ninety points there is no room for two lines and a
|
|
667
|
+
headline, so the number gets the space and the rest is dropped. */}
|
|
668
|
+
<Text size={room < 90 ? 'lg' : 'xl'} weight="semibold" numberOfLines={1}>
|
|
669
|
+
{format(slice ? slice.value : total, slice)}
|
|
670
|
+
</Text>
|
|
671
|
+
{room >= 90 && slice ? (
|
|
672
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
673
|
+
{`${Math.round(share * 100)}% of ${format(total, null)}`}
|
|
674
|
+
</Text>
|
|
675
|
+
) : null}
|
|
676
|
+
</>
|
|
677
|
+
)}
|
|
678
|
+
</View>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
PieChartCenter.displayName = 'PieChart.Center';
|
|
682
|
+
PieChartCenter.slot = 'overlay' as const;
|
|
683
|
+
|
|
684
|
+
export interface PieChartLegendProps extends ViewProps {
|
|
685
|
+
className?: string;
|
|
686
|
+
/** Show each slice's share of the whole beside its name. */
|
|
687
|
+
showValue?: boolean;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* A swatch, a name and a share per slice, under the chart and across the width
|
|
692
|
+
* of it. Pressable in the same way the slices are — the legend is usually the
|
|
693
|
+
* easier target of the two, and a slice worth a couple of percent is not a
|
|
694
|
+
* target at all.
|
|
695
|
+
*
|
|
696
|
+
* It wraps rather than stacking, so five or six entries take two lines instead
|
|
697
|
+
* of six. A key is a lookup table, and a lookup table read down a column of one
|
|
698
|
+
* word each is a column the eye has to walk.
|
|
699
|
+
*/
|
|
700
|
+
function PieChartLegend({ className, showValue = true, ...props }: PieChartLegendProps) {
|
|
701
|
+
const { data, slices, colors, activeIndex, setActiveIndex } =
|
|
702
|
+
useChart('PieChart.Legend');
|
|
703
|
+
|
|
704
|
+
if (!data.length) return null;
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<View
|
|
708
|
+
{...props}
|
|
709
|
+
className={cn(
|
|
710
|
+
'w-full flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3',
|
|
711
|
+
className
|
|
712
|
+
)}
|
|
713
|
+
>
|
|
714
|
+
{data.map((slice, index) => {
|
|
715
|
+
const percent = Math.round((slices[index]?.fraction ?? 0) * 100);
|
|
716
|
+
const dimmed = activeIndex >= 0 && activeIndex !== index;
|
|
717
|
+
return (
|
|
718
|
+
<Pressable
|
|
719
|
+
key={slice.label}
|
|
720
|
+
accessibilityRole="button"
|
|
721
|
+
accessibilityLabel={`${slice.label}, ${percent} percent`}
|
|
722
|
+
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
|
|
723
|
+
style={{ opacity: dimmed ? 0.4 : 1 }}
|
|
724
|
+
className="max-w-full flex-row items-center gap-1.5"
|
|
725
|
+
>
|
|
726
|
+
<View
|
|
727
|
+
style={{
|
|
728
|
+
width: 8,
|
|
729
|
+
height: 8,
|
|
730
|
+
borderRadius: 4,
|
|
731
|
+
backgroundColor: colors[index],
|
|
732
|
+
}}
|
|
733
|
+
/>
|
|
734
|
+
<Text size="xs" muted numberOfLines={1} className="shrink">
|
|
735
|
+
{slice.label}
|
|
736
|
+
</Text>
|
|
737
|
+
{showValue ? (
|
|
738
|
+
<Text size="xs" weight="medium">
|
|
739
|
+
{percent}%
|
|
740
|
+
</Text>
|
|
741
|
+
) : null}
|
|
742
|
+
</Pressable>
|
|
743
|
+
);
|
|
744
|
+
})}
|
|
745
|
+
</View>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
PieChartLegend.displayName = 'PieChart.Legend';
|
|
749
|
+
PieChartLegend.slot = 'footer' as const;
|
|
750
|
+
|
|
751
|
+
export interface PieChartHeaderProps extends ViewProps {
|
|
752
|
+
className?: string;
|
|
753
|
+
/** Small line above the value — what the chart is of. */
|
|
754
|
+
title?: string;
|
|
755
|
+
/** The readout. The largest thing on the card, and the first thing read. */
|
|
756
|
+
value?: string;
|
|
757
|
+
/** One muted line under the value — a period, a comparison, a caveat. */
|
|
758
|
+
caption?: string;
|
|
759
|
+
/** Prettier names for the slices, keyed by their `label`. */
|
|
760
|
+
labels?: Record<string, string>;
|
|
761
|
+
/**
|
|
762
|
+
* Draw a swatch and a name per slice along the trailing edge.
|
|
763
|
+
*
|
|
764
|
+
* For two or three short names. Past that use `PieChart.Legend`, which runs
|
|
765
|
+
* under the chart across the full width: a key of five long names crammed
|
|
766
|
+
* into the trailing corner of a header wraps to a column and leaves the title
|
|
767
|
+
* beside it a few points wide.
|
|
768
|
+
*/
|
|
769
|
+
legend?: boolean;
|
|
770
|
+
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
|
|
771
|
+
children?: ReactNode;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The strip above the pie: what the chart is of, what it reads, and what the
|
|
776
|
+
* colours mean.
|
|
777
|
+
*
|
|
778
|
+
* It belongs to the chart rather than to the card around it because it is about
|
|
779
|
+
* the *slices* — the number changes as one is selected, and the legend is the
|
|
780
|
+
* list the chart itself is holding. The card's header is a caption on the tray
|
|
781
|
+
* the chart sits in; this is the chart introducing itself.
|
|
782
|
+
*
|
|
783
|
+
* The value is not derived here even though there is a total to derive it from,
|
|
784
|
+
* because the formatting is not the chart's to guess: a total of 18420 is a
|
|
785
|
+
* count, a currency or a percentage depending on what was counted, and only the
|
|
786
|
+
* caller knows which.
|
|
787
|
+
*/
|
|
788
|
+
function PieChartHeader({
|
|
789
|
+
className,
|
|
790
|
+
title,
|
|
791
|
+
value,
|
|
792
|
+
caption,
|
|
793
|
+
labels,
|
|
794
|
+
legend = false,
|
|
795
|
+
children,
|
|
796
|
+
...props
|
|
797
|
+
}: PieChartHeaderProps) {
|
|
798
|
+
const { data, colors } = useChart('PieChart.Header');
|
|
799
|
+
const trailing =
|
|
800
|
+
children ??
|
|
801
|
+
(legend && data.length ? (
|
|
802
|
+
<View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
|
803
|
+
{data.map((slice, index) => (
|
|
804
|
+
<View key={slice.label} className="flex-row items-center gap-1.5">
|
|
805
|
+
<View
|
|
806
|
+
style={{
|
|
807
|
+
width: 8,
|
|
808
|
+
height: 8,
|
|
809
|
+
borderRadius: 4,
|
|
810
|
+
backgroundColor: colors[index],
|
|
811
|
+
}}
|
|
812
|
+
/>
|
|
813
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
814
|
+
{labels?.[slice.label] ?? slice.label}
|
|
815
|
+
</Text>
|
|
816
|
+
</View>
|
|
817
|
+
))}
|
|
818
|
+
</View>
|
|
819
|
+
) : null);
|
|
820
|
+
|
|
821
|
+
return (
|
|
822
|
+
<View
|
|
823
|
+
{...props}
|
|
824
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
825
|
+
>
|
|
826
|
+
<View className="flex-1 gap-0.5">
|
|
827
|
+
{title ? (
|
|
828
|
+
<Text size="xs" muted>
|
|
829
|
+
{title}
|
|
830
|
+
</Text>
|
|
831
|
+
) : null}
|
|
832
|
+
{value ? (
|
|
833
|
+
<Text size="xl" weight="bold">
|
|
834
|
+
{value}
|
|
835
|
+
</Text>
|
|
836
|
+
) : null}
|
|
837
|
+
{caption ? (
|
|
838
|
+
<Text size="xs" muted>
|
|
839
|
+
{caption}
|
|
840
|
+
</Text>
|
|
841
|
+
) : null}
|
|
842
|
+
</View>
|
|
843
|
+
{/*
|
|
844
|
+
* Shrinkable, unlike a view's default in React Native — and capped, which
|
|
845
|
+
* shrinking alone does not achieve. The title column is `flex-1`, so its
|
|
846
|
+
* basis is zero and it lives on what is left over: a wrapping key with
|
|
847
|
+
* nothing stopping it takes the whole row and leaves the title a few
|
|
848
|
+
* points wide, which renders it one letter to a line.
|
|
849
|
+
*/}
|
|
850
|
+
{trailing ? <View className="max-w-[55%] shrink pt-1">{trailing}</View> : null}
|
|
851
|
+
</View>
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
PieChartHeader.displayName = 'PieChart.Header';
|
|
855
|
+
PieChartHeader.slot = 'header' as const;
|
|
856
|
+
|
|
857
|
+
export const PieChart = Object.assign(PieChartRoot, {
|
|
858
|
+
Header: PieChartHeader,
|
|
859
|
+
Slices: PieChartSlices,
|
|
860
|
+
Center: PieChartCenter,
|
|
861
|
+
Legend: PieChartLegend,
|
|
862
|
+
Skeleton: PieChartSkeleton,
|
|
863
|
+
});
|