/** * PolarAreaChart — several parts, compared on one measure. * * ```tsx * * * * * * * * ``` * * ## What it is, against the pie beside it * * A pie divides one total: the angles are the quantity and they must come to a * full turn, so a slice only means anything next to the others. Here every * wedge takes the same angle and the *radius* is the quantity, which means the * values do not have to add up to anything. Six unrelated readings on one scale * are a polar area chart; six parts of one budget are a pie. * * What it buys over a bar chart is the shape. Bars are easier to read one at a * time, and a reader who has to rank the middle three should be given bars. A * dial is for the silhouette — which direction the weight sits in, whether one * reading runs away from the rest — read at a glance and without a legend walk. * * ## Radius, and what it overstates * * `scale` decides what the radius means, and the two answers are not the same * chart: * * - `radius` (the default) puts the value straight on the radius, so a reading * can be counted off the rings. The cost is that a wedge worth twice another * covers four times the area, and area is what the eye adds up first. * - `area` puts it on the square root instead, so the ink is proportional to * the value and nothing is overstated. The cost is the rings: they still mark * equal steps, but they are no longer equally spaced. * * The grid is drawn through the same conversion either way, so a ring is always * where its value falls rather than where an even split would put it. * * Touch, not hover: a wedge is selected by pressing it, and pressing the same * one again clears the selection. */ 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, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Circle, G, Line, Path } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; import { compactNumber, inkOn, polarPoint, seriesColorAt, useSeriesColor, wedgePath, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); /** How far along its own radius a wedge's label sits. */ const LABEL_AT = 0.62; /** The side of the invisible box a label is centred in. */ const LABEL_BOX = 64; /** Below this radius in points a wedge is left unlabelled. */ const DEFAULT_MIN_LABEL_RADIUS = 34; const TOOLTIP_WIDTH = 132; const TOOLTIP_HEIGHT = 30; /** 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 PolarAreaChartStatus = 'loading' | 'ready'; /** What a wedge's radius stands for. */ export type PolarAreaScale = 'radius' | 'area'; /** One wedge. Its angle is fixed, so only the value decides how far it reaches. */ export interface PolarAreaDatum { /** Name for the legend, the readout and the accessibility label. */ label: string; /** How far the wedge reaches, against `maxValue`. Negatives are treated as zero. */ value: number; /** Explicit colour, overriding the `--color-chart-*` token. */ color?: string; } /** A wedge's place on the dial, in turns clockwise from twelve o'clock. */ interface WedgeGeometry { from: number; to: number; /** Halfway along it, which is where its label sits. */ mid: number; /** How far out it reaches, in points. */ radius: number; /** Its value against the maximum, 0 to 1. */ fraction: number; } interface PolarAreaChartContextValue { data: PolarAreaDatum[]; size: number; /** Outer radius in points — the outermost ring, standing for `maxValue`. */ radius: number; /** The value the outermost ring stands for. */ maxValue: number; scale: PolarAreaScale; colors: string[]; wedges: WedgeGeometry[]; /** A value's distance from the centre, in points, under the current scale. */ radiusOf: (value: number) => number; /** 0 to 1 as the wedges grow out of the centre. */ reveal: SharedValue; status: PolarAreaChartStatus; activeIndex: number; setActiveIndex: (index: number) => void; } const PolarAreaChartContext = createContext(null); function useChart(component: string): PolarAreaChartContextValue { const context = useContext(PolarAreaChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The selected wedge and its reading, for something rendered inside the chart. */ export function usePolarAreaChart() { const { data, wedges, activeIndex } = useChart('usePolarAreaChart'); return { activeIndex, activeWedge: activeIndex >= 0 ? (data[activeIndex] ?? null) : null, /** The selected wedge's value against the maximum, 0 to 1. */ activeFraction: activeIndex >= 0 ? (wedges[activeIndex]?.fraction ?? 0) : 0, }; } /** * The steps a scale is read in, as multiples of a power of ten. * * Finer than the usual 1/2/5 ladder on purpose. Here the maximum is a *radius* * as well as a number: rounding 140 up to 200 leaves the largest wedge stopping * at seven tenths of the dial with the outer third of it empty on every chart * whose readings happen to start with a one. */ const NICE_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; /** * The first round number at or above `value`. * * The outermost ring is the number every other reading is judged against, so it * is worth it landing on 150 rather than on 147. */ function niceMax(value: number): number { if (!Number.isFinite(value) || value <= 0) return 1; const magnitude = 10 ** Math.floor(Math.log10(value)); const scaled = value / magnitude; return (NICE_STEPS.find((step) => scaled <= step) ?? 10) * magnitude; } export interface PolarAreaChartProps extends ViewProps { className?: string; /** One entry per wedge, drawn in the order they are listed. */ data: PolarAreaDatum[]; /** Diameter in points. Left out, the chart fills its column as a square. */ size?: number; /** * The value the outermost ring stands for. Defaults to the largest value * rounded up to a round number. * * Fix it to compare two dials against each other — the same reading has to be * the same distance out on both, and a maximum derived per chart makes the * largest wedge of each one reach the edge whatever it is worth. */ maxValue?: number; /** Whether the radius or the area carries the value. */ scale?: PolarAreaScale; /** Where the first wedge starts, in degrees clockwise from twelve o'clock. */ startAngle?: number; /** Gap between wedges, in degrees. */ padAngle?: number; /** `loading` draws the dial undivided, with nothing split up yet. */ status?: PolarAreaChartStatus; /** Milliseconds for the wedges to grow out of the centre. */ animationDuration?: number; /** The selected wedge, to drive the selection from outside. */ activeIndex?: number; onActiveIndexChange?: (index: number) => void; children?: ReactNode; } export interface PolarAreaChartHandle { /** Play the growth again. */ replay: () => void; } const PolarAreaChartRoot = forwardRef( function PolarAreaChartRoot( { className, data, size, maxValue: maxValueProp, scale = 'radius', startAngle = 0, padAngle = 0, status = 'ready', animationDuration = 620, 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, 0); const maxValue = useMemo(() => { if (maxValueProp !== undefined) return Math.max(maxValueProp, 0) || 1; return niceMax(data.reduce((top, datum) => Math.max(top, datum.value), 0)); }, [data, maxValueProp]); /* * One conversion, shared by the wedges, the rings and the readout. Two * copies of it would be two chances for a ring to sit somewhere its own * value does not. */ const radiusOf = useMemo( () => (value: number) => { const fraction = Math.min(Math.max(value, 0) / maxValue, 1); return radius * (scale === 'area' ? Math.sqrt(fraction) : fraction); }, [maxValue, radius, scale] ); const origin = startAngle / 360; const wedges = useMemo(() => { const count = data.length; if (!count || radius <= 0) return []; const each = 1 / count; // Never more than half a wedge from each side, or the two gaps meet in // the middle and the wedge inverts. const pad = Math.min(padAngle / 360, each / 2); return data.map((datum, index) => { const from = origin + each * index + pad / 2; const to = origin + each * (index + 1) - pad / 2; return { from, to, mid: (from + to) / 2, radius: radiusOf(datum.value), fraction: Math.min(Math.max(datum.value, 0) / maxValue, 1), }; }); }, [data, radius, origin, padAngle, radiusOf, maxValue]); 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 || !wedges.length) return; revealed.current = true; playReveal(); }, [loading, box, wedges.length, playReveal, reveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); // Resolved here rather than inside the wedges, so the legend, the header // and the readout can name a wedge'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((datum, index) => datum.color ?? seriesColorAt(palette, index)), [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, size: box, radius, maxValue, scale, colors, wedges, radiusOf, reveal, status, activeIndex, setActiveIndex, }), [ data, box, radius, maxValue, scale, colors, wedges, radiusOf, 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 dial would * be laid out inside a box taller than the one it is drawn in. */} {slots.header} {box > 0 ? ( <> {slots.svg} {/* * Over the SVG rather than inside it: these are text, and SVG * text ignores the platform's text scaling and the theme's font. */} {slots.overlay} ) : null} {slots.footer} ); } ); PolarAreaChartRoot.displayName = 'PolarAreaChart'; function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } export interface PolarAreaChartWedgesProps { /** Rounds the four turns of each wedge, in points. */ cornerRadius?: number; /** Opacity of the wedges that are not selected, once one is. */ dimOpacity?: number; } /** * Every wedge, drawn in the order the data lists them. * * One part rather than one per datum: the wedges share a dial, a maximum and a * scale by definition, and a chart where one of them could be given a different * maximum would be a chart drawing a lie. */ function PolarAreaChartWedges({ cornerRadius = 0, dimOpacity = 0.35, }: PolarAreaChartWedgesProps) { const { data, wedges, colors, size, reveal, status, activeIndex, setActiveIndex } = useChart('PolarAreaChart.Wedges'); if (status === 'loading' || !wedges.length) return null; return ( {wedges.map((wedge, index) => { const datum = data[index]; if (!datum || wedge.radius <= 0 || wedge.to <= wedge.from) return null; return ( = 0 && activeIndex !== index} dimOpacity={dimOpacity} label={datum.label} value={datum.value} onPress={() => setActiveIndex(activeIndex === index ? -1 : index)} /> ); })} ); } PolarAreaChartWedges.displayName = 'PolarAreaChart.Wedges'; PolarAreaChartWedges.slot = 'svg' as const; /** * One wedge, rebuilt on the UI thread every frame it is growing. * * It grows outward rather than sweeping round, because the radius is the * quantity here: a wedge half drawn is a smaller reading, which is the thing * the chart is animating towards. A sweep would animate the one dimension that * carries nothing. */ function Wedge({ wedge, fill, centre, cornerRadius, reveal, dimmed, dimOpacity, label, value, onPress, }: { wedge: WedgeGeometry; fill: string; centre: number; cornerRadius: number; reveal: SharedValue; dimmed: boolean; dimOpacity: number; label: string; value: number; onPress: () => void; }) { const { from, to, radius } = wedge; const animatedProps = useAnimatedProps(() => ({ d: wedgePath(centre, centre, radius * reveal.value, 0, from, to, cornerRadius), })); return ( ); } export interface PolarAreaChartGridProps { /** How many rings, including the outermost. */ rings?: number; /** Overrides the themed hairline colour. */ color?: string; /** Draw a line from the centre out along each wedge's edge. */ spokes?: boolean; } /** * The scale, drawn as rings. * * Each ring stands for an even step of the value and is placed where that value * falls, which under `scale="area"` is not an even step of the radius. Spacing * them evenly instead would be quicker and would put every ring in the wrong * place on half the charts. */ function PolarAreaChartGrid({ rings = 4, color, spokes = false }: PolarAreaChartGridProps) { const { size, radius, maxValue, radiusOf, wedges } = useChart('PolarAreaChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); if (radius <= 0 || rings < 1) return null; const centre = size / 2; return ( {Array.from({ length: rings }, (_, index) => { const r = radiusOf(((index + 1) / rings) * maxValue); if (r <= 0) return null; return ( ); })} {spokes ? wedges.map((wedge, index) => { const point = polarPoint(centre, centre, radius, wedge.from); return ( ); }) : null} ); } PolarAreaChartGrid.displayName = 'PolarAreaChart.Grid'; PolarAreaChartGrid.slot = 'svg' as const; export interface PolarAreaChartLabelsProps { /** Format the value. Defaults to a compact number. */ formatValue?: (value: number, datum: PolarAreaDatum) => string; /** Wedges reaching less far than this, in points, are left unlabelled. */ minRadius?: number; className?: string; } /** * The reading on each wedge that has room for it. * * A wedge shorter than `minRadius` is left blank — the label would sit outside * the wedge it belongs to, next to a neighbour it does not describe. Those are * read through `Tooltip` and the legend instead. * * Each label takes its colour from the wedge under it. A wedge is a theme * colour and a theme is free to set that anywhere on the scale, so a fixed * white label disappears on the pale ones. */ function PolarAreaChartLabels({ formatValue, minRadius = DEFAULT_MIN_LABEL_RADIUS, className, }: PolarAreaChartLabelsProps) { const { data, wedges, colors, size, status } = useChart('PolarAreaChart.Labels'); const backdrop = useCSSVariable('--color-background'); if (status === 'loading' || !wedges.length) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); const behind = typeof backdrop === 'string' ? backdrop : undefined; const centre = size / 2; return ( <> {wedges.map((wedge, index) => { const datum = data[index]; if (!datum || wedge.radius < minRadius) return null; const point = polarPoint(centre, centre, wedge.radius * LABEL_AT, wedge.mid); const ink = inkOn(colors[index] ?? colors[0]!, behind, 1); return ( {format(datum.value, datum)} ); })} ); } PolarAreaChartLabels.displayName = 'PolarAreaChart.Labels'; PolarAreaChartLabels.slot = 'overlay' as const; export interface PolarAreaChartTooltipProps { /** Format the value. Defaults to a compact number. */ formatValue?: (value: number, datum: PolarAreaDatum) => string; className?: string; } /** * The readout for the selected wedge, floating over the dial. * * This is how the short wedges are named. They are the ones with no room for a * label, so without it the chart answers questions about its largest readings * only — which is the half the reader could already see. */ function PolarAreaChartTooltip({ formatValue, className }: PolarAreaChartTooltipProps) { const { data, wedges, size, activeIndex, status } = useChart('PolarAreaChart.Tooltip'); if (status === 'loading' || activeIndex < 0) return null; const datum = data[activeIndex]; const wedge = wedges[activeIndex]; if (!datum || !wedge) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); const centre = size / 2; const point = polarPoint(centre, centre, wedge.radius * LABEL_AT, wedge.mid); // Over the wedge, then pushed back inside the square — a readout half off the // edge is one the reader has to guess the rest of. const left = Math.max(0, Math.min(size - TOOLTIP_WIDTH, point.x - TOOLTIP_WIDTH / 2)); const top = Math.max(0, Math.min(size - TOOLTIP_HEIGHT, point.y - TOOLTIP_HEIGHT / 2)); return ( {datum.label} {format(datum.value, datum)} ); } PolarAreaChartTooltip.displayName = 'PolarAreaChart.Tooltip'; PolarAreaChartTooltip.slot = 'overlay' as const; export interface PolarAreaChartLegendProps extends ViewProps { className?: string; /** Show each wedge's reading beside its name. */ showValue?: boolean; /** Format the value. Defaults to a compact number. */ formatValue?: (value: number, datum: PolarAreaDatum) => string; } /** * A swatch, a name and a reading per wedge, under the dial and across the width * of it. Pressable in the same way the wedges are, and the easier target of the * two for anything short. * * The reading rather than a share, unlike the pie next door: these values need * not add up to anything, so a percentage of their sum would be a number about * nothing. */ function PolarAreaChartLegend({ className, showValue = true, formatValue, ...props }: PolarAreaChartLegendProps) { const { data, colors, activeIndex, setActiveIndex } = useChart('PolarAreaChart.Legend'); if (!data.length) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); return ( {data.map((datum, index) => { 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" > {datum.label} {showValue ? ( {format(datum.value, datum)} ) : null} ); })} ); } PolarAreaChartLegend.displayName = 'PolarAreaChart.Legend'; PolarAreaChartLegend.slot = 'footer' as const; export interface PolarAreaChartSkeletonProps { color?: string; } /** * The loading state: the dial as one plain disc, with nothing divided up yet. * * Deliberately undivided. Placeholder wedges would be a made-up set of * readings, and a reader has no way to tell an invented one from a real one * until it changes under them. */ function PolarAreaChartSkeleton({ color }: PolarAreaChartSkeletonProps) { const { size, radius, status } = useChart('PolarAreaChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); if (status !== 'loading' || radius <= 0) return null; return ; } PolarAreaChartSkeleton.displayName = 'PolarAreaChart.Skeleton'; PolarAreaChartSkeleton.slot = 'svg' as const; export interface PolarAreaChartHeaderProps 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 wedges, keyed by their `label`. */ labels?: Record; /** * Draw a swatch and a name per wedge along the trailing edge. * * For two or three short names. Past that use `PolarAreaChart.Legend`, which * runs under the dial across the full width. */ legend?: boolean; /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */ children?: ReactNode; } /** * The strip above the dial: what the chart is of, what it reads, and what the * colours mean. * * The value is not derived here even though there are values to derive one * from, because the formatting is not the chart's to guess: 18420 is a count, a * currency or a duration depending on what was measured. */ function PolarAreaChartHeader({ className, title, value, caption, labels, legend = false, children, ...props }: PolarAreaChartHeaderProps) { const { data, colors } = useChart('PolarAreaChart.Header'); const trailing = children ?? (legend && data.length ? ( {data.map((datum, index) => ( {labels?.[datum.label] ?? datum.label} ))} ) : null); return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {trailing ? {trailing} : null} ); } PolarAreaChartHeader.displayName = 'PolarAreaChart.Header'; PolarAreaChartHeader.slot = 'header' as const; export const PolarAreaChart = Object.assign(PolarAreaChartRoot, { Header: PolarAreaChartHeader, Grid: PolarAreaChartGrid, Wedges: PolarAreaChartWedges, Labels: PolarAreaChartLabels, Tooltip: PolarAreaChartTooltip, Legend: PolarAreaChartLegend, Skeleton: PolarAreaChartSkeleton, });