/** * PyramidChart — two series mirrored about a centre line, drawn and animated on * the UI thread. * * ```tsx * * * * * * * * * ``` * * ## The two wings share one scale * * This is the whole contract, and everything else here follows from it. The * domain is derived from the larger of the two series' extents and used by * both, so a bar twice as long as the one facing it means twice as much. * Scaling each side to its own maximum produces a chart whose halves cannot be * compared — which is the only thing a pyramid is for. * * For the same reason the centre is zero and stays there. There is no `yDomain` * that starts anywhere else, because a wing cropped at its base is a length * that lies. * * ## Lengths, not signed values * * Which side a series is on comes from `side`, not from the sign of its * numbers, so a value is a distance outward from the centre. A negative one has * no direction left to grow in and is drawn as nothing; it still appears in the * readout, so a data error shows up as a gap rather than as a bar pointing the * wrong way. * * ## Where the category names go * * `labelPlacement="above"`, the default, gives each row a line of its own over * its pair of bars. That leaves the two wings meeting in the middle with * nothing standing between them, and puts the name where it is read before the * lengths it belongs to rather than beside them. * * `"center"` puts the names in a gutter between the wings instead — taken off * the bars rather than off the edges, so both wings stay equal — and `"start"` * puts them down the left, for a chart with room to spare on that side. * * ## Every series is one path * * A `Bar` draws all its rectangles as subpaths of a single animated path, split * in two so the row under the finger keeps full ink while the rest fade. Thirty * rows is two animated props a frame rather than thirty. */ 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 { ChartAccessibilityData, type ChartAccessibilityProps, } from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { barPath, columnValues, compactNumber, niceDomain, 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 value labels along the bottom. */ const PADDING = { top: 6, right: 10, bottom: 22, left: 10 }; /** Left gutter reserved for the category names when they sit at the start. */ const CATEGORY_GUTTER = 68; /** * Middle gutter reserved for them when they sit between the wings. Taken off * the bars rather than off the edges, so the two wings stay equal. */ const CENTRE_GUTTER = 72; /** Width the readout is laid out at, so it can be clamped inside the plot. */ const LABEL_WIDTH = 132; /** * Box each value label is centred in. Narrow, because the outermost tick on * each wing sits on the plot's own edge — a box centred there hangs half its * width off the side of the chart, and the number is cropped away. */ const AXIS_LABEL_WIDTH = 56; /** Line height of an `xs` label, for centring one on the tick it names. */ const AXIS_LABEL_HEIGHT = 16; /** * Steps the value axis is rounded out to. Four rather than the two labels each * wing draws, so the halfway label lands on a round number too. */ const DOMAIN_STEPS = 4; /** * Height taken off the top of every band for the name sitting over it. An `xs` * line plus the air that keeps it off the bar it belongs to. */ const LABEL_ROW = 20; type Layer = 'svg' | 'overlay' | 'header'; export type PyramidChartStatus = 'loading' | 'ready'; /** Which wing a series grows into. */ export type PyramidChartSide = 'start' | 'end'; /** Where the category names sit. */ export type PyramidChartLabelPlacement = 'above' | 'center' | 'start'; export type PyramidChartDatum = Record; interface PyramidSeries { key: string; color: string; side: PyramidChartSide; } interface PyramidChartContextValue { data: PyramidChartDatum[]; xDataKey: string; plot: Plot; status: PyramidChartStatus; /** Points held back in the middle for the category names. Zero otherwise. */ gutter: number; /** Points taken off the top of every band for a name sitting over it. */ labelInset: number; labelPlacement: PyramidChartLabelPlacement; barGap: number; barWidth: number | undefined; cornerRadius: number; minBarLength: number; fadedOpacity: number; series: PyramidSeries[]; registerSeries: (series: PyramidSeries) => void; unregisterSeries: (key: string) => void; /** The far end of the shared scale. The near end is zero, always. */ domainMax: SharedValue; /** The settled maximum, for the parts that draw text rather than geometry. */ extentMax: number; /** 0 to 1 as the bars grow out. Shared, so they arrive as one chart. */ reveal: SharedValue; activeIndex: SharedValue; activeIndexJS: number; setActiveIndexJS: (index: number) => void; } const PyramidChartContext = createContext(null); function useChart(component: string): PyramidChartContextValue { const context = useContext(PyramidChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * The row 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 usePyramidChart() { const { data, activeIndexJS, xDataKey } = useChart('usePyramidChart'); return { activeIndex: activeIndexJS, activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null, xDataKey, }; } export interface PyramidChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. Each one is a band across the chart, with a wing either side. */ data: PyramidChartDatum[]; /** Key holding the category label. Used by the axis and the readout. */ xDataKey?: string; /** * `loading` holds the bars at the centre and grows them out 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 * `PyramidChart.Skeleton` for something to stand in the plot meanwhile. */ status?: PyramidChartStatus; /** Width ÷ height. `1.2` suits three or four rows in a card. */ aspectRatio?: number; /** Milliseconds for the bars to grow out on mount. */ animationDuration?: number; /** Milliseconds for the scale to settle after the data changes. */ domainDuration?: number; /** * Fix the far end of the shared scale instead of deriving it. The near end is * zero either way — a pyramid measures outward from its centre. */ maxValue?: number; /** * Where the category names sit. `above`, the default, gives each row a line * of its own over its pair of bars, so the two wings meet in the middle with * nothing standing between them. `center` puts the names in a gutter between * the wings instead, and `start` down the left edge. */ labelPlacement?: PyramidChartLabelPlacement; /** * Fraction of each band left empty, `0` to `1`. A fraction rather than a * pixel gap so the proportions hold at any height. */ barGap?: number; /** Fixed bar thickness in points. Derived from the band when omitted. */ barWidth?: number; /** Corner radius on the outward 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 length says it did not. */ minBarLength?: number; /** Opacity of the rows that are not under the finger. */ fadedOpacity?: number; /** * The row under the finger as it moves, and `-1`/`null` when it lifts. * Fires when the index changes, not per frame. */ onActiveIndexChange?: (index: number, datum: PyramidChartDatum | null) => void; children?: ReactNode; } /** Imperative handle: re-run the grow-out, for a "replay" control. */ export interface PyramidChartHandle { 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 PyramidChartRoot = forwardRef( function PyramidChartRoot( { className, data, xDataKey = 'name', status = 'ready', aspectRatio = 1.2, animationDuration = 700, domainDuration = 500, maxValue, labelPlacement = 'above', barGap = 0.25, barWidth, cornerRadius = 4, minBarLength = 0, fadedOpacity = 0.3, onActiveIndexChange, accessible, accessibilityLabel, accessibilityHint, accessibilityLabelForDatum, onAccessibilityDatumPress, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [series, setSeries] = useState([]); const [activeIndexJS, setActiveIndexJS] = useState(-1); const reveal = useSharedValue(0); const domainMax = useSharedValue(0); const activeIndex = useSharedValue(-1); const reducedMotion = useReducedMotion(); const registerSeries = useMemo( () => (next: PyramidSeries) => setSeries((current) => { const existing = current.find((entry) => entry.key === next.key); if (existing?.color === next.color && existing.side === next.side) return current; return [...current.filter((entry) => entry.key !== next.key), next]; }), [] ); const unregisterSeries = useMemo( () => (key: string) => setSeries((current) => current.filter((entry) => entry.key !== key)), [] ); /* * Whether the category names are 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]); /* * Three places a name can go, and each takes its room from somewhere * different: `above` off the top of every band, `center` out of the middle * of the plot, `start` off the left edge. */ const gutter = hasYAxis && labelPlacement === 'center' ? CENTRE_GUTTER : 0; const labelInset = hasYAxis && labelPlacement === 'above' ? LABEL_ROW : 0; const pad = { ...PADDING, left: hasYAxis && labelPlacement === 'start' ? CATEGORY_GUTTER : 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((entry) => entry.key).join('|'); const extentMax = useMemo(() => { if (maxValue !== undefined && maxValue > 0) return maxValue; const keys = seriesKeys ? seriesKeys.split('|') : []; let max = 0; /* * One number for both wings. Taking a maximum per side is what makes a * pyramid whose halves cannot be compared, so the largest value anywhere * in either series sets the scale for all of them. */ for (const row of data) { for (const key of keys) { const value = row[key]; if (typeof value !== 'number' || Number.isNaN(value)) continue; if (value > max) max = value; } } /* * Rounded out to a step a reader can divide in their head, rather than * padded by a fraction. A tenth added to 73 ends the axis at 80.3 and * labels the wings 40.2 and 80.3 — numbers that are true and that nobody * was looking for. The centre is left exactly where it is: rounding it * would lift the bars off their own baseline. * * Four steps, not two, so the halfway label the axis draws by default is * a round number as well as the end one. */ return max === 0 ? 1 : niceDomain(0, max, DOMAIN_STEPS)[1]; }, [data, maxValue, seriesKeys]); const loading = status === 'loading'; useEffect(() => { if (loading) return; const first = domainMax.value === 0; if (first || reducedMotion) { domainMax.value = extentMax; return; } domainMax.value = withTiming(extentMax, { duration: domainDuration }); }, [extentMax, loading, reducedMotion, domainDuration, 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, gutter, labelInset, labelPlacement, barGap, barWidth, cornerRadius, minBarLength, fadedOpacity, series, registerSeries, unregisterSeries, domainMax, extentMax, 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, gutter, labelInset, labelPlacement, barGap, barWidth, cornerRadius, minBarLength, fadedOpacity, series, registerSeries, unregisterSeries, domainMax, extentMax, 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((entry) => [entry.key, datum[entry.key]] as [string, unknown]), ]} /> {plot.width > 0 ? ( <> {svg} {overlay} ) : null} ); } ); PyramidChartRoot.displayName = 'PyramidChart'; /** * Where the centre line is, and how much room one wing has. Every part needs * both and none of them should work it out twice. */ function geometry(plot: Plot, gutter: number) { 'worklet'; const centre = plot.left + plot.width / 2; return { centre, /** The inner edge of the start wing — where a left-growing bar begins. */ innerStart: centre - gutter / 2, /** The inner edge of the end wing. */ innerEnd: centre + gutter / 2, wing: Math.max((plot.width - gutter) / 2, 0), }; } /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface PyramidChartGridProps { /** How many lines to draw per wing, not counting the centre. */ columns?: number; color?: string; dashArray?: string; opacity?: number; /** Draw the solid line down the middle the wings are measured from. */ centreLine?: boolean; } /** * Lines up the value axis, mirrored, so a bar can be read against a number * rather than only against the bar facing it. * * The centre line is drawn solid and undashed where the others are dashed: it * is not a tick, it is the zero both wings are measured from. */ function PyramidChartGrid({ columns = 2, color, dashArray = '4,6', opacity = 1, centreLine = true, }: PyramidChartGridProps) { const { plot, gutter } = useChart('PyramidChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)'); const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter); const ticks = Array.from({ length: columns }, (_unused, index) => (index + 1) / columns); return ( {ticks.map((fraction) => ( ))} {centreLine ? ( ) : null} ); } PyramidChartGrid.displayName = 'PyramidChart.Grid'; PyramidChartGrid.layer = 'svg' as Layer; export interface PyramidChartBarProps { /** Column in the data holding this series' values. */ dataKey: string; /** Which wing it grows into. */ side?: PyramidChartSide; /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */ color?: string; /** * Which of the five chart tokens to take. Defaults to a different one per * side, so two bars declared with nothing but a `dataKey` and a `side` are * already told apart. */ colorIndex?: SeriesColorIndex; /** Corner radius, overriding the chart's. */ cornerRadius?: number; } /** * One wing. * * Drawn as two paths rather than one rectangle per row: the row 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 PyramidChartBar({ dataKey, side = 'end', color, colorIndex, cornerRadius, }: PyramidChartBarProps) { const { data, plot, status, gutter, labelInset, barGap, barWidth, cornerRadius: chartRadius, minBarLength, fadedOpacity, registerSeries, unregisterSeries, domainMax, reveal, activeIndex, } = useChart('PyramidChart.Bar'); const index: SeriesColorIndex = colorIndex ?? (side === 'start' ? 2 : 1); const fill = useSeriesColor(color, index); const radius = cornerRadius ?? chartRadius; useEffect(() => { registerSeries({ key: dataKey, color: fill, side }); return () => unregisterSeries(dataKey); }, [dataKey, fill, side, registerSeries, unregisterSeries]); const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]); const loading = status === 'loading'; const total = data.length; const towards = side === 'start' ? 'left' : 'right'; /* * Both paths come out of one builder, filtered by whether the row 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 }; } const { innerStart, innerEnd, wing } = geometry(plot, gutter); const band = plot.height / total; // What is left of a band once the name over it has taken its line. const usable = Math.max(band - labelInset, 0); const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap)); const max = domainMax.value || 1; const grow = reveal.value; const active = activeIndex.value; let d = ''; for (let i = 0; i < total; i++) { if ((i === active) !== wantActive) continue; const value = values[i]; if (value === null || value === undefined) continue; /* * Staggered by row, but every bar still finishes inside the one duration: * the window each gets is what is left after the stagger, so a chart of * twenty rows does not take twenty 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)); // A length, so a negative number has no direction left to grow in. It is // still in the readout; it just cannot be drawn. const shown = loading ? 0 : Math.max(value, 0) * eased; let length = (shown / max) * wing; if (minBarLength > 0 && shown > 0 && length < minBarLength) length = minBarLength; if (length <= 0) continue; const lead = plot.top + i * band + labelInset + (usable - thickness) / 2; d += side === 'start' ? barPath(innerStart - length, lead, length, thickness, radius, towards) : barPath(innerEnd, lead, length, thickness, radius, towards); } // 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 ( ); } PyramidChartBar.displayName = 'PyramidChart.Bar'; PyramidChartBar.layer = 'svg' as Layer; /** How much of one wing a placeholder bar takes. */ const SKELETON_LENGTH = 0.35; /** Rows to draw when there is no data yet to count them from. */ const SKELETON_ROWS = 5; export interface PyramidChartSkeletonProps { /** * How many placeholder rows to draw. Defaults to one per row of data, and to * five 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. */ rows?: number; /** Milliseconds for one pass of the sweep. */ duration?: number; color?: string; } /** * The loading state: equal stubs either side of the centre, with a highlight * travelling across them. * * Equal on purpose. Placeholder wings of differing lengths 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 rows there will be and where the centre is. */ function PyramidChartSkeleton({ rows, duration = 1400, color }: PyramidChartSkeletonProps) { const { plot, status, gutter, labelInset, data, barGap, barWidth, cornerRadius } = useChart('PyramidChart.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 total = Math.max(1, rows ?? (data.length || SKELETON_ROWS)); const d = useMemo(() => { if (plot.width <= 0 || plot.height <= 0) return ''; const { innerStart, innerEnd, wing } = geometry(plot, gutter); const band = plot.height / total; const usable = Math.max(band - labelInset, 0); const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap)); const length = wing * SKELETON_LENGTH; let path = ''; for (let i = 0; i < total; i += 1) { const lead = plot.top + i * band + labelInset + (usable - thickness) / 2; path += barPath(innerStart - length, lead, length, thickness, cornerRadius, 'left'); path += barPath(innerEnd, lead, length, thickness, cornerRadius, 'right'); } return path; }, [plot, gutter, labelInset, total, barGap, barWidth, cornerRadius]); if (!loading || !d) return null; const gradientId = 'panelui-pyramid-skeleton'; return ( ); } PyramidChartSkeleton.displayName = 'PyramidChart.Skeleton'; PyramidChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ export interface PyramidChartXAxisProps { /** How many labels per wing, not counting the zero in the middle. */ ticks?: number; /** Format a value for its label. Defaults to a compact number. */ format?: (value: number) => string; className?: string; } /** * The value labels along the bottom, mirrored either side of a zero in the * middle — the same magnitudes twice, because both wings are read outward from * the centre rather than along one continuous axis. * * Real text rather than SVG text, so the labels follow the theme's font and the * platform's text scaling. SVG text does neither. */ function PyramidChartXAxis({ ticks = 2, format, className }: PyramidChartXAxisProps) { const { plot, gutter, extentMax } = useChart('PyramidChart.XAxis'); const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter); const fmt = format ?? compactNumber; const labels = useMemo(() => { const out: { key: string; text: string; x: number }[] = [ { key: 'centre', text: fmt(0), x: centre }, ]; for (let index = 1; index <= ticks; index += 1) { const fraction = index / ticks; const text = fmt(extentMax * fraction); out.push({ key: `start-${index}`, text, x: innerStart - wing * fraction }); out.push({ key: `end-${index}`, text, x: innerEnd + wing * fraction }); } return out; }, [ticks, fmt, extentMax, innerStart, innerEnd, wing, centre]); if (wing <= 0) return null; return ( {labels.map((label) => ( {label.text} ))} ); } PyramidChartXAxis.displayName = 'PyramidChart.XAxis'; PyramidChartXAxis.layer = 'overlay' as Layer; export interface PyramidChartYAxisProps { /** Turn a row into its label. Defaults to the value at `xDataKey`. */ format?: (datum: PyramidChartDatum, index: number) => string; className?: string; } /** * The category names, one per row. * * `above` puts each name on its own line over the pair of bars it belongs to, * which leaves the two wings meeting in the middle — the name is read first and * the bars are then read outward from a centre nothing is standing in. * * One box per band rather than a spaced column, in every placement: a row owns * a *band*, so names spread evenly would be half a band out at the top and * bottom. */ function PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) { const { data, xDataKey, plot, gutter, labelInset, labelPlacement } = useChart('PyramidChart.YAxis'); const { centre } = geometry(plot, gutter); if (!data.length) return null; const label = (datum: PyramidChartDatum, index: number) => format ? format(datum, index) : String(datum[xDataKey] ?? ''); if (labelPlacement === 'above') { const band = plot.height / data.length; return ( {data.map((datum, index) => ( {label(datum, index)} ))} ); } const centred = labelPlacement === 'center'; return ( {data.map((datum, index) => ( {label(datum, index)} ))} ); } PyramidChartYAxis.displayName = 'PyramidChart.YAxis'; PyramidChartYAxis.layer = 'overlay' as Layer; // Read by the root, which has to leave room for the names before it lays the // plot out — a name drawn over a bar is unreadable, and makes the bar it is // drawn over unreadable too. PyramidChartYAxis.axis = 'y' as const; export interface PyramidChartTooltipProps { /** 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: PyramidChartDatum) => string; className?: string; } /** * The readout, and the gesture that drives it. * * There is no crosshair. A bar is already the thing being pointed at, so * highlighting its row 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. */ function PyramidChartTooltip({ formatValue, formatX, className, }: PyramidChartTooltipProps) { const { data, xDataKey, plot, series, activeIndex, activeIndexJS, setActiveIndexJS, status, } = useChart('PyramidChart.Tooltip'); const total = data.length; const top = plot.top; const height = plot.height; /* * The readout's own height, measured rather than assumed. It has to be * clamped inside the plot, and how tall it is depends on how many series are * listed in it — a constant 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 = (y: number) => { 'worklet'; if (!total) return; const offset = (y - top) / (height || 1); // Bands, not points: the finger is inside whichever row 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.y); }) .onUpdate((event) => { 'worklet'; resolve(event.y); }) .onFinalize(() => { 'worklet'; activeIndex.value = -1; runOnJS(setActiveIndexJS)(-1); }); }, [total, top, height, activeIndex, setActiveIndexJS]); /* * The readout slides down to the row it is describing and sits centred over * the plot. 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 = plot.height / total; const centre = plot.top + band * (index + 0.5); const x = plot.left + plot.width / 2 - LABEL_WIDTH / 2; // 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: x }, { translateY: y }] }; }); const active = activeIndexJS >= 0 ? data[activeIndexJS] : null; const fmtValue = formatValue ?? ((value: number) => compactNumber(value)); const fmtX = formatX ?? ((datum: PyramidChartDatum) => 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((entry) => { const value = active[entry.key]; if (typeof value !== 'number') return null; return ( {fmtValue(value, entry.key)} ); })} ) : null} ); } PyramidChartTooltip.displayName = 'PyramidChart.Tooltip'; PyramidChartTooltip.layer = 'overlay' as Layer; export interface PyramidChartLegendProps 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 PyramidChartLegend({ className, labels, ...props }: PyramidChartLegendProps) { const { series } = useChart('PyramidChart.Legend'); if (!series.length) return null; return ( {series.map((entry) => ( ))} ); } PyramidChartLegend.displayName = 'PyramidChart.Legend'; PyramidChartLegend.layer = 'overlay' as Layer; /* -------------------------------------------------------------------------- */ /* Header layer */ /* -------------------------------------------------------------------------- */ export interface PyramidChartHeaderProps 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 * `PyramidChart.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 two 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 down the rows, and the * legend is the series list the chart itself is holding. * * 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. */ function PyramidChartHeader({ className, title, value, caption, labels, legend = false, children, ...props }: PyramidChartHeaderProps) { const { series } = useChart('PyramidChart.Header'); const trailing = children ?? (legend && series.length ? ( {series.map((entry) => ( ))} ) : null); return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {/* Shrinkable, unlike a view's default in React Native. Held rigid, a two-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} ); } PyramidChartHeader.displayName = 'PyramidChart.Header'; PyramidChartHeader.layer = 'header' as Layer; export const PyramidChart = Object.assign(PyramidChartRoot, { Header: PyramidChartHeader, Grid: PyramidChartGrid, Bar: PyramidChartBar, Skeleton: PyramidChartSkeleton, XAxis: PyramidChartXAxis, YAxis: PyramidChartYAxis, Tooltip: PyramidChartTooltip, Legend: PyramidChartLegend, });