/**
* WaterfallChart — how a run of changes carried one total to another.
*
* Composed the same way the other charts are: the grid, the bars, the
* connectors, the axes and the readout are separate children, so a chart that
* wants no grid simply does not have one.
*
* ```tsx
*
*
*
*
*
*
*
* ```
*
* ## What the shape is asserting
*
* **Every bar but a total floats.** A step's bar starts where the previous one
* ended and reaches as far as its own value carries it, so the gap under it is
* the running total it is acting on. That floating is the entire point: a bar
* chart of the same numbers would compare the changes against each other, and
* this compares each of them against the balance it moved.
*
* **A total is anchored to zero.** It is a reading rather than a change, so it
* is measured from the baseline like an ordinary bar and drawn in a neutral
* colour. Marking the opening and closing steps `total` is what gives the run
* two ends to be a bridge between.
*
* **Three colours, and no more.** Up, down, and total. A fourth would have to
* mean something the reader has to be told, and the one thing this chart has
* going for it is that the direction of a bar is legible before its label is.
*
* **The connectors are the sequence.** Without them the bars are a row of
* floating rectangles at unexplained heights; the line from one bar's end to
* the next bar's start is what says the second continues the first.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
cancelAnimation,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import { Text } from '../../primitives/text';
import {
finiteChartDomain,
finiteChartNumber,
} from '../../primitives/finite-chart';
import { barPath, compactNumber, type Plot } from '../../utils/chart';
import { cn } from '../../utils/cn';
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
const AnimatedG = Animated.createAnimatedComponent(G);
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
/** Room left around the plot for the axis labels. */
const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
/**
* Sideways, the step names run down the left instead of along the bottom, so
* the room has to come off that side. Reserved rather than overlaid: a name
* drawn on top of the bars is unreadable against them and makes the bars
* unreadable too.
*/
const PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 76 };
/** Width the readout is laid out at, so it can be clamped inside the plot. */
const LABEL_WIDTH = 148;
/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
const Y_AXIS_WIDTH = 44;
/** Line height of an `xs` label, for centring one on the grid line it names. */
const AXIS_LABEL_HEIGHT = 16;
/** Fallbacks, only reached if the theme CSS was never imported. */
const FALLBACK_RISE = '#10b981';
const FALLBACK_FALL = '#ef4444';
const FALLBACK_TOTAL = '#262626';
type Layer = 'svg' | 'overlay' | 'header';
export type WaterfallChartStatus = 'loading' | 'ready';
export type WaterfallChartOrientation = 'vertical' | 'horizontal';
/** Which of the three roles a step's bar is drawn in. */
export type WaterfallKind = 'rise' | 'fall' | 'total';
export interface WaterfallDatum {
/** Name of the step, as the axis and the readout show it. */
label: string;
/**
* The change this step makes to the running total.
*
* On a `total` step it is added to the running total *before* the bar is
* drawn, so `0` reads the balance as it stands and a non-zero one opens the
* run at a starting balance.
*/
value: number;
/**
* Draw this step as a reading rather than a change: measured from the
* baseline, in the neutral colour, and counted in the legend as a total.
*/
total?: boolean;
/** Explicit colour for this one bar, overriding the role's. */
color?: string;
}
/** One step, resolved against the running total it acts on. */
export interface WaterfallStep {
datum: WaterfallDatum;
label: string;
value: number;
kind: WaterfallKind;
/** Value the bar is measured from — the running total before this step. */
start: number;
/** Value the bar reaches — the running total after it. */
end: number;
}
interface WaterfallChartContextValue {
data: WaterfallDatum[];
steps: WaterfallStep[];
plot: Plot;
status: WaterfallChartStatus;
orientation: WaterfallChartOrientation;
barGap: number;
barWidth: number | undefined;
cornerRadius: number;
minBarLength: number;
fadedOpacity: number;
colors: Record;
domainMin: SharedValue;
domainMax: SharedValue;
/** The settled domain, for the parts that draw text rather than geometry. */
extent: [number, number];
/** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */
reveal: SharedValue;
activeIndex: SharedValue;
activeIndexJS: number;
setActiveIndexJS: (index: number) => void;
}
const WaterfallChartContext = createContext(null);
function useChart(component: string): WaterfallChartContextValue {
const context = useContext(WaterfallChartContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* The step under the finger, for something rendered *inside* the chart. A
* readout in the card's header is outside this provider — use
* `onActiveIndexChange` for that.
*/
export function useWaterfallChart() {
const { steps, activeIndexJS } = useChart('useWaterfallChart');
return {
activeIndex: activeIndexJS,
activeStep: activeIndexJS >= 0 ? (steps[activeIndexJS] ?? null) : null,
};
}
/**
* The running totals every step sits on.
*
* Split out because it is the one piece of the chart that is pure arithmetic
* over the data, and every part that draws anything needs the same answer —
* two parts deriving it separately is two chances for the bars and the
* connectors to disagree about where a step ended.
*/
export function waterfallSteps(data: WaterfallDatum[]): WaterfallStep[] {
const steps: WaterfallStep[] = [];
let running = 0;
for (const datum of data) {
const value = finiteChartNumber(datum.value) ?? 0;
if (datum.total) {
// A total is a reading, so it is measured from the baseline and the
// running figure becomes whatever it reads.
running += value;
steps.push({
datum,
label: datum.label,
value: running,
kind: 'total',
start: 0,
end: running,
});
continue;
}
const start = running;
running += value;
steps.push({
datum,
label: datum.label,
value,
kind: value < 0 ? 'fall' : 'rise',
start,
end: running,
});
}
return steps;
}
export interface WaterfallChartProps extends ViewProps, ChartAccessibilityProps {
className?: string;
/** The steps, in the order they happen. */
data: WaterfallDatum[];
/**
* `loading` holds the bars at the baseline and grows them into the real ones
* when it turns `ready`. One component throughout, rather than a spinner
* swapped for a chart — swapping loses the transition. Add a
* `WaterfallChart.Skeleton` for something to stand in the plot meanwhile.
*/
status?: WaterfallChartStatus;
/** Width ÷ height. `2` is the wide card shape. */
aspectRatio?: number;
/** Milliseconds for the bars to grow in on mount. */
animationDuration?: number;
/** Milliseconds for the value axis to settle after the data changes. */
domainDuration?: number;
/**
* Fix the value axis instead of deriving it. The derived domain always
* includes zero, and one that does not is a run whose bars cannot be
* compared — pass this only when you mean it.
*/
yDomain?: [number, number];
/** `vertical` stands the bars up; `horizontal` lays the run down the side. */
orientation?: WaterfallChartOrientation;
/**
* Fraction of each band left empty, `0` to `1`. A fraction rather than a
* pixel gap so the proportions hold at any width.
*/
barGap?: number;
/** Fixed bar thickness in points. Derived from the band when omitted. */
barWidth?: number;
/** Corner radius on the ends of a bar. */
cornerRadius?: number;
/**
* Smallest length a non-zero bar is drawn at, in points. A step that rounds
* to nothing still happened, and a bar of zero length says it did not.
*/
minBarLength?: number;
/** Opacity of the bars that are not under the finger. */
fadedOpacity?: number;
/** Colour of a step that adds. Defaults to the success token. */
riseColor?: string;
/** Colour of a step that subtracts. Defaults to the destructive token. */
fallColor?: string;
/** Colour of a `total` step. Defaults to the first chart token. */
totalColor?: string;
/**
* The step under the finger as it moves, and `-1`/`null` when it lifts.
* Fires when the index changes, not per frame.
*/
onActiveIndexChange?: (index: number, step: WaterfallStep | null) => void;
/** Drop the axis padding, for a run with no axis or readout. */
compact?: boolean;
children?: ReactNode;
}
/** Imperative handle: re-run the grow-in, for a "replay" control. */
export interface WaterfallChartHandle {
replay: () => void;
}
function partition(children: ReactNode) {
const svg: ReactNode[] = [];
const overlay: ReactNode[] = [];
const header: ReactNode[] = [];
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) return;
const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
const slot = {child};
(layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
});
return { svg, overlay, header };
}
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
const WaterfallChartRoot = forwardRef(
function WaterfallChartRoot(
{
className,
data,
status = 'ready',
aspectRatio = 2,
animationDuration = 700,
domainDuration = 500,
yDomain,
orientation = 'vertical',
barGap = 0.34,
barWidth,
cornerRadius = 4,
minBarLength = 2,
fadedOpacity = 0.3,
riseColor,
fallColor,
totalColor,
onActiveIndexChange,
accessible,
accessibilityLabel,
accessibilityHint,
accessibilityLabelForDatum,
onAccessibilityDatumPress,
compact = false,
children,
...props
},
ref
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [activeIndexJS, setActiveIndexJS] = useState(-1);
const reveal = useSharedValue(0);
const domainMin = useSharedValue(0);
const domainMax = useSharedValue(0);
const activeIndex = useSharedValue(-1);
const reducedMotion = useReducedMotion();
const riseToken = useCSSVariable('--color-success');
const fallToken = useCSSVariable('--color-destructive');
const totalToken = useCSSVariable('--color-chart-1');
const colors = useMemo>(
() => ({
rise: riseColor ?? (typeof riseToken === 'string' ? riseToken : FALLBACK_RISE),
fall: fallColor ?? (typeof fallToken === 'string' ? fallToken : FALLBACK_FALL),
total: totalColor ?? (typeof totalToken === 'string' ? totalToken : FALLBACK_TOTAL),
}),
[riseColor, fallColor, totalColor, riseToken, fallToken, totalToken]
);
const steps = useMemo(() => waterfallSteps(data), [data]);
/*
* Whether an axis is asking for room. It has to be known before the plot is
* laid out, and only the root sees the children early enough to ask — the
* axis itself renders into a box that has already been decided.
*/
const hasYAxis = useMemo(() => {
let found = false;
Children.forEach(children, (child) => {
if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {
found = true;
}
});
return found;
}, [children]);
const pad = compact
? { top: 2, right: 1, bottom: 2, left: 1 }
: orientation === 'horizontal'
? PADDING_SIDEWAYS
: { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
const plot: Plot = {
left: pad.left,
top: pad.top,
width: Math.max(size.width - pad.left - pad.right, 0),
height: Math.max(size.height - pad.top - pad.bottom, 0),
};
const extent = useMemo<[number, number]>(() => {
const explicit = finiteChartDomain(yDomain);
if (explicit) return explicit;
/*
* Both ends of every bar, not just the values. A step's bar occupies the
* span between two running totals, and a domain taken from the changes
* alone would be the range of the *deltas* — which on any run that
* climbs before it falls is a fraction of the height the bars need.
*/
let min = 0;
let max = 0;
for (const step of steps) {
const low = Math.min(step.start, step.end);
const high = Math.max(step.start, step.end);
if (low < min) min = low;
if (high > max) max = high;
}
if (min === 0 && max === 0) return [0, 1];
// Headroom past the furthest bar only. The zero end is left exactly where
// it is: padding it would lift the run off its own baseline.
return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];
}, [steps, yDomain]);
const loading = status === 'loading';
useEffect(() => {
if (loading) return;
const [min, max] = extent;
const first = domainMin.value === 0 && domainMax.value === 0;
if (first || reducedMotion) {
domainMin.value = min;
domainMax.value = max;
return;
}
domainMin.value = withTiming(min, { duration: domainDuration });
domainMax.value = withTiming(max, { duration: domainDuration });
}, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
const revealed = useRef(false);
const playReveal = useMemo(
() => () => {
if (reducedMotion) {
reveal.value = 1;
return;
}
reveal.value = 0;
reveal.value = withTiming(1, {
duration: animationDuration,
easing: Easing.out(Easing.cubic),
});
},
[reducedMotion, animationDuration, reveal]
);
useEffect(() => {
/*
* Going back to `loading` arms the reveal again. Without this a chart that
* is refetched comes back fully drawn on the frame the data lands, which
* reads as the loading state having been for nothing.
*/
if (loading) {
revealed.current = false;
reveal.value = 0;
return;
}
if (revealed.current || plot.width <= 0 || !steps.length) return;
revealed.current = true;
playReveal();
}, [loading, plot.width, steps.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
const handleActiveIndex = useMemo(
() => (index: number) => {
setActiveIndexJS(index);
onActiveIndexChange?.(index, index >= 0 ? (steps[index] ?? null) : null);
},
[onActiveIndexChange, steps]
);
const onLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setSize((current) =>
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
? current
: { width, height }
);
props.onLayout?.(event);
};
const context = useMemo(
() => ({
data,
steps,
plot,
status,
orientation,
barGap,
barWidth,
cornerRadius,
minBarLength,
fadedOpacity,
colors,
domainMin,
domainMax,
extent,
reveal,
activeIndex,
activeIndexJS,
setActiveIndexJS: handleActiveIndex,
}),
// `plot` is rebuilt every render from `size`, so it is compared by value.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
data,
steps,
plot.width,
plot.height,
plot.left,
plot.top,
status,
orientation,
barGap,
barWidth,
cornerRadius,
minBarLength,
fadedOpacity,
colors,
domainMin,
domainMax,
extent,
reveal,
activeIndex,
activeIndexJS,
handleActiveIndex,
]
);
const { svg, overlay, header } = partition(children);
/*
* Two views, because the header is not part of the plot. `aspectRatio` and
* the layout measurement belong to the drawing area alone — measured on the
* outer view they would take in the header too, and the plot would lose as
* much height as the readout took while still claiming the shape asked for.
*/
return (
{header}
[
['label', step.label],
['change', step.value],
['kind', step.kind],
['starts at', step.start],
['ends at', step.end],
]}
/>
{plot.width > 0 ? (
<>
{overlay}
>
) : null}
);
}
);
WaterfallChartRoot.displayName = 'WaterfallChart';
/* -------------------------------------------------------------------------- */
/* Geometry */
/* -------------------------------------------------------------------------- */
/** The band measurements every drawn part reads the same way. */
interface Bands {
along: number;
across: number;
alongStart: number;
acrossStart: number;
band: number;
thickness: number;
}
/**
* How the plot divides between the steps.
*
* A worklet because the bars rebuild on the UI thread every frame the domain is
* tweening, and shared with the parts that only run on JS so a label and the
* bar it names can never land on different centres.
*/
function bandsOf(
plot: Plot,
total: number,
horizontal: boolean,
barGap: number,
barWidth: number | undefined
): Bands {
'worklet';
const along = horizontal ? plot.height : plot.width;
const across = horizontal ? plot.width : plot.height;
const band = along / Math.max(1, total);
const usable = band * (1 - barGap);
return {
along,
across,
alongStart: horizontal ? plot.top : plot.left,
acrossStart: horizontal ? plot.left : plot.top,
band,
thickness: Math.min(barWidth ?? usable, usable),
};
}
/* -------------------------------------------------------------------------- */
/* SVG layer */
/* -------------------------------------------------------------------------- */
export interface WaterfallChartGridProps {
/** How many lines to draw across the value axis. */
rows?: number;
color?: string;
dashArray?: string;
opacity?: number;
}
/**
* Lines across the value axis, so a bar can be read against a number rather
* than only against the bar beside it.
*/
function WaterfallChartGrid({
rows = 4,
color,
dashArray = '4,6',
opacity = 1,
}: WaterfallChartGridProps) {
const { plot, orientation } = useChart('WaterfallChart.Grid');
const token = useCSSVariable('--color-border');
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);
return (
{lines.map((fraction) =>
orientation === 'vertical' ? (
) : (
)
)}
);
}
WaterfallChartGrid.displayName = 'WaterfallChart.Grid';
WaterfallChartGrid.layer = 'svg' as Layer;
export interface WaterfallChartBarsProps {
/** Corner radius, overriding the chart's. */
cornerRadius?: number;
}
/**
* The bars.
*
* Six animated paths a frame rather than one per step: one per role, so the
* three colours can be three fills, and each of those split into the bar under
* the finger and the rest, so the others can dim without every bar carrying its
* own opacity. A run of forty steps costs the same as a run of four.
*
* Each bar grows from its own `start` towards its `end` rather than up from the
* baseline. A step is a movement from one balance to another, and growing it
* from zero would animate a quantity the chart is not claiming.
*/
function WaterfallChartBars({ cornerRadius }: WaterfallChartBarsProps) {
const {
steps,
plot,
status,
orientation,
barGap,
barWidth,
cornerRadius: chartRadius,
minBarLength,
fadedOpacity,
colors,
domainMin,
domainMax,
reveal,
activeIndex,
} = useChart('WaterfallChart.Bars');
const radius = cornerRadius ?? chartRadius;
const loading = status === 'loading';
const total = steps.length;
const horizontal = orientation === 'horizontal';
/*
* All six paths come out of one builder, filtered by role and by whether the
* bar is the active one. Six passes over the data a frame is still cheaper
* than the bookkeeping needed to build them all at once, and it keeps the
* geometry in exactly one place.
*/
const build = (kind: WaterfallKind, wantActive: boolean) => () => {
'worklet';
if (!total || plot.width <= 0) {
return { d: '', opacity: 1 };
}
const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
const min = domainMin.value;
const max = domainMax.value;
const range = max - min || 1;
const grow = reveal.value;
const active = activeIndex.value;
/*
* Where a value sits along the value axis. Vertical counts down from the
* top, horizontal counts up from the left — the same scale read in
* opposite directions, which is the only thing orientation changes.
*/
const project = (value: number) => {
'worklet';
const fraction = (value - min) / range;
return horizontal
? bands.acrossStart + fraction * bands.across
: bands.acrossStart + bands.across - fraction * bands.across;
};
let d = '';
for (let i = 0; i < total; i++) {
const step = steps[i];
if (!step || step.kind !== kind) continue;
if ((i === active) !== wantActive) continue;
/*
* Staggered by step, but every bar still finishes inside the one
* duration: the window each gets is what is left after the stagger, so a
* run of forty steps does not take forty times as long to arrive.
*/
const startAt = total > 1 ? (i / total) * 0.45 : 0;
const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));
const reached = loading ? step.start : step.start + (step.end - step.start) * eased;
const from = project(step.start);
const to = project(reached);
let length = Math.abs(to - from);
// A step that rounds to nothing still happened, and a bar of zero length
// says it did not. Held back until the grow-in has actually started, so
// the stub does not appear before the bar it belongs to.
if (minBarLength > 0 && eased > 0 && !loading && length < minBarLength) {
length = minBarLength;
}
if (length <= 0) continue;
const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
// Which way the bar points is the direction of the change, not the sign
// of the value — a fall from 900 to 400 points down whatever those two
// numbers are, and a total always points away from the baseline.
const forward = reached >= step.start;
d += horizontal
? barPath(
forward ? from : from - length,
lead,
length,
bands.thickness,
radius,
forward ? 'right' : 'left'
)
: barPath(
lead,
forward ? from - length : from,
bands.thickness,
length,
radius,
forward ? 'up' : 'down'
);
}
// Dimming only happens while something *is* active; with nothing under the
// finger every bar is at full ink, which is the resting state.
const dim = !wantActive && active >= 0 ? fadedOpacity : 1;
return { d, opacity: dim };
};
const riseRest = useAnimatedProps(build('rise', false));
const riseActive = useAnimatedProps(build('rise', true));
const fallRest = useAnimatedProps(build('fall', false));
const fallActive = useAnimatedProps(build('fall', true));
const totalRest = useAnimatedProps(build('total', false));
const totalActive = useAnimatedProps(build('total', true));
return (
);
}
WaterfallChartBars.displayName = 'WaterfallChart.Bars';
WaterfallChartBars.layer = 'svg' as Layer;
export interface WaterfallChartConnectorsProps {
color?: string;
dashArray?: string;
strokeWidth?: number;
opacity?: number;
}
/**
* The lines from each bar's end to the next bar's start.
*
* Drawn under the bars, and reaching the full width of both bands rather than
* only the gap between them, so the ends are hidden behind the bars they touch
* and the line reads as passing behind the run instead of stopping short of it.
*
* They arrive with the reveal, each one held back until the bar on its left has
* finished growing — a connector drawn to a bar that is not there yet points at
* nothing.
*/
function WaterfallChartConnectors({
color,
dashArray = '3,4',
strokeWidth = 1,
opacity = 1,
}: WaterfallChartConnectorsProps) {
const {
steps,
plot,
status,
orientation,
barGap,
barWidth,
domainMin,
domainMax,
reveal,
} = useChart('WaterfallChart.Connectors');
const token = useCSSVariable('--color-muted-foreground');
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.4)');
const total = steps.length;
const horizontal = orientation === 'horizontal';
const loading = status === 'loading';
const animatedProps = useAnimatedProps(() => {
'worklet';
if (total < 2 || plot.width <= 0 || loading) return { d: '' };
const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
const min = domainMin.value;
const max = domainMax.value;
const range = max - min || 1;
const grow = reveal.value;
const project = (value: number) => {
'worklet';
const fraction = (value - min) / range;
return horizontal
? bands.acrossStart + fraction * bands.across
: bands.acrossStart + bands.across - fraction * bands.across;
};
let d = '';
for (let i = 0; i < total - 1; i++) {
const step = steps[i];
if (!step) continue;
// The same window the bar on the left is growing in. The connector only
// starts once that bar has arrived at the level it is drawn at.
const startAt = total > 1 ? (i / total) * 0.45 : 0;
const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));
if (eased <= 0) continue;
const level = project(step.end);
const from = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
const to = bands.alongStart + (i + 2) * bands.band - (bands.band - bands.thickness) / 2;
// Extends as the next bar arrives, so the line and the bar it is
// reaching for grow together rather than the line waiting drawn.
const reach = from + (to - from) * eased;
d += horizontal
? `M${level},${from}L${level},${reach}`
: `M${from},${level}L${reach},${level}`;
}
return { d };
});
if (total < 2) return null;
return (
);
}
WaterfallChartConnectors.displayName = 'WaterfallChart.Connectors';
WaterfallChartConnectors.layer = 'svg' as Layer;
/** How much of the value axis a placeholder bar takes. */
const SKELETON_LENGTH = 0.18;
/** Steps to draw when there is no data yet to count them from. */
const SKELETON_BARS = 6;
export interface WaterfallChartSkeletonProps {
/**
* How many placeholder bars to draw. Defaults to one per step, and to six
* when the data has not arrived — the count is the one thing a loading chart
* can be honest about only if it already has the steps.
*/
bars?: number;
/** Milliseconds for one pass of the sweep. */
duration?: number;
color?: string;
}
/**
* The loading state: a row of short, equal stubs on the baseline, with a
* highlight travelling across them.
*
* Equal and on the baseline on purpose. Placeholder bars at differing heights
* are a run the reader has no way to tell from the real one until it changes
* under them, and floating them would invent a set of running totals — which is
* the one thing this chart exists to report.
*/
function WaterfallChartSkeleton({
bars,
duration = 1400,
color,
}: WaterfallChartSkeletonProps) {
const { plot, status, orientation, steps, barGap, barWidth, cornerRadius } = useChart(
'WaterfallChart.Skeleton'
);
const token = useCSSVariable('--color-skeleton');
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
const highlightToken = useCSSVariable('--color-chart-1');
const highlight = typeof highlightToken === 'string' ? highlightToken : FALLBACK_TOTAL;
// Unique per instance: a fixed id makes two of these on one screen share a
// gradient, and the second one to mount wins.
const gradientId = `panelui-waterfall-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
const sweep = useSharedValue(0);
const reducedMotion = useReducedMotion();
const loading = status === 'loading';
useEffect(() => {
if (!loading || reducedMotion) {
cancelAnimation(sweep);
sweep.value = 0;
return;
}
sweep.value = 0;
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
return () => cancelAnimation(sweep);
}, [loading, reducedMotion, duration, sweep]);
// The band travels by moving the gradient's own endpoints, so the whole
// effect is two numbers changing on the UI thread.
const animatedProps = useAnimatedProps(() => ({
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
}));
const horizontal = orientation === 'horizontal';
const total = Math.max(1, bars ?? (steps.length || SKELETON_BARS));
const d = useMemo(() => {
if (plot.width <= 0 || plot.height <= 0) return '';
const bands = bandsOf(plot, total, horizontal, barGap, barWidth);
const length = bands.across * SKELETON_LENGTH;
let path = '';
for (let i = 0; i < total; i += 1) {
const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;
path += horizontal
? barPath(plot.left, lead, length, bands.thickness, cornerRadius, 'right')
: barPath(
lead,
plot.top + plot.height - length,
bands.thickness,
length,
cornerRadius,
'up'
);
}
return path;
}, [plot, horizontal, total, barGap, barWidth, cornerRadius]);
// Held through the fade rather than to the frame the data lands: the bars
// grow out of the stubs, and cutting the stubs at the status change leaves
// the panel empty until the first bar has length.
const { mounted, opacity } = useSkeletonHandoff(loading);
const fadeProps = useAnimatedProps(() => ({ opacity: opacity.value }));
if (!mounted || !d) return null;
return (
);
}
WaterfallChartSkeleton.displayName = 'WaterfallChart.Skeleton';
WaterfallChartSkeleton.layer = 'svg' as Layer;
/* -------------------------------------------------------------------------- */
/* Overlay layer */
/* -------------------------------------------------------------------------- */
/**
* Narrowest a step label may be drawn before the axis starts dropping some.
* Roughly three characters at `xs`, which is what a short name needs.
*/
const MIN_BAND_LABEL = 34;
export interface WaterfallChartXAxisProps {
/**
* How many labels to show. Every step by default, thinned only when the bands
* get too narrow to read — pass a number to force it lower.
*/
ticks?: number;
/** Turn a step into its label. Defaults to its `label`. */
format?: (step: WaterfallStep, index: number) => string;
className?: string;
}
/**
* The step names, one under each band it has room for. Real text rather than
* SVG text, so they follow the theme's font and the platform's text scaling —
* SVG text does neither.
*/
function WaterfallChartXAxis({ ticks, format, className }: WaterfallChartXAxisProps) {
const { steps, plot, orientation } = useChart('WaterfallChart.XAxis');
const labels = useMemo(() => {
if (!steps.length) return [];
/*
* Every step, unless they will not fit. The axis asks the plot how much
* room there is and only thins when the answer is not enough — a fixed tick
* count drops names that had room to be drawn.
*/
const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));
const count = Math.max(1, Math.min(ticks ?? room, steps.length));
// Every nth band, rather than a fractional step rounded to the nearest
// index — rounding lands on the same band twice and skips its neighbour.
const stride = Math.ceil(steps.length / count);
const picked: { key: number; text: string }[] = [];
for (let index = 0; index < steps.length; index += stride) {
const step = steps[index];
if (!step) continue;
picked.push({ key: index, text: format ? format(step, index) : step.label });
}
return picked;
}, [steps, ticks, format, plot.width]);
if (orientation === 'horizontal') return null;
/*
* One box per band, exactly the band's width. Tiling them rather than giving
* each a fixed width means they can never overlap each other and the first
* and last can never hang off the ends of the plot — the row of labels
* occupies precisely the space the bars do.
*/
const bandWidth = steps.length > 0 ? plot.width / steps.length : 0;
return (
{labels.map((label) => (
{label.text}
))}
);
}
WaterfallChartXAxis.displayName = 'WaterfallChart.XAxis';
WaterfallChartXAxis.layer = 'overlay' as Layer;
export interface WaterfallChartYAxisProps {
/** How many labels to show along the value axis. */
ticks?: number;
/** Format a value for its label. Defaults to a compact number. */
format?: (value: number) => string;
className?: string;
}
/** Value labels down the side, aligned to the grid lines. */
function WaterfallChartYAxis({ ticks = 4, format, className }: WaterfallChartYAxisProps) {
const { plot, steps, orientation, extent } = useChart('WaterfallChart.YAxis');
const horizontal = orientation === 'horizontal';
/*
* Read off the settled domain rather than the shared values the paths use.
* A label is text, and text is JS — following the tween would re-render on
* every frame of it to redraw a number nobody can read while it moves.
*/
const labels = useMemo(() => {
if (horizontal) {
// Sideways, the side of the chart is the step axis.
return steps.map((step, index) => ({ key: index, text: step.label }));
}
const [min, max] = extent;
if (min === 0 && max === 0) return [];
return Array.from({ length: ticks + 1 }, (_unused, index) => {
const value = max - ((max - min) * index) / ticks;
return { key: index, text: format ? format(value) : compactNumber(value) };
});
}, [extent, ticks, format, horizontal, steps]);
/*
* Sideways the labels sit in the gutter the plot already left for them, one
* band each — `flex-1` per row rather than spacing them edge to edge, so
* every name lands beside its own bar instead of only the first and last
* doing so. Upright there are no bands to line up with, so the ticks space
* themselves against the grid.
*/
return (
{labels.map((label) => (
{label.text}
))}
);
}
WaterfallChartYAxis.displayName = 'WaterfallChart.YAxis';
WaterfallChartYAxis.layer = 'overlay' as Layer;
// Read by the root, which has to leave room for the labels before it lays the
// plot out — an axis drawn over the plot is unreadable, and makes what it is
// drawn over unreadable too.
WaterfallChartYAxis.axis = 'y' as const;
export interface WaterfallChartValuesProps {
/** Format a step's number. Defaults to a signed compact number. */
format?: (step: WaterfallStep, index: number) => string;
className?: string;
}
/**
* The change each step made, written at the far end of its bar.
*
* Signed, because on this chart the sign is the reading: a bar's direction
* already says which way it went, and a label that drops the sign makes the
* two directions look like the same number twice.
*
* Only drawn upright. Sideways the bars run across a plot whose width is a
* phone's, and a number at the end of one has nowhere to go that is not on top
* of the bar or off the chart.
*/
function WaterfallChartValues({ format, className }: WaterfallChartValuesProps) {
const { steps, plot, orientation, status, barGap, barWidth, extent } =
useChart('WaterfallChart.Values');
if (orientation === 'horizontal' || status === 'loading' || !steps.length) return null;
if (plot.width <= 0 || plot.height <= 0) return null;
const bands = bandsOf(plot, steps.length, false, barGap, barWidth);
const [min, max] = extent;
const range = max - min || 1;
return (
{steps.map((step, index) => {
const fraction = (step.end - min) / range;
const y = plot.top + plot.height - fraction * plot.height;
const forward = step.end >= step.start;
const text = format
? format(step, index)
: step.kind === 'total'
? compactNumber(step.value)
: `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`;
return (
{text}
);
})}
);
}
WaterfallChartValues.displayName = 'WaterfallChart.Values';
WaterfallChartValues.layer = 'overlay' as Layer;
export interface WaterfallChartTooltipProps {
/** Format the step's change. Defaults to a signed compact number. */
formatValue?: (step: WaterfallStep) => string;
/** Format the running total line. Return `null` to drop it. */
formatTotal?: (step: WaterfallStep) => string | null;
className?: string;
}
/**
* The readout, and the drag that drives it.
*
* It reports two numbers, because a step on this chart has two: what it changed
* by, and what the balance stood at afterwards. The second is the one a bar's
* position encodes and its length does not, so a readout that only gave the
* change would leave the reader converting the height back by eye.
*
* The hit area is the whole plot. A readout you have to land on the bar to
* summon is a readout nobody finds — and the bars here are narrower than a bar
* chart's, since the gap between them is what the connectors run through.
*/
function WaterfallChartTooltip({
formatValue,
formatTotal,
className,
}: WaterfallChartTooltipProps) {
const {
steps,
plot,
orientation,
colors,
activeIndex,
activeIndexJS,
setActiveIndexJS,
status,
} = useChart('WaterfallChart.Tooltip');
const total = steps.length;
const horizontal = orientation === 'horizontal';
const left = plot.left;
const top = plot.top;
const width = plot.width;
const height = plot.height;
/*
* The readout's own height, measured rather than assumed. Sideways it has to
* be clamped inside the plot vertically, and how tall it is depends on
* whether the running total line is shown.
*/
const labelHeight = useSharedValue(0);
/*
* Declared inside the memo, next to its callers: a worklet may only call
* another worklet, and the rule is enforced by crashing rather than warning.
*/
const pan = useMemo(() => {
const resolve = (x: number, y: number) => {
'worklet';
if (!total) return;
const span = horizontal ? height : width;
const offset = (horizontal ? y - top : x - left) / (span || 1);
// Bands, not points: the finger is inside whichever slice it lands on,
// which is a floor rather than a round to the nearest centre.
const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));
if (next === activeIndex.value) return;
activeIndex.value = next;
runOnJS(setActiveIndexJS)(next);
};
return Gesture.Pan()
.minDistance(0)
.onBegin((event) => {
'worklet';
resolve(event.x, event.y);
})
.onUpdate((event) => {
'worklet';
resolve(event.x, event.y);
})
.onFinalize(() => {
'worklet';
activeIndex.value = -1;
runOnJS(setActiveIndexJS)(-1);
});
}, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);
// The readout centres over its band and is clamped inside the plot, so it
// never runs off the edge at the first or last one. Which axis it follows is
// the axis the bands run along.
const labelStyle = useAnimatedStyle(() => {
const index = activeIndex.value;
if (index < 0 || !total) return { opacity: 0 };
const band = (horizontal ? plot.height : plot.width) / total;
const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);
const half = LABEL_WIDTH / 2;
const clamped = Math.min(
plot.left + plot.width - half,
Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)
);
if (!horizontal) {
return { opacity: 1, transform: [{ translateX: clamped - half }] };
}
const tall = labelHeight.value;
const y = Math.min(
plot.top + Math.max(plot.height - tall, 0),
Math.max(plot.top, centre - tall / 2)
);
return { opacity: 1, transform: [{ translateX: clamped - half }, { translateY: y }] };
});
const active = activeIndexJS >= 0 ? steps[activeIndexJS] : null;
const fmtValue =
formatValue ??
((step: WaterfallStep) =>
step.kind === 'total'
? compactNumber(step.value)
: `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`);
const fmtTotal =
formatTotal ??
((step: WaterfallStep) => (step.kind === 'total' ? null : compactNumber(step.end)));
if (status === 'loading') return null;
const runningTotal = active ? fmtTotal(active) : null;
return (
{active ? (
{
labelHeight.value = event.nativeEvent.layout.height;
}}
className={cn(
'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
className
)}
>
{active.label}
{fmtValue(active)}
{runningTotal ? (
{runningTotal}
) : null}
) : null}
);
}
WaterfallChartTooltip.displayName = 'WaterfallChart.Tooltip';
WaterfallChartTooltip.layer = 'overlay' as Layer;
/** One role's colour and name. Shared by the legend and the header. */
function RoleSwatch({ color, label }: { color: string; label: string }) {
return (
{label}
);
}
export interface WaterfallChartLegendProps extends ViewProps {
className?: string;
/** Names for the three roles. */
labels?: Partial>;
}
/**
* A swatch and a name for each role the run actually contains.
*
* Three entries at most, and only the ones present — a run with no totals in it
* listing a "Total" colour is a key to a colour that is not on the chart.
*/
function WaterfallChartLegend({ className, labels, ...props }: WaterfallChartLegendProps) {
const { steps, colors } = useChart('WaterfallChart.Legend');
const present = useMemo(() => {
const order: WaterfallKind[] = ['rise', 'fall', 'total'];
const seen = new Set(steps.map((step) => step.kind));
return order.filter((kind) => seen.has(kind));
}, [steps]);
if (!present.length) return null;
const names: Record = {
rise: labels?.rise ?? 'Increase',
fall: labels?.fall ?? 'Decrease',
total: labels?.total ?? 'Total',
};
return (
{present.map((kind) => (
))}
);
}
WaterfallChartLegend.displayName = 'WaterfallChart.Legend';
WaterfallChartLegend.layer = 'overlay' as Layer;
/* -------------------------------------------------------------------------- */
/* Header layer */
/* -------------------------------------------------------------------------- */
export interface WaterfallChartHeaderProps extends ViewProps {
className?: string;
/** Small line above the value — what the run 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 total. */
caption?: string;
/** Names for the three roles, as the legend takes. */
labels?: Partial>;
/**
* Draw a swatch and a name per role along the trailing edge. Prefer this to
* `WaterfallChart.Legend` on a chart that has a header: the legend floats
* over the plot, where it competes with the bars for the same corner.
*/
legend?: boolean;
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
children?: ReactNode;
}
/**
* The strip above the plot: what the run is of, what it currently reads, and
* what the colours mean.
*
* The value is not derived here. A readout that follows the finger belongs to
* whoever owns the data — take it from `onActiveIndexChange` and pass the
* formatted string down, so one header can show the closing balance when
* nothing is pressed and a step's change when something is.
*/
function WaterfallChartHeader({
className,
title,
value,
caption,
labels,
legend = false,
children,
...props
}: WaterfallChartHeaderProps) {
const { steps, colors } = useChart('WaterfallChart.Header');
const present = useMemo(() => {
const order: WaterfallKind[] = ['rise', 'fall', 'total'];
const seen = new Set(steps.map((step) => step.kind));
return order.filter((kind) => seen.has(kind));
}, [steps]);
const names: Record = {
rise: labels?.rise ?? 'Increase',
fall: labels?.fall ?? 'Decrease',
total: labels?.total ?? 'Total',
};
const trailing =
children ??
(legend && present.length ? (
{present.map((kind) => (
))}
) : null);
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{/* Shrinkable, unlike a view's default in React Native. Held rigid, a
three-role key takes the width it wants and the caption underneath the
value wraps to two lines to make room for it. */}
{trailing ? {trailing} : null}
);
}
WaterfallChartHeader.displayName = 'WaterfallChart.Header';
WaterfallChartHeader.layer = 'header' as Layer;
export const WaterfallChart = Object.assign(WaterfallChartRoot, {
Header: WaterfallChartHeader,
Grid: WaterfallChartGrid,
Connectors: WaterfallChartConnectors,
Bars: WaterfallChartBars,
Values: WaterfallChartValues,
Skeleton: WaterfallChartSkeleton,
XAxis: WaterfallChartXAxis,
YAxis: WaterfallChartYAxis,
Tooltip: WaterfallChartTooltip,
Legend: WaterfallChartLegend,
});