/**
* PieChart — one whole, divided between its parts.
*
* ```tsx
*
*
*
*
*
* ```
*
* ## What it is, against the ring beside it
*
* A ring chart draws a value against *its own* target, so three rings can all
* sit at ninety percent of three unrelated numbers and nothing has to add up.
* A pie is the opposite claim: every slice is a share of one total, the angles
* must come to a full turn, and a slice only means anything next to the others.
* That is why nothing here takes a `maxValue` and why every value is normalised
* against the sum — the sum *is* the subject.
*
* It follows that a pie is the wrong shape for a great many things. Two numbers
* that do not belong to one whole, a series over time, anything a reader has to
* compare precisely: all of those are a bar chart, because an angle is the
* hardest quantity to read off a page and the fifth-largest slice of eleven is
* not a fact anybody is going to extract. Use it for a handful of parts of one
* obvious total, and put the number in the middle.
*
* ## Drawing
*
* Each slice is a filled path rather than a stroked arc, because a slice is a
* *region* — two arcs and two radial edges — and a stroke is a band of even
* thickness with no ends of its own. `wedgePath` builds it, and rebuilds it on
* the UI thread on every frame of the reveal.
*
* That is what makes the reveal an unroll rather than a fade: one angle sweeps
* clockwise from the start and each slice is drawn only as far as it has got
* to. The pie fills the way it would be drawn by hand, and the slices arrive in
* the order they are listed rather than all at once.
*
* Touch, not hover: a slice is selected by pressing it, and pressing the same
* one again clears the selection. There is no equivalent of a pointer resting
* somewhere without committing, so a chart that only revealed its numbers on
* hover would never reveal them at all.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { 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 } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { compactNumber, useSeriesColor, wedgePath } from '../../utils/chart';
import { cn } from '../../utils/cn';
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
const AnimatedPath = Animated.createAnimatedComponent(Path);
/** Milliseconds for a slice to lift out and settle back as it is selected. */
const SELECT_DURATION = 180;
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
type Slot = 'svg' | 'overlay' | 'header' | 'footer';
/** Whether the chart is showing data or waiting for it. */
export type PieChartStatus = 'loading' | 'ready';
/** One slice. Its share is worked out from the others, so there is no maximum. */
export interface PieDatum {
/** Name for the legend, the centre readout and the accessibility label. */
label: string;
/** How much of the whole this slice is. Negatives are treated as zero. */
value: number;
/** Explicit colour, overriding the `--color-chart-*` token. */
color?: string;
}
/** A slice's place on the dial, in turns clockwise from twelve o'clock. */
interface SliceAngles {
from: number;
to: number;
/** Halfway along it, which is the direction it lifts out in. */
mid: number;
/** Its share of the total, 0 to 1. */
fraction: number;
}
interface PieChartContextValue {
data: PieDatum[];
/** Everything the values add up to. Zero when there is nothing to show. */
total: number;
size: number;
/** Outer radius in points, after the room for lifting a slice out is taken. */
radius: number;
/** Inner radius in points. Zero for a pie, above it for a donut. */
hole: number;
colors: string[];
slices: SliceAngles[];
/** Where the dial starts and how far it runs, both in turns. */
origin: number;
span: number;
/** 0 to 1 as the pie unrolls. */
reveal: SharedValue;
status: PieChartStatus;
activeIndex: number;
setActiveIndex: (index: number) => void;
}
const PieChartContext = createContext(null);
function useChart(component: string): PieChartContextValue {
const context = useContext(PieChartContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/** The selected slice and its share, for something rendered inside the chart. */
export function usePieChart() {
const { data, slices, activeIndex } = useChart('usePieChart');
return {
activeIndex,
activeSlice: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,
/** The selected slice's share of the whole, 0 to 1. */
activeFraction: activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0,
};
}
export interface PieChartProps extends ViewProps {
className?: string;
/** One entry per slice, in the order they are drawn clockwise. */
data: PieDatum[];
/** Fixed diameter in points. Measured from the container when omitted. */
size?: number;
/**
* The hole, as a share of the radius. `0` is a pie; anything above it is a
* donut, and `0.55`–`0.65` is the range that leaves room for a readout in the
* middle without the band getting thin enough to be hard to hit.
*
* Given as a share rather than in points so a chart keeps its proportions at
* whatever size it is measured at.
*/
innerRadius?: number;
/** Where the first slice begins, in degrees clockwise from twelve o'clock. */
startAngle?: number;
/**
* Where the last one ends, on the same clock. Leaving a turn's worth between
* the two gives a closed pie; anything less leaves a gap and reads as a dial.
*/
endAngle?: number;
/** Gap between one slice and the next, in degrees. */
padAngle?: number;
/**
* The smallest angle any non-zero slice is drawn at, in degrees.
*
* A slice worth a fifth of a percent is a hairline nobody can see and nobody
* can press, so it reads as missing rather than as small — and "missing" is a
* different claim from "nearly none". The angle it borrows comes off the
* others in proportion, so the turn still closes.
*/
minAngle?: number;
/** Milliseconds for the pie to unroll. */
animationDuration?: number;
/** `loading` draws a plain muted ring until the data arrives. */
status?: PieChartStatus;
/** Selected slice. Leave unset to let the chart track it. */
activeIndex?: number;
/** Fires with the selected slice, or `-1` when the selection is cleared. */
onActiveIndexChange?: (index: number) => void;
children?: ReactNode;
}
/** Imperative handle: re-run the unroll, for a "replay" control. */
export interface PieChartHandle {
replay: () => void;
}
/** Room kept outside the pie so a selected slice has somewhere to lift into. */
const LIFT = 8;
const PieChartRoot = forwardRef(function PieChartRoot(
{
className,
data,
size,
innerRadius = 0,
startAngle = 0,
endAngle = 360,
padAngle = 0,
minAngle = 0,
animationDuration = 620,
status = 'ready',
activeIndex: activeIndexProp,
onActiveIndexChange,
children,
...props
},
ref
) {
const [measured, setMeasured] = useState(0);
const [internalActive, setInternalActive] = useState(-1);
const reveal = useSharedValue(0);
const reducedMotion = useReducedMotion();
const controlled = activeIndexProp !== undefined;
const activeIndex = controlled ? activeIndexProp : internalActive;
const setActiveIndex = useMemo(
() => (index: number) => {
if (!controlled) setInternalActive(index);
onActiveIndexChange?.(index);
},
[controlled, onActiveIndexChange]
);
const box = size ?? measured;
const radius = Math.max(box / 2 - LIFT, 0);
const hole = radius * Math.max(0, Math.min(innerRadius, 0.95));
const origin = startAngle / 360;
// Clamped to one turn, because a pie drawn past 360° laps itself and the
// slice underneath is simply gone.
const span = Math.min(Math.max(endAngle - startAngle, 0), 360) / 360;
const total = useMemo(
() => data.reduce((sum, slice) => sum + Math.max(0, slice.value), 0),
[data]
);
const slices = useMemo(() => {
const count = data.length;
if (!count || total <= 0 || span <= 0) return [];
/*
* The gaps and the floors are taken out of the turn *first*, and what is
* left is shared by value. Adding them instead would push the last slice
* past the end of the dial, which on a closed pie means over the first one.
*/
const pad = Math.min(padAngle / 360, span / (count * 2));
const gaps = count > 1 || span < 1 ? pad * count : 0;
const floor = Math.min(minAngle / 360, (span - gaps) / (count * 2));
const drawn = data.filter((slice) => Math.max(0, slice.value) > 0).length;
const floors = floor * drawn;
const free = Math.max(span - gaps - floors, 0);
const result: SliceAngles[] = [];
let cursor = origin + pad / 2;
for (const slice of data) {
const value = Math.max(0, slice.value);
const fraction = value / total;
const sweep = value > 0 ? floor + free * fraction : 0;
result.push({
from: cursor,
to: cursor + sweep,
mid: cursor + sweep / 2,
fraction,
});
cursor += sweep + pad;
}
return result;
}, [data, total, origin, span, padAngle, minAngle]);
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]
);
const loading = status === 'loading';
const revealed = useRef(false);
useEffect(() => {
if (loading) {
revealed.current = false;
reveal.value = 0;
return;
}
if (revealed.current || box <= 0 || !slices.length) return;
revealed.current = true;
playReveal();
}, [loading, box, slices.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
// Resolved here rather than inside the slices, so the legend, the header and
// the centre readout can name a slice's colour without drawing one.
const c1 = useSeriesColor(undefined, 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]);
const colors = useMemo(
() => data.map((slice, index) => slice.color ?? palette[index % palette.length]!),
[data, palette]
);
const onLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
const next = Math.round(Math.min(width, height));
if (next !== measured) setMeasured(next);
props.onLayout?.(event);
};
const context = useMemo(
() => ({
data,
total,
size: box,
radius,
hole,
colors,
slices,
origin,
span,
reveal,
status,
activeIndex,
setActiveIndex,
}),
[
data,
total,
box,
radius,
hole,
colors,
slices,
origin,
span,
reveal,
status,
activeIndex,
setActiveIndex,
]
);
const slots: Record = {
svg: [],
overlay: [],
header: [],
footer: [],
};
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) return;
const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
slots[slot in slots ? slot : 'overlay'].push(
{child}
);
});
return (
{/*
* Two views, because the header is not part of the dial. The square and
* the layout measurement belong to the drawing area alone — measured on
* the outer view they would take in the header too, and the pie would be
* laid out inside a box taller than the one it is drawn in.
*/}
{slots.header}
{box > 0 ? (
<>
{/*
* The centre sits over the SVG rather than inside it: it is text,
* and SVG text ignores the platform's text scaling and the
* theme's font.
*/}
{slots.overlay}
>
) : null}
{/*
* The key goes *under* the square, in flow, rather than in the corners
* left over inside it. A ring chart can get away with the corners
* because three arcs is a long key; a pie is routinely five or six
* slices with names like "Everything else", and a key of that size laid
* over the drawing either covers it or is squeezed to one letter a line.
*/}
{slots.footer}
);
});
PieChartRoot.displayName = 'PieChart';
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
export interface PieChartSlicesProps {
/** Rounds the four turns of each slice, in points. */
cornerRadius?: number;
/** How far a selected slice lifts out of the pie, in points. */
popOut?: number;
/** Opacity of the slices that are not selected, once one is. */
dimOpacity?: number;
}
/**
* Every slice, drawn in the order the data lists them.
*
* One part rather than one per datum, unlike the rings next door. A ring is
* configured on its own — its own thickness, its own cap, its own segment count
* — because it is its own measurement. Slices of a pie are not: they share a
* radius, a hole and a dial by definition, and a chart where one of them could
* be given a different radius would be a chart drawing a lie.
*/
function PieChartSlices({
cornerRadius = 0,
popOut = 6,
dimOpacity = 0.35,
}: PieChartSlicesProps) {
const { data, slices, colors, radius, hole, size, reveal, origin, span, status, activeIndex, setActiveIndex } =
useChart('PieChart.Slices');
if (status === 'loading' || radius <= 0) return null;
return (
{slices.map((angles, index) => {
const datum = data[index];
if (!datum || angles.to <= angles.from) return null;
return (
= 0 && activeIndex !== index}
dimOpacity={dimOpacity}
label={datum.label}
percent={Math.round(angles.fraction * 100)}
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
/>
);
})}
);
}
PieChartSlices.displayName = 'PieChart.Slices';
PieChartSlices.slot = 'svg' as const;
/**
* One slice, rebuilt on the UI thread every frame it is moving.
*
* Both the unroll and the lift are folded into the path rather than laid on top
* of it as a transform. The unroll has to be: a slice half drawn is a shorter
* slice, not a smaller one, and no scale expresses that. The lift could be a
* translation, but the path is already being rebuilt, so moving the centre it
* is drawn around costs nothing and keeps the geometry in one place.
*/
function Slice({
angles,
fill,
centre,
radius,
hole,
cornerRadius,
popOut,
reveal,
origin,
span,
selected,
dimmed,
dimOpacity,
label,
percent,
onPress,
}: {
angles: SliceAngles;
fill: string;
centre: number;
radius: number;
hole: number;
cornerRadius: number;
popOut: number;
reveal: SharedValue;
origin: number;
span: number;
selected: boolean;
dimmed: boolean;
dimOpacity: number;
label: string;
percent: number;
onPress: () => void;
}) {
const lift = useDerivedValue(() =>
withTiming(selected ? 1 : 0, { duration: SELECT_DURATION })
);
const { from, to, mid } = angles;
// The direction the slice lifts in, resolved once: the middle of it, as a
// unit vector from the centre of the dial.
const angle = (mid - 0.25) * Math.PI * 2;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
const animatedProps = useAnimatedProps(() => {
// One hand sweeping clockwise from the start of the dial. A slice is drawn
// as far as the hand has reached and no further, so the pie fills the way
// it would be drawn by hand rather than every slice growing at once.
const hand = origin + span * reveal.value;
const out = popOut * lift.value;
return {
d: wedgePath(
centre + dx * out,
centre + dy * out,
radius,
hole,
from,
Math.min(to, hand),
cornerRadius
),
};
});
return (
);
}
export interface PieChartSkeletonProps {
color?: string;
}
/**
* The loading state: the dial as one plain band, with nothing divided up yet.
*
* Deliberately undivided. Placeholder slices would be a made-up split, and a
* reader has no way to 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 PieChartSkeleton({ color }: PieChartSkeletonProps) {
const { size, radius, hole, origin, span, status } = useChart('PieChart.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 band
// dissolves under the wedges sweeping over it. Cut on `status` alone, the ring
// vanishes before the first wedge has any sweep to show.
const { mounted, opacity } = useSkeletonHandoff(status === 'loading');
const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value }));
if (!mounted || radius <= 0) return null;
return (
);
}
PieChartSkeleton.displayName = 'PieChart.Skeleton';
PieChartSkeleton.slot = 'svg' as const;
export interface PieChartCenterProps {
/** Heading shown when no slice is selected. */
defaultLabel?: string;
/** Format the number under the label. Defaults to a compact number. */
formatValue?: (value: number, slice: PieDatum | null) => string;
/**
* Draw the middle yourself. Given the selected slice, or `null` when nothing
* is selected.
*/
children?: (slice: PieDatum | null) => ReactNode;
className?: string;
}
/**
* The hole's readout: the total, and the selected slice's own figures once one
* is picked.
*
* Unlike the ring chart's centre, the default here *is* an aggregate, and it is
* the honest one — the whole point of a pie is that its parts belong to a total,
* so the total is the number the chart is about. Selecting a slice swaps it for
* that slice's value and its share.
*/
function PieChartCenter({
defaultLabel = 'Total',
formatValue,
children,
className,
}: PieChartCenterProps) {
const { data, slices, total, hole, activeIndex } = useChart('PieChart.Center');
const slice = activeIndex >= 0 ? (data[activeIndex] ?? null) : null;
const share = activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0;
const format = formatValue ?? ((amount: number) => compactNumber(amount));
// A square inside a circle, not across it: the corners of a box as wide as
// the diameter fall outside the hole.
const room = hole * Math.SQRT2;
if (room <= 0) return null;
return (
{children ? (
children(slice)
) : (
<>
{slice ? slice.label : defaultLabel}
{/* Below about ninety points there is no room for two lines and a
headline, so the number gets the space and the rest is dropped. */}
{format(slice ? slice.value : total, slice)}
{room >= 90 && slice ? (
{`${Math.round(share * 100)}% of ${format(total, null)}`}
) : null}
>
)}
);
}
PieChartCenter.displayName = 'PieChart.Center';
PieChartCenter.slot = 'overlay' as const;
export interface PieChartLegendProps extends ViewProps {
className?: string;
/** Show each slice's share of the whole beside its name. */
showValue?: boolean;
}
/**
* A swatch, a name and a share per slice, under the chart and across the width
* of it. Pressable in the same way the slices are — the legend is usually the
* easier target of the two, and a slice worth a couple of percent is not a
* target at all.
*
* It wraps rather than stacking, so five or six entries take two lines instead
* of six. A key is a lookup table, and a lookup table read down a column of one
* word each is a column the eye has to walk.
*/
function PieChartLegend({ className, showValue = true, ...props }: PieChartLegendProps) {
const { data, slices, colors, activeIndex, setActiveIndex } =
useChart('PieChart.Legend');
if (!data.length) return null;
return (
{data.map((slice, index) => {
const percent = Math.round((slices[index]?.fraction ?? 0) * 100);
const dimmed = activeIndex >= 0 && activeIndex !== index;
return (
setActiveIndex(activeIndex === index ? -1 : index)}
style={{ opacity: dimmed ? 0.4 : 1 }}
className="max-w-full flex-row items-center gap-1.5"
>
{slice.label}
{showValue ? (
{percent}%
) : null}
);
})}
);
}
PieChartLegend.displayName = 'PieChart.Legend';
PieChartLegend.slot = 'footer' as const;
export interface PieChartHeaderProps extends ViewProps {
className?: string;
/** Small line above the value — what the chart 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;
/** Prettier names for the slices, keyed by their `label`. */
labels?: Record;
/**
* Draw a swatch and a name per slice along the trailing edge.
*
* For two or three short names. Past that use `PieChart.Legend`, which runs
* under the chart across the full width: a key of five long names crammed
* into the trailing corner of a header wraps to a column and leaves the title
* beside it a few points wide.
*/
legend?: boolean;
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
children?: ReactNode;
}
/**
* The strip above the pie: what the chart is of, what it reads, and what the
* colours mean.
*
* It belongs to the chart rather than to the card around it because it is about
* the *slices* — the number changes as one is selected, and the legend is the
* list the chart itself is holding. The card's header is a caption on the tray
* the chart sits in; this is the chart introducing itself.
*
* The value is not derived here even though there is a total to derive it from,
* because the formatting is not the chart's to guess: a total of 18420 is a
* count, a currency or a percentage depending on what was counted, and only the
* caller knows which.
*/
function PieChartHeader({
className,
title,
value,
caption,
labels,
legend = false,
children,
...props
}: PieChartHeaderProps) {
const { data, colors } = useChart('PieChart.Header');
const trailing =
children ??
(legend && data.length ? (
{data.map((slice, index) => (
{labels?.[slice.label] ?? slice.label}
))}
) : null);
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{/*
* Shrinkable, unlike a view's default in React Native — and capped, which
* shrinking alone does not achieve. The title column is `flex-1`, so its
* basis is zero and it lives on what is left over: a wrapping key with
* nothing stopping it takes the whole row and leaves the title a few
* points wide, which renders it one letter to a line.
*/}
{trailing ? {trailing} : null}
);
}
PieChartHeader.displayName = 'PieChart.Header';
PieChartHeader.slot = 'header' as const;
export const PieChart = Object.assign(PieChartRoot, {
Header: PieChartHeader,
Slices: PieChartSlices,
Center: PieChartCenter,
Legend: PieChartLegend,
Skeleton: PieChartSkeleton,
});