/** * BumpChart — how a set of things ranked against each other over time. * * ```tsx * * * * * * * * * * ``` * * ## Positions, not values * * The y-axis is the rank, so the rows are evenly spaced whatever the gap * between the underlying scores. That is the point of the chart: who passed * whom, and when. A line chart of the same scores answers how far apart they * were, and loses the order wherever two lines run close together. * * Pass the ranks directly, or pass the scores with `values="score"` and each * column is ranked for you, highest first. `bumpRanks` is exported so a header * or a table beside the chart can read the same order the chart drew. * * ## One line at a time * * Past three or four series, every line in its own colour is a tangle. The * chart reads best with one line picked out: `highlight` draws that one in its * colour and on top, and the rest in a muted grey. Tapping a name in * `BumpChart.Labels` picks that line, and tapping it again clears it. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, StyleSheet, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, cancelAnimation, runOnJS, runOnUI, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Rect, Stop } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; import { useControllableState } from '../../primitives/controllable-state'; import { finiteChartNumber } from '../../primitives/finite-chart'; import { bumpSegment, colorStop, dotsPath, useSeriesColor, xOf, type ChartPoint, type Plot, type SeriesColorIndex, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); const PADDING = { top: 8, right: 10, bottom: 32, left: 10 }; /** Left gutter reserved when a `YAxis` is present, for `#1`…`#12`. */ const Y_AXIS_WIDTH = 34; /** Gap between the rank labels and the first column. */ const Y_AXIS_GUTTER = 8; /** Gap between the last column and the series names. */ const LABELS_GAP = 12; /** Box each x label is centred in. */ const POINT_LABEL_WIDTH = 56; /** Line height of an `xs` label, for centring one on the row it names. */ const AXIS_LABEL_HEIGHT = 16; /** Width of the scrub readout. */ const READOUT_WIDTH = 148; /** How far the lines that are not picked out fade back. */ const DIMMED_OPACITY = 0.5; type Layer = 'svg' | 'series' | 'overlay' | 'header'; export type BumpChartStatus = 'loading' | 'ready'; export type BumpChartDatum = Record; /** Ranks per series key, one entry per row. `null` where a series has none. */ export type BumpRanks = Record; /** * Each series' rank in every row. * * With `values: 'rank'` the rows already hold ranks and they are only * validated. With `values: 'score'` each row is ranked highest first; a tie * keeps the order the keys are passed in, so two equal scores never draw on * top of each other. */ export function bumpRanks( data: BumpChartDatum[], keys: string[], options: { values?: 'rank' | 'score' } = {} ): BumpRanks { const ranks: BumpRanks = {}; for (const key of keys) ranks[key] = []; for (const row of data) { if (options.values === 'score') { const scored = keys .map((key, order) => ({ key, order, score: finiteChartNumber(row[key]) })) .filter((entry): entry is { key: string; order: number; score: number } => entry.score !== undefined ) .sort((a, b) => b.score - a.score || a.order - b.order); const place = new Map(scored.map((entry, index) => [entry.key, index + 1])); for (const key of keys) ranks[key]!.push(place.get(key) ?? null); } else { for (const key of keys) { const rank = finiteChartNumber(row[key]); ranks[key]!.push(rank !== undefined && rank >= 1 ? rank : null); } } } return ranks; } /** * Ranks as a fraction of the plot's height: `0` is the top row, `1` the * bottom. Stored this way so a line can travel between two charts with a * different number of rows without a jump. */ function rankFractions(ranks: BumpRanks, rows: number): BumpRanks { const out: BumpRanks = {}; for (const key of Object.keys(ranks)) { out[key] = ranks[key]!.map((rank) => rank === null ? null : rows <= 1 ? 0.5 : (rank - 1) / (rows - 1) ); } return out; } /** A line's points at `progress` of the way between two sets of positions. */ function pointsAt( key: string, from: BumpRanks, to: BumpRanks, progress: number, plot: Plot ): ChartPoint[][] { 'worklet'; const target = to[key]; if (!target) return []; const origin = from[key]; const runs: ChartPoint[][] = []; let run: ChartPoint[] = []; for (let i = 0; i < target.length; i += 1) { const end = target[i]; if (end === null || end === undefined) { if (run.length) runs.push(run); run = []; continue; } const start = origin?.[i]; const fraction = start === null || start === undefined ? end : start + (end - start) * progress; run.push({ x: xOf(i, target.length, plot), y: plot.top + fraction * plot.height }); } if (run.length) runs.push(run); return runs; } interface BumpSeries { key: string; color: string; label: string; strokeWidth: number; showDots: boolean; } interface BumpChartContextValue { data: BumpChartDatum[]; xDataKey: string; plot: Plot; pad: typeof PADDING; status: BumpChartStatus; series: BumpSeries[]; ranks: BumpRanks; rows: number; registerSeries: (series: BumpSeries) => void; unregisterSeries: (key: string) => void; from: SharedValue; to: SharedValue; morph: SharedValue; highlight: string | null; setHighlight: (key: string | null) => void; activeIndex: SharedValue; activeIndexJS: number; setActiveIndexJS: (index: number) => void; } const BumpChartContext = createContext(null); function useChart(component: string): BumpChartContextValue { const context = useContext(BumpChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The row under the scrub, and which line is picked out. */ export function useBumpChart() { const { data, activeIndexJS, xDataKey, ranks, highlight, setHighlight } = useChart('useBumpChart'); return { activeIndex: activeIndexJS, activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null, xDataKey, ranks, highlight, setHighlight, }; } export interface BumpChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. Each one is a column: a week, a round, a release. */ data: BumpChartDatum[]; /** Key holding the column's label. */ xDataKey?: string; /** * What the series columns hold. `rank` (the default) takes them as places, * `1` at the top. `score` ranks every row for you, highest first. */ values?: 'rank' | 'score'; /** * The series drawn in its colour and on top, with the rest muted. `null` * picks none, and every line keeps its own colour. Controlled — pair it with * `onHighlightChange`. */ highlight?: string | null; /** The series picked out on first render, when `highlight` is not passed. */ defaultHighlight?: string | null; /** Called when a name in `BumpChart.Labels` is tapped. */ onHighlightChange?: (key: string | null) => void; /** * `loading` hides the lines and shows `BumpChart.Skeleton` if there is one. * The lines are revealed when it turns `ready`. */ status?: BumpChartStatus; /** Width ÷ height. */ aspectRatio?: number; /** Milliseconds for the reveal on mount. */ animationDuration?: number; /** Milliseconds for the lines to move to new places when the data changes. */ morphDuration?: number; /** * The column under the scrub as it moves, and `-1`/`null` when it lifts. * Fires when the index changes, not per frame. */ onActiveIndexChange?: (index: number, datum: BumpChartDatum | null) => void; /** Drop the axis padding, for a chart with no axes. */ compact?: boolean; children?: ReactNode; } /** Imperative handle: re-run the reveal on demand, for a "replay" control. */ export interface BumpChartHandle { replay: () => void; } function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } function partition(children: ReactNode) { const svg: ReactNode[] = []; const series: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const layer = (child.type as { layer?: Layer }).layer ?? 'svg'; const slot = {child}; const bucket = layer === 'header' ? header : layer === 'overlay' ? overlay : layer === 'series' ? series : svg; bucket.push(slot); }); return { svg, series, overlay, header }; } /** * What the root needs to know about its children before it lays the plot out: * whether an axis or the names want room at the sides, and the order the lines * were declared in. */ function survey(children: ReactNode) { let hasYAxis = false; let labelsWidth = 0; const order: string[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) return; const type = child.type as { axis?: string; labels?: boolean; line?: boolean }; const props = child.props as { dataKey?: string; width?: number }; if (type.axis === 'y') hasYAxis = true; if (type.labels) labelsWidth = props.width ?? DEFAULT_LABELS_WIDTH; if (type.line && typeof props.dataKey === 'string') order.push(props.dataKey); }); return { hasYAxis, labelsWidth, order }; } const BumpChartRoot = forwardRef(function BumpChartRoot( { className, data, xDataKey = 'date', values = 'rank', highlight: highlightProp, defaultHighlight = null, onHighlightChange, status = 'ready', aspectRatio = 1.7, animationDuration = 700, morphDuration = 500, onActiveIndexChange, accessible, accessibilityLabel, accessibilityHint, accessibilityLabelForDatum, onAccessibilityDatumPress, compact = false, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [registered, setRegistered] = useState([]); const [activeIndexJS, setActiveIndexJS] = useState(-1); const reveal = useSharedValue(0); const morph = useSharedValue(1); const activeIndex = useSharedValue(-1); const reducedMotion = useReducedMotion(); const { value: highlight, setValue: setHighlight } = useControllableState({ value: highlightProp, defaultValue: defaultHighlight, onChange: onHighlightChange, }); const registerSeries = useMemo( () => (entry: BumpSeries) => setRegistered((current) => { const existing = current.find((item) => item.key === entry.key); if ( existing && existing.color === entry.color && existing.label === entry.label && existing.strokeWidth === entry.strokeWidth && existing.showDots === entry.showDots ) { return current; } return [...current.filter((item) => item.key !== entry.key), entry]; }), [] ); const unregisterSeries = useMemo( () => (key: string) => setRegistered((current) => current.filter((item) => item.key !== key)), [] ); const { hasYAxis, labelsWidth, order } = survey(children); const orderKey = order.join('|'); /* * Declaration order, not registration order. A line re-registers when its * colour resolves or its label changes, which would otherwise move it to the * end of the list — and in `score` mode the order breaks ties. */ const series = useMemo(() => { const declared = orderKey ? orderKey.split('|') : []; const position = (key: string) => { const index = declared.indexOf(key); return index === -1 ? declared.length : index; }; return [...registered].sort((a, b) => position(a.key) - position(b.key)); }, [registered, orderKey]); const pad = compact ? { top: 6, right: 6, bottom: 6, left: 6 } : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH + Y_AXIS_GUTTER : PADDING.left, right: labelsWidth ? labelsWidth + LABELS_GAP : PADDING.right, }; 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((item) => item.key).join('|'); const ranks = useMemo( () => bumpRanks(data, seriesKeys ? seriesKeys.split('|') : [], { values }), [data, seriesKeys, values] ); /** How many places there are: the lowest rank anybody holds. */ const rows = useMemo(() => { let most = 0; for (const key of Object.keys(ranks)) { for (const rank of ranks[key]!) { if (rank !== null && rank > most) most = rank; } } return Math.max(most, 1); }, [ranks]); const fractions = useMemo(() => rankFractions(ranks, rows), [ranks, rows]); const from = useSharedValue(fractions); const to = useSharedValue(fractions); /* * A change of data moves every line from where it is drawn now to its new * place. "Where it is drawn now" is read on the UI thread, inside the same * worklet that starts the move, so a change landing mid-move carries on from * the lines' actual positions instead of snapping back to the last target. */ const loading = status === 'loading'; const settledOnce = useRef(false); useEffect(() => { if (!settledOnce.current || reducedMotion) { settledOnce.current = true; from.value = fractions; to.value = fractions; morph.value = 1; return; } runOnUI((next: BumpRanks, duration: number) => { 'worklet'; const progress = morph.value; const current: BumpRanks = {}; const previousTo = to.value; const previousFrom = from.value; for (const key of Object.keys(next)) { const target = previousTo[key]; const origin = previousFrom[key]; current[key] = next[key]!.map((_unused, index) => { const end = target?.[index]; if (end === null || end === undefined) return null; const start = origin?.[index]; return start === null || start === undefined ? end : start + (end - start) * progress; }); } from.value = current; to.value = next; morph.value = 0; morph.value = withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) }); })(fractions, morphDuration); }, [fractions, reducedMotion, morphDuration, from, to, morph]); const revealed = useRef(false); const playReveal = useMemo( () => () => { if (reducedMotion) { reveal.value = 1; return; } reveal.value = 0; reveal.value = withTiming(1, { duration: animationDuration, easing: Easing.out(Easing.cubic), }); }, [reducedMotion, animationDuration, reveal] ); useEffect(() => { // Going back to `loading` arms the reveal again, so a refetched chart // draws itself in rather than appearing complete on the frame it lands. if (loading) { revealed.current = false; reveal.value = 0; return; } if (revealed.current || plot.width <= 0 || !data.length) return; revealed.current = true; playReveal(); }, [loading, plot.width, data.length, playReveal, reveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); /* * The reveal widens past the plot's right edge by the dots' radius, so the * last column's dots are not cut in half when it finishes. */ const revealWidth = plot.width + 8; const revealStyle = useAnimatedStyle(() => ({ width: revealWidth * reveal.value + 8 })); 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, pad, status, series, ranks, rows, registerSeries, unregisterSeries, from, to, morph, highlight, setHighlight, activeIndex, activeIndexJS, setActiveIndexJS: handleActiveIndex, }), // `plot` and `pad` are rebuilt every render from `size`, so they are // compared by value. // eslint-disable-next-line react-hooks/exhaustive-deps [ data, xDataKey, plot.width, plot.height, plot.left, plot.top, pad.right, pad.bottom, status, series, ranks, rows, registerSeries, unregisterSeries, from, to, morph, highlight, setHighlight, activeIndex, activeIndexJS, handleActiveIndex, ] ); const { svg, series: seriesLayer, overlay, header } = partition(children); return ( {header} { const index = data.indexOf(datum); return [ [xDataKey, datum[xDataKey]], ...series.map((item) => { const rank = ranks[item.key]?.[index]; return [item.label, rank == null ? null : `rank ${rank}`] as [string, unknown]; }), ]; }} /> {plot.width > 0 ? ( <> {svg} {/* * The reveal is a view that grows, not an SVG clip path: animated * props on a clip inside `` never reach the native clip on * Android, and the lines would simply appear complete there. * Started 8pt left of the plot so the first column's dots are * whole from the first frame. */} {loading ? null : ( {seriesLayer} )} {overlay} ) : null} ); }); BumpChartRoot.displayName = 'BumpChart'; /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface BumpChartGridProps { /** A guide down every column. */ vertical?: boolean; /** A guide across every rank. */ horizontal?: boolean; color?: string; dashArray?: string; opacity?: number; } /** Guides down each column, so a dot can be read against the label below it. */ function BumpChartGrid({ vertical = true, horizontal = false, color, dashArray = '3,5', opacity = 1, }: BumpChartGridProps) { const { plot, data, rows } = useChart('BumpChart.Grid'); const token = useCSSVariable('--color-border'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)'); return ( {vertical ? data.map((_unused, index) => { const x = xOf(index, data.length, plot); return ( ); }) : null} {horizontal ? Array.from({ length: rows }, (_unused, index) => { const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1)); return ( ); }) : null} ); } BumpChartGrid.displayName = 'BumpChart.Grid'; BumpChartGrid.layer = 'svg' as Layer; export interface BumpChartLineProps { /** Column in the data holding this series' rank, or its score with `values="score"`. */ dataKey: string; /** The name shown by `Labels`, the tooltip and the legend. Defaults to `dataKey`. */ label?: string; /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */ color?: string; /** Which of the five chart tokens to take. */ colorIndex?: SeriesColorIndex; /** Thickness of the line. The picked-out line is drawn one point thicker. */ strokeWidth?: number; /** Draw a dot at every column. */ showDots?: boolean; } /** * One series. It draws nothing itself: the lines are drawn together, so the * one picked out can be drawn last and sit on top of every crossing. */ function BumpChartLine({ dataKey, label, color, colorIndex = 1, strokeWidth = 1.5, showDots = true, }: BumpChartLineProps) { const { registerSeries, unregisterSeries } = useChart('BumpChart.Line'); const stroke = useSeriesColor(color, colorIndex); const name = label ?? dataKey; useEffect(() => { registerSeries({ key: dataKey, color: stroke, label: name, strokeWidth, showDots }); }, [dataKey, stroke, name, strokeWidth, showDots, registerSeries]); useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]); return null; } BumpChartLine.displayName = 'BumpChart.Line'; BumpChartLine.layer = 'series' as Layer; // Read by the root, for the order the lines were declared in. BumpChartLine.line = true as const; /** Every line, the picked-out one last. */ function BumpLines() { const { series, highlight } = useChart('BumpChart'); const muted = useCSSVariable('--color-muted-foreground'); const mutedColor = typeof muted === 'string' ? muted : '#737373'; const ordered = highlight ? [...series.filter((item) => item.key !== highlight), ...series.filter((item) => item.key === highlight)] : series; return ( {ordered.map((item) => { const picked = highlight === item.key; const dimmed = highlight !== null && !picked; return ( ); })} ); } function BumpLine({ series, color, opacity, strokeWidth, dotRadius, }: { series: BumpSeries; color: string; opacity: number; strokeWidth: number; dotRadius: number; }) { const { plot, from, to, morph } = useChart('BumpChart.Line'); const key = series.key; const lineProps = useAnimatedProps(() => ({ d: pointsAt(key, from.value, to.value, morph.value, plot) .map((run) => bumpSegment(run)) .join(' '), })); const dotProps = useAnimatedProps(() => { let d = ''; for (const run of pointsAt(key, from.value, to.value, morph.value, plot)) { d += dotsPath(run, dotRadius); } return { d }; }); return ( {series.showDots ? : null} ); } export interface BumpChartSkeletonProps { /** Milliseconds for one pass of the sweep. */ duration?: number; color?: string; /** How many rank rows to stand in for. */ rows?: number; } /** * The loading state: a thin bar on every rank row with a highlight travelling * across them. Flat, so it says where the lines will be and nothing about * where they go. */ function BumpChartSkeleton({ duration = 1400, color, rows: rowsProp }: BumpChartSkeletonProps) { const { plot, status, rows: dataRows } = useChart('BumpChart.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 rows = rowsProp ?? Math.max(dataRows, 4); 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]); 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 gradientId = 'panelui-bump-skeleton'; const thickness = 4; return ( {Array.from({ length: rows }, (_unused, index) => { const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1)); return ( ); })} ); } BumpChartSkeleton.displayName = 'BumpChart.Skeleton'; BumpChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ export interface BumpChartXAxisProps { /** How many columns to label, spread across the run. */ ticks?: number; format?: (datum: BumpChartDatum, index: number) => string; className?: string; } /** The column labels. Real text, so they follow the theme's font and text scaling. */ function BumpChartXAxis({ ticks = 4, format, className }: BumpChartXAxisProps) { const { data, xDataKey, plot, pad } = useChart('BumpChart.XAxis'); const labels = useMemo(() => { if (!data.length) return []; const count = Math.min(ticks, data.length); const step = count > 1 ? (data.length - 1) / (count - 1) : 0; return Array.from({ length: count }, (_unused, index) => { const dataIndex = Math.round(index * step); const datum = data[dataIndex]; if (!datum) return null; return { key: dataIndex, text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''), }; }).filter((label): label is { key: number; text: string } => label !== null); }, [data, ticks, format, xDataKey]); return ( {labels.map((label) => ( {label.text} ))} ); } BumpChartXAxis.displayName = 'BumpChart.XAxis'; BumpChartXAxis.layer = 'overlay' as Layer; export interface BumpChartYAxisProps { /** Format a rank. Defaults to `#1`, `#2`… */ format?: (rank: number) => string; className?: string; } /** The ranks down the side, one on every row. */ function BumpChartYAxis({ format, className }: BumpChartYAxisProps) { const { plot, rows } = useChart('BumpChart.YAxis'); return ( {Array.from({ length: rows }, (_unused, index) => ( {format ? format(index + 1) : `#${index + 1}`} ))} ); } BumpChartYAxis.displayName = 'BumpChart.YAxis'; BumpChartYAxis.layer = 'overlay' as Layer; // Read by the root, which leaves room for the ranks before laying the plot out. BumpChartYAxis.axis = 'y' as const; const DEFAULT_LABELS_WIDTH = 84; export interface BumpChartLabelsProps { /** Room kept to the right of the plot for the names. Longer names are cut short. */ width?: number; /** Let a tap on a name pick that line out, and a second tap clear it. */ pressable?: boolean; className?: string; } /** * Each series' name beside its last point, so the lines can be told apart * without a legend to look across to. The names move with their lines when * the data changes. */ function BumpChartLabels({ pressable = true, className }: BumpChartLabelsProps) { const { series, plot, pad, highlight, setHighlight, from, to, morph, status } = useChart('BumpChart.Labels'); if (status === 'loading') return null; return ( {series.map((item) => ( setHighlight(highlight === item.key ? null : item.key) : undefined } className={className} /> ))} ); } BumpChartLabels.displayName = 'BumpChart.Labels'; BumpChartLabels.layer = 'overlay' as Layer; // Read by the root, which keeps room for the names to the right of the plot. BumpChartLabels.labels = true as const; function BumpLabel({ series, left, width, plot, from, to, morph, picked, dimmed, onPress, className, }: { series: BumpSeries; left: number; width: number; plot: Plot; from: SharedValue; to: SharedValue; morph: SharedValue; picked: boolean; dimmed: boolean; onPress?: () => void; className?: string; }) { const key = series.key; // The name sits level with the line's last point, wherever that is drawn. const style = useAnimatedStyle(() => { const runs = pointsAt(key, from.value, to.value, morph.value, plot); const run = runs[runs.length - 1]; const last = run?.[run.length - 1]; if (!last) return { opacity: 0, transform: [{ translateY: 0 }] }; return { opacity: 1, transform: [{ translateY: last.y - LABEL_ROW / 2 }] }; }); return ( {series.label} ); } /** Height of one name's hit box. */ const LABEL_ROW = 20; export interface BumpChartTooltipProps { color?: string; /** Format a rank in the readout. Defaults to `#1`, `#2`… */ formatRank?: (rank: number) => string; /** Format the readout's heading from the row. Defaults to the value at xDataKey. */ formatX?: (datum: BumpChartDatum) => string; className?: string; } /** * A scrub across the columns, and a readout listing every series in the order * it stood at the column under the finger. */ function BumpChartTooltip({ color, formatRank, formatX, className }: BumpChartTooltipProps) { const { data, xDataKey, plot, series, ranks, highlight, activeIndex, activeIndexJS, setActiveIndexJS, status, } = useChart('BumpChart.Tooltip'); const token = useCSSVariable('--color-foreground'); const stroke = color ?? (typeof token === 'string' ? token : '#888888'); const muted = useCSSVariable('--color-muted-foreground'); const mutedColor = typeof muted === 'string' ? muted : '#737373'; const total = data.length; const left = plot.left; const width = plot.width; // Declared inside the memo, next to its callers: a worklet may only call // another worklet. const pan = useMemo(() => { const resolve = (x: number) => { 'worklet'; if (total < 1 || width <= 0) return; const ratio = total < 2 ? 0 : (x - left) / width; const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1)); if (next === activeIndex.value) return; activeIndex.value = next; runOnJS(setActiveIndexJS)(next); }; return Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; resolve(event.x); }) .onUpdate((event) => { 'worklet'; resolve(event.x); }) .onFinalize(() => { 'worklet'; activeIndex.value = -1; runOnJS(setActiveIndexJS)(-1); }); }, [total, left, width, activeIndex, setActiveIndexJS]); const crosshairStyle = useAnimatedStyle(() => { const index = activeIndex.value; return { opacity: index < 0 ? 0 : 0.45, transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }], }; }); /* * The readout sits beside the crosshair rather than over it, on whichever * side has room, so it never hides the column it is describing. */ const readoutStyle = useAnimatedStyle(() => { const index = activeIndex.value; if (index < 0) return { opacity: 0 }; const x = xOf(index, total, plot); const gap = 10; const right = x + gap; const fits = right + READOUT_WIDTH <= plot.left + plot.width; const at = fits ? right : Math.max(plot.left, x - gap - READOUT_WIDTH); return { opacity: 1, transform: [{ translateX: at }] }; }); const active = activeIndexJS >= 0 ? data[activeIndexJS] : null; const standings = useMemo(() => { if (activeIndexJS < 0) return []; return series .map((item) => ({ item, rank: ranks[item.key]?.[activeIndexJS] ?? null })) .filter((entry): entry is { item: BumpSeries; rank: number } => entry.rank !== null) .sort((a, b) => a.rank - b.rank); }, [series, ranks, activeIndexJS]); const fmtRank = formatRank ?? ((rank: number) => `#${rank}`); const fmtX = formatX ?? ((datum: BumpChartDatum) => String(datum[xDataKey] ?? '')); if (status === 'loading') return null; return ( {/* Stops short of the names, so a tap there picks a line rather than starting a scrub. */} {active ? ( {fmtX(active)} {standings.map(({ item, rank }) => ( {fmtRank(rank)} {/* The colour the line is drawn in, so a muted line has a muted swatch rather than its unused series colour. */} {item.label} ))} ) : null} ); } BumpChartTooltip.displayName = 'BumpChart.Tooltip'; BumpChartTooltip.layer = 'overlay' as Layer; export interface BumpChartLegendProps extends ViewProps { className?: string; } /** A swatch and a name per series, in declaration order. */ function BumpChartLegend({ className, ...props }: BumpChartLegendProps) { const { series } = useChart('BumpChart.Legend'); if (!series.length) return null; return ( {series.map((item) => ( ))} ); } BumpChartLegend.displayName = 'BumpChart.Legend'; BumpChartLegend.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 BumpChartHeaderProps 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; /** Draw a swatch and a name per series along the trailing edge. */ 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 and what it currently reads. * The value is not derived here — pass the formatted string, from * `onActiveIndexChange` or `onHighlightChange` if it follows the chart. */ function BumpChartHeader({ className, title, value, caption, legend = false, children, ...props }: BumpChartHeaderProps) { const { series } = useChart('BumpChart.Header'); const trailing = children ?? (legend && series.length ? ( {series.map((item) => ( ))} ) : null); return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {trailing ? {trailing} : null} ); } BumpChartHeader.displayName = 'BumpChart.Header'; BumpChartHeader.layer = 'header' as Layer; export const BumpChart = Object.assign(BumpChartRoot, { Header: BumpChartHeader, Grid: BumpChartGrid, Line: BumpChartLine, Skeleton: BumpChartSkeleton, XAxis: BumpChartXAxis, YAxis: BumpChartYAxis, Labels: BumpChartLabels, Tooltip: BumpChartTooltip, Legend: BumpChartLegend, });