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.
Files changed (36) hide show
  1. package/lib/module/components/sankey-chart/index.js +801 -165
  2. package/lib/module/components/sankey-chart/index.js.map +1 -1
  3. package/lib/module/components/sankey-chart/sankey-layout.js +226 -0
  4. package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -1
  5. package/lib/module/components/spinner/index.js +13 -3
  6. package/lib/module/components/spinner/index.js.map +1 -1
  7. package/lib/module/components/support/index.js +842 -0
  8. package/lib/module/components/support/index.js.map +1 -0
  9. package/lib/module/icons/index.js +40 -0
  10. package/lib/module/icons/index.js.map +1 -1
  11. package/lib/module/index.js +2 -1
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/utils/chart.js +24 -0
  14. package/lib/module/utils/chart.js.map +1 -1
  15. package/lib/typescript/src/components/sankey-chart/index.d.ts +131 -5
  16. package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -1
  17. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +49 -6
  18. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -1
  19. package/lib/typescript/src/components/spinner/index.d.ts +18 -1
  20. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
  21. package/lib/typescript/src/components/support/index.d.ts +376 -0
  22. package/lib/typescript/src/components/support/index.d.ts.map +1 -0
  23. package/lib/typescript/src/icons/index.d.ts +8 -0
  24. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  25. package/lib/typescript/src/index.d.ts +3 -2
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/lib/typescript/src/utils/chart.d.ts +11 -0
  28. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/components/sankey-chart/index.tsx +935 -172
  31. package/src/components/sankey-chart/sankey-layout.ts +276 -2
  32. package/src/components/spinner/index.tsx +27 -1
  33. package/src/components/support/index.tsx +877 -0
  34. package/src/icons/index.tsx +16 -0
  35. package/src/index.ts +35 -0
  36. 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
- /** Position in the input array, so a caller can find its own datum again. */
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
- /** The ribbon's centre where it leaves the source, in points from the top. */
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
- style={animatedStyle}
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
  );