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
@@ -67,11 +67,13 @@ import Svg, { G, Path, Rect } from 'react-native-svg';
67
67
  import { useCSSVariable } from 'uniwind';
68
68
  import { ChartAccessibilityData } from "../../primitives/chart-accessibility.js";
69
69
  import { Text } from "../../primitives/text.js";
70
- import { compactNumber, flowPath, seriesColorAt, useSeriesColor } from "../../utils/chart.js";
70
+ import { compactNumber, flowPath, flowPathVertical, seriesColorAt, useSeriesColor } from "../../utils/chart.js";
71
71
  import { cn } from "../../utils/cn.js";
72
72
  import { useDirection } from "../../hooks/use-direction.js";
73
73
  import { useSkeletonHandoff } from "../../hooks/use-skeleton-handoff.js";
74
- import { sankeyLayout } from "./sankey-layout.js";
74
+ import { sankeyLayout, transposeLayout } from "./sankey-layout.js";
75
+
76
+ /** Which way the flow runs. */
75
77
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
76
78
  const AnimatedPath = Animated.createAnimatedComponent(Path);
77
79
  const AnimatedRect = Animated.createAnimatedComponent(Rect);
@@ -80,6 +82,17 @@ const AnimatedG = Animated.createAnimatedComponent(G);
80
82
  /** How tall the diagram is drawn when the caller does not say. */
81
83
  const DEFAULT_HEIGHT = 240;
82
84
 
85
+ /**
86
+ * How much depth one stage of an upright flow is given when nothing says.
87
+ *
88
+ * A vertical diagram's height is the length of the flow, so unlike the
89
+ * horizontal case it grows with the data: four stages in the height of three
90
+ * is a set of bars with no room for a ribbon between them. Enough for a bar,
91
+ * a name on each side of it, and a run of ribbon long enough to read as one —
92
+ * and no more, because on a phone every extra point of it is a scroll.
93
+ */
94
+ const VERTICAL_STAGE = 104;
95
+
83
96
  /** How thick a node's bar is. Thin enough to read as an edge the flow meets. */
84
97
  const DEFAULT_NODE_WIDTH = 10;
85
98
 
@@ -122,6 +135,28 @@ const LABEL_GAP = 6;
122
135
  /** The smallest a label's press target is allowed to be, in points. */
123
136
  const MIN_TARGET = 44;
124
137
 
138
+ /**
139
+ * Roughly how wide a name is drawn, in points, backdrop included.
140
+ *
141
+ * An estimate rather than a measurement, because measuring every name means a
142
+ * render to find out where to render them. Deliberately on the generous side:
143
+ * guessing short is how a name ends up cut off, and guessing long only costs a
144
+ * few points of the row.
145
+ */
146
+ function nameWidth(name) {
147
+ return name.length * 7 + 18;
148
+ }
149
+
150
+ /**
151
+ * How much height one line of a name takes, in points, backdrop included.
152
+ *
153
+ * A name is centred on its bar, and a bar thinner than this carries a name
154
+ * taller than itself — which is fine until two of them sit close enough for
155
+ * the text to run into each other. So names are placed as boxes of this
156
+ * height, not of the bar's.
157
+ */
158
+ const LABEL_LINE = 20;
159
+
125
160
  /** Columns the placeholder suggests while there is no data to count. */
126
161
  const SKELETON_COLUMNS = 3;
127
162
 
@@ -147,12 +182,16 @@ function useChart(component) {
147
182
  return context;
148
183
  }
149
184
 
185
+ /** One end of a stream meeting the selected node. */
186
+
150
187
  /** The selected node and what runs through it, for something drawn inside the chart. */
151
188
  export function useSankeyChart() {
152
189
  const {
153
190
  nodes,
154
191
  layout,
155
- activeId
192
+ activeId,
193
+ colors,
194
+ labelFor
156
195
  } = useChart('useSankeyChart');
157
196
  return useMemo(() => {
158
197
  const placed = activeId ? layout.nodes.find(node => node.id === activeId) : undefined;
@@ -162,27 +201,67 @@ export function useSankeyChart() {
162
201
  activeNode: null,
163
202
  activeValue: 0,
164
203
  incoming: 0,
165
- outgoing: 0
204
+ outgoing: 0,
205
+ sources: [],
206
+ targets: [],
207
+ collapsed: null
166
208
  };
167
209
  }
168
210
  const position = layout.nodes.indexOf(placed);
169
211
  let incoming = 0;
170
212
  let outgoing = 0;
213
+ const sources = [];
214
+ const targets = [];
215
+ const flow = (at, value) => {
216
+ const other = layout.nodes[at];
217
+ if (!other) return null;
218
+ return {
219
+ id: other.id,
220
+ label: labelFor(other.id),
221
+ value,
222
+ // Against what passes through the node rather than against the side's
223
+ // own total, so in and out are read on one scale — at a node that
224
+ // loses some of what it received, two totals that differ is the point.
225
+ share: placed.value > 0 ? value / placed.value : 0,
226
+ color: colors[at] ?? colors[0] ?? '#3b82f6'
227
+ };
228
+ };
171
229
  for (const link of layout.links) {
172
- if (link.target === position) incoming += link.value;
173
- if (link.source === position) outgoing += link.value;
230
+ if (link.target === position) {
231
+ incoming += link.value;
232
+ const row = flow(link.source, link.value);
233
+ if (row) sources.push(row);
234
+ }
235
+ if (link.source === position) {
236
+ outgoing += link.value;
237
+ const row = flow(link.target, link.value);
238
+ if (row) targets.push(row);
239
+ }
174
240
  }
241
+
242
+ // Largest first: the reason to open a breakdown is to find out what the
243
+ // biggest part of it was.
244
+ sources.sort((a, b) => b.value - a.value);
245
+ targets.sort((a, b) => b.value - a.value);
175
246
  return {
176
247
  activeId,
177
- activeNode: nodes.find(node => node.id === activeId) ?? null,
248
+ activeNode: nodes[placed.index] ?? {
249
+ id: placed.id
250
+ },
178
251
  /** What passes through it — what the bar's height is drawn from. */
179
252
  activeValue: placed.value,
180
253
  /** What arrives. Zero at a node the flow starts from. */
181
254
  incoming,
182
255
  /** What leaves. Zero at a node the flow ends at. */
183
- outgoing
256
+ outgoing,
257
+ /** Where it came from, largest first. */
258
+ sources,
259
+ /** Where it went, largest first. */
260
+ targets,
261
+ /** The ids `collapse` folded in, on a bucket. `null` on every other node. */
262
+ collapsed: placed.collapsed ?? null
184
263
  };
185
- }, [nodes, layout, activeId]);
264
+ }, [nodes, layout, activeId, colors, labelFor]);
186
265
  }
187
266
 
188
267
  /** Imperative handle: re-run the entrance, for a "replay" control. */
@@ -191,9 +270,11 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
191
270
  className,
192
271
  nodes,
193
272
  links,
194
- height = DEFAULT_HEIGHT,
273
+ height: heightProp,
195
274
  nodeWidth = DEFAULT_NODE_WIDTH,
196
275
  nodePadding = DEFAULT_NODE_PADDING,
276
+ orientation = 'horizontal',
277
+ collapse,
197
278
  align = 'justify',
