panelui-native 0.100.0 → 0.101.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 (31) hide show
  1. package/README.md +2 -0
  2. package/lib/module/components/compare/index.js +576 -0
  3. package/lib/module/components/compare/index.js.map +1 -0
  4. package/lib/module/components/sankey-chart/index.js +933 -0
  5. package/lib/module/components/sankey-chart/index.js.map +1 -0
  6. package/lib/module/components/sankey-chart/sankey-layout.js +530 -0
  7. package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -0
  8. package/lib/module/icons/index.js +15 -3
  9. package/lib/module/icons/index.js.map +1 -1
  10. package/lib/module/index.js +2 -0
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/utils/chart.js +31 -0
  13. package/lib/module/utils/chart.js.map +1 -1
  14. package/lib/typescript/src/components/compare/index.d.ts +183 -0
  15. package/lib/typescript/src/components/compare/index.d.ts.map +1 -0
  16. package/lib/typescript/src/components/sankey-chart/index.d.ts +307 -0
  17. package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -0
  18. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +107 -0
  19. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -0
  20. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  21. package/lib/typescript/src/index.d.ts +2 -0
  22. package/lib/typescript/src/index.d.ts.map +1 -1
  23. package/lib/typescript/src/utils/chart.d.ts +17 -0
  24. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/compare/index.tsx +650 -0
  27. package/src/components/sankey-chart/index.tsx +1165 -0
  28. package/src/components/sankey-chart/sankey-layout.ts +645 -0
  29. package/src/icons/index.tsx +20 -6
  30. package/src/index.ts +25 -0
  31. package/src/utils/chart.ts +46 -0
