/** * RingChart — progress towards several targets, as concentric arcs. * * ```tsx * * * * * * * ``` * * ## What it is not * * It is not a pie or a donut, and the difference matters. A pie divides one * whole between its slices, so its parts are only meaningful against each * other and the angles must add to a full turn. A ring here is a value against * *its own* target — three rings can all be at ninety percent of three * unrelated numbers, and that is the reading. Nothing is normalised across * rings, and nothing has to add up. * * That is also why each ring gets a track. An arc drawn on nothing shows how * far something went; an arc drawn on a full circle shows how far it went *of * what it was aiming at*, which is the entire question. * * ## Drawing * * Each ring is two arcs — a track and the progress over it — with only the * progress animated, through `strokeDasharray`. Sweeping the arc by rebuilding * its path would work, but a dash offset is two numbers moving on an unchanged * path, and it keeps the rounded cap pinned to the moving end for free. * * The same dash pattern is what makes the other two shapes possible without any * more geometry. An open gauge is the pattern cut short of the circumference * and the whole circle turned to put the gap where it is wanted; a segmented * ring is the pattern repeated, one pair per tick. Both stay one `Circle`. * * Touch, not hover: a ring 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, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Circle, G } from 'react-native-svg'; import { Text } from '../../primitives/text'; import { compactNumber, useSeriesColor, type SeriesColorIndex } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); /** * Where a child is drawn. Rings go inside the SVG, the header above it, and * everything else over it as ordinary views. */ type Slot = 'ring' | 'overlay' | 'header'; /** One ring per datum, and the datum is the whole of its contract. */ export interface RingDatum { /** Name for the legend and the centre readout. */ label: string; /** Where this ring has got to. */ value: number; /** What it is aiming at. The ring is full when `value` reaches it. */ maxValue: number; /** Explicit colour, overriding the `--color-chart-*` token. */ color?: string; } interface RingChartContextValue { data: RingDatum[]; size: number; strokeWidth: number; ringGap: number; colors: string[]; /** 0 to 1 as the arcs sweep in. */ reveal: SharedValue; activeIndex: number; setActiveIndex: (index: number) => void; radiusOf: (index: number) => number; /** Where the arcs begin, in degrees clockwise from twelve o'clock. */ startAngle: number; /** How far they run, as a fraction of a full turn. 1 is a closed ring. */ arc: number; } const RingChartContext = createContext(null); function useChart(component: string): RingChartContextValue { const context = useContext(RingChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The selected ring, for something rendered inside the chart. */ export function useRingChart() { const { data, activeIndex } = useChart('useRingChart'); return { activeIndex, activeRing: activeIndex >= 0 ? (data[activeIndex] ?? null) : null, }; } export interface RingChartProps extends ViewProps { className?: string; /** One entry per ring, outermost first. */ data: RingDatum[]; /** Fixed diameter in points. Measured from the container when omitted. */ size?: number; /** Thickness of each ring. */ strokeWidth?: number; /** Gap between one ring and the next. */ ringGap?: number; /** * Where the arcs begin, in degrees clockwise from twelve o'clock. `0` is the * top, `90` the right-hand side. */ startAngle?: number; /** * Where they end, on the same clock. Leaving a turn's worth between the two * gives a closed ring; anything less leaves a gap and reads as a gauge — * `startAngle={-90} endAngle={90}` is the half circle over the top. */ endAngle?: number; /** Milliseconds for the arcs to sweep in. */ animationDuration?: number; /** Selected ring. Leave unset to let the chart track it. */ activeIndex?: number; /** Fires with the selected ring, or `-1` when the selection is cleared. */ onActiveIndexChange?: (index: number) => void; children?: ReactNode; } /** Imperative handle: re-run the sweep, for a "replay" control. */ export interface RingChartHandle { replay: () => void; } const RingChartRoot = forwardRef(function RingChartRoot( { className, data, size, strokeWidth = 12, ringGap = 6, startAngle = 0, endAngle = 360, animationDuration = 700, 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; /* * The sweep as a fraction of a turn, which is the form every arc length here * is wanted in. Clamped to one turn because a ring drawn past 360° laps * itself, and clamped above zero because a ring of no length is not a chart. */ const arc = Math.min(Math.max(endAngle - startAngle, 0), 360) / 360; /* * Outermost ring first, so `data[0]` is the one the eye lands on. Each ring * inside it steps in by its own thickness plus the gap. */ const radiusOf = useMemo( () => (index: number) => Math.max(box / 2 - strokeWidth / 2 - index * (strokeWidth + ringGap), strokeWidth / 2), [box, strokeWidth, ringGap] ); 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 revealed = useRef(false); useEffect(() => { if (revealed.current || box <= 0 || !data.length) return; revealed.current = true; playReveal(); }, [box, data.length, playReveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); // Resolved here rather than inside each ring, so the legend and the centre // readout can name a ring's colour without rendering 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((ring, index) => ring.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, size: box, strokeWidth, ringGap, colors, reveal, activeIndex, setActiveIndex, radiusOf, startAngle, arc, }), [ data, box, strokeWidth, ringGap, colors, reveal, activeIndex, setActiveIndex, radiusOf, startAngle, arc, ] ); const rings: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const slot = (child.type as { slot?: Slot }).slot ?? 'overlay'; (slot === 'ring' ? rings : slot === 'header' ? header : overlay).push( {child} ); }); return ( {/* * Two views, because the header is not part of the plot. 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 rings would * be laid out inside a box taller than the one they are drawn in. */} {header} {/* * Given a size the plot is that square and sits in the middle of * whatever it was handed; left to measure, it takes the width and is * as tall as it is wide. `size` is the usual answer inside a card — * a full-width square is twice the height the other charts take. */} {box > 0 ? ( <> {rings} {/* * The centre and the legend sit over the SVG rather than inside * it: both are text, and SVG text ignores the platform's text * scaling and the theme's font. */} {overlay} ) : null} ); }); RingChartRoot.displayName = 'RingChart'; function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } export interface RingChartRingProps { /** Which entry in `data` this ring draws. */ index: number; /** Explicit colour, overriding the datum's and the token. */ color?: string; /** Which of the five chart tokens to take, when the datum names no colour. */ colorIndex?: SeriesColorIndex; /** * Rounded ends, or square ones. Defaults to round, and to square when the * ring is segmented — a rounded cap on a tick as long as it is wide draws a * lozenge rather than a tick. */ lineCap?: 'round' | 'butt'; /** Opacity of the track behind the arc. */ trackOpacity?: number; /** * Break the ring into this many ticks, lit one at a time as the value * climbs. For a target made of countable things — eight of twelve sessions * reads off ticks you can count, and off a smooth arc only as "about two * thirds". */ segments?: number; /** Gap between one tick and the next, in points. */ segmentGap?: number; } /** * One ring: a full-circle track, and the arc showing how far along it the * value has got. * * The arc is swept with `strokeDasharray` rather than by rebuilding its path. * Both work, but a dash offset moves two numbers on a path that never changes * — and it keeps the rounded cap pinned to the moving end without any extra * geometry. */ function RingChartRing({ index, color, colorIndex, lineCap, trackOpacity = 0.15, segments, segmentGap = 3, }: RingChartRingProps) { const { data, size, strokeWidth, colors, reveal, activeIndex, setActiveIndex, radiusOf, startAngle, arc, } = useChart('RingChart.Ring'); const datum = data[index]; const tokenColor = useSeriesColor(undefined, colorIndex ?? 1); const stroke = color ?? datum?.color ?? (colorIndex ? tokenColor : colors[index]) ?? tokenColor; const radius = radiusOf(index); const circumference = 2 * Math.PI * radius; const centre = size / 2; /* The drawn part of the circle. The whole of it for a closed ring. */ const arcLength = circumference * arc; const ticks = segments && segments > 0 ? Math.floor(segments) : 0; /* One tick and the gap after it. The gap is taken out of the tick, not added to it, so N ticks still span exactly the arc asked for. */ const slot = ticks ? arcLength / ticks : 0; const dash = ticks ? Math.max(slot - segmentGap, 0.5) : 0; const cap = lineCap ?? (ticks ? 'butt' : 'round'); const fraction = datum && datum.maxValue > 0 ? datum.value / datum.maxValue : 0; // A value past its target fills the ring and stops. Going round twice would // draw 110% as 10%, which is the wrong answer told confidently. const clamped = Math.max(0, Math.min(1, fraction)); /* * Staggered outward-in, so the rings arrive as a sequence rather than all at * once. The window each gets is what is left after the stagger, so a chart * of five rings still finishes inside the one duration. */ const count = Math.max(1, data.length); const start = (index / count) * 0.4; const props = useAnimatedProps(() => { const progress = Math.max(0, Math.min(1, (reveal.value - start) / 0.6)); if (!ticks) { return { strokeDasharray: [arcLength * clamped * progress, circumference] }; } /* * A tick is lit or it is not, so the sweep rounds to whole ones. The array * stays the same length whatever is lit — an unlit tick is a dash of no * length rather than a missing pair — because a dash pattern that changes * length between frames is a new pattern, not an animated one. */ const lit = Math.round(ticks * clamped * progress); const pattern: number[] = []; for (let tick = 0; tick < ticks; tick += 1) { if (tick < lit) pattern.push(dash, slot - dash); else pattern.push(0, slot); } return { strokeDasharray: pattern }; }); const dimmed = activeIndex >= 0 && activeIndex !== index; if (!datum || radius <= 0) return null; /* * A circle's stroke starts at three o'clock, so everything is turned back a * quarter to put the start at twelve — an arc starting at three reads as a * gauge that has already been running. `startAngle` turns it on from there. */ const rotate = `rotate(${startAngle - 90} ${centre} ${centre})`; /* The unlit part of a segmented ring is the gaps between its ticks, so the track is ticked too; an open gauge's track stops where its arc stops. */ const trackDash = ticks ? [dash, slot - dash] : [arcLength, circumference]; return ( {/* * The touch target is the ring itself, drawn as a transparent stroke * over it and made wider than the ring — a twelve-point band is below * the size a finger can reliably hit. It follows the sweep rather than * closing the circle, so the dead half of a gauge stays dead. */} setActiveIndex(activeIndex === index ? -1 : index)} /> ); } RingChartRing.displayName = 'RingChart.Ring'; RingChartRing.slot = 'ring' as const; export interface RingChartCenterProps { /** * Heading shown when no ring is selected. Defaults to the outermost ring's * own name, which is what the centre shows when nothing has been picked. */ defaultLabel?: string; /** Format the number under the label. Defaults to a compact number. */ formatValue?: (value: number, ring: RingDatum | null) => string; /** * Draw the middle yourself. Given the selected ring, or `null` when nothing * is selected. */ children?: (ring: RingDatum | null) => ReactNode; className?: string; } /** * The readout in the hole. * * With nothing selected it shows the outermost ring — the one the eye lands on * first. Not a total: the rings measure different things against different * targets, so their values do not add up and their percentages do not average, * and a total here would be a confident number about nothing. Selecting a ring * swaps it for that ring's own figures. */ function RingChartCenter({ defaultLabel, formatValue, children, className, }: RingChartCenterProps) { const { data, activeIndex, strokeWidth, radiusOf } = useChart('RingChart.Center'); /* * With nothing selected the centre shows the outermost ring — the one the * eye lands on — rather than an aggregate. There is no aggregate to show: * the rings measure different things against different targets, so their * values do not add up and their percentages do not average. A total here * would be a confident number about nothing. */ const ring = (activeIndex >= 0 ? data[activeIndex] : data[0]) ?? null; const selected = activeIndex >= 0; const format = formatValue ?? ((amount: number) => compactNumber(amount)); /* * The hole is what is left inside the innermost ring, measured off the same * function that places the rings so the two cannot disagree. Text wider than * it would sit on the arcs rather than inside them. */ const inner = Math.max(radiusOf(Math.max(data.length - 1, 0)) - strokeWidth / 2, 0); // A square inside a circle, not across it: the corners of a box as wide as // the diameter fall outside the hole. const hole = inner * Math.SQRT2; return ( {children ? ( children(selected ? ring : null) ) : ring ? ( <> {selected ? ring.label : (defaultLabel ?? ring.label)} {/* 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(ring.value, ring)} {hole >= 90 ? ( {`of ${format(ring.maxValue, ring)}`} ) : null} ) : null} ); } RingChartCenter.displayName = 'RingChart.Center'; RingChartCenter.slot = 'overlay' as const; export interface RingChartLegendProps extends ViewProps { className?: string; /** Show each ring's percentage of its own target beside its name. */ showValue?: boolean; } /** * A swatch and a name per ring, pressable in the same way the rings are — the * legend is usually the easier target of the two, and on a small chart it is * the only comfortable one. */ function RingChartLegend({ className, showValue = true, ...props }: RingChartLegendProps) { const { data, colors, activeIndex, setActiveIndex } = useChart('RingChart.Legend'); if (!data.length) return null; return ( {data.map((ring, index) => { const percent = ring.maxValue > 0 ? Math.round((ring.value / ring.maxValue) * 100) : 0; const dimmed = activeIndex >= 0 && activeIndex !== index; return ( setActiveIndex(activeIndex === index ? -1 : index)} style={{ opacity: dimmed ? 0.4 : 1 }} className="flex-row items-center gap-1.5" > {ring.label} {showValue ? ( {percent}% ) : null} ); })} ); } RingChartLegend.displayName = 'RingChart.Legend'; RingChartLegend.slot = 'overlay' as const; export interface RingChartHeaderProps 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 target. */ caption?: string; /** Prettier names for the rings, keyed by their `label`. */ labels?: Record; /** * Draw a swatch and a name per ring along the trailing edge. Prefer this to * `RingChart.Legend` on a chart that has a header: that legend hangs off the * bottom of the square, where it overlaps whatever is under the chart. */ legend?: boolean; /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */ children?: ReactNode; } /** * The strip above the rings: what the chart is of, what it currently reads, and * what the colours mean. * * It belongs to the chart rather than to the card around it because it is about * the *rings* — 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. There is no total to derive: the rings measure * different things against different targets. Take it from `onActiveIndexChange` * and pass the formatted string down, so one header can show the headline figure * when nothing is selected and a ring's own when something is. */ function RingChartHeader({ className, title, value, caption, labels, legend = false, children, ...props }: RingChartHeaderProps) { const { data, colors } = useChart('RingChart.Header'); const trailing = children ?? (legend && data.length ? ( {data.map((ring, index) => ( {labels?.[ring.label] ?? ring.label} ))} ) : null); return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {/* Shrinkable, unlike a view's default in React Native. Held rigid, a three-ring key takes the width it wants and the caption underneath the value wraps to two lines to make room for it. */} {trailing ? {trailing} : null} ); } RingChartHeader.displayName = 'RingChart.Header'; RingChartHeader.slot = 'header' as const; export const RingChart = Object.assign(RingChartRoot, { Header: RingChartHeader, Ring: RingChartRing, Center: RingChartCenter, Legend: RingChartLegend, });