panelui-native 0.101.1 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/module/components/sankey-chart/index.js +801 -165
- package/lib/module/components/sankey-chart/index.js.map +1 -1
- package/lib/module/components/sankey-chart/sankey-layout.js +226 -0
- package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -1
- package/lib/module/components/spinner/index.js +13 -3
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/support/index.js +842 -0
- package/lib/module/components/support/index.js.map +1 -0
- package/lib/module/icons/index.js +40 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/sankey-chart/index.d.ts +131 -5
- package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +49 -6
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +18 -1
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/support/index.d.ts +376 -0
- package/lib/typescript/src/components/support/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +8 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +11 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/sankey-chart/index.tsx +935 -172
- package/src/components/sankey-chart/sankey-layout.ts +276 -2
- package/src/components/spinner/index.tsx +27 -1
- package/src/components/support/index.tsx +877 -0
- package/src/icons/index.tsx +16 -0
- package/src/index.ts +35 -0
- package/src/utils/chart.ts +40 -0
|
@@ -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)
|
|
173
|
-
|
|
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.
|
|
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
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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.
|
|
350
|
-
* `Labels`
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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:
|
|
397
|
-
disabled:
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
x1:
|
|
472
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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,
|
|
727
|
+
const grown = thickness * progress(reveal.value, windowFrom, windowTo);
|
|
511
728
|
return {
|
|
512
|
-
d: flowPath(
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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 ||
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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 =
|
|
579
|
-
const centre = (
|
|
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
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
const
|
|
904
|
+
const upright = orientation === 'horizontal';
|
|
905
|
+
const drawing = status !== 'loading' && layout.nodes.length > 0;
|
|
635
906
|
|
|
636
907
|
/*
|
|
637
|
-
* Where
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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
|
|
672
|
-
|
|
673
|
-
|
|
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,
|
|
1086
|
+
const value = format(node.value, datumFor(node));
|
|
676
1087
|
const selected = activeId === node.id;
|
|
677
1088
|
|
|
678
1089
|
/*
|
|
679
|
-
* A sliver's
|
|
680
|
-
*
|
|
681
|
-
* overlapping targets are worse than
|
|
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 -
|
|
684
|
-
const
|
|
685
|
-
return /*#__PURE__*/
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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:
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
780
|
-
const
|
|
781
|
-
|
|
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
|
-
|
|
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 = (
|
|
847
|
-
const band =
|
|
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(
|
|
856
|
-
ribbons.push(
|
|
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((
|
|
873
|
-
x:
|
|
874
|
-
y:
|
|
875
|
-
width: shape.bar,
|
|
876
|
-
height:
|
|
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,
|