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