panelui-native 0.57.0 → 0.60.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 -1
- package/lib/module/components/fab/index.js +63 -30
- package/lib/module/components/fab/index.js.map +1 -1
- package/lib/module/components/funnel-chart/index.js +828 -0
- package/lib/module/components/funnel-chart/index.js.map +1 -0
- package/lib/module/components/progress/index.js +67 -13
- package/lib/module/components/progress/index.js.map +1 -1
- package/lib/module/components/selection-mode/index.js +805 -0
- package/lib/module/components/selection-mode/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +384 -341
- package/lib/module/components/tabs/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 +36 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts +11 -5
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
- package/lib/typescript/src/components/funnel-chart/index.d.ts +289 -0
- package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress/index.d.ts +9 -1
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
- package/lib/typescript/src/components/selection-mode/index.d.ts +498 -0
- package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +64 -46
- package/lib/typescript/src/components/tabs/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 +19 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/fab/index.tsx +66 -31
- package/src/components/funnel-chart/index.tsx +1121 -0
- package/src/components/progress/index.tsx +64 -11
- package/src/components/selection-mode/index.tsx +1027 -0
- package/src/components/tabs/index.tsx +452 -413
- package/src/index.ts +29 -0
- package/src/utils/chart.ts +52 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FunnelChart — how many were left at each step, and where the rest went.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <FunnelChart data={checkout}>
|
|
8
|
+
* <FunnelChart.Header title="Checkout" value="41,800" />
|
|
9
|
+
* <FunnelChart.Stages />
|
|
10
|
+
* <FunnelChart.Labels />
|
|
11
|
+
* </FunnelChart>
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## What it is, against the bars next door
|
|
15
|
+
*
|
|
16
|
+
* A bar chart compares quantities that need not have anything to do with each
|
|
17
|
+
* other. A funnel makes a much stronger claim: every stage is a *subset of the
|
|
18
|
+
* one above it*, in order, and the reader is being shown where a population
|
|
19
|
+
* drained away. That is why the stages are not sorted, why nothing is
|
|
20
|
+
* normalised to a total, and why the interesting number is not any stage's
|
|
21
|
+
* value but the ratio between two of them.
|
|
22
|
+
*
|
|
23
|
+
* It follows that the order is the caller's and never the chart's. Stages are
|
|
24
|
+
* steps in a process — a signup, a checkout, a support queue — and reordering
|
|
25
|
+
* them by size would destroy the only thing the chart is asserting. A stage
|
|
26
|
+
* larger than the one above it is therefore drawn as given, wider than its
|
|
27
|
+
* parent, because that is a real and visible data problem and hiding it would
|
|
28
|
+
* be the chart lying to save face.
|
|
29
|
+
*
|
|
30
|
+
* ## Drawing
|
|
31
|
+
*
|
|
32
|
+
* One ribbon running across the card, not a stack of blocks. The stages divide
|
|
33
|
+
* the width between them, and each is a band symmetrical about the centre line
|
|
34
|
+
* — as tall as its value where it starts and as tall as the next stage's where
|
|
35
|
+
* it ends. The sides are curves that reach past each other, so consecutive
|
|
36
|
+
* bands meet flush and the whole run reads as a single narrowing channel rather
|
|
37
|
+
* than a row of separate shapes. The slope across a band is the drop.
|
|
38
|
+
*
|
|
39
|
+
* Each band is drawn several times over, concentrically: a wide faint ring on
|
|
40
|
+
* the outside through to a tight near-solid core. It is a halo, and it does two
|
|
41
|
+
* jobs. It gives the ribbon an edge that falls off rather than stopping dead;
|
|
42
|
+
* and it leaves a band of low-opacity fill above and below the core that text
|
|
43
|
+
* can sit on and still be read.
|
|
44
|
+
*
|
|
45
|
+
* The stages arrive one after another rather than together, each growing out of
|
|
46
|
+
* the centre line. A funnel is a sequence, and a sequence that assembles in its
|
|
47
|
+
* own order tells the reader which way to read it before they have read a word.
|
|
48
|
+
*
|
|
49
|
+
* ## Reading it
|
|
50
|
+
*
|
|
51
|
+
* The readings are laid out around the ribbon rather than crowded onto one
|
|
52
|
+
* line: the count above the band, the name below it, and the conversion in a
|
|
53
|
+
* pill in the middle of the band itself. Three readings, three places, none of
|
|
54
|
+
* them competing for the same space — which is what keeps a stage name whole
|
|
55
|
+
* under a column narrow enough to fit five of them on a phone, and what keeps
|
|
56
|
+
* the pill legible whatever the ribbon is doing underneath it.
|
|
57
|
+
*
|
|
58
|
+
* ## Colour
|
|
59
|
+
*
|
|
60
|
+
* One hue, fading along the run, rather than a colour per stage. A funnel's
|
|
61
|
+
* stages are one quantity at successive moments, not five unrelated series, and
|
|
62
|
+
* five hues would say they were. A stage can still be given its own `color`
|
|
63
|
+
* when it means something — the step where the money is taken, the one being
|
|
64
|
+
* discussed — and that one is drawn at full strength.
|
|
65
|
+
*/
|
|
66
|
+
import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
67
|
+
import { Pressable, View } from 'react-native';
|
|
68
|
+
import Animated, { Easing, useAnimatedProps, useDerivedValue, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
69
|
+
import Svg, { G, Path } from 'react-native-svg';
|
|
70
|
+
import { useCSSVariable } from 'uniwind';
|
|
71
|
+
import { useDirection } from "../../hooks/use-direction.js";
|
|
72
|
+
import { Text } from "../../primitives/text.js";
|
|
73
|
+
import { compactNumber, ribbonPath, useSeriesColor } from "../../utils/chart.js";
|
|
74
|
+
import { cn } from "../../utils/cn.js";
|
|
75
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
76
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
77
|
+
|
|
78
|
+
/** Milliseconds for a stage to dim as another is selected. */
|
|
79
|
+
const SELECT_DURATION = 180;
|
|
80
|
+
|
|
81
|
+
/** How far the hue has faded by the end of the run. */
|
|
82
|
+
const FADE = 0.35;
|
|
83
|
+
|
|
84
|
+
/** Stages' worth of room kept for a loading chart that has no data yet. */
|
|
85
|
+
const SKELETON_STAGES = 4;
|
|
86
|
+
|
|
87
|
+
/** Rings drawn per stage, unless the caller asks for another number. */
|
|
88
|
+
const DEFAULT_LAYERS = 3;
|
|
89
|
+
|
|
90
|
+
/** How far in the innermost ring is drawn from the outermost. */
|
|
91
|
+
const RING_INSET = 0.35;
|
|
92
|
+
|
|
93
|
+
/** Opacity of the outermost ring, and of the innermost. */
|
|
94
|
+
const RING_FAINTEST = 0.18;
|
|
95
|
+
const RING_STRONGEST = 0.83;
|
|
96
|
+
|
|
97
|
+
/** How much further the rings spread when their stage is selected. */
|
|
98
|
+
const SELECT_SPREAD = 0.12;
|
|
99
|
+
|
|
100
|
+
/** Milliseconds between one stage starting to grow and the next. */
|
|
101
|
+
const STAGGER = 90;
|
|
102
|
+
|
|
103
|
+
/** How tall the run is drawn when the caller does not say. */
|
|
104
|
+
const DEFAULT_HEIGHT = 200;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How far the sides' control points reach along a band, as a fraction of it.
|
|
108
|
+
*
|
|
109
|
+
* Past a half they overshoot each other, which is what flattens the ends of the
|
|
110
|
+
* join and puts the whole slope in the middle — the difference between a run
|
|
111
|
+
* that looks poured and one that looks folded.
|
|
112
|
+
*/
|
|
113
|
+
const CURVE = 0.55;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The share of the height kept above the ribbon for the count, and below it for
|
|
117
|
+
* the name. The ribbon takes what is left and sits centred in it.
|
|
118
|
+
*
|
|
119
|
+
* Equal, because what they hold is equal: both are one line. So the widest
|
|
120
|
+
* stage reaches exactly to the text at both ends of the band, with nothing
|
|
121
|
+
* creeping under the words and no strip of nothing between them and the shape.
|
|
122
|
+
*/
|
|
123
|
+
const TEXT_BAND = 0.15;
|
|
124
|
+
|
|
125
|
+
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
|
|
126
|
+
|
|
127
|
+
/** Whether the chart is showing data or waiting for it. */
|
|
128
|
+
|
|
129
|
+
/** Whether the sides of a band are drawn as curves or as straight diagonals. */
|
|
130
|
+
|
|
131
|
+
/** One step of the process. */
|
|
132
|
+
|
|
133
|
+
/** One stage's band: where it sits along the run and how tall it is at each end. */
|
|
134
|
+
|
|
135
|
+
const FunnelChartContext = /*#__PURE__*/createContext(null);
|
|
136
|
+
function useChart(component) {
|
|
137
|
+
const context = useContext(FunnelChartContext);
|
|
138
|
+
if (!context) {
|
|
139
|
+
throw new Error(`${component} must be used within a <FunnelChart>`);
|
|
140
|
+
}
|
|
141
|
+
return context;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The selected stage and how it converted, for something drawn inside the chart. */
|
|
145
|
+
export function useFunnelChart() {
|
|
146
|
+
const {
|
|
147
|
+
data,
|
|
148
|
+
shares,
|
|
149
|
+
steps,
|
|
150
|
+
activeIndex
|
|
151
|
+
} = useChart('useFunnelChart');
|
|
152
|
+
return {
|
|
153
|
+
activeIndex,
|
|
154
|
+
activeStage: activeIndex >= 0 ? data[activeIndex] ?? null : null,
|
|
155
|
+
/** Its share of the first stage, 0 to 1. */
|
|
156
|
+
activeShare: activeIndex >= 0 ? shares[activeIndex] ?? 0 : 0,
|
|
157
|
+
/** Its share of the stage above it, 0 to 1. */
|
|
158
|
+
activeStep: activeIndex >= 0 ? steps[activeIndex] ?? 0 : 0
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Imperative handle: re-run the entrance, for a "replay" control. */
|
|
163
|
+
|
|
164
|
+
const FunnelChartRoot = /*#__PURE__*/forwardRef(function FunnelChartRoot({
|
|
165
|
+
className,
|
|
166
|
+
data,
|
|
167
|
+
height = DEFAULT_HEIGHT,
|
|
168
|
+
stageSize,
|
|
169
|
+
gap = 4,
|
|
170
|
+
layers = DEFAULT_LAYERS,
|
|
171
|
+
edges = 'curved',
|
|
172
|
+
minWidth = 0.1,
|
|
173
|
+
color,
|
|
174
|
+
animationDuration = 700,
|
|
175
|
+
staggerDelay = STAGGER,
|
|
176
|
+
status = 'ready',
|
|
177
|
+
activeIndex: activeIndexProp,
|
|
178
|
+
onActiveIndexChange,
|
|
179
|
+
children,
|
|
180
|
+
...props
|
|
181
|
+
}, ref) {
|
|
182
|
+
const [width, setWidth] = useState(0);
|
|
183
|
+
const [internalActive, setInternalActive] = useState(-1);
|
|
184
|
+
const reveal = useSharedValue(0);
|
|
185
|
+
const reducedMotion = useReducedMotion();
|
|
186
|
+
const direction = useDirection();
|
|
187
|
+
const controlled = activeIndexProp !== undefined;
|
|
188
|
+
const activeIndex = controlled ? activeIndexProp : internalActive;
|
|
189
|
+
const setActiveIndex = useMemo(() => index => {
|
|
190
|
+
if (!controlled) setInternalActive(index);
|
|
191
|
+
onActiveIndexChange?.(index);
|
|
192
|
+
}, [controlled, onActiveIndexChange]);
|
|
193
|
+
const count = data.length;
|
|
194
|
+
/*
|
|
195
|
+
* A funnel that is still loading usually has no data at all, and no stages
|
|
196
|
+
* to divide the width between. So an empty loading chart is given a
|
|
197
|
+
* plausible number of them — which is also what stops the card from jumping
|
|
198
|
+
* the moment the data lands.
|
|
199
|
+
*/
|
|
200
|
+
const stages = count || (status === 'loading' ? SKELETON_STAGES : 0);
|
|
201
|
+
const spacing = Math.max(gap, 0);
|
|
202
|
+
|
|
203
|
+
/*
|
|
204
|
+
* The stages divide the width unless they are given a size. A run sized in
|
|
205
|
+
* points would stop somewhere short of the edge and leave the rest of the
|
|
206
|
+
* card blank, which is the one thing a chart across a card must not do.
|
|
207
|
+
*/
|
|
208
|
+
const size = stageSize ?? (stages > 0 ? Math.max(0, (width - (stages - 1) * spacing) / stages) : 0);
|
|
209
|
+
|
|
210
|
+
/** The largest value in the run — see the note about broken funnels above. */
|
|
211
|
+
const peak = useMemo(() => data.reduce((most, stage) => Math.max(most, Math.max(0, stage.value)), 0), [data]);
|
|
212
|
+
const shares = useMemo(() => {
|
|
213
|
+
const first = Math.max(0, data[0]?.value ?? 0);
|
|
214
|
+
return data.map(stage => first > 0 ? Math.max(0, stage.value) / first : 0);
|
|
215
|
+
}, [data]);
|
|
216
|
+
const steps = useMemo(() => data.map((stage, index) => {
|
|
217
|
+
if (index === 0) return 1;
|
|
218
|
+
const previous = Math.max(0, data[index - 1]?.value ?? 0);
|
|
219
|
+
return previous > 0 ? Math.max(0, stage.value) / previous : 0;
|
|
220
|
+
}), [data]);
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
* The run reads leading-edge first like the text under it, and SVG has no
|
|
224
|
+
* leading edge — so under a right-to-left layout the bands are laid from
|
|
225
|
+
* the other end, and each one's two heights swap with them.
|
|
226
|
+
*/
|
|
227
|
+
const mirrored = direction === 'rtl';
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* The ribbon takes whatever the two text bands leave and sits centred in it,
|
|
231
|
+
* so the tallest stage reaches exactly to the count above and the name
|
|
232
|
+
* below: no strip of nothing between the shape and the words, and no shape
|
|
233
|
+
* creeping under them.
|
|
234
|
+
*/
|
|
235
|
+
const textBand = height * TEXT_BAND;
|
|
236
|
+
const reach = Math.max(0, height / 2 - textBand);
|
|
237
|
+
const middle = height / 2;
|
|
238
|
+
const bands = useMemo(() => {
|
|
239
|
+
if (!count || width <= 0 || reach <= 0) return [];
|
|
240
|
+
const floor = Math.max(0, Math.min(minWidth, 1));
|
|
241
|
+
const extents = data.map(stage => {
|
|
242
|
+
const value = Math.max(0, stage.value);
|
|
243
|
+
if (value <= 0 || peak <= 0) return 0;
|
|
244
|
+
// The floor is a floor, not a rescale: a stage already above it keeps
|
|
245
|
+
// its true height, so the heights of the stages that matter still read
|
|
246
|
+
// against each other exactly.
|
|
247
|
+
return reach * Math.max(value / peak, floor);
|
|
248
|
+
});
|
|
249
|
+
return extents.map((extent, index) => {
|
|
250
|
+
const offset = index * (size + spacing);
|
|
251
|
+
const next = extents[index + 1] ?? extent;
|
|
252
|
+
return {
|
|
253
|
+
offset: mirrored ? width - offset - size : offset,
|
|
254
|
+
length: size,
|
|
255
|
+
head: mirrored ? next : extent,
|
|
256
|
+
tail: mirrored ? extent : next
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}, [count, width, reach, data, peak, minWidth, size, spacing, mirrored]);
|
|
260
|
+
const hue = useSeriesColor(color, 1);
|
|
261
|
+
const colors = useMemo(() => data.map(stage => stage.color ?? hue), [data, hue]);
|
|
262
|
+
const strengths = useMemo(() => data.map((stage, index) => {
|
|
263
|
+
if (stage.color) return 1;
|
|
264
|
+
if (count < 2) return 1;
|
|
265
|
+
return 1 - index / (count - 1) * FADE;
|
|
266
|
+
}), [data, count]);
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* One clock for the whole entrance, with each stage given the slice of it
|
|
270
|
+
* that it grows in. A shared value per stage would be the same animation
|
|
271
|
+
* played `n` times and `n` more things for a replay to have to find.
|
|
272
|
+
*/
|
|
273
|
+
const stagger = Math.max(0, staggerDelay);
|
|
274
|
+
const total = animationDuration + Math.max(count - 1, 0) * stagger;
|
|
275
|
+
const windows = useMemo(() => data.map((_, index) => {
|
|
276
|
+
const from = index * stagger;
|
|
277
|
+
return {
|
|
278
|
+
from: total > 0 ? from / total : 0,
|
|
279
|
+
to: total > 0 ? (from + animationDuration) / total : 1
|
|
280
|
+
};
|
|
281
|
+
}), [data, stagger, animationDuration, total]);
|
|
282
|
+
const playReveal = useMemo(() => () => {
|
|
283
|
+
if (reducedMotion) {
|
|
284
|
+
reveal.value = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
reveal.value = 0;
|
|
288
|
+
// Linear, because the shaping is per stage: each one eases inside its
|
|
289
|
+
// own window, and easing the clock as well would ease it twice.
|
|
290
|
+
reveal.value = withTiming(1, {
|
|
291
|
+
duration: total,
|
|
292
|
+
easing: Easing.linear
|
|
293
|
+
});
|
|
294
|
+
}, [reducedMotion, total, reveal]);
|
|
295
|
+
const loading = status === 'loading';
|
|
296
|
+
const revealed = useRef(false);
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (loading) {
|
|
299
|
+
revealed.current = false;
|
|
300
|
+
reveal.value = 0;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (revealed.current || !bands.length) return;
|
|
304
|
+
revealed.current = true;
|
|
305
|
+
playReveal();
|
|
306
|
+
}, [loading, bands.length, playReveal, reveal]);
|
|
307
|
+
useImperativeHandle(ref, () => ({
|
|
308
|
+
replay: playReveal
|
|
309
|
+
}), [playReveal]);
|
|
310
|
+
|
|
311
|
+
// The caller's own `onLayout` is not forwarded from here: it is already on
|
|
312
|
+
// the outer view, and the box it wants is the whole chart's rather than the
|
|
313
|
+
// run's — which are different heights the moment there is a header.
|
|
314
|
+
const onLayout = event => {
|
|
315
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
316
|
+
if (next !== width) setWidth(next);
|
|
317
|
+
};
|
|
318
|
+
const context = useMemo(() => ({
|
|
319
|
+
data,
|
|
320
|
+
width,
|
|
321
|
+
height,
|
|
322
|
+
textBand,
|
|
323
|
+
middle,
|
|
324
|
+
bands,
|
|
325
|
+
shares,
|
|
326
|
+
steps,
|
|
327
|
+
colors,
|
|
328
|
+
strengths,
|
|
329
|
+
layers: Math.max(1, Math.round(layers)),
|
|
330
|
+
curve: edges === 'curved' ? CURVE : 0,
|
|
331
|
+
reveal,
|
|
332
|
+
windows,
|
|
333
|
+
status,
|
|
334
|
+
activeIndex,
|
|
335
|
+
setActiveIndex
|
|
336
|
+
}), [data, width, height, textBand, middle, bands, shares, steps, colors, strengths, layers, edges, reveal, windows, status, activeIndex, setActiveIndex]);
|
|
337
|
+
const slots = {
|
|
338
|
+
svg: [],
|
|
339
|
+
overlay: [],
|
|
340
|
+
header: [],
|
|
341
|
+
footer: []
|
|
342
|
+
};
|
|
343
|
+
Children.forEach(children, (child, index) => {
|
|
344
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
345
|
+
const slot = child.type.slot ?? 'overlay';
|
|
346
|
+
slots[slot in slots ? slot : 'overlay'].push(/*#__PURE__*/_jsx(ChildSlot, {
|
|
347
|
+
children: child
|
|
348
|
+
}, index));
|
|
349
|
+
});
|
|
350
|
+
return /*#__PURE__*/_jsx(FunnelChartContext.Provider, {
|
|
351
|
+
value: context,
|
|
352
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
353
|
+
...props,
|
|
354
|
+
style: props.style,
|
|
355
|
+
className: cn('w-full', className),
|
|
356
|
+
children: [slots.header, /*#__PURE__*/_jsx(View, {
|
|
357
|
+
onLayout: onLayout,
|
|
358
|
+
style: {
|
|
359
|
+
height
|
|
360
|
+
},
|
|
361
|
+
className: "w-full",
|
|
362
|
+
children: width > 0 && height > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
363
|
+
children: [/*#__PURE__*/_jsx(Svg, {
|
|
364
|
+
width: width,
|
|
365
|
+
height: height,
|
|
366
|
+
children: slots.svg
|
|
367
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
368
|
+
pointerEvents: "box-none",
|
|
369
|
+
style: {
|
|
370
|
+
position: 'absolute',
|
|
371
|
+
width,
|
|
372
|
+
height
|
|
373
|
+
},
|
|
374
|
+
children: slots.overlay
|
|
375
|
+
})]
|
|
376
|
+
}) : null
|
|
377
|
+
}), slots.footer]
|
|
378
|
+
})
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
FunnelChartRoot.displayName = 'FunnelChart';
|
|
382
|
+
function ChildSlot({
|
|
383
|
+
children
|
|
384
|
+
}) {
|
|
385
|
+
return /*#__PURE__*/_jsx(_Fragment, {
|
|
386
|
+
children: children
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Every stage, drawn in the order the data lists them.
|
|
391
|
+
*
|
|
392
|
+
* One part rather than one per datum. A stage's near edge is the previous
|
|
393
|
+
* stage's far edge, so they cannot be configured apart without the ribbon
|
|
394
|
+
* coming apart with them — a funnel whose third stage could be given its own
|
|
395
|
+
* height would be a funnel drawing a shape that is not in the data.
|
|
396
|
+
*/
|
|
397
|
+
function FunnelChartStages({
|
|
398
|
+
dimOpacity = 0.3
|
|
399
|
+
}) {
|
|
400
|
+
const {
|
|
401
|
+
data,
|
|
402
|
+
bands,
|
|
403
|
+
colors,
|
|
404
|
+
strengths,
|
|
405
|
+
middle,
|
|
406
|
+
layers,
|
|
407
|
+
curve,
|
|
408
|
+
reveal,
|
|
409
|
+
windows,
|
|
410
|
+
status,
|
|
411
|
+
shares,
|
|
412
|
+
activeIndex,
|
|
413
|
+
setActiveIndex
|
|
414
|
+
} = useChart('FunnelChart.Stages');
|
|
415
|
+
if (status === 'loading' || !bands.length) return null;
|
|
416
|
+
return /*#__PURE__*/_jsx(G, {
|
|
417
|
+
children: bands.map((band, index) => {
|
|
418
|
+
const datum = data[index];
|
|
419
|
+
const window = windows[index];
|
|
420
|
+
if (!datum || !window || band.head <= 0 && band.tail <= 0) return null;
|
|
421
|
+
return /*#__PURE__*/_jsx(Stage, {
|
|
422
|
+
band: band,
|
|
423
|
+
middle: middle,
|
|
424
|
+
curve: curve,
|
|
425
|
+
layers: layers,
|
|
426
|
+
fill: colors[index] ?? colors[0],
|
|
427
|
+
strength: strengths[index] ?? 1,
|
|
428
|
+
reveal: reveal,
|
|
429
|
+
window: window,
|
|
430
|
+
selected: activeIndex === index,
|
|
431
|
+
dimmed: activeIndex >= 0 && activeIndex !== index,
|
|
432
|
+
dimOpacity: dimOpacity,
|
|
433
|
+
label: datum.label,
|
|
434
|
+
value: datum.value,
|
|
435
|
+
percent: Math.round((shares[index] ?? 0) * 100),
|
|
436
|
+
onPress: () => setActiveIndex(activeIndex === index ? -1 : index)
|
|
437
|
+
}, datum.label);
|
|
438
|
+
})
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
FunnelChartStages.displayName = 'FunnelChart.Stages';
|
|
442
|
+
FunnelChartStages.slot = 'svg';
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* One stage, drawn as a stack of concentric rings.
|
|
446
|
+
*
|
|
447
|
+
* Every ring is rebuilt on the UI thread each frame it is moving. They grow out
|
|
448
|
+
* of the centre line rather than being scaled from nothing, because a scale
|
|
449
|
+
* would take the curve's control points with it and the sides would flex on the
|
|
450
|
+
* way in; growing the heights leaves the shape's geometry alone and only ever
|
|
451
|
+
* changes how far it reaches.
|
|
452
|
+
*/
|
|
453
|
+
function Stage({
|
|
454
|
+
band,
|
|
455
|
+
middle,
|
|
456
|
+
curve,
|
|
457
|
+
layers,
|
|
458
|
+
fill,
|
|
459
|
+
strength,
|
|
460
|
+
reveal,
|
|
461
|
+
window,
|
|
462
|
+
selected,
|
|
463
|
+
dimmed,
|
|
464
|
+
dimOpacity,
|
|
465
|
+
label,
|
|
466
|
+
value,
|
|
467
|
+
percent,
|
|
468
|
+
onPress
|
|
469
|
+
}) {
|
|
470
|
+
const dim = useDerivedValue(() => withTiming(dimmed ? 1 : 0, {
|
|
471
|
+
duration: SELECT_DURATION
|
|
472
|
+
}));
|
|
473
|
+
const spread = useDerivedValue(() => withTiming(selected ? 1 : 0, {
|
|
474
|
+
duration: SELECT_DURATION
|
|
475
|
+
}));
|
|
476
|
+
return /*#__PURE__*/_jsx(G, {
|
|
477
|
+
onPress: onPress
|
|
478
|
+
// An SVG node takes a label but not a role, so the stages are reachable
|
|
479
|
+
// and named without being announced as buttons. `FunnelChart.Labels` and
|
|
480
|
+
// `FunnelChart.Legend` are the properly wired way through the same
|
|
481
|
+
// selection, and the larger targets.
|
|
482
|
+
,
|
|
483
|
+
accessibilityLabel: `${label}, ${value}, ${percent} percent of the first stage`,
|
|
484
|
+
children: Array.from({
|
|
485
|
+
length: layers
|
|
486
|
+
}, (_, ring) => /*#__PURE__*/_jsx(Ring, {
|
|
487
|
+
band: band,
|
|
488
|
+
middle: middle,
|
|
489
|
+
curve: curve,
|
|
490
|
+
fill: fill
|
|
491
|
+
// The outermost ring is the tallest and the faintest, the innermost
|
|
492
|
+
// the tightest and the strongest, so the fill falls off towards the
|
|
493
|
+
// edge instead of ending at one.
|
|
494
|
+
,
|
|
495
|
+
scale: 1 - ring / layers * RING_INSET,
|
|
496
|
+
opacity: strength * (RING_FAINTEST + ring / Math.max(layers - 1, 1) * (RING_STRONGEST - RING_FAINTEST))
|
|
497
|
+
// Selecting a stage pushes the core out further than the halo, which
|
|
498
|
+
// reads as the band swelling rather than as the whole thing resizing.
|
|
499
|
+
,
|
|
500
|
+
spreadBy: ring / Math.max(layers - 1, 1) * SELECT_SPREAD,
|
|
501
|
+
reveal: reveal,
|
|
502
|
+
window: window,
|
|
503
|
+
spread: spread,
|
|
504
|
+
dim: dim,
|
|
505
|
+
dimOpacity: dimOpacity
|
|
506
|
+
}, ring))
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function Ring({
|
|
510
|
+
band,
|
|
511
|
+
middle,
|
|
512
|
+
curve,
|
|
513
|
+
fill,
|
|
514
|
+
scale,
|
|
515
|
+
opacity,
|
|
516
|
+
spreadBy,
|
|
517
|
+
reveal,
|
|
518
|
+
window,
|
|
519
|
+
spread,
|
|
520
|
+
dim,
|
|
521
|
+
dimOpacity
|
|
522
|
+
}) {
|
|
523
|
+
const {
|
|
524
|
+
offset,
|
|
525
|
+
length,
|
|
526
|
+
head,
|
|
527
|
+
tail
|
|
528
|
+
} = band;
|
|
529
|
+
const {
|
|
530
|
+
from,
|
|
531
|
+
to
|
|
532
|
+
} = window;
|
|
533
|
+
const animatedProps = useAnimatedProps(() => {
|
|
534
|
+
const range = to - from;
|
|
535
|
+
const raw = range > 0 ? (reveal.value - from) / range : 1;
|
|
536
|
+
const progress = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
537
|
+
// Ease out cubic: fast to most of the way, then settling. Written out
|
|
538
|
+
// rather than called, because an easing from the animation library is not
|
|
539
|
+
// a worklet and this runs on the UI thread.
|
|
540
|
+
const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress);
|
|
541
|
+
const reach = scale * eased * (1 + spread.value * spreadBy);
|
|
542
|
+
return {
|
|
543
|
+
d: ribbonPath(offset, length, head * reach, tail * reach, middle, curve),
|
|
544
|
+
fillOpacity: opacity * (1 - dim.value * (1 - dimOpacity))
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
return /*#__PURE__*/_jsx(AnimatedPath, {
|
|
548
|
+
animatedProps: animatedProps,
|
|
549
|
+
fill: fill
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The loading state: one plain ribbon over the whole run, undivided.
|
|
554
|
+
*
|
|
555
|
+
* Deliberately undivided. Placeholder stages would be an invented drop-off, and
|
|
556
|
+
* a reader has no way to tell an invented one from a real one until it changes
|
|
557
|
+
* under them — which is worse than showing nothing, because it is showing
|
|
558
|
+
* something wrong.
|
|
559
|
+
*/
|
|
560
|
+
function FunnelChartSkeleton({
|
|
561
|
+
color
|
|
562
|
+
}) {
|
|
563
|
+
const {
|
|
564
|
+
width,
|
|
565
|
+
height,
|
|
566
|
+
middle,
|
|
567
|
+
textBand,
|
|
568
|
+
curve,
|
|
569
|
+
status
|
|
570
|
+
} = useChart('FunnelChart.Skeleton');
|
|
571
|
+
const token = useCSSVariable('--color-skeleton');
|
|
572
|
+
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
573
|
+
if (status !== 'loading' || width <= 0 || height <= 0) return null;
|
|
574
|
+
const extent = Math.max(0, height / 2 - textBand);
|
|
575
|
+
return /*#__PURE__*/_jsx(Path, {
|
|
576
|
+
d: ribbonPath(0, width, extent, extent * 0.4, middle, curve),
|
|
577
|
+
fill: fill
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
FunnelChartSkeleton.displayName = 'FunnelChart.Skeleton';
|
|
581
|
+
FunnelChartSkeleton.slot = 'svg';
|
|
582
|
+
|
|
583
|
+
/** Which ratio a stage's pill reports. */
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The readings, arranged around the ribbon: the count above the band, the name
|
|
587
|
+
* under it, and the conversion in a pill on the band itself.
|
|
588
|
+
*
|
|
589
|
+
* Three places rather than one line, and that is the whole point of it. Put a
|
|
590
|
+
* name, a count and a percentage together on one row and the row is as wide as
|
|
591
|
+
* all three — so at the width a phone actually has, the name is the one that
|
|
592
|
+
* gives way and the reader is left with "Checkout st…" against a number. Split
|
|
593
|
+
* around the band, each reading has the stage's full column to itself.
|
|
594
|
+
*
|
|
595
|
+
* The pill is a filled chip rather than bare text because it is the one reading
|
|
596
|
+
* that sits *on* the ribbon, where the fill behind it is the same token family
|
|
597
|
+
* as the text would be. Punched out of its own background, it reads whatever
|
|
598
|
+
* the band is doing underneath.
|
|
599
|
+
*
|
|
600
|
+
* The columns are pressable rather than the shape alone: a two-percent stage is
|
|
601
|
+
* a sliver, and the column it lives in is a target.
|
|
602
|
+
*/
|
|
603
|
+
function FunnelChartLabels({
|
|
604
|
+
className,
|
|
605
|
+
formatValue,
|
|
606
|
+
formatShare,
|
|
607
|
+
share = 'top',
|
|
608
|
+
showValue = true,
|
|
609
|
+
showLabel = true
|
|
610
|
+
}) {
|
|
611
|
+
const {
|
|
612
|
+
data,
|
|
613
|
+
bands,
|
|
614
|
+
shares,
|
|
615
|
+
steps,
|
|
616
|
+
textBand,
|
|
617
|
+
status,
|
|
618
|
+
activeIndex,
|
|
619
|
+
setActiveIndex
|
|
620
|
+
} = useChart('FunnelChart.Labels');
|
|
621
|
+
if (status === 'loading' || !bands.length) return null;
|
|
622
|
+
const format = formatValue ?? (value => compactNumber(value));
|
|
623
|
+
const formatPercent = formatShare ?? (ratio => `${Math.round(ratio * 100)}%`);
|
|
624
|
+
return /*#__PURE__*/_jsx(_Fragment, {
|
|
625
|
+
children: bands.map((band, index) => {
|
|
626
|
+
const datum = data[index];
|
|
627
|
+
if (!datum) return null;
|
|
628
|
+
const ratio = share === 'top' ? shares[index] : steps[index];
|
|
629
|
+
const pill = share === 'none' || share === 'previous' && index === 0 ? null : formatPercent(ratio ?? 0, datum);
|
|
630
|
+
const dimmed = activeIndex >= 0 && activeIndex !== index;
|
|
631
|
+
const value = format(datum.value, datum);
|
|
632
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
633
|
+
accessibilityRole: "button",
|
|
634
|
+
accessibilityState: {
|
|
635
|
+
selected: activeIndex === index
|
|
636
|
+
},
|
|
637
|
+
accessibilityLabel: `${datum.label}, ${value}${pill ? `, ${pill}` : ''}`,
|
|
638
|
+
onPress: () => setActiveIndex(activeIndex === index ? -1 : index),
|
|
639
|
+
style: {
|
|
640
|
+
position: 'absolute',
|
|
641
|
+
left: band.offset,
|
|
642
|
+
width: band.length,
|
|
643
|
+
top: 0,
|
|
644
|
+
bottom: 0,
|
|
645
|
+
opacity: dimmed ? 0.5 : 1
|
|
646
|
+
},
|
|
647
|
+
className: cn('items-center px-1', className),
|
|
648
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
649
|
+
style: {
|
|
650
|
+
height: textBand
|
|
651
|
+
},
|
|
652
|
+
className: "justify-end pb-1",
|
|
653
|
+
children: showValue ? /*#__PURE__*/_jsx(Text, {
|
|
654
|
+
size: "sm",
|
|
655
|
+
weight: "semibold",
|
|
656
|
+
numberOfLines: 1,
|
|
657
|
+
children: value
|
|
658
|
+
}) : null
|
|
659
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
660
|
+
className: "flex-1 justify-center",
|
|
661
|
+
children: pill ? /*#__PURE__*/_jsx(View, {
|
|
662
|
+
className: "rounded-full border border-border bg-background px-2 py-0.5",
|
|
663
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
664
|
+
size: "xs",
|
|
665
|
+
weight: "bold",
|
|
666
|
+
numberOfLines: 1,
|
|
667
|
+
children: pill
|
|
668
|
+
})
|
|
669
|
+
}) : null
|
|
670
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
671
|
+
style: {
|
|
672
|
+
height: textBand
|
|
673
|
+
},
|
|
674
|
+
className: "justify-start pt-1",
|
|
675
|
+
children: showLabel ? /*#__PURE__*/_jsx(Text, {
|
|
676
|
+
size: "xs",
|
|
677
|
+
muted: true,
|
|
678
|
+
weight: "medium",
|
|
679
|
+
numberOfLines: 1,
|
|
680
|
+
children: datum.label
|
|
681
|
+
}) : null
|
|
682
|
+
})]
|
|
683
|
+
}, datum.label);
|
|
684
|
+
})
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
FunnelChartLabels.displayName = 'FunnelChart.Labels';
|
|
688
|
+
FunnelChartLabels.slot = 'overlay';
|
|
689
|
+
|
|
690
|
+
/** How the key under the run is arranged. */
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* A swatch, a name and a reading per stage, under the run. Pressable in the
|
|
694
|
+
* same way the stages are.
|
|
695
|
+
*
|
|
696
|
+
* For a funnel drawn without `Labels` — a compact one on a dashboard card,
|
|
697
|
+
* where the run is a shape and the reading is underneath it.
|
|
698
|
+
*
|
|
699
|
+
* A row each, by default. The stages of a funnel are a sequence, and a wrapped
|
|
700
|
+
* centred line loses that: the reader gets a ragged block of names in which the
|
|
701
|
+
* order is only implied by the order they happen to be read in, and a long
|
|
702
|
+
* stage name breaks it across lines that no longer line up with anything. Down
|
|
703
|
+
* a column the order is the order, and the numbers stack into a column of their
|
|
704
|
+
* own that can be compared at a glance.
|
|
705
|
+
*/
|
|
706
|
+
function FunnelChartLegend({
|
|
707
|
+
className,
|
|
708
|
+
layout = 'list',
|
|
709
|
+
showValue = true,
|
|
710
|
+
formatValue,
|
|
711
|
+
...props
|
|
712
|
+
}) {
|
|
713
|
+
const {
|
|
714
|
+
data,
|
|
715
|
+
shares,
|
|
716
|
+
colors,
|
|
717
|
+
strengths,
|
|
718
|
+
activeIndex,
|
|
719
|
+
setActiveIndex
|
|
720
|
+
} = useChart('FunnelChart.Legend');
|
|
721
|
+
if (!data.length) return null;
|
|
722
|
+
const list = layout === 'list';
|
|
723
|
+
const format = formatValue ?? (value => compactNumber(value));
|
|
724
|
+
return /*#__PURE__*/_jsx(View, {
|
|
725
|
+
...props,
|
|
726
|
+
className: cn('w-full pt-3', list ? 'gap-1.5' : 'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5', className),
|
|
727
|
+
children: data.map((stage, index) => {
|
|
728
|
+
const percent = Math.round((shares[index] ?? 0) * 100);
|
|
729
|
+
const dimmed = activeIndex >= 0 && activeIndex !== index;
|
|
730
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
731
|
+
accessibilityRole: "button",
|
|
732
|
+
accessibilityState: {
|
|
733
|
+
selected: activeIndex === index
|
|
734
|
+
},
|
|
735
|
+
accessibilityLabel: `${stage.label}, ${percent} percent of the first stage`,
|
|
736
|
+
onPress: () => setActiveIndex(activeIndex === index ? -1 : index),
|
|
737
|
+
style: {
|
|
738
|
+
opacity: dimmed ? 0.4 : 1
|
|
739
|
+
},
|
|
740
|
+
className: cn('flex-row items-center gap-1.5', list ? 'w-full' : 'max-w-full'),
|
|
741
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
742
|
+
style: {
|
|
743
|
+
width: 8,
|
|
744
|
+
height: 8,
|
|
745
|
+
borderRadius: 4,
|
|
746
|
+
backgroundColor: colors[index],
|
|
747
|
+
opacity: strengths[index] ?? 1
|
|
748
|
+
}
|
|
749
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
750
|
+
size: "xs",
|
|
751
|
+
muted: true,
|
|
752
|
+
numberOfLines: 1,
|
|
753
|
+
className: list ? 'flex-1' : 'shrink',
|
|
754
|
+
children: stage.label
|
|
755
|
+
}), showValue ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
756
|
+
children: [list ? /*#__PURE__*/_jsx(Text, {
|
|
757
|
+
size: "xs",
|
|
758
|
+
weight: "medium",
|
|
759
|
+
numberOfLines: 1,
|
|
760
|
+
children: format(stage.value, stage)
|
|
761
|
+
}) : null, /*#__PURE__*/_jsxs(Text, {
|
|
762
|
+
size: "xs",
|
|
763
|
+
muted: list,
|
|
764
|
+
weight: list ? 'normal' : 'medium',
|
|
765
|
+
numberOfLines: 1,
|
|
766
|
+
className: list ? 'w-10 text-right' : undefined,
|
|
767
|
+
children: [percent, "%"]
|
|
768
|
+
})]
|
|
769
|
+
}) : null]
|
|
770
|
+
}, stage.label);
|
|
771
|
+
})
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
FunnelChartLegend.displayName = 'FunnelChart.Legend';
|
|
775
|
+
FunnelChartLegend.slot = 'footer';
|
|
776
|
+
/**
|
|
777
|
+
* The strip above the run: what the funnel is of and what it reads.
|
|
778
|
+
*
|
|
779
|
+
* It belongs to the chart rather than to the card around it because it is about
|
|
780
|
+
* the *stages* — the number changes as one is selected. The card's header is a
|
|
781
|
+
* caption on the tray the chart sits in; this is the chart introducing itself.
|
|
782
|
+
*
|
|
783
|
+
* The value is not derived even though there is a first stage to derive it
|
|
784
|
+
* from, because the formatting is not the chart's to guess: 41800 is a count,
|
|
785
|
+
* a currency or a rate depending on what was counted.
|
|
786
|
+
*/
|
|
787
|
+
function FunnelChartHeader({
|
|
788
|
+
className,
|
|
789
|
+
title,
|
|
790
|
+
value,
|
|
791
|
+
caption,
|
|
792
|
+
children,
|
|
793
|
+
...props
|
|
794
|
+
}) {
|
|
795
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
796
|
+
...props,
|
|
797
|
+
className: cn('flex-row items-start justify-between gap-3 pb-3', className),
|
|
798
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
799
|
+
className: "flex-1 gap-0.5",
|
|
800
|
+
children: [title ? /*#__PURE__*/_jsx(Text, {
|
|
801
|
+
size: "xs",
|
|
802
|
+
muted: true,
|
|
803
|
+
children: title
|
|
804
|
+
}) : null, value ? /*#__PURE__*/_jsx(Text, {
|
|
805
|
+
size: "xl",
|
|
806
|
+
weight: "bold",
|
|
807
|
+
children: value
|
|
808
|
+
}) : null, caption ? /*#__PURE__*/_jsx(Text, {
|
|
809
|
+
size: "xs",
|
|
810
|
+
muted: true,
|
|
811
|
+
children: caption
|
|
812
|
+
}) : null]
|
|
813
|
+
}), children ? /*#__PURE__*/_jsx(View, {
|
|
814
|
+
className: "max-w-[55%] shrink pt-1",
|
|
815
|
+
children: children
|
|
816
|
+
}) : null]
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
FunnelChartHeader.displayName = 'FunnelChart.Header';
|
|
820
|
+
FunnelChartHeader.slot = 'header';
|
|
821
|
+
export const FunnelChart = Object.assign(FunnelChartRoot, {
|
|
822
|
+
Header: FunnelChartHeader,
|
|
823
|
+
Stages: FunnelChartStages,
|
|
824
|
+
Labels: FunnelChartLabels,
|
|
825
|
+
Legend: FunnelChartLegend,
|
|
826
|
+
Skeleton: FunnelChartSkeleton
|
|
827
|
+
});
|
|
828
|
+
//# sourceMappingURL=index.js.map
|