/**
* Plot — a chart you assemble, for the chart that is not in the box.
*
* The other charts in this library each answer one question and answer it
* completely: a line chart knows it is drawing a series over time, and every
* decision inside it follows from that. This one knows nothing. It measures a
* box, resolves a scale, and hands both to whatever marks you put in it — so a
* combination nothing here ships as its own component is still a chart you can
* build rather than a chart you have to go without.
*
* ```tsx
*
*
*
*
*
*
*
*
*
* ```
*
* ## One scale, however many marks
*
* Every mark reads the same plot box and the same y-domain, and the domain is
* derived from all of them together. That is the whole reason to compose rather
* than to stack two charts on top of each other: two scales drawn over each
* other look like a comparison and are not one.
*
* ## Where your own marks go
*
* `Plot.Layer` drops its children into the SVG tree, and `usePlot()` gives them
* the resolved geometry — the box, the tweening domain, the reveal, the palette.
* The scale functions are worklets exported alongside this component, so a mark
* of your own is rebuilt on the UI thread on the same frames these are:
*
* ```tsx
* function Threshold({ value }: { value: number }) {
* const { plot, domainMin, domainMax } = usePlot();
* const props = useAnimatedProps(() => {
* const y = yOf(value, plot, domainMin.value, domainMax.value);
* return { d: `M${plot.left},${y}H${plot.left + plot.width}` };
* });
* return ;
* }
*
*
*
*
* ```
*
* Anything that is text or takes a touch goes in `Plot.Overlay` instead, which
* is a React Native view over the drawing. That split is not a preference: 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.
*
* ## What it will not do for you
*
* There is no `type` prop and no set of defaults that guess at one. A chart
* assembled here is exactly the marks you wrote, in the order you wrote them —
* which is also the order they are drawn in, so a line over bars is a line
* written after them.
*/
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 { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import Svg, {
Circle,
Defs,
G,
Line as SvgLine,
Path,
Rect,
} from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import { Text } from '../../primitives/text';
import { finiteChartNumber } from '../../primitives/finite-chart';
import {
areaPath,
bandOf,
barPath,
columnValues,
compactNumber,
linePath,
niceDomain,
useSeriesColor,
xOf,
yOf,
type Plot as PlotBox,
} from '../../utils/chart';
import { cn } from '../../utils/cn';
import {
registerPlotSeries,
unregisterPlotSeries,
visiblePlotSeries,
type PlotSeriesRegistration,
} from './plot-series-registry';
/*
* The scale functions, re-exported so a mark written outside this file can sit
* on the same geometry the built-in ones do. Every one of them is a worklet, so
* a custom mark is rebuilt on the UI thread on the frames these are — which is
* the difference between an escape hatch and a second, slower chart drawn on
* top of the first.
*/
export {
areaPath,
bandOf,
barPath,
compactNumber,
linePath,
segment,
xAt,
xOf,
yOf,
} from '../../utils/chart';
export type { Plot as PlotBox, ChartPoint } from '../../utils/chart';
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
/** Room left around the plot for the axis labels and the markers' rings. */
const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
/** 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 rule it names. */
const AXIS_LABEL_HEIGHT = 16;
/** Box each x label is centred in; a longer one is ellipsised rather than shoving. */
const POINT_LABEL_WIDTH = 56;
/** Width of the readout that rides the cursor. */
const LABEL_WIDTH = 120;
/**
* Which layer a part belongs to. Read off the component itself, so composition
* stays a flat list of children instead of three nested slots whose order the
* caller has to remember.
*/
type Layer = 'svg' | 'series' | 'overlay' | 'header';
export type PlotStatus = 'loading' | 'ready';
export type PlotCurve = 'monotone' | 'linear';
export type PlotDatum = Record;
/**
* How the index of a row becomes an x.
*
* `point` puts the first and last rows on the plot's own edges, which is what a
* series wants — the line should reach the frame. `band` gives every row an
* equal slice and centres it in the middle of that, which is what anything with
* width wants: a bar sitting on the edge of the plot is a bar half of which is
* outside it.
*
* Resolved from the marks by default, so a chart with bars in it is banded
* without being told.
*/
export type PlotScale = 'point' | 'band';
/** One end of the y-domain: a number to pin it at, or `auto` to derive it. */
export type PlotBound = number | 'auto';
/** Everything a mark needs to draw itself. Read it with `usePlot()`. */
export interface PlotGeometry {
/** The rows, in order. */
data: PlotDatum[];
/** Key holding the x label. */
xDataKey: string;
/** The drawable box, after the padding and any axis gutter are taken off. */
plot: PlotBox;
/** How an index becomes an x. */
xScale: PlotScale;
/** `loading` draws nothing but the frame. */
status: PlotStatus;
/**
* The y-domain, tweened. Read these inside worklets — they are what makes a
* chart whose numbers changed redraw against a moving axis rather than jump.
*/
domainMin: SharedValue;
domainMax: SharedValue;
/**
* The domain the tween is heading for. Labels read this rather than the shared
* values: a number re-rendered on every frame of a tween is thirty renders of
* a label that lands on the string it started on.
*/
extent: [number, number];
/** `0` to `1` as the plot is uncovered on mount. */
reveal: SharedValue;
/** The five theme series colours, in order of prominence. */
palette: string[];
/** Every mark that registered itself, as `[dataKey, colour]`. */
series: [string, string][];
registerSeries: (key: string, color: string) => void;
unregisterSeries: (key: string, color?: string) => void;
/** Row under the cursor, or `-1`. On the UI thread. */
activeIndex: SharedValue;
/** The same index on the JS thread, for anything that has to re-render. */
activeIndexJS: number;
setActiveIndexJS: (index: number) => void;
/** The clip every mark shares, so they are revealed as one drawing. */
}
const PlotContext = createContext(null);
function useChart(component: string): PlotGeometry {
const context = useContext(PlotContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* The resolved geometry, for a mark of your own.
*
* Everything in it is either a plain number that changes on layout or a shared
* value that changes every frame, so a mark built from it animates with the
* rest of the chart rather than beside it.
*/
export function usePlot(): PlotGeometry {
return useChart('usePlot');
}
/** The row under the cursor, for a readout drawn inside the plot. */
export function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum | null } {
const { data, activeIndexJS } = useChart('usePlotCursor');
return {
activeIndex: activeIndexJS,
activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
};
}
export interface PlotProps extends ViewProps, ChartAccessibilityProps {
className?: string;
/** The rows. Each one is a position along the x-axis. */
data: PlotDatum[];
/** Key holding the x label. Used by the axis and the readout. */
xDataKey?: string;
/**
* `loading` draws the frame and nothing in it, and reveals the marks when it
* turns `ready`. One component throughout rather than a spinner swapped for a
* chart — swapping loses the transition.
*/
status?: PlotStatus;
/** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */
aspectRatio?: number;
/** Milliseconds for the plot to be uncovered on mount. */
animationDuration?: number;
/** Milliseconds for the y-axis to settle after the data changes. */
domainDuration?: number;
/**
* The y-domain, as `[low, high]`. Either end may be a number to pin it there
* or `auto` to take it from the data.
*
* Pinning one end is the case this exists for: `[0, 'auto']` keeps the
* baseline at zero, which a chart of lengths needs — a bar cropped at the
* bottom is a length that lies — while still letting the top follow whatever
* arrives.
*/
yDomain?: [PlotBound, PlotBound];
/**
* Round the derived ends of the y-domain out to whole numbers.
*
* Left off, an axis ends a tenth of the span past the largest value, so it
* gets labelled 34,650 — true, and not a number anybody was looking for. On,
* the ends move out to a step of 1, 2 or 5 times a power of ten, and the
* labels become values a reader can measure against.
*
* It only ever widens the axis, and a pinned end is left alone.
*/
nice?: boolean;
/**
* How an index becomes an x. Derived from the marks when left out: a plot
* with bars in it is banded, and anything else is on points.
*/
xScale?: PlotScale;
/** How series are joined between points, unless a mark overrides it. */
curve?: PlotCurve;
/**
* The row under the cursor as it moves, and `-1`/`null` when the finger
* lifts. This is how a readout *outside* the plot gets its value — that
* header is not inside this provider, so it cannot use `usePlotCursor`.
*/
onActiveIndexChange?: (index: number, datum: PlotDatum | null) => void;
/**
* Drop the padding so the marks reach the edges — for a plot with no axis,
* grid or cursor, where the shape is the whole point.
*/
compact?: boolean;
children?: ReactNode;
}
/** Imperative handle: re-run the reveal, for a "replay" control. */
export interface PlotHandle {
replay: () => void;
}
const PlotRoot = forwardRef(function PlotRoot(
{
className,
data,
xDataKey = 'label',
status = 'ready',
aspectRatio = 2,
animationDuration = 700,
domainDuration = 500,
yDomain,
nice = false,
xScale: xScaleProp,
curve = 'monotone',
onActiveIndexChange,
accessible,
accessibilityLabel,
accessibilityHint,
accessibilityLabelForDatum,
onAccessibilityDatumPress,
compact = false,
children,
...props
},
ref
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [registrations, setRegistrations] = useState([]);
const [activeIndexJS, setActiveIndexJS] = useState(-1);
const reveal = useSharedValue(0);
const domainMin = useSharedValue(0);
const domainMax = useSharedValue(0);
const activeIndex = useSharedValue(-1);
const reducedMotion = useReducedMotion();
const registerSeries = useMemo(
() => (key: string, color: string) =>
setRegistrations((current) => registerPlotSeries(current, key, color)),
[]
);
const unregisterSeries = useMemo(
() => (key: string, color?: string) =>
setRegistrations((current) => unregisterPlotSeries(current, key, color)),
[]
);
const series = useMemo(() => visiblePlotSeries(registrations), [registrations]);
/*
* What the children need decided before anything is laid out.
*
* Both have to be known *before* the plot box exists, and neither can be
* asked of the parts themselves — a part renders into a box that has already
* been decided. An axis given no gutter is drawn over the marks; a bar placed
* on a point scale hangs half of itself off the frame, and a bar on an axis
* that skips zero is drawn at a length the data does not support.
*/
const { hasYAxis, hasBars } = useMemo(() => {
let axis = false;
let bars = false;
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
const type = child.type as { axis?: string; mark?: string };
if (type.axis === 'y') axis = true;
if (type.mark === 'band') bars = true;
});
return { hasYAxis: axis, hasBars: bars };
}, [children]);
const xScale: PlotScale = xScaleProp ?? (hasBars ? 'band' : 'point');
const pad = compact
? { top: 2, right: 1, bottom: 2, left: 1 }
: { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
const plot: PlotBox = {
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),
};
/*
* One extent across every registered mark, so two series share one axis and
* stay comparable. A scale per series makes two quantities orders of
* magnitude apart look alike, which is the chart lying rather than the chart
* being convenient.
*/
const seriesKeys = useMemo(() => series.map(([key]) => key), [series]);
const extent = useMemo<[number, number]>(() => {
const lowPin = finiteChartNumber(yDomain?.[0]);
const highPin = finiteChartNumber(yDomain?.[1]);
if (lowPin !== undefined && highPin !== undefined) {
return [lowPin, highPin];
}
let min = Infinity;
let max = -Infinity;
for (const row of data) {
for (const key of seriesKeys) {
const value = finiteChartNumber(row[key]);
if (value === undefined) continue;
if (value < min) min = value;
if (value > max) max = value;
}
}
if (min === Infinity) {
// Nothing to measure. Fall back to whatever was pinned, and to a unit
// domain when nothing was — dividing by a span of zero is how a mark
// ends up drawn at infinity.
const low = lowPin ?? 0;
const high = highPin ?? low + 1;
return [low, high];
}
/*
* A bar's length is measured from zero, so an axis that does not contain
* zero makes every bar on it a lie about its own size — six columns of
* near-identical height standing for numbers that differ by half. So a plot
* with bars in it gets zero whether the data reaches it or not, and that end
* is then left exactly where it is: headroom under a baseline lifts the bars
* off the thing they are measured from.
*/
let lowRoom = 1;
let highRoom = 1;
if (hasBars) {
if (min > 0) min = 0;
if (max < 0) max = 0;
if (min === 0) lowRoom = 0;
if (max === 0) highRoom = 0;
}
// A flat series has no extent of its own; give it one so it lands on the
// middle of the plot instead of dividing by zero.
if (min === max) {
min -= 1;
max += 1;
}
/*
* Headroom goes on the derived ends only. A pinned zero that got a tenth of
* the span subtracted from it is not a pinned zero, and the baseline
* drifting off the axis is exactly what pinning it was for.
*/
const headroom = (max - min) * 0.1;
/*
* `nice` replaces the headroom rather than adding to it: rounding out to a
* whole step is already room, and doing both would leave a tenth of the
* span of empty axis above a top the reader was told is round.
*/
const [niceLow, niceHigh] = nice ? niceDomain(min, max) : [min, max];
const derivedLow = nice ? niceLow : min - headroom * lowRoom;
const derivedHigh = nice ? niceHigh : max + headroom * highRoom;
return [
lowPin ?? derivedLow,
highPin ?? derivedHigh,
];
}, [data, yDomain, seriesKeys, hasBars, nice]);
const loading = status === 'loading';
// Nothing has said what the axis is yet — no mark has registered and no
// domain was given. Assigning one now would tween the real domain up out of a
// placeholder on the frame the first mark mounts.
const hasDomain = seriesKeys.length > 0 || yDomain !== undefined;
useEffect(() => {
if (loading || !hasDomain) return;
const [min, max] = extent;
// 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 = domainMin.value === 0 && domainMax.value === 0;
if (first || reducedMotion) {
domainMin.value = min;
domainMax.value = max;
return;
}
domainMin.value = withTiming(min, { duration: domainDuration });
domainMax.value = withTiming(max, { duration: domainDuration });
}, [extent, loading, hasDomain, reducedMotion, domainDuration, 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, so a refetch is uncovered
// rather than appearing whole on the frame the data lands.
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]);
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 handleActiveIndex = useMemo(
() => (index: number) => {
setActiveIndexJS(index);
onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);
},
[onActiveIndexChange, data]
);
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,
xScale,
status,
domainMin,
domainMax,
extent,
reveal,
palette,
series,
registerSeries,
unregisterSeries,
activeIndex,
activeIndexJS,
setActiveIndexJS: handleActiveIndex,
}),
// `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,
xScale,
status,
domainMin,
domainMax,
extent,
reveal,
palette,
series,
registerSeries,
unregisterSeries,
activeIndex,
activeIndexJS,
handleActiveIndex,
]
);
const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));
const { svg, series: seriesLayer, 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 the header in 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]],
...seriesKeys.map((key) => [key, datum[key]] as [string, unknown]),
]}
/>
{plot.width > 0 ? (
<>
{/*
* One reveal for every mark, and it is a view that grows rather
* than an SVG clip path. Sharing it is what makes the reveal read
* as the chart arriving, rather than as four separate things
* animating in at once — which is the thing a composed chart is
* most at risk of looking like.
*
* It used to be an animated `` inside ``, and on
* Android those animated props never reach the native clip — the
* rect keeps whatever width was declared on it, so the marks drew
* complete and the reveal simply did not play. A view with
* `overflow: 'hidden'` is clipped by the platform itself, which
* both platforms agree on.
*
* Animating `width` is normally a layout pass per frame. Here the
* view is absolutely positioned and its only child is an `
);
});
PlotRoot.displayName = 'Plot';
/** Sorts the children into the SVG tree, the view layer over it, and the header. */
function partition(children: ReactNode) {
const svg: ReactNode[] = [];
const series: 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 bucket =
layer === 'header'
? header
: layer === 'overlay'
? overlay
: layer === 'series'
? series
: svg;
bucket.push(
// Children of a `Children.forEach` need keys of their own once they are
// put into a new array.
{child}
);
});
return { svg, series, overlay, header };
}
/** Identity wrapper, purely so the partitioned arrays can carry keys. */
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
/**
* A mark's colour: the one it was given, else the theme token at its index.
*
* Registration happens here rather than in each mark, because every mark that
* registers does it for the same two reasons — the domain has to take it in,
* and the legend has to name it.
*/
function useMark(dataKey: string, color: string | undefined, colorIndex: number) {
const { palette, registerSeries, unregisterSeries } = useChart('Plot mark');
const resolved = color ?? palette[(colorIndex - 1) % palette.length] ?? palette[0]!;
useEffect(() => {
registerSeries(dataKey, resolved);
return () => unregisterSeries(dataKey, resolved);
}, [dataKey, resolved, registerSeries, unregisterSeries]);
return resolved;
}
/* -------------------------------------------------------------------------- */
/* SVG layer */
/* -------------------------------------------------------------------------- */
export interface PlotGridProps {
/** Horizontal rules across the plot. */
rows?: number;
color?: string;
/** Dash pattern, e.g. `"4,6"`. Omit for a solid rule. */
dashArray?: string;
opacity?: number;
}
/**
* Horizontal reference lines.
*
* Outside the reveal clip on purpose: the grid is the frame the chart arrives
* into, so it is already there when the marks start being uncovered.
*/
function PlotGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: PlotGridProps) {
const { plot } = useChart('Plot.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 }, (_, index) => {
const y = plot.top + (plot.height / rows) * index;
return (
);
})}
);
}
PlotGrid.displayName = 'Plot.Grid';
PlotGrid.layer = 'svg' as Layer;
export interface PlotSeriesProps {
/** Column of `data` this mark draws. */
dataKey: string;
/** Overrides the theme token. */
color?: string;
/** Which `--color-chart-*` token to take, `1` to `5`. */
colorIndex?: number;
}
export interface PlotLineProps extends PlotSeriesProps {
strokeWidth?: number;
/** `monotone` never overshoots between points; `linear` joins them straight. */
curve?: PlotCurve;
/** Dash pattern, e.g. `"6,4"` — for a forecast, or a series that is not real. */
dashArray?: string;
}
/** A series as a stroked line. */
function PlotLine({
dataKey,
color,
colorIndex = 1,
strokeWidth = 2.5,
curve = 'monotone',
dashArray,
}: PlotLineProps) {
const { data, plot, xScale, domainMin, domainMax, status } =
useChart('Plot.Line');
const stroke = useMark(dataKey, color, colorIndex);
/*
* Pulled out of the worklet as a plain array. A worklet may close over numbers
* and arrays of them freely, but reading `data`'s rows — objects of mixed
* types, keyed by strings the caller chose — inside one on every frame is work
* that belongs on the JS side once.
*/
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
const banded = xScale === 'band';
const animatedProps = useAnimatedProps(() => {
const min = domainMin.value;
const max = domainMax.value;
if (max === min || plot.width <= 0) return { d: '' };
/*
* `banded` moves the points to the middle of their own slice, which is
* where a line drawn over columns has to sit — spread edge to edge it
* starts half a slice left of the bar it is describing.
*/
return { d: linePath(values, plot, min, max, curve, false, undefined, banded) };
});
if (status === 'loading') return null;
return (
);
}
PlotLine.displayName = 'Plot.Line';
PlotLine.layer = 'series' as Layer;
export interface PlotAreaProps extends PlotSeriesProps {
opacity?: number;
curve?: PlotCurve;
}
/**
* A series as a fill down to the baseline.
*
* Written before the line it belongs under, since the order the marks are
* written is the order they are drawn.
*/
function PlotArea({
dataKey,
color,
colorIndex = 1,
opacity = 0.18,
curve = 'monotone',
}: PlotAreaProps) {
const { data, plot, xScale, domainMin, domainMax, status } =
useChart('Plot.Area');
const fill = useMark(dataKey, color, colorIndex);
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
const banded = xScale === 'band';
const animatedProps = useAnimatedProps(() => {
const min = domainMin.value;
const max = domainMax.value;
if (max === min || plot.width <= 0) return { d: '' };
// Band-aware for the same reason the line is: an area on a plot that also
// carries columns has to close over the same slices they occupy.
return { d: areaPath(values, plot, min, max, curve, false, undefined, banded) };
});
if (status === 'loading') return null;
return (
);
}
PlotArea.displayName = 'Plot.Area';
PlotArea.layer = 'series' as Layer;
export interface PlotBarsProps extends PlotSeriesProps {
/** Fraction of each slice left empty, `0` to `1`. */
gap?: number;
/** Rounds the end the bar grows towards, in points. */
radius?: number;
opacity?: number;
/**
* The value the columns grow from. Zero by default, and zero is nearly always
* right — a bar is a length, and a length has to start where the quantity
* does.
*
* Set it for the case where the reader is being shown movement rather than
* size: temperatures against a seasonal average, a score against a pass mark.
* Columns then run up and down from that line instead of all standing on the
* floor. It is clamped into the axis, so a baseline the domain does not cover
* falls back to the nearer edge.
*/
baseline?: number;
}
/**
* A series as columns.
*
* One path for all of them rather than one node per bar: a plot of two hundred
* periods is the same single animated prop a frame as a plot of twenty.
*
* A bar has width, so its presence puts the whole plot on a band scale unless
* the root was told otherwise — see `xScale`.
*/
function PlotBars({
dataKey,
color,
colorIndex = 1,
gap = 0.35,
radius = 4,
opacity = 1,
baseline = 0,
}: PlotBarsProps) {
const { data, plot, domainMin, domainMax, status } = useChart('Plot.Bars');
const fill = useMark(dataKey, color, colorIndex);
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
const total = values.length;
const slice = total > 0 ? plot.width / total : 0;
const width = Math.max(1, slice * (1 - Math.min(Math.max(gap, 0), 0.95)));
const animatedProps = useAnimatedProps(() => {
const min = domainMin.value;
const max = domainMax.value;
if (max === min || plot.width <= 0 || total === 0) return { d: '' };
/*
* Bars grow from the baseline — zero unless the caller moved it — clamped
* into the domain where the axis does not reach it. A bar drawn from the
* bottom of a plot whose axis starts at 40 is a length that is not in the
* data.
*/
const base = yOf(Math.min(Math.max(baseline, min), max), plot, min, max);
let path = '';
for (let index = 0; index < total; index += 1) {
const value = values[index];
if (value === null || value === undefined) continue;
const y = yOf(value, plot, min, max);
const top = Math.min(y, base);
const height = Math.abs(base - y);
if (height <= 0) continue;
path += barPath(
bandOf(index, total, plot) - width / 2,
top,
width,
height,
radius,
y <= base ? 'up' : 'down'
);
}
return { d: path };
});
if (status === 'loading') return null;
return (
);
}
PlotBars.displayName = 'Plot.Bars';
PlotBars.layer = 'series' as Layer;
// Read by the root before it resolves the scale: a mark with width cannot sit
// on a point scale without half of the first and last one leaving the plot.
PlotBars.mark = 'band' as const;
export interface PlotDotsProps extends PlotSeriesProps {
/** Radius, in points. */
size?: number;
/** Ring around each dot, so it reads on top of the line rather than in it. */
ringWidth?: number;
}
/** A dot per row — for a series short enough that its points are worth marking. */
function PlotDots({
dataKey,
color,
colorIndex = 1,
size = 3.5,
ringWidth = 2,
}: PlotDotsProps) {
const { data, plot, xScale, domainMin, domainMax, status } =
useChart('Plot.Dots');
const fill = useMark(dataKey, color, colorIndex);
const ringToken = useCSSVariable('--color-background');
const ring = typeof ringToken === 'string' ? ringToken : '#000000';
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
const banded = xScale === 'band';
return (
{status === 'loading'
? null
: values.map((value, index) =>
value === null || value === undefined ? null : (
)
)}
);
}
PlotDots.displayName = 'Plot.Dots';
PlotDots.layer = 'series' as Layer;
/** One dot, whose y follows the domain tween. */
function Dot({
value,
x,
plot,
domainMin,
domainMax,
fill,
ring,
size,
ringWidth,
}: {
value: number;
x: number;
plot: PlotBox;
domainMin: SharedValue;
domainMax: SharedValue;
fill: string;
ring: string;
size: number;
ringWidth: number;
}) {
const animatedProps = useAnimatedProps(() => ({
cy: yOf(value, plot, domainMin.value, domainMax.value),
}));
return (
);
}
export interface PlotLayerProps {
children?: ReactNode;
}
/**
* Marks of your own, in the SVG tree.
*
* Whatever is inside is drawn where it is written — before the marks written
* after it, after the ones before it — and reaches the geometry through
* `usePlot()`. It is not given the geometry as an argument, because a mark that
* animates has to hold hooks of its own and a render prop is not a component.
*/
function PlotLayer({ children }: PlotLayerProps) {
useChart('Plot.Layer');
return {children};
}
PlotLayer.displayName = 'Plot.Layer';
PlotLayer.layer = 'series' as Layer;
/* -------------------------------------------------------------------------- */
/* Overlay layer */
/* -------------------------------------------------------------------------- */
export interface PlotOverlayProps {
className?: string;
children?: ReactNode;
}
/**
* Anything of your own that is text or takes a touch, laid over the drawing.
*
* 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 — so those two go
* here rather than in `Plot.Layer`.
*/
function PlotOverlay({ className, children }: PlotOverlayProps) {
return (
{children}
);
}
PlotOverlay.displayName = 'Plot.Overlay';
PlotOverlay.layer = 'overlay' as Layer;
export interface PlotRuleProps {
/**
* Where to draw it, in the data's own units. Omit it and pass `x` instead for
* a rule down the plot rather than across it.
*/
y?: number;
/**
* A row to draw a vertical rule at, by index — the release the numbers are
* read against, the day a change landed.
*
* The x axis here carries positions rather than quantities, so this is which
* row rather than what value. Exactly one of `y` and `x` is drawn; `y` wins
* if both are given.
*/
x?: number;
/** A name for what the line means. Nothing is drawn without one. */
label?: string;
/** Overrides the line *and* its caption, so the two cannot drift apart. */
color?: string;
/** Thickness in points. */
strokeWidth?: number;
/**
* Break the line into dashes, for a rule that should read as an annotation
* rather than as a series the chart is drawing.
*/
dashed?: boolean;
/** Fades the line and its caption together. */
opacity?: number;
/** Which end of the rule the caption sits at. */
labelPlacement?: 'start' | 'end';
labelClassName?: string;
className?: string;
}
/**
* A reference line across the plot — a target, a limit, an average.
*
* A view rather than an SVG line, so its label is real text and follows the
* theme. Its thickness is a border and its position is a transform, so it costs
* no more than the SVG line would while gaining a legible caption.
*
* It is drawn at full strength in the foreground colour, and it is meant to be.
* A reference line is the number the series is being judged against — a target
* nobody can read is a target the chart is not actually stating — so what keeps
* it from being mistaken for a series is that it is neutral and optionally
* dashed, not that it is faint.
*/
function PlotRule({
y,
x,
label,
color,
strokeWidth = 1,
dashed = false,
opacity = 1,
labelPlacement = 'end',
labelClassName,
className,
}: PlotRuleProps) {
const { plot, domainMin, domainMax, status, data } = useChart('Plot.Rule');
const token = useCSSVariable('--color-foreground');
const stroke = color ?? (typeof token === 'string' ? token : '#888888');
const vertical = y === undefined && x !== undefined;
const horizontalStyle = useAnimatedStyle(() => {
if (vertical || y === undefined) return { opacity: 0 };
const min = domainMin.value;
const max = domainMax.value;
if (max === min) return { opacity: 0 };
const at = yOf(y, plot, min, max);
// Off the top or bottom of the plot is out of the chart's range, and a rule
// pinned to the edge would claim a value the axis does not cover.
const inside = at >= plot.top - 0.5 && at <= plot.top + plot.height + 0.5;
return { opacity: inside ? opacity : 0, transform: [{ translateY: at }] };
});
if (status === 'loading') return null;
const line = dashed
? {
borderColor: stroke,
borderStyle: 'dashed' as const,
borderTopWidth: strokeWidth,
}
: { backgroundColor: stroke, height: strokeWidth };
if (vertical) {
/*
* The x axis carries positions, not quantities, so a vertical rule is
* placed from the row's own geometry rather than from the tweening domain —
* there is nothing on this axis for it to tween against.
*/
const total = data.length;
const at =
total > 0
? xOf(Math.max(0, Math.min(total - 1, x!)), total, plot)
: plot.left + plot.width / 2;
return (
{label ? (
{label}
) : null}
);
}
return (
{label ? (
{label}
) : null}
);
}
PlotRule.displayName = 'Plot.Rule';
PlotRule.layer = 'overlay' as Layer;
export interface PlotXAxisProps {
/** How many labels to show. The rest are dropped, evenly. */
ticks?: number;
/** Turn a row into its label. Defaults to the value at `xDataKey`. */
format?: (datum: PlotDatum, index: number) => string;
className?: string;
}
/**
* The x labels. Real text rather than SVG text, so they follow the theme's font
* and the platform's text scaling — SVG text does neither.
*/
function PlotXAxis({ ticks = 4, format, className }: PlotXAxisProps) {
const { data, xDataKey, plot, xScale } = useChart('Plot.XAxis');
const labels = useMemo(() => {
if (!data.length) return [];
const count = Math.min(ticks, data.length);
const step = count > 1 ? (data.length - 1) / (count - 1) : 0;
return Array.from({ length: count }, (_, index) => {
const dataIndex = Math.round(index * step);
const datum = data[dataIndex];
if (!datum) return null;
return {
key: dataIndex,
text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),
};
}).filter((label): label is { key: number; text: string } => label !== null);
}, [data, ticks, format, xDataKey]);
/*
* Each label sits on its own point rather than being spread along the axis.
* The indices `ticks` picks are not evenly spaced — twelve months shown five
* at a time gives 0, 3, 6, 8, 11 — so spreading them evenly put the ones in
* between over the wrong part of the plot.
*/
return (
{labels.map((label) => {
const x =
xScale === 'band'
? bandOf(label.key, data.length, plot)
: xOf(label.key, data.length, plot);
return (
{label.text}
);
})}
);
}
PlotXAxis.displayName = 'Plot.XAxis';
PlotXAxis.layer = 'overlay' as Layer;
export interface PlotYAxisProps {
/** 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.
*
* Give it the same `ticks` as the grid, or the numbers name lines that are not
* there. Four is the default on both for that reason.
*
* The labels are the domain the data settles at, not the tweening one — a
* number counting through every intermediate value while the axis animates is
* noise, and the axis is the part of a chart that has to hold still enough to
* be read.
*/
function PlotYAxis({ ticks = 4, format, className }: PlotYAxisProps) {
const { plot, extent } = useChart('Plot.YAxis');
const labels = useMemo(() => {
const [min, max] = extent;
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) };
});
}, [extent, ticks, format]);
return (
{labels.map((label) => (
{label.text}
))}
);
}
PlotYAxis.displayName = 'Plot.YAxis';
PlotYAxis.layer = 'overlay' as Layer;
// Read by the root, which has to leave room for the labels before it lays the
// plot out.
PlotYAxis.axis = 'y' as const;
export interface PlotCursorProps {
color?: string;
/** Hide the vertical line and keep only the touch handling. */
showLine?: boolean;
}
/**
* The touch handling, and the line that follows it.
*
* Split from the readout next door because they are separately useful: a plot
* whose value is shown in its own header wants this and no label, and a plot
* that highlights a bar wants this and nothing else at all. Both read the same
* index.
*
* The hit area is the whole plot. A cursor you have to land on the line to
* summon is a cursor nobody finds.
*/
function PlotCursor({ color, showLine = true }: PlotCursorProps) {
const { data, plot, xScale, activeIndex, setActiveIndexJS, status } =
useChart('Plot.Cursor');
const token = useCSSVariable('--color-foreground');
const stroke = color ?? (typeof token === 'string' ? token : '#888888');
const total = data.length;
const left = plot.left;
const width = plot.width;
const banded = xScale === 'band';
/*
* Built in one closure, and everything it captures is 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 next to its
* callers instead of as a helper further down the file where it would be easy
* to leave un-workletised.
*/
const pan = useMemo(() => {
const resolve = (x: number) => {
'worklet';
if (total < 1 || width <= 0) return;
const ratio = (x - left) / width;
const clamped = Math.min(1, Math.max(0, ratio));
// A band scale divides the plot into `total` slices and the finger is in
// one of them; a point scale has `total - 1` gaps between marks and the
// finger rounds to the nearest.
const next = banded
? Math.min(total - 1, Math.floor(clamped * total))
: Math.round(clamped * Math.max(total - 1, 0));
if (next === activeIndex.value) return;
activeIndex.value = next;
// Only the index needs JS, and only when it changes — a drag across a
// hundred rows costs a hundred re-renders at most, not one per frame for
// the length of the gesture.
runOnJS(setActiveIndexJS)(next);
};
return Gesture.Pan()
.minDistance(0)
.onBegin((event) => {
'worklet';
resolve(event.x);
})
.onUpdate((event) => {
'worklet';
resolve(event.x);
})
.onFinalize(() => {
'worklet';
activeIndex.value = -1;
runOnJS(setActiveIndexJS)(-1);
});
}, [total, left, width, banded, activeIndex, setActiveIndexJS]);
const lineStyle = useAnimatedStyle(() => {
const index = activeIndex.value;
if (index < 0) return { opacity: 0 };
const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);
return { opacity: 0.45, transform: [{ translateX: x }] };
});
if (status === 'loading') return null;
return (
{showLine ? (
) : null}
);
}
PlotCursor.displayName = 'Plot.Cursor';
PlotCursor.layer = 'overlay' as Layer;
export interface PlotTooltipProps {
/** Format one series' value. Defaults to a compact number. */
formatValue?: (value: number, key: string) => string;
/** Format the heading from the row. Defaults to the value at `xDataKey`. */
formatX?: (datum: PlotDatum) => string;
/** Draw the readout yourself, given the row under the cursor. */
children?: (datum: PlotDatum, index: number) => ReactNode;
className?: string;
}
/**
* The readout that rides the cursor.
*
* Needs a `Plot.Cursor` beside it — the cursor owns the gesture and this only
* reads the index it resolves. On its own it never appears, which is the right
* failure: a label with no way to move is worse than no label.
*/
function PlotTooltip({ formatValue, formatX, children, className }: PlotTooltipProps) {
const { data, xDataKey, plot, xScale, series, activeIndex, activeIndexJS, status } =
useChart('Plot.Tooltip');
const total = data.length;
const banded = xScale === 'band';
// Centred over the cursor but clamped inside the plot, so it never runs off
// the edge at the first or last row.
const style = useAnimatedStyle(() => {
const index = activeIndex.value;
if (index < 0) return { opacity: 0 };
const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);
const half = LABEL_WIDTH / 2;
const clamped = Math.min(
plot.left + plot.width - half,
Math.max(plot.left + half, x)
);
return { opacity: 1, transform: [{ translateX: clamped - half }] };
});
const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
const fmtX = formatX ?? ((datum: PlotDatum) => String(datum[xDataKey] ?? ''));
if (status === 'loading') return null;
return (
{active ? (
children ? (
children(active, activeIndexJS)
) : (
<>
{fmtX(active)}
{series.map(([key, color]) => {
const value = active[key];
if (typeof value !== 'number') return null;
return (
{series.length > 1 ? (
) : null}
{fmtValue(value, key)}
);
})}
>
)
) : null}
);
}
PlotTooltip.displayName = 'Plot.Tooltip';
PlotTooltip.layer = 'overlay' as Layer;
/* -------------------------------------------------------------------------- */
/* Header layer */
/* -------------------------------------------------------------------------- */
export interface PlotHeaderProps extends ViewProps {
className?: string;
/** A word for what the plot is of. */
title?: string;
/** The figure, large. Usually the total, or the row under the cursor. */
value?: string;
/** A line under the value. */
caption?: string;
/** Replaces the whole header, keeping only its place above the drawing. */
children?: ReactNode;
}
/** The row above the drawing: what it is, and the one number worth reading. */
function PlotHeader({
className,
title,
value,
caption,
children,
...props
}: PlotHeaderProps) {
return (
{children ?? (
<>
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
>
)}
);
}
PlotHeader.displayName = 'Plot.Header';
PlotHeader.layer = 'header' as Layer;
export interface PlotLegendProps extends ViewProps {
className?: string;
/** Names for the columns, keyed by `dataKey`. Falls back to the key itself. */
labels?: Record;
}
/**
* A swatch and a name per mark, taken from the marks that registered.
*
* In the header rather than over the drawing, because a key laid inside the
* plot either covers a mark or is squeezed to one word a line.
*/
function PlotLegend({ className, labels, ...props }: PlotLegendProps) {
const { series } = useChart('Plot.Legend');
if (!series.length) return null;
return (
{series.map(([key, color]) => (
{labels?.[key] ?? key}
))}
);
}
PlotLegend.displayName = 'Plot.Legend';
PlotLegend.layer = 'header' as Layer;
export const Plot = Object.assign(PlotRoot, {
Header: PlotHeader,
Legend: PlotLegend,
Grid: PlotGrid,
Area: PlotArea,
Bars: PlotBars,
Line: PlotLine,
Dots: PlotDots,
Rule: PlotRule,
Layer: PlotLayer,
Overlay: PlotOverlay,
XAxis: PlotXAxis,
YAxis: PlotYAxis,
Cursor: PlotCursor,
Tooltip: PlotTooltip,
});