/**
* ScatterChart — two quantities against each other, drawn on the UI thread.
*
* Every other chart in this library spaces its points evenly along the x-axis,
* because their x is a position: twelve months are twelve equal steps whatever
* the gaps between the dates behind them. A scatter plot is the one shape where
* that is wrong. Both coordinates are *measured*, and the reader is being asked
* to look for a relationship between them — spread the points evenly and the
* relationship is the one thing you have thrown away.
*
* So this chart carries an x-domain as well as a y-domain, and both are tweened
* when the data changes.
*
* ```tsx
*
*
*
*
*
*
*
* ```
*
* As elsewhere, there are two layers and the parts sort themselves into the
* right one: the geometry is SVG, and anything with text or a gesture on it is
* a React Native view over the top. SVG text ignores the platform's text
* scaling and the theme's font, and a gesture handler cannot be attached to an
* SVG node at all.
*
* **Finding a point.** A crosshair that snaps to an x index — the way a line
* chart's does — has nothing to snap to here, because there is no shared x and
* two points can sit at the same one. Instead the nearest point to the finger
* is resolved by distance, on the UI thread, and only within a radius: a touch
* in an empty corner of the plot selects nothing rather than lighting up
* whichever point happens to be least far away. The radius is generous, because
* the points are a few pixels across and a fingertip is not.
*
* Colours come from the `--color-chart-*` tokens, so a chart follows the active
* theme. Nothing here hardcodes a hex.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
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,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useReducedMotion,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart';
import { cn } from '../../utils/cn';
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const AnimatedG = Animated.createAnimatedComponent(G);
/**
* How much of the reveal is spent handing out the points' start times. The rest
* is the window each one gets, so the whole field still lands inside the one
* duration however many points there are.
*/
const STAGGER = 0.4;
/** Milliseconds for a point to swell as it is selected, and settle as it is not. */
const SELECT_DURATION = 140;
/**
* A point arriving: up past its size and back to it.
*
* A dot that simply grows to its radius reads as the chart still loading right
* up to the last frame. The small overshoot is what makes it read as landing.
*/
function landing(t: number): number {
'worklet';
const back = 1.3;
const u = t - 1;
return 1 + (back + 1) * u * u * u + back * u * u;
}
/** Room left around the plot for the axis labels and the outermost dots. */
const PADDING = { top: 14, right: 14, bottom: 22, left: 14 };
/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
const Y_AXIS_WIDTH = 44;
/** Gap between the value labels and the plot they sit beside. */
const Y_AXIS_GUTTER = 6;
/** Line height of an `xs` label, for centring one on the grid line it names. */
const AXIS_LABEL_HEIGHT = 16;
/** Box each x label is centred in, so a long number is ellipsised not shoved. */
const POINT_LABEL_WIDTH = 56;
/** Width of the readout that floats by the selected point. */
const LABEL_WIDTH = 132;
/** How far the readout is lifted, to clear the point it describes. */
const LABEL_HEIGHT = 52;
/**
* How far from a point a touch still counts as being on it, in points.
*
* Sized for a fingertip rather than for the dot. Apple and Android both put the
* minimum comfortable target at around 44pt, and a scatter point is nearer 7 —
* without a hit radius the chart is only usable with a mouse it will never see.
*/
const HIT_RADIUS = 32;
type Layer = 'svg' | 'overlay' | 'header';
export type ScatterChartStatus = 'loading' | 'ready';
export type ScatterChartDatum = Record;
/** One plotted point, resolved back to the row it came from. */
export interface ScatterChartPoint {
/** Index into `data`. */
index: number;
/** The series key this point belongs to. */
dataKey: string;
x: number;
y: number;
datum: ScatterChartDatum;
}
interface ScatterChartContextValue {
data: ScatterChartDatum[];
xDataKey: string;
plot: Plot;
status: ScatterChartStatus;
series: [string, string][];
registerSeries: (key: string, color: string) => void;
unregisterSeries: (key: string) => void;
/** Tweened domains. Read inside worklets to place the points. */
xMin: SharedValue;
xMax: SharedValue;
yMin: SharedValue;
yMax: SharedValue;
/** The domains the tweens are heading for, for the axis labels. */
xExtent: [number, number];
yExtent: [number, number];
/** The selected point, as `":"`, or `''` for none. */
activeId: SharedValue;
activePoint: ScatterChartPoint | null;
setActivePoint: (point: ScatterChartPoint | null) => void;
/** 0 to 1 as the field arrives. Each point reads its own slice of it. */
reveal: SharedValue;
}
const ScatterChartContext = createContext(null);
function useChart(component: string): ScatterChartContextValue {
const context = useContext(ScatterChartContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* The selected point, for something rendered *inside* the chart.
*
* A readout usually belongs in the card's header, which is outside this
* provider — use `onActivePointChange` for that. A hook cannot reach up out of
* the subtree it is called in.
*/
export function useScatterChart() {
const { activePoint, xDataKey } = useChart('useScatterChart');
return { activePoint, xDataKey };
}
export interface ScatterChartProps extends ViewProps, ChartAccessibilityProps {
className?: string;
/** The rows. Each one is a point, placed by two of its values. */
data: ScatterChartDatum[];
/** Key holding the x value. Unlike the other charts, this must be a number. */
xDataKey?: string;
/**
* `loading` draws a still field of muted dots and settles into the real ones
* when it turns `ready`. One component throughout, rather than a spinner
* swapped for a chart — swapping loses the transition.
*/
status?: ScatterChartStatus;
/** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */
aspectRatio?: number;
/** Milliseconds for the reveal on mount. Defaults to `650`. */
animationDuration?: number;
/** Milliseconds for the axes to settle after the data changes. */
domainDuration?: number;
/** Fix the x-axis instead of deriving it from the data. */
xDomain?: [number, number];
/** Fix the y-axis instead of deriving it from the data. */
yDomain?: [number, number];
/**
* The point under the finger, and `null` when it lifts. This is how a readout
* in the card's header gets its value — that header is outside the chart, so
* it cannot use `useScatterChart`.
*
* Fires when the selection changes, not per frame.
*/
onActivePointChange?: (point: ScatterChartPoint | null) => void;
/** Drop the axis padding so the field reaches the edges, for a thumbnail. */
compact?: boolean;
children?: ReactNode;
}
/** Imperative handle: re-run the reveal on demand, for a "replay" control. */
export interface ScatterChartHandle {
replay: () => void;
}
const ScatterChartRoot = forwardRef(
function ScatterChartRoot(
{
className,
data,
xDataKey = 'x',
status = 'ready',
aspectRatio = 1,
animationDuration = 650,
domainDuration = 500,
xDomain,
yDomain,
onActivePointChange,
accessible,
accessibilityLabel,
accessibilityHint,
accessibilityLabelForDatum,
onAccessibilityDatumPress,
compact = false,
children,
...props
},
ref
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [series, setSeries] = useState<[string, string][]>([]);
const [activePoint, setActivePointState] = useState(null);
const reveal = useSharedValue(0);
const xMin = useSharedValue(0);
const xMax = useSharedValue(0);
const yMin = useSharedValue(0);
const yMax = useSharedValue(0);
const activeId = useSharedValue('');
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 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: 4, right: 4, bottom: 4, left: 4 }
: { ...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 seriesKeys = series.map(([key]) => key).join('|');
/*
* Both extents in one pass. A scatter plot's x is a measured quantity, so
* unlike the rest of the family it needs a domain of its own rather than a
* count of positions.
*
* Neither axis is floored at zero. An area chart is floored because a
* filled region floating above the baseline reads as a shape rather than a
* quantity — but a scatter plot's subject is the *spread*, and forcing a
* cluster of values between 80 and 90 to share a frame with zero squashes
* it into a smudge in one corner and hides the very thing being plotted.
*/
const extents = useMemo<{ x: [number, number]; y: [number, number] }>(() => {
const keys = seriesKeys ? seriesKeys.split('|') : [];
let lowX = Infinity;
let highX = -Infinity;
let lowY = Infinity;
let highY = -Infinity;
for (const row of data) {
const x = row[xDataKey];
if (typeof x !== 'number' || Number.isNaN(x)) continue;
for (const key of keys) {
const y = row[key];
// A row with no reading for this series is not a point at the
// origin — it is not a point at all, and must not stretch the axes.
if (typeof y !== 'number' || Number.isNaN(y)) continue;
if (x < lowX) lowX = x;
if (x > highX) highX = x;
if (y < lowY) lowY = y;
if (y > highY) highY = y;
}
}
return {
x: xDomain ?? padExtent(lowX, highX),
y: yDomain ?? padExtent(lowY, highY),
};
}, [data, xDataKey, seriesKeys, xDomain, yDomain]);
const loading = status === 'loading';
useEffect(() => {
if (loading) return;
const [x0, x1] = extents.x;
const [y0, y1] = extents.y;
// The first domain lands without a tween: there is no previous scale to
// move from, and animating up from zero reads as the numbers changing.
const first = xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;
if (first || reducedMotion) {
xMin.value = x0;
xMax.value = x1;
yMin.value = y0;
yMax.value = y1;
return;
}
xMin.value = withTiming(x0, { duration: domainDuration });
xMax.value = withTiming(x1, { duration: domainDuration });
yMin.value = withTiming(y0, { duration: domainDuration });
yMax.value = withTiming(y1, { duration: domainDuration });
}, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);
const revealed = useRef(false);
const playReveal = useMemo(
() => () => {
if (reducedMotion) {
reveal.value = 1;
return;
}
reveal.value = 0;
/*
* Eased out rather than in and out. Each point is given a slice of this
* one clock, so an ease that dawdles at the start spends it on the
* first few points and leaves the rest to arrive in a rush.
*/
reveal.value = withTiming(1, {
duration: animationDuration,
easing: Easing.out(Easing.cubic),
});
},
[reducedMotion, animationDuration, reveal]
);
/*
* Armed again every time the chart goes back to loading, so a chart that
* reloads animates on the second pass as well as the first. Left latched,
* the guard made "loading" a state the chart could only leave once.
*/
useEffect(() => {
if (loading) {
revealed.current = false;
reveal.value = 0;
return;
}
if (revealed.current || plot.width <= 0 || !data.length) return;
revealed.current = true;
playReveal();
}, [loading, plot.width, data.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
// One place the selection lands, so the chart's own children and a readout
// outside it never disagree about which point is active.
const setActivePoint = useMemo(
() => (point: ScatterChartPoint | null) => {
setActivePointState(point);
onActivePointChange?.(point);
},
[onActivePointChange]
);
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,
xDataKey,
plot,
status,
series,
registerSeries,
unregisterSeries,
xMin,
xMax,
yMin,
yMax,
xExtent: extents.x,
yExtent: extents.y,
activeId,
activePoint,
setActivePoint,
reveal,
}),
// `plot` is rebuilt every render from `size`, so it is compared by value.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
data,
xDataKey,
plot.width,
plot.height,
plot.left,
plot.top,
status,
series,
registerSeries,
unregisterSeries,
xMin,
xMax,
yMin,
yMax,
extents,
activeId,
activePoint,
setActivePoint,
reveal,
]
);
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}
[
[xDataKey, datum[xDataKey]],
...series.map(([key]) => [key, datum[key]] as [string, unknown]),
]}
/>
{plot.width > 0 ? (
<>
{overlay}
>
) : null}
);
}
);
ScatterChartRoot.displayName = 'ScatterChart';
/**
* An extent with a little air around it, and a usable one for the degenerate
* cases — no data at all, or every reading identical. A domain of zero width
* divides by zero and puts every point on the same edge.
*/
function padExtent(min: number, max: number): [number, number] {
if (min === Infinity) return [0, 1];
if (min === max) return [min - 1, max + 1];
const pad = (max - min) * 0.08;
return [min - pad, max + pad];
}
/** Sorts the children into the SVG tree and the view layer over it. */
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';
(layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(
// Children of a `Children.forEach` need keys of their own once they are
// put into a new array.
{child}
);
});
return { svg, overlay, header };
}
/** Identity wrapper, purely so the partitioned arrays can carry keys. */
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
/* -------------------------------------------------------------------------- */
/* SVG layer */
/* -------------------------------------------------------------------------- */
export interface ScatterChartGridProps {
/** Horizontal rules across the plot. */
rows?: number;
/**
* Vertical rules down it. A scatter plot's x is a quantity, so it earns a
* grid in both directions — a line chart's does not, because its x is a
* label and a rule under a label divides nothing.
*/
columns?: number;
color?: string;
/** Dash pattern, e.g. `"4,6"`. Omit for a solid rule. */
dashArray?: string;
opacity?: number;
}
/** Reference lines both ways. Drawn under everything, and not part of the reveal. */
function ScatterChartGrid({
rows = 4,
columns = 4,
color,
dashArray = '4,6',
opacity = 1,
}: ScatterChartGridProps) {
const { plot } = useChart('ScatterChart.Grid');
const token = useCSSVariable('--color-border');
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
return (
{Array.from({ length: rows + 1 }, (_unused, index) => {
const y = plot.top + (plot.height / rows) * index;
return (
);
})}
{Array.from({ length: columns + 1 }, (_unused, index) => {
const x = plot.left + (plot.width / columns) * index;
return (
);
})}
);
}
ScatterChartGrid.displayName = 'ScatterChart.Grid';
ScatterChartGrid.layer = 'svg' as Layer;
export interface ScatterChartPointsProps {
/** Key holding this series' y values. */
dataKey: string;
/**
* Fill 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?: 1 | 2 | 3 | 4 | 5;
/** Radius of a point, in points. Ignored when `sizeKey` is given. */
size?: number;
/**
* Key holding a third quantity, mapped to each point's *area* — a bubble
* chart. Area rather than radius, because doubling a radius quadruples the
* ink and the reader sees four times the value that is there.
*/
sizeKey?: string;
/** Smallest and largest radius `sizeKey` maps onto. */
sizeRange?: [number, number];
/**
* Fill opacity. Below 1 by default so that overlapping points read as denser
* rather than hiding each other — in a crowded region that overlap *is* the
* finding, and opaque dots erase it.
*/
opacity?: number;
}
/** One series, as a field of dots. */
function ScatterChartPoints({
dataKey,
color,
colorIndex = 1,
size = 4.5,
sizeKey,
sizeRange = [3, 14],
opacity = 0.75,
}: ScatterChartPointsProps) {
const {
data,
xDataKey,
plot,
xMin,
xMax,
yMin,
yMax,
status,
activeId,
registerSeries,
unregisterSeries,
reveal,
} = useChart('ScatterChart.Points');
const fill = useSeriesColor(color, colorIndex);
useEffect(() => {
registerSeries(dataKey, fill);
return () => unregisterSeries(dataKey);
}, [dataKey, fill, registerSeries, unregisterSeries]);
const loading = status === 'loading';
// The size scale is over the whole series, so one row's bubble means the same
// thing as another's. Recomputed only when the data or the key changes.
const sizeExtent = useMemo<[number, number] | null>(() => {
if (!sizeKey) return null;
let min = Infinity;
let max = -Infinity;
for (const row of data) {
const value = row[sizeKey];
if (typeof value !== 'number' || Number.isNaN(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
return min === Infinity ? null : [min, max];
}, [data, sizeKey]);
const points = useMemo(
() =>
data
.map((row, index) => {
const x = row[xDataKey];
const y = row[dataKey];
if (typeof x !== 'number' || Number.isNaN(x)) return null;
if (typeof y !== 'number' || Number.isNaN(y)) return null;
return { index, x, y, r: radiusFor(row[sizeKey ?? ''], sizeExtent, sizeRange, size) };
})
.filter((point): point is { index: number; x: number; y: number; r: number } =>
point !== null
),
[data, xDataKey, dataKey, sizeKey, sizeExtent, sizeRange, size]
);
if (loading) return null;
return (
{points.map((point, order) => (
))}
);
}
ScatterChartPoints.displayName = 'ScatterChart.Points';
ScatterChartPoints.layer = 'svg' as Layer;
/**
* A value's radius on the bubble scale.
*
* The value maps to *area* and the radius is taken from it, so a point holding
* twice the value carries twice the ink rather than four times it.
*/
function radiusFor(
value: string | number | null | undefined,
extent: [number, number] | null,
range: [number, number],
fallback: number
): number {
if (!extent || typeof value !== 'number' || Number.isNaN(value)) return fallback;
const [min, max] = extent;
const [rMin, rMax] = range;
const ratio = max === min ? 1 : (value - min) / (max - min);
const area = rMin * rMin + ratio * (rMax * rMax - rMin * rMin);
return Math.sqrt(area);
}
/**
* One point. Its position follows both domain tweens, so a data change moves
* the whole field to the new scale rather than cutting to it.
*
* It arrives by growing in place, on its own slice of the shared reveal. The
* alternative — sweeping a clip across the plot, which is what the line and
* area charts do — is right for a series read along the x-axis and wrong here:
* a wipe gives the reader a direction to read the arrival in, and a scatter
* plot has none. Position is the whole message, so a point may only ever appear
* where it belongs.
*/
function Dot({
id,
x,
y,
r,
plot,
xMin,
xMax,
yMin,
yMax,
fill,
opacity,
activeId,
reveal,
order,
total,
}: {
id: string;
x: number;
y: number;
r: number;
plot: Plot;
xMin: SharedValue;
xMax: SharedValue;
yMin: SharedValue;
yMax: SharedValue;
fill: string;
opacity: number;
activeId: SharedValue;
reveal: SharedValue;
order: number;
total: number;
}) {
/*
* The selection, as something that moves. It was a hard switch on
* `activeId`, so the point it named jumped half as big again between one
* frame and the next while every neighbour stayed put — read as a glitch
* rather than as a response to the finger.
*/
const selected = useDerivedValue(() =>
withTiming(activeId.value === id ? 1 : 0, { duration: SELECT_DURATION })
);
// Where in the reveal this point starts. Spread over `STAGGER`, so the field
// settles as a field rather than switching on all at once.
const start = total > 1 ? (order / total) * STAGGER : 0;
const animatedProps = useAnimatedProps(() => {
const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));
// The selected point swells and goes solid. Both, rather than one: a size
// change alone is easy to miss among neighbours, and an opacity change
// alone is invisible wherever the points already overlap.
const swell = 1 + 0.5 * selected.value;
return {
cx: xAt(x, plot, xMin.value, xMax.value),
cy: yOf(y, plot, yMin.value, yMax.value),
r: r * landing(arrived) * swell,
// Ahead of the size, so a point is legible by the time it stops moving
// rather than fading in for the whole of its arrival.
fillOpacity:
Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),
};
});
return ;
}
export interface ScatterChartSkeletonProps {
/** How many placeholder dots to scatter. */
count?: number;
color?: string;
}
/**
* The loading state: a still field of muted dots where the data will be.
*
* Deliberately still. A shimmer over a field of dots reads as the points
* *moving*, which is the one thing a scatter plot must never appear to do —
* position is the entire message, and a loading state that implies it is
* changing is a loading state that lies.
*
* The layout is deterministic rather than random, so it does not reshuffle on
* every render of a component that may re-render several times while waiting.
*
* Still is not the same as abrupt, though. It dissolves as the real points grow
* in, and outlives the status change by exactly that long — cut at the frame the
* data lands, the placeholder disappears before anything has replaced it and the
* plot is briefly empty.
*/
function ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps) {
const { plot, status } = useChart('ScatterChart.Skeleton');
const token = useCSSVariable('--color-skeleton');
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');
const dots = useMemo(() => {
// A cheap deterministic scatter: two irrational-ish strides that do not
// share a factor, so the points spread instead of falling into a lattice.
return Array.from({ length: count }, (_unused, index) => ({
key: index,
fx: ((index * 0.618) % 1) * 0.92 + 0.04,
fy: ((index * 0.379) % 1) * 0.92 + 0.04,
}));
}, [count]);
const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));
if (!mounted) return null;
return (
{dots.map((dot) => (
))}
);
}
ScatterChartSkeleton.displayName = 'ScatterChart.Skeleton';
ScatterChartSkeleton.layer = 'svg' as Layer;
/* -------------------------------------------------------------------------- */
/* Overlay layer */
/* -------------------------------------------------------------------------- */
export interface ScatterChartXAxisProps {
/** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
ticks?: number;
/** Turn a value into its label. Defaults to a compact number. */
format?: (value: number) => string;
className?: string;
}
/**
* The x labels, evenly along the axis.
*
* Evenly spaced here — unlike a line chart's, where each label sits on the point
* it names — because this axis is a continuous scale rather than a list of
* rows. There is no point to sit on.
*/
function ScatterChartXAxis({ ticks = 4, format, className }: ScatterChartXAxisProps) {
const { plot, xExtent } = useChart('ScatterChart.XAxis');
const labels = useMemo(() => {
const [min, max] = xExtent;
if (min === 0 && max === 0) return [];
return Array.from({ length: ticks + 1 }, (_unused, index) => {
const value = min + ((max - min) * index) / ticks;
return { key: index, value, text: format ? format(value) : compactNumber(value) };
});
}, [xExtent, ticks, format]);
return (
{labels.map((label) => (
{label.text}
))}
);
}
ScatterChartXAxis.displayName = 'ScatterChart.XAxis';
ScatterChartXAxis.layer = 'overlay' as Layer;
export interface ScatterChartYAxisProps {
/** How many intervals to divide the axis into. Yields `ticks + 1` labels. */
ticks?: number;
/** Turn a value into its label. Defaults to a compact number. */
format?: (value: number) => string;
className?: string;
}
/** Value labels down the side, one per grid line. */
function ScatterChartYAxis({ ticks = 4, format, className }: ScatterChartYAxisProps) {
const { plot, yExtent } = useChart('ScatterChart.YAxis');
const labels = useMemo(() => {
const [min, max] = yExtent;
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) };
});
}, [yExtent, ticks, format]);
return (
{labels.map((label) => (
{label.text}
))}
);
}
ScatterChartYAxis.displayName = 'ScatterChart.YAxis';
ScatterChartYAxis.layer = 'overlay' as Layer;
// Read by the root, which has to leave room for the labels before it lays the
// plot out.
ScatterChartYAxis.axis = 'y' as const;
export interface ScatterChartTooltipProps {
/** Float a small readout beside the selected point. On by default. */
showLabel?: boolean;
/** Format the x value for the readout. Defaults to a compact number. */
formatX?: (value: number) => string;
/** Format the y value for the readout. Defaults to a compact number. */
formatY?: (value: number, key: string) => string;
/** A heading for the readout, from the row — a name, a label, a category. */
formatTitle?: (datum: ScatterChartDatum) => string;
/** How far from a point a touch still counts as being on it, in points. */
hitRadius?: number;
}
/**
* The touch target, the selection it drives, and the readout that follows it.
*
* A line chart's crosshair snaps to an x index. That is not available here:
* there is no shared x, and two points can sit on the same one. So the nearest
* point is found by distance instead — and only within `hitRadius`, so a touch
* in an empty corner selects nothing rather than lighting up whichever point is
* least far away.
*
* The search runs on the UI thread over a flat array of already-projected
* coordinates, and only the *identity* of the winner crosses back into JS, and
* only when it changes. A drag across the plot therefore costs a handful of
* re-renders rather than one per frame.
*
* Distances are compared squared. The nearest point by distance is the nearest
* by distance-squared, and a square root per point per frame buys nothing.
*/
function ScatterChartTooltip({
showLabel = true,
formatX,
formatY,
formatTitle,
hitRadius = HIT_RADIUS,
}: ScatterChartTooltipProps) {
const {
data,
xDataKey,
plot,
series,
xExtent,
yExtent,
activeId,
activePoint,
setActivePoint,
status,
} = useChart('ScatterChart.Tooltip');
const seriesKeys = series.map(([key]) => key).join('|');
/*
* Every point in the chart, projected once, as parallel arrays.
*
* Parallel arrays rather than an array of objects because this is read inside
* a worklet: Reanimated has to copy whatever the gesture captures across to
* the UI thread, and three number arrays cross far more cheaply than a few
* hundred small objects.
*
* Projected against the *settled* extents rather than the tweening shared
* values. Hit-testing against a moving scale would mean rebuilding this on
* every frame of a domain animation, and a point being half a second stale
* during a transition is not something a finger can notice.
*/
const hit = useMemo(() => {
const keys = seriesKeys ? seriesKeys.split('|') : [];
const xs: number[] = [];
const ys: number[] = [];
const ids: string[] = [];
const indices: number[] = [];
const owners: string[] = [];
for (const key of keys) {
for (let index = 0; index < data.length; index += 1) {
const row = data[index]!;
const x = row[xDataKey];
const y = row[key];
if (typeof x !== 'number' || Number.isNaN(x)) continue;
if (typeof y !== 'number' || Number.isNaN(y)) continue;
xs.push(xAt(x, plot, xExtent[0], xExtent[1]));
ys.push(yOf(y, plot, yExtent[0], yExtent[1]));
ids.push(`${key}:${index}`);
indices.push(index);
owners.push(key);
}
}
return { xs, ys, ids, indices, owners };
}, [data, xDataKey, seriesKeys, plot, xExtent, yExtent]);
// Resolves an id, which is all the worklet can cheaply hand back. JS turns it
// into the row it came from.
const select = useMemo(
() => (id: string) => {
if (!id) {
setActivePoint(null);
return;
}
const at = hit.ids.indexOf(id);
if (at < 0) {
setActivePoint(null);
return;
}
const index = hit.indices[at]!;
const key = hit.owners[at]!;
const datum = data[index];
if (!datum) {
setActivePoint(null);
return;
}
setActivePoint({
index,
dataKey: key,
x: datum[xDataKey] as number,
y: datum[key] as number,
datum,
});
},
[hit, data, xDataKey, setActivePoint]
);
/*
* Built in one closure, and everything it captures is a plain array, a number
* or a shared value. A worklet may only call another worklet, and the rule is
* enforced by crashing rather than by warning — so the resolver is declared
* here, next to its callers, rather than as a helper elsewhere in the file
* where it would be easy to leave un-workletised.
*/
const pan = useMemo(() => {
const xs = hit.xs;
const ys = hit.ys;
const ids = hit.ids;
const limit = hitRadius * hitRadius;
const resolve = (px: number, py: number) => {
'worklet';
let bestId = '';
let best = limit;
for (let i = 0; i < xs.length; i += 1) {
const dx = xs[i]! - px;
const dy = ys[i]! - py;
const distance = dx * dx + dy * dy;
if (distance <= best) {
best = distance;
bestId = ids[i]!;
}
}
if (bestId === activeId.value) return;
activeId.value = bestId;
runOnJS(select)(bestId);
};
const clear = () => {
'worklet';
if (activeId.value === '') return;
activeId.value = '';
runOnJS(select)('');
};
return Gesture.Pan()
.minDistance(0)
.onBegin((event) => {
'worklet';
resolve(event.x, event.y);
})
.onUpdate((event) => {
'worklet';
resolve(event.x, event.y);
})
.onFinalize(() => {
'worklet';
clear();
});
}, [hit, hitRadius, activeId, select]);
// The readout sits above the point and is clamped inside the plot, so it
// never runs off an edge at an extreme value.
const labelStyle = useAnimatedStyle(() => {
const id = activeId.value;
if (!id) return { opacity: 0 };
const at = hit.ids.indexOf(id);
if (at < 0) return { opacity: 0 };
const x = hit.xs[at]!;
const y = hit.ys[at]!;
const half = LABEL_WIDTH / 2;
return {
opacity: 1,
transform: [
{
translateX: Math.min(
plot.left + plot.width - half,
Math.max(plot.left + half, x)
) - half,
},
// Above the point, and pushed below it near the top of the plot where
// there is no room above.
{ translateY: y - plot.top < LABEL_HEIGHT ? y + 16 : y - LABEL_HEIGHT },
],
};
});
const fmtX = formatX ?? ((value: number) => compactNumber(value));
const fmtY = formatY ?? ((value: number) => compactNumber(value));
if (status === 'loading') return null;
return (
{showLabel ? (
{activePoint ? (
<>
{formatTitle ? (
{formatTitle(activePoint.datum)}
) : null}
{series.length > 1 ? (
key === activePoint.dataKey)?.[1] ?? undefined,
}}
className="h-1.5 w-1.5 rounded-full"
/>
) : null}
{fmtX(activePoint.x)} · {fmtY(activePoint.y, activePoint.dataKey)}
>
) : null}
) : null}
);
}
ScatterChartTooltip.displayName = 'ScatterChart.Tooltip';
ScatterChartTooltip.layer = 'overlay' as Layer;
export interface ScatterChartLegendProps extends ViewProps {
className?: string;
/** Label per series key. A key with no label falls back to the key itself. */
labels?: Record;
}
/**
* A swatch and a name per registered series. Sits in the top-left of the plot
* by default — move it with `className`.
*/
function ScatterChartLegend({ className, labels, ...props }: ScatterChartLegendProps) {
const { series } = useChart('ScatterChart.Legend');
if (!series.length) return null;
return (
{series.map(([key, color]) => (
))}
);
}
ScatterChartLegend.displayName = 'ScatterChart.Legend';
ScatterChartLegend.layer = 'overlay' as Layer;
/** One series' colour and name. Shared by the legend and the header. */
function SeriesSwatch({ color, label }: { color: string; label: string }) {
return (
{label}
);
}
/* -------------------------------------------------------------------------- */
/* Header layer */
/* -------------------------------------------------------------------------- */
export interface ScatterChartHeaderProps 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 total. */
caption?: string;
/** Prettier names for the series keys, as the legend takes. */
labels?: Record;
/**
* Draw a swatch and a name per series along the trailing edge. Prefer this to
* `ScatterChart.Legend` on a chart that has a header: the legend floats over
* the plot, where it competes with the points 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 chart 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 `onActivePointChange` and pass the
* formatted string down, so one header can show a summary when nothing is
* pressed and a point's values when something is.
*/
function ScatterChartHeader({
className,
title,
value,
caption,
labels,
legend = false,
children,
...props
}: ScatterChartHeaderProps) {
const { series } = useChart('ScatterChart.Header');
const trailing =
children ??
(legend && series.length ? (
{series.map(([key, color]) => (
))}
) : null);
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{/* Shrinkable, unlike a view's default in React Native. Held rigid, a
three-series key takes the width it wants and the caption underneath
the value wraps to two lines to make room for it. */}
{trailing ? {trailing} : null}
);
}
ScatterChartHeader.displayName = 'ScatterChart.Header';
ScatterChartHeader.layer = 'header' as Layer;
export const ScatterChart = Object.assign(ScatterChartRoot, {
Header: ScatterChartHeader,
Grid: ScatterChartGrid,
Points: ScatterChartPoints,
Skeleton: ScatterChartSkeleton,
XAxis: ScatterChartXAxis,
YAxis: ScatterChartYAxis,
Tooltip: ScatterChartTooltip,
Legend: ScatterChartLegend,
});