/** * BarChart — categories compared by length, drawn and animated on the UI thread. * * Composed the same way `LineChart` is: the grid, each series, the axes and the * readout are separate children, so a chart that wants no grid simply does not * have one. * * ```tsx * * * * * * * ``` * * ## What is different from a line * * **The baseline is zero, and not negotiable.** A line's job is to show change, * so it may crop its axis to the range the data actually occupies. A bar's job * is to compare *lengths*, and a bar cropped at the bottom is a length that * lies — twice as tall no longer means twice as much. So the domain always * reaches zero unless `yDomain` says otherwise, and saying otherwise is opting * into a chart that misreads. * * **Bands, not points.** A line has a point at each x; a bar owns a slice of * width around it. `barGap` is the fraction of that slice left empty, so bars * stay proportional at any width instead of needing a pixel gap that is wrong * on half of them. * * **Every series is one path.** A `Bar` draws all its rectangles as subpaths of * a single animated path, split in two so the band under the finger keeps full * ink while the rest fade. Fifty bars is two animated props a frame rather * than fifty, and the corners are drawn as a path because a bar is rounded on * the end it grows towards and square on the end it grows from — which `rx` * cannot express, since it rounds all four corners or none. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, cancelAnimation, runOnJS, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { 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 { bandOf, barPath, columnValues, compactNumber, useSeriesColor, type Plot, type SeriesColorIndex, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); /** Room left around the plot for the axis labels. */ const PADDING = { top: 12, right: 10, bottom: 22, left: 10 }; /** * Sideways, the category names run down the left instead of along the bottom, * so the room has to come off that side rather than off the bottom. Reserved * rather than overlaid: a name drawn on top of the bars is unreadable against * them and makes the bars unreadable too. */ const PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 68 }; /** Width the readout is laid out at, so it can be clamped inside the plot. */ const LABEL_WIDTH = 132; /** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */ const Y_AXIS_WIDTH = 44; /** Gap between the value labels and the plot they sit beside. */ const Y_AXIS_GUTTER = 6; /** Line height of an `xs` label, for centring one on the grid line it names. */ const AXIS_LABEL_HEIGHT = 16; type Layer = 'svg' | 'overlay' | 'header'; export type BarChartStatus = 'loading' | 'ready'; export type BarChartOrientation = 'vertical' | 'horizontal'; export type BarChartDatum = Record; interface BarChartContextValue { data: BarChartDatum[]; xDataKey: string; plot: Plot; status: BarChartStatus; orientation: BarChartOrientation; stacked: boolean; barGap: number; barWidth: number | undefined; stackGap: number; cornerRadius: number; minBarLength: number; fadedOpacity: number; series: [string, string][]; registerSeries: (key: string, color: string) => void; unregisterSeries: (key: string) => void; domainMin: SharedValue; domainMax: SharedValue; /** The settled domain, for the parts that draw text rather than geometry. */ extent: [number, number]; /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */ reveal: SharedValue; activeIndex: SharedValue; activeIndexJS: number; setActiveIndexJS: (index: number) => void; } const BarChartContext = createContext(null); function useChart(component: string): BarChartContextValue { const context = useContext(BarChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * The band under the finger, for something rendered *inside* the chart. A * readout in the card's header is outside this provider — use * `onActiveIndexChange` for that. */ export function useBarChart() { const { data, activeIndexJS, xDataKey } = useChart('useBarChart'); return { activeIndex: activeIndexJS, activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null, xDataKey, }; } export interface BarChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. Each one is a band along the category axis. */ data: BarChartDatum[]; /** Key holding the category label. Used by the axis and the readout. */ xDataKey?: string; /** * `loading` holds the bars at the baseline and grows them into the real ones * when it turns `ready`. One component throughout, rather than a spinner * swapped for a chart — swapping loses the transition. Add a * `BarChart.Skeleton` for something to stand in the plot meanwhile. */ status?: BarChartStatus; /** Width ÷ height. `2` is the wide card shape. */ aspectRatio?: number; /** Milliseconds for the bars to grow in on mount. */ animationDuration?: number; /** Milliseconds for the value axis to settle after the data changes. */ domainDuration?: number; /** * Fix the value axis instead of deriving it. Note that the derived domain * always includes zero, and a domain that does not is a bar chart whose * lengths cannot be compared — pass this only when you mean it. */ yDomain?: [number, number]; /** `vertical` grows the bars upward; `horizontal` grows them rightward. */ orientation?: BarChartOrientation; /** Stack the series on each other instead of standing them side by side. */ stacked?: boolean; /** * Fraction of each band left empty, `0` to `1`. A fraction rather than a * pixel gap so the proportions hold at any width. */ barGap?: number; /** Fixed bar thickness in points. Derived from the band when omitted. */ barWidth?: number; /** Points between the segments of a stack. */ stackGap?: number; /** Corner radius on the growing end of a bar. */ cornerRadius?: number; /** * Smallest length a non-zero bar is drawn at, in points. A value that rounds * to nothing still happened, and a bar of zero height says it did not. */ minBarLength?: number; /** Opacity of the bars that are not under the finger. */ fadedOpacity?: number; /** * The band under the finger as it moves, and `-1`/`null` when it lifts. * Fires when the index changes, not per frame. */ onActiveIndexChange?: (index: number, datum: BarChartDatum | null) => void; /** Drop the axis padding, for a bar sparkline with no axis or readout. */ compact?: boolean; children?: ReactNode; } /** Imperative handle: re-run the grow-in, for a "replay" control. */ export interface BarChartHandle { replay: () => void; } function partition(children: ReactNode) { const svg: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const layer = (child.type as { layer?: Layer }).layer ?? 'svg'; const slot = {child}; (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot); }); return { svg, overlay, header }; } function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } const BarChartRoot = forwardRef(function BarChartRoot( { className, data, xDataKey = 'name', status = 'ready', aspectRatio = 2, animationDuration = 700, domainDuration = 500, yDomain, orientation = 'vertical', stacked = false, barGap = 0.2, barWidth, stackGap = 0, cornerRadius = 4, minBarLength = 0, fadedOpacity = 0.3, 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 } : orientation === 'horizontal' ? PADDING_SIDEWAYS : { ...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 = 0; let max = 0; for (const row of data) { // Stacked, the tall thing is the total; grouped, it is the tallest bar. // Reading the same number for both would crop a stack at its largest // segment and clip everything above it. let positive = 0; let negative = 0; for (const key of keys) { const value = row[key]; if (typeof value !== 'number' || Number.isNaN(value)) continue; if (stacked) { if (value >= 0) positive += value; else negative += value; } else { if (value > positive) positive = value; if (value < negative) negative = value; } } if (positive > max) max = positive; if (negative < min) min = negative; } if (min === 0 && max === 0) return [0, 1]; // Headroom above the tallest bar only. The zero end is left exactly where // it is: padding it would lift the bars off their own baseline. return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.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 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, orientation, stacked, barGap, barWidth, stackGap, cornerRadius, minBarLength, fadedOpacity, series, registerSeries, unregisterSeries, domainMin, domainMax, extent, reveal, 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, orientation, stacked, barGap, barWidth, stackGap, cornerRadius, minBarLength, fadedOpacity, series, registerSeries, unregisterSeries, domainMin, domainMax, extent, reveal, activeIndex, activeIndexJS, handleActiveIndex, ] ); const { svg, overlay, header } = partition(children); /* * Two views, because the header is not part of the plot. `aspectRatio` and * the layout measurement belong to the drawing area alone — measured on the * outer view they would take in the header too, and the plot would lose as * much height as the readout took while still claiming the shape asked for. */ return ( {header} [ [xDataKey, datum[xDataKey]], ...series.map(([key]) => [key, datum[key]] as [string, unknown]), ]} /> {plot.width > 0 ? ( <> {svg} {overlay} ) : null} ); }); BarChartRoot.displayName = 'BarChart'; /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface BarChartGridProps { /** How many lines to draw across the value axis. */ rows?: number; color?: string; dashArray?: string; opacity?: number; } /** * Lines across the value axis, so a bar can be read against a number rather * than only against the bar beside it. */ function BarChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: BarChartGridProps) { const { plot, orientation } = useChart('BarChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)'); const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows); return ( {lines.map((fraction) => orientation === 'vertical' ? ( ) : ( ) )} ); } BarChartGrid.displayName = 'BarChart.Grid'; BarChartGrid.layer = 'svg' as Layer; export interface BarChartBarProps { /** 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; /** Corner radius, overriding the chart's. */ cornerRadius?: number; } /** * One series of bars. * * Drawn as two paths rather than one rectangle per band: the band under the * finger, and everything else. That is the fewest animated props that can * still dim the rest — one path could not, since a path has one opacity, and * a view per bar would be one animated prop per bar for the same picture. */ function BarChartBar({ dataKey, color, colorIndex = 1, cornerRadius }: BarChartBarProps) { const { data, plot, status, orientation, stacked, barGap, barWidth, stackGap, cornerRadius: chartRadius, minBarLength, fadedOpacity, series, registerSeries, unregisterSeries, domainMin, domainMax, reveal, activeIndex, } = useChart('BarChart.Bar'); const fill = useSeriesColor(color, colorIndex); const radius = cornerRadius ?? chartRadius; useEffect(() => { registerSeries(dataKey, fill); return () => unregisterSeries(dataKey); }, [dataKey, fill, registerSeries, unregisterSeries]); const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]); /* * What each bar in this series sits on. Grouped, that is nothing — every bar * starts at zero. Stacked, it is the running total of the series registered * *before* this one, which is why registration order is the stacking order. */ const baselines = useMemo(() => { if (!stacked) return null; const below = series.slice(0, series.findIndex(([key]) => key === dataKey)); if (!below.length) return null; 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 seriesIndex = Math.max( 0, series.findIndex(([key]) => key === dataKey) ); const seriesCount = Math.max(1, series.length); const loading = status === 'loading'; const total = data.length; const horizontal = orientation === 'horizontal'; /* * Both paths come out of one builder, filtered by whether the band is the * active one. Two passes over the data a frame is still cheaper than the * bookkeeping needed to build both at once, and it keeps the geometry in * exactly one place. */ const build = (wantActive: boolean) => () => { 'worklet'; if (!total || plot.width <= 0) { return { d: '', opacity: 1 }; } // The category axis runs across the plot when the bars grow up, and down // it when they grow right. `along` is that axis; `across` is the value one. const along = horizontal ? plot.height : plot.width; const across = horizontal ? plot.width : plot.height; const alongStart = horizontal ? plot.top : plot.left; const acrossStart = horizontal ? plot.left : plot.top; const band = along / total; // The slot a single bar gets: the band, less the gap, divided between the // series when they stand side by side. const usable = band * (1 - barGap); const slot = stacked ? usable : usable / seriesCount; const thickness = Math.min(barWidth ?? slot, slot); const min = domainMin.value; const max = domainMax.value; const range = max - min || 1; const grow = reveal.value; const active = activeIndex.value; /* * Where a value sits along the value axis. Vertical counts down from the * top, horizontal counts up from the left — the same scale read in * opposite directions, which is the only thing orientation changes. */ const project = (value: number) => { 'worklet'; const fraction = (value - min) / range; return horizontal ? acrossStart + fraction * across : acrossStart + across - fraction * across; }; let d = ''; for (let i = 0; i < total; i++) { if ((i === active) !== wantActive) continue; const value = values[i]; if (value === null || value === undefined) continue; const base = baselines?.[i] ?? 0; /* * Staggered by band, but every bar still finishes inside the one * duration: the window each gets is what is left after the stagger, so * a chart of fifty bands does not take fifty times as long to arrive. */ const start = total > 1 ? (i / total) * 0.45 : 0; const eased = Math.max(0, Math.min(1, (grow - start) / 0.55)); const shown = loading ? 0 : value * eased; const zero = project(base); const tip = project(base + shown); const shrink = stacked && stackGap > 0 ? stackGap : 0; let length = Math.abs(tip - zero) - shrink; // A value that rounds to nothing still happened, and a bar of zero // length says it did not. if (minBarLength > 0 && shown !== 0 && length < minBarLength) { length = minBarLength; } if (length <= 0) continue; const offset = (stacked ? 0 : seriesIndex * slot) + (band - usable) / 2 + (slot - thickness) / 2; const lead = alongStart + i * band + offset; const positive = shown >= 0; d += horizontal ? barPath( positive ? zero : zero - length, lead, length, thickness, radius, positive ? 'right' : 'left' ) : barPath( lead, positive ? zero - length : zero, thickness, length, radius, positive ? 'up' : 'down' ); } // Dimming only happens while something *is* active; with nothing under the // finger every bar is at full ink, which is the resting state. const dim = !wantActive && active >= 0 ? fadedOpacity : 1; return { d, opacity: dim }; }; const restProps = useAnimatedProps(build(false)); const activeProps = useAnimatedProps(build(true)); return ( ); } BarChartBar.displayName = 'BarChart.Bar'; BarChartBar.layer = 'svg' as Layer; /** How much of the value axis a placeholder bar takes. */ const SKELETON_LENGTH = 0.22; /** Bands to draw when there is no data yet to count them from. */ const SKELETON_BARS = 7; export interface BarChartSkeletonProps { /** * How many placeholder bars to draw. Defaults to one per row, and to seven * when the data has not arrived — the count is the one thing a loading * chart can be honest about only if it already has the rows. */ bars?: number; /** Milliseconds for one pass of the sweep. */ duration?: number; color?: string; } /** * The loading state: a row of short, equal stubs on the baseline, with a * highlight travelling across them. * * Equal on purpose. Placeholder bars of differing heights are a distribution * the reader has no way to tell from the real one until it changes under them, * so these say only how many bars there will be and where the baseline 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 BarChartSkeleton({ bars, duration = 1400, color }: BarChartSkeletonProps) { const { plot, status, orientation, data, barGap, barWidth, cornerRadius } = useChart('BarChart.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}%`, })); const horizontal = orientation === 'horizontal'; const total = Math.max(1, bars ?? (data.length || SKELETON_BARS)); const d = useMemo(() => { if (plot.width <= 0 || plot.height <= 0) return ''; // The same two axes the bars use: `along` is the category one, `across` // the value one, so a sideways chart is this code with the pair swapped. const along = horizontal ? plot.height : plot.width; const alongStart = horizontal ? plot.top : plot.left; const across = horizontal ? plot.width : plot.height; const band = along / total; const usable = band * (1 - barGap); const thickness = Math.min(barWidth ?? usable, usable); const length = across * SKELETON_LENGTH; let path = ''; for (let i = 0; i < total; i += 1) { const lead = alongStart + i * band + (band - thickness) / 2; path += horizontal ? barPath(plot.left, lead, length, thickness, cornerRadius, 'right') : barPath(lead, plot.top + plot.height - length, thickness, length, cornerRadius, 'up'); } return path; }, [plot, horizontal, total, barGap, barWidth, cornerRadius]); if (!loading || !d) return null; const gradientId = 'panelui-bar-skeleton'; return ( ); } BarChartSkeleton.displayName = 'BarChart.Skeleton'; BarChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ /** * Narrowest a category label may be drawn before the axis starts dropping * some. Roughly three characters at `xs`, which is what a month abbreviation * or a short name needs. */ const MIN_BAND_LABEL = 34; export interface BarChartXAxisProps { /** * How many labels to show. Every category by default, thinned only when the * bands get too narrow to read — pass a number to force it lower. */ ticks?: number; /** Turn a row into its label. Defaults to the value at `xDataKey`. */ format?: (datum: BarChartDatum, index: number) => string; className?: string; } /** * The category labels, one under each band it has room for. Real text rather * than SVG text, so they follow the theme's font and the platform's text * scaling — SVG text does neither. */ function BarChartXAxis({ ticks, format, className }: BarChartXAxisProps) { const { data, xDataKey, plot, orientation } = useChart('BarChart.XAxis'); const labels = useMemo(() => { if (!data.length) return []; /* * Every category, unless they will not fit. A fixed tick count dropped * names that had room to be drawn — eight months at six ticks lost March * and June for no reason anyone looking at the chart could see. The axis * asks the plot how much room there is instead, and only thins when the * answer is not enough. */ const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL)); const count = Math.max(1, Math.min(ticks ?? room, data.length)); // Every nth band, rather than a fractional step rounded to the nearest // index — rounding lands on the same band twice and skips its neighbour. const stride = Math.ceil(data.length / count); const picked: { key: number; text: string }[] = []; for (let index = 0; index < data.length; index += stride) { const datum = data[index]; if (!datum) continue; picked.push({ key: index, text: format ? format(datum, index) : String(datum[xDataKey] ?? ''), }); } return picked; }, [data, ticks, format, xDataKey, plot.width]); if (orientation === 'horizontal') return null; /* * Each label is placed on its own band's centre, not spaced evenly along the * axis. Two things went wrong when they were spread with `justify-between`: * a bar owns a *band* rather than sitting on a point, so the first and last * labels were half a band out at the edges; and the indices `ticks` picks * are not evenly spaced (eight months shown six at a time gives 0,1,3,4,6,7), * so the ones between were spread evenly over gaps that are not. * * The box is backed off by half its own width rather than translated by * `-50%`, which is not reliable across React Native versions. */ /* * One box per band, exactly the band's width. Tiling them rather than giving * each a fixed width means they can never overlap each other and the first * and last can never hang off the ends of the plot — the row of labels * occupies precisely the space the bars do. */ const bandWidth = data.length > 0 ? plot.width / data.length : 0; return ( {labels.map((label) => ( {label.text} ))} ); } BarChartXAxis.displayName = 'BarChart.XAxis'; BarChartXAxis.layer = 'overlay' as Layer; export interface BarChartYAxisProps { /** How many labels to show along the value axis. */ ticks?: number; /** Format a value for its label. Defaults to a compact number. */ format?: (value: number) => string; className?: string; } /** Value labels down the side, aligned to the grid lines. */ function BarChartYAxis({ ticks = 4, format, className }: BarChartYAxisProps) { const { plot, data, xDataKey, orientation, extent } = useChart('BarChart.YAxis'); /* * Read off the settled domain rather than the shared values the paths use. * A label is text, and text is JS — following the tween would re-render on * every frame of it to redraw a number nobody can read while it moves. */ const horizontal = orientation === 'horizontal'; const labels = useMemo(() => { if (horizontal) { // Sideways, the side of the chart is the category axis. return data.map((row, index) => ({ key: index, text: String(row[xDataKey] ?? ''), })); } 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, horizontal, data, xDataKey]); /* * Sideways the labels sit in the gutter the plot already left for them, one * band each — `flex-1` per row rather than spacing them edge to edge, so * every name lands beside its own bar instead of only the first and last * doing so. Upright there are no bands to line up with, so the ticks space * themselves against the grid. */ return ( {labels.map((label) => ( {label.text} ))} ); } BarChartYAxis.displayName = 'BarChart.YAxis'; BarChartYAxis.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. BarChartYAxis.axis = 'y' as const; export interface BarChartTooltipProps { /** 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: BarChartDatum) => string; className?: string; } /** * The readout, and the gesture that drives it. * * There is no crosshair. A line needs one because a point on a line has no * width of its own to point at; a bar is already the thing being pointed at, * so highlighting it and dimming the rest says the same thing without drawing * a line through the chart. * * The hit area is the whole plot. A readout you have to land on the bar to * summon is a readout nobody finds — and the thinner the bars, the truer that * gets. */ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipProps) { const { data, xDataKey, plot, series, orientation, activeIndex, activeIndexJS, setActiveIndexJS, status, } = useChart('BarChart.Tooltip'); const total = data.length; const horizontal = orientation === 'horizontal'; const left = plot.left; const top = plot.top; const width = plot.width; const height = plot.height; /* * The readout's own height, measured rather than assumed. Sideways it has to * be clamped inside the plot vertically, and how tall it is depends on how * many series are listed in it — a constant here would either let a * three-series readout hang off the bottom or reserve room a one-series * readout never uses. */ const labelHeight = useSharedValue(0); /* * 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, y: number) => { 'worklet'; if (!total) return; const span = horizontal ? height : width; const offset = (horizontal ? y - top : x - left) / (span || 1); // Bands, not points: the finger is inside whichever slice it lands on, // which is a floor rather than a round to the nearest centre. const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total))); if (next === activeIndex.value) return; activeIndex.value = next; runOnJS(setActiveIndexJS)(next); }; return Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; resolve(event.x, event.y); }) .onUpdate((event) => { 'worklet'; resolve(event.x, event.y); }) .onFinalize(() => { 'worklet'; activeIndex.value = -1; runOnJS(setActiveIndexJS)(-1); }); }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]); /* * The readout centres over its band and is clamped inside the plot, so it * never runs off the edge at the first or last one. * * Which axis the band runs along is the whole difference between the two * orientations. Upright, the bands are side by side, so the readout slides * across and sits above them all. Sideways, the bands are stacked down the * plot, so it slides *down* to the row it is describing and stays centred * across — held at the top instead, it would name the row under the finger * while covering the first one, which is the row a reader checks it against. */ const labelStyle = useAnimatedStyle(() => { const index = activeIndex.value; if (index < 0 || !total) return { opacity: 0 }; const band = (horizontal ? plot.height : plot.width) / total; const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5); const half = LABEL_WIDTH / 2; const clamped = Math.min( plot.left + plot.width - half, Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre) ); if (!horizontal) { return { opacity: 1, transform: [{ translateX: clamped - half }] }; } // Until the first measurement lands the height is zero, which clamps to // the top of the plot — the same place it used to sit, rather than a jump // from somewhere it never was. const tall = labelHeight.value; const y = Math.min( plot.top + Math.max(plot.height - tall, 0), Math.max(plot.top, centre - tall / 2) ); return { opacity: 1, transform: [{ translateX: clamped - half }, { translateY: y }], }; }); const active = activeIndexJS >= 0 ? data[activeIndexJS] : null; const fmtValue = formatValue ?? ((value: number) => compactNumber(value)); const fmtX = formatX ?? ((datum: BarChartDatum) => String(datum[xDataKey] ?? '')); if (status === 'loading') return null; return ( {active ? ( { labelHeight.value = event.nativeEvent.layout.height; }} className={cn( 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg', className )} > {fmtX(active)} {series.map(([key, color]) => { const value = active[key]; if (typeof value !== 'number') return null; return ( {fmtValue(value, key)} ); })} ) : null} ); } BarChartTooltip.displayName = 'BarChart.Tooltip'; BarChartTooltip.layer = 'overlay' as Layer; export interface BarChartLegendProps extends ViewProps { className?: string; /** Prettier names for the series keys. */ labels?: Record; } /** One series' colour and name. Shared by the legend and the header. */ function SeriesSwatch({ color, label }: { color: string; label: string }) { return ( {label} ); } /** A swatch and a name per series, in the order the series were declared. */ function BarChartLegend({ className, labels, ...props }: BarChartLegendProps) { const { series } = useChart('BarChart.Legend'); if (!series.length) return null; return ( {series.map(([key, color]) => ( ))} ); } BarChartLegend.displayName = 'BarChart.Legend'; BarChartLegend.layer = 'overlay' as Layer; /* -------------------------------------------------------------------------- */ /* Header layer */ /* -------------------------------------------------------------------------- */ export interface BarChartHeaderProps 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 * `BarChart.Legend` on a chart that has a header: the legend floats over the * plot, where it competes with the bars 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 bars, 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 band's value when something is. */ function BarChartHeader({ className, title, value, caption, labels, legend = false, children, ...props }: BarChartHeaderProps) { const { series } = useChart('BarChart.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} ); } BarChartHeader.displayName = 'BarChart.Header'; BarChartHeader.layer = 'header' as Layer; export const BarChart = Object.assign(BarChartRoot, { Header: BarChartHeader, Grid: BarChartGrid, Bar: BarChartBar, Skeleton: BarChartSkeleton, XAxis: BarChartXAxis, YAxis: BarChartYAxis, Tooltip: BarChartTooltip, Legend: BarChartLegend, });