/**
* RadarChart — several measures of one thing, on one shape.
*
* A radar answers a question a bar chart cannot: not "which of these is
* biggest" but "what shape is this". Five scores read as five bars are five
* facts; read as a polygon they are a profile, and two profiles laid over each
* other are comparable at a glance in a way two groups of bars never are.
*
* ```tsx
*
*
*
*
*
*
*
* ```
*
* That is also the shape's limit, and worth saying out loud: the order of the
* axes changes the outline, and the outline is what people read. Two datasets
* are only comparable on one radar if the axes are in the same order, and a
* radar is the wrong chart for data whose axes have no natural order at all.
*
* The reveal grows the polygons out of the centre rather than sweeping across
* them, because a polar chart has no left-hand edge for a sweep to start at.
* Everything below the root is drawn on the UI thread.
*/
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 Svg, {
Circle,
G,
Line,
Path,
Polygon,
Text as SvgText,
} from 'react-native-svg';
import Animated, {
Easing,
useAnimatedProps,
useDerivedValue,
useReducedMotion,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import { cn } from '../../utils/cn';
import {
polarPoint,
radarPath,
useSeriesColor,
type SeriesColorIndex,
} from '../../utils/chart';
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
/**
* Room reserved around the rings for the axis labels — wider than it is tall.
*
* Labels sit outside the shape rather than along an edge, and they are
* horizontal text: the one at three o'clock needs its whole width to the right
* of the ring, while the one at twelve needs a single line's height above it.
* A square box therefore cannot hold them — either the sides are clipped or
* the top and bottom are wasted — which is also why a radar drawn in a square
* ends up so much taller than the wide charts beside it.
*/
const LABEL_ROOM = { x: 62, y: 26 };
/** …and with no axis labels asking for it, only enough not to clip the stroke. */
const BARE_ROOM = { x: 6, y: 6 };
/** How long the polygons take to grow out of the centre. */
const REVEAL_DURATION = 620;
/** …and how long one profile takes to travel to the next when the data changes. */
const MORPH_DURATION = 420;
/**
* Ring diameter when nothing says otherwise.
*
* The other charts here fill their container, because they are wide and a
* wider one carries more. A radar filling a panel is as tall as the panel is
* wide — twice the height of the chart beside it, for the same handful of
* numbers — so it sizes itself instead, and centres.
*/
const DEFAULT_SIZE = 180;
export type RadarChartStatus = 'loading' | 'ready';
/** One row is one axis: its label, and one value per series. */
export type RadarChartDatum = Record;
interface RadarChartContextValue {
data: RadarChartDatum[];
axisKey: string;
/** Centre of the rings, in view coordinates. */
cx: number;
cy: number;
/** Radius of the outermost ring. */
radius: number;
/** The drawing box, so a label can be kept inside it rather than clipped. */
width: number;
height: number;
status: RadarChartStatus;
/** The value the outermost ring stands for, tweened. */
domainMax: SharedValue;
/** …and the innermost, which is usually but not always zero. */
domainMin: SharedValue;
reveal: SharedValue;
series: [string, string][];
registerSeries: (key: string, color: string) => void;
unregisterSeries: (key: string) => void;
}
const RadarChartContext = createContext(null);
/** The chart's geometry and data, for a part or a readout beside one. */
export function useRadarChart(): RadarChartContextValue {
const context = useContext(RadarChartContext);
if (!context) throw new Error('useRadarChart must be used inside .');
return context;
}
function useChart(part: string): RadarChartContextValue {
const context = useContext(RadarChartContext);
if (!context) throw new Error(`${part} must be used inside .`);
return context;
}
/** Which layer a part draws into. */
type Layer = 'svg' | 'overlay' | 'header';
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 };
}
/** Keeps a child's own key out of the array index the partition gives it. */
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
/** A theme token as a colour, with the usual guard for a token that is not one. */
function useToken(variable: string, fallback: string): string {
const raw = useCSSVariable(variable);
return typeof raw === 'string' ? raw : fallback;
}
/* ------------------------------------------------------------------ *
* Root.
* ------------------------------------------------------------------ */
export interface RadarChartProps extends Omit {
className?: string;
/** One row per axis, in the order they go round. */
data: RadarChartDatum[];
/** Key holding each row's axis label. */
axisKey?: string;
/**
* `loading` holds the shape at the centre until the data arrives, then grows
* it — one component throughout rather than a spinner swapped for a chart,
* because swapping loses the transition.
*/
status?: RadarChartStatus;
/**
* Diameter of the outermost ring, in points. The view is that plus the room
* the axis labels need around it, and centres itself.
*
* The ring rather than the box, because the ring is the thing being sized —
* a box measurement would mean "bigger" also meant "labels further from the
* shape", and the chart would grow without the drawing growing with it.
*
* Pass `size={undefined}` with an `aspectRatio` to fill the container the
* way the other charts do.
*/
size?: number;
/**
* Width ÷ height when `size` is not given. `1` is the square a radar wants;
* the rings stay circular whatever it is set to.
*/
aspectRatio?: number;
/**
* Fix the scale instead of deriving it from the data. A radar almost always
* wants this: the shape only means something against a known maximum, and a
* scale that moves with the data makes two charts incomparable.
*/
domain?: [number, number];
/** Milliseconds for the reveal on mount. */
animationDuration?: number;
/** Drop the room reserved for axis labels, for a radar with none. */
compact?: boolean;
children?: ReactNode;
/** Accessible data labels and optional activation for each axis row. */
accessibilityLabelForDatum?: ChartAccessibilityProps['accessibilityLabelForDatum'];
onAccessibilityDatumPress?: ChartAccessibilityProps['onAccessibilityDatumPress'];
}
/** Imperative handle: re-run the reveal on demand, for a "replay" control. */
export interface RadarChartHandle {
replay: () => void;
}
const RadarChartRoot = forwardRef(
function RadarChartRoot(
{
className,
data,
axisKey = 'axis',
status = 'ready',
size: fixedSize = DEFAULT_SIZE,
aspectRatio = 1,
domain,
animationDuration = REVEAL_DURATION,
compact = false,
accessible,
accessibilityLabel,
accessibilityHint,
accessibilityLabelForDatum,
onAccessibilityDatumPress,
children,
...props
},
ref
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [series, setSeries] = useState<[string, string][]>([]);
const reveal = useSharedValue(0);
const domainMin = useSharedValue(0);
const domainMax = useSharedValue(0);
const reducedMotion = useReducedMotion();
const registerSeries = useMemo(
() => (key: string, color: string) =>
setSeries((current) => {
const existing = current.find(([k]) => k === key);
if (existing?.[1] === color) return current;
return [...current.filter(([k]) => k !== key), [key, color]];
}),
[]
);
const unregisterSeries = useMemo(
() => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
[]
);
const room = compact ? BARE_ROOM : LABEL_ROOM;
const cx = size.width / 2;
const cy = size.height / 2;
// Measured rather than assumed, so the `aspectRatio` path — where the box
// is whatever the container gave it — lands on the same geometry.
const radius = Math.max(
Math.min(size.width / 2 - room.x, size.height / 2 - room.y),
0
);
const seriesKeys = series.map(([key]) => key).join('|');
const extent = useMemo<[number, number]>(() => {
if (domain) return domain;
const keys = seriesKeys ? seriesKeys.split('|') : [];
let max = -Infinity;
for (const row of data) {
for (const key of keys) {
const value = row[key];
if (typeof value !== 'number' || Number.isNaN(value)) continue;
if (value > max) max = value;
}
}
if (max === -Infinity) return [0, 1];
/*
* From zero, always. A radar's rings are read as fractions of the whole —
* "three quarters of the way out" — and a floor above zero makes a small
* value look like a large one, which is the failure mode this chart is
* most prone to.
*/
return [0, max === 0 ? 1 : max * 1.05];
}, [data, domain, seriesKeys]);
const loading = status === 'loading';
useEffect(() => {
if (loading) return;
const [min, max] = extent;
domainMin.value = min;
domainMax.value = max;
}, [extent, loading, 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 || radius <= 0 || !data.length) return;
revealed.current = true;
playReveal();
}, [loading, radius, data.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
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,
axisKey,
cx,
cy,
radius,
width: size.width,
height: size.height,
status,
domainMin,
domainMax,
reveal,
series,
registerSeries,
unregisterSeries,
}),
[
data,
axisKey,
cx,
cy,
radius,
size.width,
size.height,
status,
domainMin,
domainMax,
reveal,
series,
registerSeries,
unregisterSeries,
]
);
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 rings would lose as
* much radius as the header took while still claiming the shape asked for.
*/
return (
{header}
[
[axisKey, datum[axisKey]],
...series.map(([key]) => [key, datum[key]] as [string, unknown]),
]}
/>
{radius > 0 ? (
<>
{overlay}
>
) : null}
);
}
);
/* ------------------------------------------------------------------ *
* Grid — the rings, and the spokes under them.
* ------------------------------------------------------------------ */
export interface RadarChartGridProps {
/** How many rings, including the outermost. */
rings?: number;
/** Overrides the themed hairline colour. */
color?: string;
/** Draw the rings as circles rather than as polygons through the spokes. */
circular?: boolean;
/** Draw a line from the centre out to each axis. */
spokes?: boolean;
}
/**
* The scale, drawn as rings.
*
* Polygonal by default rather than circular, because the rings are read
* against the shape laid over them — a round ring behind an angular polygon
* gives every axis a different apparent distance to the edge, and the whole
* point of the rings is to say how far out something is.
*/
function RadarChartGrid({
rings = 4,
color,
circular = false,
spokes = true,
}: RadarChartGridProps) {
const { data, cx, cy, radius } = useChart('RadarChart.Grid');
const themed = useToken('--color-border', 'rgba(128,128,128,0.2)');
const stroke = color ?? themed;
const count = data.length;
if (count < 3) return null;
const levels = Array.from({ length: rings }, (_, index) => ((index + 1) / rings) * radius);
return (
{levels.map((r, index) =>
circular ? (
) : (
{
const point = polarPoint(cx, cy, r, spoke / count);
return `${point.x},${point.y}`;
}).join(' ')}
fill="none"
stroke={stroke}
strokeWidth={1}
/>
)
)}
{spokes
? Array.from({ length: count }, (_, index) => {
const point = polarPoint(cx, cy, radius, index / count);
return (
);
})
: null}
);
}
/* ------------------------------------------------------------------ *
* Axis — the labels round the outside.
* ------------------------------------------------------------------ */
export interface RadarChartAxisProps {
/** Overrides the themed label colour. */
color?: string;
/** Label size in points. */
fontSize?: number;
/** How far outside the rings the labels sit. */
offset?: number;
/** Rewrites a label — to shorten it, or to add a unit. */
formatLabel?: (label: string, index: number) => string;
}
/**
* The axis names, placed around the rings.
*
* Each label is anchored by which side of the circle it is on rather than
* centred on its point: a label centred at three o'clock overlaps the shape it
* belongs to, and one centred at nine o'clock overlaps the view's edge.
*/
function RadarChartAxis({ color, fontSize = 11, offset = 10, formatLabel }: RadarChartAxisProps) {
const { data, axisKey, cx, cy, radius, width, height } = useChart('RadarChart.Axis');
const themed = useToken('--color-muted-foreground', '#737373');
const fill = color ?? themed;
const count = data.length;
if (count < 3) return null;
return (
{data.map((row, index) => {
const raw = row[axisKey];
const label = formatLabel
? formatLabel(String(raw ?? ''), index)
: String(raw ?? '');
const point = polarPoint(cx, cy, radius + offset, index / count);
// Within a few points of the vertical it is a top or bottom label and
// wants centring; either side of that it wants to run away from the
// chart rather than across it.
const dx = point.x - cx;
const anchor = Math.abs(dx) < radius * 0.15 ? 'middle' : dx > 0 ? 'start' : 'end';
/*
* Pull the anchor back inside the view before drawing.
*
* SVG clips at its viewport and does not reflow, so a label wider than
* the room left for it loses its tail with no sign that anything is
* missing — the reader sees "Accur" and has no reason to think it was
* ever longer. Nudging the anchor in costs a couple of points of gap
* between the label and the ring, which is the cheaper of the two.
*/
const estimated = label.length * fontSize * 0.55;
const x =
anchor === 'start'
? Math.min(point.x, Math.max(width - estimated, cx))
: anchor === 'end'
? Math.max(point.x, Math.min(estimated, cx))
: point.x;
// Text is anchored on its baseline, so a label below the chart needs
// pushing down by roughly its cap height to look level with one above.
const dy = point.y < cy ? 0 : fontSize * 0.72;
const y = Math.min(Math.max(point.y + dy, fontSize), height - 2);
return (
{label}
);
})}
);
}
/* ------------------------------------------------------------------ *
* Series — one polygon.
* ------------------------------------------------------------------ */
export interface RadarChartSeriesProps {
/** Key holding this series' value on each row. */
dataKey: string;
/** Name for the legend. Defaults to `dataKey`. */
name?: string;
/**
* Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so
* a series follows the theme without the call site naming a colour.
*/
color?: string;
/** Which `--color-chart-*` token to take when `color` is not given. */
colorIndex?: SeriesColorIndex;
strokeWidth?: number;
/**
* Opacity of the fill. Two filled polygons over each other make a third
* colour that means nothing, so drop it towards `0` — or to `0` — on the
* second and subsequent series.
*/
fillOpacity?: number;
/** A dot at each vertex. Worth it on a radar with few axes. */
showDots?: boolean;
}
/** One profile. */
function RadarChartSeries({
dataKey,
name,
color,
colorIndex = 1,
strokeWidth = 2,
fillOpacity = 0.18,
showDots = false,
}: RadarChartSeriesProps) {
const { data, cx, cy, radius, domainMin, domainMax, reveal, status, registerSeries, unregisterSeries } =
useChart('RadarChart.Series');
const stroke = useSeriesColor(color, colorIndex);
const count = data.length;
useEffect(() => {
registerSeries(dataKey, stroke);
return () => unregisterSeries(dataKey);
}, [dataKey, stroke, registerSeries, unregisterSeries]);
const values = useMemo(
() =>
data.map((row) => {
const value = row[dataKey];
return typeof value === 'number' && !Number.isNaN(value) ? value : null;
}),
[data, dataKey]
);
const loading = status === 'loading';
/*
* Where the shape is coming from and where it is going, and how far between
* them it currently is.
*
* A radar is the chart people put behind a switch — this quarter or last,
* you or the team — and a polygon that jumps from one profile to the next
* loses the only thing worth showing at the moment of the switch, which is
* which axes moved and by how much. Held as two arrays and tweened per
* vertex, the outline travels and the eye follows the parts of it that
* travelled furthest.
*/
const from = useSharedValue<(number | null)[]>(values);
const to = useSharedValue<(number | null)[]>(values);
const morph = useSharedValue(1);
const settled = useRef(false);
const reducedMotion = useReducedMotion();
useEffect(() => {
// The first values are not a change from anything.
if (!settled.current) {
settled.current = true;
from.value = values;
to.value = values;
morph.value = 1;
return;
}
if (reducedMotion) {
from.value = values;
to.value = values;
morph.value = 1;
return;
}
// Leave from wherever the last tween had got to, so a switch part way
// through another one carries on from the shape actually on screen.
const t = morph.value;
const previous = from.value;
const current = to.value;
from.value = current.map((value, index) => {
const start = previous[index];
if (value === null || start === null || start === undefined) return value ?? start ?? null;
return start + (value - start) * t;
});
to.value = values;
morph.value = 0;
morph.value = withTiming(1, { duration: MORPH_DURATION });
}, [values, reducedMotion, from, to, morph]);
/*
* The polygon is rebuilt on the UI thread every frame the reveal or the
* morph is running. Scaling the *values* rather than transforming the group
* is what makes the shape grow along its own axes — a `scale` transform
* would grow the stroke and the dots with it, and arrive at the wrong
* stroke width.
*/
const animatedProps = useAnimatedProps(() => {
const span = Math.max(domainMax.value - domainMin.value, 1e-6);
const target = to.value;
const start = from.value;
const scaled = target.map((value, index) => {
const a = start[index] ?? value;
const b = value ?? a;
if (a === null || a === undefined || b === null || b === undefined) return null;
const mixed = a + (b - a) * morph.value;
return ((mixed - domainMin.value) / span) * reveal.value;
});
return { d: loading ? '' : radarPath(scaled, cx, cy, radius) };
});
return (
{showDots && count >= 3
? values.map((value, index) =>
value === null ? null : (
)
)
: null}
);
}
/** One vertex, riding the same reveal and the same morph as its polygon. */
function RadarDot({
index,
turn,
cx,
cy,
radius,
color,
from,
to,
morph,
domainMin,
domainMax,
reveal,
}: {
index: number;
turn: number;
cx: number;
cy: number;
radius: number;
color: string;
from: SharedValue<(number | null)[]>;
to: SharedValue<(number | null)[]>;
morph: SharedValue;
domainMin: SharedValue;
domainMax: SharedValue;
reveal: SharedValue;
}) {
const point = useDerivedValue(() => {
const span = Math.max(domainMax.value - domainMin.value, 1e-6);
const target = to.value[index];
const start = from.value[index] ?? target;
if (target === null || target === undefined || start === null || start === undefined) {
return { x: cx, y: cy };
}
const mixed = start + (target - start) * morph.value;
const fraction = ((mixed - domainMin.value) / span) * reveal.value;
return polarPoint(cx, cy, radius * fraction, turn);
});
const animatedProps = useAnimatedProps(() => ({
cx: point.value.x,
cy: point.value.y,
}));
return ;
}
/* ------------------------------------------------------------------ *
* Header and legend.
* ------------------------------------------------------------------ */
export interface RadarChartHeaderProps {
className?: string;
/** Small caption above the value. */
title?: string;
/** The headline figure, if there is one. */
value?: string;
/** A line under the value. */
caption?: string;
/** Draw the series legend on the trailing end of the strip. */
legend?: boolean;
children?: ReactNode;
}
/** The strip above the rings. */
function RadarChartHeader({
className,
title,
value,
caption,
legend = false,
children,
}: RadarChartHeaderProps) {
const { series } = useChart('RadarChart.Header');
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{legend ? (
{series.map(([key, color]) => (
{key}
))}
) : null}
{children}
);
}
export interface RadarChartLegendProps {
className?: string;
/** Rewrites a series' name — the key is rarely what a reader should see. */
formatName?: (key: string) => string;
}
/**
* The series, named and coloured.
*
* In the bottom-left of the plot rather than under it, which on a radar costs
* nothing: the shape is a circle in a square box, so the corners are empty by
* construction. Move it with `className`.
*/
function RadarChartLegend({ className, formatName }: RadarChartLegendProps) {
const { series } = useChart('RadarChart.Legend');
if (!series.length) return null;
return (
{series.map(([key, color]) => (
{formatName ? formatName(key) : key}
))}
);
}
RadarChartRoot.displayName = 'RadarChart';
RadarChartGrid.displayName = 'RadarChart.Grid';
RadarChartAxis.displayName = 'RadarChart.Axis';
RadarChartSeries.displayName = 'RadarChart.Series';
RadarChartHeader.displayName = 'RadarChart.Header';
RadarChartLegend.displayName = 'RadarChart.Legend';
// Which layer each part draws into. Read by `partition` on the root, so a part
// can be written in any order and still land in the right place.
(RadarChartGrid as { layer?: Layer }).layer = 'svg';
(RadarChartAxis as { layer?: Layer }).layer = 'svg';
(RadarChartSeries as { layer?: Layer }).layer = 'svg';
(RadarChartHeader as { layer?: Layer }).layer = 'header';
(RadarChartLegend as { layer?: Layer }).layer = 'overlay';
export const RadarChart = Object.assign(RadarChartRoot, {
Header: RadarChartHeader,
Grid: RadarChartGrid,
Axis: RadarChartAxis,
Series: RadarChartSeries,
Legend: RadarChartLegend,
});