@@ -0,0 +1,1165 @@
1
+ /**
2
+ * SankeyChart — where a quantity came from and where it ended up.
3
+ *
4
+ * ```tsx
5
+ * <SankeyChart nodes={stages} links={flows}>
6
+ * <SankeyChart.Header title="Traffic" value="128,400" />
7
+ * <SankeyChart.Links />
8
+ * <SankeyChart.Nodes />
9
+ * <SankeyChart.Labels />
10
+ * <SankeyChart.Tooltip />
11
+ * </SankeyChart>
12
+ * ```
13
+ *
14
+ * ## What it answers that the other charts do not
15
+ *
16
+ * Every other chart here takes one set of things and measures them. This one
17
+ * takes two and says how much of the first became the second. A treemap cuts a
18
+ * total into its parts, a funnel counts what survived each step, a waterfall
19
+ * carries a balance from one figure to another — none of them can say that
20
+ * *this* source fed *that* destination, because none of them draws a thing that
21
+ * has two ends.
22
+ *
23
+ * So the question to bring to it is a routing question. Which campaigns
24
+ * produced which signups; which budget lines paid for which departments; what
25
+ * the traffic that arrived on the landing page went on to do. If the answer
26
+ * does not need a source *and* a target, one of the simpler charts will read
27
+ * better at the same size.
28
+ *
29
+ * ## The ribbon is the reading
30
+ *
31
+ * A ribbon's thickness is its value, on one scale shared by the whole diagram,
32
+ * so a ribbon twice as thick is twice as much wherever it is on the page. That
33
+ * is the only quantity here: the horizontal distance a ribbon travels is the
34
+ * number of columns between its ends and means nothing else, and the vertical
35
+ * order within a column is chosen to keep the ribbons from crossing rather than
36
+ * to rank anything.
37
+ *
38
+ * A node's height is what passes through it — the larger of what arrives and
39
+ * what leaves, which are the same number unless some of it went nowhere. Where
40
+ * they differ, give the node an explicit `value` to pin it; the diagram cannot
41
+ * infer a loss it was never told about.
42
+ *
43
+ * ## Columns are the flow's, not the caller's
44
+ *
45
+ * Nothing about the order of the `nodes` array decides where a node is drawn. A
46
+ * node that receives from another has to be drawn after it or its ribbon would
47
+ * run backwards, so the columns come out of the links. `align` only settles the
48
+ * cases the flow leaves open, and the default pushes every node that feeds
49
+ * nothing into the last column, so the diagram ends on a straight edge of
50
+ * destinations instead of a ragged one.
51
+ *
52
+ * ## Bad rows are dropped rather than fatal
53
+ *
54
+ * Flow data is nearly always joined together from somewhere nobody in the room
55
+ * owns, and it arrives with rows that name a node that is not there, carry a
56
+ * zero, or close a loop. A loop in particular has no left-to-right reading at
57
+ * all. All of them are dropped and counted, and `onDropLinks` reports how many
58
+ * — so a screen can say "3 rows could not be drawn" instead of going blank or
59
+ * quietly showing less than it was given.
60
+ */
61
+ import {
62
+ Children,
63
+ createContext,
64
+ forwardRef,
65
+ isValidElement,
66
+ useContext,
67
+ useEffect,
68
+ useImperativeHandle,
69
+ useMemo,
70
+ useRef,
71
+ useState,
72
+ type ReactNode,
73
+ } from 'react';
74
+ import { Platform, Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
75
+ import Animated, {
76
+ Easing,
77
+ useAnimatedProps,
78
+ useDerivedValue,
79
+ useReducedMotion,
80
+ useSharedValue,
81
+ withTiming,
82
+ type SharedValue,
83
+ } from 'react-native-reanimated';
84
+ import Svg, { G, Path, Rect } from 'react-native-svg';
85
+ import { useCSSVariable } from 'uniwind';
86
+ import {
87
+ ChartAccessibilityData,
88
+ type ChartAccessibilityProps,
89
+ } from '../../primitives/chart-accessibility';
90
+ import { Text } from '../../primitives/text';
91
+ import { compactNumber, flowPath, seriesColorAt, useSeriesColor } from '../../utils/chart';
92
+ import { cn } from '../../utils/cn';
93
+ import { useDirection } from '../../hooks/use-direction';
94
+ import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
95
+ import { sankeyLayout, type SankeyAlign, type SankeyLayout } from './sankey-layout';
96
+
97
+ export type { SankeyAlign } from './sankey-layout';
98
+
99
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
100
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
101
+ const AnimatedG = Animated.createAnimatedComponent(G);
102
+
103
+ /** How tall the diagram is drawn when the caller does not say. */
104
+ const DEFAULT_HEIGHT = 240;
105
+
106
+ /** How thick a node's bar is. Thin enough to read as an edge the flow meets. */
107
+ const DEFAULT_NODE_WIDTH = 10;
108
+
109
+ /** The gap asked for between two nodes in a column. */
110
+ const DEFAULT_NODE_PADDING = 14;
111
+
112
+ /** Relaxation rounds. Where the arrangement stops visibly improving. */
113
+ const DEFAULT_ITERATIONS = 6;
114
+
115
+ /**
116
+ * How far a ribbon's control points reach towards the middle.
117
+ *
118
+ * Exactly half, which puts both on the centre line and makes the two halves of
119
+ * every ribbon mirror images. Anything less leaves a visible straight section
120
+ * in the middle that reads as a kink where two ribbons were joined.
121
+ */
122
+ const CURVE = 0.5;
123
+
124
+ /** A ribbon at rest. Translucent, because ribbons cross and both must be read. */
125
+ const LINK_OPACITY = 0.4;
126
+
127
+ /** A ribbon belonging to the selected node. */
128
+ const LINK_ACTIVE_OPACITY = 0.78;
129
+
130
+ /** And one that does not, once something is selected. */
131
+ const LINK_DIM_OPACITY = 0.08;
132
+
133
+ /** Milliseconds for one column of the flow to draw itself. */
134
+ const DEFAULT_DURATION = 620;
135
+
136
+ /** Milliseconds between one column starting and the next. */
137
+ const STAGGER = 110;
138
+
139
+ /** Milliseconds for a selection to take hold. */
140
+ const SELECT_DURATION = 180;
141
+
142
+ /** Space between a node's bar and its name. */
143
+ const LABEL_GAP = 6;
144
+
145
+ /** The smallest a label's press target is allowed to be, in points. */
146
+ const MIN_TARGET = 44;
147
+
148
+ /** Columns the placeholder suggests while there is no data to count. */
149
+ const SKELETON_COLUMNS = 3;
150
+
151
+ /**
152
+ * What takes the geometry out of the accessibility tree, per platform.
153
+ *
154
+ * The two native props reach the DOM untranslated through react-native-svg, so
155
+ * on web they mean nothing and one of them draws a React warning for its
156
+ * casing. `aria-hidden` is what hides an `<svg>` there.
157
+ */
158
+ const HIDDEN = (
159
+ Platform.OS === 'web'
160
+ ? { 'aria-hidden': true }
161
+ : {
162
+ accessibilityElementsHidden: true,
163
+ importantForAccessibility: 'no-hide-descendants',
164
+ }
165
+ ) as Record<string, unknown>;
166
+
167
+ type Slot = 'svg' | 'overlay' | 'header' | 'footer';
168
+
169
+ export type SankeyChartStatus = 'loading' | 'ready';
170
+
171
+ export interface SankeyNode {
172
+ /** Stable key the links name. Must be unique; a repeat is ignored. */
173
+ id: string;
174
+ /** Name drawn beside the bar. Defaults to the id. */
175
+ label?: string;
176
+ /** Explicit colour, instead of the node's place in the palette. */
177
+ color?: string;
178
+ /**
179
+ * Pins what passes through the node, for a stage that loses some of what it
180
+ * received to somewhere the links do not describe. Left unset the node is
181
+ * worth the larger of what arrives and what leaves.
182
+ */
183
+ value?: number;
184
+ }
185
+
186
+ export interface SankeyLink {
187
+ /** `id` of the node it leaves. */
188
+ source: string;
189
+ /** `id` of the node it reaches. */
190
+ target: string;
191
+ /** How much travels. Zero, negative and non-finite rows are dropped. */
192
+ value: number;
193
+ /** Explicit colour, instead of taking the source node's. */
194
+ color?: string;
195
+ }
196
+
197
+ interface SankeyChartContextValue {
198
+ nodes: SankeyNode[];
199
+ links: SankeyLink[];
200
+ layout: SankeyLayout;
201
+ width: number;
202
+ height: number;
203
+ curve: number;
204
+ /** One colour per laid-out node, in the laid-out order. */
205
+ colors: string[];
206
+ /** Where each column sits in the entrance, `0` to `1`. */
207
+ windows: { from: number; to: number }[];
208
+ reveal: SharedValue<number>;
209
+ status: SankeyChartStatus;
210
+ activeId: string | null;
211
+ setActiveId: (id: string | null) => void;
212
+ labelFor: (id: string) => string;
213
+ }
214
+
215
+ const SankeyChartContext = createContext<SankeyChartContextValue | null>(null);
216
+
217
+ function useChart(component: string): SankeyChartContextValue {
218
+ const context = useContext(SankeyChartContext);
219
+ if (!context) {
220
+ throw new Error(`${component} must be used within a <SankeyChart>`);
221
+ }
222
+ return context;
223
+ }
224
+
225
+ /** The selected node and what runs through it, for something drawn inside the chart. */
226
+ export function useSankeyChart() {
227
+ const { nodes, layout, activeId } = useChart('useSankeyChart');
228
+
229
+ return useMemo(() => {
230
+ const placed = activeId ? layout.nodes.find((node) => node.id === activeId) : undefined;
231
+ if (!placed) {
232
+ return { activeId: null, activeNode: null, activeValue: 0, incoming: 0, outgoing: 0 };
233
+ }
234
+
235
+ const position = layout.nodes.indexOf(placed);
236
+ let incoming = 0;
237
+ let outgoing = 0;
238
+ for (const link of layout.links) {
239
+ if (link.target === position) incoming += link.value;
240
+ if (link.source === position) outgoing += link.value;
241
+ }
242
+
243
+ return {
244
+ activeId,
245
+ activeNode: nodes.find((node) => node.id === activeId) ?? null,
246
+ /** What passes through it — what the bar's height is drawn from. */
247
+ activeValue: placed.value,
248
+ /** What arrives. Zero at a node the flow starts from. */
249
+ incoming,
250
+ /** What leaves. Zero at a node the flow ends at. */
251
+ outgoing,
252
+ };
253
+ }, [nodes, layout, activeId]);
254
+ }
255
+
256
+ export interface SankeyChartProps
257
+ extends ViewProps,
258
+ ChartAccessibilityProps<SankeyNode> {
259
+ className?: string;
260
+ /** The stages. Order does not decide position — the links do. */
261
+ nodes: SankeyNode[];
262
+ /** What travels between them. */
263
+ links: SankeyLink[];
264
+ /**
265
+ * How tall the diagram is drawn, in points.
266
+ *
267
+ * The width is the card's, but nothing in a flow says how deep it should be:
268
+ * a diagram of four nodes and one of forty are the same data at two heights,
269
+ * and which of them is right is a question about the screen.
270
+ */
271
+ height?: number;
272
+ /** How thick a node's bar is, in points. */
273
+ nodeWidth?: number;
274
+ /**
275
+ * The gap between two nodes in a column, in points.
276
+ *
277
+ * A maximum rather than a promise. A crowded column gives its spacing up
278
+ * before it gives up the height of its bars, because the bar is the reading.
279
+ */
280
+ nodePadding?: number;
281
+ /** Which column a node goes in where the flow leaves a choice. */
282
+ align?: SankeyAlign;
283
+ /** Relaxation rounds spent untangling the ribbons. */
284
+ iterations?: number;
285
+ /** How far a ribbon bends, `0` for a straight diagonal and `0.5` for an S. */
286
+ curve?: number;
287
+ /** The first hue. The rest of the palette follows from the theme's tokens. */
288
+ color?: string;
289
+ /** Milliseconds for one column to draw itself. */
290
+ animationDuration?: number;
291
+ /** Milliseconds between one column starting and the next. `0` for all at once. */
292
+ staggerDelay?: number;
293
+ /** `loading` draws a plain placeholder until the data arrives. */
294
+ status?: SankeyChartStatus;
295
+ /** Selected node. Leave unset to let the chart track it. */
296
+ activeId?: string | null;
297
+ /** Fires with the selected node's id, or `null` when the selection is cleared. */
298
+ onActiveIdChange?: (id: string | null) => void;
299
+ /**
300
+ * Fires with how many link rows could not be drawn — ones naming a node that
301
+ * is not there, carrying nothing, or closing a loop. `0` after a clean render,
302
+ * so a banner can be shown and taken away from the same signal.
303
+ */
304
+ onDropLinks?: (count: number) => void;
305
+ children?: ReactNode;
306
+ }
307
+
308
+ /** Imperative handle: re-run the entrance, for a "replay" control. */
309
+ export interface SankeyChartHandle {
310
+ replay: () => void;
311
+ }
312
+
313
+ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
314
+ function SankeyChartRoot(
315
+ {
316
+ className,
317
+ nodes,
318
+ links,
319
+ height = DEFAULT_HEIGHT,
320
+ nodeWidth = DEFAULT_NODE_WIDTH,
321
+ nodePadding = DEFAULT_NODE_PADDING,
322
+ align = 'justify',
323
+ iterations = DEFAULT_ITERATIONS,
324
+ curve = CURVE,
325
+ color,
326
+ animationDuration = DEFAULT_DURATION,
327
+ staggerDelay = STAGGER,
328
+ status = 'ready',
329
+ activeId: activeIdProp,
330
+ onActiveIdChange,
331
+ onDropLinks,
332
+ accessibilityLabel,
333
+ accessibilityHint,
334
+ accessibilityLabelForDatum,
335
+ onAccessibilityDatumPress,
336
+ children,
337
+ ...props
338
+ },
339
+ ref
340
+ ) {
341
+ const [width, setWidth] = useState(0);
342
+ const [internalActive, setInternalActive] = useState<string | null>(null);
343
+ const reveal = useSharedValue(0);
344
+ const reducedMotion = useReducedMotion();
345
+ const direction = useDirection();
346
+
347
+ const controlled = activeIdProp !== undefined;
348
+ const activeId = controlled ? activeIdProp : internalActive;
349
+
350
+ const setActiveId = useMemo(
351
+ () => (id: string | null) => {
352
+ if (!controlled) setInternalActive(id);
353
+ onActiveIdChange?.(id);
354
+ },
355
+ [controlled, onActiveIdChange]
356
+ );
357
+
358
+ const layout = useMemo(
359
+ () =>
360
+ sankeyLayout(nodes, links, {
361
+ width,
362
+ height,
363
+ nodeWidth,
364
+ nodePadding,
365
+ align,
366
+ iterations,
367
+ }),
368
+ [nodes, links, width, height, nodeWidth, nodePadding, align, iterations]
369
+ );
370
+
371
+ /*
372
+ * A flow reads from where it starts, and under a right-to-left layout that
373
+ * is the right-hand edge. Mirroring the finished layout rather than laying
374
+ * it out backwards keeps one set of maths under both directions — the
375
+ * arrangement is identical, it is only read from the other end.
376
+ */
377
+ const mirrored = direction === 'rtl';
378
+ const placed = useMemo<SankeyLayout>(() => {
379
+ if (!mirrored || !layout.nodes.length) return layout;
380
+ return {
381
+ ...layout,
382
+ nodes: layout.nodes.map((node) => ({
383
+ ...node,
384
+ x0: width - node.x1,
385
+ x1: width - node.x0,
386
+ })),
387
+ };
388
+ }, [layout, mirrored, width]);
389
+
390
+ const dropped = layout.dropped;
391
+ useEffect(() => {
392
+ onDropLinks?.(dropped);
393
+ }, [dropped, onDropLinks]);
394
+
395
+ const c1 = useSeriesColor(color, 1);
396
+ const c2 = useSeriesColor(undefined, 2);
397
+ const c3 = useSeriesColor(undefined, 3);
398
+ const c4 = useSeriesColor(undefined, 4);
399
+ const c5 = useSeriesColor(undefined, 5);
400
+ const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);
401
+
402
+ /*
403
+ * Coloured by the node's own position in the data rather than by its column.
404
+ * A colour per column would say the column means something, and it does not
405
+ * — it is just how far along the flow a node happens to sit.
406
+ */
407
+ const colors = useMemo(
408
+ () =>
409
+ placed.nodes.map(
410
+ (node, index) => nodes[node.index]?.color ?? seriesColorAt(palette, index)
411
+ ),
412
+ [placed.nodes, nodes, palette]
413
+ );
414
+
415
+ const labelFor = useMemo(() => {
416
+ const names = new Map(nodes.map((node) => [node.id, node.label ?? node.id]));
417
+ return (id: string) => names.get(id) ?? id;
418
+ }, [nodes]);
419
+
420
+ /*
421
+ * One clock, with each column given the slice of it that it draws in, so
422
+ * the flow arrives in the order it happens rather than all at once. A
423
+ * shared value per column would be the same animation played n times and n
424
+ * more things a replay would have to find.
425
+ */
426
+ const stagger = Math.max(0, staggerDelay);
427
+ const columns = Math.max(placed.columns, 1);
428
+ const total = animationDuration + Math.max(columns - 1, 0) * stagger;
429
+ const windows = useMemo(
430
+ () =>
431
+ Array.from({ length: columns }, (_, column) => {
432
+ const from = column * stagger;
433
+ return {
434
+ from: total > 0 ? from / total : 0,
435
+ to: total > 0 ? (from + animationDuration) / total : 1,
436
+ };
437
+ }),
438
+ [columns, stagger, animationDuration, total]
439
+ );
440
+
441
+ const playReveal = useMemo(
442
+ () => () => {
443
+ if (reducedMotion) {
444
+ reveal.value = 1;
445
+ return;
446
+ }
447
+ reveal.value = 0;
448
+ // Linear, because the shaping is per column: each one eases inside its
449
+ // own window, and easing the clock as well would ease it twice.
450
+ reveal.value = withTiming(1, { duration: total, easing: Easing.linear });
451
+ },
452
+ [reducedMotion, total, reveal]
453
+ );
454
+
455
+ const loading = status === 'loading';
456
+ const revealed = useRef(false);
457
+
458
+ useEffect(() => {
459
+ if (loading) {
460
+ revealed.current = false;
461
+ reveal.value = 0;
462
+ return;
463
+ }
464
+ if (revealed.current || !placed.nodes.length) return;
465
+ revealed.current = true;
466
+ playReveal();
467
+ }, [loading, placed.nodes.length, playReveal, reveal]);
468
+
469
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
470
+
471
+ // Measured on the plot's own view rather than the outer one, so a header or
472
+ // a footer cannot change how wide the diagram thinks it is.
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<SankeyChartContextValue>(
479
+ () => ({
480
+ nodes,
481
+ links,
482
+ layout: placed,
483
+ width,
484
+ height,
485
+ curve,
486
+ colors,
487
+ windows,
488
+ reveal,
489
+ status,
490
+ activeId: activeId ?? null,
491
+ setActiveId,
492
+ labelFor,
493
+ }),
494
+ [
495
+ nodes,
496
+ links,
497
+ placed,
498
+ width,
499
+ height,
500
+ curve,
501
+ colors,
502
+ windows,
503
+ reveal,
504
+ status,
505
+ activeId,
506
+ setActiveId,
507
+ labelFor,
508
+ ]
509
+ );
510
+
511
+ const slots: Record<Slot, ReactNode[]> = {
512
+ svg: [],
513
+ overlay: [],
514
+ header: [],
515
+ footer: [],
516
+ };
517
+ /*
518
+ * Whether the names are on the chart decides how it is read out. With
519
+ * `Labels` there is a pressable row per node already, and the semantic
520
+ * list below would say all of it a second time; without them the diagram
521
+ * is pure geometry and the list is the only way through it.
522
+ */
523
+ let labelled = false;
524
+ Children.forEach(children, (child, index) => {
525
+ if (!isValidElement(child)) return;
526
+ const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
527
+ if ((child.type as { displayName?: string }).displayName === 'SankeyChart.Labels') {
528
+ labelled = true;
529
+ }
530
+ slots[slot in slots ? slot : 'overlay'].push(
531
+ <ChildSlot key={index}>{child}</ChildSlot>
532
+ );
533
+ });
534
+
535
+ return (
536
+ <SankeyChartContext.Provider value={context}>
537
+ <View {...props} style={props.style} className={cn('w-full', className)}>
538
+ {slots.header}
539
+ <View onLayout={onLayout} style={{ height }} className="w-full">
540
+ {width > 0 && height > 0 ? (
541
+ <>
542
+ {/*
543
+ * The geometry is decorative: every ribbon and bar in here is
544
+ * already spoken once, either by the names over it or by the
545
+ * semantic list below. Left in the tree it is a few hundred
546
+ * unlabelled paths to swipe through before reaching either.
547
+ */}
548
+ <Svg width={width} height={height} {...HIDDEN}>
549
+ {slots.svg}
550
+ </Svg>
551
+ {/*
552
+ * Names sit over the SVG rather than inside it: they are text,
553
+ * and SVG text ignores the platform's text scaling and the
554
+ * theme's font.
555
+ */}
556
+ {/*
557
+ * Laid out left to right whatever the reading direction, to
558
+ * match the SVG underneath it. React Native swaps `left` and
559
+ * `right` inside a right-to-left subtree, and the positions
560
+ * here are already mirrored — so left alone they would be
561
+ * mirrored a second time and every name would sit against the
562
+ * wrong side of the plot. The glyphs still run the right way:
563
+ * `Text` carries the writing direction of its own accord.
564
+ */}
565
+ <View
566
+ pointerEvents="box-none"
567
+ style={{ position: 'absolute', width, height, direction: 'ltr' }}
568
+ >
569
+ {slots.overlay}
570
+ </View>
571
+ </>
572
+ ) : null}
573
+ </View>
574
+ {slots.footer}
575
+ <ChartAccessibilityData
576
+ chart="Flow diagram"
577
+ data={nodes}
578
+ disabled={labelled || status === 'loading'}
579
+ valueOf={(node) => [
580
+ ['Node', node.label ?? node.id],
581
+ ['Value', placed.nodes.find((placedNode) => placedNode.id === node.id)?.value],
582
+ ]}
583
+ accessibilityLabel={accessibilityLabel}
584
+ accessibilityHint={accessibilityHint}
585
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
586
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
587
+ />
588
+ </View>
589
+ </SankeyChartContext.Provider>
590
+ );
591
+ }
592
+ );
593
+ SankeyChartRoot.displayName = 'SankeyChart';
594
+
595
+ function ChildSlot({ children }: { children: ReactNode }) {
596
+ return <>{children}</>;
597
+ }
598
+
599
+ /**
600
+ * How much of the entrance a column has played, eased.
601
+ *
602
+ * Ease out cubic, written out rather than called: an easing from the animation
603
+ * library is not a worklet and this runs on the UI thread every frame.
604
+ */
605
+ function progress(clock: number, from: number, to: number): number {
606
+ 'worklet';
607
+ const range = to - from;
608
+ const raw = range > 0 ? (clock - from) / range : 1;
609
+ const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
610
+ return 1 - (1 - clamped) * (1 - clamped) * (1 - clamped);
611
+ }
612
+
613
+ export interface SankeyChartLinksProps {
614
+ /** A ribbon's opacity at rest. */
615
+ opacity?: number;
616
+ /** A ribbon's opacity when its node is selected. */
617
+ activeOpacity?: number;
618
+ /** And when something else is. */
619
+ dimOpacity?: number;
620
+ }
621
+
622
+ /**
623
+ * The ribbons.
624
+ *
625
+ * Drawn before the bars so a bar sits on top of the flows that meet it, which
626
+ * is what gives a node a clean edge to arrive at instead of a fringe of ribbon
627
+ * ends poking through it.
628
+ *
629
+ * Translucent at rest, and that is not decoration. Ribbons cross — that is the
630
+ * shape of routed data — and an opaque one hides whatever passes under it, so
631
+ * the reader loses the smaller of every pair. At this opacity a crossing reads
632
+ * as two ribbons rather than as one with a notch in it.
633
+ */
634
+ function SankeyChartLinks({
635
+ opacity = LINK_OPACITY,
636
+ activeOpacity = LINK_ACTIVE_OPACITY,
637
+ dimOpacity = LINK_DIM_OPACITY,
638
+ }: SankeyChartLinksProps) {
639
+ const { layout, links, colors, curve, reveal, windows, status, activeId } =
640
+ useChart('SankeyChart.Links');
641
+
642
+ if (status === 'loading' || !layout.links.length) return null;
643
+
644
+ return (
645
+ <G>
646
+ {layout.links.map((link) => {
647
+ const source = layout.nodes[link.source];
648
+ const target = layout.nodes[link.target];
649
+ if (!source || !target) return null;
650
+
651
+ const touching = activeId === source.id || activeId === target.id;
652
+ const window = windows[source.layer] ?? windows[0] ?? { from: 0, to: 1 };
653
+
654
+ return (
655
+ <Ribbon
656
+ key={link.index}
657
+ x0={source.x1}
658
+ cy0={link.y0}
659
+ x1={target.x0}
660
+ cy1={link.y1}
661
+ thickness={link.width}
662
+ curve={curve}
663
+ fill={links[link.input]?.color ?? colors[link.source] ?? colors[0] ?? '#3b82f6'}
664
+ reveal={reveal}
665
+ window={window}
666
+ opacity={
667
+ activeId === null ? opacity : touching ? activeOpacity : dimOpacity
668
+ }
669
+ />
670
+ );
671
+ })}
672
+ </G>
673
+ );
674
+ }
675
+ SankeyChartLinks.displayName = 'SankeyChart.Links';
676
+ SankeyChartLinks.slot = 'svg' as const;
677
+
678
+ function Ribbon({
679
+ x0,
680
+ cy0,
681
+ x1,
682
+ cy1,
683
+ thickness,
684
+ curve,
685
+ fill,
686
+ reveal,
687
+ window,
688
+ opacity,
689
+ }: {
690
+ x0: number;
691
+ cy0: number;
692
+ x1: number;
693
+ cy1: number;
694
+ thickness: number;
695
+ curve: number;
696
+ fill: string;
697
+ reveal: SharedValue<number>;
698
+ window: { from: number; to: number };
699
+ opacity: number;
700
+ }) {
701
+ const { from, to } = window;
702
+ const settled = useDerivedValue<number>(() =>
703
+ withTiming(opacity, { duration: SELECT_DURATION })
704
+ );
705
+
706
+ const animatedProps = useAnimatedProps(() => {
707
+ /*
708
+ * The ribbon thickens about its own centre line rather than growing from
709
+ * one edge, so it stays anchored where it meets the node instead of
710
+ * sliding down it as it arrives.
711
+ */
712
+ const grown = thickness * progress(reveal.value, from, to);
713
+ return {
714
+ d: flowPath(x0, cy0, x1, cy1, grown, curve),
715
+ fillOpacity: settled.value,
716
+ };
717
+ });
718
+
719
+ return <AnimatedPath animatedProps={animatedProps} fill={fill} />;
720
+ }
721
+
722
+ export interface SankeyChartNodesProps {
723
+ /** Corner radius on a node's bar, in points. */
724
+ radius?: number;
725
+ /** A bar's opacity when something else is selected. */
726
+ dimOpacity?: number;
727
+ }
728
+
729
+ /**
730
+ * The bars the ribbons run between.
731
+ *
732
+ * Solid where the ribbons are translucent, because a node is the one thing on
733
+ * the diagram that is not crossing anything else — it is the edge the flow
734
+ * arrives at, and it reads as an edge only if nothing shows through it.
735
+ */
736
+ function SankeyChartNodes({ radius = 2, dimOpacity = 0.25 }: SankeyChartNodesProps) {
737
+ const { layout, colors, reveal, windows, status, activeId } =
738
+ useChart('SankeyChart.Nodes');
739
+
740
+ if (status === 'loading' || !layout.nodes.length) return null;
741
+
742
+ return (
743
+ <G>
744
+ {layout.nodes.map((node, index) => {
745
+ const window = windows[node.layer] ?? windows[0] ?? { from: 0, to: 1 };
746
+ return (
747
+ <NodeBar
748
+ key={node.id}
749
+ x={node.x0}
750
+ width={node.x1 - node.x0}
751
+ y0={node.y0}
752
+ y1={node.y1}
753
+ radius={radius}
754
+ fill={colors[index] ?? '#3b82f6'}
755
+ reveal={reveal}
756
+ window={window}
757
+ opacity={activeId === null || activeId === node.id ? 1 : dimOpacity}
758
+ />
759
+ );
760
+ })}
761
+ </G>
762
+ );
763
+ }
764
+ SankeyChartNodes.displayName = 'SankeyChart.Nodes';
765
+ SankeyChartNodes.slot = 'svg' as const;
766
+
767
+ function NodeBar({
768
+ x,
769
+ width,
770
+ y0,
771
+ y1,
772
+ radius,
773
+ fill,
774
+ reveal,
775
+ window,
776
+ opacity,
777
+ }: {
778
+ x: number;
779
+ width: number;
780
+ y0: number;
781
+ y1: number;
782
+ radius: number;
783
+ fill: string;
784
+ reveal: SharedValue<number>;
785
+ window: { from: number; to: number };
786
+ opacity: number;
787
+ }) {
788
+ const { from, to } = window;
789
+ const extent = y1 - y0;
790
+ const centre = (y0 + y1) / 2;
791
+ const settled = useDerivedValue<number>(() =>
792
+ withTiming(opacity, { duration: SELECT_DURATION })
793
+ );
794
+
795
+ const animatedProps = useAnimatedProps(() => {
796
+ // Grown about its centre, to match the ribbons meeting it.
797
+ const grown = extent * progress(reveal.value, from, to);
798
+ return { y: centre - grown / 2, height: grown, opacity: settled.value };
799
+ });
800
+
801
+ return <AnimatedRect animatedProps={animatedProps} x={x} width={width} rx={radius} fill={fill} />;
802
+ }
803
+
804
+ export interface SankeyChartLabelsProps {
805
+ className?: string;
806
+ /** Format the figure beside a name. Defaults to a compact number. */
807
+ formatValue?: (value: number, node: SankeyNode) => string;
808
+ /** Show the figure under the name. */
809
+ showValue?: boolean;
810
+ /**
811
+ * Hide the name on a bar shorter than this, in points.
812
+ *
813
+ * A diagram of forty nodes has bars a few points tall, and forty names at
814
+ * that spacing overlap into a grey band that hides the flow behind it. The
815
+ * names that are dropped are the smallest ones, which is where the tooltip
816
+ * takes over.
817
+ */
818
+ minHeight?: number;
819
+ }
820
+
821
+ /**
822
+ * The names, and the press targets that go with them.
823
+ *
824
+ * Outside the bars rather than on them. A node's bar is as thick as it was
825
+ * asked to be — ten points by default — and no name fits inside ten points, so
826
+ * putting the name on the bar means widening every bar to suit the longest
827
+ * label and losing the width the ribbons need.
828
+ *
829
+ * Which side a name goes on is decided by the column: the last column reads
830
+ * inwards from the right edge, everything else outwards to the right. So the
831
+ * names stay inside the chart's box at both ends, instead of the leftmost and
832
+ * rightmost ones being clipped.
833
+ *
834
+ * The target is the label's row, not the bar. A node worth one percent of the
835
+ * flow is a two-point sliver and cannot be hit; the row it sits in can, and it
836
+ * is padded out to a proper target where the sliver is smaller than one.
837
+ */
838
+ function SankeyChartLabels({
839
+ className,
840
+ formatValue,
841
+ showValue = false,
842
+ minHeight = 6,
843
+ }: SankeyChartLabelsProps) {
844
+ const { layout, nodes, width, height, status, activeId, setActiveId, labelFor } =
845
+ useChart('SankeyChart.Labels');
846
+
847
+ if (status === 'loading' || !layout.nodes.length) return null;
848
+
849
+ const format = formatValue ?? ((value: number) => compactNumber(value));
850
+
851
+ /*
852
+ * Where the neighbouring columns sit, in points across the plot.
853
+ *
854
+ * A name's row needs a bound on both sides or it runs the width of the chart
855
+ * and covers every row it crosses — and since the rows are absolutely
856
+ * positioned siblings, the last one drawn takes the touch. That is a tap on
857
+ * one name selecting a node two columns away, which is worse than a small
858
+ * target because it is wrong rather than merely hard.
859
+ */
860
+ const edges: number[] = [];
861
+ for (const placed of layout.nodes) {
862
+ if (!edges.includes(placed.x0)) edges.push(placed.x0);
863
+ if (!edges.includes(placed.x1)) edges.push(placed.x1);
864
+ }
865
+ edges.sort((a, b) => a - b);
866
+ const nextEdge = (x: number) => edges.find((edge) => edge > x + 1e-6);
867
+ const previousEdge = (x: number) => {
868
+ let found: number | undefined;
869
+ for (const edge of edges) if (edge < x - 1e-6) found = edge;
870
+ return found;
871
+ };
872
+
873
+ return (
874
+ <>
875
+ {layout.nodes.map((node) => {
876
+ const datum = nodes[node.index];
877
+ if (!datum) return null;
878
+
879
+ const extent = node.y1 - node.y0;
880
+ if (extent < minHeight) return null;
881
+
882
+ /*
883
+ * Which side the name goes on is decided by where the bar actually is,
884
+ * not by which column it belongs to. Under a right-to-left layout the
885
+ * finished diagram is mirrored, so the last column is the one on the
886
+ * left — reading the side off the column number there puts every name
887
+ * in a box of zero width and the chart loses all of them.
888
+ */
889
+ const after = nextEdge(node.x1);
890
+ const before = previousEdge(node.x0);
891
+ const trailing = after === undefined;
892
+ const name = labelFor(node.id);
893
+ const value = format(node.value, datum);
894
+ const selected = activeId === node.id;
895
+
896
+ /*
897
+ * A sliver's row is padded out to a real target rather than drawn
898
+ * taller — growing the row would push it over its neighbours, and two
899
+ * overlapping targets are worse than a small one.
900
+ */
901
+ const slack = Math.max(0, (MIN_TARGET - extent) / 2);
902
+ const top = Math.max(0, Math.min(node.y0, height - extent));
903
+
904
+ return (
905
+ <Pressable
906
+ key={node.id}
907
+ accessibilityRole="button"
908
+ accessibilityState={{ selected }}
909
+ accessibilityLabel={`${name}, ${value}`}
910
+ hitSlop={{ top: slack, bottom: slack }}
911
+ onPress={() => setActiveId(selected ? null : node.id)}
912
+ style={{
913
+ position: 'absolute',
914
+ top,
915
+ height: extent,
916
+ justifyContent: 'center',
917
+ /*
918
+ * Each row takes the half of its gap nearest its own bar, so the
919
+ * name leaving one column and the name arriving at the next can
920
+ * share the space between them without sharing a touch target.
921
+ */
922
+ ...(trailing
923
+ ? (() => {
924
+ const from = before === undefined ? 0 : (before + node.x0) / 2;
925
+ return {
926
+ left: from,
927
+ width: Math.max(0, node.x0 - LABEL_GAP - from),
928
+ alignItems: 'flex-end' as const,
929
+ };
930
+ })()
931
+ : (() => {
932
+ const from = node.x1 + LABEL_GAP;
933
+ const to = after === undefined ? width : (node.x1 + after) / 2;
934
+ return { left: from, width: Math.max(0, to - from) };
935
+ })()),
936
+ }}
937
+ className={cn(className)}
938
+ >
939
+ {/*
940
+ * Both alignments are stated rather than inherited. A paragraph's
941
+ * default alignment follows the reading direction, so under a
942
+ * right-to-left layout an unaligned name drifts to the far end of
943
+ * its row and ends up sitting in the middle of the plot instead of
944
+ * against the bar it belongs to. Which side the name hugs is a
945
+ * fact about where its bar is, not about the language.
946
+ */}
947
+ <Text
948
+ size="xs"
949
+ weight={selected ? 'bold' : 'medium'}
950
+ numberOfLines={1}
951
+ style={{ textAlign: trailing ? 'right' : 'left' }}
952
+ >
953
+ {name}
954
+ </Text>
955
+ {showValue ? (
956
+ <Text
957
+ size="xs"
958
+ muted
959
+ numberOfLines={1}
960
+ style={{ textAlign: trailing ? 'right' : 'left' }}
961
+ >
962
+ {value}
963
+ </Text>
964
+ ) : null}
965
+ </Pressable>
966
+ );
967
+ })}
968
+ </>
969
+ );
970
+ }
971
+ SankeyChartLabels.displayName = 'SankeyChart.Labels';
972
+ SankeyChartLabels.slot = 'overlay' as const;
973
+
974
+ export interface SankeyChartTooltipProps {
975
+ className?: string;
976
+ /** Format the figures. Defaults to a compact number. */
977
+ formatValue?: (value: number) => string;
978
+ }
979
+
980
+ /**
981
+ * What the selected node carries: the total through it, and what that total is
982
+ * made of at each end.
983
+ *
984
+ * In and out are shown separately because they are the two readings a flow
985
+ * diagram is for, and they are only the same number when nothing was lost. A
986
+ * node where they differ is the interesting one on the whole chart, and a
987
+ * single total would hide exactly that.
988
+ *
989
+ * Anchored beside the node and clamped to the plot, so it never leaves the box
990
+ * it belongs to — a card half off the edge of a phone is a card nobody can read.
991
+ */
992
+ function SankeyChartTooltip({ className, formatValue }: SankeyChartTooltipProps) {
993
+ const { layout, width, height, labelFor } = useChart('SankeyChart.Tooltip');
994
+ const { activeId, activeValue, incoming, outgoing } = useSankeyChart();
995
+
996
+ if (!activeId) return null;
997
+ const node = layout.nodes.find((placed) => placed.id === activeId);
998
+ if (!node) return null;
999
+
1000
+ const format = formatValue ?? compactNumber;
1001
+ const CARD = 132;
1002
+ const trailing = node.x1 + LABEL_GAP + CARD > width;
1003
+ const left = trailing
1004
+ ? Math.max(0, node.x0 - LABEL_GAP - CARD)
1005
+ : Math.min(node.x1 + LABEL_GAP, Math.max(0, width - CARD));
1006
+ const centre = (node.y0 + node.y1) / 2;
1007
+
1008
+ return (
1009
+ <View
1010
+ pointerEvents="none"
1011
+ style={{
1012
+ position: 'absolute',
1013
+ left,
1014
+ width: CARD,
1015
+ top: Math.max(0, Math.min(centre - 34, height - 68)),
1016
+ }}
1017
+ className={cn(
1018
+ 'gap-0.5 rounded-lg border border-border bg-background px-2.5 py-2 shadow-sm',
1019
+ className
1020
+ )}
1021
+ >
1022
+ <Text size="xs" weight="bold" numberOfLines={1}>
1023
+ {labelFor(activeId)}
1024
+ </Text>
1025
+ <Text size="xs" weight="semibold" numberOfLines={1}>
1026
+ {format(activeValue)}
1027
+ </Text>
1028
+ <Text size="xs" muted numberOfLines={1}>
1029
+ {`In ${format(incoming)} · Out ${format(outgoing)}`}
1030
+ </Text>
1031
+ </View>
1032
+ );
1033
+ }
1034
+ SankeyChartTooltip.displayName = 'SankeyChart.Tooltip';
1035
+ SankeyChartTooltip.slot = 'overlay' as const;
1036
+
1037
+ export interface SankeyChartSkeletonProps {
1038
+ color?: string;
1039
+ }
1040
+
1041
+ /**
1042
+ * The loading state: a few plain bars and the ribbons between them, carrying no
1043
+ * values.
1044
+ *
1045
+ * Every bar the same height and every ribbon the same thickness, deliberately.
1046
+ * A placeholder with varied thicknesses would be an invented routing, and a
1047
+ * reader cannot tell an invented one from a real one until it changes under
1048
+ * them — which is worse than showing nothing, because it is showing something
1049
+ * wrong.
1050
+ */
1051
+ function SankeyChartSkeleton({ color }: SankeyChartSkeletonProps) {
1052
+ const { width, height, curve, status } = useChart('SankeyChart.Skeleton');
1053
+ const token = useCSSVariable('--color-skeleton');
1054
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
1055
+
1056
+ // Held through the fade rather than to the frame the data lands, so the plain
1057
+ // shape dissolves under the real one growing across it instead of leaving a
1058
+ // blank panel between the two.
1059
+ const { mounted, opacity } = useSkeletonHandoff(status === 'loading');
1060
+ const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value }));
1061
+
1062
+ const shape = useMemo(() => {
1063
+ if (width <= 0 || height <= 0) return null;
1064
+ const bar = DEFAULT_NODE_WIDTH;
1065
+ const step = (width - bar) / Math.max(SKELETON_COLUMNS - 1, 1);
1066
+ const band = height / 3;
1067
+ const columns = Array.from({ length: SKELETON_COLUMNS }, (_, i) => i * step);
1068
+ const ribbons: string[] = [];
1069
+ for (let i = 0; i < SKELETON_COLUMNS - 1; i += 1) {
1070
+ const from = columns[i]! + bar;
1071
+ const to = columns[i + 1]!;
1072
+ ribbons.push(flowPath(from, height / 3, to, height / 3, band * 0.5, curve));
1073
+ ribbons.push(flowPath(from, (height * 2) / 3, to, (height * 2) / 3, band * 0.5, curve));
1074
+ }
1075
+ return { bar, band, columns, ribbons };
1076
+ }, [width, height, curve]);
1077
+
1078
+ if (!mounted || !shape) return null;
1079
+
1080
+ return (
1081
+ <AnimatedG animatedProps={animatedProps}>
1082
+ {shape.ribbons.map((d, index) => (
1083
+ <Path key={`ribbon-${index}`} d={d} fill={fill} fillOpacity={0.5} />
1084
+ ))}
1085
+ {shape.columns.map((x, index) => (
1086
+ <Rect
1087
+ key={`bar-${index}`}
1088
+ x={x}
1089
+ y={height / 6}
1090
+ width={shape.bar}
1091
+ height={(height * 2) / 3}
1092
+ rx={2}
1093
+ fill={fill}
1094
+ />
1095
+ ))}
1096
+ </AnimatedG>
1097
+ );
1098
+ }
1099
+ SankeyChartSkeleton.displayName = 'SankeyChart.Skeleton';
1100
+ SankeyChartSkeleton.slot = 'svg' as const;
1101
+
1102
+ export interface SankeyChartHeaderProps extends ViewProps {
1103
+ className?: string;
1104
+ /** Small line above the value — what the flow is of. */
1105
+ title?: string;
1106
+ /** The readout. The largest thing on the card, and the first thing read. */
1107
+ value?: string;
1108
+ /** One muted line under the value — a period, a comparison, a caveat. */
1109
+ caption?: string;
1110
+ /** Trailing slot — a control, a badge, a range picker. */
1111
+ children?: ReactNode;
1112
+ }
1113
+
1114
+ /**
1115
+ * The strip above the diagram: what the flow is of and what it totals.
1116
+ *
1117
+ * The value is not derived even though there are sources to add up, because the
1118
+ * formatting is not the chart's to guess: 128400 is a count, a currency or a
1119
+ * rate depending on what was routed.
1120
+ */
1121
+ function SankeyChartHeader({
1122
+ className,
1123
+ title,
1124
+ value,
1125
+ caption,
1126
+ children,
1127
+ ...props
1128
+ }: SankeyChartHeaderProps) {
1129
+ return (
1130
+ <View
1131
+ {...props}
1132
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1133
+ >
1134
+ <View className="flex-1 gap-0.5">
1135
+ {title ? (
1136
+ <Text size="xs" muted>
1137
+ {title}
1138
+ </Text>
1139
+ ) : null}
1140
+ {value ? (
1141
+ <Text size="xl" weight="bold">
1142
+ {value}
1143
+ </Text>
1144
+ ) : null}
1145
+ {caption ? (
1146
+ <Text size="xs" muted>
1147
+ {caption}
1148
+ </Text>
1149
+ ) : null}
1150
+ </View>
1151
+ {children ? <View className="max-w-[55%] shrink pt-1">{children}</View> : null}
1152
+ </View>
1153
+ );
1154
+ }
1155
+ SankeyChartHeader.displayName = 'SankeyChart.Header';
1156
+ SankeyChartHeader.slot = 'header' as const;
1157
+
1158
+ export const SankeyChart = Object.assign(SankeyChartRoot, {
1159
+ Header: SankeyChartHeader,
1160
+ Links: SankeyChartLinks,
1161
+ Nodes: SankeyChartNodes,
1162
+ Labels: SankeyChartLabels,
1163
+ Tooltip: SankeyChartTooltip,
1164
+ Skeleton: SankeyChartSkeleton,
1165
+ });