/** * SankeyChart — where a quantity came from and where it ended up. * * ```tsx * * * * * * * * ``` * * ## What it answers that the other charts do not * * Every other chart here takes one set of things and measures them. This one * takes two and says how much of the first became the second. A treemap cuts a * total into its parts, a funnel counts what survived each step, a waterfall * carries a balance from one figure to another — none of them can say that * *this* source fed *that* destination, because none of them draws a thing that * has two ends. * * So the question to bring to it is a routing question. Which campaigns * produced which signups; which budget lines paid for which departments; what * the traffic that arrived on the landing page went on to do. If the answer * does not need a source *and* a target, one of the simpler charts will read * better at the same size. * * ## The ribbon is the reading * * A ribbon's thickness is its value, on one scale shared by the whole diagram, * so a ribbon twice as thick is twice as much wherever it is on the page. That * is the only quantity here: the horizontal distance a ribbon travels is the * number of columns between its ends and means nothing else, and the vertical * order within a column is chosen to keep the ribbons from crossing rather than * to rank anything. * * A node's height is what passes through it — the larger of what arrives and * what leaves, which are the same number unless some of it went nowhere. Where * they differ, give the node an explicit `value` to pin it; the diagram cannot * infer a loss it was never told about. * * ## Columns are the flow's, not the caller's * * Nothing about the order of the `nodes` array decides where a node is drawn. A * node that receives from another has to be drawn after it or its ribbon would * run backwards, so the columns come out of the links. `align` only settles the * cases the flow leaves open, and the default pushes every node that feeds * nothing into the last column, so the diagram ends on a straight edge of * destinations instead of a ragged one. * * ## Bad rows are dropped rather than fatal * * Flow data is nearly always joined together from somewhere nobody in the room * owns, and it arrives with rows that name a node that is not there, carry a * zero, or close a loop. A loop in particular has no left-to-right reading at * all. All of them are dropped and counted, and `onDropLinks` reports how many * — so a screen can say "3 rows could not be drawn" instead of going blank or * quietly showing less than it was given. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Platform, Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import Animated, { Easing, useAnimatedProps, useDerivedValue, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { G, Path, Rect } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { ChartAccessibilityData, type ChartAccessibilityProps, } from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { compactNumber, flowPath, seriesColorAt, useSeriesColor } from '../../utils/chart'; import { cn } from '../../utils/cn'; import { useDirection } from '../../hooks/use-direction'; import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff'; import { sankeyLayout, type SankeyAlign, type SankeyLayout } from './sankey-layout'; export type { SankeyAlign } from './sankey-layout'; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedRect = Animated.createAnimatedComponent(Rect); const AnimatedG = Animated.createAnimatedComponent(G); /** How tall the diagram is drawn when the caller does not say. */ const DEFAULT_HEIGHT = 240; /** How thick a node's bar is. Thin enough to read as an edge the flow meets. */ const DEFAULT_NODE_WIDTH = 10; /** The gap asked for between two nodes in a column. */ const DEFAULT_NODE_PADDING = 14; /** Relaxation rounds. Where the arrangement stops visibly improving. */ const DEFAULT_ITERATIONS = 6; /** * How far a ribbon's control points reach towards the middle. * * Exactly half, which puts both on the centre line and makes the two halves of * every ribbon mirror images. Anything less leaves a visible straight section * in the middle that reads as a kink where two ribbons were joined. */ const CURVE = 0.5; /** A ribbon at rest. Translucent, because ribbons cross and both must be read. */ const LINK_OPACITY = 0.4; /** A ribbon belonging to the selected node. */ const LINK_ACTIVE_OPACITY = 0.78; /** And one that does not, once something is selected. */ const LINK_DIM_OPACITY = 0.08; /** Milliseconds for one column of the flow to draw itself. */ const DEFAULT_DURATION = 620; /** Milliseconds between one column starting and the next. */ const STAGGER = 110; /** Milliseconds for a selection to take hold. */ const SELECT_DURATION = 180; /** Space between a node's bar and its name. */ const LABEL_GAP = 6; /** The smallest a label's press target is allowed to be, in points. */ const MIN_TARGET = 44; /** Columns the placeholder suggests while there is no data to count. */ const SKELETON_COLUMNS = 3; /** * What takes the geometry out of the accessibility tree, per platform. * * The two native props reach the DOM untranslated through react-native-svg, so * on web they mean nothing and one of them draws a React warning for its * casing. `aria-hidden` is what hides an `` there. */ const HIDDEN = ( Platform.OS === 'web' ? { 'aria-hidden': true } : { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants', } ) as Record; type Slot = 'svg' | 'overlay' | 'header' | 'footer'; export type SankeyChartStatus = 'loading' | 'ready'; export interface SankeyNode { /** Stable key the links name. Must be unique; a repeat is ignored. */ id: string; /** Name drawn beside the bar. Defaults to the id. */ label?: string; /** Explicit colour, instead of the node's place in the palette. */ color?: string; /** * Pins what passes through the node, for a stage that loses some of what it * received to somewhere the links do not describe. Left unset the node is * worth the larger of what arrives and what leaves. */ value?: number; } export interface SankeyLink { /** `id` of the node it leaves. */ source: string; /** `id` of the node it reaches. */ target: string; /** How much travels. Zero, negative and non-finite rows are dropped. */ value: number; /** Explicit colour, instead of taking the source node's. */ color?: string; } interface SankeyChartContextValue { nodes: SankeyNode[]; links: SankeyLink[]; layout: SankeyLayout; width: number; height: number; curve: number; /** One colour per laid-out node, in the laid-out order. */ colors: string[]; /** Where each column sits in the entrance, `0` to `1`. */ windows: { from: number; to: number }[]; reveal: SharedValue; status: SankeyChartStatus; activeId: string | null; setActiveId: (id: string | null) => void; labelFor: (id: string) => string; } const SankeyChartContext = createContext(null); function useChart(component: string): SankeyChartContextValue { const context = useContext(SankeyChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The selected node and what runs through it, for something drawn inside the chart. */ export function useSankeyChart() { const { nodes, layout, activeId } = useChart('useSankeyChart'); return useMemo(() => { const placed = activeId ? layout.nodes.find((node) => node.id === activeId) : undefined; if (!placed) { return { activeId: null, activeNode: null, activeValue: 0, incoming: 0, outgoing: 0 }; } const position = layout.nodes.indexOf(placed); let incoming = 0; let outgoing = 0; for (const link of layout.links) { if (link.target === position) incoming += link.value; if (link.source === position) outgoing += link.value; } return { activeId, activeNode: nodes.find((node) => node.id === activeId) ?? null, /** What passes through it — what the bar's height is drawn from. */ activeValue: placed.value, /** What arrives. Zero at a node the flow starts from. */ incoming, /** What leaves. Zero at a node the flow ends at. */ outgoing, }; }, [nodes, layout, activeId]); } export interface SankeyChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The stages. Order does not decide position — the links do. */ nodes: SankeyNode[]; /** What travels between them. */ links: SankeyLink[]; /** * How tall the diagram is drawn, in points. * * The width is the card's, but nothing in a flow says how deep it should be: * a diagram of four nodes and one of forty are the same data at two heights, * and which of them is right is a question about the screen. */ height?: number; /** How thick a node's bar is, in points. */ nodeWidth?: number; /** * The gap between two nodes in a column, in points. * * A maximum rather than a promise. A crowded column gives its spacing up * before it gives up the height of its bars, because the bar is the reading. */ nodePadding?: number; /** Which column a node goes in where the flow leaves a choice. */ align?: SankeyAlign; /** Relaxation rounds spent untangling the ribbons. */ iterations?: number; /** How far a ribbon bends, `0` for a straight diagonal and `0.5` for an S. */ curve?: number; /** The first hue. The rest of the palette follows from the theme's tokens. */ color?: string; /** Milliseconds for one column to draw itself. */ animationDuration?: number; /** Milliseconds between one column starting and the next. `0` for all at once. */ staggerDelay?: number; /** `loading` draws a plain placeholder until the data arrives. */ status?: SankeyChartStatus; /** Selected node. Leave unset to let the chart track it. */ activeId?: string | null; /** Fires with the selected node's id, or `null` when the selection is cleared. */ onActiveIdChange?: (id: string | null) => void; /** * Fires with how many link rows could not be drawn — ones naming a node that * is not there, carrying nothing, or closing a loop. `0` after a clean render, * so a banner can be shown and taken away from the same signal. */ onDropLinks?: (count: number) => void; children?: ReactNode; } /** Imperative handle: re-run the entrance, for a "replay" control. */ export interface SankeyChartHandle { replay: () => void; } const SankeyChartRoot = forwardRef( function SankeyChartRoot( { className, nodes, links, height = DEFAULT_HEIGHT, nodeWidth = DEFAULT_NODE_WIDTH, nodePadding = DEFAULT_NODE_PADDING, align = 'justify', iterations = DEFAULT_ITERATIONS, curve = CURVE, color, animationDuration = DEFAULT_DURATION, staggerDelay = STAGGER, status = 'ready', activeId: activeIdProp, onActiveIdChange, onDropLinks, accessibilityLabel, accessibilityHint, accessibilityLabelForDatum, onAccessibilityDatumPress, children, ...props }, ref ) { const [width, setWidth] = useState(0); const [internalActive, setInternalActive] = useState(null); const reveal = useSharedValue(0); const reducedMotion = useReducedMotion(); const direction = useDirection(); const controlled = activeIdProp !== undefined; const activeId = controlled ? activeIdProp : internalActive; const setActiveId = useMemo( () => (id: string | null) => { if (!controlled) setInternalActive(id); onActiveIdChange?.(id); }, [controlled, onActiveIdChange] ); const layout = useMemo( () => sankeyLayout(nodes, links, { width, height, nodeWidth, nodePadding, align, iterations, }), [nodes, links, width, height, nodeWidth, nodePadding, align, iterations] ); /* * A flow reads from where it starts, and under a right-to-left layout that * is the right-hand edge. Mirroring the finished layout rather than laying * it out backwards keeps one set of maths under both directions — the * arrangement is identical, it is only read from the other end. */ const mirrored = direction === 'rtl'; const placed = useMemo(() => { if (!mirrored || !layout.nodes.length) return layout; return { ...layout, nodes: layout.nodes.map((node) => ({ ...node, x0: width - node.x1, x1: width - node.x0, })), }; }, [layout, mirrored, width]); const dropped = layout.dropped; useEffect(() => { onDropLinks?.(dropped); }, [dropped, onDropLinks]); const c1 = useSeriesColor(color, 1); const c2 = useSeriesColor(undefined, 2); const c3 = useSeriesColor(undefined, 3); const c4 = useSeriesColor(undefined, 4); const c5 = useSeriesColor(undefined, 5); const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]); /* * Coloured by the node's own position in the data rather than by its column. * A colour per column would say the column means something, and it does not * — it is just how far along the flow a node happens to sit. */ const colors = useMemo( () => placed.nodes.map( (node, index) => nodes[node.index]?.color ?? seriesColorAt(palette, index) ), [placed.nodes, nodes, palette] ); const labelFor = useMemo(() => { const names = new Map(nodes.map((node) => [node.id, node.label ?? node.id])); return (id: string) => names.get(id) ?? id; }, [nodes]); /* * One clock, with each column given the slice of it that it draws in, so * the flow arrives in the order it happens rather than all at once. A * shared value per column would be the same animation played n times and n * more things a replay would have to find. */ const stagger = Math.max(0, staggerDelay); const columns = Math.max(placed.columns, 1); const total = animationDuration + Math.max(columns - 1, 0) * stagger; const windows = useMemo( () => Array.from({ length: columns }, (_, column) => { const from = column * stagger; return { from: total > 0 ? from / total : 0, to: total > 0 ? (from + animationDuration) / total : 1, }; }), [columns, stagger, animationDuration, total] ); const playReveal = useMemo( () => () => { if (reducedMotion) { reveal.value = 1; return; } reveal.value = 0; // Linear, because the shaping is per column: each one eases inside its // own window, and easing the clock as well would ease it twice. reveal.value = withTiming(1, { duration: total, easing: Easing.linear }); }, [reducedMotion, total, reveal] ); const loading = status === 'loading'; const revealed = useRef(false); useEffect(() => { if (loading) { revealed.current = false; reveal.value = 0; return; } if (revealed.current || !placed.nodes.length) return; revealed.current = true; playReveal(); }, [loading, placed.nodes.length, playReveal, reveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); // Measured on the plot's own view rather than the outer one, so a header or // a footer cannot change how wide the diagram thinks it is. const onLayout = (event: LayoutChangeEvent) => { const next = Math.round(event.nativeEvent.layout.width); if (next !== width) setWidth(next); }; const context = useMemo( () => ({ nodes, links, layout: placed, width, height, curve, colors, windows, reveal, status, activeId: activeId ?? null, setActiveId, labelFor, }), [ nodes, links, placed, width, height, curve, colors, windows, reveal, status, activeId, setActiveId, labelFor, ] ); const slots: Record = { svg: [], overlay: [], header: [], footer: [], }; /* * Whether the names are on the chart decides how it is read out. With * `Labels` there is a pressable row per node already, and the semantic * list below would say all of it a second time; without them the diagram * is pure geometry and the list is the only way through it. */ let labelled = false; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const slot = (child.type as { slot?: Slot }).slot ?? 'overlay'; if ((child.type as { displayName?: string }).displayName === 'SankeyChart.Labels') { labelled = true; } slots[slot in slots ? slot : 'overlay'].push( {child} ); }); return ( {slots.header} {width > 0 && height > 0 ? ( <> {/* * The geometry is decorative: every ribbon and bar in here is * already spoken once, either by the names over it or by the * semantic list below. Left in the tree it is a few hundred * unlabelled paths to swipe through before reaching either. */} {slots.svg} {/* * Names sit over the SVG rather than inside it: they are text, * and SVG text ignores the platform's text scaling and the * theme's font. */} {/* * Laid out left to right whatever the reading direction, to * match the SVG underneath it. React Native swaps `left` and * `right` inside a right-to-left subtree, and the positions * here are already mirrored — so left alone they would be * mirrored a second time and every name would sit against the * wrong side of the plot. The glyphs still run the right way: * `Text` carries the writing direction of its own accord. */} {slots.overlay} ) : null} {slots.footer} [ ['Node', node.label ?? node.id], ['Value', placed.nodes.find((placedNode) => placedNode.id === node.id)?.value], ]} accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} accessibilityLabelForDatum={accessibilityLabelForDatum} onAccessibilityDatumPress={onAccessibilityDatumPress} /> ); } ); SankeyChartRoot.displayName = 'SankeyChart'; function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } /** * How much of the entrance a column has played, eased. * * Ease out cubic, written out rather than called: an easing from the animation * library is not a worklet and this runs on the UI thread every frame. */ function progress(clock: number, from: number, to: number): number { 'worklet'; const range = to - from; const raw = range > 0 ? (clock - from) / range : 1; const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw; return 1 - (1 - clamped) * (1 - clamped) * (1 - clamped); } export interface SankeyChartLinksProps { /** A ribbon's opacity at rest. */ opacity?: number; /** A ribbon's opacity when its node is selected. */ activeOpacity?: number; /** And when something else is. */ dimOpacity?: number; } /** * The ribbons. * * Drawn before the bars so a bar sits on top of the flows that meet it, which * is what gives a node a clean edge to arrive at instead of a fringe of ribbon * ends poking through it. * * Translucent at rest, and that is not decoration. Ribbons cross — that is the * shape of routed data — and an opaque one hides whatever passes under it, so * the reader loses the smaller of every pair. At this opacity a crossing reads * as two ribbons rather than as one with a notch in it. */ function SankeyChartLinks({ opacity = LINK_OPACITY, activeOpacity = LINK_ACTIVE_OPACITY, dimOpacity = LINK_DIM_OPACITY, }: SankeyChartLinksProps) { const { layout, links, colors, curve, reveal, windows, status, activeId } = useChart('SankeyChart.Links'); if (status === 'loading' || !layout.links.length) return null; return ( {layout.links.map((link) => { const source = layout.nodes[link.source]; const target = layout.nodes[link.target]; if (!source || !target) return null; const touching = activeId === source.id || activeId === target.id; const window = windows[source.layer] ?? windows[0] ?? { from: 0, to: 1 }; return ( ); })} ); } SankeyChartLinks.displayName = 'SankeyChart.Links'; SankeyChartLinks.slot = 'svg' as const; function Ribbon({ x0, cy0, x1, cy1, thickness, curve, fill, reveal, window, opacity, }: { x0: number; cy0: number; x1: number; cy1: number; thickness: number; curve: number; fill: string; reveal: SharedValue; window: { from: number; to: number }; opacity: number; }) { const { from, to } = window; const settled = useDerivedValue(() => withTiming(opacity, { duration: SELECT_DURATION }) ); const animatedProps = useAnimatedProps(() => { /* * The ribbon thickens about its own centre line rather than growing from * one edge, so it stays anchored where it meets the node instead of * sliding down it as it arrives. */ const grown = thickness * progress(reveal.value, from, to); return { d: flowPath(x0, cy0, x1, cy1, grown, curve), fillOpacity: settled.value, }; }); return ; } export interface SankeyChartNodesProps { /** Corner radius on a node's bar, in points. */ radius?: number; /** A bar's opacity when something else is selected. */ dimOpacity?: number; } /** * The bars the ribbons run between. * * Solid where the ribbons are translucent, because a node is the one thing on * the diagram that is not crossing anything else — it is the edge the flow * arrives at, and it reads as an edge only if nothing shows through it. */ function SankeyChartNodes({ radius = 2, dimOpacity = 0.25 }: SankeyChartNodesProps) { const { layout, colors, reveal, windows, status, activeId } = useChart('SankeyChart.Nodes'); if (status === 'loading' || !layout.nodes.length) return null; return ( {layout.nodes.map((node, index) => { const window = windows[node.layer] ?? windows[0] ?? { from: 0, to: 1 }; return ( ); })} ); } SankeyChartNodes.displayName = 'SankeyChart.Nodes'; SankeyChartNodes.slot = 'svg' as const; function NodeBar({ x, width, y0, y1, radius, fill, reveal, window, opacity, }: { x: number; width: number; y0: number; y1: number; radius: number; fill: string; reveal: SharedValue; window: { from: number; to: number }; opacity: number; }) { const { from, to } = window; const extent = y1 - y0; const centre = (y0 + y1) / 2; const settled = useDerivedValue(() => withTiming(opacity, { duration: SELECT_DURATION }) ); const animatedProps = useAnimatedProps(() => { // Grown about its centre, to match the ribbons meeting it. const grown = extent * progress(reveal.value, from, to); return { y: centre - grown / 2, height: grown, opacity: settled.value }; }); return ; } export interface SankeyChartLabelsProps { className?: string; /** Format the figure beside a name. Defaults to a compact number. */ formatValue?: (value: number, node: SankeyNode) => string; /** Show the figure under the name. */ showValue?: boolean; /** * Hide the name on a bar shorter than this, in points. * * A diagram of forty nodes has bars a few points tall, and forty names at * that spacing overlap into a grey band that hides the flow behind it. The * names that are dropped are the smallest ones, which is where the tooltip * takes over. */ minHeight?: number; } /** * The names, and the press targets that go with them. * * Outside the bars rather than on them. A node's bar is as thick as it was * asked to be — ten points by default — and no name fits inside ten points, so * putting the name on the bar means widening every bar to suit the longest * label and losing the width the ribbons need. * * Which side a name goes on is decided by the column: the last column reads * inwards from the right edge, everything else outwards to the right. So the * names stay inside the chart's box at both ends, instead of the leftmost and * rightmost ones being clipped. * * The target is the label's row, not the bar. A node worth one percent of the * flow is a two-point sliver and cannot be hit; the row it sits in can, and it * is padded out to a proper target where the sliver is smaller than one. */ function SankeyChartLabels({ className, formatValue, showValue = false, minHeight = 6, }: SankeyChartLabelsProps) { const { layout, nodes, width, height, status, activeId, setActiveId, labelFor } = useChart('SankeyChart.Labels'); if (status === 'loading' || !layout.nodes.length) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); /* * Where the neighbouring columns sit, in points across the plot. * * A name's row needs a bound on both sides or it runs the width of the chart * and covers every row it crosses — and since the rows are absolutely * positioned siblings, the last one drawn takes the touch. That is a tap on * one name selecting a node two columns away, which is worse than a small * target because it is wrong rather than merely hard. */ const edges: number[] = []; for (const placed of layout.nodes) { if (!edges.includes(placed.x0)) edges.push(placed.x0); if (!edges.includes(placed.x1)) edges.push(placed.x1); } edges.sort((a, b) => a - b); const nextEdge = (x: number) => edges.find((edge) => edge > x + 1e-6); const previousEdge = (x: number) => { let found: number | undefined; for (const edge of edges) if (edge < x - 1e-6) found = edge; return found; }; return ( <> {layout.nodes.map((node) => { const datum = nodes[node.index]; if (!datum) return null; const extent = node.y1 - node.y0; if (extent < minHeight) return null; /* * Which side the name goes on is decided by where the bar actually is, * not by which column it belongs to. Under a right-to-left layout the * finished diagram is mirrored, so the last column is the one on the * left — reading the side off the column number there puts every name * in a box of zero width and the chart loses all of them. */ const after = nextEdge(node.x1); const before = previousEdge(node.x0); const trailing = after === undefined; const name = labelFor(node.id); const value = format(node.value, datum); const selected = activeId === node.id; /* * A sliver's row is padded out to a real target rather than drawn * taller — growing the row would push it over its neighbours, and two * overlapping targets are worse than a small one. */ const slack = Math.max(0, (MIN_TARGET - extent) / 2); const top = Math.max(0, Math.min(node.y0, height - extent)); return ( setActiveId(selected ? null : node.id)} style={{ position: 'absolute', top, height: extent, justifyContent: 'center', /* * Each row takes the half of its gap nearest its own bar, so the * name leaving one column and the name arriving at the next can * share the space between them without sharing a touch target. */ ...(trailing ? (() => { const from = before === undefined ? 0 : (before + node.x0) / 2; return { left: from, width: Math.max(0, node.x0 - LABEL_GAP - from), alignItems: 'flex-end' as const, }; })() : (() => { const from = node.x1 + LABEL_GAP; const to = after === undefined ? width : (node.x1 + after) / 2; return { left: from, width: Math.max(0, to - from) }; })()), }} className={cn(className)} > {/* * Both alignments are stated rather than inherited. A paragraph's * default alignment follows the reading direction, so under a * right-to-left layout an unaligned name drifts to the far end of * its row and ends up sitting in the middle of the plot instead of * against the bar it belongs to. Which side the name hugs is a * fact about where its bar is, not about the language. */} {name} {showValue ? ( {value} ) : null} ); })} ); } SankeyChartLabels.displayName = 'SankeyChart.Labels'; SankeyChartLabels.slot = 'overlay' as const; export interface SankeyChartTooltipProps { className?: string; /** Format the figures. Defaults to a compact number. */ formatValue?: (value: number) => string; } /** * What the selected node carries: the total through it, and what that total is * made of at each end. * * In and out are shown separately because they are the two readings a flow * diagram is for, and they are only the same number when nothing was lost. A * node where they differ is the interesting one on the whole chart, and a * single total would hide exactly that. * * Anchored beside the node and clamped to the plot, so it never leaves the box * it belongs to — a card half off the edge of a phone is a card nobody can read. */ function SankeyChartTooltip({ className, formatValue }: SankeyChartTooltipProps) { const { layout, width, height, labelFor } = useChart('SankeyChart.Tooltip'); const { activeId, activeValue, incoming, outgoing } = useSankeyChart(); if (!activeId) return null; const node = layout.nodes.find((placed) => placed.id === activeId); if (!node) return null; const format = formatValue ?? compactNumber; const CARD = 132; const trailing = node.x1 + LABEL_GAP + CARD > width; const left = trailing ? Math.max(0, node.x0 - LABEL_GAP - CARD) : Math.min(node.x1 + LABEL_GAP, Math.max(0, width - CARD)); const centre = (node.y0 + node.y1) / 2; return ( {labelFor(activeId)} {format(activeValue)} {`In ${format(incoming)} · Out ${format(outgoing)}`} ); } SankeyChartTooltip.displayName = 'SankeyChart.Tooltip'; SankeyChartTooltip.slot = 'overlay' as const; export interface SankeyChartSkeletonProps { color?: string; } /** * The loading state: a few plain bars and the ribbons between them, carrying no * values. * * Every bar the same height and every ribbon the same thickness, deliberately. * A placeholder with varied thicknesses would be an invented routing, and a * reader cannot tell an invented one from a real one until it changes under * them — which is worse than showing nothing, because it is showing something * wrong. */ function SankeyChartSkeleton({ color }: SankeyChartSkeletonProps) { const { width, height, curve, status } = useChart('SankeyChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); // Held through the fade rather than to the frame the data lands, so the plain // shape dissolves under the real one growing across it instead of leaving a // blank panel between the two. const { mounted, opacity } = useSkeletonHandoff(status === 'loading'); const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value })); const shape = useMemo(() => { if (width <= 0 || height <= 0) return null; const bar = DEFAULT_NODE_WIDTH; const step = (width - bar) / Math.max(SKELETON_COLUMNS - 1, 1); const band = height / 3; const columns = Array.from({ length: SKELETON_COLUMNS }, (_, i) => i * step); const ribbons: string[] = []; for (let i = 0; i < SKELETON_COLUMNS - 1; i += 1) { const from = columns[i]! + bar; const to = columns[i + 1]!; ribbons.push(flowPath(from, height / 3, to, height / 3, band * 0.5, curve)); ribbons.push(flowPath(from, (height * 2) / 3, to, (height * 2) / 3, band * 0.5, curve)); } return { bar, band, columns, ribbons }; }, [width, height, curve]); if (!mounted || !shape) return null; return ( {shape.ribbons.map((d, index) => ( ))} {shape.columns.map((x, index) => ( ))} ); } SankeyChartSkeleton.displayName = 'SankeyChart.Skeleton'; SankeyChartSkeleton.slot = 'svg' as const; export interface SankeyChartHeaderProps extends ViewProps { className?: string; /** Small line above the value — what the flow is of. */ title?: string; /** The readout. The largest thing on the card, and the first thing read. */ value?: string; /** One muted line under the value — a period, a comparison, a caveat. */ caption?: string; /** Trailing slot — a control, a badge, a range picker. */ children?: ReactNode; } /** * The strip above the diagram: what the flow is of and what it totals. * * The value is not derived even though there are sources to add up, because the * formatting is not the chart's to guess: 128400 is a count, a currency or a * rate depending on what was routed. */ function SankeyChartHeader({ className, title, value, caption, children, ...props }: SankeyChartHeaderProps) { return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {children ? {children} : null} ); } SankeyChartHeader.displayName = 'SankeyChart.Header'; SankeyChartHeader.slot = 'header' as const; export const SankeyChart = Object.assign(SankeyChartRoot, { Header: SankeyChartHeader, Links: SankeyChartLinks, Nodes: SankeyChartNodes, Labels: SankeyChartLabels, Tooltip: SankeyChartTooltip, Skeleton: SankeyChartSkeleton, });