198
279
  iterations = DEFAULT_ITERATIONS,
199
280
  curve = CURVE,
@@ -207,6 +288,7 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
207
288
  accessibilityLabel,
208
289
  accessibilityHint,
209
290
  accessibilityLabelForDatum,
291
+ accessibilityLabelForLink,
210
292
  onAccessibilityDatumPress,
211
293
  children,
212
294
  ...props
@@ -222,22 +304,57 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
222
304
  if (!controlled) setInternalActive(id);
223
305
  onActiveIdChange?.(id);
224
306
  }, [controlled, onActiveIdChange]);
225
- const layout = useMemo(() => sankeyLayout(nodes, links, {
226
- width,
227
- height,
228
- nodeWidth,
229
- nodePadding,
230
- align,
231
- iterations
232
- }), [nodes, links, width, height, nodeWidth, nodePadding, align, iterations]);
307
+ const upright = orientation === 'horizontal';
308
+
309
+ /*
310
+ * How many stages the flow needs, for the case where the height has to be
311
+ * worked out from it. Solved with no relaxation rounds and in an arbitrary
312
+ * box, because the column count falls out of the links alone — the
313
+ * arrangement inside the columns is exactly the part being skipped.
314
+ */
315
+ const stages = useMemo(() => {
316
+ if (upright || heightProp !== undefined) return 0;
317
+ return sankeyLayout(nodes, links, {
318
+ width: 1000,
319
+ height: 1000,
320
+ nodeWidth,
321
+ nodePadding,
322
+ align,
323
+ iterations: 0,
324
+ collapse
325
+ }).columns;
326
+ }, [upright, heightProp, nodes, links, nodeWidth, nodePadding, align, collapse]);
327
+ const height = heightProp ?? (upright ? DEFAULT_HEIGHT : Math.max(DEFAULT_HEIGHT, stages * VERTICAL_STAGE));
328
+ const layout = useMemo(() => {
329
+ /*
330
+ * An upright flow is solved in the box as given. A vertical one is
331
+ * solved in that box turned on its side and then turned back, so the
332
+ * stages advance down the screen — one set of maths, read the other way.
333
+ */
334
+ const solved = sankeyLayout(nodes, links, {
335
+ width: upright ? width : height,
336
+ height: upright ? height : width,
337
+ nodeWidth,
338
+ nodePadding,
339
+ align,
340
+ iterations,
341
+ collapse
342
+ });
343
+ return upright ? solved : transposeLayout(solved);
344
+ }, [nodes, links, width, height, nodeWidth, nodePadding, align, iterations, collapse, upright]);
233
345
 
234
346
  /*
235
347
  * A flow reads from where it starts, and under a right-to-left layout that
236
348
  * is the right-hand edge. Mirroring the finished layout rather than laying
237
349
  * it out backwards keeps one set of maths under both directions — the
238
350
  * arrangement is identical, it is only read from the other end.
351
+ *
352
+ * Only where the flow runs across the screen. A vertical one starts at the
353
+ * top in every script, and mirroring it would flip the axis carrying the
354
+ * values rather than the one carrying the order — the same diagram with
355
+ * its bars reflected, for no reason a reader could name.
239
356
  */
240
- const mirrored = direction === 'rtl';
357
+ const mirrored = direction === 'rtl' && upright;
241
358
  const placed = useMemo(() => {
242
359
  if (!mirrored || !layout.nodes.length) return layout;
243
360
  return {
@@ -270,6 +387,9 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
270
387
  const names = new Map(nodes.map(node => [node.id, node.label ?? node.id]));
271
388
  return id => names.get(id) ?? id;
272
389
  }, [nodes]);
390
+ const datumFor = useMemo(() => node => nodes[node.index] ?? {
391
+ id: node.id
392
+ }, [nodes]);
273
393
 
274
394
  /*
275
395
  * One clock, with each column given the slice of it that it draws in, so
@@ -324,6 +444,22 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
324
444
  const next = Math.round(event.nativeEvent.layout.width);
325
445
  if (next !== width) setWidth(next);
326
446
  };
447
+
448
+ /*
449
+ * The ids `SankeyChart.Labels` drew a name for, or `null` while it has not
450
+ * said. Compared rather than replaced, because Labels reports on every
451
+ * layout change and a fresh array each time would re-render the chart
452
+ * forever.
453
+ */
454
+ const [labelledIds, setLabelledIds] = useState(null);
455
+ const reportLabelled = useMemo(() => ids => setLabelledIds(current => {
456
+ if (current === ids) return current;
457
+ if (current === null || ids === null) return ids;
458
+ if (current.length === ids.length && current.every((id, i) => id === ids[i])) {
459
+ return current;
460
+ }
461
+ return ids;
462
+ }), []);
327
463
  const context = useMemo(() => ({
328
464
  nodes,
329
465
  links,
@@ -337,8 +473,11 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
337
473
  status,
338
474
  activeId: activeId ?? null,
339
475
  setActiveId,
340
- labelFor
341
- }), [nodes, links, placed, width, height, curve, colors, windows, reveal, status, activeId, setActiveId, labelFor]);
476
+ labelFor,
477
+ reportLabelled,
478
+ orientation,
479
+ datumFor
480
+ }), [nodes, links, placed, width, height, curve, colors, windows, reveal, status, activeId, setActiveId, labelFor, reportLabelled, orientation, datumFor]);
342
481
  const slots = {
343
482
  svg: [],
344
483
  overlay: [],
@@ -346,10 +485,14 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
346
485
  footer: []
347
486
  };
348
487
  /*
349
- * Whether the names are on the chart decides how it is read out. With
350
- * `Labels` there is a pressable row per node already, and the semantic
351
- * list below would say all of it a second time; without them the diagram
352
- * is pure geometry and the list is the only way through it.
488
+ * Whether the names are on the chart decides how it is read out. Without
489
+ * `Labels` the diagram is pure geometry and the semantic list is the only
490
+ * way through it. With them, most nodes are a pressable row already and
491
+ * the list would say it all a second time — but only most: a bar too short
492
+ * to label is left with no target and no entry, which is how the smallest
493
+ * nodes on a crowded diagram became unreachable by either route. So the
494
+ * list is narrowed to what `Labels` reports it dropped rather than turned
495
+ * off wholesale.
353
496
  */
354
497
  let labelled = false;
355
498
  Children.forEach(children, (child, index) => {
@@ -362,6 +505,21 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
362
505
  children: child
363
506
  }, index));
364
507
  });
