panelui-native 0.101.1 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/module/components/sankey-chart/index.js +801 -165
- package/lib/module/components/sankey-chart/index.js.map +1 -1
- package/lib/module/components/sankey-chart/sankey-layout.js +226 -0
- package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -1
- package/lib/module/components/spinner/index.js +13 -3
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/support/index.js +842 -0
- package/lib/module/components/support/index.js.map +1 -0
- package/lib/module/icons/index.js +40 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/sankey-chart/index.d.ts +131 -5
- package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +49 -6
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +18 -1
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/support/index.d.ts +376 -0
- package/lib/typescript/src/components/support/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +8 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +11 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/sankey-chart/index.tsx +935 -172
- package/src/components/sankey-chart/sankey-layout.ts +276 -2
- package/src/components/spinner/index.tsx +27 -1
- package/src/components/support/index.tsx +877 -0
- package/src/icons/index.tsx +16 -0
- package/src/index.ts +35 -0
- package/src/utils/chart.ts +40 -0
|
@@ -88,13 +88,29 @@ import {
|
|
|
88
88
|
type ChartAccessibilityProps,
|
|
89
89
|
} from '../../primitives/chart-accessibility';
|
|
90
90
|
import { Text } from '../../primitives/text';
|
|
91
|
-
import {
|
|
91
|
+
import {
|
|
92
|
+
compactNumber,
|
|
93
|
+
flowPath,
|
|
94
|
+
flowPathVertical,
|
|
95
|
+
seriesColorAt,
|
|
96
|
+
useSeriesColor,
|
|
97
|
+
} from '../../utils/chart';
|
|
92
98
|
import { cn } from '../../utils/cn';
|
|
93
99
|
import { useDirection } from '../../hooks/use-direction';
|
|
94
100
|
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
|
|
95
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
sankeyLayout,
|
|
103
|
+
transposeLayout,
|
|
104
|
+
type SankeyAlign,
|
|
105
|
+
type SankeyCollapse,
|
|
106
|
+
type SankeyLayout,
|
|
107
|
+
type SankeyLayoutNode,
|
|
108
|
+
} from './sankey-layout';
|
|
96
109
|
|
|
97
|
-
export type { SankeyAlign } from './sankey-layout';
|
|
110
|
+
export type { SankeyAlign, SankeyCollapse } from './sankey-layout';
|
|
111
|
+
|
|
112
|
+
/** Which way the flow runs. */
|
|
113
|
+
export type SankeyOrientation = 'horizontal' | 'vertical';
|
|
98
114
|
|
|
99
115
|
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
100
116
|
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
|
@@ -103,6 +119,17 @@ const AnimatedG = Animated.createAnimatedComponent(G);
|
|
|
103
119
|
/** How tall the diagram is drawn when the caller does not say. */
|
|
104
120
|
const DEFAULT_HEIGHT = 240;
|
|
105
121
|
|
|
122
|
+
/**
|
|
123
|
+
* How much depth one stage of an upright flow is given when nothing says.
|
|
124
|
+
*
|
|
125
|
+
* A vertical diagram's height is the length of the flow, so unlike the
|
|
126
|
+
* horizontal case it grows with the data: four stages in the height of three
|
|
127
|
+
* is a set of bars with no room for a ribbon between them. Enough for a bar,
|
|
128
|
+
* a name on each side of it, and a run of ribbon long enough to read as one —
|
|
129
|
+
* and no more, because on a phone every extra point of it is a scroll.
|
|
130
|
+
*/
|
|
131
|
+
const VERTICAL_STAGE = 104;
|
|
132
|
+
|
|
106
133
|
/** How thick a node's bar is. Thin enough to read as an edge the flow meets. */
|
|
107
134
|
const DEFAULT_NODE_WIDTH = 10;
|
|
108
135
|
|
|
@@ -145,6 +172,28 @@ const LABEL_GAP = 6;
|
|
|
145
172
|
/** The smallest a label's press target is allowed to be, in points. */
|
|
146
173
|
const MIN_TARGET = 44;
|
|
147
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Roughly how wide a name is drawn, in points, backdrop included.
|
|
177
|
+
*
|
|
178
|
+
* An estimate rather than a measurement, because measuring every name means a
|
|
179
|
+
* render to find out where to render them. Deliberately on the generous side:
|
|
180
|
+
* guessing short is how a name ends up cut off, and guessing long only costs a
|
|
181
|
+
* few points of the row.
|
|
182
|
+
*/
|
|
183
|
+
function nameWidth(name: string): number {
|
|
184
|
+
return name.length * 7 + 18;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* How much height one line of a name takes, in points, backdrop included.
|
|
189
|
+
*
|
|
190
|
+
* A name is centred on its bar, and a bar thinner than this carries a name
|
|
191
|
+
* taller than itself — which is fine until two of them sit close enough for
|
|
192
|
+
* the text to run into each other. So names are placed as boxes of this
|
|
193
|
+
* height, not of the bar's.
|
|
194
|
+
*/
|
|
195
|
+
const LABEL_LINE = 20;
|
|
196
|
+
|
|
148
197
|
/** Columns the placeholder suggests while there is no data to count. */
|
|
149
198
|
const SKELETON_COLUMNS = 3;
|
|
150
199
|
|
|
@@ -210,6 +259,25 @@ interface SankeyChartContextValue {
|
|
|
210
259
|
activeId: string | null;
|
|
211
260
|
setActiveId: (id: string | null) => void;
|
|
212
261
|
labelFor: (id: string) => string;
|
|
262
|
+
orientation: SankeyOrientation;
|
|
263
|
+
/**
|
|
264
|
+
* The caller's datum for a laid-out node, or a stand-in for one the layout
|
|
265
|
+
* invented. `collapse` produces bars nobody wrote a row for, and every part
|
|
266
|
+
* that reaches for a node's own data would otherwise drop them silently —
|
|
267
|
+
* which, for the bucket holding a column's whole tail, is the one bar on the
|
|
268
|
+
* diagram that most needs its name.
|
|
269
|
+
*/
|
|
270
|
+
datumFor: (node: SankeyLayoutNode) => SankeyNode;
|
|
271
|
+
/**
|
|
272
|
+
* How `SankeyChart.Labels` tells the chart which names it actually drew.
|
|
273
|
+
*
|
|
274
|
+
* The names are press targets, so a node that has one is already reachable
|
|
275
|
+
* and saying it again in the semantic list below is a duplicate. A node that
|
|
276
|
+
* does not — a bar too short to label — is reachable by nothing at all
|
|
277
|
+
* unless the list keeps it. Only `Labels` knows which is which, because
|
|
278
|
+
* only `Labels` has the threshold.
|
|
279
|
+
*/
|
|
280
|
+
reportLabelled: (ids: string[] | null) => void;
|
|
213
281
|
}
|
|
214
282
|
|
|
215
283
|
const SankeyChartContext = createContext<SankeyChartContextValue | null>(null);
|
|
@@ -222,35 +290,95 @@ function useChart(component: string): SankeyChartContextValue {
|
|
|
222
290
|
return context;
|
|
223
291
|
}
|
|
224
292
|
|
|
293
|
+
/** One end of a stream meeting the selected node. */
|
|
294
|
+
export interface SankeyChartFlow {
|
|
295
|
+
/** The node at the other end. */
|
|
296
|
+
id: string;
|
|
297
|
+
/** Its name, already resolved. */
|
|
298
|
+
label: string;
|
|
299
|
+
/** What travels between the two. */
|
|
300
|
+
value: number;
|
|
301
|
+
/** That value as a fraction of the selected node's total, `0` to `1`. */
|
|
302
|
+
share: number;
|
|
303
|
+
/** The other node's colour, for a swatch beside its name. */
|
|
304
|
+
color: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
225
307
|
/** The selected node and what runs through it, for something drawn inside the chart. */
|
|
226
308
|
export function useSankeyChart() {
|
|
227
|
-
const { nodes, layout, activeId } = useChart('useSankeyChart');
|
|
309
|
+
const { nodes, layout, activeId, colors, labelFor } = useChart('useSankeyChart');
|
|
228
310
|
|
|
229
311
|
return useMemo(() => {
|
|
230
312
|
const placed = activeId ? layout.nodes.find((node) => node.id === activeId) : undefined;
|
|
231
313
|
if (!placed) {
|
|
232
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
activeId: null,
|
|
316
|
+
activeNode: null,
|
|
317
|
+
activeValue: 0,
|
|
318
|
+
incoming: 0,
|
|
319
|
+
outgoing: 0,
|
|
320
|
+
sources: [] as SankeyChartFlow[],
|
|
321
|
+
targets: [] as SankeyChartFlow[],
|
|
322
|
+
collapsed: null as string[] | null,
|
|
323
|
+
};
|
|
233
324
|
}
|
|
234
325
|
|
|
235
326
|
const position = layout.nodes.indexOf(placed);
|
|
236
327
|
let incoming = 0;
|
|
237
328
|
let outgoing = 0;
|
|
329
|
+
const sources: SankeyChartFlow[] = [];
|
|
330
|
+
const targets: SankeyChartFlow[] = [];
|
|
331
|
+
|
|
332
|
+
const flow = (at: number, value: number): SankeyChartFlow | null => {
|
|
333
|
+
const other = layout.nodes[at];
|
|
334
|
+
if (!other) return null;
|
|
335
|
+
return {
|
|
336
|
+
id: other.id,
|
|
337
|
+
label: labelFor(other.id),
|
|
338
|
+
value,
|
|
339
|
+
// Against what passes through the node rather than against the side's
|
|
340
|
+
// own total, so in and out are read on one scale — at a node that
|
|
341
|
+
// loses some of what it received, two totals that differ is the point.
|
|
342
|
+
share: placed.value > 0 ? value / placed.value : 0,
|
|
343
|
+
color: colors[at] ?? colors[0] ?? '#3b82f6',
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
238
347
|
for (const link of layout.links) {
|
|
239
|
-
if (link.target === position)
|
|
240
|
-
|
|
348
|
+
if (link.target === position) {
|
|
349
|
+
incoming += link.value;
|
|
350
|
+
const row = flow(link.source, link.value);
|
|
351
|
+
if (row) sources.push(row);
|
|
352
|
+
}
|
|
353
|
+
if (link.source === position) {
|
|
354
|
+
outgoing += link.value;
|
|
355
|
+
const row = flow(link.target, link.value);
|
|
356
|
+
if (row) targets.push(row);
|
|
357
|
+
}
|
|
241
358
|
}
|
|
242
359
|
|
|
360
|
+
// Largest first: the reason to open a breakdown is to find out what the
|
|
361
|
+
// biggest part of it was.
|
|
362
|
+
sources.sort((a, b) => b.value - a.value);
|
|
363
|
+
targets.sort((a, b) => b.value - a.value);
|
|
364
|
+
|
|
243
365
|
return {
|
|
244
366
|
activeId,
|
|
245
|
-
activeNode: nodes.
|
|
367
|
+
activeNode: nodes[placed.index] ?? { id: placed.id },
|
|
246
368
|
/** What passes through it — what the bar's height is drawn from. */
|
|
247
369
|
activeValue: placed.value,
|
|
248
370
|
/** What arrives. Zero at a node the flow starts from. */
|
|
249
371
|
incoming,
|
|
250
372
|
/** What leaves. Zero at a node the flow ends at. */
|
|
251
373
|
outgoing,
|
|
374
|
+
/** Where it came from, largest first. */
|
|
375
|
+
sources,
|
|
376
|
+
/** Where it went, largest first. */
|
|
377
|
+
targets,
|
|
378
|
+
/** The ids `collapse` folded in, on a bucket. `null` on every other node. */
|
|
379
|
+
collapsed: placed.collapsed ?? null,
|
|
252
380
|
};
|
|
253
|
-
}, [nodes, layout, activeId]);
|
|
381
|
+
}, [nodes, layout, activeId, colors, labelFor]);
|
|
254
382
|
}
|
|
255
383
|
|
|
256
384
|
export interface SankeyChartProps
|
|
@@ -262,7 +390,9 @@ export interface SankeyChartProps
|
|
|
262
390
|
/** What travels between them. */
|
|
263
391
|
links: SankeyLink[];
|
|
264
392
|
/**
|
|
265
|
-
* How tall the diagram is drawn, in points.
|
|
393
|
+
* How tall the diagram is drawn, in points. Under `orientation="vertical"`
|
|
394
|
+
* this is the length of the flow rather than the size of the bars, and left
|
|
395
|
+
* unset it is worked out from how many stages the flow turned out to need.
|
|
266
396
|
*
|
|
267
397
|
* The width is the card's, but nothing in a flow says how deep it should be:
|
|
268
398
|
* a diagram of four nodes and one of forty are the same data at two heights,
|
|
@@ -278,6 +408,28 @@ export interface SankeyChartProps
|
|
|
278
408
|
* before it gives up the height of its bars, because the bar is the reading.
|
|
279
409
|
*/
|
|
280
410
|
nodePadding?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Which way the flow runs: `horizontal` from one side to the other,
|
|
413
|
+
* `vertical` from the top of the diagram down to the bottom.
|
|
414
|
+
*
|
|
415
|
+
* This is the axis the *stages* advance along, not the one the bars point
|
|
416
|
+
* along — a vertical flow draws its bars as horizontal rules and stacks them
|
|
417
|
+
* down the screen. Prefer it on a phone: the stages get the long side of the
|
|
418
|
+
* screen, and a name gets the whole width of the card instead of the gap
|
|
419
|
+
* between two columns.
|
|
420
|
+
*/
|
|
421
|
+
orientation?: SankeyOrientation;
|
|
422
|
+
/**
|
|
423
|
+
* Folds each column's smallest nodes into one bucket, named `Other` unless
|
|
424
|
+
* you say otherwise.
|
|
425
|
+
*
|
|
426
|
+
* A ribbon carries its value in its thickness, so a column of thirty is
|
|
427
|
+
* thirty hairlines. `maxPerColumn` caps how many nodes a column keeps, the
|
|
428
|
+
* bucket included; `minShare` folds anything under that fraction of its own
|
|
429
|
+
* column. A column where fewer than two nodes would go in is left alone,
|
|
430
|
+
* because one node in a bucket is a rename rather than a simplification.
|
|
431
|
+
*/
|
|
432
|
+
collapse?: SankeyCollapse;
|
|
281
433
|
/** Which column a node goes in where the flow leaves a choice. */
|
|
282
434
|
align?: SankeyAlign;
|
|
283
435
|
/** Relaxation rounds spent untangling the ribbons. */
|
|
@@ -302,6 +454,15 @@ export interface SankeyChartProps
|
|
|
302
454
|
* so a banner can be shown and taken away from the same signal.
|
|
303
455
|
*/
|
|
304
456
|
onDropLinks?: (count: number) => void;
|
|
457
|
+
/**
|
|
458
|
+
* Overrides what a screen reader says for one ribbon.
|
|
459
|
+
*
|
|
460
|
+
* The ribbons are the reading — a node's total says how much passed through
|
|
461
|
+
* it, never where it went — so each one is spoken in its own right, as
|
|
462
|
+
* "source to target, value". Only the rows that could be drawn are offered;
|
|
463
|
+
* a dropped row is reported through `onDropLinks` instead.
|
|
464
|
+
*/
|
|
465
|
+
accessibilityLabelForLink?: (link: SankeyLink, index: number) => string;
|
|
305
466
|
children?: ReactNode;
|
|
306
467
|
}
|
|
307
468
|
|
|
@@ -316,9 +477,11 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
316
477
|
className,
|
|
317
478
|
nodes,
|
|
318
479
|
links,
|
|
319
|
-
height
|
|
480
|
+
height: heightProp,
|
|
320
481
|
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
321
482
|
nodePadding = DEFAULT_NODE_PADDING,
|
|
483
|
+
orientation = 'horizontal',
|
|
484
|
+
collapse,
|
|
322
485
|
align = 'justify',
|
|
323
486
|
iterations = DEFAULT_ITERATIONS,
|
|
324
487
|
curve = CURVE,
|
|
@@ -332,6 +495,7 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
332
495
|
accessibilityLabel,
|
|
333
496
|
accessibilityHint,
|
|
334
497
|
accessibilityLabelForDatum,
|
|
498
|
+
accessibilityLabelForLink,
|
|
335
499
|
onAccessibilityDatumPress,
|
|
336
500
|
children,
|
|
337
501
|
...props
|
|
@@ -355,26 +519,61 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
355
519
|
[controlled, onActiveIdChange]
|
|
356
520
|
);
|
|
357
521
|
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
522
|
+
const upright = orientation === 'horizontal';
|
|
523
|
+
|
|
524
|
+
/*
|
|
525
|
+
* How many stages the flow needs, for the case where the height has to be
|
|
526
|
+
* worked out from it. Solved with no relaxation rounds and in an arbitrary
|
|
527
|
+
* box, because the column count falls out of the links alone — the
|
|
528
|
+
* arrangement inside the columns is exactly the part being skipped.
|
|
529
|
+
*/
|
|
530
|
+
const stages = useMemo(() => {
|
|
531
|
+
if (upright || heightProp !== undefined) return 0;
|
|
532
|
+
return sankeyLayout(nodes, links, {
|
|
533
|
+
width: 1000,
|
|
534
|
+
height: 1000,
|
|
535
|
+
nodeWidth,
|
|
536
|
+
nodePadding,
|
|
537
|
+
align,
|
|
538
|
+
iterations: 0,
|
|
539
|
+
collapse,
|
|
540
|
+
}).columns;
|
|
541
|
+
}, [upright, heightProp, nodes, links, nodeWidth, nodePadding, align, collapse]);
|
|
542
|
+
|
|
543
|
+
const height =
|
|
544
|
+
heightProp ??
|
|
545
|
+
(upright ? DEFAULT_HEIGHT : Math.max(DEFAULT_HEIGHT, stages * VERTICAL_STAGE));
|
|
546
|
+
|
|
547
|
+
const layout = useMemo(() => {
|
|
548
|
+
/*
|
|
549
|
+
* An upright flow is solved in the box as given. A vertical one is
|
|
550
|
+
* solved in that box turned on its side and then turned back, so the
|
|
551
|
+
* stages advance down the screen — one set of maths, read the other way.
|
|
552
|
+
*/
|
|
553
|
+
const solved = sankeyLayout(nodes, links, {
|
|
554
|
+
width: upright ? width : height,
|
|
555
|
+
height: upright ? height : width,
|
|
556
|
+
nodeWidth,
|
|
557
|
+
nodePadding,
|
|
558
|
+
align,
|
|
559
|
+
iterations,
|
|
560
|
+
collapse,
|
|
561
|
+
});
|
|
562
|
+
return upright ? solved : transposeLayout(solved);
|
|
563
|
+
}, [nodes, links, width, height, nodeWidth, nodePadding, align, iterations, collapse, upright]);
|
|
370
564
|
|
|
371
565
|
/*
|
|
372
566
|
* A flow reads from where it starts, and under a right-to-left layout that
|
|
373
567
|
* is the right-hand edge. Mirroring the finished layout rather than laying
|
|
374
568
|
* it out backwards keeps one set of maths under both directions — the
|
|
375
569
|
* arrangement is identical, it is only read from the other end.
|
|
570
|
+
*
|
|
571
|
+
* Only where the flow runs across the screen. A vertical one starts at the
|
|
572
|
+
* top in every script, and mirroring it would flip the axis carrying the
|
|
573
|
+
* values rather than the one carrying the order — the same diagram with
|
|
574
|
+
* its bars reflected, for no reason a reader could name.
|
|
376
575
|
*/
|
|
377
|
-
const mirrored = direction === 'rtl';
|
|
576
|
+
const mirrored = direction === 'rtl' && upright;
|
|
378
577
|
const placed = useMemo<SankeyLayout>(() => {
|
|
379
578
|
if (!mirrored || !layout.nodes.length) return layout;
|
|
380
579
|
return {
|
|
@@ -417,6 +616,11 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
417
616
|
return (id: string) => names.get(id) ?? id;
|
|
418
617
|
}, [nodes]);
|
|
419
618
|
|
|
619
|
+
const datumFor = useMemo(
|
|
620
|
+
() => (node: SankeyLayoutNode) => nodes[node.index] ?? { id: node.id },
|
|
621
|
+
[nodes]
|
|
622
|
+
);
|
|
623
|
+
|
|
420
624
|
/*
|
|
421
625
|
* One clock, with each column given the slice of it that it draws in, so
|
|
422
626
|
* the flow arrives in the order it happens rather than all at once. A
|
|
@@ -475,6 +679,26 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
475
679
|
if (next !== width) setWidth(next);
|
|
476
680
|
};
|
|
477
681
|
|
|
682
|
+
/*
|
|
683
|
+
* The ids `SankeyChart.Labels` drew a name for, or `null` while it has not
|
|
684
|
+
* said. Compared rather than replaced, because Labels reports on every
|
|
685
|
+
* layout change and a fresh array each time would re-render the chart
|
|
686
|
+
* forever.
|
|
687
|
+
*/
|
|
688
|
+
const [labelledIds, setLabelledIds] = useState<string[] | null>(null);
|
|
689
|
+
const reportLabelled = useMemo(
|
|
690
|
+
() => (ids: string[] | null) =>
|
|
691
|
+
setLabelledIds((current) => {
|
|
692
|
+
if (current === ids) return current;
|
|
693
|
+
if (current === null || ids === null) return ids;
|
|
694
|
+
if (current.length === ids.length && current.every((id, i) => id === ids[i])) {
|
|
695
|
+
return current;
|
|
696
|
+
}
|
|
697
|
+
return ids;
|
|
698
|
+
}),
|
|
699
|
+
[]
|
|
700
|
+
);
|
|
701
|
+
|
|
478
702
|
const context = useMemo<SankeyChartContextValue>(
|
|
479
703
|
() => ({
|
|
480
704
|
nodes,
|
|
@@ -490,6 +714,9 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
490
714
|
activeId: activeId ?? null,
|
|
491
715
|
setActiveId,
|
|
492
716
|
labelFor,
|
|
717
|
+
reportLabelled,
|
|
718
|
+
orientation,
|
|
719
|
+
datumFor,
|
|
493
720
|
}),
|
|
494
721
|
[
|
|
495
722
|
nodes,
|
|
@@ -505,6 +732,9 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
505
732
|
activeId,
|
|
506
733
|
setActiveId,
|
|
507
734
|
labelFor,
|
|
735
|
+
reportLabelled,
|
|
736
|
+
orientation,
|
|
737
|
+
datumFor,
|
|
508
738
|
]
|
|
509
739
|
);
|
|
510
740
|
|
|
@@ -515,10 +745,14 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
515
745
|
footer: [],
|
|
516
746
|
};
|
|
517
747
|
/*
|
|
518
|
-
* Whether the names are on the chart decides how it is read out.
|
|
519
|
-
* `Labels`
|
|
520
|
-
*
|
|
521
|
-
*
|
|
748
|
+
* Whether the names are on the chart decides how it is read out. Without
|
|
749
|
+
* `Labels` the diagram is pure geometry and the semantic list is the only
|
|
750
|
+
* way through it. With them, most nodes are a pressable row already and
|
|
751
|
+
* the list would say it all a second time — but only most: a bar too short
|
|
752
|
+
* to label is left with no target and no entry, which is how the smallest
|
|
753
|
+
* nodes on a crowded diagram became unreachable by either route. So the
|
|
754
|
+
* list is narrowed to what `Labels` reports it dropped rather than turned
|
|
755
|
+
* off wholesale.
|
|
522
756
|
*/
|
|
523
757
|
let labelled = false;
|
|
524
758
|
Children.forEach(children, (child, index) => {
|
|
@@ -532,6 +766,21 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
532
766
|
);
|
|
533
767
|
});
|
|
534
768
|
|
|
769
|
+
/*
|
|
770
|
+
* The nodes the semantic list still has to carry.
|
|
771
|
+
*
|
|
772
|
+
* With no `Labels` that is all of them. With `Labels` it is the ones it
|
|
773
|
+
* reported dropping — and until it has reported, none: a name that turns
|
|
774
|
+
* out to exist is a duplicate entry, which is worse for one render than a
|
|
775
|
+
* missing one, and the report lands on the render straight after.
|
|
776
|
+
*/
|
|
777
|
+
const spoken = useMemo(() => {
|
|
778
|
+
if (!labelled) return nodes;
|
|
779
|
+
if (labelledIds === null) return [];
|
|
780
|
+
const drawn = new Set(labelledIds);
|
|
781
|
+
return nodes.filter((node) => !drawn.has(node.id));
|
|
782
|
+
}, [nodes, labelled, labelledIds]);
|
|
783
|
+
|
|
535
784
|
return (
|
|
536
785
|
<SankeyChartContext.Provider value={context}>
|
|
537
786
|
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
@@ -574,8 +823,8 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
574
823
|
{slots.footer}
|
|
575
824
|
<ChartAccessibilityData
|
|
576
825
|
chart="Flow diagram"
|
|
577
|
-
data={
|
|
578
|
-
disabled={
|
|
826
|
+
data={spoken}
|
|
827
|
+
disabled={status === 'loading' || (labelled && spoken.length === 0)}
|
|
579
828
|
valueOf={(node) => [
|
|
580
829
|
['Node', node.label ?? node.id],
|
|
581
830
|
['Value', placed.nodes.find((placedNode) => placedNode.id === node.id)?.value],
|
|
@@ -585,6 +834,13 @@ const SankeyChartRoot = forwardRef<SankeyChartHandle, SankeyChartProps>(
|
|
|
585
834
|
accessibilityLabelForDatum={accessibilityLabelForDatum}
|
|
586
835
|
onAccessibilityDatumPress={onAccessibilityDatumPress}
|
|
587
836
|
/>
|
|
837
|
+
<LinkAccessibilityData
|
|
838
|
+
disabled={status === 'loading'}
|
|
839
|
+
layout={placed}
|
|
840
|
+
links={links}
|
|
841
|
+
labelFor={labelFor}
|
|
842
|
+
labelForLink={accessibilityLabelForLink}
|
|
843
|
+
/>
|
|
588
844
|
</View>
|
|
589
845
|
</SankeyChartContext.Provider>
|
|
590
846
|
);
|
|
@@ -596,6 +852,63 @@ function ChildSlot({ children }: { children: ReactNode }) {
|
|
|
596
852
|
return <>{children}</>;
|
|
597
853
|
}
|
|
598
854
|
|
|
855
|
+
/**
|
|
856
|
+
* The ribbons, for a screen reader.
|
|
857
|
+
*
|
|
858
|
+
* Kept apart from the node list rather than folded into it, because the two
|
|
859
|
+
* are different readings and a caller overrides them separately: a node says
|
|
860
|
+
* how much passed through a stage, a ribbon says where it came from and where
|
|
861
|
+
* it went. A diagram read out as nodes alone is a list of totals with the
|
|
862
|
+
* routing — the thing the chart exists to show — missing from it.
|
|
863
|
+
*
|
|
864
|
+
* Offscreen rather than hidden, so the entries are reachable by swiping while
|
|
865
|
+
* nothing about the drawn diagram moves.
|
|
866
|
+
*/
|
|
867
|
+
function LinkAccessibilityData({
|
|
868
|
+
disabled,
|
|
869
|
+
layout,
|
|
870
|
+
links,
|
|
871
|
+
labelFor,
|
|
872
|
+
labelForLink,
|
|
873
|
+
}: {
|
|
874
|
+
disabled: boolean;
|
|
875
|
+
layout: SankeyLayout;
|
|
876
|
+
links: SankeyLink[];
|
|
877
|
+
labelFor: (id: string) => string;
|
|
878
|
+
labelForLink?: (link: SankeyLink, index: number) => string;
|
|
879
|
+
}) {
|
|
880
|
+
if (disabled || !layout.links.length) return null;
|
|
881
|
+
|
|
882
|
+
return (
|
|
883
|
+
<View style={{ position: 'absolute', left: -10_000, width: 1, height: 1 }}>
|
|
884
|
+
{layout.links.map((link) => {
|
|
885
|
+
// `input` points back at the caller's row; `index` is the drawn order,
|
|
886
|
+
// which has closed up behind every row that could not be drawn.
|
|
887
|
+
// A ribbon that `collapse` merged has no single row of the caller's
|
|
888
|
+
// behind it, so there is nothing to hand an override — it still gets
|
|
889
|
+
// spoken, in the chart's own words.
|
|
890
|
+
const datum = link.input >= 0 ? links[link.input] : undefined;
|
|
891
|
+
const source = layout.nodes[link.source];
|
|
892
|
+
const target = layout.nodes[link.target];
|
|
893
|
+
if (!source || !target) return null;
|
|
894
|
+
|
|
895
|
+
const label =
|
|
896
|
+
(datum ? labelForLink?.(datum, link.input) : undefined) ??
|
|
897
|
+
`${labelFor(source.id)} to ${labelFor(target.id)}, ${compactNumber(link.value)}`;
|
|
898
|
+
|
|
899
|
+
return (
|
|
900
|
+
<View
|
|
901
|
+
key={`${source.id}-${target.id}-${link.index}`}
|
|
902
|
+
accessible
|
|
903
|
+
accessibilityRole="text"
|
|
904
|
+
accessibilityLabel={label}
|
|
905
|
+
/>
|
|
906
|
+
);
|
|
907
|
+
})}
|
|
908
|
+
</View>
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
599
912
|
/**
|
|
600
913
|
* How much of the entrance a column has played, eased.
|
|
601
914
|
*
|
|
@@ -636,11 +949,13 @@ function SankeyChartLinks({
|
|
|
636
949
|
activeOpacity = LINK_ACTIVE_OPACITY,
|
|
637
950
|
dimOpacity = LINK_DIM_OPACITY,
|
|
638
951
|
}: SankeyChartLinksProps) {
|
|
639
|
-
const { layout, links, colors, curve, reveal, windows, status, activeId } =
|
|
952
|
+
const { layout, links, colors, curve, reveal, windows, status, activeId, orientation } =
|
|
640
953
|
useChart('SankeyChart.Links');
|
|
641
954
|
|
|
642
955
|
if (status === 'loading' || !layout.links.length) return null;
|
|
643
956
|
|
|
957
|
+
const upright = orientation === 'horizontal';
|
|
958
|
+
|
|
644
959
|
return (
|
|
645
960
|
<G>
|
|
646
961
|
{layout.links.map((link) => {
|
|
@@ -654,13 +969,21 @@ function SankeyChartLinks({
|
|
|
654
969
|
return (
|
|
655
970
|
<Ribbon
|
|
656
971
|
key={link.index}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
972
|
+
// Where the ribbon leaves and arrives, along whichever axis the
|
|
973
|
+
// stages advance on; `link.y0`/`y1` are already across it.
|
|
974
|
+
from={upright ? source.x1 : source.y1}
|
|
975
|
+
cFrom={link.y0}
|
|
976
|
+
to={upright ? target.x0 : target.y0}
|
|
977
|
+
cTo={link.y1}
|
|
978
|
+
upright={upright}
|
|
661
979
|
thickness={link.width}
|
|
662
980
|
curve={curve}
|
|
663
|
-
fill={
|
|
981
|
+
fill={
|
|
982
|
+
(link.input >= 0 ? links[link.input]?.color : undefined) ??
|
|
983
|
+
colors[link.source] ??
|
|
984
|
+
colors[0] ??
|
|
985
|
+
'#3b82f6'
|
|
986
|
+
}
|
|
664
987
|
reveal={reveal}
|
|
665
988
|
window={window}
|
|
666
989
|
opacity={
|
|
@@ -676,29 +999,31 @@ SankeyChartLinks.displayName = 'SankeyChart.Links';
|
|
|
676
999
|
SankeyChartLinks.slot = 'svg' as const;
|
|
677
1000
|
|
|
678
1001
|
function Ribbon({
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1002
|
+
from: edgeFrom,
|
|
1003
|
+
cFrom: centreFrom,
|
|
1004
|
+
to: edgeTo,
|
|
1005
|
+
cTo: centreTo,
|
|
683
1006
|
thickness,
|
|
684
1007
|
curve,
|
|
685
1008
|
fill,
|
|
686
1009
|
reveal,
|
|
687
1010
|
window,
|
|
688
1011
|
opacity,
|
|
1012
|
+
upright,
|
|
689
1013
|
}: {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1014
|
+
from: number;
|
|
1015
|
+
cFrom: number;
|
|
1016
|
+
to: number;
|
|
1017
|
+
cTo: number;
|
|
694
1018
|
thickness: number;
|
|
695
1019
|
curve: number;
|
|
696
1020
|
fill: string;
|
|
697
1021
|
reveal: SharedValue<number>;
|
|
698
1022
|
window: { from: number; to: number };
|
|
699
1023
|
opacity: number;
|
|
1024
|
+
upright: boolean;
|
|
700
1025
|
}) {
|
|
701
|
-
const { from, to } = window;
|
|
1026
|
+
const { from: windowFrom, to: windowTo } = window;
|
|
702
1027
|
const settled = useDerivedValue<number>(() =>
|
|
703
1028
|
withTiming(opacity, { duration: SELECT_DURATION })
|
|
704
1029
|
);
|
|
@@ -709,9 +1034,11 @@ function Ribbon({
|
|
|
709
1034
|
* one edge, so it stays anchored where it meets the node instead of
|
|
710
1035
|
* sliding down it as it arrives.
|
|
711
1036
|
*/
|
|
712
|
-
const grown = thickness * progress(reveal.value,
|
|
1037
|
+
const grown = thickness * progress(reveal.value, windowFrom, windowTo);
|
|
713
1038
|
return {
|
|
714
|
-
d:
|
|
1039
|
+
d: upright
|
|
1040
|
+
? flowPath(edgeFrom, centreFrom, edgeTo, centreTo, grown, curve)
|
|
1041
|
+
: flowPathVertical(edgeFrom, centreFrom, edgeTo, centreTo, grown, curve),
|
|
715
1042
|
fillOpacity: settled.value,
|
|
716
1043
|
};
|
|
717
1044
|
});
|
|
@@ -724,6 +1051,14 @@ export interface SankeyChartNodesProps {
|
|
|
724
1051
|
radius?: number;
|
|
725
1052
|
/** A bar's opacity when something else is selected. */
|
|
726
1053
|
dimOpacity?: number;
|
|
1054
|
+
/**
|
|
1055
|
+
* Whether pressing a bar selects its node.
|
|
1056
|
+
*
|
|
1057
|
+
* On by default, because the names are not always there to press. A bar
|
|
1058
|
+
* below `SankeyChart.Labels`' `minHeight` has no name and, without this, no
|
|
1059
|
+
* way to be selected at all.
|
|
1060
|
+
*/
|
|
1061
|
+
interactive?: boolean;
|
|
727
1062
|
}
|
|
728
1063
|
|
|
729
1064
|
/**
|
|
@@ -733,28 +1068,41 @@ export interface SankeyChartNodesProps {
|
|
|
733
1068
|
* the diagram that is not crossing anything else — it is the edge the flow
|
|
734
1069
|
* arrives at, and it reads as an edge only if nothing shows through it.
|
|
735
1070
|
*/
|
|
736
|
-
function SankeyChartNodes({
|
|
737
|
-
|
|
1071
|
+
function SankeyChartNodes({
|
|
1072
|
+
radius = 2,
|
|
1073
|
+
dimOpacity = 0.25,
|
|
1074
|
+
interactive = true,
|
|
1075
|
+
}: SankeyChartNodesProps) {
|
|
1076
|
+
const { layout, colors, reveal, windows, status, activeId, setActiveId, orientation } =
|
|
738
1077
|
useChart('SankeyChart.Nodes');
|
|
739
1078
|
|
|
740
1079
|
if (status === 'loading' || !layout.nodes.length) return null;
|
|
741
1080
|
|
|
1081
|
+
const upright = orientation === 'horizontal';
|
|
1082
|
+
|
|
742
1083
|
return (
|
|
743
1084
|
<G>
|
|
744
1085
|
{layout.nodes.map((node, index) => {
|
|
745
1086
|
const window = windows[node.layer] ?? windows[0] ?? { from: 0, to: 1 };
|
|
1087
|
+
const selected = activeId === node.id;
|
|
746
1088
|
return (
|
|
747
1089
|
<NodeBar
|
|
748
1090
|
key={node.id}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1091
|
+
// The bar is `nodeWidth` across the flow and its value along it,
|
|
1092
|
+
// so which pair of edges is which swaps with the orientation.
|
|
1093
|
+
thin0={upright ? node.x0 : node.y0}
|
|
1094
|
+
thin1={upright ? node.x1 : node.y1}
|
|
1095
|
+
long0={upright ? node.y0 : node.x0}
|
|
1096
|
+
long1={upright ? node.y1 : node.x1}
|
|
1097
|
+
upright={upright}
|
|
753
1098
|
radius={radius}
|
|
754
1099
|
fill={colors[index] ?? '#3b82f6'}
|
|
755
1100
|
reveal={reveal}
|
|
756
1101
|
window={window}
|
|
757
|
-
opacity={activeId === null ||
|
|
1102
|
+
opacity={activeId === null || selected ? 1 : dimOpacity}
|
|
1103
|
+
onPress={
|
|
1104
|
+
interactive ? () => setActiveId(selected ? null : node.id) : undefined
|
|
1105
|
+
}
|
|
758
1106
|
/>
|
|
759
1107
|
);
|
|
760
1108
|
})}
|
|
@@ -765,29 +1113,36 @@ SankeyChartNodes.displayName = 'SankeyChart.Nodes';
|
|
|
765
1113
|
SankeyChartNodes.slot = 'svg' as const;
|
|
766
1114
|
|
|
767
1115
|
function NodeBar({
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1116
|
+
thin0,
|
|
1117
|
+
thin1,
|
|
1118
|
+
long0,
|
|
1119
|
+
long1,
|
|
1120
|
+
upright,
|
|
772
1121
|
radius,
|
|
773
1122
|
fill,
|
|
774
1123
|
reveal,
|
|
775
1124
|
window,
|
|
776
1125
|
opacity,
|
|
1126
|
+
onPress,
|
|
777
1127
|
}: {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1128
|
+
/** The bar's leading edge across the flow — `nodeWidth` separates the two. */
|
|
1129
|
+
thin0: number;
|
|
1130
|
+
thin1: number;
|
|
1131
|
+
/** And along it, which is where the value is. */
|
|
1132
|
+
long0: number;
|
|
1133
|
+
long1: number;
|
|
1134
|
+
upright: boolean;
|
|
782
1135
|
radius: number;
|
|
783
1136
|
fill: string;
|
|
784
1137
|
reveal: SharedValue<number>;
|
|
785
1138
|
window: { from: number; to: number };
|
|
786
1139
|
opacity: number;
|
|
1140
|
+
onPress?: () => void;
|
|
787
1141
|
}) {
|
|
788
1142
|
const { from, to } = window;
|
|
789
|
-
const extent =
|
|
790
|
-
const centre = (
|
|
1143
|
+
const extent = long1 - long0;
|
|
1144
|
+
const centre = (long0 + long1) / 2;
|
|
1145
|
+
const thickness = thin1 - thin0;
|
|
791
1146
|
const settled = useDerivedValue<number>(() =>
|
|
792
1147
|
withTiming(opacity, { duration: SELECT_DURATION })
|
|
793
1148
|
);
|
|
@@ -795,10 +1150,43 @@ function NodeBar({
|
|
|
795
1150
|
const animatedProps = useAnimatedProps(() => {
|
|
796
1151
|
// Grown about its centre, to match the ribbons meeting it.
|
|
797
1152
|
const grown = extent * progress(reveal.value, from, to);
|
|
798
|
-
|
|
1153
|
+
const near = centre - grown / 2;
|
|
1154
|
+
return upright
|
|
1155
|
+
? { y: near, height: grown, x: thin0, width: thickness, opacity: settled.value }
|
|
1156
|
+
: { x: near, width: grown, y: thin0, height: thickness, opacity: settled.value };
|
|
799
1157
|
});
|
|
800
1158
|
|
|
801
|
-
|
|
1159
|
+
const bar = <AnimatedRect animatedProps={animatedProps} rx={radius} fill={fill} onPress={onPress} />;
|
|
1160
|
+
|
|
1161
|
+
if (!onPress || extent >= MIN_TARGET) return bar;
|
|
1162
|
+
|
|
1163
|
+
/*
|
|
1164
|
+
* A sliver gets a second, invisible rectangle to be pressed by, because the
|
|
1165
|
+
* bar itself is two points tall and nobody can land on it. `transparent`
|
|
1166
|
+
* rather than no fill: a shape with no fill is not hit-tested at all, so it
|
|
1167
|
+
* would look identical and do nothing.
|
|
1168
|
+
*
|
|
1169
|
+
* It is drawn before the bar so the bar keeps the tap where the two overlap,
|
|
1170
|
+
* and it grows about the same centre, which keeps the target over the bar
|
|
1171
|
+
* rather than beside it.
|
|
1172
|
+
*/
|
|
1173
|
+
const target = Math.max(extent, MIN_TARGET);
|
|
1174
|
+
const thinCentre = (thin0 + thin1) / 2;
|
|
1175
|
+
const across = { from: thinCentre - MIN_TARGET / 2, size: MIN_TARGET };
|
|
1176
|
+
const along = { from: centre - target / 2, size: target };
|
|
1177
|
+
return (
|
|
1178
|
+
<>
|
|
1179
|
+
<Rect
|
|
1180
|
+
x={upright ? across.from : along.from}
|
|
1181
|
+
width={upright ? across.size : along.size}
|
|
1182
|
+
y={upright ? along.from : across.from}
|
|
1183
|
+
height={upright ? along.size : across.size}
|
|
1184
|
+
fill="transparent"
|
|
1185
|
+
onPress={onPress}
|
|
1186
|
+
/>
|
|
1187
|
+
{bar}
|
|
1188
|
+
</>
|
|
1189
|
+
);
|
|
802
1190
|
}
|
|
803
1191
|
|
|
804
1192
|
export interface SankeyChartLabelsProps {
|
|
@@ -841,65 +1229,210 @@ function SankeyChartLabels({
|
|
|
841
1229
|
showValue = false,
|
|
842
1230
|
minHeight = 6,
|
|
843
1231
|
}: SankeyChartLabelsProps) {
|
|
844
|
-
const {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1232
|
+
const {
|
|
1233
|
+
layout,
|
|
1234
|
+
width,
|
|
1235
|
+
height,
|
|
1236
|
+
status,
|
|
1237
|
+
activeId,
|
|
1238
|
+
setActiveId,
|
|
1239
|
+
labelFor,
|
|
1240
|
+
reportLabelled,
|
|
1241
|
+
orientation,
|
|
1242
|
+
datumFor,
|
|
1243
|
+
} = useChart('SankeyChart.Labels');
|
|
1244
|
+
|
|
1245
|
+
const upright = orientation === 'horizontal';
|
|
1246
|
+
const drawing = status !== 'loading' && layout.nodes.length > 0;
|
|
850
1247
|
|
|
851
1248
|
/*
|
|
852
|
-
* Where
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
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.
|
|
1249
|
+
* Where every name goes, and which ones there is no room for, worked out
|
|
1250
|
+
* once: the chart has to be told which names were drawn so its semantic list
|
|
1251
|
+
* can carry the rest, and a second copy of the rule would be a second answer
|
|
1252
|
+
* to the same question.
|
|
859
1253
|
*/
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1254
|
+
const placements = useMemo(() => {
|
|
1255
|
+
if (!drawing) return [];
|
|
1256
|
+
|
|
1257
|
+
// Along the flow is the axis the stages advance on; across it is the axis
|
|
1258
|
+
// carrying the values. Which is horizontal swaps with the orientation.
|
|
1259
|
+
const alongStart = (node: SankeyLayoutNode) => (upright ? node.x0 : node.y0);
|
|
1260
|
+
const alongEnd = (node: SankeyLayoutNode) => (upright ? node.x1 : node.y1);
|
|
1261
|
+
const crossStart = (node: SankeyLayoutNode) => (upright ? node.y0 : node.x0);
|
|
1262
|
+
const crossEnd = (node: SankeyLayoutNode) => (upright ? node.y1 : node.x1);
|
|
1263
|
+
const alongSpan = upright ? width : height;
|
|
1264
|
+
const crossSpan = upright ? height : width;
|
|
1265
|
+
|
|
1266
|
+
/*
|
|
1267
|
+
* Where the neighbouring stages sit along the flow.
|
|
1268
|
+
*
|
|
1269
|
+
* A name's box needs a bound on both sides or it runs the length of the
|
|
1270
|
+
* chart and covers every stage it crosses — and since the boxes are
|
|
1271
|
+
* absolutely positioned siblings, the last one drawn takes the touch. That
|
|
1272
|
+
* is a tap on one name selecting a node two stages away, which is worse
|
|
1273
|
+
* than a small target because it is wrong rather than merely hard.
|
|
1274
|
+
*/
|
|
1275
|
+
const edges: number[] = [];
|
|
1276
|
+
for (const placed of layout.nodes) {
|
|
1277
|
+
if (!edges.includes(alongStart(placed))) edges.push(alongStart(placed));
|
|
1278
|
+
if (!edges.includes(alongEnd(placed))) edges.push(alongEnd(placed));
|
|
1279
|
+
}
|
|
1280
|
+
edges.sort((a, b) => a - b);
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
const placed = layout.nodes.map((node) => {
|
|
1284
|
+
const extent = crossEnd(node) - crossStart(node);
|
|
1285
|
+
|
|
1286
|
+
const after = edges.find((edge) => edge > alongEnd(node) + 1e-6);
|
|
1287
|
+
const before = edges.filter((edge) => edge < alongStart(node) - 1e-6).pop();
|
|
1288
|
+
const trailing = after === undefined;
|
|
1289
|
+
|
|
1290
|
+
/*
|
|
1291
|
+
* The box along the flow: the half of the gap nearest its own bar, so
|
|
1292
|
+
* the name leaving one stage and the name arriving at the next can share
|
|
1293
|
+
* the space between them without sharing a touch target.
|
|
1294
|
+
*/
|
|
1295
|
+
const gap = trailing
|
|
1296
|
+
? (() => {
|
|
1297
|
+
const from = before === undefined ? 0 : (before + alongStart(node)) / 2;
|
|
1298
|
+
return { from, size: Math.max(0, alongStart(node) - LABEL_GAP - from) };
|
|
1299
|
+
})()
|
|
1300
|
+
: (() => {
|
|
1301
|
+
const from = alongEnd(node) + LABEL_GAP;
|
|
1302
|
+
const to = after === undefined ? alongSpan : (alongEnd(node) + after) / 2;
|
|
1303
|
+
return { from, size: Math.max(0, to - from) };
|
|
1304
|
+
})();
|
|
1305
|
+
|
|
1306
|
+
// And across it: the bar's own run, clamped inside the plot.
|
|
1307
|
+
let crossFrom = Math.max(0, Math.min(crossStart(node), crossSpan - extent));
|
|
1308
|
+
let crossSize = extent;
|
|
1309
|
+
|
|
1310
|
+
/*
|
|
1311
|
+
* Along the flow the test is the bar's own run: forty names on forty
|
|
1312
|
+
* hairlines overlap into a grey band that hides the flow behind it.
|
|
1313
|
+
* Across it, where the flow runs downwards, whether a name fits is
|
|
1314
|
+
* decided below, once every name in the row is known.
|
|
1315
|
+
*/
|
|
1316
|
+
const show = extent >= minHeight;
|
|
1317
|
+
|
|
1318
|
+
return { node, show, trailing, gap, crossFrom, crossSize, extent };
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
if (!upright) {
|
|
1322
|
+
/*
|
|
1323
|
+
* Where the flow runs downwards a name sits across its bar, and a bar is
|
|
1324
|
+
* as wide as its value — so a stage worth a few percent has a bar
|
|
1325
|
+
* narrower than its own name. Squeezing the name into it gives "Groce…",
|
|
1326
|
+
* which is cut off rather than shown.
|
|
1327
|
+
*
|
|
1328
|
+
* So a name is laid out at its own width, centred on its bar, and the
|
|
1329
|
+
* row is filled from its largest stage down. A name may run over a
|
|
1330
|
+
* neighbour's bar when that neighbour is not using the room — and where
|
|
1331
|
+
* it would collide with a name already placed it slides clear, as long
|
|
1332
|
+
* as it still sits over its own bar. A name that cannot be shown whole
|
|
1333
|
+
* is not shown at all; the legend and the breakdown carry it, and its
|
|
1334
|
+
* bar is still pressable.
|
|
1335
|
+
*/
|
|
1336
|
+
const taken = new Map<number, [number, number][]>();
|
|
1337
|
+
const order = [...placed].sort((a, b) => b.node.value - a.node.value);
|
|
1338
|
+
for (const entry of order) {
|
|
1339
|
+
if (!entry.show) continue;
|
|
1340
|
+
const centre = (entry.node.x0 + entry.node.x1) / 2;
|
|
1341
|
+
const size = Math.min(crossSpan, Math.max(entry.extent, nameWidth(labelFor(entry.node.id))));
|
|
1342
|
+
const boxes = taken.get(entry.node.layer) ?? [];
|
|
1343
|
+
|
|
1344
|
+
// The free stretch of the row around this bar's centre.
|
|
1345
|
+
let lo = 0;
|
|
1346
|
+
let hi = crossSpan;
|
|
1347
|
+
for (const [a, b] of boxes) {
|
|
1348
|
+
if (b <= centre) lo = Math.max(lo, b + LABEL_GAP);
|
|
1349
|
+
else if (a >= centre) hi = Math.min(hi, a - LABEL_GAP);
|
|
1350
|
+
else {
|
|
1351
|
+
lo = hi = centre;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (hi - lo < size) {
|
|
1355
|
+
entry.show = false;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
// Centred where it can be, slid clear where it cannot, and never so
|
|
1359
|
+
// far that it stops sitting over the bar it names.
|
|
1360
|
+
const from = Math.max(lo, Math.min(centre - size / 2, hi - size));
|
|
1361
|
+
if (centre < from || centre > from + size) {
|
|
1362
|
+
entry.show = false;
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
boxes.push([from, from + size]);
|
|
1366
|
+
taken.set(entry.node.layer, boxes);
|
|
1367
|
+
entry.crossFrom = from;
|
|
1368
|
+
entry.crossSize = size;
|
|
1369
|
+
}
|
|
1370
|
+
return placed;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/*
|
|
1374
|
+
* Across the flow, an upright name is a line of text centred on a bar that
|
|
1375
|
+
* may be thinner than the line. Where two such bars sit close together
|
|
1376
|
+
* their names run into each other and both come out unreadable — which is
|
|
1377
|
+
* worse than one of them being missing. So each column keeps names from
|
|
1378
|
+
* its largest node down, and a name whose line would touch one already
|
|
1379
|
+
* kept is left off. The node keeps its pressable bar, its place in the
|
|
1380
|
+
* accessibility list and its entry in the legend.
|
|
1381
|
+
*/
|
|
1382
|
+
const lineHeight = showValue ? LABEL_LINE * 2 : LABEL_LINE;
|
|
1383
|
+
const kept = new Map<string, [number, number][]>();
|
|
1384
|
+
const order = [...placed].sort((a, b) => b.node.value - a.node.value);
|
|
1385
|
+
for (const entry of order) {
|
|
1386
|
+
if (!entry.show) continue;
|
|
1387
|
+
const centre = (entry.node.y0 + entry.node.y1) / 2;
|
|
1388
|
+
const size = Math.max(entry.extent, lineHeight);
|
|
1389
|
+
const from = Math.max(0, Math.min(centre - size / 2, height - size));
|
|
1390
|
+
const to = from + size;
|
|
1391
|
+
const key = `${entry.node.layer}:${entry.trailing ? 'end' : 'start'}`;
|
|
1392
|
+
const taken = kept.get(key) ?? [];
|
|
1393
|
+
if (taken.some(([a, b]) => from < b && to > a)) {
|
|
1394
|
+
entry.show = false;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
taken.push([from, to]);
|
|
1398
|
+
kept.set(key, taken);
|
|
1399
|
+
entry.crossFrom = from;
|
|
1400
|
+
entry.crossSize = size;
|
|
1401
|
+
}
|
|
1402
|
+
return placed;
|
|
1403
|
+
}, [drawing, layout.nodes, width, height, upright, minHeight, showValue, labelFor]);
|
|
1404
|
+
|
|
1405
|
+
const drawn = useMemo(
|
|
1406
|
+
() => (drawing ? placements.filter((p) => p.show).map((p) => p.node.id) : null),
|
|
1407
|
+
[drawing, placements]
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
useEffect(() => {
|
|
1411
|
+
reportLabelled(drawn);
|
|
1412
|
+
return () => reportLabelled(null);
|
|
1413
|
+
}, [drawn, reportLabelled]);
|
|
1414
|
+
|
|
1415
|
+
if (!drawing) return null;
|
|
1416
|
+
|
|
1417
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
872
1418
|
|
|
873
1419
|
return (
|
|
874
1420
|
<>
|
|
875
|
-
{
|
|
876
|
-
|
|
877
|
-
if (!datum) return null;
|
|
878
|
-
|
|
879
|
-
const extent = node.y1 - node.y0;
|
|
880
|
-
if (extent < minHeight) return null;
|
|
1421
|
+
{placements.map(({ node, show, trailing, gap, crossFrom, crossSize }) => {
|
|
1422
|
+
if (!show) return null;
|
|
881
1423
|
|
|
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
1424
|
const name = labelFor(node.id);
|
|
893
|
-
const value = format(node.value,
|
|
1425
|
+
const value = format(node.value, datumFor(node));
|
|
894
1426
|
const selected = activeId === node.id;
|
|
895
1427
|
|
|
896
1428
|
/*
|
|
897
|
-
* A sliver's
|
|
898
|
-
*
|
|
899
|
-
* overlapping targets are worse than
|
|
1429
|
+
* A sliver's box is padded out to a real target rather than drawn
|
|
1430
|
+
* longer — growing it would push it over its neighbours, and two
|
|
1431
|
+
* overlapping targets are worse than one small one.
|
|
900
1432
|
*/
|
|
901
|
-
const slack = Math.max(0, (MIN_TARGET -
|
|
902
|
-
|
|
1433
|
+
const slack = Math.max(0, (MIN_TARGET - crossSize) / 2);
|
|
1434
|
+
|
|
1435
|
+
const align = upright ? (trailing ? 'right' : 'left') : 'center';
|
|
903
1436
|
|
|
904
1437
|
return (
|
|
905
1438
|
<Pressable
|
|
@@ -907,32 +1440,30 @@ function SankeyChartLabels({
|
|
|
907
1440
|
accessibilityRole="button"
|
|
908
1441
|
accessibilityState={{ selected }}
|
|
909
1442
|
accessibilityLabel={`${name}, ${value}`}
|
|
910
|
-
hitSlop={{ top: slack, bottom: slack }}
|
|
1443
|
+
hitSlop={upright ? { top: slack, bottom: slack } : { left: slack, right: slack }}
|
|
911
1444
|
onPress={() => setActiveId(selected ? null : node.id)}
|
|
912
1445
|
style={{
|
|
913
1446
|
position: 'absolute',
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
return { left: from, width: Math.max(0, to - from) };
|
|
935
|
-
})()),
|
|
1447
|
+
...(upright
|
|
1448
|
+
? {
|
|
1449
|
+
top: crossFrom,
|
|
1450
|
+
height: crossSize,
|
|
1451
|
+
left: gap.from,
|
|
1452
|
+
width: gap.size,
|
|
1453
|
+
justifyContent: 'center',
|
|
1454
|
+
alignItems: trailing ? ('flex-end' as const) : ('flex-start' as const),
|
|
1455
|
+
}
|
|
1456
|
+
: {
|
|
1457
|
+
left: crossFrom,
|
|
1458
|
+
width: crossSize,
|
|
1459
|
+
top: gap.from,
|
|
1460
|
+
height: gap.size,
|
|
1461
|
+
alignItems: 'center',
|
|
1462
|
+
// Against the bar rather than centred in the gap, so the
|
|
1463
|
+
// name sits with what it names instead of floating in the
|
|
1464
|
+
// middle of the ribbons.
|
|
1465
|
+
justifyContent: trailing ? ('flex-end' as const) : ('flex-start' as const),
|
|
1466
|
+
}),
|
|
936
1467
|
}}
|
|
937
1468
|
className={cn(className)}
|
|
938
1469
|
>
|
|
@@ -940,28 +1471,32 @@ function SankeyChartLabels({
|
|
|
940
1471
|
* Both alignments are stated rather than inherited. A paragraph's
|
|
941
1472
|
* default alignment follows the reading direction, so under a
|
|
942
1473
|
* right-to-left layout an unaligned name drifts to the far end of
|
|
943
|
-
* its
|
|
944
|
-
*
|
|
945
|
-
*
|
|
1474
|
+
* its box and ends up in the middle of the plot instead of against
|
|
1475
|
+
* the bar it belongs to. Which side the name hugs is a fact about
|
|
1476
|
+
* where its bar is, not about the language.
|
|
946
1477
|
*/}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
{showValue ? (
|
|
1478
|
+
{/*
|
|
1479
|
+
* A backdrop in the page colour, because the names sit over the
|
|
1480
|
+
* ribbons and a ribbon can be any colour at all — dark text on a
|
|
1481
|
+
* dark ribbon, or light on light, is unreadable with nothing
|
|
1482
|
+
* between them. Tight to the text rather than filling the box, so
|
|
1483
|
+
* it hides as little of the flow as it can.
|
|
1484
|
+
*/}
|
|
1485
|
+
<View className="max-w-full rounded-md bg-background/80 px-1.5 py-0.5">
|
|
956
1486
|
<Text
|
|
957
1487
|
size="xs"
|
|
958
|
-
|
|
1488
|
+
weight={selected ? 'bold' : 'medium'}
|
|
959
1489
|
numberOfLines={1}
|
|
960
|
-
style={{ textAlign:
|
|
1490
|
+
style={{ textAlign: align }}
|
|
961
1491
|
>
|
|
962
|
-
{
|
|
1492
|
+
{name}
|
|
963
1493
|
</Text>
|
|
964
|
-
|
|
1494
|
+
{showValue ? (
|
|
1495
|
+
<Text size="xs" muted numberOfLines={1} style={{ textAlign: align }}>
|
|
1496
|
+
{value}
|
|
1497
|
+
</Text>
|
|
1498
|
+
) : null}
|
|
1499
|
+
</View>
|
|
965
1500
|
</Pressable>
|
|
966
1501
|
);
|
|
967
1502
|
})}
|
|
@@ -990,7 +1525,7 @@ export interface SankeyChartTooltipProps {
|
|
|
990
1525
|
* it belongs to — a card half off the edge of a phone is a card nobody can read.
|
|
991
1526
|
*/
|
|
992
1527
|
function SankeyChartTooltip({ className, formatValue }: SankeyChartTooltipProps) {
|
|
993
|
-
const { layout, width, height, labelFor } = useChart('SankeyChart.Tooltip');
|
|
1528
|
+
const { layout, width, height, labelFor, orientation } = useChart('SankeyChart.Tooltip');
|
|
994
1529
|
const { activeId, activeValue, incoming, outgoing } = useSankeyChart();
|
|
995
1530
|
|
|
996
1531
|
if (!activeId) return null;
|
|
@@ -999,20 +1534,38 @@ function SankeyChartTooltip({ className, formatValue }: SankeyChartTooltipProps)
|
|
|
999
1534
|
|
|
1000
1535
|
const format = formatValue ?? compactNumber;
|
|
1001
1536
|
const CARD = 132;
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1537
|
+
const HALF = 34;
|
|
1538
|
+
const upright = orientation === 'horizontal';
|
|
1539
|
+
|
|
1540
|
+
/*
|
|
1541
|
+
* Beside the bar on the axis the flow advances along, and level with its
|
|
1542
|
+
* middle on the other — then clamped into the plot at both ends, so a node
|
|
1543
|
+
* at an edge gets a card that is still entirely on the chart. It flips to
|
|
1544
|
+
* the near side when there is no room on the far one, which is what keeps a
|
|
1545
|
+
* last-stage node's card off the edge rather than half over it.
|
|
1546
|
+
*/
|
|
1547
|
+
const alongEnd = upright ? node.x1 : node.y1;
|
|
1548
|
+
const alongStart = upright ? node.x0 : node.y0;
|
|
1549
|
+
const alongSpan = upright ? width : height;
|
|
1550
|
+
const cardAlong = upright ? CARD : HALF * 2;
|
|
1551
|
+
const trailing = alongEnd + LABEL_GAP + cardAlong > alongSpan;
|
|
1552
|
+
const along = trailing
|
|
1553
|
+
? Math.max(0, alongStart - LABEL_GAP - cardAlong)
|
|
1554
|
+
: Math.min(alongEnd + LABEL_GAP, Math.max(0, alongSpan - cardAlong));
|
|
1555
|
+
|
|
1556
|
+
const crossCentre = upright ? (node.y0 + node.y1) / 2 : (node.x0 + node.x1) / 2;
|
|
1557
|
+
const crossSpan = upright ? height : width;
|
|
1558
|
+
const cardCross = upright ? HALF * 2 : CARD;
|
|
1559
|
+
const cross = Math.max(0, Math.min(crossCentre - cardCross / 2, crossSpan - cardCross));
|
|
1007
1560
|
|
|
1008
1561
|
return (
|
|
1009
1562
|
<View
|
|
1010
1563
|
pointerEvents="none"
|
|
1011
1564
|
style={{
|
|
1012
1565
|
position: 'absolute',
|
|
1013
|
-
left,
|
|
1014
1566
|
width: CARD,
|
|
1015
|
-
|
|
1567
|
+
left: upright ? along : cross,
|
|
1568
|
+
top: upright ? cross : along,
|
|
1016
1569
|
}}
|
|
1017
1570
|
className={cn(
|
|
1018
1571
|
'gap-0.5 rounded-lg border border-border bg-background px-2.5 py-2 shadow-sm',
|
|
@@ -1034,6 +1587,206 @@ function SankeyChartTooltip({ className, formatValue }: SankeyChartTooltipProps)
|
|
|
1034
1587
|
SankeyChartTooltip.displayName = 'SankeyChart.Tooltip';
|
|
1035
1588
|
SankeyChartTooltip.slot = 'overlay' as const;
|
|
1036
1589
|
|
|
1590
|
+
export interface SankeyChartBreakdownProps extends ViewProps {
|
|
1591
|
+
className?: string;
|
|
1592
|
+
/** Format the figures. Defaults to a compact number. */
|
|
1593
|
+
formatValue?: (value: number) => string;
|
|
1594
|
+
/** How many rows each side shows before stopping. */
|
|
1595
|
+
maxRows?: number;
|
|
1596
|
+
/**
|
|
1597
|
+
* Fires with the node at the other end of a row, so a breakdown can be
|
|
1598
|
+
* walked: press where a stream went and the chart follows it there.
|
|
1599
|
+
*/
|
|
1600
|
+
onSelectNode?: (id: string) => void;
|
|
1601
|
+
/** Shown in place of the rows when nothing is selected. */
|
|
1602
|
+
placeholder?: string;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* What the selected node carries, written out in full underneath the diagram.
|
|
1607
|
+
*
|
|
1608
|
+
* The names on a flow diagram live in the gaps between its stages, which on a
|
|
1609
|
+
* phone is a few dozen points — wide enough to truncate almost anything. Here
|
|
1610
|
+
* there is the whole width of the card, so a stream is read as the name of
|
|
1611
|
+
* where it came from and the number it carried rather than as a ribbon whose
|
|
1612
|
+
* label ran out of room.
|
|
1613
|
+
*
|
|
1614
|
+
* In and out are listed separately because they are only the same total when
|
|
1615
|
+
* nothing was lost on the way through, and a node where they differ is the
|
|
1616
|
+
* interesting one on the chart.
|
|
1617
|
+
*/
|
|
1618
|
+
function SankeyChartBreakdown({
|
|
1619
|
+
className,
|
|
1620
|
+
formatValue,
|
|
1621
|
+
maxRows,
|
|
1622
|
+
onSelectNode,
|
|
1623
|
+
placeholder = 'Select a stage to see what it carries',
|
|
1624
|
+
...props
|
|
1625
|
+
}: SankeyChartBreakdownProps) {
|
|
1626
|
+
const { status, setActiveId, labelFor } = useChart('SankeyChart.Breakdown');
|
|
1627
|
+
const { activeId, activeValue, sources, targets } = useSankeyChart();
|
|
1628
|
+
|
|
1629
|
+
if (status === 'loading') return null;
|
|
1630
|
+
|
|
1631
|
+
const format = formatValue ?? compactNumber;
|
|
1632
|
+
const limit = maxRows && maxRows > 0 ? Math.floor(maxRows) : undefined;
|
|
1633
|
+
|
|
1634
|
+
if (!activeId) {
|
|
1635
|
+
return (
|
|
1636
|
+
<View {...props} className={cn('w-full pt-3', className)}>
|
|
1637
|
+
<Text size="xs" muted>
|
|
1638
|
+
{placeholder}
|
|
1639
|
+
</Text>
|
|
1640
|
+
</View>
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const side = (title: string, rows: SankeyChartFlow[]) => {
|
|
1645
|
+
if (!rows.length) return null;
|
|
1646
|
+
const shown = limit ? rows.slice(0, limit) : rows;
|
|
1647
|
+
const rest = rows.length - shown.length;
|
|
1648
|
+
return (
|
|
1649
|
+
<View className="w-full gap-1">
|
|
1650
|
+
<Text size="xs" muted weight="medium">
|
|
1651
|
+
{title}
|
|
1652
|
+
</Text>
|
|
1653
|
+
{shown.map((row) => (
|
|
1654
|
+
<Pressable
|
|
1655
|
+
key={`${title}-${row.id}`}
|
|
1656
|
+
accessibilityRole="button"
|
|
1657
|
+
accessibilityLabel={`${row.label}, ${format(row.value)}, ${Math.round(row.share * 100)} percent`}
|
|
1658
|
+
onPress={() => {
|
|
1659
|
+
setActiveId(row.id);
|
|
1660
|
+
onSelectNode?.(row.id);
|
|
1661
|
+
}}
|
|
1662
|
+
className="w-full flex-row items-center gap-2 py-1"
|
|
1663
|
+
>
|
|
1664
|
+
<View
|
|
1665
|
+
style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: row.color }}
|
|
1666
|
+
/>
|
|
1667
|
+
{/* The name takes whatever the numbers leave, which is most of it. */}
|
|
1668
|
+
<Text size="xs" numberOfLines={1} className="shrink grow">
|
|
1669
|
+
{row.label}
|
|
1670
|
+
</Text>
|
|
1671
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1672
|
+
{format(row.value)}
|
|
1673
|
+
</Text>
|
|
1674
|
+
<Text size="xs" muted numberOfLines={1} className="w-10 text-right">
|
|
1675
|
+
{`${Math.round(row.share * 100)}%`}
|
|
1676
|
+
</Text>
|
|
1677
|
+
</Pressable>
|
|
1678
|
+
))}
|
|
1679
|
+
{rest > 0 ? (
|
|
1680
|
+
<Text size="xs" muted>
|
|
1681
|
+
{`and ${rest} more`}
|
|
1682
|
+
</Text>
|
|
1683
|
+
) : null}
|
|
1684
|
+
</View>
|
|
1685
|
+
);
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
return (
|
|
1689
|
+
<View {...props} className={cn('w-full gap-2 pt-3', className)}>
|
|
1690
|
+
<View className="w-full flex-row items-baseline justify-between gap-2">
|
|
1691
|
+
<Text size="xs" weight="bold" numberOfLines={1} className="shrink">
|
|
1692
|
+
{labelFor(activeId)}
|
|
1693
|
+
</Text>
|
|
1694
|
+
<Text size="xs" weight="semibold">
|
|
1695
|
+
{format(activeValue)}
|
|
1696
|
+
</Text>
|
|
1697
|
+
</View>
|
|
1698
|
+
{side('In', sources)}
|
|
1699
|
+
{side('Out', targets)}
|
|
1700
|
+
</View>
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
SankeyChartBreakdown.displayName = 'SankeyChart.Breakdown';
|
|
1704
|
+
SankeyChartBreakdown.slot = 'footer' as const;
|
|
1705
|
+
|
|
1706
|
+
export interface SankeyChartLegendProps extends ViewProps {
|
|
1707
|
+
className?: string;
|
|
1708
|
+
/** How many names to show before stopping. */
|
|
1709
|
+
limit?: number;
|
|
1710
|
+
/** Show each node's share of the whole flow beside its name. */
|
|
1711
|
+
showShare?: boolean;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Every stage named, under the diagram.
|
|
1716
|
+
*
|
|
1717
|
+
* The names on the chart are dropped wherever a bar is too short to carry one,
|
|
1718
|
+
* which is the right call — forty names at that spacing overlap into a grey
|
|
1719
|
+
* band that hides the flow behind them — but it leaves the smallest nodes with
|
|
1720
|
+
* no name and nothing to press. Here each one gets its full name and a proper
|
|
1721
|
+
* target, and pressing it selects the same node the bar would.
|
|
1722
|
+
*/
|
|
1723
|
+
function SankeyChartLegend({
|
|
1724
|
+
className,
|
|
1725
|
+
limit,
|
|
1726
|
+
showShare = true,
|
|
1727
|
+
...props
|
|
1728
|
+
}: SankeyChartLegendProps) {
|
|
1729
|
+
const { layout, colors, status, activeId, setActiveId, labelFor } =
|
|
1730
|
+
useChart('SankeyChart.Legend');
|
|
1731
|
+
|
|
1732
|
+
if (status === 'loading' || !layout.nodes.length) return null;
|
|
1733
|
+
|
|
1734
|
+
// Against the widest stage rather than the sum of every node, which counts
|
|
1735
|
+
// whatever passes through a middle stage twice and makes every share small.
|
|
1736
|
+
let total = 0;
|
|
1737
|
+
const byLayer = new Map<number, number>();
|
|
1738
|
+
for (const node of layout.nodes) {
|
|
1739
|
+
const carried = (byLayer.get(node.layer) ?? 0) + node.value;
|
|
1740
|
+
byLayer.set(node.layer, carried);
|
|
1741
|
+
if (carried > total) total = carried;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const shown = limit && limit > 0 ? layout.nodes.slice(0, Math.floor(limit)) : layout.nodes;
|
|
1745
|
+
|
|
1746
|
+
return (
|
|
1747
|
+
<View
|
|
1748
|
+
{...props}
|
|
1749
|
+
className={cn('w-full flex-row flex-wrap items-center gap-x-3 gap-y-1.5 pt-3', className)}
|
|
1750
|
+
>
|
|
1751
|
+
{shown.map((node, index) => {
|
|
1752
|
+
const percent = total > 0 ? Math.round((node.value / total) * 100) : 0;
|
|
1753
|
+
const selected = activeId === node.id;
|
|
1754
|
+
const dimmed = activeId !== null && !selected;
|
|
1755
|
+
return (
|
|
1756
|
+
<Pressable
|
|
1757
|
+
key={node.id}
|
|
1758
|
+
accessibilityRole="button"
|
|
1759
|
+
accessibilityState={{ selected }}
|
|
1760
|
+
accessibilityLabel={`${labelFor(node.id)}, ${percent} percent`}
|
|
1761
|
+
onPress={() => setActiveId(selected ? null : node.id)}
|
|
1762
|
+
style={{ opacity: dimmed ? 0.4 : 1 }}
|
|
1763
|
+
className="max-w-full flex-row items-center gap-1.5"
|
|
1764
|
+
>
|
|
1765
|
+
<View
|
|
1766
|
+
style={{
|
|
1767
|
+
width: 8,
|
|
1768
|
+
height: 8,
|
|
1769
|
+
borderRadius: 2,
|
|
1770
|
+
backgroundColor: colors[index] ?? '#3b82f6',
|
|
1771
|
+
}}
|
|
1772
|
+
/>
|
|
1773
|
+
<Text size="xs" muted numberOfLines={1} className="shrink">
|
|
1774
|
+
{labelFor(node.id)}
|
|
1775
|
+
</Text>
|
|
1776
|
+
{showShare ? (
|
|
1777
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1778
|
+
{`${percent}%`}
|
|
1779
|
+
</Text>
|
|
1780
|
+
) : null}
|
|
1781
|
+
</Pressable>
|
|
1782
|
+
);
|
|
1783
|
+
})}
|
|
1784
|
+
</View>
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
SankeyChartLegend.displayName = 'SankeyChart.Legend';
|
|
1788
|
+
SankeyChartLegend.slot = 'footer' as const;
|
|
1789
|
+
|
|
1037
1790
|
export interface SankeyChartSkeletonProps {
|
|
1038
1791
|
color?: string;
|
|
1039
1792
|
}
|
|
@@ -1049,7 +1802,7 @@ export interface SankeyChartSkeletonProps {
|
|
|
1049
1802
|
* wrong.
|
|
1050
1803
|
*/
|
|
1051
1804
|
function SankeyChartSkeleton({ color }: SankeyChartSkeletonProps) {
|
|
1052
|
-
const { width, height, curve, status } = useChart('SankeyChart.Skeleton');
|
|
1805
|
+
const { width, height, curve, status, orientation } = useChart('SankeyChart.Skeleton');
|
|
1053
1806
|
const token = useCSSVariable('--color-skeleton');
|
|
1054
1807
|
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
1055
1808
|
|
|
@@ -1059,21 +1812,29 @@ function SankeyChartSkeleton({ color }: SankeyChartSkeletonProps) {
|
|
|
1059
1812
|
const { mounted, opacity } = useSkeletonHandoff(status === 'loading');
|
|
1060
1813
|
const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value }));
|
|
1061
1814
|
|
|
1815
|
+
const upright = orientation === 'horizontal';
|
|
1816
|
+
|
|
1062
1817
|
const shape = useMemo(() => {
|
|
1063
1818
|
if (width <= 0 || height <= 0) return null;
|
|
1819
|
+
// The placeholder is the diagram's own shape, turned the same way: a
|
|
1820
|
+
// waiting state that does not match what replaces it moves the whole card
|
|
1821
|
+
// at the moment the data lands.
|
|
1822
|
+
const along = upright ? width : height;
|
|
1823
|
+
const cross = upright ? height : width;
|
|
1064
1824
|
const bar = DEFAULT_NODE_WIDTH;
|
|
1065
|
-
const step = (
|
|
1066
|
-
const band =
|
|
1825
|
+
const step = (along - bar) / Math.max(SKELETON_COLUMNS - 1, 1);
|
|
1826
|
+
const band = cross / 3;
|
|
1067
1827
|
const columns = Array.from({ length: SKELETON_COLUMNS }, (_, i) => i * step);
|
|
1828
|
+
const path = upright ? flowPath : flowPathVertical;
|
|
1068
1829
|
const ribbons: string[] = [];
|
|
1069
1830
|
for (let i = 0; i < SKELETON_COLUMNS - 1; i += 1) {
|
|
1070
1831
|
const from = columns[i]! + bar;
|
|
1071
1832
|
const to = columns[i + 1]!;
|
|
1072
|
-
ribbons.push(
|
|
1073
|
-
ribbons.push(
|
|
1833
|
+
ribbons.push(path(from, cross / 3, to, cross / 3, band * 0.5, curve));
|
|
1834
|
+
ribbons.push(path(from, (cross * 2) / 3, to, (cross * 2) / 3, band * 0.5, curve));
|
|
1074
1835
|
}
|
|
1075
|
-
return { bar, band, columns, ribbons };
|
|
1076
|
-
}, [width, height, curve]);
|
|
1836
|
+
return { bar, band, columns, ribbons, cross };
|
|
1837
|
+
}, [width, height, curve, upright]);
|
|
1077
1838
|
|
|
1078
1839
|
if (!mounted || !shape) return null;
|
|
1079
1840
|
|
|
@@ -1082,13 +1843,13 @@ function SankeyChartSkeleton({ color }: SankeyChartSkeletonProps) {
|
|
|
1082
1843
|
{shape.ribbons.map((d, index) => (
|
|
1083
1844
|
<Path key={`ribbon-${index}`} d={d} fill={fill} fillOpacity={0.5} />
|
|
1084
1845
|
))}
|
|
1085
|
-
{shape.columns.map((
|
|
1846
|
+
{shape.columns.map((at, index) => (
|
|
1086
1847
|
<Rect
|
|
1087
1848
|
key={`bar-${index}`}
|
|
1088
|
-
x={
|
|
1089
|
-
y={
|
|
1090
|
-
width={shape.bar}
|
|
1091
|
-
height={(
|
|
1849
|
+
x={upright ? at : shape.cross / 6}
|
|
1850
|
+
y={upright ? shape.cross / 6 : at}
|
|
1851
|
+
width={upright ? shape.bar : (shape.cross * 2) / 3}
|
|
1852
|
+
height={upright ? (shape.cross * 2) / 3 : shape.bar}
|
|
1092
1853
|
rx={2}
|
|
1093
1854
|
fill={fill}
|
|
1094
1855
|
/>
|
|
@@ -1156,6 +1917,8 @@ SankeyChartHeader.displayName = 'SankeyChart.Header';
|
|
|
1156
1917
|
SankeyChartHeader.slot = 'header' as const;
|
|
1157
1918
|
|
|
1158
1919
|
export const SankeyChart = Object.assign(SankeyChartRoot, {
|
|
1920
|
+
Breakdown: SankeyChartBreakdown,
|
|
1921
|
+
Legend: SankeyChartLegend,
|
|
1159
1922
|
Header: SankeyChartHeader,
|
|
1160
1923
|
Links: SankeyChartLinks,
|
|
1161
1924
|
Nodes: SankeyChartNodes,
|