/** * LineChart — a time series, drawn and animated on the UI thread. * * The chart is composed rather than configured: the grid, each series, the * axis and the crosshair are separate children, so a chart that wants no grid * simply does not have one. A single component with twenty booleans is how * charts end up unreadable at the call site. * * ```tsx * * * * * * * * ``` * * Internally 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 laid over the top. That split is not a detail — 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. * * Three things animate, each for a different reason: * * - **The reveal.** On mount the plot is uncovered left to right by an animated * clip rectangle. Everything inside shares that clip, so the line, its fill * and its markers arrive together rather than as three separate effects. * - **The y-domain.** When the data changes the *scale* is tweened rather than * the path swapped, so a series that grows is redrawn against a moving axis * instead of jumping to a new shape. The reveal does not replay — it happened * once, and repeating it on every refresh turns a data update into an * animation. * - **The crosshair.** A drag resolves the nearest index on the UI thread and * moves the line and the dots from there. Only the index crosses back into * JS, and only when it changes, so a drag costs a handful of re-renders * rather than one per frame. * * Colours come from the `--color-chart-*` tokens, so a chart follows the active * theme and is put on brand by overriding those five in the app's own * global.css. Nothing here hardcodes a hex. */ 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, { cancelAnimation, Easing, runOnJS, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Circle, Defs, G, Line as SvgLine, LinearGradient, Path, 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 Plot, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); /** Room left around the plot for the axis labels and the marker 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; /** * Which layer a part belongs to. Read off the component itself, so composition * stays a flat list of children instead of two nested slots the caller has to * remember the order of. */ type Layer = 'svg' | 'series' | 'overlay' | 'header'; export type LineChartStatus = 'loading' | 'ready'; export type LineChartCurve = 'monotone' | 'linear'; export type LineChartDatum = Record; interface LineChartContextValue { data: LineChartDatum[]; xDataKey: string; plot: Plot; status: LineChartStatus; curve: LineChartCurve; /** Series registered by the `Line` children, so the tooltip can read them. */ series: [string, string][]; registerSeries: (key: string, color: string) => void; unregisterSeries: (key: string) => void; /** Tweened y-domain. Read inside worklets to build the paths. */ domainMin: SharedValue; domainMax: SharedValue; /** * The domain the tween is heading for. The axis labels read this rather than * the shared values: a number re-rendered on every frame of a 500ms tween is * 30 renders of a label that lands on the same string it started on. */ extent: [number, number]; /** Index under the finger, or -1 when nothing is being touched. */ activeIndex: SharedValue; activeIndexJS: number; setActiveIndexJS: (index: number) => void; } const LineChartContext = createContext(null); function useChart(component: string): LineChartContextValue { const context = useContext(LineChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * The point under the crosshair, for something rendered *inside* the chart. * * A readout usually belongs in the card's header, which is outside this * provider — use `onActiveIndexChange` for that. A hook cannot reach up out of * the subtree it is called in, and pretending otherwise is how a component * ends up with a context that has to wrap half the screen. */ export function useLineChart() { const { data, activeIndexJS, xDataKey } = useChart('useLineChart'); return { activeIndex: activeIndexJS, activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null, xDataKey, }; } export interface LineChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. Each one is a point along the x-axis. */ data: LineChartDatum[]; /** Key holding the x label. Used by the axis and the crosshair readout. */ xDataKey?: string; /** * `loading` draws a flat skeleton with a sweep running along it, and morphs * into the real series when it turns `ready`. One component throughout, * rather than a spinner swapped for a chart — swapping loses the transition. */ status?: LineChartStatus; /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */ 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]; /** `monotone` never overshoots between points; `linear` joins them straight. */ curve?: LineChartCurve; /** * The point under the crosshair as it moves, and `-1`/`null` when the finger * lifts. This is how a readout in the card's header gets its value — that * header is outside the chart, so it cannot use `useLineChart`. * * Fires when the index changes, not per frame. */ onActiveIndexChange?: (index: number, datum: LineChartDatum | null) => void; /** * Drop the axis padding so the line reaches the edges — for a sparkline with * no grid, axis or crosshair, where the shape is the whole point. */ compact?: boolean; children?: ReactNode; } /** Imperative handle: re-run the reveal on demand, for a "replay" control. */ export interface LineChartHandle { replay: () => void; } const LineChartRoot = forwardRef(function LineChartRoot( { className, data, xDataKey = 'date', status = 'ready', aspectRatio = 2, animationDuration = 700, domainDuration = 500, yDomain, 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 a `YAxis` was declared, read off the children before anything is * laid out. The gutter its labels sit in has to come off the plot's width * *before* the plot exists — an axis given no room is drawn over the series, * which loses both the numbers and the shape they were meant to explain. */ 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]); // A sparkline has no axis or grid to leave room for, so the line reaches the // edges; a stroke of a couple of pixels still needs a hair of inset not to be // clipped at the very top and bottom. 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), }; // One extent across every registered series, so two series share one axis // and stay comparable — a per-series scale makes them look alike when they // are orders of magnitude apart. 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) { for (const key of keys) { const value = row[key]; if (typeof value !== 'number' || Number.isNaN(value)) continue; if (value < min) min = value; if (value > max) max = value; } } if (min === Infinity) return [0, 1]; // 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) return [min - 1, max + 1]; // A little headroom, so the peak is not welded to the top edge. const pad = (max - min) * 0.1; return [min - pad, max + pad]; }, [data, yDomain, seriesKeys]); const loading = status === 'loading'; useEffect(() => { if (loading) 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, reducedMotion, domainDuration, domainMin, domainMax]); // Plays once, when there is both a plot to reveal and data to reveal in it. 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]); // `replay()` re-runs the reveal — for a control the caller wires up. useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value })); // One place the crosshair index lands, so the chart's own children and a // readout outside it never disagree about which point is active. 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, 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, 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 ? ( <> {svg} {/* * 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 `` * with an explicit width and height, so the work is one node and * nothing around it moves. * * The inner `` is pulled back by `plot.left` so the series * keeps the same coordinate space as the grid underneath it — * otherwise every mark would shift by the y-axis gutter. */} {seriesLayer} {overlay} ) : null} ); }); LineChartRoot.displayName = 'LineChart'; /** Sorts the children into the SVG tree and the view layer over it. */ 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}; } /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface LineChartGridProps { /** 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. Drawn under everything, outside the reveal clip. */ function LineChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: LineChartGridProps) { const { plot } = useChart('LineChart.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 ( ); })} ); } LineChartGrid.displayName = 'LineChart.Grid'; LineChartGrid.layer = 'svg' as Layer; export interface LineChartLineProps { /** Key holding this series' y values. */ dataKey: string; /** * Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so * a series follows the theme without the call site naming a colour. */ color?: string; /** Which `--color-chart-*` token to take when `color` is not given. */ colorIndex?: 1 | 2 | 3 | 4 | 5; strokeWidth?: number; /** Dash pattern, e.g. `"6,4"` — for a projection or a secondary series. */ dashArray?: string; /** A dot at every point. Best kept for short series. */ showMarkers?: boolean; } /** One series. */ function LineChartLine({ dataKey, color, colorIndex = 1, strokeWidth = 2.5, dashArray, showMarkers = false, }: LineChartLineProps) { const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries } = useChart('LineChart.Line'); const stroke = useSeriesColor(color, colorIndex); useEffect(() => { registerSeries(dataKey, stroke); return () => unregisterSeries(dataKey); }, [dataKey, stroke, registerSeries, unregisterSeries]); const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]); const loading = status === 'loading'; const animatedProps = useAnimatedProps(() => ({ d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading), })); return ( {showMarkers && !loading ? values.map((value, index) => value === null ? null : ( ) ) : null} ); } LineChartLine.displayName = 'LineChart.Line'; LineChartLine.layer = 'series' as Layer; /** A dot at one point. Follows the y-domain tween exactly as the line does. */ function PointMarker({ index, total, value, plot, domainMin, domainMax, color, }: { index: number; total: number; value: number; plot: Plot; domainMin: SharedValue; domainMax: SharedValue; color: string; }) { const animatedProps = useAnimatedProps(() => ({ cy: yOf(value, plot, domainMin.value, domainMax.value), })); return ( ); } export interface LineChartAreaProps { dataKey: string; color?: string; colorIndex?: 1 | 2 | 3 | 4 | 5; /** Opacity at the line. Fades to nothing at the baseline. */ opacity?: number; } /** * The fill under a series. A separate child from the line, because a chart with * two series usually wants the fill on only one of them — two translucent * fills over each other make a third colour that means nothing. */ function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) { const { data, plot, domainMin, domainMax, curve, status } = useChart('LineChart.Area'); const fill = useSeriesColor(color, colorIndex); const gradientId = `panelui-area-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]); const loading = status === 'loading'; const animatedProps = useAnimatedProps(() => ({ d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading), })); return ( ); } LineChartArea.displayName = 'LineChart.Area'; LineChartArea.layer = 'series' as Layer; export interface LineChartSkeletonProps { /** Milliseconds for one pass of the sweep. */ duration?: number; color?: string; } /** * The loading state: a flat rule where the series will be, with a highlight * travelling along it. Drawn inside the same SVG rather than as an overlay, so * arriving data is one tree changing rather than one view replacing another — * which is what lets the flat line become the series instead of cutting to it. */ function LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) { const { plot, status } = useChart('LineChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); const highlight = useSeriesColor(undefined, 1); // Unique per instance: a fixed id makes two of these on one screen share a // gradient, and the second one to mount wins. const gradientId = `panelui-chart-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; 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 y = plot.top + plot.height / 2; return ( ); } LineChartSkeleton.displayName = 'LineChart.Skeleton'; LineChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ export interface LineChartXAxisProps { /** 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: LineChartDatum, 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 LineChartXAxis({ ticks = 4, format, className }: LineChartXAxisProps) { const { data, xDataKey, plot } = useChart('LineChart.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 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} ))} ); } LineChartXAxis.displayName = 'LineChart.XAxis'; LineChartXAxis.layer = 'overlay' as Layer; export interface LineChartYAxisProps { /** 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 that counts up through every intermediate value while the axis * animates is noise, and the axis is the part of the chart that is supposed to * hold still enough to read. */ function LineChartYAxis({ ticks = 4, format, className }: LineChartYAxisProps) { const { plot, extent } = useChart('LineChart.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} ))} ); } LineChartYAxis.displayName = 'LineChart.YAxis'; LineChartYAxis.layer = 'overlay' as Layer; // Read by the root, which has to leave room for the labels before it lays the // plot out. LineChartYAxis.axis = 'y' as const; export interface LineChartTooltipProps { color?: string; /** * Float a small label at the crosshair showing the x-value and each series' * value at that point — the minimal readout a drag wants. On by default. */ showLabel?: boolean; /** Format one series' value for the label. Defaults to a compact number. */ formatValue?: (value: number, key: string) => string; /** Format the label's heading from the row. Defaults to the value at xDataKey. */ formatX?: (datum: LineChartDatum) => string; } /** * The crosshair, the gesture that drives it, and the label that rides it. * * They live in the view layer: a gesture handler cannot be attached to an SVG * node, and once the gesture is a view the crosshair may as well be one too — * a 1px view moved by `translateX` costs less than re-rendering an SVG line. * The label follows on the UI thread the same way; only its *text* crosses back * into JS, and only when the active index changes. * * The hit area is the whole plot. A crosshair you have to land on the line to * summon is a crosshair nobody finds. */ function LineChartTooltip({ color, showLabel = true, formatValue, formatX }: LineChartTooltipProps) { const { data, xDataKey, plot, domainMin, domainMax, series, activeIndex, activeIndexJS, setActiveIndexJS, status, } = useChart('LineChart.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; /* * 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 the app rather than by warning — so the resolver is declared here, * 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 < 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; // Only the index needs JS, and only when it changes — a drag across a // hundred points 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, 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) }], }; }); // The label centres over the crosshair but is clamped inside the plot, so it // never runs off the edge at the first or last point. 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 }], }; }); const active = activeIndexJS >= 0 ? data[activeIndexJS] : null; const fmtValue = formatValue ?? ((value: number) => compactNumber(value)); const fmtX = formatX ?? ((datum: LineChartDatum) => String(datum[xDataKey] ?? '')); if (status === 'loading') return null; return ( {series.map(([key, seriesColor]) => ( ))} {showLabel ? ( {active ? ( <> {fmtX(active)} {series.map(([key, seriesColor]) => { const value = active[key]; if (typeof value !== 'number') return null; return ( {series.length > 1 ? ( ) : null} {fmtValue(value, key)} ); })} ) : null} ) : null} ); } LineChartTooltip.displayName = 'LineChart.Tooltip'; LineChartTooltip.layer = 'overlay' as Layer; const LABEL_WIDTH = 112; /** * 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; /** Diameter of the dot that rides a series under the crosshair. */ const DOT = 9; /** The dot riding one series under the crosshair. */ function TooltipDot({ values, plot, domainMin, domainMax, activeIndex, color, }: { values: (number | null)[]; plot: Plot; domainMin: SharedValue; domainMax: SharedValue; activeIndex: SharedValue; color: string; }) { const style = useAnimatedStyle(() => { const index = activeIndex.value; const value = index < 0 ? null : values[index]; if (index < 0 || value === null || value === undefined) return { opacity: 0 }; return { opacity: 1, transform: [ { translateX: xOf(index, values.length, plot) - DOT / 2 }, { translateY: yOf(value, plot, domainMin.value, domainMax.value) - DOT / 2 }, ], }; }); return ( ); } export interface LineChartLegendProps 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 LineChartLegend({ className, labels, ...props }: LineChartLegendProps) { const { series } = useChart('LineChart.Legend'); if (!series.length) return null; return ( {series.map(([key, color]) => ( ))} ); } LineChartLegend.displayName = 'LineChart.Legend'; LineChartLegend.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 LineChartHeaderProps 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 * `LineChart.Legend` on a chart that has a header: the legend floats over the * plot, where it competes with the lines 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. * * 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 line, 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 LineChartHeader({ className, title, value, caption, labels, legend = false, children, ...props }: LineChartHeaderProps) { const { series } = useChart('LineChart.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} ); } LineChartHeader.displayName = 'LineChart.Header'; LineChartHeader.layer = 'header' as Layer; export const LineChart = Object.assign(LineChartRoot, { Header: LineChartHeader, Grid: LineChartGrid, Area: LineChartArea, Line: LineChartLine, Skeleton: LineChartSkeleton, XAxis: LineChartXAxis, YAxis: LineChartYAxis, Tooltip: LineChartTooltip, Legend: LineChartLegend, });