/**
* AreaChart — filled bands over time, stacked or overlaid.
*
* ```tsx
*
*
*
*
*
*
*
* ```
*
* ## Why this is not `LineChart.Area`
*
* `LineChart.Area` shades under a line, which is one series saying "this is
* the shape of the thing". This chart answers a different question — *what is
* it made of* — and that needs stacking: each band sits on the running total
* of the ones below it, so the top edge is the whole and the thickness of each
* band is its share.
*
* Stacking is not a flag that could be bolted on. It changes the y-domain from
* the largest single series to the largest *sum*, it makes each area's
* baseline a curve rather than the axis, and it makes the order the series are
* declared in load-bearing. A chart where all of that is true of some children
* and not others is a chart nobody can read, so it is a chart of its own.
*
* Unstacked, the bands overlay each other and the fills are drawn translucent,
* which is the right reading when the series are alternatives rather than
* parts — two plans compared, not two slices of one total.
*/
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,
cancelAnimation,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import Svg, {
Circle,
Defs,
G,
Line as SvgLine,
LinearGradient,
Path,
Rect,
Stop,
} from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import {
areaPath,
columnValues,
compactNumber,
linePath,
useSeriesColor,
xOf,
yOf,
type ChartCurve,
type Plot,
type SeriesColorIndex,
} from '../../utils/chart';
import { cn } from '../../utils/cn';
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
const LABEL_WIDTH = 132;
/**
* Box each x label is centred in. Wide enough for a short label to sit over
* its point without the neighbours colliding; a longer one is ellipsised
* rather than allowed to push the others out of place.
*/
const POINT_LABEL_WIDTH = 56;
/** Line height of an `xs` label, for centring one on the grid line it names. */
const AXIS_LABEL_HEIGHT = 16;
const DOT = 9;
/** 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;
type Layer = 'svg' | 'series' | 'overlay' | 'header';
export type AreaChartStatus = 'loading' | 'ready';
export type AreaChartDatum = Record;
interface AreaChartContextValue {
data: AreaChartDatum[];
xDataKey: string;
plot: Plot;
status: AreaChartStatus;
curve: ChartCurve;
stacked: boolean;
series: [string, string][];
registerSeries: (key: string, color: string) => void;
unregisterSeries: (key: string) => void;
domainMin: SharedValue;
domainMax: SharedValue;
extent: [number, number];
activeIndex: SharedValue;
activeIndexJS: number;
setActiveIndexJS: (index: number) => void;
}
const AreaChartContext = createContext(null);
function useChart(component: string): AreaChartContextValue {
const context = useContext(AreaChartContext);
if (!context) {
throw new Error(`${component} must be used within an `);
}
return context;
}
/**
* The point under the crosshair, for something rendered *inside* the chart.
* A readout in the card's header is outside this provider — use
* `onActiveIndexChange` for that.
*/
export function useAreaChart() {
const { data, activeIndexJS, xDataKey } = useChart('useAreaChart');
return {
activeIndex: activeIndexJS,
activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,
xDataKey,
};
}
export interface AreaChartProps extends ViewProps, ChartAccessibilityProps {
className?: string;
/** The rows. Each one is a point along the x-axis. */
data: AreaChartDatum[];
/** Key holding the x label. Used by the axis and the crosshair readout. */
xDataKey?: string;
/**
* `loading` holds the bands flat and grows them into the real ones when it
* turns `ready`. Add an `AreaChart.Skeleton` for something to stand in the
* plot meanwhile.
*/
status?: AreaChartStatus;
/** Width ÷ height. `2` is the wide card shape. */
aspectRatio?: number;
/** Milliseconds for the reveal on mount. */
animationDuration?: number;
/** Milliseconds for the y-axis to settle after the data changes. */
domainDuration?: number;
/** Fix the y-axis instead of deriving it from the data. */
yDomain?: [number, number];
/**
* Sit each band on the running total of the ones below it, so the top edge
* is the whole and each thickness is a share. The order the `Area` children
* are declared in is the stacking order, bottom first.
*
* Unstacked, the bands overlay and their fills are translucent — the right
* reading when the series are alternatives rather than parts of a total.
*/
stacked?: boolean;
/** `monotone` never overshoots between points; `linear` joins them straight. */
curve?: ChartCurve;
/**
* The point under the crosshair as it moves, and `-1`/`null` when it lifts.
* Fires when the index changes, not per frame.
*/
onActiveIndexChange?: (index: number, datum: AreaChartDatum | null) => void;
/** Drop the axis padding so the bands reach the edges, for a sparkline. */
compact?: boolean;
children?: ReactNode;
}
/** Imperative handle: re-run the reveal on demand, for a "replay" control. */
export interface AreaChartHandle {
replay: () => void;
}
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
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 slot = {child};
const bucket =
layer === 'header'
? header
: layer === 'overlay'
? overlay
: layer === 'series'
? series
: svg;
bucket.push(slot);
});
return { svg, series, overlay, header };
}
const AreaChartRoot = forwardRef(function AreaChartRoot(
{
className,
data,
xDataKey = 'date',
status = 'ready',
aspectRatio = 2,
animationDuration = 700,
domainDuration = 500,
yDomain,
stacked = false,
curve = 'monotone',
onActiveIndexChange,
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 [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) =>
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)),
[]
);
/*
* Whether an axis is asking for room. It has to be known before the plot is
* laid out, and only the root sees the children early enough to ask — the
* axis itself renders into a box that has already been decided.
*/
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: 2, right: 1, bottom: 2, left: 1 }
: { ...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('|');
const extent = useMemo<[number, number]>(() => {
if (yDomain) return yDomain;
const keys = seriesKeys ? seriesKeys.split('|') : [];
let min = Infinity;
let max = -Infinity;
for (const row of data) {
/*
* Stacked, the tall thing is the sum of the row; overlaid, it is the
* largest single value in it. Reading the same number for both crops a
* stack at its largest band and clips everything above it.
*/
let rowTotal = 0;
for (const key of keys) {
const value = row[key];
if (typeof value !== 'number' || Number.isNaN(value)) continue;
if (stacked) {
rowTotal += value;
} else {
if (value < min) min = value;
if (value > max) max = value;
}
}
if (stacked) {
if (rowTotal < min) min = rowTotal;
if (rowTotal > max) max = rowTotal;
}
}
if (min === Infinity) return [0, 1];
if (min === max) return [min - 1, max + 1];
/*
* An area is a filled region, and a region floating above the bottom of
* the plot reads as a shape rather than as a quantity — so a series that
* never goes below zero is drawn from zero. Padding only the top.
*/
const floor = min >= 0 ? 0 : min - (max - min) * 0.1;
return [floor, max + (max - min) * 0.1];
}, [data, yDomain, seriesKeys, stacked]);
const loading = status === 'loading';
useEffect(() => {
if (loading) return;
const [min, max] = extent;
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, 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. 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 || plot.width <= 0 || !data.length) return;
revealed.current = true;
playReveal();
}, [loading, plot.width, data.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));
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,
status,
curve,
stacked,
series,
registerSeries,
unregisterSeries,
domainMin,
domainMax,
extent,
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,
status,
curve,
stacked,
series,
registerSeries,
unregisterSeries,
domainMin,
domainMax,
extent,
activeIndex,
activeIndexJS,
handleActiveIndex,
]
);
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 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 ? (
<>
{/*
* The reveal is a view that grows, not an SVG clip path.
*
* 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 chart 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 `
);
});
AreaChartRoot.displayName = 'AreaChart';
/* -------------------------------------------------------------------------- */
/* SVG layer */
/* -------------------------------------------------------------------------- */
export interface AreaChartGridProps {
rows?: number;
color?: string;
dashArray?: string;
opacity?: number;
}
/** Horizontal rules, so a band can be read against a number. */
function AreaChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: AreaChartGridProps) {
const { plot } = useChart('AreaChart.Grid');
const token = useCSSVariable('--color-border');
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');
return (
{Array.from({ length: rows + 1 }, (_unused, index) => index / rows).map((fraction) => (
))}
);
}
AreaChartGrid.displayName = 'AreaChart.Grid';
AreaChartGrid.layer = 'svg' as Layer;
export interface AreaChartAreaProps {
/** Column in the data holding this series' values. */
dataKey: string;
/** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */
color?: string;
/** Which of the five chart tokens to take. */
colorIndex?: SeriesColorIndex;
/** Opacity of the fill at the top of the band. */
fillOpacity?: number;
/** Opacity at the bottom. `0` fades the band out; match `fillOpacity` for a flat fill. */
gradientToOpacity?: number;
/** Draw the line along the top edge of the band. */
showLine?: boolean;
/** Thickness of that line. */
strokeWidth?: number;
}
/**
* One filled band.
*
* The fill is a gradient by default rather than a flat wash, because a flat
* fill of any weight competes with the line on top of it — fading it downward
* keeps the top edge, which is the part carrying the numbers, the darkest
* thing in the band.
*/
function AreaChartArea({
dataKey,
color,
colorIndex = 1,
fillOpacity,
gradientToOpacity = 0,
showLine = true,
strokeWidth = 2,
}: AreaChartAreaProps) {
const {
data,
plot,
status,
curve,
stacked,
series,
registerSeries,
unregisterSeries,
domainMin,
domainMax,
} = useChart('AreaChart.Area');
const stroke = useSeriesColor(color, colorIndex);
const gradientId = `panelui-area-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
// Stacked bands touch, so a translucent one shows the band beneath through
// it and the two read as a third colour. Overlaid they must be translucent,
// because the one in front would otherwise hide the ones behind entirely.
const topOpacity = fillOpacity ?? (stacked ? 0.85 : 0.35);
useEffect(() => {
registerSeries(dataKey, stroke);
return () => unregisterSeries(dataKey);
}, [dataKey, stroke, registerSeries, unregisterSeries]);
const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
/*
* The running total of the series declared below this one, which is what
* this band sits on. Registration order is declaration order, so the first
* `Area` child is the bottom of the stack.
*/
const baselines = useMemo(() => {
if (!stacked) return undefined;
const below = series.slice(0, series.findIndex(([key]) => key === dataKey));
if (!below.length) return undefined;
return data.map((row) => {
let total = 0;
for (const [key] of below) {
const value = row[key];
if (typeof value === 'number' && !Number.isNaN(value)) total += value;
}
return total;
});
}, [stacked, series, dataKey, data]);
const loading = status === 'loading';
const fillProps = useAnimatedProps(() => ({
d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),
}));
const lineProps = useAnimatedProps(() => ({
d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),
}));
return (
{showLine ? (
) : null}
);
}
AreaChartArea.displayName = 'AreaChart.Area';
AreaChartArea.layer = 'series' as Layer;
/** How much of the plot's height the resting band takes. */
const SKELETON_BAND = 0.18;
export interface AreaChartSkeletonProps {
/** Milliseconds for one pass of the sweep. */
duration?: number;
color?: string;
}
/**
* The loading state: a low band along the baseline with a highlight travelling
* across it.
*
* Flat on purpose. A placeholder with a shape in it is a shape the reader has
* no way to tell from the real one until it changes under them, so the band
* says only where the series will be and how tall the plot is.
*
* The sweep is the part that carries the meaning. Without it a chart waiting
* for data and a chart whose values are all zero draw the same picture, and
* the reader is left to guess which one they are looking at.
*/
function AreaChartSkeleton({ duration = 1400, color }: AreaChartSkeletonProps) {
const { plot, status } = useChart('AreaChart.Skeleton');
const token = useCSSVariable('--color-skeleton');
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
const highlight = useSeriesColor(undefined, 1);
const sweep = useSharedValue(0);
const reducedMotion = useReducedMotion();
const loading = status === 'loading';
useEffect(() => {
if (!loading || reducedMotion) {
cancelAnimation(sweep);
sweep.value = 0;
return;
}
sweep.value = 0;
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
return () => cancelAnimation(sweep);
}, [loading, reducedMotion, duration, sweep]);
// The band travels by moving the gradient's own endpoints, so the whole
// effect is two numbers changing on the UI thread.
const animatedProps = useAnimatedProps(() => ({
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
}));
if (!loading) return null;
const height = plot.height * SKELETON_BAND;
const top = plot.top + plot.height - height;
const gradientId = 'panelui-area-skeleton';
return (
);
}
AreaChartSkeleton.displayName = 'AreaChart.Skeleton';
AreaChartSkeleton.layer = 'svg' as Layer;
/* -------------------------------------------------------------------------- */
/* Overlay layer */
/* -------------------------------------------------------------------------- */
export interface AreaChartXAxisProps {
ticks?: number;
format?: (datum: AreaChartDatum, index: number) => string;
className?: string;
}
/** The x labels. Real text, so they follow the theme's font and text scaling. */
function AreaChartXAxis({ ticks = 4, format, className }: AreaChartXAxisProps) {
const { data, xDataKey, plot } = useChart('AreaChart.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 }, (_unused, 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 line.
*
* The box is backed off by half its own width rather than translated by
* `-50%`, which is not reliable across React Native versions.
*/
return (
{labels.map((label) => (
{label.text}
))}
);
}
AreaChartXAxis.displayName = 'AreaChart.XAxis';
AreaChartXAxis.layer = 'overlay' as Layer;
export interface AreaChartYAxisProps {
ticks?: number;
format?: (value: number) => string;
className?: string;
}
/** Value labels down the side, aligned to the grid lines. */
function AreaChartYAxis({ ticks = 4, format, className }: AreaChartYAxisProps) {
const { plot, extent } = useChart('AreaChart.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}
))}
);
}
AreaChartYAxis.displayName = 'AreaChart.YAxis';
AreaChartYAxis.layer = 'overlay' as Layer;
// Read by the root, which has to leave room for the labels before it
// lays the plot out — an axis drawn over the plot is unreadable, and
// makes what it is drawn over unreadable too.
AreaChartYAxis.axis = 'y' as const;
/** A dot riding the top edge of one band as the crosshair moves. */
function CrosshairDot({
values,
baselines,
plot,
domainMin,
domainMax,
activeIndex,
total,
color,
}: {
values: (number | null)[];
baselines: number[] | undefined;
plot: Plot;
domainMin: SharedValue;
domainMax: SharedValue;
activeIndex: SharedValue;
total: number;
color: string;
}) {
const props = useAnimatedProps(() => {
const index = activeIndex.value;
const value = index >= 0 ? values[index] : null;
if (index < 0 || value === null || value === undefined) {
return { opacity: 0, cx: 0, cy: 0 };
}
const stacked = value + (baselines?.[index] ?? 0);
return {
opacity: 1,
cx: xOf(index, total, plot),
cy: yOf(stacked, plot, domainMin.value, domainMax.value),
};
});
return (
);
}
export interface AreaChartTooltipProps {
color?: string;
/** Format one series' value. Defaults to a compact number. */
formatValue?: (value: number, key: string) => string;
/** Format the readout's heading from the row. Defaults to the value at xDataKey. */
formatX?: (datum: AreaChartDatum) => string;
className?: string;
}
/**
* The crosshair, the gesture that drives it, and the readout that rides it.
*
* The hit area is the whole plot. A crosshair you have to land on a band to
* summon is a crosshair nobody finds.
*/
function AreaChartTooltip({ color, formatValue, formatX, className }: AreaChartTooltipProps) {
const {
data,
xDataKey,
plot,
stacked,
series,
domainMin,
domainMax,
activeIndex,
activeIndexJS,
setActiveIndexJS,
status,
} = useChart('AreaChart.Tooltip');
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;
/*
* Declared inside the memo, next to its callers: a worklet may only call
* another worklet, and the rule is enforced by crashing rather than warning.
*/
const pan = useMemo(() => {
const resolve = (x: number) => {
'worklet';
if (total < 2 || width <= 0) return;
const ratio = (x - left) / width;
const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));
if (next === activeIndex.value) return;
activeIndex.value = next;
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, activeIndex, setActiveIndexJS]);
const crosshairStyle = useAnimatedStyle(() => {
const index = activeIndex.value;
return {
opacity: index < 0 ? 0 : 0.45,
transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],
};
});
const labelStyle = useAnimatedStyle(() => {
const index = activeIndex.value;
if (index < 0) return { opacity: 0 };
const x = 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 }] };
});
/*
* The dots ride the *top edge* of each band, which for a stack is the
* running total rather than the value — so the baselines are recomputed here
* the same way the bands compute them, and for the same reason.
*/
const stacks = useMemo(() => {
return series.map(([key, seriesColor], index) => {
const below = series.slice(0, index);
const baselines =
stacked && below.length
? data.map((row) => {
let sum = 0;
for (const [k] of below) {
const value = row[k];
if (typeof value === 'number' && !Number.isNaN(value)) sum += value;
}
return sum;
})
: undefined;
return { key, color: seriesColor, values: columnValues(data, key), baselines };
});
}, [series, data, stacked]);
const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
const fmtValue = formatValue ?? ((value: number) => compactNumber(value));
const fmtX = formatX ?? ((datum: AreaChartDatum) => String(datum[xDataKey] ?? ''));
if (status === 'loading') return null;
return (
{stacks.map((stack) => (
))}
{active ? (
{fmtX(active)}
{series.map(([key, seriesColor]) => {
const value = active[key];
if (typeof value !== 'number') return null;
return (
{fmtValue(value, key)}
);
})}
) : null}
);
}
AreaChartTooltip.displayName = 'AreaChart.Tooltip';
AreaChartTooltip.layer = 'overlay' as Layer;
export interface AreaChartLegendProps extends ViewProps {
className?: string;
/** Prettier names for the series keys. */
labels?: Record;
}
/**
* A swatch and a name per series.
*
* Reversed for a stack, so the key reads in the order the bands appear on the
* chart: the last one declared is the top band, and a legend listing it last
* points at the bottom one.
*/
function AreaChartLegend({ className, labels, ...props }: AreaChartLegendProps) {
const { series, stacked } = useChart('AreaChart.Legend');
if (!series.length) return null;
const ordered = stacked ? [...series].reverse() : series;
return (
{ordered.map(([key, color]) => (
))}
);
}
AreaChartLegend.displayName = 'AreaChart.Legend';
AreaChartLegend.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 AreaChartHeaderProps 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, in the order
* the bands appear on a stack. Prefer this to `AreaChart.Legend` on a chart
* that has a header: the legend floats over the plot, where a tall band and a
* key end up in 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.
*
* It belongs to the chart rather than to the card around it because it is about
* the *plot* — the number changes as a finger moves along the bands, and the
* legend is the series list the chart itself is holding. The card's header is a
* caption on the tray the chart sits in; this is the chart introducing itself.
*
* The value is not derived here. A readout that follows the finger belongs to
* whoever owns the data — take it from `onActiveIndexChange` and pass the
* formatted string down, so one header can show a total when nothing is pressed
* and a point's value when something is.
*/
function AreaChartHeader({
className,
title,
value,
caption,
labels,
legend = false,
children,
...props
}: AreaChartHeaderProps) {
const { series, stacked } = useChart('AreaChart.Header');
const ordered = stacked ? [...series].reverse() : series;
const trailing =
children ??
(legend && ordered.length ? (
{ordered.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}
);
}
AreaChartHeader.displayName = 'AreaChart.Header';
AreaChartHeader.layer = 'header' as Layer;
export const AreaChart = Object.assign(AreaChartRoot, {
Header: AreaChartHeader,
Grid: AreaChartGrid,
Area: AreaChartArea,
Skeleton: AreaChartSkeleton,
XAxis: AreaChartXAxis,
YAxis: AreaChartYAxis,
Tooltip: AreaChartTooltip,
Legend: AreaChartLegend,
});