panelui-native 0.56.0 → 0.59.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.
Files changed (32) hide show
  1. package/README.md +21 -10
  2. package/lib/module/components/funnel-chart/index.js +828 -0
  3. package/lib/module/components/funnel-chart/index.js.map +1 -0
  4. package/lib/module/components/progress/index.js +67 -13
  5. package/lib/module/components/progress/index.js.map +1 -1
  6. package/lib/module/components/qr-code/index.js +641 -0
  7. package/lib/module/components/qr-code/index.js.map +1 -0
  8. package/lib/module/components/qr-code/qr-encode.js +475 -0
  9. package/lib/module/components/qr-code/qr-encode.js.map +1 -0
  10. package/lib/module/index.js +2 -0
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/utils/chart.js +36 -0
  13. package/lib/module/utils/chart.js.map +1 -1
  14. package/lib/typescript/src/components/funnel-chart/index.d.ts +289 -0
  15. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -0
  16. package/lib/typescript/src/components/progress/index.d.ts +9 -1
  17. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/qr-code/index.d.ts +221 -0
  19. package/lib/typescript/src/components/qr-code/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/qr-code/qr-encode.d.ts +39 -0
  21. package/lib/typescript/src/components/qr-code/qr-encode.d.ts.map +1 -0
  22. package/lib/typescript/src/index.d.ts +2 -0
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/utils/chart.d.ts +19 -0
  25. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/funnel-chart/index.tsx +1121 -0
  28. package/src/components/progress/index.tsx +64 -11
  29. package/src/components/qr-code/index.tsx +663 -0
  30. package/src/components/qr-code/qr-encode.ts +548 -0
  31. package/src/index.ts +35 -0
  32. package/src/utils/chart.ts +52 -0
