panelui-native 0.101.0 → 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 (48) hide show
  1. package/lib/module/components/card/index.js +1 -1
  2. package/lib/module/components/card/index.js.map +1 -1
  3. package/lib/module/components/planner/index.js +8 -1
  4. package/lib/module/components/planner/index.js.map +1 -1
  5. package/lib/module/components/sankey-chart/index.js +801 -165
  6. package/lib/module/components/sankey-chart/index.js.map +1 -1
  7. package/lib/module/components/sankey-chart/sankey-layout.js +226 -0
  8. package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -1
  9. package/lib/module/components/spinner/index.js +13 -3
  10. package/lib/module/components/spinner/index.js.map +1 -1
  11. package/lib/module/components/support/index.js +842 -0
  12. package/lib/module/components/support/index.js.map +1 -0
  13. package/lib/module/components/typography/index.js +10 -2
  14. package/lib/module/components/typography/index.js.map +1 -1
  15. package/lib/module/icons/index.js +40 -0
  16. package/lib/module/icons/index.js.map +1 -1
  17. package/lib/module/index.js +2 -1
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/utils/chart.js +24 -0
  20. package/lib/module/utils/chart.js.map +1 -1
  21. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/sankey-chart/index.d.ts +131 -5
  23. package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +49 -6
  25. package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -1
  26. package/lib/typescript/src/components/spinner/index.d.ts +18 -1
  27. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/support/index.d.ts +376 -0
  29. package/lib/typescript/src/components/support/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/typography/index.d.ts +0 -3
  31. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  32. package/lib/typescript/src/icons/index.d.ts +8 -0
  33. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -2
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/lib/typescript/src/utils/chart.d.ts +11 -0
  37. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/components/card/index.tsx +1 -1
  40. package/src/components/planner/index.tsx +8 -1
  41. package/src/components/sankey-chart/index.tsx +935 -172
  42. package/src/components/sankey-chart/sankey-layout.ts +276 -2
  43. package/src/components/spinner/index.tsx +27 -1
  44. package/src/components/support/index.tsx +877 -0
  45. package/src/components/typography/index.tsx +10 -2
  46. package/src/icons/index.tsx +16 -0
  47. package/src/index.ts +35 -0
  48. 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 { compactNumber, flowPath, seriesColorAt, useSeriesColor } from '../../utils/chart';
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 { sankeyLayout, type SankeyAlign, type SankeyLayout } from './sankey-layout';
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 { activeId: null, activeNode: null, activeValue: 0, incoming: 0, outgoing: 0 };
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) incoming += link.value;
240
- if (link.source === position) outgoing += link.value;
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.find((node) => node.id === activeId) ?? null,
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 = DEFAULT_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 layout = useMemo(
359
- () =>
360
- sankeyLayout(nodes, links, {
361
- width,
362
- height,
363
- nodeWidth,
364
- nodePadding,
365
- align,
366
- iterations,
367
- }),
368
- [nodes, links, width, height, nodeWidth, nodePadding, align, iterations]
369
- );
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. With
519
- * `Labels` there is a pressable row per node already, and the semantic
520
- * list below would say all of it a second time; without them the diagram
521
- * is pure geometry and the list is the only way through it.
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={nodes}
578
- disabled={labelled || status === 'loading'}
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
- x0={source.x1}
658
- cy0={link.y0}
659
- x1={target.x0}
660
- cy1={link.y1}
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={links[link.input]?.color ?? colors[link.source] ?? colors[0] ?? '#3b82f6'}
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
- x0,
680
- cy0,
681
- x1,
682
- cy1,
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
- x0: number;
691
- cy0: number;
692
- x1: number;
693
- cy1: number;
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, from, to);
1037
+ const grown = thickness * progress(reveal.value, windowFrom, windowTo);
713
1038
  return {
714
- d: flowPath(x0, cy0, x1, cy1, grown, curve),
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({ radius = 2, dimOpacity = 0.25 }: SankeyChartNodesProps) {
737
- const { layout, colors, reveal, windows, status, activeId } =
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
- x={node.x0}
750
- width={node.x1 - node.x0}
751
- y0={node.y0}
752
- y1={node.y1}
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 || activeId === node.id ? 1 : dimOpacity}
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
- x,
769
- width,
770
- y0,
771
- y1,
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
- x: number;
779
- width: number;
780
- y0: number;
781
- y1: number;
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 = y1 - y0;
790
- const centre = (y0 + y1) / 2;
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
- return { y: centre - grown / 2, height: grown, opacity: settled.value };
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
- return <AnimatedRect animatedProps={animatedProps} x={x} width={width} rx={radius} fill={fill} />;
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 { layout, nodes, width, height, status, activeId, setActiveId, labelFor } =
845
- useChart('SankeyChart.Labels');
846
-
847
- if (status === 'loading' || !layout.nodes.length) return null;
848
-
849
- const format = formatValue ?? ((value: number) => compactNumber(value));
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 the neighbouring columns sit, in points across the plot.
853
- *
854
- * A name's row needs a bound on both sides or it runs the width of the chart
855
- * and covers every row it crosses — and since the rows are absolutely
856
- * positioned siblings, the last one drawn takes the touch. That is a tap on
857
- * one name selecting a node two columns away, which is worse than a small
858
- * target because it is wrong rather than merely hard.
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 edges: number[] = [];
861
- for (const placed of layout.nodes) {
862
- if (!edges.includes(placed.x0)) edges.push(placed.x0);
863
- if (!edges.includes(placed.x1)) edges.push(placed.x1);
864
- }
865
- edges.sort((a, b) => a - b);
866
- const nextEdge = (x: number) => edges.find((edge) => edge > x + 1e-6);
867
- const previousEdge = (x: number) => {
868
- let found: number | undefined;
869
- for (const edge of edges) if (edge < x - 1e-6) found = edge;
870
- return found;
871
- };
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
- {layout.nodes.map((node) => {
876
- const datum = nodes[node.index];
877
- if (!datum) return null;
878
-
879
- const extent = node.y1 - node.y0;
880
- if (extent < minHeight) return null;
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, datum);
1425
+ const value = format(node.value, datumFor(node));
894
1426
  const selected = activeId === node.id;
895
1427
 
896
1428
  /*
897
- * A sliver's row is padded out to a real target rather than drawn
898
- * taller — growing the row would push it over its neighbours, and two
899
- * overlapping targets are worse than a small one.
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 - extent) / 2);
902
- const top = Math.max(0, Math.min(node.y0, height - extent));
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
- top,
915
- height: extent,
916
- justifyContent: 'center',
917
- /*
918
- * Each row takes the half of its gap nearest its own bar, so the
919
- * name leaving one column and the name arriving at the next can
920
- * share the space between them without sharing a touch target.
921
- */
922
- ...(trailing
923
- ? (() => {
924
- const from = before === undefined ? 0 : (before + node.x0) / 2;
925
- return {
926
- left: from,
927
- width: Math.max(0, node.x0 - LABEL_GAP - from),
928
- alignItems: 'flex-end' as const,
929
- };
930
- })()
931
- : (() => {
932
- const from = node.x1 + LABEL_GAP;
933
- const to = after === undefined ? width : (node.x1 + after) / 2;
934
- return { left: from, width: Math.max(0, to - from) };
935
- })()),
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 row and ends up sitting in the middle of the plot instead of
944
- * against the bar it belongs to. Which side the name hugs is a
945
- * fact about where its bar is, not about the language.
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
- <Text
948
- size="xs"
949
- weight={selected ? 'bold' : 'medium'}
950
- numberOfLines={1}
951
- style={{ textAlign: trailing ? 'right' : 'left' }}
952
- >
953
- {name}
954
- </Text>
955
- {showValue ? (
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
- muted
1488
+ weight={selected ? 'bold' : 'medium'}
959
1489
  numberOfLines={1}
960
- style={{ textAlign: trailing ? 'right' : 'left' }}
1490
+ style={{ textAlign: align }}
961
1491
  >
962
- {value}
1492
+ {name}
963
1493
  </Text>
964
- ) : null}
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 trailing = node.x1 + LABEL_GAP + CARD > width;
1003
- const left = trailing
1004
- ? Math.max(0, node.x0 - LABEL_GAP - CARD)
1005
- : Math.min(node.x1 + LABEL_GAP, Math.max(0, width - CARD));
1006
- const centre = (node.y0 + node.y1) / 2;
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
- top: Math.max(0, Math.min(centre - 34, height - 68)),
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 = (width - bar) / Math.max(SKELETON_COLUMNS - 1, 1);
1066
- const band = height / 3;
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(flowPath(from, height / 3, to, height / 3, band * 0.5, curve));
1073
- ribbons.push(flowPath(from, (height * 2) / 3, to, (height * 2) / 3, band * 0.5, curve));
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((x, index) => (
1846
+ {shape.columns.map((at, index) => (
1086
1847
  <Rect
1087
1848
  key={`bar-${index}`}
1088
- x={x}
1089
- y={height / 6}
1090
- width={shape.bar}
1091
- height={(height * 2) / 3}
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,