508
+
509
+ /*
510
+ * The nodes the semantic list still has to carry.
511
+ *
512
+ * With no `Labels` that is all of them. With `Labels` it is the ones it
513
+ * reported dropping — and until it has reported, none: a name that turns
514
+ * out to exist is a duplicate entry, which is worse for one render than a
515
+ * missing one, and the report lands on the render straight after.
516
+ */
517
+ const spoken = useMemo(() => {
518
+ if (!labelled) return nodes;
519
+ if (labelledIds === null) return [];
520
+ const drawn = new Set(labelledIds);
521
+ return nodes.filter(node => !drawn.has(node.id));
522
+ }, [nodes, labelled, labelledIds]);
365
523
  return /*#__PURE__*/_jsx(SankeyChartContext.Provider, {
366
524
  value: context,
367
525
  children: /*#__PURE__*/_jsxs(View, {
@@ -393,13 +551,19 @@ const SankeyChartRoot = /*#__PURE__*/forwardRef(function SankeyChartRoot({
393
551
  }) : null
394
552
  }), slots.footer, /*#__PURE__*/_jsx(ChartAccessibilityData, {
395
553
  chart: "Flow diagram",
396
- data: nodes,
397
- disabled: labelled || status === 'loading',
554
+ data: spoken,
555
+ disabled: status === 'loading' || labelled && spoken.length === 0,
398
556
  valueOf: node => [['Node', node.label ?? node.id], ['Value', placed.nodes.find(placedNode => placedNode.id === node.id)?.value]],
399
557
  accessibilityLabel: accessibilityLabel,
400
558
  accessibilityHint: accessibilityHint,
401
559
  accessibilityLabelForDatum: accessibilityLabelForDatum,
402
560
  onAccessibilityDatumPress: onAccessibilityDatumPress
561
+ }), /*#__PURE__*/_jsx(LinkAccessibilityData, {
562
+ disabled: status === 'loading',
563
+ layout: placed,
564
+ links: links,
565
+ labelFor: labelFor,
566
+ labelForLink: accessibilityLabelForLink
403
567
  })]
404
568
  })
405
569
  });
@@ -413,6 +577,53 @@ function ChildSlot({
413
577
  });
414
578
  }
415
579
 