@@ -0,0 +1,1121 @@
1
+ /**
2
+ * FunnelChart — how many were left at each step, and where the rest went.
3
+ *
4
+ * ```tsx
5
+ * <FunnelChart data={checkout}>
6
+ * <FunnelChart.Header title="Checkout" value="41,800" />
7
+ * <FunnelChart.Stages />
8
+ * <FunnelChart.Labels />
9
+ * </FunnelChart>
10
+ * ```
11
+ *
12
+ * ## What it is, against the bars next door
13
+ *
14
+ * A bar chart compares quantities that need not have anything to do with each
15
+ * other. A funnel makes a much stronger claim: every stage is a *subset of the
16
+ * one above it*, in order, and the reader is being shown where a population
17
+ * drained away. That is why the stages are not sorted, why nothing is
18
+ * normalised to a total, and why the interesting number is not any stage's
19
+ * value but the ratio between two of them.
20
+ *
21
+ * It follows that the order is the caller's and never the chart's. Stages are
22
+ * steps in a process — a signup, a checkout, a support queue — and reordering
23
+ * them by size would destroy the only thing the chart is asserting. A stage
24
+ * larger than the one above it is therefore drawn as given, wider than its
25
+ * parent, because that is a real and visible data problem and hiding it would
26
+ * be the chart lying to save face.
27
+ *
28
+ * ## Drawing
29
+ *
30
+ * One ribbon running across the card, not a stack of blocks. The stages divide
31
+ * the width between them, and each is a band symmetrical about the centre line
32
+ * — as tall as its value where it starts and as tall as the next stage's where
33
+ * it ends. The sides are curves that reach past each other, so consecutive
34
+ * bands meet flush and the whole run reads as a single narrowing channel rather
35
+ * than a row of separate shapes. The slope across a band is the drop.
36
+ *
37
+ * Each band is drawn several times over, concentrically: a wide faint ring on
38
+ * the outside through to a tight near-solid core. It is a halo, and it does two
39
+ * jobs. It gives the ribbon an edge that falls off rather than stopping dead;
40
+ * and it leaves a band of low-opacity fill above and below the core that text
41
+ * can sit on and still be read.
42
+ *
43
+ * The stages arrive one after another rather than together, each growing out of
44
+ * the centre line. A funnel is a sequence, and a sequence that assembles in its
45
+ * own order tells the reader which way to read it before they have read a word.
46
+ *
47
+ * ## Reading it
48
+ *
49
+ * The readings are laid out around the ribbon rather than crowded onto one
50
+ * line: the count above the band, the name below it, and the conversion in a
51
+ * pill in the middle of the band itself. Three readings, three places, none of
52
+ * them competing for the same space — which is what keeps a stage name whole
53
+ * under a column narrow enough to fit five of them on a phone, and what keeps
54
+ * the pill legible whatever the ribbon is doing underneath it.
55
+ *
56
+ * ## Colour
57
+ *
58
+ * One hue, fading along the run, rather than a colour per stage. A funnel's
59
+ * stages are one quantity at successive moments, not five unrelated series, and
60
+ * five hues would say they were. A stage can still be given its own `color`
61
+ * when it means something — the step where the money is taken, the one being
62
+ * discussed — and that one is drawn at full strength.
63
+ */
64
+ import {
65
+ Children,
66
+ createContext,
67
+ forwardRef,
68
+ isValidElement,
69
+ useContext,
70
+ useEffect,
71
+ useImperativeHandle,
72
+ useMemo,
73
+ useRef,
74
+ useState,
75
+ type ReactNode,
76
+ } from 'react';
77
+ import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
78
+ import Animated, {
79
+ Easing,
80
+ useAnimatedProps,
81
+ useDerivedValue,
82
+ useReducedMotion,
83
+ useSharedValue,
84
+ withTiming,
85
+ type SharedValue,
86
+ } from 'react-native-reanimated';
87
+ import Svg, { G, Path } from 'react-native-svg';
88
+ import { useCSSVariable } from 'uniwind';
89
+ import { useDirection } from '../../hooks/use-direction';
90
+ import { Text } from '../../primitives/text';
91
+ import { compactNumber, ribbonPath, useSeriesColor } from '../../utils/chart';
92
+ import { cn } from '../../utils/cn';
93
+
94
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
95
+
96
+ /** Milliseconds for a stage to dim as another is selected. */
97
+ const SELECT_DURATION = 180;
98
+
99
+ /** How far the hue has faded by the end of the run. */
100
+ const FADE = 0.35;
101
+
102
+ /** Stages' worth of room kept for a loading chart that has no data yet. */
103
+ const SKELETON_STAGES = 4;
104
+
105
+ /** Rings drawn per stage, unless the caller asks for another number. */
106
+ const DEFAULT_LAYERS = 3;
107
+
108
+ /** How far in the innermost ring is drawn from the outermost. */
109
+ const RING_INSET = 0.35;
110
+
111
+ /** Opacity of the outermost ring, and of the innermost. */
112
+ const RING_FAINTEST = 0.18;
113
+ const RING_STRONGEST = 0.83;
114
+
115
+ /** How much further the rings spread when their stage is selected. */
116
+ const SELECT_SPREAD = 0.12;
117
+
118
+ /** Milliseconds between one stage starting to grow and the next. */
119
+ const STAGGER = 90;
120
+
121
+ /** How tall the run is drawn when the caller does not say. */
122
+ const DEFAULT_HEIGHT = 200;
123
+
124
+ /**
125
+ * How far the sides' control points reach along a band, as a fraction of it.
126
+ *
127
+ * Past a half they overshoot each other, which is what flattens the ends of the
128
+ * join and puts the whole slope in the middle — the difference between a run
129
+ * that looks poured and one that looks folded.
130
+ */
131
+ const CURVE = 0.55;
132
+
133
+ /**
134
+ * The share of the height kept above the ribbon for the count, and below it for
135
+ * the name. The ribbon takes what is left and sits centred in it.
136
+ *
137
+ * Equal, because what they hold is equal: both are one line. So the widest
138
+ * stage reaches exactly to the text at both ends of the band, with nothing
139
+ * creeping under the words and no strip of nothing between them and the shape.
140
+ */
141
+ const TEXT_BAND = 0.15;
142
+
143
+ /** Where a child is drawn: inside the SVG, over it, above it, or under it. */
144
+ type Slot = 'svg' | 'overlay' | 'header' | 'footer';
145
+
146
+ /** Whether the chart is showing data or waiting for it. */
147
+ export type FunnelChartStatus = 'loading' | 'ready';
148
+
149
+ /** Whether the sides of a band are drawn as curves or as straight diagonals. */
150
+ export type FunnelEdges = 'curved' | 'straight';
151
+
152
+ /** One step of the process. */
153
+ export interface FunnelDatum {
154
+ /** Name of the step, for the label, the legend and the accessibility label. */
155
+ label: string;
156
+ /** How many were left at it. Negatives are treated as zero. */
157
+ value: number;
158
+ /** Explicit colour, drawn at full strength instead of the faded hue. */
159
+ color?: string;
160
+ }
161
+
162
+ /** One stage's band: where it sits along the run and how tall it is at each end. */
163
+ interface Band {
164
+ /** Where it starts along the run, in points. */
165
+ offset: number;
166
+ /** How wide it is, in points. */
167
+ length: number;
168
+ /** Half-height where it starts, in points. */
169
+ head: number;
170
+ /** Half-height where it ends — the next stage's, or its own if it is last. */
171
+ tail: number;
172
+ }
173
+
174
+ interface FunnelChartContextValue {
175
+ data: FunnelDatum[];
176
+ /** The run's width, in points. */
177
+ width: number;
178
+ /** The run's height, in points. */
179
+ height: number;
180
+ /** Room kept above the ribbon and below it, in points. */
181
+ textBand: number;
182
+ /** The ribbon's centre line, in points from the top. */
183
+ middle: number;
184
+ bands: Band[];
185
+ /** Each stage's share of the *first* stage, 0 to 1. */
186
+ shares: number[];
187
+ /** Each stage's share of the one above it, 0 to 1. The first is 1. */
188
+ steps: number[];
189
+ colors: string[];
190
+ /** How far the hue has faded at each stage. `1` where a colour was given. */
191
+ strengths: number[];
192
+ layers: number;
193
+ curve: number;
194
+ /** `0` to `1` across the whole staggered entrance. */
195
+ reveal: SharedValue<number>;
196
+ /** Where in that each stage's own growth begins and ends. */
197
+ windows: { from: number; to: number }[];
198
+ status: FunnelChartStatus;
199
+ activeIndex: number;
200
+ setActiveIndex: (index: number) => void;
201
+ }
202
+
203
+ const FunnelChartContext = createContext<FunnelChartContextValue | null>(null);
204
+
205
+ function useChart(component: string): FunnelChartContextValue {
206
+ const context = useContext(FunnelChartContext);
207
+ if (!context) {
208
+ throw new Error(`${component} must be used within a <FunnelChart>`);
209
+ }
210
+ return context;
211
+ }
212
+
213
+ /** The selected stage and how it converted, for something drawn inside the chart. */
214
+ export function useFunnelChart() {
215
+ const { data, shares, steps, activeIndex } = useChart('useFunnelChart');
216
+ return {
217
+ activeIndex,
218
+ activeStage: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,
219
+ /** Its share of the first stage, 0 to 1. */
220
+ activeShare: activeIndex >= 0 ? (shares[activeIndex] ?? 0) : 0,
221
+ /** Its share of the stage above it, 0 to 1. */
222
+ activeStep: activeIndex >= 0 ? (steps[activeIndex] ?? 0) : 0,
223
+ };
224
+ }
225
+
226
+ export interface FunnelChartProps extends ViewProps {
227
+ className?: string;
228
+ /** The steps, in the order they happen. Never reordered. */
229
+ data: FunnelDatum[];
230
+ /**
231
+ * How tall the run is drawn, in points.
232
+ *
233
+ * The run is as wide as it is given and as deep as this: the width is the
234
+ * card's, but nothing in the data says how far the ribbon should taper
235
+ * through, so it is a decision rather than a measurement.
236
+ */
237
+ height?: number;
238
+ /**
239
+ * How wide one stage is, in points.
240
+ *
241
+ * Left unset the stages divide the width between them, which is nearly always
242
+ * what a run across a card wants. Worth setting only to make a run stop short
243
+ * of the edge.
244
+ */
245
+ stageSize?: number;
246
+ /** Space between one stage and the next, in points. */
247
+ gap?: number;
248
+ /**
249
+ * Concentric rings drawn per stage, faint and wide on the outside through to
250
+ * a near-solid core. `1` draws the band once, flat.
251
+ */
252
+ layers?: number;
253
+ /** Whether the sides of a band are curves or straight diagonals. */
254
+ edges?: FunnelEdges;
255
+ /**
256
+ * The shortest a non-zero stage is drawn, as a share of the tallest.
257
+ *
258
+ * A stage worth a fifth of a percent of the first is a hairline: it reads as
259
+ * missing rather than as small, and "missing" is a different claim. The floor
260
+ * is only applied to stages that have something in them — a genuine zero is
261
+ * drawn as nothing, because there it is the truth.
262
+ */
263
+ minWidth?: number;
264
+ /** The funnel's hue. Defaults to the first chart token. */
265
+ color?: string;
266
+ /** Milliseconds for one stage to grow. */
267
+ animationDuration?: number;
268
+ /** Milliseconds between one stage starting and the next. `0` for all at once. */
269
+ staggerDelay?: number;
270
+ /** `loading` draws one plain muted ribbon until the data arrives. */
271
+ status?: FunnelChartStatus;
272
+ /** Selected stage. Leave unset to let the chart track it. */
273
+ activeIndex?: number;
274
+ /** Fires with the selected stage, or `-1` when the selection is cleared. */
275
+ onActiveIndexChange?: (index: number) => void;
276
+ children?: ReactNode;
277
+ }
278
+
279
+ /** Imperative handle: re-run the entrance, for a "replay" control. */
280
+ export interface FunnelChartHandle {
281
+ replay: () => void;
282
+ }
283
+
284
+ const FunnelChartRoot = forwardRef<FunnelChartHandle, FunnelChartProps>(
285
+ function FunnelChartRoot(
286
+ {
287
+ className,
288
+ data,
289
+ height = DEFAULT_HEIGHT,
290
+ stageSize,
291
+ gap = 4,
292
+ layers = DEFAULT_LAYERS,
293
+ edges = 'curved',
294
+ minWidth = 0.1,
295
+ color,
296
+ animationDuration = 700,
297
+ staggerDelay = STAGGER,
298
+ status = 'ready',
299
+ activeIndex: activeIndexProp,
300
+ onActiveIndexChange,
301
+ children,
302
+ ...props
303
+ },
304
+ ref
305
+ ) {
306
+ const [width, setWidth] = useState(0);
307
+ const [internalActive, setInternalActive] = useState(-1);
308
+ const reveal = useSharedValue(0);
309
+ const reducedMotion = useReducedMotion();
310
+ const direction = useDirection();
311
+
312
+ const controlled = activeIndexProp !== undefined;
313
+ const activeIndex = controlled ? activeIndexProp : internalActive;
314
+
315
+ const setActiveIndex = useMemo(
316
+ () => (index: number) => {
317
+ if (!controlled) setInternalActive(index);
318
+ onActiveIndexChange?.(index);
319
+ },
320
+ [controlled, onActiveIndexChange]
321
+ );
322
+
323
+ const count = data.length;
324
+ /*
325
+ * A funnel that is still loading usually has no data at all, and no stages
326
+ * to divide the width between. So an empty loading chart is given a
327
+ * plausible number of them — which is also what stops the card from jumping
328
+ * the moment the data lands.
329
+ */
330
+ const stages = count || (status === 'loading' ? SKELETON_STAGES : 0);
331
+ const spacing = Math.max(gap, 0);
332
+
333
+ /*
334
+ * The stages divide the width unless they are given a size. A run sized in
335
+ * points would stop somewhere short of the edge and leave the rest of the
336
+ * card blank, which is the one thing a chart across a card must not do.
337
+ */
338
+ const size =
339
+ stageSize ??
340
+ (stages > 0 ? Math.max(0, (width - (stages - 1) * spacing) / stages) : 0);
341
+
342
+ /** The largest value in the run — see the note about broken funnels above. */
343
+ const peak = useMemo(
344
+ () => data.reduce((most, stage) => Math.max(most, Math.max(0, stage.value)), 0),
345
+ [data]
346
+ );
347
+
348
+ const shares = useMemo(() => {
349
+ const first = Math.max(0, data[0]?.value ?? 0);
350
+ return data.map((stage) =>
351
+ first > 0 ? Math.max(0, stage.value) / first : 0
352
+ );
353
+ }, [data]);
354
+
355
+ const steps = useMemo(
356
+ () =>
357
+ data.map((stage, index) => {
358
+ if (index === 0) return 1;
359
+ const previous = Math.max(0, data[index - 1]?.value ?? 0);
360
+ return previous > 0 ? Math.max(0, stage.value) / previous : 0;
361
+ }),
362
+ [data]
363
+ );
364
+
365
+ /*
366
+ * The run reads leading-edge first like the text under it, and SVG has no
367
+ * leading edge — so under a right-to-left layout the bands are laid from
368
+ * the other end, and each one's two heights swap with them.
369
+ */
370
+ const mirrored = direction === 'rtl';
371
+
372
+ /*
373
+ * The ribbon takes whatever the two text bands leave and sits centred in it,
374
+ * so the tallest stage reaches exactly to the count above and the name
375
+ * below: no strip of nothing between the shape and the words, and no shape
376
+ * creeping under them.
377
+ */
378
+ const textBand = height * TEXT_BAND;
379
+ const reach = Math.max(0, height / 2 - textBand);
380
+ const middle = height / 2;
381
+
382
+ const bands = useMemo<Band[]>(() => {
383
+ if (!count || width <= 0 || reach <= 0) return [];
384
+ const floor = Math.max(0, Math.min(minWidth, 1));
385
+ const extents = data.map((stage) => {
386
+ const value = Math.max(0, stage.value);
387
+ if (value <= 0 || peak <= 0) return 0;
388
+ // The floor is a floor, not a rescale: a stage already above it keeps
389
+ // its true height, so the heights of the stages that matter still read
390
+ // against each other exactly.
391
+ return reach * Math.max(value / peak, floor);
392
+ });
393
+
394
+ return extents.map((extent, index) => {
395
+ const offset = index * (size + spacing);
396
+ const next = extents[index + 1] ?? extent;
397
+ return {
398
+ offset: mirrored ? width - offset - size : offset,
399
+ length: size,
400
+ head: mirrored ? next : extent,
401
+ tail: mirrored ? extent : next,
402
+ };
403
+ });
404
+ }, [count, width, reach, data, peak, minWidth, size, spacing, mirrored]);
405
+
406
+ const hue = useSeriesColor(color, 1);
407
+ const colors = useMemo(
408
+ () => data.map((stage) => stage.color ?? hue),
409
+ [data, hue]
410
+ );
411
+ const strengths = useMemo(
412
+ () =>
413
+ data.map((stage, index) => {
414
+ if (stage.color) return 1;
415
+ if (count < 2) return 1;
416
+ return 1 - (index / (count - 1)) * FADE;
417
+ }),
418
+ [data, count]
419
+ );
420
+
421
+ /*
422
+ * One clock for the whole entrance, with each stage given the slice of it
423
+ * that it grows in. A shared value per stage would be the same animation
424
+ * played `n` times and `n` more things for a replay to have to find.
425
+ */
426
+ const stagger = Math.max(0, staggerDelay);
427
+ const total = animationDuration + Math.max(count - 1, 0) * stagger;
428
+ const windows = useMemo(
429
+ () =>
430
+ data.map((_, index) => {
431
+ const from = index * stagger;
432
+ return {
433
+ from: total > 0 ? from / total : 0,
434
+ to: total > 0 ? (from + animationDuration) / total : 1,
435
+ };
436
+ }),
437
+ [data, stagger, animationDuration, total]
438
+ );
439
+
440
+ const playReveal = useMemo(
441
+ () => () => {
442
+ if (reducedMotion) {
443
+ reveal.value = 1;
444
+ return;
445
+ }
446
+ reveal.value = 0;
447
+ // Linear, because the shaping is per stage: each one eases inside its
448
+ // own window, and easing the clock as well would ease it twice.
449
+ reveal.value = withTiming(1, { duration: total, easing: Easing.linear });
450
+ },
451
+ [reducedMotion, total, reveal]
452
+ );
453
+
454
+ const loading = status === 'loading';
455
+ const revealed = useRef(false);
456
+
457
+ useEffect(() => {
458
+ if (loading) {
459
+ revealed.current = false;
460
+ reveal.value = 0;
461
+ return;
462
+ }
463
+ if (revealed.current || !bands.length) return;
464
+ revealed.current = true;
465
+ playReveal();
466
+ }, [loading, bands.length, playReveal, reveal]);
467
+
468
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
469
+
470
+ // The caller's own `onLayout` is not forwarded from here: it is already on
471
+ // the outer view, and the box it wants is the whole chart's rather than the
472
+ // run's — which are different heights the moment there is a header.
473
+ const onLayout = (event: LayoutChangeEvent) => {
474
+ const next = Math.round(event.nativeEvent.layout.width);
475
+ if (next !== width) setWidth(next);
476
+ };
477
+
478
+ const context = useMemo<FunnelChartContextValue>(
479
+ () => ({
480
+ data,
481
+ width,
482
+ height,
483
+ textBand,
484
+ middle,
485
+ bands,
486
+ shares,
487
+ steps,
488
+ colors,
489
+ strengths,
490
+ layers: Math.max(1, Math.round(layers)),
491
+ curve: edges === 'curved' ? CURVE : 0,
492
+ reveal,
493
+ windows,
494
+ status,
495
+ activeIndex,
496
+ setActiveIndex,
497
+ }),
498
+ [
499
+ data,
500
+ width,
501
+ height,
502
+ textBand,
503
+ middle,
504
+ bands,
505
+ shares,
506
+ steps,
507
+ colors,
508
+ strengths,
509
+ layers,
510
+ edges,
511
+ reveal,
512
+ windows,
513
+ status,
514
+ activeIndex,
515
+ setActiveIndex,
516
+ ]
517
+ );
518
+
519
+ const slots: Record<Slot, ReactNode[]> = {
520
+ svg: [],
521
+ overlay: [],
522
+ header: [],
523
+ footer: [],
524
+ };
525
+ Children.forEach(children, (child, index) => {
526
+ if (!isValidElement(child)) return;
527
+ const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
528
+ slots[slot in slots ? slot : 'overlay'].push(
529
+ <ChildSlot key={index}>{child}</ChildSlot>
530
+ );
531
+ });
532
+
533
+ return (
534
+ <FunnelChartContext.Provider value={context}>
535
+ <View {...props} style={props.style} className={cn('w-full', className)}>
536
+ {slots.header}
537
+ {/*
538
+ * The run is measured on its own view rather than the outer one, so a
539
+ * header or a legend cannot change how wide the funnel thinks it is.
540
+ */}
541
+ <View onLayout={onLayout} style={{ height }} className="w-full">
542
+ {width > 0 && height > 0 ? (
543
+ <>
544
+ <Svg width={width} height={height}>
545
+ {slots.svg}
546
+ </Svg>
547
+ {/*
548
+ * Labels sit over the SVG rather than inside it: they are text,
549
+ * and SVG text ignores the platform's text scaling and the
550
+ * theme's font.
551
+ */}
552
+ <View
553
+ pointerEvents="box-none"
554
+ style={{ position: 'absolute', width, height }}
555
+ >
556
+ {slots.overlay}
557
+ </View>
558
+ </>
559
+ ) : null}
560
+ </View>
561
+ {slots.footer}
562
+ </View>
563
+ </FunnelChartContext.Provider>
564
+ );
565
+ }
566
+ );
567
+ FunnelChartRoot.displayName = 'FunnelChart';
568
+
569
+ function ChildSlot({ children }: { children: ReactNode }) {
570
+ return <>{children}</>;
571
+ }
572
+
573
+ export interface FunnelChartStagesProps {
574
+ /** Opacity of the stages that are not selected, once one is. */
575
+ dimOpacity?: number;
576
+ }
577
+
578
+ /**
579
+ * Every stage, drawn in the order the data lists them.
580
+ *
581
+ * One part rather than one per datum. A stage's near edge is the previous
582
+ * stage's far edge, so they cannot be configured apart without the ribbon
583
+ * coming apart with them — a funnel whose third stage could be given its own
584
+ * height would be a funnel drawing a shape that is not in the data.
585
+ */
586
+ function FunnelChartStages({ dimOpacity = 0.3 }: FunnelChartStagesProps) {
587
+ const {
588
+ data,
589
+ bands,
590
+ colors,
591
+ strengths,
592
+ middle,
593
+ layers,
594
+ curve,
595
+ reveal,
596
+ windows,
597
+ status,
598
+ shares,
599
+ activeIndex,
600
+ setActiveIndex,
601
+ } = useChart('FunnelChart.Stages');
602
+
603
+ if (status === 'loading' || !bands.length) return null;
604
+
605
+ return (
606
+ <G>
607
+ {bands.map((band, index) => {
608
+ const datum = data[index];
609
+ const window = windows[index];
610
+ if (!datum || !window || (band.head <= 0 && band.tail <= 0)) return null;
611
+ return (
612
+ <Stage
613
+ key={datum.label}
614
+ band={band}
615
+ middle={middle}
616
+ curve={curve}
617
+ layers={layers}
618
+ fill={colors[index] ?? colors[0]!}
619
+ strength={strengths[index] ?? 1}
620
+ reveal={reveal}
621
+ window={window}
622
+ selected={activeIndex === index}
623
+ dimmed={activeIndex >= 0 && activeIndex !== index}
624
+ dimOpacity={dimOpacity}
625
+ label={datum.label}
626
+ value={datum.value}
627
+ percent={Math.round((shares[index] ?? 0) * 100)}
628
+ onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
629
+ />
630
+ );
631
+ })}
632
+ </G>
633
+ );
634
+ }
635
+ FunnelChartStages.displayName = 'FunnelChart.Stages';
636
+ FunnelChartStages.slot = 'svg' as const;
637
+
638
+ /**
639
+ * One stage, drawn as a stack of concentric rings.
640
+ *
641
+ * Every ring is rebuilt on the UI thread each frame it is moving. They grow out
642
+ * of the centre line rather than being scaled from nothing, because a scale
643
+ * would take the curve's control points with it and the sides would flex on the
644
+ * way in; growing the heights leaves the shape's geometry alone and only ever
645
+ * changes how far it reaches.
646
+ */
647
+ function Stage({
648
+ band,
649
+ middle,
650
+ curve,
651
+ layers,
652
+ fill,
653
+ strength,
654
+ reveal,
655
+ window,
656
+ selected,
657
+ dimmed,
658
+ dimOpacity,
659
+ label,
660
+ value,
661
+ percent,
662
+ onPress,
663
+ }: {
664
+ band: Band;
665
+ middle: number;
666
+ curve: number;
667
+ layers: number;
668
+ fill: string;
669
+ strength: number;
670
+ reveal: SharedValue<number>;
671
+ window: { from: number; to: number };
672
+ selected: boolean;
673
+ dimmed: boolean;
674
+ dimOpacity: number;
675
+ label: string;
676
+ value: number;
677
+ percent: number;
678
+ onPress: () => void;
679
+ }) {
680
+ const dim = useDerivedValue<number>(() =>
681
+ withTiming(dimmed ? 1 : 0, { duration: SELECT_DURATION })
682
+ );
683
+ const spread = useDerivedValue<number>(() =>
684
+ withTiming(selected ? 1 : 0, { duration: SELECT_DURATION })
685
+ );
686
+
687
+ return (
688
+ <G
689
+ onPress={onPress}
690
+ // An SVG node takes a label but not a role, so the stages are reachable
691
+ // and named without being announced as buttons. `FunnelChart.Labels` and
692
+ // `FunnelChart.Legend` are the properly wired way through the same
693
+ // selection, and the larger targets.
694
+ accessibilityLabel={`${label}, ${value}, ${percent} percent of the first stage`}
695
+ >
696
+ {Array.from({ length: layers }, (_, ring) => (
697
+ <Ring
698
+ key={ring}
699
+ band={band}
700
+ middle={middle}
701
+ curve={curve}
702
+ fill={fill}
703
+ // The outermost ring is the tallest and the faintest, the innermost
704
+ // the tightest and the strongest, so the fill falls off towards the
705
+ // edge instead of ending at one.
706
+ scale={1 - (ring / layers) * RING_INSET}
707
+ opacity={
708
+ strength *
709
+ (RING_FAINTEST +
710
+ (ring / Math.max(layers - 1, 1)) * (RING_STRONGEST - RING_FAINTEST))
711
+ }
712
+ // Selecting a stage pushes the core out further than the halo, which
713
+ // reads as the band swelling rather than as the whole thing resizing.
714
+ spreadBy={(ring / Math.max(layers - 1, 1)) * SELECT_SPREAD}
715
+ reveal={reveal}
716
+ window={window}
717
+ spread={spread}
718
+ dim={dim}
719
+ dimOpacity={dimOpacity}
720
+ />
721
+ ))}
722
+ </G>
723
+ );
724
+ }
725
+
726
+ function Ring({
727
+ band,
728
+ middle,
729
+ curve,
730
+ fill,
731
+ scale,
732
+ opacity,
733
+ spreadBy,
734
+ reveal,
735
+ window,
736
+ spread,
737
+ dim,
738
+ dimOpacity,
739
+ }: {
740
+ band: Band;
741
+ middle: number;
742
+ curve: number;
743
+ fill: string;
744
+ scale: number;
745
+ opacity: number;
746
+ spreadBy: number;
747
+ reveal: SharedValue<number>;
748
+ window: { from: number; to: number };
749
+ spread: SharedValue<number>;
750
+ dim: SharedValue<number>;
751
+ dimOpacity: number;
752
+ }) {
753
+ const { offset, length, head, tail } = band;
754
+ const { from, to } = window;
755
+
756
+ const animatedProps = useAnimatedProps(() => {
757
+ const range = to - from;
758
+ const raw = range > 0 ? (reveal.value - from) / range : 1;
759
+ const progress = raw < 0 ? 0 : raw > 1 ? 1 : raw;
760
+ // Ease out cubic: fast to most of the way, then settling. Written out
761
+ // rather than called, because an easing from the animation library is not
762
+ // a worklet and this runs on the UI thread.
763
+ const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress);
764
+ const reach = scale * eased * (1 + spread.value * spreadBy);
765
+
766
+ return {
767
+ d: ribbonPath(offset, length, head * reach, tail * reach, middle, curve),
768
+ fillOpacity: opacity * (1 - dim.value * (1 - dimOpacity)),
769
+ };
770
+ });
771
+
772
+ return <AnimatedPath animatedProps={animatedProps} fill={fill} />;
773
+ }
774
+
775
+ export interface FunnelChartSkeletonProps {
776
+ color?: string;
777
+ }
778
+
779
+ /**
780
+ * The loading state: one plain ribbon over the whole run, undivided.
781
+ *
782
+ * Deliberately undivided. Placeholder stages would be an invented drop-off, and
783
+ * a reader has no way to tell an invented one from a real one until it changes
784
+ * under them — which is worse than showing nothing, because it is showing
785
+ * something wrong.
786
+ */
787
+ function FunnelChartSkeleton({ color }: FunnelChartSkeletonProps) {
788
+ const { width, height, middle, textBand, curve, status } =
789
+ useChart('FunnelChart.Skeleton');
790
+ const token = useCSSVariable('--color-skeleton');
791
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
792
+
793
+ if (status !== 'loading' || width <= 0 || height <= 0) return null;
794
+
795
+ const extent = Math.max(0, height / 2 - textBand);
796
+
797
+ return (
798
+ <Path
799
+ d={ribbonPath(0, width, extent, extent * 0.4, middle, curve)}
800
+ fill={fill}
801
+ />
802
+ );
803
+ }
804
+ FunnelChartSkeleton.displayName = 'FunnelChart.Skeleton';
805
+ FunnelChartSkeleton.slot = 'svg' as const;
806
+
807
+ /** Which ratio a stage's pill reports. */
808
+ export type FunnelShare = 'previous' | 'top' | 'none';
809
+
810
+ export interface FunnelChartLabelsProps {
811
+ className?: string;
812
+ /** Format the count. Defaults to a compact number. */
813
+ formatValue?: (value: number, stage: FunnelDatum) => string;
814
+ /** Format the conversion in the pill. Defaults to a whole percent. */
815
+ formatShare?: (share: number, stage: FunnelDatum) => string;
816
+ /**
817
+ * Which conversion the pill reports. `top` is the share of the first stage,
818
+ * which every stage has and which reads along the run as one falling series.
819
+ * `previous` is the drop from the stage above — the step-by-step reading,
820
+ * where the first stage has nothing above it and so carries no pill.
821
+ */
822
+ share?: FunnelShare;
823
+ /** Show the count above the ribbon. */
824
+ showValue?: boolean;
825
+ /** Show the stage's name under the ribbon. */
826
+ showLabel?: boolean;
827
+ }
828
+
829
+ /**
830
+ * The readings, arranged around the ribbon: the count above the band, the name
831
+ * under it, and the conversion in a pill on the band itself.
832
+ *
833
+ * Three places rather than one line, and that is the whole point of it. Put a
834
+ * name, a count and a percentage together on one row and the row is as wide as
835
+ * all three — so at the width a phone actually has, the name is the one that
836
+ * gives way and the reader is left with "Checkout st…" against a number. Split
837
+ * around the band, each reading has the stage's full column to itself.
838
+ *
839
+ * The pill is a filled chip rather than bare text because it is the one reading
840
+ * that sits *on* the ribbon, where the fill behind it is the same token family
841
+ * as the text would be. Punched out of its own background, it reads whatever
842
+ * the band is doing underneath.
843
+ *
844
+ * The columns are pressable rather than the shape alone: a two-percent stage is
845
+ * a sliver, and the column it lives in is a target.
846
+ */
847
+ function FunnelChartLabels({
848
+ className,
849
+ formatValue,
850
+ formatShare,
851
+ share = 'top',
852
+ showValue = true,
853
+ showLabel = true,
854
+ }: FunnelChartLabelsProps) {
855
+ const { data, bands, shares, steps, textBand, status, activeIndex, setActiveIndex } =
856
+ useChart('FunnelChart.Labels');
857
+
858
+ if (status === 'loading' || !bands.length) return null;
859
+
860
+ const format = formatValue ?? ((value: number) => compactNumber(value));
861
+ const formatPercent =
862
+ formatShare ?? ((ratio: number) => `${Math.round(ratio * 100)}%`);
863
+
864
+ return (
865
+ <>
866
+ {bands.map((band, index) => {
867
+ const datum = data[index];
868
+ if (!datum) return null;
869
+
870
+ const ratio = share === 'top' ? shares[index] : steps[index];
871
+ const pill =
872
+ share === 'none' || (share === 'previous' && index === 0)
873
+ ? null
874
+ : formatPercent(ratio ?? 0, datum);
875
+ const dimmed = activeIndex >= 0 && activeIndex !== index;
876
+ const value = format(datum.value, datum);
877
+
878
+ return (
879
+ <Pressable
880
+ key={datum.label}
881
+ accessibilityRole="button"
882
+ accessibilityState={{ selected: activeIndex === index }}
883
+ accessibilityLabel={`${datum.label}, ${value}${pill ? `, ${pill}` : ''}`}
884
+ onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
885
+ style={{
886
+ position: 'absolute',
887
+ left: band.offset,
888
+ width: band.length,
889
+ top: 0,
890
+ bottom: 0,
891
+ opacity: dimmed ? 0.5 : 1,
892
+ }}
893
+ className={cn('items-center px-1', className)}
894
+ >
895
+ {/*
896
+ * The two strips are the same share of the height the ribbon leaves
897
+ * them, so the text ends exactly where the shape starts rather than
898
+ * at a padding somebody guessed.
899
+ */}
900
+ <View style={{ height: textBand }} className="justify-end pb-1">
901
+ {showValue ? (
902
+ <Text size="sm" weight="semibold" numberOfLines={1}>
903
+ {value}
904
+ </Text>
905
+ ) : null}
906
+ </View>
907
+ <View className="flex-1 justify-center">
908
+ {pill ? (
909
+ <View className="rounded-full border border-border bg-background px-2 py-0.5">
910
+ <Text size="xs" weight="bold" numberOfLines={1}>
911
+ {pill}
912
+ </Text>
913
+ </View>
914
+ ) : null}
915
+ </View>
916
+ <View style={{ height: textBand }} className="justify-start pt-1">
917
+ {showLabel ? (
918
+ <Text size="xs" muted weight="medium" numberOfLines={1}>
919
+ {datum.label}
920
+ </Text>
921
+ ) : null}
922
+ </View>
923
+ </Pressable>
924
+ );
925
+ })}
926
+ </>
927
+ );
928
+ }
929
+ FunnelChartLabels.displayName = 'FunnelChart.Labels';
930
+ FunnelChartLabels.slot = 'overlay' as const;
931
+
932
+ /** How the key under the run is arranged. */
933
+ export type FunnelLegendLayout = 'list' | 'inline';
934
+
935
+ export interface FunnelChartLegendProps extends ViewProps {
936
+ className?: string;
937
+ /**
938
+ * `list` gives every stage a row of its own, with the names down one column
939
+ * and the numbers down another. `inline` runs them together across the width
940
+ * and wraps, which is the denser arrangement where the names are short.
941
+ */
942
+ layout?: FunnelLegendLayout;
943
+ /** Show each stage's reading beside its name. */
944
+ showValue?: boolean;
945
+ /** Format the value in a `list` key. Defaults to a compact number. */
946
+ formatValue?: (value: number, stage: FunnelDatum) => string;
947
+ }
948
+
949
+ /**
950
+ * A swatch, a name and a reading per stage, under the run. Pressable in the
951
+ * same way the stages are.
952
+ *
953
+ * For a funnel drawn without `Labels` — a compact one on a dashboard card,
954
+ * where the run is a shape and the reading is underneath it.
955
+ *
956
+ * A row each, by default. The stages of a funnel are a sequence, and a wrapped
957
+ * centred line loses that: the reader gets a ragged block of names in which the
958
+ * order is only implied by the order they happen to be read in, and a long
959
+ * stage name breaks it across lines that no longer line up with anything. Down
960
+ * a column the order is the order, and the numbers stack into a column of their
961
+ * own that can be compared at a glance.
962
+ */
963
+ function FunnelChartLegend({
964
+ className,
965
+ layout = 'list',
966
+ showValue = true,
967
+ formatValue,
968
+ ...props
969
+ }: FunnelChartLegendProps) {
970
+ const { data, shares, colors, strengths, activeIndex, setActiveIndex } =
971
+ useChart('FunnelChart.Legend');
972
+
973
+ if (!data.length) return null;
974
+
975
+ const list = layout === 'list';
976
+ const format = formatValue ?? ((value: number) => compactNumber(value));
977
+
978
+ return (
979
+ <View
980
+ {...props}
981
+ className={cn(
982
+ 'w-full pt-3',
983
+ list
984
+ ? 'gap-1.5'
985
+ : 'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5',
986
+ className
987
+ )}
988
+ >
989
+ {data.map((stage, index) => {
990
+ const percent = Math.round((shares[index] ?? 0) * 100);
991
+ const dimmed = activeIndex >= 0 && activeIndex !== index;
992
+ return (
993
+ <Pressable
994
+ key={stage.label}
995
+ accessibilityRole="button"
996
+ accessibilityState={{ selected: activeIndex === index }}
997
+ accessibilityLabel={`${stage.label}, ${percent} percent of the first stage`}
998
+ onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
999
+ style={{ opacity: dimmed ? 0.4 : 1 }}
1000
+ className={cn(
1001
+ 'flex-row items-center gap-1.5',
1002
+ list ? 'w-full' : 'max-w-full'
1003
+ )}
1004
+ >
1005
+ <View
1006
+ style={{
1007
+ width: 8,
1008
+ height: 8,
1009
+ borderRadius: 4,
1010
+ backgroundColor: colors[index],
1011
+ opacity: strengths[index] ?? 1,
1012
+ }}
1013
+ />
1014
+ <Text
1015
+ size="xs"
1016
+ muted
1017
+ numberOfLines={1}
1018
+ className={list ? 'flex-1' : 'shrink'}
1019
+ >
1020
+ {stage.label}
1021
+ </Text>
1022
+ {showValue ? (
1023
+ <>
1024
+ {/*
1025
+ * The value only earns its place in a list, where it lands in a
1026
+ * column with the others. Inline it would be a second number
1027
+ * loose in a wrapping line, and the share is the one worth
1028
+ * having there.
1029
+ */}
1030
+ {list ? (
1031
+ <Text size="xs" weight="medium" numberOfLines={1}>
1032
+ {format(stage.value, stage)}
1033
+ </Text>
1034
+ ) : null}
1035
+ <Text
1036
+ size="xs"
1037
+ muted={list}
1038
+ weight={list ? 'normal' : 'medium'}
1039
+ numberOfLines={1}
1040
+ className={list ? 'w-10 text-right' : undefined}
1041
+ >
1042
+ {percent}%
1043
+ </Text>
1044
+ </>
1045
+ ) : null}
1046
+ </Pressable>
1047
+ );
1048
+ })}
1049
+ </View>
1050
+ );
1051
+ }
1052
+ FunnelChartLegend.displayName = 'FunnelChart.Legend';
1053
+ FunnelChartLegend.slot = 'footer' as const;
1054
+
1055
+ export interface FunnelChartHeaderProps extends ViewProps {
1056
+ className?: string;
1057
+ /** Small line above the value — what the funnel is of. */
1058
+ title?: string;
1059
+ /** The readout. The largest thing on the card, and the first thing read. */
1060
+ value?: string;
1061
+ /** One muted line under the value — a period, a comparison, a caveat. */
1062
+ caption?: string;
1063
+ /** Trailing slot — a control, a badge, a range picker. */
1064
+ children?: ReactNode;
1065
+ }
1066
+
1067
+ /**
1068
+ * The strip above the run: what the funnel is of and what it reads.
1069
+ *
1070
+ * It belongs to the chart rather than to the card around it because it is about
1071
+ * the *stages* — the number changes as one is selected. The card's header is a
1072
+ * caption on the tray the chart sits in; this is the chart introducing itself.
1073
+ *
1074
+ * The value is not derived even though there is a first stage to derive it
1075
+ * from, because the formatting is not the chart's to guess: 41800 is a count,
1076
+ * a currency or a rate depending on what was counted.
1077
+ */
1078
+ function FunnelChartHeader({
1079
+ className,
1080
+ title,
1081
+ value,
1082
+ caption,
1083
+ children,
1084
+ ...props
1085
+ }: FunnelChartHeaderProps) {
1086
+ return (
1087
+ <View
1088
+ {...props}
1089
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1090
+ >
1091
+ <View className="flex-1 gap-0.5">
1092
+ {title ? (
1093
+ <Text size="xs" muted>
1094
+ {title}
1095
+ </Text>
1096
+ ) : null}
1097
+ {value ? (
1098
+ <Text size="xl" weight="bold">
1099
+ {value}
1100
+ </Text>
1101
+ ) : null}
1102
+ {caption ? (
1103
+ <Text size="xs" muted>
1104
+ {caption}
1105
+ </Text>
1106
+ ) : null}
1107
+ </View>
1108
+ {children ? <View className="max-w-[55%] shrink pt-1">{children}</View> : null}
1109
+ </View>
1110
+ );
1111
+ }
1112
+ FunnelChartHeader.displayName = 'FunnelChart.Header';
1113
+ FunnelChartHeader.slot = 'header' as const;
1114
+
1115
+ export const FunnelChart = Object.assign(FunnelChartRoot, {
1116
+ Header: FunnelChartHeader,
1117
+ Stages: FunnelChartStages,
1118
+ Labels: FunnelChartLabels,
1119
+ Legend: FunnelChartLegend,
1120
+ Skeleton: FunnelChartSkeleton,
1121
+ });