/** * BubbleChart — one labelled circle per row, on two measured axes, with a third * quantity on each circle's area. * * ```tsx * * * * * * * * * ``` * * ## When this and not a scatter plot * * `ScatterChart` also maps a third quantity onto point area, through `sizeKey`, * and it is the right component for a *series* of observations: many points of * one colour, where the shape of the cloud is the finding and no single dot * needs a name. * * This one is for a handful of named things. Each row is its own circle with * its own colour and its own label written inside it, so the chart can be read * entity by entity rather than as a distribution. Eight teams, twelve products, * six regions — where the reader wants to find one of them and see where it * sits. * * ## Area, not radius * * `sizeKey` maps to a circle's area. Doubling a radius quadruples the ink, so a * chart that scaled the radius would show a doubled value as four times the * size, and the reader would believe the picture. The scale runs over the whole * data set, so one bubble's size means the same thing as another's. * * ## Labels are text, not SVG * * The names inside the bubbles are React Native `Text` in a layer over the * plot, so they follow the theme's font and the platform's text scaling — SVG * text does neither. A bubble too small to hold its own label is left without * one rather than given an unreadable one; the readout still names it. */ 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, runOnJS, useAnimatedProps, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff'; import { ChartAccessibilityData, type ChartAccessibilityProps, } from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { bubbleRadius, compactNumber, niceDomain, useSeriesColor, xAt, yOf, type Plot, } from '../../utils/chart'; import { cn } from '../../utils/cn'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedG = Animated.createAnimatedComponent(G); const AnimatedLine = Animated.createAnimatedComponent(SvgLine); /** * How much of the reveal is spent handing out the bubbles' start times. The * rest is the window each one gets, so the whole field still lands inside the * one duration however many there are. */ const STAGGER = 0.4; /** Milliseconds for a bubble to swell as it is selected, and settle as it is not. */ const SELECT_DURATION = 140; /** * A bubble arriving: up past its size and back to it. * * A circle that simply grows to its radius reads as the chart still loading * right up to the last frame. The small overshoot is what makes it read as * landing. */ function landing(t: number): number { 'worklet'; const back = 1.3; const u = t - 1; return 1 + (back + 1) * u * u * u + back * u * u; } /** Room left around the plot for the axis labels and the outermost bubbles. */ const PADDING = { top: 18, right: 18, bottom: 22, left: 18 }; /** 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; /** Box each axis label is centred in, so a long number is ellipsised not shoved. */ const AXIS_LABEL_WIDTH = 56; /** Width of the readout that floats by the selected bubble. */ const LABEL_WIDTH = 132; /** Gap between the readout and the edge of the bubble it describes. */ const LABEL_GAP = 10; /** Line box a bubble's own label is laid out in. */ const BUBBLE_LABEL_HEIGHT = 16; /** * Smallest radius a bubble may have and still be given its label. Below this * the name is wider than the circle and reads as text lying on the plot rather * than as the bubble's own. * * Ten points, which is a twenty-point circle: enough for the initial or the * short code a bubble chart's labels usually are. Set higher and the smallest * bubble in an ordinary set silently loses its name, which reads as a bug * rather than as a decision. */ const LABEL_MIN_RADIUS = 10; /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */ const HIT_RADIUS = 22; /** How many colours the ramp cycles through. */ const PALETTE_SIZE = 5; /** Steps each axis is rounded out to. Matches the labels an axis draws. */ const AXIS_STEPS = 4; /** Room under the numbers for an axis's own name, when it is given one. */ const AXIS_TITLE_HEIGHT = 18; /** Gap between a quadrant's caption and the corner it is written in. */ const QUADRANT_LABEL_INSET = 6; /** * How strongly the overlays draw: the quadrant crosshair, and the size key's * rings. * * Both are furniture rather than data, and both were faint enough to be missed * — the crosshair at 0.4 and the rings at 0.6, on a token that is already a * mid-grey chosen to stay behind the circles. The restraint has to come from * the token, not from a second reduction on top of it, or the mark is one * nobody finds. `Trend` has drawn at 0.7 all along and reads correctly in both * themes, so that is the number. */ const CROSSHAIR_OPACITY = 0.7; const KEY_RING_OPACITY = 0.85; /** Column the size key's values are written in, beside its circles. */ const SIZE_KEY_LABEL_WIDTH = 44; /** Gap between the size key's circles and the values naming them. */ const SIZE_KEY_GAP = 6; /** Room beside the numbers for the y axis's name, which is turned on its side. */ const AXIS_TITLE_WIDTH = 18; type Layer = 'svg' | 'overlay' | 'header' | 'footer'; export type BubbleChartStatus = 'loading' | 'ready'; export type BubbleChartDatum = Record; /** One bubble, resolved back to the row it came from. */ export interface BubbleChartPoint { /** Index into `data`. */ index: number; x: number; y: number; /** The value behind the area, when `sizeKey` is set. */ size: number | null; /** The name written inside the circle, when `labelKey` is set. */ label: string; /** The colour it was drawn in. */ color: string; datum: BubbleChartDatum; } /** A bubble with its geometry resolved. Shared by every part. */ interface ResolvedBubble extends BubbleChartPoint { /** Radius in points, off the area scale. */ r: number; } interface BubbleChartContextValue { data: BubbleChartDatum[]; xDataKey: string; yDataKey: string; labelKey: string | undefined; plot: Plot; status: BubbleChartStatus; bubbles: ResolvedBubble[]; xMin: SharedValue; xMax: SharedValue; yMin: SharedValue; yMax: SharedValue; /** The settled domains, for the parts that draw text rather than geometry. */ xExtent: [number, number]; yExtent: [number, number]; /** Lowest and highest value behind the areas, or null without a `sizeKey`. */ sizeExtent: [number, number] | null; /** The radii those two values map onto. */ sizeRange: [number, number]; /** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */ reveal: SharedValue; activeIndex: SharedValue; activeIndexJS: number; setActivePoint: (point: BubbleChartPoint | null) => void; } const BubbleChartContext = createContext(null); function useChart(component: string): BubbleChartContextValue { const context = useContext(BubbleChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * The bubble under the finger, for something rendered *inside* the chart. A * readout in the card's header is outside this provider — use * `onActivePointChange` for that. */ export function useBubbleChart() { const { bubbles, activeIndexJS } = useChart('useBubbleChart'); const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null; return { activeIndex: activeIndexJS, activePoint: active }; } /** * An extent widened out to round numbers, and a usable one for the degenerate * cases — no data at all, or every reading identical. A domain of zero width * divides by zero and puts every bubble on the same edge. * * Rounded rather than padded by a fraction: a fraction of the data's own span * ends the axis at 52.7, which is true and which nobody was looking for. Two * steps, matching the labels each axis draws by default, so the middle one is * round as well as the ends. */ function padExtent(min: number, max: number): [number, number] { if (min === Infinity) return [0, 1]; if (min === max) return [min - 1, max + 1]; return niceDomain(min, max, AXIS_STEPS); } export interface BubbleChartProps extends ViewProps, ChartAccessibilityProps { className?: string; /** The rows. One bubble each. */ data: BubbleChartDatum[]; /** Key holding the horizontal value. */ xDataKey?: string; /** Key holding the vertical value. */ yDataKey?: string; /** * Key holding the third quantity, mapped to each bubble's *area*. Without it * every bubble is drawn at the middle of `sizeRange` and the chart is a * scatter plot with names on it. */ sizeKey?: string; /** Key holding the name written inside the circle. */ labelKey?: string; /** * Key holding a colour for the row — either a CSS colour or a number from 1 * to 5 naming a `--color-chart-*` token. Without it the ramp cycles by row. */ colorKey?: string; /** * Smallest and largest radius `sizeKey` maps onto, in points. The largest is * also what the plot holds back at every edge, so raising it costs room. */ sizeRange?: [number, number]; /** * `loading` shows a still field of muted circles and dissolves it as the real * bubbles grow in. One component throughout, rather than a spinner swapped * for a chart — swapping loses the transition. Add a `BubbleChart.Skeleton` * for something to stand in the plot meanwhile. */ status?: BubbleChartStatus; /** Width ÷ height. `1` is the square shape a bubble field reads best in. */ aspectRatio?: number; /** Milliseconds for the bubbles to grow in on mount. */ animationDuration?: number; /** Milliseconds for the axes to settle after the data changes. */ domainDuration?: number; /** Fix the horizontal axis instead of deriving it. */ xDomain?: [number, number]; /** Fix the vertical axis instead of deriving it. */ yDomain?: [number, number]; /** The bubble under the finger, and `null` when it lifts. */ onActivePointChange?: (point: BubbleChartPoint | null) => void; children?: ReactNode; } /** Imperative handle: re-run the grow-in, for a "replay" control. */ export interface BubbleChartHandle { replay: () => void; } function partition(children: ReactNode) { const svg: ReactNode[] = []; const overlay: ReactNode[] = []; const header: ReactNode[] = []; const footer: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const layer = (child.type as { layer?: Layer }).layer ?? 'svg'; const slot = {child}; const into = layer === 'header' ? header : layer === 'footer' ? footer : layer === 'overlay' ? overlay : svg; into.push(slot); }); return { svg, overlay, header, footer }; } function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } const BubbleChartRoot = forwardRef( function BubbleChartRoot( { className, data, xDataKey = 'x', yDataKey = 'y', sizeKey, labelKey, colorKey, sizeRange = [10, 28], status = 'ready', aspectRatio = 1, animationDuration = 800, domainDuration = 500, xDomain, yDomain, onActivePointChange, accessible, accessibilityLabel, accessibilityHint, accessibilityLabelForDatum, onAccessibilityDatumPress, children, ...props }, ref ) { const [size, setSize] = useState({ width: 0, height: 0 }); const [activeIndexJS, setActiveIndexJS] = useState(-1); const reveal = useSharedValue(0); const xMin = useSharedValue(0); const xMax = useSharedValue(0); const yMin = useSharedValue(0); const yMax = useSharedValue(0); const activeIndex = useSharedValue(-1); const reducedMotion = useReducedMotion(); /* * The whole ramp, resolved once. Every bubble is its own category here * rather than a member of a series, so the colour is chosen by row index * and there is nothing to register. */ const chart1 = useSeriesColor(undefined, 1); const chart2 = useSeriesColor(undefined, 2); const chart3 = useSeriesColor(undefined, 3); const chart4 = useSeriesColor(undefined, 4); const chart5 = useSeriesColor(undefined, 5); const palette = useMemo( () => [chart1, chart2, chart3, chart4, chart5], [chart1, chart2, chart3, chart4, chart5] ); /* * What the axes are going to need before anything has been laid out. The * gutter for the y labels and the strip under the x ones are the plot's * padding, so they have to be known here rather than by the parts that draw * them — a part that reserved its own room would be positioned against a * plot that had already been sized without it. */ const axes = useMemo(() => { let y = false; let yTitle = false; let xTitle = false; Children.forEach(children, (child) => { if (!isValidElement(child)) return; const axis = (child.type as { axis?: string }).axis; const labelled = Boolean((child.props as { label?: string }).label); if (axis === 'y') { y = true; if (labelled) yTitle = true; } if (axis === 'x' && labelled) xTitle = true; }); return { y, yTitle, xTitle }; }, [children]); /* * A circle is drawn about its centre, so every edge of the plot has to hold * back the largest radius or the outermost bubble is cropped by it — and * the largest bubble is the one carrying the largest value, which is the * last one that should be half missing. `sizeRange` is the ceiling, so this * is known before anything is measured. */ const reach = sizeRange[1]; const pad = { top: Math.max(PADDING.top, reach), right: Math.max(PADDING.right, reach), bottom: Math.max(PADDING.bottom, reach) + (axes.xTitle ? AXIS_TITLE_HEIGHT : 0), left: Math.max( axes.y ? Y_AXIS_WIDTH + (axes.yTitle ? AXIS_TITLE_WIDTH : 0) : PADDING.left, reach ), }; 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 extents = useMemo(() => { let lowX = Infinity; let highX = -Infinity; let lowY = Infinity; let highY = -Infinity; for (const row of data) { const x = row[xDataKey]; const y = row[yDataKey]; // A row missing either coordinate is not a bubble at all, and must not // stretch the axes towards an origin it never had. if (typeof x !== 'number' || Number.isNaN(x)) continue; if (typeof y !== 'number' || Number.isNaN(y)) continue; if (x < lowX) lowX = x; if (x > highX) highX = x; if (y < lowY) lowY = y; if (y > highY) highY = y; } return { x: xDomain ?? padExtent(lowX, highX), y: yDomain ?? padExtent(lowY, highY), }; }, [data, xDataKey, yDataKey, xDomain, yDomain]); /* * The size scale runs over the whole data set, so one bubble's area means * the same thing as another's. Without a `sizeKey` there is nothing to * scale and every bubble takes the middle of the range — which is a * scatter plot with names on it, and an honest one. */ const sizeExtent = useMemo<[number, number] | null>(() => { if (!sizeKey) return null; let min = Infinity; let max = -Infinity; for (const row of data) { const value = row[sizeKey]; if (typeof value !== 'number' || Number.isNaN(value)) continue; if (value < min) min = value; if (value > max) max = value; } return min === Infinity ? null : [min, max]; }, [data, sizeKey]); /* * Resolved once, in the root, because four parts need exactly this list and * three of them would otherwise derive it again: the circles, the labels * over them, the hit test under them and the legend beside them all have to * agree about where a bubble is and how big it is. */ const bubbles = useMemo(() => { const middle = (sizeRange[0] + sizeRange[1]) / 2; const out: ResolvedBubble[] = []; data.forEach((datum, index) => { const x = datum[xDataKey]; const y = datum[yDataKey]; if (typeof x !== 'number' || Number.isNaN(x)) return; if (typeof y !== 'number' || Number.isNaN(y)) return; const raw = sizeKey ? datum[sizeKey] : undefined; const value = typeof raw === 'number' && !Number.isNaN(raw) ? raw : null; const r = sizeExtent && value !== null ? bubbleRadius(value, sizeExtent, sizeRange) : middle; const explicit = colorKey ? datum[colorKey] : undefined; const color = typeof explicit === 'string' ? explicit : typeof explicit === 'number' ? (palette[(Math.round(explicit) - 1 + PALETTE_SIZE) % PALETTE_SIZE] ?? palette[0]!) : (palette[index % PALETTE_SIZE] ?? palette[0]!); out.push({ index, x, y, r, size: value, label: labelKey ? String(datum[labelKey] ?? '') : '', color, datum, }); }); return out; }, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, sizeExtent, sizeRange, palette]); const loading = status === 'loading'; useEffect(() => { if (loading) return; const [x0, x1] = extents.x; const [y0, y1] = extents.y; // The first domain lands without a tween: there is no previous scale to // move from, and animating up from zero reads as the numbers changing. const first = xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0; if (first || reducedMotion) { xMin.value = x0; xMax.value = x1; yMin.value = y0; yMax.value = y1; return; } xMin.value = withTiming(x0, { duration: domainDuration }); xMax.value = withTiming(x1, { duration: domainDuration }); yMin.value = withTiming(y0, { duration: domainDuration }); yMax.value = withTiming(y1, { duration: domainDuration }); }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]); const revealed = useRef(false); const playReveal = useMemo( () => () => { if (reducedMotion) { reveal.value = 1; return; } reveal.value = 0; /* * Eased out rather than in and out. Each bubble is given a slice of * this one clock, so an ease that dawdles at the start spends it on the * first few and leaves the rest to arrive in a rush. */ reveal.value = withTiming(1, { duration: animationDuration, easing: Easing.out(Easing.cubic), }); }, [reducedMotion, animationDuration, reveal] ); useEffect(() => { if (loading) { revealed.current = false; reveal.value = 0; return; } if (revealed.current || plot.width <= 0 || !bubbles.length) return; revealed.current = true; playReveal(); }, [loading, plot.width, bubbles.length, playReveal, reveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); // One place the selection lands, so the chart's own children and a readout // outside it never disagree about which bubble is active. const setActivePoint = useMemo( () => (point: BubbleChartPoint | null) => { setActiveIndexJS(point ? point.index : -1); onActivePointChange?.(point); }, [onActivePointChange] ); 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, yDataKey, labelKey, plot, status, bubbles, xMin, xMax, yMin, yMax, xExtent: extents.x, yExtent: extents.y, sizeExtent, sizeRange, reveal, activeIndex, activeIndexJS, setActivePoint, }), // `plot` is rebuilt every render from `size`, so it is compared by value. // eslint-disable-next-line react-hooks/exhaustive-deps [ data, xDataKey, yDataKey, labelKey, plot.width, plot.height, plot.left, plot.top, status, bubbles, xMin, xMax, yMin, yMax, extents, sizeExtent, sizeRange, reveal, activeIndex, activeIndexJS, setActivePoint, ] ); const { svg, overlay, header, footer } = 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} { const pairs: [string, unknown][] = [ [xDataKey, datum[xDataKey]], [yDataKey, datum[yDataKey]], ]; if (labelKey) pairs.unshift([labelKey, datum[labelKey]]); if (sizeKey) pairs.push([sizeKey, datum[sizeKey]]); return pairs; }} /> {plot.width > 0 ? ( <> {svg} {overlay} ) : null} {footer} ); } ); BubbleChartRoot.displayName = 'BubbleChart'; /* -------------------------------------------------------------------------- */ /* SVG layer */ /* -------------------------------------------------------------------------- */ export interface BubbleChartGridProps { /** * Horizontal rules across the plot. * * Eight, which is twice the four intervals an axis is divided into by * default, so every second line carries a number and the ones between it are * halves of a labelled step rather than an unrelated rhythm. Squares this * size recede behind the circles; the coarse grid a smaller number draws * reads as blocks laid over the plot. */ rows?: number; /** Vertical rules up it. Both axes are measured, so both earn lines. */ columns?: number; /** Dash pattern for the rules. Pass `undefined` for solid ones. */ dashArray?: string; color?: string; opacity?: number; } /** Reference lines both ways, so a bubble can be placed against two numbers. */ function BubbleChartGrid({ rows = 8, columns = 8, dashArray = '4,6', color, opacity = 1, }: BubbleChartGridProps) { const { plot } = useChart('BubbleChart.Grid'); const token = useCSSVariable('--color-border'); // The fallback is grey rather than black: it stands in when the theme cannot // be read, and a black hairline is invisible on a dark background. const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows); const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns); return ( {horizontals.map((fraction) => ( ))} {verticals.map((fraction) => ( ))} ); } BubbleChartGrid.displayName = 'BubbleChart.Grid'; BubbleChartGrid.layer = 'svg' as Layer; export interface BubbleChartTrendProps { /** * The line's slope and intercept, and how tightly the cloud sits on it, once * they have been computed. `r` runs 0 to 1: 1 is every bubble on the line, * 0 is a cloud with no direction at all. * * Given here rather than left for the caller to work out, because the fit is * already being computed to draw the line and doing it twice invites the two * answers to disagree. * * It fires when the numbers change, not on every render that produced the * same ones, so putting the fit straight into state is safe. */ onFit?: (fit: { slope: number; intercept: number; r: number }) => void; color?: string; strokeWidth?: number; /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */ dashArray?: string; opacity?: number; } /** * The straight line that fits the cloud best, drawn across the plot. * * It is dashed and drawn under the circles, because it is not data — it is a * summary of the data, and a solid rule through the middle of a field of * bubbles reads as a value somebody plotted. * * The fit is least squares on the raw values, so it moves with the data rather * than with the frame: resizing the chart never changes the line's meaning. * Fewer than two bubbles, or every bubble on one vertical, has no line to draw * and none is drawn. */ function BubbleChartTrend({ onFit, color, strokeWidth = 1.5, dashArray = '6,5', opacity = 0.7, }: BubbleChartTrendProps) { const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } = useChart('BubbleChart.Trend'); const token = useCSSVariable('--color-muted-foreground'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)'); const fit = useMemo(() => { const n = bubbles.length; if (n < 2) return null; let sumX = 0; let sumY = 0; for (const bubble of bubbles) { sumX += bubble.x; sumY += bubble.y; } const meanX = sumX / n; const meanY = sumY / n; let sxy = 0; let sxx = 0; let syy = 0; for (const bubble of bubbles) { const dx = bubble.x - meanX; const dy = bubble.y - meanY; sxy += dx * dy; sxx += dx * dx; syy += dy * dy; } // Every bubble on one vertical: the best fit is that vertical, which has no // slope and nothing useful to draw. if (sxx === 0) return null; const slope = sxy / sxx; const denominator = Math.sqrt(sxx * syy); return { slope, intercept: meanY - slope * meanX, r: denominator === 0 ? 0 : Math.abs(sxy / denominator), }; }, [bubbles]); const fitRef = useRef(onFit); useEffect(() => { fitRef.current = onFit; }); /* * Reported when the numbers change, not when the object does. * * `bubbles` is rebuilt whenever any of its inputs changes identity, and * `sizeRange={[14, 30]}` written at a call site is a new array every render — * so the fit is a new object every render even when the data has not moved. * Handing that to a caller who puts it in state is a render loop, and the * caller has no way to see that coming. */ const reported = useRef<{ slope: number; intercept: number; r: number } | null>(null); useEffect(() => { if (!fit) return; const last = reported.current; if ( last && last.slope === fit.slope && last.intercept === fit.intercept && last.r === fit.r ) { return; } reported.current = fit; fitRef.current?.(fit); }, [fit]); const slope = fit?.slope ?? 0; const intercept = fit?.intercept ?? 0; const animatedProps = useAnimatedProps(() => { const x0 = xMin.value; const x1 = xMax.value; const lowY = yMin.value; const highY = yMax.value; /* * Solved in data space and then clipped there, rather than drawn across the * plot and clipped by the frame: a line that leaves the top of the chart * has to stop where it leaves it, and the x of that point is only knowable * from the equation. */ let ax = x0; let bx = x1; if (slope !== 0) { const atLow = (lowY - intercept) / slope; const atHigh = (highY - intercept) / slope; const enter = Math.min(atLow, atHigh); const exit = Math.max(atLow, atHigh); ax = Math.max(ax, enter); bx = Math.min(bx, exit); } if (bx < ax) { // The line never crosses the visible box. return { x1: 0, x2: 0, y1: 0, y2: 0, opacity: 0 }; } // Drawn out from the middle as the bubbles land, so the line arrives with // the field rather than being there waiting for it. const grown = Math.max(0, Math.min(1, reveal.value)); const midpoint = (ax + bx) / 2; const half = ((bx - ax) / 2) * grown; const startX = midpoint - half; const endX = midpoint + half; return { x1: xAt(startX, plot, x0, x1), x2: xAt(endX, plot, x0, x1), y1: yOf(intercept + slope * startX, plot, lowY, highY), y2: yOf(intercept + slope * endX, plot, lowY, highY), opacity: opacity * grown, }; }); if (status === 'loading' || !fit) return null; return ( ); } BubbleChartTrend.displayName = 'BubbleChart.Trend'; BubbleChartTrend.layer = 'svg' as Layer; export interface BubbleChartBubblesProps { /** * Fill opacity. Below 1 by default so that overlapping bubbles read as * denser rather than hiding each other — in a crowded corner that overlap * *is* the finding, and opaque circles erase it. */ opacity?: number; /** One colour for every bubble, overriding the per-row ramp. */ color?: string; } /** The circles. */ function BubbleChartBubbles({ opacity = 0.9, color }: BubbleChartBubblesProps) { const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal, activeIndex } = useChart('BubbleChart.Bubbles'); if (status === 'loading') return null; return ( {bubbles.map((bubble, order) => ( ))} ); } BubbleChartBubbles.displayName = 'BubbleChart.Bubbles'; BubbleChartBubbles.layer = 'svg' as Layer; /** * One bubble. Its position follows both domain tweens, so a data change moves * the whole field to the new scale rather than cutting to it. * * It arrives by growing in place, on its own slice of the shared reveal. A wipe * across the plot — which is what the line and area charts do — gives the * reader a direction to read the arrival in, and a field of bubbles has none: * position is the whole message, so a bubble may only ever appear where it * belongs. */ function Bubble({ bubble, plot, xMin, xMax, yMin, yMax, fill, opacity, activeIndex, reveal, order, total, }: { bubble: ResolvedBubble; plot: Plot; xMin: SharedValue; xMax: SharedValue; yMin: SharedValue; yMax: SharedValue; fill: string; opacity: number; activeIndex: SharedValue; reveal: SharedValue; order: number; total: number; }) { const { index, x, y, r } = bubble; /* * The selection, as something that moves. A hard switch made the bubble it * named jump between one frame and the next while every neighbour stayed put, * which reads as a glitch rather than as a response to the finger. */ const selected = useDerivedValue(() => withTiming(activeIndex.value === index ? 1 : 0, { duration: SELECT_DURATION }) ); // Where in the reveal this bubble starts. Spread over `STAGGER`, so the field // settles as a field rather than switching on all at once. const start = total > 1 ? (order / total) * STAGGER : 0; const animatedProps = useAnimatedProps(() => { const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER))); // The selected bubble swells and goes solid. Both, rather than one: a size // change alone is easy to miss among neighbours, and an opacity change // alone is invisible wherever the circles already overlap. const swell = 1 + 0.12 * selected.value; return { cx: xAt(x, plot, xMin.value, xMax.value), cy: yOf(y, plot, yMin.value, yMax.value), r: r * landing(arrived) * swell, // Ahead of the size, so a bubble is legible by the time it stops moving // rather than fading in for the whole of its arrival. fillOpacity: Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value), }; }); return ; } export interface BubbleChartSkeletonProps { /** How many placeholder circles to scatter. */ count?: number; color?: string; } /** * The loading state: a still field of muted circles where the data will be. * * Deliberately still. A shimmer over a field of circles reads as them *moving*, * which is the one thing this chart must never appear to do — position is the * entire message, and a loading state that implies it is changing is a loading * state that lies. * * The layout is deterministic rather than random, so it does not reshuffle on * every render of a component that may re-render several times while waiting. * It dissolves as the real bubbles grow in, and outlives the status change by * exactly that long: cut at the frame the data lands, the placeholder would * disappear before anything had replaced it. */ function BubbleChartSkeleton({ count = 7, color }: BubbleChartSkeletonProps) { const { plot, status } = useChart('BubbleChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading'); const circles = useMemo( () => // Two irrational-ish strides that do not share a factor, so the circles // spread instead of falling into a lattice. Array.from({ length: count }, (_unused, index) => ({ key: index, fx: ((index * 0.618) % 1) * 0.84 + 0.08, fy: ((index * 0.379) % 1) * 0.84 + 0.08, r: 16 + ((index * 0.472) % 1) * 12, })), [count] ); const animatedProps = useAnimatedProps(() => ({ opacity: fade.value })); if (!mounted) return null; return ( {circles.map((circle) => ( ))} ); } BubbleChartSkeleton.displayName = 'BubbleChart.Skeleton'; BubbleChartSkeleton.layer = 'svg' as Layer; /* -------------------------------------------------------------------------- */ /* Overlay layer */ /* -------------------------------------------------------------------------- */ export interface BubbleChartLabelsProps { /** * Smallest radius a bubble may have and still be given its label. Below it * the name is wider than the circle it names. */ minRadius?: number; /** Turn a bubble into its label. Defaults to the value at `labelKey`. */ format?: (point: BubbleChartPoint) => string; className?: string; } /** * The names, written inside the circles. * * Real text over the plot rather than SVG text, so they follow the theme's font * and the platform's text scaling. Each one rides the same domain tweens the * circle under it does, so a label never lags the bubble it belongs to. * * A bubble too small to hold its name is left without one. Shrinking the text * to fit would make it unreadable on exactly the bubbles the reader is * squinting at already; the readout names those instead. */ function BubbleChartLabels({ minRadius = LABEL_MIN_RADIUS, format, className, }: BubbleChartLabelsProps) { const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } = useChart('BubbleChart.Labels'); if (status === 'loading') return null; return ( {bubbles .filter((bubble) => bubble.r >= minRadius && (format || bubble.label)) .map((bubble, order) => ( ))} ); } BubbleChartLabels.displayName = 'BubbleChart.Labels'; BubbleChartLabels.layer = 'overlay' as Layer; export interface BubbleChartQuadrantsProps { /** Where the vertical rule stands. Defaults to the mean of the x values. */ x?: number; /** Where the horizontal rule lies. Defaults to the mean of the y values. */ y?: number; /** A word for each corner, written in the corner it belongs to. */ labels?: { topLeft?: string; topRight?: string; bottomLeft?: string; bottomRight?: string; }; /** Tint the high-high and low-low corners. On by default. */ tint?: boolean; color?: string; className?: string; } /** * A crosshair splitting the plot into four, with a name for each corner. * * A field of bubbles is usually read as four groups rather than as a cloud — * which of these is doing well on both counts, which on neither — and without * a divider the reader draws that line by eye, in a different place each time. * Putting it on the chart makes it one line everybody sees. * * It stands at the mean of each axis by default, because that is the split the * data itself argues for. Pass `x` and `y` for a target, a budget or last * year's number — a threshold somebody decided rather than one the data * produced. * * The tint marks the two corners a reading usually ends at. Turn it off where * all four corners matter equally. */ function BubbleChartQuadrants({ x, y, labels, tint = true, color, className, }: BubbleChartQuadrantsProps) { const { bubbles, plot, status, xMin, xMax, yMin, yMax } = useChart('BubbleChart.Quadrants'); const token = useCSSVariable('--color-muted-foreground'); const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)'); const centre = useMemo(() => { if (!bubbles.length) return null; let sumX = 0; let sumY = 0; for (const bubble of bubbles) { sumX += bubble.x; sumY += bubble.y; } return { x: x ?? sumX / bubbles.length, y: y ?? sumY / bubbles.length }; }, [bubbles, x, y]); const atX = centre?.x ?? 0; const atY = centre?.y ?? 0; const verticalStyle = useAnimatedStyle(() => ({ left: xAt(atX, plot, xMin.value, xMax.value), })); const horizontalStyle = useAnimatedStyle(() => ({ top: yOf(atY, plot, yMin.value, yMax.value), })); /* * Two rectangles rather than four: the pair that is tinted is the pair the * reader is being pointed at, and shading all four would only be a checked * background. */ const highStyle = useAnimatedStyle(() => { const cx = xAt(atX, plot, xMin.value, xMax.value); const cy = yOf(atY, plot, yMin.value, yMax.value); return { left: cx, top: plot.top, width: Math.max(plot.left + plot.width - cx, 0), height: Math.max(cy - plot.top, 0), }; }); const lowStyle = useAnimatedStyle(() => { const cx = xAt(atX, plot, xMin.value, xMax.value); const cy = yOf(atY, plot, yMin.value, yMax.value); return { left: plot.left, top: cy, width: Math.max(cx - plot.left, 0), height: Math.max(plot.top + plot.height - cy, 0), }; }); if (status === 'loading' || !centre) return null; const corner = { position: 'absolute' as const, width: plot.width / 2 - QUADRANT_LABEL_INSET, }; return ( {tint ? ( <> ) : null} {/* The same weight `Trend` draws at, because it is the same kind of mark: a reference laid over the field rather than a reading taken from it. At 0.4 of a mid-grey the split was a line you had to already know was there, which leaves four labelled corners and nothing dividing them. */} {/* Pinned to the plot's corners rather than to the crosshair. A caption names the region, and a region's name belongs at the far end of it — following the rules it would crowd them as the split moved. */} {labels?.topLeft ? ( {labels.topLeft} ) : null} {labels?.topRight ? ( {labels.topRight} ) : null} {labels?.bottomLeft ? ( {labels.bottomLeft} ) : null} {labels?.bottomRight ? ( {labels.bottomRight} ) : null} ); } BubbleChartQuadrants.displayName = 'BubbleChart.Quadrants'; BubbleChartQuadrants.layer = 'overlay' as Layer; export interface BubbleChartSizeKeyProps { /** Which corner of the plot it sits in. */ placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; /** Turn a value into its label. Defaults to a compact number. */ format?: (value: number) => string; /** A word for what the area means — "people", "revenue". */ label?: string; className?: string; } /** * Three nested circles saying what a bubble's area is worth. * * A bubble chart's third quantity is the one it cannot state: position can be * read off the axes, but area has no axis, so a reader can see that one circle * is bigger than another and has no way to know by how much. This is the only * part of the chart that answers that. * * Nested and sharing a baseline, which is how a difference in area is compared * — three circles in a row are three sizes, three circles inside one another * are one scale. * * It needs a `sizeKey` on the chart. Without one every bubble is the same size * and there is no scale to key. */ function BubbleChartSizeKey({ placement = 'bottom-right', format, label, className, }: BubbleChartSizeKeyProps) { const { plot, status, sizeExtent, sizeRange } = useChart('BubbleChart.SizeKey'); const token = useCSSVariable('--color-muted-foreground'); const stroke = typeof token === 'string' ? token : 'rgba(128,128,128,0.8)'; const steps = useMemo(() => { if (!sizeExtent) return null; const [min, max] = sizeExtent; const middle = (min + max) / 2; // Largest first, so the smallest is drawn last and stays visible inside it. return [max, middle, min].map((value) => ({ value, r: bubbleRadius(value, sizeExtent, sizeRange), })); }, [sizeExtent, sizeRange]); if (status === 'loading' || !steps) return null; const outer = steps[0]!.r; const width = outer * 2 + SIZE_KEY_LABEL_WIDTH; const height = outer * 2 + (label ? AXIS_LABEL_HEIGHT : 0); const top = placement.startsWith('top') ? plot.top : plot.top + plot.height - height; const left = placement.endsWith('left') ? plot.left : plot.left + plot.width - width; return ( {label ? ( {label} ) : null} {steps.map((step) => ( ))} {steps.map((step) => ( {format ? format(step.value) : compactNumber(step.value)} ))} ); } BubbleChartSizeKey.displayName = 'BubbleChart.SizeKey'; BubbleChartSizeKey.layer = 'overlay' as Layer; function BubbleLabel({ bubble, text, plot, xMin, xMax, yMin, yMax, reveal, order, total, className, }: { bubble: ResolvedBubble; text: string; plot: Plot; xMin: SharedValue; xMax: SharedValue; yMin: SharedValue; yMax: SharedValue; reveal: SharedValue; order: number; total: number; className?: string; }) { const { x, y, r } = bubble; const width = r * 2; const start = total > 1 ? (order / total) * STAGGER : 0; const style = useAnimatedStyle(() => { const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER))); return { opacity: Math.max(0, arrived * 2 - 1), transform: [ { translateX: xAt(x, plot, xMin.value, xMax.value) - width / 2 }, { translateY: yOf(y, plot, yMin.value, yMax.value) - BUBBLE_LABEL_HEIGHT / 2 }, ], }; }); return ( {text} ); } export interface BubbleChartXAxisProps { /** * How many intervals to divide the axis into. Yields `ticks + 1` labels. * * Four, and the domain is rounded out to four steps to match, so the numbers * come out round. Fewer leaves most of the grid unnamed — a line with nothing * beside it is a line the reader has to count their way to. */ ticks?: number; /** Turn a value into its label. Defaults to a compact number. */ format?: (value: number) => string; /** What the axis measures, written under the numbers. */ label?: string; className?: string; } /** * The x labels, evenly along the axis. * * Evenly spaced, because this axis is a continuous scale rather than a list of * rows. There is no bubble for a label to sit under. */ function BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXAxisProps) { const { plot, xExtent } = useChart('BubbleChart.XAxis'); const labels = useMemo(() => { const [min, max] = xExtent; if (min === 0 && max === 0) return []; return Array.from({ length: ticks + 1 }, (_unused, index) => { const value = min + ((max - min) * index) / ticks; return { key: index, text: format ? format(value) : compactNumber(value) }; }); }, [xExtent, ticks, format]); return ( {label ? ( {label} ) : null} {labels.map((tick) => ( {tick.text} ))} ); } BubbleChartXAxis.displayName = 'BubbleChart.XAxis'; BubbleChartXAxis.layer = 'overlay' as Layer; // Read by the root, which has to leave room under the numbers before it lays // the plot out. BubbleChartXAxis.axis = 'x' as const; export interface BubbleChartYAxisProps { /** * How many intervals to divide the axis into. Yields `ticks + 1` labels. * * Four, matching the four steps the domain is rounded out to and every second * line of the default grid. */ ticks?: number; /** Turn a value into its label. Defaults to a compact number. */ format?: (value: number) => string; /** What the axis measures, written up the side of it. */ label?: string; className?: string; } /** * Value labels down the side, evenly over the axis, and the gutter they sit in. * * They land on every second line of the default grid rather than on all of * them: a number beside every line of a grid fine enough to read against is a * column of numbers, and the reader stops seeing the chart. */ function BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYAxisProps) { const { plot, yExtent } = useChart('BubbleChart.YAxis'); const labels = useMemo(() => { const [min, max] = yExtent; 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) }; }); }, [yExtent, ticks, format]); const titleWidth = label ? AXIS_TITLE_WIDTH : 0; return ( <> {label ? ( /* * Turned on its side, which is the only way a word fits in a gutter * sized for numbers. The box is laid out as tall as the plot and then * rotated about its own centre, so the text runs the length of the axis * it names rather than of whatever it happens to say. */ {label} ) : null} {labels.map((tick) => ( {tick.text} ))} ); } BubbleChartYAxis.displayName = 'BubbleChart.YAxis'; BubbleChartYAxis.layer = 'overlay' as Layer; // Read by the root, which has to leave room for the labels before it lays the // plot out. BubbleChartYAxis.axis = 'y' as const; export interface BubbleChartTooltipProps { /** Float a small readout beside the selected bubble. On by default. */ showLabel?: boolean; /** Format the x value for the readout. Defaults to a compact number. */ formatX?: (value: number) => string; /** Format the y value for the readout. Defaults to a compact number. */ formatY?: (value: number) => string; /** Format the size value for the readout. Defaults to a compact number. */ formatSize?: (value: number) => string; /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */ hitRadius?: number; className?: string; } /** * The touch target, the selection it drives, and the readout that follows it. * * A touch picks the nearest bubble whose own circle — or the `hitRadius` floor, * whichever is larger — reaches the finger. Nearest rather than topmost, * because where bubbles overlap the one drawn last is not the one being aimed * at. * * The search runs on the UI thread over flat arrays of already-projected * coordinates, and only the *index* of the winner crosses back into JS, and * only when it changes. A drag across the plot therefore costs a handful of * re-renders rather than one per frame. * * Distances are compared squared. The nearest bubble by distance is the nearest * by distance-squared, and a square root per bubble per frame buys nothing. */ function BubbleChartTooltip({ showLabel = true, formatX, formatY, formatSize, hitRadius = HIT_RADIUS, className, }: BubbleChartTooltipProps) { const { bubbles, plot, xExtent, yExtent, activeIndex, activeIndexJS, setActivePoint, status, } = useChart('BubbleChart.Tooltip'); /* * Every bubble, projected once, as parallel arrays. * * Parallel arrays rather than an array of objects because this is read inside * a worklet: Reanimated has to copy whatever the gesture captures across to * the UI thread, and four number arrays cross far more cheaply than a few * hundred small objects. * * Projected against the *settled* extents rather than the tweening shared * values. Hit-testing against a moving scale would mean rebuilding this on * every frame of a domain animation, and a bubble being half a second stale * during a transition is not something a finger can notice. */ const hit = useMemo(() => { const xs: number[] = []; const ys: number[] = []; const rs: number[] = []; const limits: number[] = []; const indices: number[] = []; for (const bubble of bubbles) { xs.push(xAt(bubble.x, plot, xExtent[0], xExtent[1])); ys.push(yOf(bubble.y, plot, yExtent[0], yExtent[1])); rs.push(bubble.r); const reach = Math.max(bubble.r, hitRadius); limits.push(reach * reach); indices.push(bubble.index); } return { xs, ys, rs, limits, indices }; }, [bubbles, plot, xExtent, yExtent, hitRadius]); const select = useMemo( () => (index: number) => { if (index < 0) { setActivePoint(null); return; } setActivePoint(bubbles.find((bubble) => bubble.index === index) ?? null); }, [bubbles, setActivePoint] ); /* * Built in one closure, and everything it captures is a plain array, a number * or a shared value. A worklet may only call another worklet, and the rule is * enforced by crashing rather than by warning — so the resolver is declared * here, next to its callers, rather than as a helper elsewhere in the file * where it would be easy to leave un-workletised. */ const pan = useMemo(() => { const xs = hit.xs; const ys = hit.ys; const limits = hit.limits; const indices = hit.indices; const resolve = (px: number, py: number) => { 'worklet'; let bestIndex = -1; let best = Infinity; for (let i = 0; i < xs.length; i += 1) { const dx = xs[i]! - px; const dy = ys[i]! - py; const distance = dx * dx + dy * dy; // Each bubble reaches as far as it is big, so a large one is not // beaten by a small one that happens to be marginally nearer. if (distance <= limits[i]! && distance < best) { best = distance; bestIndex = indices[i]!; } } if (bestIndex === activeIndex.value) return; activeIndex.value = bestIndex; runOnJS(select)(bestIndex); }; const clear = () => { 'worklet'; if (activeIndex.value === -1) return; activeIndex.value = -1; runOnJS(select)(-1); }; return Gesture.Pan() .minDistance(0) .onBegin((event) => { 'worklet'; resolve(event.x, event.y); }) .onUpdate((event) => { 'worklet'; resolve(event.x, event.y); }) .onFinalize(() => { 'worklet'; clear(); }); }, [hit, activeIndex, select]); /* * The readout's own height, measured rather than assumed. It decides whether * the readout fits above the bubble, and how tall it is depends on whether * the row has a label and a size — a constant would either overlap a * three-line readout or reserve room a one-line one never uses. */ const labelHeight = useSharedValue(0); /* * Above the bubble, clear of its edge rather than of its centre, and clamped * inside the plot. Lifted by a constant it landed *on* the larger circles — * which are exactly the ones a finger is most likely to be resting on, so the * readout was hidden under the hand that summoned it. * * Where there is no room above, it goes below instead. Sliding it down to the * top edge of the plot would leave it over the bubble again. */ const labelStyle = useAnimatedStyle(() => { const index = activeIndex.value; if (index < 0) return { opacity: 0 }; const at = hit.indices.indexOf(index); if (at < 0) return { opacity: 0 }; const x = hit.xs[at]!; const y = hit.ys[at]!; const r = hit.rs[at]!; const half = LABEL_WIDTH / 2; const tall = labelHeight.value; const above = y - r - LABEL_GAP - tall; const below = y + r + LABEL_GAP; const top = above >= plot.top ? above : below; return { opacity: 1, transform: [ { translateX: Math.min(plot.left + plot.width - half, Math.max(plot.left + half, x)) - half, }, { translateY: top }, ], }; }); const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null; const fmtX = formatX ?? compactNumber; const fmtY = formatY ?? compactNumber; const fmtSize = formatSize ?? compactNumber; if (status === 'loading') return null; return ( {showLabel ? ( {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 )} > {active.label ? ( {active.label} ) : null} {fmtX(active.x)}, {fmtY(active.y)} {active.size !== null ? ( {fmtSize(active.size)} ) : null} ) : null} ) : null} ); } BubbleChartTooltip.displayName = 'BubbleChart.Tooltip'; BubbleChartTooltip.layer = 'overlay' as Layer; export interface BubbleChartLegendProps extends ViewProps { className?: string; /** Cap on how many bubbles are named. The rest are left to the readout. */ limit?: number; } /** * A swatch and a name per bubble, for a chart whose circles are too small to * carry their own labels. * * Drawn **under** the plot rather than floating in a corner of it. A key that * overlays the drawing area competes with the bubbles for the space they are * plotted in, and on a square chart there is no corner that is reliably empty — * the position of a bubble is the data, so nowhere can be reserved for it. * * It lists rows rather than series, because in this chart a row *is* a * category. Use it instead of `BubbleChart.Labels`, not beside it — the same * names twice is the legend telling the reader what the plot already says. */ function BubbleChartLegend({ className, limit = 8, ...props }: BubbleChartLegendProps) { const { bubbles } = useChart('BubbleChart.Legend'); const shown = bubbles.filter((bubble) => bubble.label).slice(0, limit); if (!shown.length) return null; return ( {shown.map((bubble) => ( {bubble.label} ))} ); } BubbleChartLegend.displayName = 'BubbleChart.Legend'; BubbleChartLegend.layer = 'footer' as Layer; /* -------------------------------------------------------------------------- */ /* Header layer */ /* -------------------------------------------------------------------------- */ export interface BubbleChartHeaderProps 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 — what the area means, usually. */ caption?: string; /** Trailing slot — a control, a badge, a range picker. */ children?: ReactNode; } /** * The strip above the plot: what the chart is of, what it currently reads, and * what the size of a circle means. * * The caption earns its place here more than on most charts. Two axes and an * area is three quantities, and a reader who is not told what the area is has * no way to work it out from the picture. * * The value is not derived here. A readout that follows the finger belongs to * whoever owns the data — take it from `onActivePointChange` and pass the * formatted string down. */ function BubbleChartHeader({ className, title, value, caption, children, ...props }: BubbleChartHeaderProps) { return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {/* Shrinkable, unlike a view's default in React Native. Held rigid, a control takes the width it wants and the caption underneath the value wraps to two lines to make room for it. */} {children ? {children} : null} ); } BubbleChartHeader.displayName = 'BubbleChart.Header'; BubbleChartHeader.layer = 'header' as Layer; export const BubbleChart = Object.assign(BubbleChartRoot, { Header: BubbleChartHeader, Grid: BubbleChartGrid, Quadrants: BubbleChartQuadrants, Trend: BubbleChartTrend, Bubbles: BubbleChartBubbles, Labels: BubbleChartLabels, SizeKey: BubbleChartSizeKey, Skeleton: BubbleChartSkeleton, XAxis: BubbleChartXAxis, YAxis: BubbleChartYAxis, Tooltip: BubbleChartTooltip, Legend: BubbleChartLegend, });