580
+ /**
581
+ * The ribbons, for a screen reader.
582
+ *
583
+ * Kept apart from the node list rather than folded into it, because the two
584
+ * are different readings and a caller overrides them separately: a node says
585
+ * how much passed through a stage, a ribbon says where it came from and where
586
+ * it went. A diagram read out as nodes alone is a list of totals with the
587
+ * routing — the thing the chart exists to show — missing from it.
588
+ *
589
+ * Offscreen rather than hidden, so the entries are reachable by swiping while
590
+ * nothing about the drawn diagram moves.
591
+ */
592
+ function LinkAccessibilityData({
593
+ disabled,
594
+ layout,
595
+ links,
596
+ labelFor,
597
+ labelForLink
598
+ }) {
599
+ if (disabled || !layout.links.length) return null;
600
+ return /*#__PURE__*/_jsx(View, {
601
+ style: {
602
+ position: 'absolute',
603
+ left: -10_000,
604
+ width: 1,
605
+ height: 1
606
+ },
607
+ children: layout.links.map(link => {
608
+ // `input` points back at the caller's row; `index` is the drawn order,
609
+ // which has closed up behind every row that could not be drawn.
610
+ // A ribbon that `collapse` merged has no single row of the caller's
611
+ // behind it, so there is nothing to hand an override — it still gets
612
+ // spoken, in the chart's own words.
613
+ const datum = link.input >= 0 ? links[link.input] : undefined;
614
+ const source = layout.nodes[link.source];
615
+ const target = layout.nodes[link.target];
616
+ if (!source || !target) return null;
617
+ const label = (datum ? labelForLink?.(datum, link.input) : undefined) ?? `${labelFor(source.id)} to ${labelFor(target.id)}, ${compactNumber(link.value)}`;
618
+ return /*#__PURE__*/_jsx(View, {
619
+ accessible: true,
620
+ accessibilityRole: "text",
621
+ accessibilityLabel: label
622
+ }, `${source.id}-${target.id}-${link.index}`);
623
+ })
624
+ });
625
+ }
626
+
416
627
  /**
417
628
  * How much of the entrance a column has played, eased.
418
629
  *
@@ -452,9 +663,11 @@ function SankeyChartLinks({
452
663
  reveal,
453
664
  windows,
454
665
  status,
455
- activeId
666
+ activeId,
667
+ orientation
456
668
  } = useChart('SankeyChart.Links');
457
669
  if (status === 'loading' || !layout.links.length) return null;
670
+ const upright = orientation === 'horizontal';
458
671
  return /*#__PURE__*/_jsx(G, {
459
672
  children: layout.links.map(link => {
460
673
  const source = layout.nodes[link.source];
@@ -466,13 +679,16 @@ function SankeyChartLinks({
466
679
  to: 1
467
680
  };
468
681
  return /*#__PURE__*/_jsx(Ribbon, {
469
- x0: source.x1,
470
- cy0: link.y0,
471
- x1: target.x0,
472
- cy1: link.y1,
682
+ // Where the ribbon leaves and arrives, along whichever axis the
683
+ // stages advance on; `link.y0`/`y1` are already across it.
684
+ from: upright ? source.x1 : source.y1,
685
+ cFrom: link.y0,
686
+ to: upright ? target.x0 : target.y0,
687
+ cTo: link.y1,
688
+ upright: upright,
473
689
  thickness: link.width,
474
690
  curve: curve,
475
- fill: links[link.input]?.color ?? colors[link.source] ?? colors[0] ?? '#3b82f6',
691
+ fill: (link.input >= 0 ? links[link.input]?.color : undefined) ?? colors[link.source] ?? colors[0] ?? '#3b82f6',
476
692
  reveal: reveal,
477
693
  window: window,
478
694
  opacity: activeId === null ? opacity : touching ? activeOpacity : dimOpacity
@@ -483,20 +699,21 @@ function SankeyChartLinks({
483
699
  SankeyChartLinks.displayName = 'SankeyChart.Links';
484
700
  SankeyChartLinks.slot = 'svg';
485
701
  function Ribbon({
486
- x0,
487
- cy0,
488
- x1,
489
- cy1,
702
+ from: edgeFrom,
703
+ cFrom: centreFrom,
704
+ to: edgeTo,
705
+ cTo: centreTo,
490
706
  thickness,
491
707
  curve,
492
708
  fill,
493
709
  reveal,
494
710
  window,
495
- opacity
711
+ opacity,
712
+ upright
496
713
  }) {
497
714
  const {
498
- from,
499
- to
715
+ from: windowFrom,
716
+ to: windowTo
500
717
  } = window;
501
718
  const settled = useDerivedValue(() => withTiming(opacity, {
502
719
  duration: SELECT_DURATION
@@ -507,9 +724,9 @@ function Ribbon({
507
724
  * one edge, so it stays anchored where it meets the node instead of
508
725
  * sliding down it as it arrives.
509
726
  */
510
- const grown = thickness * progress(reveal.value, from, to);
727
+ const grown = thickness * progress(reveal.value, windowFrom, windowTo);
511
728
  return {
512
- d: flowPath(x0, cy0, x1, cy1, grown, curve),
729
+ d: upright ? flowPath(edgeFrom, centreFrom, edgeTo, centreTo, grown, curve) : flowPathVertical(edgeFrom, centreFrom, edgeTo, centreTo, grown, curve),
513
730
  fillOpacity: settled.value
514
731
  };
515
732
  });
@@ -527,7 +744,8 @@ function Ribbon({
527
744
  */
528
745
  function SankeyChartNodes({
529
746
  radius = 2,
530
- dimOpacity = 0.25
747
+ dimOpacity = 0.25,
748
+ interactive = true
531
749
  }) {
532
750
  const {
533
751
  layout,
@@ -535,25 +753,33 @@ function SankeyChartNodes({
535
753
  reveal,
536
754
  windows,
537
755
  status,
538
- activeId
756
+ activeId,
757
+ setActiveId,
758
+ orientation
539
759
  } = useChart('SankeyChart.Nodes');
540
760
  if (status === 'loading' || !layout.nodes.length) return null;
761
+ const upright = orientation === 'horizontal';
541
762
  return /*#__PURE__*/_jsx(G, {
542
763
  children: layout.nodes.map((node, index) => {
543
764
  const window = windows[node.layer] ?? windows[0] ?? {
544
765
  from: 0,
545
766
  to: 1
546
767
  };
768
+ const selected = activeId === node.id;
547
769
  return /*#__PURE__*/_jsx(NodeBar, {
548
- x: node.x0,
549
- width: node.x1 - node.x0,
550
- y0: node.y0,
551
- y1: node.y1,
770
+ // The bar is `nodeWidth` across the flow and its value along it,
771
+ // so which pair of edges is which swaps with the orientation.
772
+ thin0: upright ? node.x0 : node.y0,
773
+ thin1: upright ? node.x1 : node.y1,
774
+ long0: upright ? node.y0 : node.x0,
775
+ long1: upright ? node.y1 : node.x1,
776
+ upright: upright,
552
777
  radius: radius,
553
778
  fill: colors[index] ?? '#3b82f6',
554
779
  reveal: reveal,
555
780
  window: window,
556
- opacity: activeId === null || activeId === node.id ? 1 : dimOpacity
781
+ opacity: activeId === null || selected ? 1 : dimOpacity,
782
+ onPress: interactive ? () => setActiveId(selected ? null : node.id) : undefined
557
783
  }, node.id);
558
784
  })
559
785
  });
@@ -561,40 +787,83 @@ function SankeyChartNodes({
561
787
  SankeyChartNodes.displayName = 'SankeyChart.Nodes';
562
788
  SankeyChartNodes.slot = 'svg';
563
789
  function NodeBar({
564
- x,
565
- width,
566
- y0,
567
- y1,
790
+ thin0,
791
+ thin1,
792
+ long0,
793
+ long1,
794
+ upright,
568
795
  radius,
569
796
  fill,
570
797
  reveal,
571
798
  window,
572
- opacity
799
+ opacity,
800
+ onPress
573
801
  }) {
574
802
  const {
575
803
  from,
576
804
  to
577
805
  } = window;
578
- const extent = y1 - y0;
579
- const centre = (y0 + y1) / 2;
806
+ const extent = long1 - long0;
807
+ const centre = (long0 + long1) / 2;
808
+ const thickness = thin1 - thin0;
580
809
  const settled = useDerivedValue(() => withTiming(opacity, {
581
810
  duration: SELECT_DURATION
582
811
  }));
583
812
  const animatedProps = useAnimatedProps(() => {
584
813
  // Grown about its centre, to match the ribbons meeting it.
585
814
  const grown = extent * progress(reveal.value, from, to);
586
- return {
587
- y: centre - grown / 2,
815
+ const near = centre - grown / 2;
816
+ return upright ? {
817
+ y: near,
588
818
  height: grown,
819
+ x: thin0,
820
+ width: thickness,
821
+ opacity: settled.value
822
+ } : {
823
+ x: near,
824
+ width: grown,
825
+ y: thin0,
826
+ height: thickness,
589
827
  opacity: settled.value
590
828
  };
591
829
  });
592
- return /*#__PURE__*/_jsx(AnimatedRect, {
830
+ const bar = /*#__PURE__*/_jsx(AnimatedRect, {
593
831
  animatedProps: animatedProps,
594
- x: x,
595
- width: width,
596
832
  rx: radius,
597
- fill: fill
833
+ fill: fill,
834
+ onPress: onPress
835
+ });
836
+ if (!onPress || extent >= MIN_TARGET) return bar;
837
+
838
+ /*
839
+ * A sliver gets a second, invisible rectangle to be pressed by, because the
840
+ * bar itself is two points tall and nobody can land on it. `transparent`
841
+ * rather than no fill: a shape with no fill is not hit-tested at all, so it
842
+ * would look identical and do nothing.
843
+ *
844
+ * It is drawn before the bar so the bar keeps the tap where the two overlap,
845
+ * and it grows about the same centre, which keeps the target over the bar
846
+ * rather than beside it.
847
+ */
848
+ const target = Math.max(extent, MIN_TARGET);
849
+ const thinCentre = (thin0 + thin1) / 2;
850
+ const across = {
851
+ from: thinCentre - MIN_TARGET / 2,
852
+ size: MIN_TARGET
853
+ };
854
+ const along = {
855
+ from: centre - target / 2,
856
+ size: target
857
+ };
858
+ return /*#__PURE__*/_jsxs(_Fragment, {
859
+ children: [/*#__PURE__*/_jsx(Rect, {
860
+ x: upright ? across.from : along.from,
861
+ width: upright ? across.size : along.size,
862
+ y: upright ? along.from : across.from,
863
+ height: upright ? along.size : across.size,
864
+ fill: "transparent",
865
+ onPress: onPress
866
+ }), bar]
598
867
  });
599
868
  }
600
869
  /**
@@ -622,121 +891,264 @@ function SankeyChartLabels({
622
891
  }) {
623
892
  const {
624
893
  layout,
625
- nodes,
626
894
  width,
627
895
  height,
628
896
  status,
629
897
  activeId,
630
898
  setActiveId,
631
- labelFor
899
+ labelFor,
900
+ reportLabelled,
901
+ orientation,
902
+ datumFor
632
903
  } = useChart('SankeyChart.Labels');
633
- if (status === 'loading' || !layout.nodes.length) return null;
634
- const format = formatValue ?? (value => compactNumber(value));
904
+ const upright = orientation === 'horizontal';
905
+ const drawing = status !== 'loading' && layout.nodes.length > 0;
635
906
 
636
907
  /*
637
- * Where the neighbouring columns sit, in points across the plot.
638
- *
639
- * A name's row needs a bound on both sides or it runs the width of the chart
640
- * and covers every row it crosses — and since the rows are absolutely
641
- * positioned siblings, the last one drawn takes the touch. That is a tap on
642
- * one name selecting a node two columns away, which is worse than a small
643
- * target because it is wrong rather than merely hard.
908
+ * Where every name goes, and which ones there is no room for, worked out
909
+ * once: the chart has to be told which names were drawn so its semantic list
910
+ * can carry the rest, and a second copy of the rule would be a second answer
911
+ * to the same question.
644
912
  */
645
- const edges = [];
646
- for (const placed of layout.nodes) {
647
- if (!edges.includes(placed.x0)) edges.push(placed.x0);
648
- if (!edges.includes(placed.x1)) edges.push(placed.x1);
649
- }
650
- edges.sort((a, b) => a - b);
651
- const nextEdge = x => edges.find(edge => edge > x + 1e-6);
652
- const previousEdge = x => {
653
- let found;
654
- for (const edge of edges) if (edge < x - 1e-6) found = edge;
655
- return found;
656
- };
657
- return /*#__PURE__*/_jsx(_Fragment, {
658
- children: layout.nodes.map(node => {
659
- const datum = nodes[node.index];
660
- if (!datum) return null;
661
- const extent = node.y1 - node.y0;
662
- if (extent < minHeight) return null;
913
+ const placements = useMemo(() => {
914
+ if (!drawing) return [];
915
+
916
+ // Along the flow is the axis the stages advance on; across it is the axis
917
+ // carrying the values. Which is horizontal swaps with the orientation.
918
+ const alongStart = node => upright ? node.x0 : node.y0;
919
+ const alongEnd = node => upright ? node.x1 : node.y1;
920
+ const crossStart = node => upright ? node.y0 : node.x0;
921
+ const crossEnd = node => upright ? node.y1 : node.x1;
922
+ const alongSpan = upright ? width : height;
923
+ const crossSpan = upright ? height : width;
924
+
925
+ /*
926
+ * Where the neighbouring stages sit along the flow.
927
+ *
928
+ * A name's box needs a bound on both sides or it runs the length of the
929
+ * chart and covers every stage it crosses — and since the boxes are
930
+ * absolutely positioned siblings, the last one drawn takes the touch. That
931
+ * is a tap on one name selecting a node two stages away, which is worse
932
+ * than a small target because it is wrong rather than merely hard.
933
+ */
934
+ const edges = [];
935
+ for (const placed of layout.nodes) {
936
+ if (!edges.includes(alongStart(placed))) edges.push(alongStart(placed));
937
+ if (!edges.includes(alongEnd(placed))) edges.push(alongEnd(placed));
938
+ }
939
+ edges.sort((a, b) => a - b);
940
+ const placed = layout.nodes.map(node => {
941
+ const extent = crossEnd(node) - crossStart(node);
942
+ const after = edges.find(edge => edge > alongEnd(node) + 1e-6);
943
+ const before = edges.filter(edge => edge < alongStart(node) - 1e-6).pop();
944
+ const trailing = after === undefined;
663
945
 
664
946
  /*
665
- * Which side the name goes on is decided by where the bar actually is,
666
- * not by which column it belongs to. Under a right-to-left layout the
667
- * finished diagram is mirrored, so the last column is the one on the
668
- * left — reading the side off the column number there puts every name
669
- * in a box of zero width and the chart loses all of them.
947
+ * The box along the flow: the half of the gap nearest its own bar, so
948
+ * the name leaving one stage and the name arriving at the next can share
949
+ * the space between them without sharing a touch target.
670
950
  */
671
- const after = nextEdge(node.x1);
672
- const before = previousEdge(node.x0);
673
- const trailing = after === undefined;
951
+ const gap = trailing ? (() => {
952
+ const from = before === undefined ? 0 : (before + alongStart(node)) / 2;
953
+ return {
954
+ from,
955
+ size: Math.max(0, alongStart(node) - LABEL_GAP - from)
956
+ };
957
+ })() : (() => {
958
+ const from = alongEnd(node) + LABEL_GAP;
959
+ const to = after === undefined ? alongSpan : (alongEnd(node) + after) / 2;
960
+ return {
961
+ from,
962
+ size: Math.max(0, to - from)
963
+ };
964
+ })();
965
+
966
+ // And across it: the bar's own run, clamped inside the plot.
967
+ let crossFrom = Math.max(0, Math.min(crossStart(node), crossSpan - extent));
968
+ let crossSize = extent;
969
+
970
+ /*
971
+ * Along the flow the test is the bar's own run: forty names on forty
972
+ * hairlines overlap into a grey band that hides the flow behind it.
973
+ * Across it, where the flow runs downwards, whether a name fits is
974
+ * decided below, once every name in the row is known.
975
+ */
976
+ const show = extent >= minHeight;
977
+ return {
978
+ node,
979
+ show,
980
+ trailing,
981
+ gap,
982
+ crossFrom,
983
+ crossSize,
984
+ extent
985
+ };
986
+ });
987
+ if (!upright) {
988
+ /*
989
+ * Where the flow runs downwards a name sits across its bar, and a bar is
990
+ * as wide as its value — so a stage worth a few percent has a bar
991
+ * narrower than its own name. Squeezing the name into it gives "Groce…",
992
+ * which is cut off rather than shown.
993
+ *
994
+ * So a name is laid out at its own width, centred on its bar, and the
995
+ * row is filled from its largest stage down. A name may run over a
996
+ * neighbour's bar when that neighbour is not using the room — and where
997
+ * it would collide with a name already placed it slides clear, as long
998
+ * as it still sits over its own bar. A name that cannot be shown whole
999
+ * is not shown at all; the legend and the breakdown carry it, and its
1000
+ * bar is still pressable.
1001
+ */
1002
+ const taken = new Map();
1003
+ const order = [...placed].sort((a, b) => b.node.value - a.node.value);
1004
+ for (const entry of order) {
1005
+ if (!entry.show) continue;
1006
+ const centre = (entry.node.x0 + entry.node.x1) / 2;
1007
+ const size = Math.min(crossSpan, Math.max(entry.extent, nameWidth(labelFor(entry.node.id))));
1008
+ const boxes = taken.get(entry.node.layer) ?? [];
1009
+
1010
+ // The free stretch of the row around this bar's centre.
1011
+ let lo = 0;
1012
+ let hi = crossSpan;
1013
+ for (const [a, b] of boxes) {
1014
+ if (b <= centre) lo = Math.max(lo, b + LABEL_GAP);else if (a >= centre) hi = Math.min(hi, a - LABEL_GAP);else {
1015
+ lo = hi = centre;
1016
+ }
1017
+ }
1018
+ if (hi - lo < size) {
1019
+ entry.show = false;
1020
+ continue;
1021
+ }
1022
+ // Centred where it can be, slid clear where it cannot, and never so
1023
+ // far that it stops sitting over the bar it names.
1024
+ const from = Math.max(lo, Math.min(centre - size / 2, hi - size));
1025
+ if (centre < from || centre > from + size) {
1026
+ entry.show = false;
1027
+ continue;
1028
+ }
1029
+ boxes.push([from, from + size]);
1030
+ taken.set(entry.node.layer, boxes);
1031
+ entry.crossFrom = from;
1032
+ entry.crossSize = size;
1033
+ }
1034
+ return placed;
1035
+ }
1036
+
1037
+ /*
1038
+ * Across the flow, an upright name is a line of text centred on a bar that
1039
+ * may be thinner than the line. Where two such bars sit close together
1040
+ * their names run into each other and both come out unreadable — which is
1041
+ * worse than one of them being missing. So each column keeps names from
1042
+ * its largest node down, and a name whose line would touch one already
1043
+ * kept is left off. The node keeps its pressable bar, its place in the
1044
+ * accessibility list and its entry in the legend.
1045
+ */
1046
+ const lineHeight = showValue ? LABEL_LINE * 2 : LABEL_LINE;
1047
+ const kept = new Map();
1048
+ const order = [...placed].sort((a, b) => b.node.value - a.node.value);
1049
+ for (const entry of order) {
1050
+ if (!entry.show) continue;
1051
+ const centre = (entry.node.y0 + entry.node.y1) / 2;
1052
+ const size = Math.max(entry.extent, lineHeight);
1053
+ const from = Math.max(0, Math.min(centre - size / 2, height - size));
1054
+ const to = from + size;
1055
+ const key = `${entry.node.layer}:${entry.trailing ? 'end' : 'start'}`;
1056
+ const taken = kept.get(key) ?? [];
1057
+ if (taken.some(([a, b]) => from < b && to > a)) {
1058
+ entry.show = false;
1059
+ continue;
1060
+ }
1061
+ taken.push([from, to]);
1062
+ kept.set(key, taken);
1063
+ entry.crossFrom = from;
1064
+ entry.crossSize = size;
1065
+ }
1066
+ return placed;
1067
+ }, [drawing, layout.nodes, width, height, upright, minHeight, showValue, labelFor]);
1068
+ const drawn = useMemo(() => drawing ? placements.filter(p => p.show).map(p => p.node.id) : null, [drawing, placements]);
1069
+ useEffect(() => {
1070
+ reportLabelled(drawn);
1071
+ return () => reportLabelled(null);
1072
+ }, [drawn, reportLabelled]);
1073
+ if (!drawing) return null;
1074
+ const format = formatValue ?? (value => compactNumber(value));
1075
+ return /*#__PURE__*/_jsx(_Fragment, {
1076
+ children: placements.map(({
1077
+ node,
1078
+ show,
1079
+ trailing,
1080
+ gap,
1081
+ crossFrom,
1082
+ crossSize
1083
+ }) => {
1084
+ if (!show) return null;
674
1085
  const name = labelFor(node.id);
675
- const value = format(node.value, datum);
1086
+ const value = format(node.value, datumFor(node));
676
1087
  const selected = activeId === node.id;
677
1088
 
678
1089
  /*
679
- * A sliver's row is padded out to a real target rather than drawn
680
- * taller — growing the row would push it over its neighbours, and two
681
- * overlapping targets are worse than a small one.
1090
+ * A sliver's box is padded out to a real target rather than drawn
1091
+ * longer — growing it would push it over its neighbours, and two
1092
+ * overlapping targets are worse than one small one.
682
1093
  */
683
- const slack = Math.max(0, (MIN_TARGET - extent) / 2);
684
- const top = Math.max(0, Math.min(node.y0, height - extent));
685
- return /*#__PURE__*/_jsxs(Pressable, {
1094
+ const slack = Math.max(0, (MIN_TARGET - crossSize) / 2);
1095
+ const align = upright ? trailing ? 'right' : 'left' : 'center';
1096
+ return /*#__PURE__*/_jsx(Pressable, {
686
1097
  accessibilityRole: "button",
687
1098
  accessibilityState: {
688
1099
  selected
689
1100
  },
690
1101
  accessibilityLabel: `${name}, ${value}`,
691
- hitSlop: {
1102
+ hitSlop: upright ? {
692
1103
  top: slack,
693
1104
  bottom: slack
1105
+ } : {
1106
+ left: slack,
1107
+ right: slack
694
1108
  },
695
1109
  onPress: () => setActiveId(selected ? null : node.id),
696
1110
  style: {
697
1111
  position: 'absolute',
698
- top,
699
- height: extent,
700
- justifyContent: 'center',
701
- /*
702
- * Each row takes the half of its gap nearest its own bar, so the
703
- * name leaving one column and the name arriving at the next can
704
- * share the space between them without sharing a touch target.
705
- */
706
- ...(trailing ? (() => {
707
- const from = before === undefined ? 0 : (before + node.x0) / 2;
708
- return {
709
- left: from,
710
- width: Math.max(0, node.x0 - LABEL_GAP - from),
711
- alignItems: 'flex-end'
712
- };
713
- })() : (() => {
714
- const from = node.x1 + LABEL_GAP;
715
- const to = after === undefined ? width : (node.x1 + after) / 2;
716
- return {
717
- left: from,
718
- width: Math.max(0, to - from)
719
- };
720
- })())
1112
+ ...(upright ? {
1113
+ top: crossFrom,
1114
+ height: crossSize,
1115
+ left: gap.from,
1116
+ width: gap.size,
1117
+ justifyContent: 'center',
1118
+ alignItems: trailing ? 'flex-end' : 'flex-start'
1119
+ } : {
1120
+ left: crossFrom,
1121
+ width: crossSize,
1122
+ top: gap.from,
1123
+ height: gap.size,
1124
+ alignItems: 'center',
1125
+ // Against the bar rather than centred in the gap, so the
1126
+ // name sits with what it names instead of floating in the
1127
+ // middle of the ribbons.
1128
+ justifyContent: trailing ? 'flex-end' : 'flex-start'
1129
+ })
721
1130
  },
722
1131
  className: cn(className),
723
- children: [/*#__PURE__*/_jsx(Text, {
724
- size: "xs",
725
- weight: selected ? 'bold' : 'medium',
726
- numberOfLines: 1,
727
- style: {
728
- textAlign: trailing ? 'right' : 'left'
729
- },
730
- children: name
731
- }), showValue ? /*#__PURE__*/_jsx(Text, {
732
- size: "xs",
733
- muted: true,
734
- numberOfLines: 1,
735
- style: {
736
- textAlign: trailing ? 'right' : 'left'
737
- },
738
- children: value
739
- }) : null]
1132
+ children: /*#__PURE__*/_jsxs(View, {
1133
+ className: "max-w-full rounded-md bg-background/80 px-1.5 py-0.5",
1134
+ children: [/*#__PURE__*/_jsx(Text, {
1135
+ size: "xs",
1136
+ weight: selected ? 'bold' : 'medium',
1137
+ numberOfLines: 1,
1138
+ style: {
1139
+ textAlign: align
1140
+ },
1141
+ children: name
1142
+ }), showValue ? /*#__PURE__*/_jsx(Text, {
1143
+ size: "xs",
1144
+ muted: true,
1145
+ numberOfLines: 1,
1146
+ style: {
1147
+ textAlign: align
1148
+ },
1149
+ children: value
1150
+ }) : null]
1151
+ })
740
1152
  }, node.id);
741
1153
  })
742
1154
  });
@@ -763,7 +1175,8 @@ function SankeyChartTooltip({
763
1175
  layout,
764
1176
  width,
765
1177
  height,
766
- labelFor
1178
+ labelFor,
1179
+ orientation
767
1180
  } = useChart('SankeyChart.Tooltip');
768
1181
  const {
769
1182
  activeId,
@@ -776,16 +1189,33 @@ function SankeyChartTooltip({
776
1189
  if (!node) return null;
777
1190
  const format = formatValue ?? compactNumber;
778
1191
  const CARD = 132;
779
- const trailing = node.x1 + LABEL_GAP + CARD > width;
780
- const left = trailing ? Math.max(0, node.x0 - LABEL_GAP - CARD) : Math.min(node.x1 + LABEL_GAP, Math.max(0, width - CARD));
781
- const centre = (node.y0 + node.y1) / 2;
1192
+ const HALF = 34;
1193
+ const upright = orientation === 'horizontal';
1194
+
1195
+ /*
1196
+ * Beside the bar on the axis the flow advances along, and level with its
1197
+ * middle on the other — then clamped into the plot at both ends, so a node
1198
+ * at an edge gets a card that is still entirely on the chart. It flips to
1199
+ * the near side when there is no room on the far one, which is what keeps a
1200
+ * last-stage node's card off the edge rather than half over it.
1201
+ */
1202
+ const alongEnd = upright ? node.x1 : node.y1;
1203
+ const alongStart = upright ? node.x0 : node.y0;
1204
+ const alongSpan = upright ? width : height;
1205
+ const cardAlong = upright ? CARD : HALF * 2;
1206
+ const trailing = alongEnd + LABEL_GAP + cardAlong > alongSpan;
1207
+ const along = trailing ? Math.max(0, alongStart - LABEL_GAP - cardAlong) : Math.min(alongEnd + LABEL_GAP, Math.max(0, alongSpan - cardAlong));
1208
+ const crossCentre = upright ? (node.y0 + node.y1) / 2 : (node.x0 + node.x1) / 2;
1209
+ const crossSpan = upright ? height : width;
1210
+ const cardCross = upright ? HALF * 2 : CARD;
1211
+ const cross = Math.max(0, Math.min(crossCentre - cardCross / 2, crossSpan - cardCross));
782
1212
  return /*#__PURE__*/_jsxs(View, {
783
1213
  pointerEvents: "none",
784
1214
  style: {
785
1215
  position: 'absolute',
786
- left,
787
1216
  width: CARD,
788
- top: Math.max(0, Math.min(centre - 34, height - 68))
1217
+ left: upright ? along : cross,
1218
+ top: upright ? cross : along
789
1219
  },
790
1220
  className: cn('gap-0.5 rounded-lg border border-border bg-background px-2.5 py-2 shadow-sm', className),
791
1221
  children: [/*#__PURE__*/_jsx(Text, {
@@ -808,6 +1238,201 @@ function SankeyChartTooltip({
808
1238
  }
809
1239
  SankeyChartTooltip.displayName = 'SankeyChart.Tooltip';
810
1240
  SankeyChartTooltip.slot = 'overlay';
1241
+ /**
1242
+ * What the selected node carries, written out in full underneath the diagram.
1243
+ *
1244
+ * The names on a flow diagram live in the gaps between its stages, which on a
1245
+ * phone is a few dozen points — wide enough to truncate almost anything. Here
1246
+ * there is the whole width of the card, so a stream is read as the name of
1247
+ * where it came from and the number it carried rather than as a ribbon whose
1248
+ * label ran out of room.
1249
+ *
1250
+ * In and out are listed separately because they are only the same total when
1251
+ * nothing was lost on the way through, and a node where they differ is the
1252
+ * interesting one on the chart.
1253
+ */
1254
+ function SankeyChartBreakdown({
1255
+ className,
1256
+ formatValue,
1257
+ maxRows,
1258
+ onSelectNode,
1259
+ placeholder = 'Select a stage to see what it carries',
1260
+ ...props
1261
+ }) {
1262
+ const {
1263
+ status,
1264
+ setActiveId,
1265
+ labelFor
1266
+ } = useChart('SankeyChart.Breakdown');
1267
+ const {
1268
+ activeId,
1269
+ activeValue,
1270
+ sources,
1271
+ targets
1272
+ } = useSankeyChart();
1273
+ if (status === 'loading') return null;
1274
+ const format = formatValue ?? compactNumber;
1275
+ const limit = maxRows && maxRows > 0 ? Math.floor(maxRows) : undefined;
1276
+ if (!activeId) {
1277
+ return /*#__PURE__*/_jsx(View, {
1278
+ ...props,
1279
+ className: cn('w-full pt-3', className),
1280
+ children: /*#__PURE__*/_jsx(Text, {
1281
+ size: "xs",
1282
+ muted: true,
1283
+ children: placeholder
1284
+ })
1285
+ });
1286
+ }
1287
+ const side = (title, rows) => {
1288
+ if (!rows.length) return null;
1289
+ const shown = limit ? rows.slice(0, limit) : rows;
1290
+ const rest = rows.length - shown.length;
1291
+ return /*#__PURE__*/_jsxs(View, {
1292
+ className: "w-full gap-1",
1293
+ children: [/*#__PURE__*/_jsx(Text, {
1294
+ size: "xs",
1295
+ muted: true,
1296
+ weight: "medium",
1297
+ children: title
1298
+ }), shown.map(row => /*#__PURE__*/_jsxs(Pressable, {
1299
+ accessibilityRole: "button",
1300
+ accessibilityLabel: `${row.label}, ${format(row.value)}, ${Math.round(row.share * 100)} percent`,
1301
+ onPress: () => {
1302
+ setActiveId(row.id);
1303
+ onSelectNode?.(row.id);
1304
+ },
1305
+ className: "w-full flex-row items-center gap-2 py-1",
1306
+ children: [/*#__PURE__*/_jsx(View, {
1307
+ style: {
1308
+ width: 8,
1309
+ height: 8,
1310
+ borderRadius: 2,
1311
+ backgroundColor: row.color
1312
+ }
1313
+ }), /*#__PURE__*/_jsx(Text, {
1314
+ size: "xs",
1315
+ numberOfLines: 1,
1316
+ className: "shrink grow",
1317
+ children: row.label
1318
+ }), /*#__PURE__*/_jsx(Text, {
1319
+ size: "xs",
1320
+ weight: "medium",
1321
+ numberOfLines: 1,
1322
+ children: format(row.value)
1323
+ }), /*#__PURE__*/_jsx(Text, {
1324
+ size: "xs",
1325
+ muted: true,
1326
+ numberOfLines: 1,
1327
+ className: "w-10 text-right",
1328
+ children: `${Math.round(row.share * 100)}%`
1329
+ })]
1330
+ }, `${title}-${row.id}`)), rest > 0 ? /*#__PURE__*/_jsx(Text, {
1331
+ size: "xs",
1332
+ muted: true,
1333
+ children: `and ${rest} more`
1334
+ }) : null]
1335
+ });
1336
+ };
1337
+ return /*#__PURE__*/_jsxs(View, {
1338
+ ...props,
1339
+ className: cn('w-full gap-2 pt-3', className),
1340
+ children: [/*#__PURE__*/_jsxs(View, {
1341
+ className: "w-full flex-row items-baseline justify-between gap-2",
1342
+ children: [/*#__PURE__*/_jsx(Text, {
1343
+ size: "xs",
1344
+ weight: "bold",
1345
+ numberOfLines: 1,
1346
+ className: "shrink",
1347
+ children: labelFor(activeId)
1348
+ }), /*#__PURE__*/_jsx(Text, {
1349
+ size: "xs",
1350
+ weight: "semibold",
1351
+ children: format(activeValue)
1352
+ })]
1353
+ }), side('In', sources), side('Out', targets)]
1354
+ });
1355
+ }
1356
+ SankeyChartBreakdown.displayName = 'SankeyChart.Breakdown';
1357
+ SankeyChartBreakdown.slot = 'footer';
1358
+ /**
1359
+ * Every stage named, under the diagram.
1360
+ *
1361
+ * The names on the chart are dropped wherever a bar is too short to carry one,
1362
+ * which is the right call — forty names at that spacing overlap into a grey
1363
+ * band that hides the flow behind them — but it leaves the smallest nodes with
1364
+ * no name and nothing to press. Here each one gets its full name and a proper
1365
+ * target, and pressing it selects the same node the bar would.
1366
+ */
1367
+ function SankeyChartLegend({
1368
+ className,
1369
+ limit,
1370
+ showShare = true,
1371
+ ...props
1372
+ }) {
1373
+ const {
1374
+ layout,
1375
+ colors,
1376
+ status,
1377
+ activeId,
1378
+ setActiveId,
1379
+ labelFor
1380
+ } = useChart('SankeyChart.Legend');
1381
+ if (status === 'loading' || !layout.nodes.length) return null;
1382
+
1383
+ // Against the widest stage rather than the sum of every node, which counts
1384
+ // whatever passes through a middle stage twice and makes every share small.
1385
+ let total = 0;
1386
+ const byLayer = new Map();
1387
+ for (const node of layout.nodes) {
1388
+ const carried = (byLayer.get(node.layer) ?? 0) + node.value;
1389
+ byLayer.set(node.layer, carried);
1390
+ if (carried > total) total = carried;
1391
+ }
1392
+ const shown = limit && limit > 0 ? layout.nodes.slice(0, Math.floor(limit)) : layout.nodes;
1393
+ return /*#__PURE__*/_jsx(View, {
1394
+ ...props,
1395
+ className: cn('w-full flex-row flex-wrap items-center gap-x-3 gap-y-1.5 pt-3', className),
1396
+ children: shown.map((node, index) => {
1397
+ const percent = total > 0 ? Math.round(node.value / total * 100) : 0;
1398
+ const selected = activeId === node.id;
1399
+ const dimmed = activeId !== null && !selected;
1400
+ return /*#__PURE__*/_jsxs(Pressable, {
1401
+ accessibilityRole: "button",
1402
+ accessibilityState: {
1403
+ selected
1404
+ },
1405
+ accessibilityLabel: `${labelFor(node.id)}, ${percent} percent`,
1406
+ onPress: () => setActiveId(selected ? null : node.id),
1407
+ style: {
1408
+ opacity: dimmed ? 0.4 : 1
1409
+ },
1410
+ className: "max-w-full flex-row items-center gap-1.5",
1411
+ children: [/*#__PURE__*/_jsx(View, {
1412
+ style: {
1413
+ width: 8,
1414
+ height: 8,
1415
+ borderRadius: 2,
1416
+ backgroundColor: colors[index] ?? '#3b82f6'
1417
+ }
1418
+ }), /*#__PURE__*/_jsx(Text, {
1419
+ size: "xs",
1420
+ muted: true,
1421
+ numberOfLines: 1,
1422
+ className: "shrink",
1423
+ children: labelFor(node.id)
1424
+ }), showShare ? /*#__PURE__*/_jsx(Text, {
1425
+ size: "xs",
1426
+ weight: "medium",
1427
+ numberOfLines: 1,
1428
+ children: `${percent}%`
1429
+ }) : null]
1430
+ }, node.id);
1431
+ })
1432
+ });
1433
+ }
1434
+ SankeyChartLegend.displayName = 'SankeyChart.Legend';
1435
+ SankeyChartLegend.slot = 'footer';
811
1436
  /**
812
1437
  * The loading state: a few plain bars and the ribbons between them, carrying no
813
1438
  * values.
@@ -825,7 +1450,8 @@ function SankeyChartSkeleton({
825
1450
  width,
826
1451
  height,
827
1452
  curve,
828
- status
1453
+ status,
1454
+ orientation
829
1455
  } = useChart('SankeyChart.Skeleton');
830
1456
  const token = useCSSVariable('--color-skeleton');
831
1457
  const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
@@ -840,28 +1466,36 @@ function SankeyChartSkeleton({
840
1466
  const animatedProps = useAnimatedProps(() => ({
841
1467
  opacity: opacity.value
842
1468
  }));
1469
+ const upright = orientation === 'horizontal';
843
1470
  const shape = useMemo(() => {
844
1471
  if (width <= 0 || height <= 0) return null;
1472
+ // The placeholder is the diagram's own shape, turned the same way: a
1473
+ // waiting state that does not match what replaces it moves the whole card
1474
+ // at the moment the data lands.
1475
+ const along = upright ? width : height;
1476
+ const cross = upright ? height : width;
845
1477
  const bar = DEFAULT_NODE_WIDTH;
846
- const step = (width - bar) / Math.max(SKELETON_COLUMNS - 1, 1);
847
- const band = height / 3;
1478
+ const step = (along - bar) / Math.max(SKELETON_COLUMNS - 1, 1);
1479
+ const band = cross / 3;
848
1480
  const columns = Array.from({
849
1481
  length: SKELETON_COLUMNS
850
1482
  }, (_, i) => i * step);
1483
+ const path = upright ? flowPath : flowPathVertical;
851
1484
  const ribbons = [];
852
1485
  for (let i = 0; i < SKELETON_COLUMNS - 1; i += 1) {
853
1486
  const from = columns[i] + bar;
854
1487
  const to = columns[i + 1];
855
- ribbons.push(flowPath(from, height / 3, to, height / 3, band * 0.5, curve));
856
- ribbons.push(flowPath(from, height * 2 / 3, to, height * 2 / 3, band * 0.5, curve));
1488
+ ribbons.push(path(from, cross / 3, to, cross / 3, band * 0.5, curve));
1489
+ ribbons.push(path(from, cross * 2 / 3, to, cross * 2 / 3, band * 0.5, curve));
857
1490
  }
858
1491
  return {
859
1492
  bar,
860
1493
  band,
861
1494
  columns,
862
- ribbons
1495
+ ribbons,
1496
+ cross
863
1497
  };
864
- }, [width, height, curve]);
1498
+ }, [width, height, curve, upright]);
865
1499
  if (!mounted || !shape) return null;
866
1500
  return /*#__PURE__*/_jsxs(AnimatedG, {
867
1501
  animatedProps: animatedProps,
@@ -869,11 +1503,11 @@ function SankeyChartSkeleton({
869
1503
  d: d,
870
1504
  fill: fill,
871
1505
  fillOpacity: 0.5
872
- }, `ribbon-${index}`)), shape.columns.map((x, index) => /*#__PURE__*/_jsx(Rect, {
873
- x: x,
874
- y: height / 6,
875
- width: shape.bar,
876
- height: height * 2 / 3,
1506
+ }, `ribbon-${index}`)), shape.columns.map((at, index) => /*#__PURE__*/_jsx(Rect, {
1507
+ x: upright ? at : shape.cross / 6,
1508
+ y: upright ? shape.cross / 6 : at,
1509
+ width: upright ? shape.bar : shape.cross * 2 / 3,
1510
+ height: upright ? shape.cross * 2 / 3 : shape.bar,
877
1511
  rx: 2,
878
1512
  fill: fill
879
1513
  }, `bar-${index}`))]
@@ -923,6 +1557,8 @@ function SankeyChartHeader({
923
1557
  SankeyChartHeader.displayName = 'SankeyChart.Header';
924
1558
  SankeyChartHeader.slot = 'header';
925
1559
  export const SankeyChart = Object.assign(SankeyChartRoot, {
1560
+ Breakdown: SankeyChartBreakdown,
1561
+ Legend: SankeyChartLegend,
926
1562
  Header: SankeyChartHeader,
927
1563
  Links: SankeyChartLinks,
928
1564
  Nodes: SankeyChartNodes,