panelui-native 0.101.1 → 0.102.1
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
|
@@ -49,6 +49,21 @@ export interface SankeyLayoutLinkInput {
|
|
|
49
49
|
value: number;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Which nodes get folded into one, and what the result is called. */
|
|
53
|
+
export interface SankeyCollapse {
|
|
54
|
+
/**
|
|
55
|
+
* Keep at most this many nodes in a column, bucket included. A column with
|
|
56
|
+
* more folds its smallest until it fits.
|
|
57
|
+
*/
|
|
58
|
+
maxPerColumn?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Fold any node worth less than this share of its own column, `0` to `1`.
|
|
61
|
+
*/
|
|
62
|
+
minShare?: number;
|
|
63
|
+
/** What the bucket is called. Defaults to `Other`. */
|
|
64
|
+
label?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export interface SankeyLayoutOptions {
|
|
53
68
|
width: number;
|
|
54
69
|
height: number;
|
|
@@ -59,12 +74,30 @@ export interface SankeyLayoutOptions {
|
|
|
59
74
|
align: SankeyAlign;
|
|
60
75
|
/** Relaxation rounds. More is steadier and slower; six is the useful knee. */
|
|
61
76
|
iterations: number;
|
|
77
|
+
/**
|
|
78
|
+
* Folds a column's smallest nodes into one bucket before laying it out.
|
|
79
|
+
*
|
|
80
|
+
* A ribbon carries its value in its thickness, so a column of thirty is
|
|
81
|
+
* thirty hairlines — present in the data and unreadable on the screen. This
|
|
82
|
+
* is the only option here that reduces what is drawn rather than rearranging
|
|
83
|
+
* it.
|
|
84
|
+
*/
|
|
85
|
+
collapse?: SankeyCollapse;
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
export interface SankeyLayoutNode {
|
|
65
89
|
id: string;
|
|
66
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Position in the input array, so a caller can find its own datum again.
|
|
92
|
+
* `-1` on a node the layout made up rather than was given — see `collapsed`.
|
|
93
|
+
*/
|
|
67
94
|
index: number;
|
|
95
|
+
/**
|
|
96
|
+
* The ids folded into this node, on the bucket `collapse` produced. Absent
|
|
97
|
+
* on every node that came from the caller's array, which is how the two are
|
|
98
|
+
* told apart without comparing ids against the input.
|
|
99
|
+
*/
|
|
100
|
+
collapsed?: string[];
|
|
68
101
|
/** Which column it landed in, counting from the left. */
|
|
69
102
|
layer: number;
|
|
70
103
|
/** What flows through it: the larger of what arrives and what leaves. */
|
|
@@ -89,7 +122,10 @@ export interface SankeyLayoutLink {
|
|
|
89
122
|
value: number;
|
|
90
123
|
/** How thick the ribbon is, in points. */
|
|
91
124
|
width: number;
|
|
92
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* The ribbon's centre where it leaves the source, across the flow — points
|
|
127
|
+
* from the top of an upright layout, and from the left of a transposed one.
|
|
128
|
+
*/
|
|
93
129
|
y0: number;
|
|
94
130
|
/** And where it meets the target. */
|
|
95
131
|
y1: number;
|
|
@@ -374,6 +410,208 @@ function resolveCollisions(column: Node[], alpha: number, padding: number, heigh
|
|
|
374
410
|
* cannot be drawn — no nodes, no room, nothing carrying a value — comes back
|
|
375
411
|
* empty rather than as a diagram of zeroes.
|
|
376
412
|
*/
|
|
413
|
+
/**
|
|
414
|
+
* A bucket's id, chosen so it cannot be one the caller already used.
|
|
415
|
+
*
|
|
416
|
+
* Suffixed rather than prefixed with something unlikely: the id is what the
|
|
417
|
+
* chart falls back to for the name, so a readable one means a bucket reads as
|
|
418
|
+
* "Other" without anybody registering a label for it.
|
|
419
|
+
*/
|
|
420
|
+
function bucketId(label: string, taken: Set<string>, layer: number): string {
|
|
421
|
+
if (!taken.has(label)) return label;
|
|
422
|
+
let candidate = `${label} (${layer + 1})`;
|
|
423
|
+
let n = 2;
|
|
424
|
+
while (taken.has(candidate)) {
|
|
425
|
+
candidate = `${label} (${layer + 1}.${n})`;
|
|
426
|
+
n += 1;
|
|
427
|
+
}
|
|
428
|
+
return candidate;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface CollapsePlan {
|
|
432
|
+
nodes: SankeyLayoutNodeInput[];
|
|
433
|
+
links: SankeyLayoutLinkInput[];
|
|
434
|
+
/** Bucket id -> the caller ids folded into it. */
|
|
435
|
+
buckets: Map<string, string[]>;
|
|
436
|
+
/** New node id -> the caller's index, or `-1` for a bucket. */
|
|
437
|
+
origin: Map<string, number>;
|
|
438
|
+
/** New link position -> the caller's row, or `-1` where rows were merged. */
|
|
439
|
+
linkOrigin: number[];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Which nodes to fold, and the graph that results.
|
|
444
|
+
*
|
|
445
|
+
* Worked out from a finished layout rather than from the raw rows, because
|
|
446
|
+
* "smallest in its column" needs the columns, and the columns come from the
|
|
447
|
+
* links. So the flow is solved once to find out where everything landed, the
|
|
448
|
+
* tail is folded, and it is solved again — twice through a few hundred
|
|
449
|
+
* microseconds of arithmetic, against a diagram nobody can read.
|
|
450
|
+
*
|
|
451
|
+
* Returns `null` when there is nothing worth folding. One node in a bucket is
|
|
452
|
+
* not a bucket, it is a rename, so a column only collapses where at least two
|
|
453
|
+
* of its nodes go in.
|
|
454
|
+
*/
|
|
455
|
+
function planCollapse(
|
|
456
|
+
layout: SankeyLayout,
|
|
457
|
+
nodeInput: readonly SankeyLayoutNodeInput[],
|
|
458
|
+
linkInput: readonly SankeyLayoutLinkInput[],
|
|
459
|
+
collapse: SankeyCollapse
|
|
460
|
+
): CollapsePlan | null {
|
|
461
|
+
const label = collapse.label ?? 'Other';
|
|
462
|
+
const maxPerColumn =
|
|
463
|
+
typeof collapse.maxPerColumn === 'number' && collapse.maxPerColumn >= 1
|
|
464
|
+
? Math.floor(collapse.maxPerColumn)
|
|
465
|
+
: Infinity;
|
|
466
|
+
const minShare =
|
|
467
|
+
typeof collapse.minShare === 'number' && collapse.minShare > 0 ? collapse.minShare : 0;
|
|
468
|
+
if (maxPerColumn === Infinity && minShare === 0) return null;
|
|
469
|
+
|
|
470
|
+
const byLayer = new Map<number, SankeyLayoutNode[]>();
|
|
471
|
+
for (const node of layout.nodes) {
|
|
472
|
+
const column = byLayer.get(node.layer);
|
|
473
|
+
if (column) column.push(node);
|
|
474
|
+
else byLayer.set(node.layer, [node]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Caller id -> the bucket it was folded into. */
|
|
478
|
+
const folded = new Map<string, string>();
|
|
479
|
+
const buckets = new Map<string, string[]>();
|
|
480
|
+
const taken = new Set(layout.nodes.map((node) => node.id));
|
|
481
|
+
|
|
482
|
+
for (const [layer, column] of byLayer) {
|
|
483
|
+
if (column.length < 2) continue;
|
|
484
|
+
let total = 0;
|
|
485
|
+
for (const node of column) total += node.value;
|
|
486
|
+
if (!(total > 0)) continue;
|
|
487
|
+
|
|
488
|
+
// Largest first, so "the ones that go" is always a tail of the list and
|
|
489
|
+
// the two rules can be applied to the same ordering.
|
|
490
|
+
const ranked = [...column].sort((a, b) => b.value - a.value || a.id.localeCompare(b.id));
|
|
491
|
+
const doomed = new Set<string>();
|
|
492
|
+
if (minShare > 0) {
|
|
493
|
+
for (const node of ranked) {
|
|
494
|
+
if (node.value / total < minShare) doomed.add(node.id);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (ranked.length > maxPerColumn) {
|
|
498
|
+
// The bucket takes one of the places, so only `maxPerColumn - 1` survive.
|
|
499
|
+
for (const node of ranked.slice(Math.max(0, maxPerColumn - 1))) doomed.add(node.id);
|
|
500
|
+
}
|
|
501
|
+
if (doomed.size < 2) continue;
|
|
502
|
+
|
|
503
|
+
const id = bucketId(label, taken, layer);
|
|
504
|
+
taken.add(id);
|
|
505
|
+
const members = ranked.filter((node) => doomed.has(node.id)).map((node) => node.id);
|
|
506
|
+
buckets.set(id, members);
|
|
507
|
+
for (const member of members) folded.set(member, id);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!buckets.size) return null;
|
|
511
|
+
|
|
512
|
+
const origin = new Map<string, number>();
|
|
513
|
+
const nodes: SankeyLayoutNodeInput[] = [];
|
|
514
|
+
const emitted = new Set<string>();
|
|
515
|
+
for (const [index, input] of nodeInput.entries()) {
|
|
516
|
+
const bucket = folded.get(input.id);
|
|
517
|
+
if (bucket === undefined) {
|
|
518
|
+
if (emitted.has(input.id)) continue;
|
|
519
|
+
emitted.add(input.id);
|
|
520
|
+
nodes.push(input);
|
|
521
|
+
origin.set(input.id, index);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// The bucket takes the place of the first of its members, so a column's
|
|
525
|
+
// order is still the order the caller wrote.
|
|
526
|
+
if (emitted.has(bucket)) continue;
|
|
527
|
+
emitted.add(bucket);
|
|
528
|
+
// No pinned value: a bucket is worth what its links carry, and summing
|
|
529
|
+
// pins would state a total none of the rows support.
|
|
530
|
+
nodes.push({ id: bucket });
|
|
531
|
+
origin.set(bucket, -1);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/*
|
|
535
|
+
* The rows, with folded endpoints renamed to their bucket.
|
|
536
|
+
*
|
|
537
|
+
* Two rows that now name the same pair are added together — that is the
|
|
538
|
+
* whole point of a bucket, and leaving them separate would stack a dozen
|
|
539
|
+
* hairlines between the same two bars instead of one ribbon. A row whose
|
|
540
|
+
* ends both landed in one bucket described a flow inside it and has nowhere
|
|
541
|
+
* left to go, so it is taken out here rather than being failed by the second
|
|
542
|
+
* pass and counted a second time.
|
|
543
|
+
*
|
|
544
|
+
* Rows that were already undrawable pass through untouched, so the second
|
|
545
|
+
* pass fails them for the reason the first one did.
|
|
546
|
+
*/
|
|
547
|
+
const rename = (id: string) => folded.get(id) ?? id;
|
|
548
|
+
const links: SankeyLayoutLinkInput[] = [];
|
|
549
|
+
const linkOrigin: number[] = [];
|
|
550
|
+
const mergedAt = new Map<string, number>();
|
|
551
|
+
for (const [index, row] of linkInput.entries()) {
|
|
552
|
+
const source = rename(row.source);
|
|
553
|
+
const target = rename(row.target);
|
|
554
|
+
const usable =
|
|
555
|
+
origin.has(source) &&
|
|
556
|
+
origin.has(target) &&
|
|
557
|
+
source !== target &&
|
|
558
|
+
typeof row.value === 'number' &&
|
|
559
|
+
Number.isFinite(row.value) &&
|
|
560
|
+
row.value > 0;
|
|
561
|
+
|
|
562
|
+
if (!usable) {
|
|
563
|
+
if (folded.has(row.source) && folded.has(row.target) && source === target) continue;
|
|
564
|
+
links.push(row);
|
|
565
|
+
linkOrigin.push(index);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const key = `${source}\u0000${target}`;
|
|
570
|
+
const at = mergedAt.get(key);
|
|
571
|
+
if (at === undefined) {
|
|
572
|
+
mergedAt.set(key, links.length);
|
|
573
|
+
links.push({ source, target, value: row.value });
|
|
574
|
+
linkOrigin.push(index);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
links[at] = { source, target, value: links[at]!.value + row.value };
|
|
578
|
+
// Merged: no single row of the caller's describes it any more.
|
|
579
|
+
linkOrigin[at] = -1;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return { nodes, links, buckets, origin, linkOrigin };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The same arrangement, read down the screen instead of across it.
|
|
587
|
+
*
|
|
588
|
+
* A flow diagram has one axis carrying the order of the stages and another
|
|
589
|
+
* carrying the values, and which of them is horizontal is a question about the
|
|
590
|
+
* screen rather than about the data. So the layout is solved once, upright,
|
|
591
|
+
* and turned afterwards — the alternative is a second copy of the relaxation
|
|
592
|
+
* with every comparison reversed, which is the same maths maintained twice.
|
|
593
|
+
*
|
|
594
|
+
* Reflecting about the diagonal, so it is its own inverse: transposing twice
|
|
595
|
+
* returns the layout unchanged. Call `sankeyLayout` with `width` and `height`
|
|
596
|
+
* swapped and then pass the result through here, or the diagram comes back
|
|
597
|
+
* fitted to the wrong box.
|
|
598
|
+
*/
|
|
599
|
+
export function transposeLayout(layout: SankeyLayout): SankeyLayout {
|
|
600
|
+
return {
|
|
601
|
+
...layout,
|
|
602
|
+
nodes: layout.nodes.map((node) => ({
|
|
603
|
+
...node,
|
|
604
|
+
x0: node.y0,
|
|
605
|
+
x1: node.y1,
|
|
606
|
+
y0: node.x0,
|
|
607
|
+
y1: node.x1,
|
|
608
|
+
})),
|
|
609
|
+
// A link's `y0`/`y1` are already across the flow rather than along it, so
|
|
610
|
+
// the axis they name changes while the numbers do not.
|
|
611
|
+
links: layout.links.map((link) => ({ ...link })),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
377
615
|
export function sankeyLayout(
|
|
378
616
|
nodeInput: readonly SankeyLayoutNodeInput[],
|
|
379
617
|
linkInput: readonly SankeyLayoutLinkInput[],
|
|
@@ -385,6 +623,42 @@ export function sankeyLayout(
|
|
|
385
623
|
|
|
386
624
|
if (!nodeInput.length || !(width > 0) || !(height > 0)) return EMPTY;
|
|
387
625
|
|
|
626
|
+
/*
|
|
627
|
+
* Folding the tail needs the columns, and the columns come out of the links,
|
|
628
|
+
* so the only way to know what to fold is to lay it out once and look. The
|
|
629
|
+
* second pass runs with `collapse` cleared, which is what stops this
|
|
630
|
+
* recurring.
|
|
631
|
+
*/
|
|
632
|
+
if (options.collapse) {
|
|
633
|
+
const bare = { ...options, collapse: undefined };
|
|
634
|
+
const first = sankeyLayout(nodeInput, linkInput, bare);
|
|
635
|
+
const plan = planCollapse(first, nodeInput, linkInput, options.collapse);
|
|
636
|
+
if (plan) {
|
|
637
|
+
const second = sankeyLayout(plan.nodes, plan.links, bare);
|
|
638
|
+
return {
|
|
639
|
+
...second,
|
|
640
|
+
nodes: second.nodes.map((node) => {
|
|
641
|
+
const members = plan.buckets.get(node.id);
|
|
642
|
+
return {
|
|
643
|
+
...node,
|
|
644
|
+
index: plan.origin.get(node.id) ?? -1,
|
|
645
|
+
...(members ? { collapsed: members } : {}),
|
|
646
|
+
};
|
|
647
|
+
}),
|
|
648
|
+
links: second.links.map((link) => ({
|
|
649
|
+
...link,
|
|
650
|
+
input: plan.linkOrigin[link.input] ?? -1,
|
|
651
|
+
})),
|
|
652
|
+
/*
|
|
653
|
+
* Counted from the pass that saw the caller's own rows. The second
|
|
654
|
+
* pass reads a graph this function invented, and how many of those
|
|
655
|
+
* failed is not something anybody asked about.
|
|
656
|
+
*/
|
|
657
|
+
dropped: first.dropped,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
388
662
|
const nodes: Node[] = [];
|
|
389
663
|
const byId = new Map<string, Node>();
|
|
390
664
|
for (const [index, input] of nodeInput.entries()) {
|
|
@@ -44,6 +44,23 @@ export interface SpinnerProps extends VariantProps<typeof spinnerVariants> {
|
|
|
44
44
|
* the spinner is the only sign, or the wait passes in silence.
|
|
45
45
|
*/
|
|
46
46
|
label?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The colour of the turning arc. Any colour React Native accepts — a hex
|
|
49
|
+
* string, `rgb()`, a named colour. Leave it unset to follow the theme's
|
|
50
|
+
* primary colour.
|
|
51
|
+
*
|
|
52
|
+
* Set it where the spinner sits on a surface the theme did not choose for
|
|
53
|
+
* it: a spinner on a filled button, or over a photo, wants the colour of the
|
|
54
|
+
* text around it rather than the accent.
|
|
55
|
+
*/
|
|
56
|
+
color?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The colour of the faint ring the arc turns around. Leave it unset for the
|
|
59
|
+
* theme's muted colour. Worth setting alongside `color` on a dark or
|
|
60
|
+
* coloured surface, where the default ring can read as a second, fainter
|
|
61
|
+
* spinner rather than as a track.
|
|
62
|
+
*/
|
|
63
|
+
trackColor?: string;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
@@ -64,6 +81,8 @@ export const Spinner = memo(function Spinner({
|
|
|
64
81
|
className,
|
|
65
82
|
size,
|
|
66
83
|
label,
|
|
84
|
+
color,
|
|
85
|
+
trackColor,
|
|
67
86
|
}: SpinnerProps) {
|
|
68
87
|
const reducedMotion = useReducedMotion();
|
|
69
88
|
const progress = useSharedValue(0);
|
|
@@ -113,7 +132,14 @@ export const Spinner = memo(function Spinner({
|
|
|
113
132
|
accessibilityState={announced ? { busy: true } : undefined}
|
|
114
133
|
accessibilityElementsHidden={!announced}
|
|
115
134
|
importantForAccessibility={announced ? 'auto' : 'no-hide-descendants'}
|
|
116
|
-
|
|
135
|
+
// After the class, so a colour passed here wins over the theme's. The
|
|
136
|
+
// track goes on every side and the arc on the top, the same split the
|
|
137
|
+
// classes make, so setting one never repaints the other.
|
|
138
|
+
style={[
|
|
139
|
+
trackColor ? { borderColor: trackColor } : null,
|
|
140
|
+
color ? { borderTopColor: color } : null,
|
|
141
|
+
animatedStyle,
|
|
142
|
+
]}
|
|
117
143
|
className={spinnerVariants({ size, className })}
|
|
118
144
|
/>
|
|
119
145
|
);
|