diff --git a/apps/docs/content/docs/charts/heatmap-chart.mdx b/apps/docs/content/docs/charts/heatmap-chart.mdx index 970ae8383..01520b0b0 100644 --- a/apps/docs/content/docs/charts/heatmap-chart.mdx +++ b/apps/docs/content/docs/charts/heatmap-chart.mdx @@ -336,7 +336,7 @@ Rows that are hours and columns that are weekdays. “When is this busy” is a | Prop | Type | Default | Description | | --- | --- | --- | --- | | `className` | `string` | — | | -| `width` | `number` | `26` | Width reserved for the labels. The grid is sized around it. | +| `width` | `number` | — | Width reserved for the labels. The grid is sized around it. Defaults to 26, and widens with the system text size so a larger weekday does not wrap. | | `tickFilter` | `'all' \| 'odd' \| 'even'` | `'odd'` | Which rows get a label. Every other row is the usual choice. | | `labelFormat` | `'initial' \| 'full'` | `'full'` | `initial` is the single letter; `full` is the abbreviated name. | | `labels` | `string[]` | — | Row labels, top to bottom. Overrides the weekday names — for a grid whose rows are not days. | diff --git a/apps/docs/public/r/area-chart.json b/apps/docs/public/r/area-chart.json index 6cb08830b..387ee22b7 100644 --- a/apps/docs/public/r/area-chart.json +++ b/apps/docs/public/r/area-chart.json @@ -19,7 +19,7 @@ { "path": "ui/area-chart.tsx", "type": "registry:ui", - "content": "/**\n * AreaChart — filled bands over time, stacked or overlaid.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why this is not `LineChart.Area`\n *\n * `LineChart.Area` shades under a line, which is one series saying \"this is\n * the shape of the thing\". This chart answers a different question — *what is\n * it made of* — and that needs stacking: each band sits on the running total\n * of the ones below it, so the top edge is the whole and the thickness of each\n * band is its share.\n *\n * Stacking is not a flag that could be bolted on. It changes the y-domain from\n * the largest single series to the largest *sum*, it makes each area's\n * baseline a curve rather than the axis, and it makes the order the series are\n * declared in load-bearing. A chart where all of that is true of some children\n * and not others is a chart nobody can read, so it is a chart of its own.\n *\n * Unstacked, the bands overlay each other and the fills are drawn translucent,\n * which is the right reading when the series are alternatives rather than\n * parts — two plans compared, not two slices of one total.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport {\n areaPath,\n columnValues,\n compactNumber,\n linePath,\n useSeriesColor,\n xOf,\n yOf,\n type ChartCurve,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\nconst LABEL_WIDTH = 132;\n\n/**\n * Box each x label is centred in. Wide enough for a short label to sit over\n * its point without the neighbours colliding; a longer one is ellipsised\n * rather than allowed to push the others out of place.\n */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\nconst DOT = 9;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type AreaChartStatus = 'loading' | 'ready';\nexport type AreaChartDatum = Record;\n\ninterface AreaChartContextValue {\n data: AreaChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: AreaChartStatus;\n curve: ChartCurve;\n stacked: boolean;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n domainMin: SharedValue;\n domainMax: SharedValue;\n extent: [number, number];\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst AreaChartContext = createContext(null);\n\nfunction useChart(component: string): AreaChartContextValue {\n const context = useContext(AreaChartContext);\n if (!context) {\n throw new Error(`${component} must be used within an `);\n }\n return context;\n}\n\n/**\n * The point under the crosshair, for something rendered *inside* the chart.\n * A readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useAreaChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useAreaChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface AreaChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: AreaChartDatum[];\n /** Key holding the x label. Used by the axis and the crosshair readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bands flat and grows them into the real ones when it\n * turns `ready`. Add an `AreaChart.Skeleton` for something to stand in the\n * plot meanwhile.\n */\n status?: AreaChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /**\n * Sit each band on the running total of the ones below it, so the top edge\n * is the whole and each thickness is a share. The order the `Area` children\n * are declared in is the stacking order, bottom first.\n *\n * Unstacked, the bands overlay and their fills are translucent — the right\n * reading when the series are alternatives rather than parts of a total.\n */\n stacked?: boolean;\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: AreaChartDatum | null) => void;\n /** Drop the axis padding so the bands reach the edges, for a sparkline. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface AreaChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\nconst AreaChartRoot = forwardRef(function AreaChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n stacked = false,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = Infinity;\n let max = -Infinity;\n\n for (const row of data) {\n /*\n * Stacked, the tall thing is the sum of the row; overlaid, it is the\n * largest single value in it. Reading the same number for both crops a\n * stack at its largest band and clips everything above it.\n */\n let rowTotal = 0;\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (stacked) {\n rowTotal += value;\n } else {\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n if (stacked) {\n if (rowTotal < min) min = rowTotal;\n if (rowTotal > max) max = rowTotal;\n }\n }\n\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n /*\n * An area is a filled region, and a region floating above the bottom of\n * the plot reads as a shape rather than as a quantity — so a series that\n * never goes below zero is drawn from zero. Padding only the top.\n */\n const floor = min >= 0 ? 0 : min - (max - min) * 0.1;\n return [floor, max + (max - min) * 0.1];\n }, [data, yDomain, seriesKeys, stacked]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the chart drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the bands\n * keep the same coordinate space as the grid underneath them.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nAreaChartRoot.displayName = 'AreaChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartGridProps {\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/** Horizontal rules, so a band can be read against a number. */\nfunction AreaChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: AreaChartGridProps) {\n const { plot } = useChart('AreaChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => index / rows).map((fraction) => (\n \n ))}\n \n );\n}\nAreaChartGrid.displayName = 'AreaChart.Grid';\nAreaChartGrid.layer = 'svg' as Layer;\n\nexport interface AreaChartAreaProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Opacity of the fill at the top of the band. */\n fillOpacity?: number;\n /** Opacity at the bottom. `0` fades the band out; match `fillOpacity` for a flat fill. */\n gradientToOpacity?: number;\n /** Draw the line along the top edge of the band. */\n showLine?: boolean;\n /** Thickness of that line. */\n strokeWidth?: number;\n}\n\n/**\n * One filled band.\n *\n * The fill is a gradient by default rather than a flat wash, because a flat\n * fill of any weight competes with the line on top of it — fading it downward\n * keeps the top edge, which is the part carrying the numbers, the darkest\n * thing in the band.\n */\nfunction AreaChartArea({\n dataKey,\n color,\n colorIndex = 1,\n fillOpacity,\n gradientToOpacity = 0,\n showLine = true,\n strokeWidth = 2,\n}: AreaChartAreaProps) {\n const {\n data,\n plot,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n } = useChart('AreaChart.Area');\n\n const stroke = useSeriesColor(color, colorIndex);\n const gradientId = `panelui-area-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n // Stacked bands touch, so a translucent one shows the band beneath through\n // it and the two read as a third colour. Overlaid they must be translucent,\n // because the one in front would otherwise hide the ones behind entirely.\n const topOpacity = fillOpacity ?? (stacked ? 0.85 : 0.35);\n\n useEffect(() => {\n registerSeries(dataKey, stroke);\n return () => unregisterSeries(dataKey);\n }, [dataKey, stroke, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n /*\n * The running total of the series declared below this one, which is what\n * this band sits on. Registration order is declaration order, so the first\n * `Area` child is the bottom of the stack.\n */\n const baselines = useMemo(() => {\n if (!stacked) return undefined;\n const below = series.slice(0, series.findIndex(([key]) => key === dataKey));\n if (!below.length) return undefined;\n return data.map((row) => {\n let total = 0;\n for (const [key] of below) {\n const value = row[key];\n if (typeof value === 'number' && !Number.isNaN(value)) total += value;\n }\n return total;\n });\n }, [stacked, series, dataKey, data]);\n\n const loading = status === 'loading';\n\n const fillProps = useAnimatedProps(() => ({\n d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),\n }));\n\n const lineProps = useAnimatedProps(() => ({\n d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),\n }));\n\n return (\n \n \n \n \n \n \n \n \n {showLine ? (\n \n ) : null}\n \n );\n}\nAreaChartArea.displayName = 'AreaChart.Area';\nAreaChartArea.layer = 'series' as Layer;\n\n/** How much of the plot's height the resting band takes. */\nconst SKELETON_BAND = 0.18;\n\nexport interface AreaChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a low band along the baseline with a highlight travelling\n * across it.\n *\n * Flat on purpose. A placeholder with a shape in it is a shape the reader has\n * no way to tell from the real one until it changes under them, so the band\n * says only where the series will be and how tall the plot is.\n *\n * The sweep is the part that carries the meaning. Without it a chart waiting\n * for data and a chart whose values are all zero draw the same picture, and\n * the reader is left to guess which one they are looking at.\n */\nfunction AreaChartSkeleton({ duration = 1400, color }: AreaChartSkeletonProps) {\n const { plot, status } = useChart('AreaChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const height = plot.height * SKELETON_BAND;\n const top = plot.top + plot.height - height;\n const gradientId = 'panelui-area-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nAreaChartSkeleton.displayName = 'AreaChart.Skeleton';\nAreaChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartXAxisProps {\n ticks?: number;\n format?: (datum: AreaChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The x labels. Real text, so they follow the theme's font and text scaling. */\nfunction AreaChartXAxis({ ticks = 4, format, className }: AreaChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('AreaChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0,3,6,8,11 — so spreading them evenly put the ones in\n * between over the wrong part of the line.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nAreaChartXAxis.displayName = 'AreaChart.XAxis';\nAreaChartXAxis.layer = 'overlay' as Layer;\n\nexport interface AreaChartYAxisProps {\n ticks?: number;\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction AreaChartYAxis({ ticks = 4, format, className }: AreaChartYAxisProps) {\n const { plot, extent } = useChart('AreaChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nAreaChartYAxis.displayName = 'AreaChart.YAxis';\nAreaChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it\n// lays the plot out — an axis drawn over the plot is unreadable, and\n// makes what it is drawn over unreadable too.\nAreaChartYAxis.axis = 'y' as const;\n\n/** A dot riding the top edge of one band as the crosshair moves. */\nfunction CrosshairDot({\n values,\n baselines,\n plot,\n domainMin,\n domainMax,\n activeIndex,\n total,\n color,\n}: {\n values: (number | null)[];\n baselines: number[] | undefined;\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n activeIndex: SharedValue;\n total: number;\n color: string;\n}) {\n const props = useAnimatedProps(() => {\n const index = activeIndex.value;\n const value = index >= 0 ? values[index] : null;\n if (index < 0 || value === null || value === undefined) {\n return { opacity: 0, cx: 0, cy: 0 };\n }\n const stacked = value + (baselines?.[index] ?? 0);\n return {\n opacity: 1,\n cx: xOf(index, total, plot),\n cy: yOf(stacked, plot, domainMin.value, domainMax.value),\n };\n });\n\n return (\n \n );\n}\n\nexport interface AreaChartTooltipProps {\n color?: string;\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: AreaChartDatum) => string;\n className?: string;\n}\n\n/**\n * The crosshair, the gesture that drives it, and the readout that rides it.\n *\n * The hit area is the whole plot. A crosshair you have to land on a band to\n * summon is a crosshair nobody finds.\n */\nfunction AreaChartTooltip({ color, formatValue, formatX, className }: AreaChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n stacked,\n series,\n domainMin,\n domainMax,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('AreaChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 2 || width <= 0) return;\n const ratio = (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n /*\n * The dots ride the *top edge* of each band, which for a stack is the\n * running total rather than the value — so the baselines are recomputed here\n * the same way the bands compute them, and for the same reason.\n */\n const stacks = useMemo(() => {\n return series.map(([key, seriesColor], index) => {\n const below = series.slice(0, index);\n const baselines =\n stacked && below.length\n ? data.map((row) => {\n let sum = 0;\n for (const [k] of below) {\n const value = row[k];\n if (typeof value === 'number' && !Number.isNaN(value)) sum += value;\n }\n return sum;\n })\n : undefined;\n return { key, color: seriesColor, values: columnValues(data, key), baselines };\n });\n }, [series, data, stacked]);\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: AreaChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n {stacks.map((stack) => (\n \n ))}\n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {series.map(([key, seriesColor]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nAreaChartTooltip.displayName = 'AreaChart.Tooltip';\nAreaChartTooltip.layer = 'overlay' as Layer;\n\nexport interface AreaChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per series.\n *\n * Reversed for a stack, so the key reads in the order the bands appear on the\n * chart: the last one declared is the top band, and a legend listing it last\n * points at the bottom one.\n */\nfunction AreaChartLegend({ className, labels, ...props }: AreaChartLegendProps) {\n const { series, stacked } = useChart('AreaChart.Legend');\n if (!series.length) return null;\n const ordered = stacked ? [...series].reverse() : series;\n\n return (\n \n {ordered.map(([key, color]) => (\n \n ))}\n \n );\n}\nAreaChartLegend.displayName = 'AreaChart.Legend';\nAreaChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge, in the order\n * the bands appear on a stack. Prefer this to `AreaChart.Legend` on a chart\n * that has a header: the legend floats over the plot, where a tall band and a\n * key end up in the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the bands, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a point's value when something is.\n */\nfunction AreaChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: AreaChartHeaderProps) {\n const { series, stacked } = useChart('AreaChart.Header');\n const ordered = stacked ? [...series].reverse() : series;\n const trailing =\n children ??\n (legend && ordered.length ? (\n \n {ordered.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nAreaChartHeader.displayName = 'AreaChart.Header';\nAreaChartHeader.layer = 'header' as Layer;\n\nexport const AreaChart = Object.assign(AreaChartRoot, {\n Header: AreaChartHeader,\n Grid: AreaChartGrid,\n Area: AreaChartArea,\n Skeleton: AreaChartSkeleton,\n XAxis: AreaChartXAxis,\n YAxis: AreaChartYAxis,\n Tooltip: AreaChartTooltip,\n Legend: AreaChartLegend,\n});\n" + "content": "/**\n * AreaChart — filled bands over time, stacked or overlaid.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why this is not `LineChart.Area`\n *\n * `LineChart.Area` shades under a line, which is one series saying \"this is\n * the shape of the thing\". This chart answers a different question — *what is\n * it made of* — and that needs stacking: each band sits on the running total\n * of the ones below it, so the top edge is the whole and the thickness of each\n * band is its share.\n *\n * Stacking is not a flag that could be bolted on. It changes the y-domain from\n * the largest single series to the largest *sum*, it makes each area's\n * baseline a curve rather than the axis, and it makes the order the series are\n * declared in load-bearing. A chart where all of that is true of some children\n * and not others is a chart nobody can read, so it is a chart of its own.\n *\n * Unstacked, the bands overlay each other and the fills are drawn translucent,\n * which is the right reading when the series are alternatives rather than\n * parts — two plans compared, not two slices of one total.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport {\n areaPath,\n columnValues,\n compactNumber,\n linePath,\n useSeriesColor,\n xOf,\n yOf,\n type ChartCurve,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\nconst LABEL_WIDTH = 132;\n\n/**\n * Box each x label is centred in. Wide enough for a short label to sit over\n * its point without the neighbours colliding; a longer one is ellipsised\n * rather than allowed to push the others out of place.\n */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\nconst DOT = 9;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type AreaChartStatus = 'loading' | 'ready';\nexport type AreaChartDatum = Record;\n\ninterface AreaChartContextValue {\n data: AreaChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: AreaChartStatus;\n curve: ChartCurve;\n stacked: boolean;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n domainMin: SharedValue;\n domainMax: SharedValue;\n extent: [number, number];\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst AreaChartContext = createContext(null);\n\nfunction useChart(component: string): AreaChartContextValue {\n const context = useContext(AreaChartContext);\n if (!context) {\n throw new Error(`${component} must be used within an `);\n }\n return context;\n}\n\n/**\n * The point under the crosshair, for something rendered *inside* the chart.\n * A readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useAreaChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useAreaChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface AreaChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: AreaChartDatum[];\n /** Key holding the x label. Used by the axis and the crosshair readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bands flat and grows them into the real ones when it\n * turns `ready`. Add an `AreaChart.Skeleton` for something to stand in the\n * plot meanwhile.\n */\n status?: AreaChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /**\n * Sit each band on the running total of the ones below it, so the top edge\n * is the whole and each thickness is a share. The order the `Area` children\n * are declared in is the stacking order, bottom first.\n *\n * Unstacked, the bands overlay and their fills are translucent — the right\n * reading when the series are alternatives rather than parts of a total.\n */\n stacked?: boolean;\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: AreaChartDatum | null) => void;\n /** Drop the axis padding so the bands reach the edges, for a sparkline. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface AreaChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\nconst AreaChartRoot = forwardRef(function AreaChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n stacked = false,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = Infinity;\n let max = -Infinity;\n\n for (const row of data) {\n /*\n * Stacked, the tall thing is the sum of the row; overlaid, it is the\n * largest single value in it. Reading the same number for both crops a\n * stack at its largest band and clips everything above it.\n */\n let rowTotal = 0;\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (stacked) {\n rowTotal += value;\n } else {\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n if (stacked) {\n if (rowTotal < min) min = rowTotal;\n if (rowTotal > max) max = rowTotal;\n }\n }\n\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n /*\n * An area is a filled region, and a region floating above the bottom of\n * the plot reads as a shape rather than as a quantity — so a series that\n * never goes below zero is drawn from zero. Padding only the top.\n */\n const floor = min >= 0 ? 0 : min - (max - min) * 0.1;\n return [floor, max + (max - min) * 0.1];\n }, [data, yDomain, seriesKeys, stacked]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the chart drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the bands\n * keep the same coordinate space as the grid underneath them.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nAreaChartRoot.displayName = 'AreaChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartGridProps {\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/** Horizontal rules, so a band can be read against a number. */\nfunction AreaChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: AreaChartGridProps) {\n const { plot } = useChart('AreaChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => index / rows).map((fraction) => (\n \n ))}\n \n );\n}\nAreaChartGrid.displayName = 'AreaChart.Grid';\nAreaChartGrid.layer = 'svg' as Layer;\n\nexport interface AreaChartAreaProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Opacity of the fill at the top of the band. */\n fillOpacity?: number;\n /** Opacity at the bottom. `0` fades the band out; match `fillOpacity` for a flat fill. */\n gradientToOpacity?: number;\n /** Draw the line along the top edge of the band. */\n showLine?: boolean;\n /** Thickness of that line. */\n strokeWidth?: number;\n}\n\n/**\n * One filled band.\n *\n * The fill is a gradient by default rather than a flat wash, because a flat\n * fill of any weight competes with the line on top of it — fading it downward\n * keeps the top edge, which is the part carrying the numbers, the darkest\n * thing in the band.\n */\nfunction AreaChartArea({\n dataKey,\n color,\n colorIndex = 1,\n fillOpacity,\n gradientToOpacity = 0,\n showLine = true,\n strokeWidth = 2,\n}: AreaChartAreaProps) {\n const {\n data,\n plot,\n status,\n curve,\n stacked,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n } = useChart('AreaChart.Area');\n\n const stroke = useSeriesColor(color, colorIndex);\n const gradientId = `panelui-area-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n // Stacked bands touch, so a translucent one shows the band beneath through\n // it and the two read as a third colour. Overlaid they must be translucent,\n // because the one in front would otherwise hide the ones behind entirely.\n const topOpacity = fillOpacity ?? (stacked ? 0.85 : 0.35);\n\n useEffect(() => {\n registerSeries(dataKey, stroke);\n return () => unregisterSeries(dataKey);\n }, [dataKey, stroke, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n /*\n * The running total of the series declared below this one, which is what\n * this band sits on. Registration order is declaration order, so the first\n * `Area` child is the bottom of the stack.\n */\n const baselines = useMemo(() => {\n if (!stacked) return undefined;\n const below = series.slice(0, series.findIndex(([key]) => key === dataKey));\n if (!below.length) return undefined;\n return data.map((row) => {\n let total = 0;\n for (const [key] of below) {\n const value = row[key];\n if (typeof value === 'number' && !Number.isNaN(value)) total += value;\n }\n return total;\n });\n }, [stacked, series, dataKey, data]);\n\n const loading = status === 'loading';\n\n const fillProps = useAnimatedProps(() => ({\n d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),\n }));\n\n const lineProps = useAnimatedProps(() => ({\n d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading, baselines),\n }));\n\n return (\n \n \n \n \n \n \n \n \n {showLine ? (\n \n ) : null}\n \n );\n}\nAreaChartArea.displayName = 'AreaChart.Area';\nAreaChartArea.layer = 'series' as Layer;\n\n/** How much of the plot's height the resting band takes. */\nconst SKELETON_BAND = 0.18;\n\nexport interface AreaChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a low band along the baseline with a highlight travelling\n * across it.\n *\n * Flat on purpose. A placeholder with a shape in it is a shape the reader has\n * no way to tell from the real one until it changes under them, so the band\n * says only where the series will be and how tall the plot is.\n *\n * The sweep is the part that carries the meaning. Without it a chart waiting\n * for data and a chart whose values are all zero draw the same picture, and\n * the reader is left to guess which one they are looking at.\n */\nfunction AreaChartSkeleton({ duration = 1400, color }: AreaChartSkeletonProps) {\n const { plot, status } = useChart('AreaChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const height = plot.height * SKELETON_BAND;\n const top = plot.top + plot.height - height;\n const gradientId = 'panelui-area-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nAreaChartSkeleton.displayName = 'AreaChart.Skeleton';\nAreaChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartXAxisProps {\n ticks?: number;\n format?: (datum: AreaChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The x labels. Real text, so they follow the theme's font and text scaling. */\nfunction AreaChartXAxis({ ticks = 4, format, className }: AreaChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('AreaChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0,3,6,8,11 — so spreading them evenly put the ones in\n * between over the wrong part of the line.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nAreaChartXAxis.displayName = 'AreaChart.XAxis';\nAreaChartXAxis.layer = 'overlay' as Layer;\n\nexport interface AreaChartYAxisProps {\n ticks?: number;\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction AreaChartYAxis({ ticks = 4, format, className }: AreaChartYAxisProps) {\n const { plot, extent } = useChart('AreaChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nAreaChartYAxis.displayName = 'AreaChart.YAxis';\nAreaChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it\n// lays the plot out — an axis drawn over the plot is unreadable, and\n// makes what it is drawn over unreadable too.\nAreaChartYAxis.axis = 'y' as const;\n\n/** A dot riding the top edge of one band as the crosshair moves. */\nfunction CrosshairDot({\n values,\n baselines,\n plot,\n domainMin,\n domainMax,\n activeIndex,\n total,\n color,\n}: {\n values: (number | null)[];\n baselines: number[] | undefined;\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n activeIndex: SharedValue;\n total: number;\n color: string;\n}) {\n const props = useAnimatedProps(() => {\n const index = activeIndex.value;\n const value = index >= 0 ? values[index] : null;\n if (index < 0 || value === null || value === undefined) {\n return { opacity: 0, cx: 0, cy: 0 };\n }\n const stacked = value + (baselines?.[index] ?? 0);\n return {\n opacity: 1,\n cx: xOf(index, total, plot),\n cy: yOf(stacked, plot, domainMin.value, domainMax.value),\n };\n });\n\n return (\n \n );\n}\n\nexport interface AreaChartTooltipProps {\n color?: string;\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: AreaChartDatum) => string;\n className?: string;\n}\n\n/**\n * The crosshair, the gesture that drives it, and the readout that rides it.\n *\n * The hit area is the whole plot. A crosshair you have to land on a band to\n * summon is a crosshair nobody finds.\n */\nfunction AreaChartTooltip({ color, formatValue, formatX, className }: AreaChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n stacked,\n series,\n domainMin,\n domainMax,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('AreaChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 2 || width <= 0) return;\n const ratio = (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n /*\n * The dots ride the *top edge* of each band, which for a stack is the\n * running total rather than the value — so the baselines are recomputed here\n * the same way the bands compute them, and for the same reason.\n */\n const stacks = useMemo(() => {\n return series.map(([key, seriesColor], index) => {\n const below = series.slice(0, index);\n const baselines =\n stacked && below.length\n ? data.map((row) => {\n let sum = 0;\n for (const [k] of below) {\n const value = row[k];\n if (typeof value === 'number' && !Number.isNaN(value)) sum += value;\n }\n return sum;\n })\n : undefined;\n return { key, color: seriesColor, values: columnValues(data, key), baselines };\n });\n }, [series, data, stacked]);\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: AreaChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n {stacks.map((stack) => (\n \n ))}\n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {series.map(([key, seriesColor]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nAreaChartTooltip.displayName = 'AreaChart.Tooltip';\nAreaChartTooltip.layer = 'overlay' as Layer;\n\nexport interface AreaChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per series.\n *\n * Reversed for a stack, so the key reads in the order the bands appear on the\n * chart: the last one declared is the top band, and a legend listing it last\n * points at the bottom one.\n */\nfunction AreaChartLegend({ className, labels, ...props }: AreaChartLegendProps) {\n const { series, stacked } = useChart('AreaChart.Legend');\n if (!series.length) return null;\n const ordered = stacked ? [...series].reverse() : series;\n\n return (\n \n {ordered.map(([key, color]) => (\n \n ))}\n \n );\n}\nAreaChartLegend.displayName = 'AreaChart.Legend';\nAreaChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface AreaChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge, in the order\n * the bands appear on a stack. Prefer this to `AreaChart.Legend` on a chart\n * that has a header: the legend floats over the plot, where a tall band and a\n * key end up in the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the bands, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a point's value when something is.\n */\nfunction AreaChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: AreaChartHeaderProps) {\n const { series, stacked } = useChart('AreaChart.Header');\n const ordered = stacked ? [...series].reverse() : series;\n const trailing =\n children ??\n (legend && ordered.length ? (\n \n {ordered.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nAreaChartHeader.displayName = 'AreaChart.Header';\nAreaChartHeader.layer = 'header' as Layer;\n\nexport const AreaChart = Object.assign(AreaChartRoot, {\n Header: AreaChartHeader,\n Grid: AreaChartGrid,\n Area: AreaChartArea,\n Skeleton: AreaChartSkeleton,\n XAxis: AreaChartXAxis,\n YAxis: AreaChartYAxis,\n Tooltip: AreaChartTooltip,\n Legend: AreaChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/bar-chart.json b/apps/docs/public/r/bar-chart.json index a39b0fe2f..36abe8414 100644 --- a/apps/docs/public/r/bar-chart.json +++ b/apps/docs/public/r/bar-chart.json @@ -19,7 +19,7 @@ { "path": "ui/bar-chart.tsx", "type": "registry:ui", - "content": "/**\n * BarChart — categories compared by length, drawn and animated on the UI thread.\n *\n * Composed the same way `LineChart` is: the grid, each series, the axes and the\n * readout are separate children, so a chart that wants no grid simply does not\n * have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What is different from a line\n *\n * **The baseline is zero, and not negotiable.** A line's job is to show change,\n * so it may crop its axis to the range the data actually occupies. A bar's job\n * is to compare *lengths*, and a bar cropped at the bottom is a length that\n * lies — twice as tall no longer means twice as much. So the domain always\n * reaches zero unless `yDomain` says otherwise, and saying otherwise is opting\n * into a chart that misreads.\n *\n * **Bands, not points.** A line has a point at each x; a bar owns a slice of\n * width around it. `barGap` is the fraction of that slice left empty, so bars\n * stay proportional at any width instead of needing a pixel gap that is wrong\n * on half of them.\n *\n * **Every series is one path.** A `Bar` draws all its rectangles as subpaths of\n * a single animated path, split in two so the band under the finger keeps full\n * ink while the rest fade. Fifty bars is two animated props a frame rather\n * than fifty, and the corners are drawn as a path because a bar is rounded on\n * the end it grows towards and square on the end it grows from — which `rx`\n * cannot express, since it rounds all four corners or none.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport {\n bandOf,\n barPath,\n columnValues,\n compactNumber,\n useSeriesColor,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/**\n * Sideways, the category names run down the left instead of along the bottom,\n * so the room has to come off that side rather than off the bottom. Reserved\n * rather than overlaid: a name drawn on top of the bars is unreadable against\n * them and makes the bars unreadable too.\n */\nconst PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 68 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 132;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type BarChartStatus = 'loading' | 'ready';\nexport type BarChartOrientation = 'vertical' | 'horizontal';\nexport type BarChartDatum = Record;\n\ninterface BarChartContextValue {\n data: BarChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: BarChartStatus;\n orientation: BarChartOrientation;\n stacked: boolean;\n barGap: number;\n barWidth: number | undefined;\n stackGap: number;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst BarChartContext = createContext(null);\n\nfunction useChart(component: string): BarChartContextValue {\n const context = useContext(BarChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The band under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useBarChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useBarChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface BarChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a band along the category axis. */\n data: BarChartDatum[];\n /** Key holding the category label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bars at the baseline and grows them into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `BarChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: BarChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the value axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the value axis instead of deriving it. Note that the derived domain\n * always includes zero, and a domain that does not is a bar chart whose\n * lengths cannot be compared — pass this only when you mean it.\n */\n yDomain?: [number, number];\n /** `vertical` grows the bars upward; `horizontal` grows them rightward. */\n orientation?: BarChartOrientation;\n /** Stack the series on each other instead of standing them side by side. */\n stacked?: boolean;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any width.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Points between the segments of a stack. */\n stackGap?: number;\n /** Corner radius on the growing end of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A value that rounds\n * to nothing still happened, and a bar of zero height says it did not.\n */\n minBarLength?: number;\n /** Opacity of the bars that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The band under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: BarChartDatum | null) => void;\n /** Drop the axis padding, for a bar sparkline with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface BarChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst BarChartRoot = forwardRef(function BarChartRoot(\n {\n className,\n data,\n xDataKey = 'name',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n orientation = 'vertical',\n stacked = false,\n barGap = 0.2,\n barWidth,\n stackGap = 0,\n cornerRadius = 4,\n minBarLength = 0,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : orientation === 'horizontal'\n ? PADDING_SIDEWAYS\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = 0;\n let max = 0;\n\n for (const row of data) {\n // Stacked, the tall thing is the total; grouped, it is the tallest bar.\n // Reading the same number for both would crop a stack at its largest\n // segment and clip everything above it.\n let positive = 0;\n let negative = 0;\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (stacked) {\n if (value >= 0) positive += value;\n else negative += value;\n } else {\n if (value > positive) positive = value;\n if (value < negative) negative = value;\n }\n }\n if (positive > max) max = positive;\n if (negative < min) min = negative;\n }\n\n if (min === 0 && max === 0) return [0, 1];\n // Headroom above the tallest bar only. The zero end is left exactly where\n // it is: padding it would lift the bars off their own baseline.\n return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];\n }, [data, yDomain, seriesKeys, stacked]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nBarChartRoot.displayName = 'BarChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BarChartGridProps {\n /** How many lines to draw across the value axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the value axis, so a bar can be read against a number rather\n * than only against the bar beside it.\n */\nfunction BarChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: BarChartGridProps) {\n const { plot, orientation } = useChart('BarChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);\n\n return (\n \n {lines.map((fraction) =>\n orientation === 'vertical' ? (\n \n ) : (\n \n )\n )}\n \n );\n}\nBarChartGrid.displayName = 'BarChart.Grid';\nBarChartGrid.layer = 'svg' as Layer;\n\nexport interface BarChartBarProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * One series of bars.\n *\n * Drawn as two paths rather than one rectangle per band: the band under the\n * finger, and everything else. That is the fewest animated props that can\n * still dim the rest — one path could not, since a path has one opacity, and\n * a view per bar would be one animated prop per bar for the same picture.\n */\nfunction BarChartBar({ dataKey, color, colorIndex = 1, cornerRadius }: BarChartBarProps) {\n const {\n data,\n plot,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('BarChart.Bar');\n\n const fill = useSeriesColor(color, colorIndex);\n const radius = cornerRadius ?? chartRadius;\n\n useEffect(() => {\n registerSeries(dataKey, fill);\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n /*\n * What each bar in this series sits on. Grouped, that is nothing — every bar\n * starts at zero. Stacked, it is the running total of the series registered\n * *before* this one, which is why registration order is the stacking order.\n */\n const baselines = useMemo(() => {\n if (!stacked) return null;\n const below = series.slice(0, series.findIndex(([key]) => key === dataKey));\n if (!below.length) return null;\n return data.map((row) => {\n let total = 0;\n for (const [key] of below) {\n const value = row[key];\n if (typeof value === 'number' && !Number.isNaN(value)) total += value;\n }\n return total;\n });\n }, [stacked, series, dataKey, data]);\n\n const seriesIndex = Math.max(\n 0,\n series.findIndex(([key]) => key === dataKey)\n );\n const seriesCount = Math.max(1, series.length);\n const loading = status === 'loading';\n const total = data.length;\n const horizontal = orientation === 'horizontal';\n\n /*\n * Both paths come out of one builder, filtered by whether the band is the\n * active one. Two passes over the data a frame is still cheaper than the\n * bookkeeping needed to build both at once, and it keeps the geometry in\n * exactly one place.\n */\n const build = (wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n // The category axis runs across the plot when the bars grow up, and down\n // it when they grow right. `along` is that axis; `across` is the value one.\n const along = horizontal ? plot.height : plot.width;\n const across = horizontal ? plot.width : plot.height;\n const alongStart = horizontal ? plot.top : plot.left;\n const acrossStart = horizontal ? plot.left : plot.top;\n\n const band = along / total;\n // The slot a single bar gets: the band, less the gap, divided between the\n // series when they stand side by side.\n const usable = band * (1 - barGap);\n const slot = stacked ? usable : usable / seriesCount;\n const thickness = Math.min(barWidth ?? slot, slot);\n\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n /*\n * Where a value sits along the value axis. Vertical counts down from the\n * top, horizontal counts up from the left — the same scale read in\n * opposite directions, which is the only thing orientation changes.\n */\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? acrossStart + fraction * across\n : acrossStart + across - fraction * across;\n };\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n if ((i === active) !== wantActive) continue;\n\n const value = values[i];\n if (value === null || value === undefined) continue;\n\n const base = baselines?.[i] ?? 0;\n /*\n * Staggered by band, but every bar still finishes inside the one\n * duration: the window each gets is what is left after the stagger, so\n * a chart of fifty bands does not take fifty times as long to arrive.\n */\n const start = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n const shown = loading ? 0 : value * eased;\n\n const zero = project(base);\n const tip = project(base + shown);\n const shrink = stacked && stackGap > 0 ? stackGap : 0;\n let length = Math.abs(tip - zero) - shrink;\n // A value that rounds to nothing still happened, and a bar of zero\n // length says it did not.\n if (minBarLength > 0 && shown !== 0 && length < minBarLength) {\n length = minBarLength;\n }\n if (length <= 0) continue;\n\n const offset =\n (stacked ? 0 : seriesIndex * slot) + (band - usable) / 2 + (slot - thickness) / 2;\n const lead = alongStart + i * band + offset;\n const positive = shown >= 0;\n\n d += horizontal\n ? barPath(\n positive ? zero : zero - length,\n lead,\n length,\n thickness,\n radius,\n positive ? 'right' : 'left'\n )\n : barPath(\n lead,\n positive ? zero - length : zero,\n thickness,\n length,\n radius,\n positive ? 'up' : 'down'\n );\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const restProps = useAnimatedProps(build(false));\n const activeProps = useAnimatedProps(build(true));\n\n return (\n \n \n \n \n );\n}\nBarChartBar.displayName = 'BarChart.Bar';\nBarChartBar.layer = 'svg' as Layer;\n\n/** How much of the value axis a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.22;\n\n/** Bands to draw when there is no data yet to count them from. */\nconst SKELETON_BARS = 7;\n\nexport interface BarChartSkeletonProps {\n /**\n * How many placeholder bars to draw. Defaults to one per row, and to seven\n * when the data has not arrived — the count is the one thing a loading\n * chart can be honest about only if it already has the rows.\n */\n bars?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a row of short, equal stubs on the baseline, with a\n * highlight travelling across them.\n *\n * Equal on purpose. Placeholder bars of differing heights are a distribution\n * the reader has no way to tell from the real one until it changes under them,\n * so these say only how many bars there will be and where the baseline is.\n *\n * The sweep is the part that carries the meaning. Without it a chart waiting\n * for data and a chart whose values are all zero draw the same picture, and\n * the reader is left to guess which one they are looking at.\n */\nfunction BarChartSkeleton({ bars, duration = 1400, color }: BarChartSkeletonProps) {\n const { plot, status, orientation, data, barGap, barWidth, cornerRadius } =\n useChart('BarChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const horizontal = orientation === 'horizontal';\n const total = Math.max(1, bars ?? (data.length || SKELETON_BARS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n\n // The same two axes the bars use: `along` is the category one, `across`\n // the value one, so a sideways chart is this code with the pair swapped.\n const along = horizontal ? plot.height : plot.width;\n const alongStart = horizontal ? plot.top : plot.left;\n const across = horizontal ? plot.width : plot.height;\n\n const band = along / total;\n const usable = band * (1 - barGap);\n const thickness = Math.min(barWidth ?? usable, usable);\n const length = across * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = alongStart + i * band + (band - thickness) / 2;\n path += horizontal\n ? barPath(plot.left, lead, length, thickness, cornerRadius, 'right')\n : barPath(lead, plot.top + plot.height - length, thickness, length, cornerRadius, 'up');\n }\n return path;\n }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);\n\n if (!loading || !d) return null;\n\n const gradientId = 'panelui-bar-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nBarChartSkeleton.displayName = 'BarChart.Skeleton';\nBarChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Narrowest a category label may be drawn before the axis starts dropping\n * some. Roughly three characters at `xs`, which is what a month abbreviation\n * or a short name needs.\n */\nconst MIN_BAND_LABEL = 34;\n\nexport interface BarChartXAxisProps {\n /**\n * How many labels to show. Every category by default, thinned only when the\n * bands get too narrow to read — pass a number to force it lower.\n */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: BarChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The category labels, one under each band it has room for. Real text rather\n * than SVG text, so they follow the theme's font and the platform's text\n * scaling — SVG text does neither.\n */\nfunction BarChartXAxis({ ticks, format, className }: BarChartXAxisProps) {\n const { data, xDataKey, plot, orientation } = useChart('BarChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n\n /*\n * Every category, unless they will not fit. A fixed tick count dropped\n * names that had room to be drawn — eight months at six ticks lost March\n * and June for no reason anyone looking at the chart could see. The axis\n * asks the plot how much room there is instead, and only thins when the\n * answer is not enough.\n */\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, data.length));\n\n // Every nth band, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same band twice and skips its neighbour.\n const stride = Math.ceil(data.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < data.length; index += stride) {\n const datum = data[index];\n if (!datum) continue;\n picked.push({\n key: index,\n text: format ? format(datum, index) : String(datum[xDataKey] ?? ''),\n });\n }\n return picked;\n }, [data, ticks, format, xDataKey, plot.width]);\n\n if (orientation === 'horizontal') return null;\n\n /*\n * Each label is placed on its own band's centre, not spaced evenly along the\n * axis. Two things went wrong when they were spread with `justify-between`:\n * a bar owns a *band* rather than sitting on a point, so the first and last\n * labels were half a band out at the edges; and the indices `ticks` picks\n * are not evenly spaced (eight months shown six at a time gives 0,1,3,4,6,7),\n * so the ones between were spread evenly over gaps that are not.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n /*\n * One box per band, exactly the band's width. Tiling them rather than giving\n * each a fixed width means they can never overlap each other and the first\n * and last can never hang off the ends of the plot — the row of labels\n * occupies precisely the space the bars do.\n */\n const bandWidth = data.length > 0 ? plot.width / data.length : 0;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nBarChartXAxis.displayName = 'BarChart.XAxis';\nBarChartXAxis.layer = 'overlay' as Layer;\n\nexport interface BarChartYAxisProps {\n /** How many labels to show along the value axis. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction BarChartYAxis({ ticks = 4, format, className }: BarChartYAxisProps) {\n const { plot, data, xDataKey, orientation, extent } = useChart('BarChart.YAxis');\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const horizontal = orientation === 'horizontal';\n\n const labels = useMemo(() => {\n if (horizontal) {\n // Sideways, the side of the chart is the category axis.\n return data.map((row, index) => ({\n key: index,\n text: String(row[xDataKey] ?? ''),\n }));\n }\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format, horizontal, data, xDataKey]);\n\n /*\n * Sideways the labels sit in the gutter the plot already left for them, one\n * band each — `flex-1` per row rather than spacing them edge to edge, so\n * every name lands beside its own bar instead of only the first and last\n * doing so. Upright there are no bands to line up with, so the ticks space\n * themselves against the grid.\n */\n return (\n \n {labels.map((label) => (\n \n \n {label.text}\n \n \n ))}\n \n );\n}\nBarChartYAxis.displayName = 'BarChart.YAxis';\nBarChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it\n// lays the plot out — an axis drawn over the plot is unreadable, and\n// makes what it is drawn over unreadable too.\nBarChartYAxis.axis = 'y' as const;\n\nexport interface BarChartTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: BarChartDatum) => string;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A line needs one because a point on a line has no\n * width of its own to point at; a bar is already the thing being pointed at,\n * so highlighting it and dimming the rest says the same thing without drawing\n * a line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds — and the thinner the bars, the truer that\n * gets.\n */\nfunction BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n orientation,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('BarChart.Tooltip');\n\n const total = data.length;\n const horizontal = orientation === 'horizontal';\n const left = plot.left;\n const top = plot.top;\n const width = plot.width;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. Sideways it has to\n * be clamped inside the plot vertically, and how tall it is depends on how\n * many series are listed in it — a constant here would either let a\n * three-series readout hang off the bottom or reserve room a one-series\n * readout never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number, y: number) => {\n 'worklet';\n if (!total) return;\n const span = horizontal ? height : width;\n const offset = (horizontal ? y - top : x - left) / (span || 1);\n // Bands, not points: the finger is inside whichever slice it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);\n\n /*\n * The readout centres over its band and is clamped inside the plot, so it\n * never runs off the edge at the first or last one.\n *\n * Which axis the band runs along is the whole difference between the two\n * orientations. Upright, the bands are side by side, so the readout slides\n * across and sits above them all. Sideways, the bands are stacked down the\n * plot, so it slides *down* to the row it is describing and stays centred\n * across — held at the top instead, it would name the row under the finger\n * while covering the first one, which is the row a reader checks it against.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = (horizontal ? plot.height : plot.width) / total;\n const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)\n );\n\n if (!horizontal) {\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n }\n\n // Until the first measurement lands the height is zero, which clamps to\n // the top of the plot — the same place it used to sit, rather than a jump\n // from somewhere it never was.\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return {\n opacity: 1,\n transform: [{ translateX: clamped - half }, { translateY: y }],\n };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: BarChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {fmtX(active)}\n \n {series.map(([key, color]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nBarChartTooltip.displayName = 'BarChart.Tooltip';\nBarChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BarChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/** A swatch and a name per series, in the order the series were declared. */\nfunction BarChartLegend({ className, labels, ...props }: BarChartLegendProps) {\n const { series } = useChart('BarChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nBarChartLegend.displayName = 'BarChart.Legend';\nBarChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BarChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `BarChart.Legend` on a chart that has a header: the legend floats over the\n * plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the bars, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a band's value when something is.\n */\nfunction BarChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: BarChartHeaderProps) {\n const { series } = useChart('BarChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nBarChartHeader.displayName = 'BarChart.Header';\nBarChartHeader.layer = 'header' as Layer;\n\nexport const BarChart = Object.assign(BarChartRoot, {\n Header: BarChartHeader,\n Grid: BarChartGrid,\n Bar: BarChartBar,\n Skeleton: BarChartSkeleton,\n XAxis: BarChartXAxis,\n YAxis: BarChartYAxis,\n Tooltip: BarChartTooltip,\n Legend: BarChartLegend,\n});\n" + "content": "/**\n * BarChart — categories compared by length, drawn and animated on the UI thread.\n *\n * Composed the same way `LineChart` is: the grid, each series, the axes and the\n * readout are separate children, so a chart that wants no grid simply does not\n * have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What is different from a line\n *\n * **The baseline is zero, and not negotiable.** A line's job is to show change,\n * so it may crop its axis to the range the data actually occupies. A bar's job\n * is to compare *lengths*, and a bar cropped at the bottom is a length that\n * lies — twice as tall no longer means twice as much. So the domain always\n * reaches zero unless `yDomain` says otherwise, and saying otherwise is opting\n * into a chart that misreads.\n *\n * **Bands, not points.** A line has a point at each x; a bar owns a slice of\n * width around it. `barGap` is the fraction of that slice left empty, so bars\n * stay proportional at any width instead of needing a pixel gap that is wrong\n * on half of them.\n *\n * **Every series is one path.** A `Bar` draws all its rectangles as subpaths of\n * a single animated path, split in two so the band under the finger keeps full\n * ink while the rest fade. Fifty bars is two animated props a frame rather\n * than fifty, and the corners are drawn as a path because a bar is rounded on\n * the end it grows towards and square on the end it grows from — which `rx`\n * cannot express, since it rounds all four corners or none.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport {\n bandOf,\n barPath,\n columnValues,\n compactNumber,\n useSeriesColor,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/**\n * Sideways, the category names run down the left instead of along the bottom,\n * so the room has to come off that side rather than off the bottom. Reserved\n * rather than overlaid: a name drawn on top of the bars is unreadable against\n * them and makes the bars unreadable too.\n */\nconst PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 68 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 132;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type BarChartStatus = 'loading' | 'ready';\nexport type BarChartOrientation = 'vertical' | 'horizontal';\nexport type BarChartDatum = Record;\n\ninterface BarChartContextValue {\n data: BarChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: BarChartStatus;\n orientation: BarChartOrientation;\n stacked: boolean;\n barGap: number;\n barWidth: number | undefined;\n stackGap: number;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst BarChartContext = createContext(null);\n\nfunction useChart(component: string): BarChartContextValue {\n const context = useContext(BarChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The band under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useBarChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useBarChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface BarChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a band along the category axis. */\n data: BarChartDatum[];\n /** Key holding the category label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bars at the baseline and grows them into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `BarChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: BarChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the value axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the value axis instead of deriving it. Note that the derived domain\n * always includes zero, and a domain that does not is a bar chart whose\n * lengths cannot be compared — pass this only when you mean it.\n */\n yDomain?: [number, number];\n /** `vertical` grows the bars upward; `horizontal` grows them rightward. */\n orientation?: BarChartOrientation;\n /** Stack the series on each other instead of standing them side by side. */\n stacked?: boolean;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any width.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Points between the segments of a stack. */\n stackGap?: number;\n /** Corner radius on the growing end of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A value that rounds\n * to nothing still happened, and a bar of zero height says it did not.\n */\n minBarLength?: number;\n /** Opacity of the bars that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The band under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: BarChartDatum | null) => void;\n /** Drop the axis padding, for a bar sparkline with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface BarChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst BarChartRoot = forwardRef(function BarChartRoot(\n {\n className,\n data,\n xDataKey = 'name',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n orientation = 'vertical',\n stacked = false,\n barGap = 0.2,\n barWidth,\n stackGap = 0,\n cornerRadius = 4,\n minBarLength = 0,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : orientation === 'horizontal'\n ? PADDING_SIDEWAYS\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = 0;\n let max = 0;\n\n for (const row of data) {\n // Stacked, the tall thing is the total; grouped, it is the tallest bar.\n // Reading the same number for both would crop a stack at its largest\n // segment and clip everything above it.\n let positive = 0;\n let negative = 0;\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (stacked) {\n if (value >= 0) positive += value;\n else negative += value;\n } else {\n if (value > positive) positive = value;\n if (value < negative) negative = value;\n }\n }\n if (positive > max) max = positive;\n if (negative < min) min = negative;\n }\n\n if (min === 0 && max === 0) return [0, 1];\n // Headroom above the tallest bar only. The zero end is left exactly where\n // it is: padding it would lift the bars off their own baseline.\n return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];\n }, [data, yDomain, seriesKeys, stacked]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nBarChartRoot.displayName = 'BarChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BarChartGridProps {\n /** How many lines to draw across the value axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the value axis, so a bar can be read against a number rather\n * than only against the bar beside it.\n */\nfunction BarChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: BarChartGridProps) {\n const { plot, orientation } = useChart('BarChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);\n\n return (\n \n {lines.map((fraction) =>\n orientation === 'vertical' ? (\n \n ) : (\n \n )\n )}\n \n );\n}\nBarChartGrid.displayName = 'BarChart.Grid';\nBarChartGrid.layer = 'svg' as Layer;\n\nexport interface BarChartBarProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * One series of bars.\n *\n * Drawn as two paths rather than one rectangle per band: the band under the\n * finger, and everything else. That is the fewest animated props that can\n * still dim the rest — one path could not, since a path has one opacity, and\n * a view per bar would be one animated prop per bar for the same picture.\n */\nfunction BarChartBar({ dataKey, color, colorIndex = 1, cornerRadius }: BarChartBarProps) {\n const {\n data,\n plot,\n status,\n orientation,\n stacked,\n barGap,\n barWidth,\n stackGap,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('BarChart.Bar');\n\n const fill = useSeriesColor(color, colorIndex);\n const radius = cornerRadius ?? chartRadius;\n\n useEffect(() => {\n registerSeries(dataKey, fill);\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n /*\n * What each bar in this series sits on. Grouped, that is nothing — every bar\n * starts at zero. Stacked, it is the running total of the series registered\n * *before* this one, which is why registration order is the stacking order.\n */\n const baselines = useMemo(() => {\n if (!stacked) return null;\n const below = series.slice(0, series.findIndex(([key]) => key === dataKey));\n if (!below.length) return null;\n return data.map((row) => {\n let total = 0;\n for (const [key] of below) {\n const value = row[key];\n if (typeof value === 'number' && !Number.isNaN(value)) total += value;\n }\n return total;\n });\n }, [stacked, series, dataKey, data]);\n\n const seriesIndex = Math.max(\n 0,\n series.findIndex(([key]) => key === dataKey)\n );\n const seriesCount = Math.max(1, series.length);\n const loading = status === 'loading';\n const total = data.length;\n const horizontal = orientation === 'horizontal';\n\n /*\n * Both paths come out of one builder, filtered by whether the band is the\n * active one. Two passes over the data a frame is still cheaper than the\n * bookkeeping needed to build both at once, and it keeps the geometry in\n * exactly one place.\n */\n const build = (wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n // The category axis runs across the plot when the bars grow up, and down\n // it when they grow right. `along` is that axis; `across` is the value one.\n const along = horizontal ? plot.height : plot.width;\n const across = horizontal ? plot.width : plot.height;\n const alongStart = horizontal ? plot.top : plot.left;\n const acrossStart = horizontal ? plot.left : plot.top;\n\n const band = along / total;\n // The slot a single bar gets: the band, less the gap, divided between the\n // series when they stand side by side.\n const usable = band * (1 - barGap);\n const slot = stacked ? usable : usable / seriesCount;\n const thickness = Math.min(barWidth ?? slot, slot);\n\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n /*\n * Where a value sits along the value axis. Vertical counts down from the\n * top, horizontal counts up from the left — the same scale read in\n * opposite directions, which is the only thing orientation changes.\n */\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? acrossStart + fraction * across\n : acrossStart + across - fraction * across;\n };\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n if ((i === active) !== wantActive) continue;\n\n const value = values[i];\n if (value === null || value === undefined) continue;\n\n const base = baselines?.[i] ?? 0;\n /*\n * Staggered by band, but every bar still finishes inside the one\n * duration: the window each gets is what is left after the stagger, so\n * a chart of fifty bands does not take fifty times as long to arrive.\n */\n const start = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n const shown = loading ? 0 : value * eased;\n\n const zero = project(base);\n const tip = project(base + shown);\n const shrink = stacked && stackGap > 0 ? stackGap : 0;\n let length = Math.abs(tip - zero) - shrink;\n // A value that rounds to nothing still happened, and a bar of zero\n // length says it did not.\n if (minBarLength > 0 && shown !== 0 && length < minBarLength) {\n length = minBarLength;\n }\n if (length <= 0) continue;\n\n const offset =\n (stacked ? 0 : seriesIndex * slot) + (band - usable) / 2 + (slot - thickness) / 2;\n const lead = alongStart + i * band + offset;\n const positive = shown >= 0;\n\n d += horizontal\n ? barPath(\n positive ? zero : zero - length,\n lead,\n length,\n thickness,\n radius,\n positive ? 'right' : 'left'\n )\n : barPath(\n lead,\n positive ? zero - length : zero,\n thickness,\n length,\n radius,\n positive ? 'up' : 'down'\n );\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const restProps = useAnimatedProps(build(false));\n const activeProps = useAnimatedProps(build(true));\n\n return (\n \n \n \n \n );\n}\nBarChartBar.displayName = 'BarChart.Bar';\nBarChartBar.layer = 'svg' as Layer;\n\n/** How much of the value axis a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.22;\n\n/** Bands to draw when there is no data yet to count them from. */\nconst SKELETON_BARS = 7;\n\nexport interface BarChartSkeletonProps {\n /**\n * How many placeholder bars to draw. Defaults to one per row, and to seven\n * when the data has not arrived — the count is the one thing a loading\n * chart can be honest about only if it already has the rows.\n */\n bars?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a row of short, equal stubs on the baseline, with a\n * highlight travelling across them.\n *\n * Equal on purpose. Placeholder bars of differing heights are a distribution\n * the reader has no way to tell from the real one until it changes under them,\n * so these say only how many bars there will be and where the baseline is.\n *\n * The sweep is the part that carries the meaning. Without it a chart waiting\n * for data and a chart whose values are all zero draw the same picture, and\n * the reader is left to guess which one they are looking at.\n */\nfunction BarChartSkeleton({ bars, duration = 1400, color }: BarChartSkeletonProps) {\n const { plot, status, orientation, data, barGap, barWidth, cornerRadius } =\n useChart('BarChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const horizontal = orientation === 'horizontal';\n const total = Math.max(1, bars ?? (data.length || SKELETON_BARS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n\n // The same two axes the bars use: `along` is the category one, `across`\n // the value one, so a sideways chart is this code with the pair swapped.\n const along = horizontal ? plot.height : plot.width;\n const alongStart = horizontal ? plot.top : plot.left;\n const across = horizontal ? plot.width : plot.height;\n\n const band = along / total;\n const usable = band * (1 - barGap);\n const thickness = Math.min(barWidth ?? usable, usable);\n const length = across * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = alongStart + i * band + (band - thickness) / 2;\n path += horizontal\n ? barPath(plot.left, lead, length, thickness, cornerRadius, 'right')\n : barPath(lead, plot.top + plot.height - length, thickness, length, cornerRadius, 'up');\n }\n return path;\n }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);\n\n if (!loading || !d) return null;\n\n const gradientId = 'panelui-bar-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nBarChartSkeleton.displayName = 'BarChart.Skeleton';\nBarChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Narrowest a category label may be drawn before the axis starts dropping\n * some. Roughly three characters at `xs`, which is what a month abbreviation\n * or a short name needs.\n */\nconst MIN_BAND_LABEL = 34;\n\nexport interface BarChartXAxisProps {\n /**\n * How many labels to show. Every category by default, thinned only when the\n * bands get too narrow to read — pass a number to force it lower.\n */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: BarChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The category labels, one under each band it has room for. Real text rather\n * than SVG text, so they follow the theme's font and the platform's text\n * scaling — SVG text does neither.\n */\nfunction BarChartXAxis({ ticks, format, className }: BarChartXAxisProps) {\n const { data, xDataKey, plot, orientation } = useChart('BarChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n\n /*\n * Every category, unless they will not fit. A fixed tick count dropped\n * names that had room to be drawn — eight months at six ticks lost March\n * and June for no reason anyone looking at the chart could see. The axis\n * asks the plot how much room there is instead, and only thins when the\n * answer is not enough.\n */\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, data.length));\n\n // Every nth band, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same band twice and skips its neighbour.\n const stride = Math.ceil(data.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < data.length; index += stride) {\n const datum = data[index];\n if (!datum) continue;\n picked.push({\n key: index,\n text: format ? format(datum, index) : String(datum[xDataKey] ?? ''),\n });\n }\n return picked;\n }, [data, ticks, format, xDataKey, plot.width]);\n\n if (orientation === 'horizontal') return null;\n\n /*\n * Each label is placed on its own band's centre, not spaced evenly along the\n * axis. Two things went wrong when they were spread with `justify-between`:\n * a bar owns a *band* rather than sitting on a point, so the first and last\n * labels were half a band out at the edges; and the indices `ticks` picks\n * are not evenly spaced (eight months shown six at a time gives 0,1,3,4,6,7),\n * so the ones between were spread evenly over gaps that are not.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n /*\n * One box per band, exactly the band's width. Tiling them rather than giving\n * each a fixed width means they can never overlap each other and the first\n * and last can never hang off the ends of the plot — the row of labels\n * occupies precisely the space the bars do.\n */\n const bandWidth = data.length > 0 ? plot.width / data.length : 0;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nBarChartXAxis.displayName = 'BarChart.XAxis';\nBarChartXAxis.layer = 'overlay' as Layer;\n\nexport interface BarChartYAxisProps {\n /** How many labels to show along the value axis. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction BarChartYAxis({ ticks = 4, format, className }: BarChartYAxisProps) {\n const { plot, data, xDataKey, orientation, extent } = useChart('BarChart.YAxis');\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const horizontal = orientation === 'horizontal';\n\n const labels = useMemo(() => {\n if (horizontal) {\n // Sideways, the side of the chart is the category axis.\n return data.map((row, index) => ({\n key: index,\n text: String(row[xDataKey] ?? ''),\n }));\n }\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format, horizontal, data, xDataKey]);\n\n /*\n * Sideways the labels sit in the gutter the plot already left for them, one\n * band each — `flex-1` per row rather than spacing them edge to edge, so\n * every name lands beside its own bar instead of only the first and last\n * doing so. Upright there are no bands to line up with, so the ticks space\n * themselves against the grid.\n */\n return (\n \n {labels.map((label) => (\n \n \n {label.text}\n \n \n ))}\n \n );\n}\nBarChartYAxis.displayName = 'BarChart.YAxis';\nBarChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it\n// lays the plot out — an axis drawn over the plot is unreadable, and\n// makes what it is drawn over unreadable too.\nBarChartYAxis.axis = 'y' as const;\n\nexport interface BarChartTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: BarChartDatum) => string;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A line needs one because a point on a line has no\n * width of its own to point at; a bar is already the thing being pointed at,\n * so highlighting it and dimming the rest says the same thing without drawing\n * a line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds — and the thinner the bars, the truer that\n * gets.\n */\nfunction BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n orientation,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('BarChart.Tooltip');\n\n const total = data.length;\n const horizontal = orientation === 'horizontal';\n const left = plot.left;\n const top = plot.top;\n const width = plot.width;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. Sideways it has to\n * be clamped inside the plot vertically, and how tall it is depends on how\n * many series are listed in it — a constant here would either let a\n * three-series readout hang off the bottom or reserve room a one-series\n * readout never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number, y: number) => {\n 'worklet';\n if (!total) return;\n const span = horizontal ? height : width;\n const offset = (horizontal ? y - top : x - left) / (span || 1);\n // Bands, not points: the finger is inside whichever slice it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);\n\n /*\n * The readout centres over its band and is clamped inside the plot, so it\n * never runs off the edge at the first or last one.\n *\n * Which axis the band runs along is the whole difference between the two\n * orientations. Upright, the bands are side by side, so the readout slides\n * across and sits above them all. Sideways, the bands are stacked down the\n * plot, so it slides *down* to the row it is describing and stays centred\n * across — held at the top instead, it would name the row under the finger\n * while covering the first one, which is the row a reader checks it against.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = (horizontal ? plot.height : plot.width) / total;\n const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)\n );\n\n if (!horizontal) {\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n }\n\n // Until the first measurement lands the height is zero, which clamps to\n // the top of the plot — the same place it used to sit, rather than a jump\n // from somewhere it never was.\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return {\n opacity: 1,\n transform: [{ translateX: clamped - half }, { translateY: y }],\n };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: BarChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {fmtX(active)}\n \n {series.map(([key, color]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nBarChartTooltip.displayName = 'BarChart.Tooltip';\nBarChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BarChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/** A swatch and a name per series, in the order the series were declared. */\nfunction BarChartLegend({ className, labels, ...props }: BarChartLegendProps) {\n const { series } = useChart('BarChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nBarChartLegend.displayName = 'BarChart.Legend';\nBarChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BarChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `BarChart.Legend` on a chart that has a header: the legend floats over the\n * plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the bars, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a band's value when something is.\n */\nfunction BarChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: BarChartHeaderProps) {\n const { series } = useChart('BarChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nBarChartHeader.displayName = 'BarChart.Header';\nBarChartHeader.layer = 'header' as Layer;\n\nexport const BarChart = Object.assign(BarChartRoot, {\n Header: BarChartHeader,\n Grid: BarChartGrid,\n Bar: BarChartBar,\n Skeleton: BarChartSkeleton,\n XAxis: BarChartXAxis,\n YAxis: BarChartYAxis,\n Tooltip: BarChartTooltip,\n Legend: BarChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/bubble-chart.json b/apps/docs/public/r/bubble-chart.json index 11f291746..094f36ebf 100644 --- a/apps/docs/public/r/bubble-chart.json +++ b/apps/docs/public/r/bubble-chart.json @@ -20,7 +20,7 @@ { "path": "ui/bubble-chart.tsx", "type": "registry:ui", - "content": "/**\n * BubbleChart — one labelled circle per row, on two measured axes, with a third\n * quantity on each circle's area.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## When this and not a scatter plot\n *\n * `ScatterChart` also maps a third quantity onto point area, through `sizeKey`,\n * and it is the right component for a *series* of observations: many points of\n * one colour, where the shape of the cloud is the finding and no single dot\n * needs a name.\n *\n * This one is for a handful of named things. Each row is its own circle with\n * its own colour and its own label written inside it, so the chart can be read\n * entity by entity rather than as a distribution. Eight teams, twelve products,\n * six regions — where the reader wants to find one of them and see where it\n * sits.\n *\n * ## Area, not radius\n *\n * `sizeKey` maps to a circle's area. Doubling a radius quadruples the ink, so a\n * chart that scaled the radius would show a doubled value as four times the\n * size, and the reader would believe the picture. The scale runs over the whole\n * data set, so one bubble's size means the same thing as another's.\n *\n * ## Labels are text, not SVG\n *\n * The names inside the bubbles are React Native `Text` in a layer over the\n * plot, so they follow the theme's font and the platform's text scaling — SVG\n * text does neither. A bubble too small to hold its own label is left without\n * one rather than given an unreadable one; the readout still names it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\nimport {\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n bubbleRadius,\n compactNumber,\n niceDomain,\n useSeriesColor,\n xAt,\n yOf,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedG = Animated.createAnimatedComponent(G);\nconst AnimatedLine = Animated.createAnimatedComponent(SvgLine);\n\n/**\n * How much of the reveal is spent handing out the bubbles' start times. The\n * rest is the window each one gets, so the whole field still lands inside the\n * one duration however many there are.\n */\nconst STAGGER = 0.4;\n\n/** Milliseconds for a bubble to swell as it is selected, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/**\n * A bubble arriving: up past its size and back to it.\n *\n * A circle that simply grows to its radius reads as the chart still loading\n * right up to the last frame. The small overshoot is what makes it read as\n * landing.\n */\nfunction landing(t: number): number {\n 'worklet';\n const back = 1.3;\n const u = t - 1;\n return 1 + (back + 1) * u * u * u + back * u * u;\n}\n\n/** Room left around the plot for the axis labels and the outermost bubbles. */\nconst PADDING = { top: 18, right: 18, bottom: 22, left: 18 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each axis label is centred in, so a long number is ellipsised not shoved. */\nconst AXIS_LABEL_WIDTH = 56;\n\n/** Width of the readout that floats by the selected bubble. */\nconst LABEL_WIDTH = 132;\n\n/** Gap between the readout and the edge of the bubble it describes. */\nconst LABEL_GAP = 10;\n\n/** Line box a bubble's own label is laid out in. */\nconst BUBBLE_LABEL_HEIGHT = 16;\n\n/**\n * Smallest radius a bubble may have and still be given its label. Below this\n * the name is wider than the circle and reads as text lying on the plot rather\n * than as the bubble's own.\n *\n * Ten points, which is a twenty-point circle: enough for the initial or the\n * short code a bubble chart's labels usually are. Set higher and the smallest\n * bubble in an ordinary set silently loses its name, which reads as a bug\n * rather than as a decision.\n */\nconst LABEL_MIN_RADIUS = 10;\n\n/** Floor on the touch target, for a chart whose smallest bubbles are tiny. */\nconst HIT_RADIUS = 22;\n\n/** How many colours the ramp cycles through. */\nconst PALETTE_SIZE = 5;\n\n/** Steps each axis is rounded out to. Matches the labels an axis draws. */\nconst AXIS_STEPS = 4;\n\n/** Room under the numbers for an axis's own name, when it is given one. */\nconst AXIS_TITLE_HEIGHT = 18;\n\n/** Gap between a quadrant's caption and the corner it is written in. */\nconst QUADRANT_LABEL_INSET = 6;\n\n/**\n * How strongly the overlays draw: the quadrant crosshair, and the size key's\n * rings.\n *\n * Both are furniture rather than data, and both were faint enough to be missed\n * — the crosshair at 0.4 and the rings at 0.6, on a token that is already a\n * mid-grey chosen to stay behind the circles. The restraint has to come from\n * the token, not from a second reduction on top of it, or the mark is one\n * nobody finds. `Trend` has drawn at 0.7 all along and reads correctly in both\n * themes, so that is the number.\n */\nconst CROSSHAIR_OPACITY = 0.7;\nconst KEY_RING_OPACITY = 0.85;\n\n/** Column the size key's values are written in, beside its circles. */\nconst SIZE_KEY_LABEL_WIDTH = 44;\n\n/** Gap between the size key's circles and the values naming them. */\nconst SIZE_KEY_GAP = 6;\n\n/** Room beside the numbers for the y axis's name, which is turned on its side. */\nconst AXIS_TITLE_WIDTH = 18;\n\ntype Layer = 'svg' | 'overlay' | 'header' | 'footer';\n\nexport type BubbleChartStatus = 'loading' | 'ready';\n\nexport type BubbleChartDatum = Record;\n\n/** One bubble, resolved back to the row it came from. */\nexport interface BubbleChartPoint {\n /** Index into `data`. */\n index: number;\n x: number;\n y: number;\n /** The value behind the area, when `sizeKey` is set. */\n size: number | null;\n /** The name written inside the circle, when `labelKey` is set. */\n label: string;\n /** The colour it was drawn in. */\n color: string;\n datum: BubbleChartDatum;\n}\n\n/** A bubble with its geometry resolved. Shared by every part. */\ninterface ResolvedBubble extends BubbleChartPoint {\n /** Radius in points, off the area scale. */\n r: number;\n}\n\ninterface BubbleChartContextValue {\n data: BubbleChartDatum[];\n xDataKey: string;\n yDataKey: string;\n labelKey: string | undefined;\n plot: Plot;\n status: BubbleChartStatus;\n bubbles: ResolvedBubble[];\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n /** The settled domains, for the parts that draw text rather than geometry. */\n xExtent: [number, number];\n yExtent: [number, number];\n /** Lowest and highest value behind the areas, or null without a `sizeKey`. */\n sizeExtent: [number, number] | null;\n /** The radii those two values map onto. */\n sizeRange: [number, number];\n /** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActivePoint: (point: BubbleChartPoint | null) => void;\n}\n\nconst BubbleChartContext = createContext(null);\n\nfunction useChart(component: string): BubbleChartContextValue {\n const context = useContext(BubbleChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The bubble under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActivePointChange` for that.\n */\nexport function useBubbleChart() {\n const { bubbles, activeIndexJS } = useChart('useBubbleChart');\n const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;\n return { activeIndex: activeIndexJS, activePoint: active };\n}\n\n/**\n * An extent widened out to round numbers, and a usable one for the degenerate\n * cases — no data at all, or every reading identical. A domain of zero width\n * divides by zero and puts every bubble on the same edge.\n *\n * Rounded rather than padded by a fraction: a fraction of the data's own span\n * ends the axis at 52.7, which is true and which nobody was looking for. Two\n * steps, matching the labels each axis draws by default, so the middle one is\n * round as well as the ends.\n */\nfunction padExtent(min: number, max: number): [number, number] {\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n return niceDomain(min, max, AXIS_STEPS);\n}\n\nexport interface BubbleChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. One bubble each. */\n data: BubbleChartDatum[];\n /** Key holding the horizontal value. */\n xDataKey?: string;\n /** Key holding the vertical value. */\n yDataKey?: string;\n /**\n * Key holding the third quantity, mapped to each bubble's *area*. Without it\n * every bubble is drawn at the middle of `sizeRange` and the chart is a\n * scatter plot with names on it.\n */\n sizeKey?: string;\n /** Key holding the name written inside the circle. */\n labelKey?: string;\n /**\n * Key holding a colour for the row — either a CSS colour or a number from 1\n * to 5 naming a `--color-chart-*` token. Without it the ramp cycles by row.\n */\n colorKey?: string;\n /**\n * Smallest and largest radius `sizeKey` maps onto, in points. The largest is\n * also what the plot holds back at every edge, so raising it costs room.\n */\n sizeRange?: [number, number];\n /**\n * `loading` shows a still field of muted circles and dissolves it as the real\n * bubbles grow in. One component throughout, rather than a spinner swapped\n * for a chart — swapping loses the transition. Add a `BubbleChart.Skeleton`\n * for something to stand in the plot meanwhile.\n */\n status?: BubbleChartStatus;\n /** Width ÷ height. `1` is the square shape a bubble field reads best in. */\n aspectRatio?: number;\n /** Milliseconds for the bubbles to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the axes to settle after the data changes. */\n domainDuration?: number;\n /** Fix the horizontal axis instead of deriving it. */\n xDomain?: [number, number];\n /** Fix the vertical axis instead of deriving it. */\n yDomain?: [number, number];\n /** The bubble under the finger, and `null` when it lifts. */\n onActivePointChange?: (point: BubbleChartPoint | null) => void;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface BubbleChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n const footer: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const into =\n layer === 'header' ? header : layer === 'footer' ? footer : layer === 'overlay' ? overlay : svg;\n into.push(slot);\n });\n return { svg, overlay, header, footer };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst BubbleChartRoot = forwardRef(\n function BubbleChartRoot(\n {\n className,\n data,\n xDataKey = 'x',\n yDataKey = 'y',\n sizeKey,\n labelKey,\n colorKey,\n sizeRange = [10, 28],\n status = 'ready',\n aspectRatio = 1,\n animationDuration = 800,\n domainDuration = 500,\n xDomain,\n yDomain,\n onActivePointChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const xMin = useSharedValue(0);\n const xMax = useSharedValue(0);\n const yMin = useSharedValue(0);\n const yMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n /*\n * The whole ramp, resolved once. Every bubble is its own category here\n * rather than a member of a series, so the colour is chosen by row index\n * and there is nothing to register.\n */\n const chart1 = useSeriesColor(undefined, 1);\n const chart2 = useSeriesColor(undefined, 2);\n const chart3 = useSeriesColor(undefined, 3);\n const chart4 = useSeriesColor(undefined, 4);\n const chart5 = useSeriesColor(undefined, 5);\n const palette = useMemo(\n () => [chart1, chart2, chart3, chart4, chart5],\n [chart1, chart2, chart3, chart4, chart5]\n );\n\n /*\n * What the axes are going to need before anything has been laid out. The\n * gutter for the y labels and the strip under the x ones are the plot's\n * padding, so they have to be known here rather than by the parts that draw\n * them — a part that reserved its own room would be positioned against a\n * plot that had already been sized without it.\n */\n const axes = useMemo(() => {\n let y = false;\n let yTitle = false;\n let xTitle = false;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const axis = (child.type as { axis?: string }).axis;\n const labelled = Boolean((child.props as { label?: string }).label);\n if (axis === 'y') {\n y = true;\n if (labelled) yTitle = true;\n }\n if (axis === 'x' && labelled) xTitle = true;\n });\n return { y, yTitle, xTitle };\n }, [children]);\n\n /*\n * A circle is drawn about its centre, so every edge of the plot has to hold\n * back the largest radius or the outermost bubble is cropped by it — and\n * the largest bubble is the one carrying the largest value, which is the\n * last one that should be half missing. `sizeRange` is the ceiling, so this\n * is known before anything is measured.\n */\n const reach = sizeRange[1];\n const pad = {\n top: Math.max(PADDING.top, reach),\n right: Math.max(PADDING.right, reach),\n bottom:\n Math.max(PADDING.bottom, reach) + (axes.xTitle ? AXIS_TITLE_HEIGHT : 0),\n left: Math.max(\n axes.y ? Y_AXIS_WIDTH + (axes.yTitle ? AXIS_TITLE_WIDTH : 0) : PADDING.left,\n reach\n ),\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const extents = useMemo(() => {\n let lowX = Infinity;\n let highX = -Infinity;\n let lowY = Infinity;\n let highY = -Infinity;\n for (const row of data) {\n const x = row[xDataKey];\n const y = row[yDataKey];\n // A row missing either coordinate is not a bubble at all, and must not\n // stretch the axes towards an origin it never had.\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n if (x < lowX) lowX = x;\n if (x > highX) highX = x;\n if (y < lowY) lowY = y;\n if (y > highY) highY = y;\n }\n return {\n x: xDomain ?? padExtent(lowX, highX),\n y: yDomain ?? padExtent(lowY, highY),\n };\n }, [data, xDataKey, yDataKey, xDomain, yDomain]);\n\n /*\n * The size scale runs over the whole data set, so one bubble's area means\n * the same thing as another's. Without a `sizeKey` there is nothing to\n * scale and every bubble takes the middle of the range — which is a\n * scatter plot with names on it, and an honest one.\n */\n const sizeExtent = useMemo<[number, number] | null>(() => {\n if (!sizeKey) return null;\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n const value = row[sizeKey];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n return min === Infinity ? null : [min, max];\n }, [data, sizeKey]);\n\n /*\n * Resolved once, in the root, because four parts need exactly this list and\n * three of them would otherwise derive it again: the circles, the labels\n * over them, the hit test under them and the legend beside them all have to\n * agree about where a bubble is and how big it is.\n */\n const bubbles = useMemo(() => {\n const middle = (sizeRange[0] + sizeRange[1]) / 2;\n const out: ResolvedBubble[] = [];\n data.forEach((datum, index) => {\n const x = datum[xDataKey];\n const y = datum[yDataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) return;\n if (typeof y !== 'number' || Number.isNaN(y)) return;\n\n const raw = sizeKey ? datum[sizeKey] : undefined;\n const value = typeof raw === 'number' && !Number.isNaN(raw) ? raw : null;\n const r =\n sizeExtent && value !== null\n ? bubbleRadius(value, sizeExtent, sizeRange)\n : middle;\n\n const explicit = colorKey ? datum[colorKey] : undefined;\n const color =\n typeof explicit === 'string'\n ? explicit\n : typeof explicit === 'number'\n ? (palette[(Math.round(explicit) - 1 + PALETTE_SIZE) % PALETTE_SIZE] ??\n palette[0]!)\n : (palette[index % PALETTE_SIZE] ?? palette[0]!);\n\n out.push({\n index,\n x,\n y,\n r,\n size: value,\n label: labelKey ? String(datum[labelKey] ?? '') : '',\n color,\n datum,\n });\n });\n return out;\n }, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, sizeExtent, sizeRange, palette]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [x0, x1] = extents.x;\n const [y0, y1] = extents.y;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first =\n xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;\n if (first || reducedMotion) {\n xMin.value = x0;\n xMax.value = x1;\n yMin.value = y0;\n yMax.value = y1;\n return;\n }\n xMin.value = withTiming(x0, { duration: domainDuration });\n xMax.value = withTiming(x1, { duration: domainDuration });\n yMin.value = withTiming(y0, { duration: domainDuration });\n yMax.value = withTiming(y1, { duration: domainDuration });\n }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n /*\n * Eased out rather than in and out. Each bubble is given a slice of\n * this one clock, so an ease that dawdles at the start spends it on the\n * first few and leaves the rest to arrive in a rush.\n */\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !bubbles.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, bubbles.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n // One place the selection lands, so the chart's own children and a readout\n // outside it never disagree about which bubble is active.\n const setActivePoint = useMemo(\n () => (point: BubbleChartPoint | null) => {\n setActiveIndexJS(point ? point.index : -1);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n yDataKey,\n labelKey,\n plot,\n status,\n bubbles,\n xMin,\n xMax,\n yMin,\n yMax,\n xExtent: extents.x,\n yExtent: extents.y,\n sizeExtent,\n sizeRange,\n reveal,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n yDataKey,\n labelKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n bubbles,\n xMin,\n xMax,\n yMin,\n yMax,\n extents,\n sizeExtent,\n sizeRange,\n reveal,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n ]\n );\n\n const { svg, overlay, header, footer } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n {\n const pairs: [string, unknown][] = [\n [xDataKey, datum[xDataKey]],\n [yDataKey, datum[yDataKey]],\n ];\n if (labelKey) pairs.unshift([labelKey, datum[labelKey]]);\n if (sizeKey) pairs.push([sizeKey, datum[sizeKey]]);\n return pairs;\n }}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n {footer}\n \n \n );\n }\n);\nBubbleChartRoot.displayName = 'BubbleChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartGridProps {\n /**\n * Horizontal rules across the plot.\n *\n * Eight, which is twice the four intervals an axis is divided into by\n * default, so every second line carries a number and the ones between it are\n * halves of a labelled step rather than an unrelated rhythm. Squares this\n * size recede behind the circles; the coarse grid a smaller number draws\n * reads as blocks laid over the plot.\n */\n rows?: number;\n /** Vertical rules up it. Both axes are measured, so both earn lines. */\n columns?: number;\n /** Dash pattern for the rules. Pass `undefined` for solid ones. */\n dashArray?: string;\n color?: string;\n opacity?: number;\n}\n\n/** Reference lines both ways, so a bubble can be placed against two numbers. */\nfunction BubbleChartGrid({\n rows = 8,\n columns = 8,\n dashArray = '4,6',\n color,\n opacity = 1,\n}: BubbleChartGridProps) {\n const { plot } = useChart('BubbleChart.Grid');\n const token = useCSSVariable('--color-border');\n // The fallback is grey rather than black: it stands in when the theme cannot\n // be read, and a black hairline is invisible on a dark background.\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows);\n const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns);\n\n return (\n \n {horizontals.map((fraction) => (\n \n ))}\n {verticals.map((fraction) => (\n \n ))}\n \n );\n}\nBubbleChartGrid.displayName = 'BubbleChart.Grid';\nBubbleChartGrid.layer = 'svg' as Layer;\n\nexport interface BubbleChartTrendProps {\n /**\n * The line's slope and intercept, and how tightly the cloud sits on it, once\n * they have been computed. `r` runs 0 to 1: 1 is every bubble on the line,\n * 0 is a cloud with no direction at all.\n *\n * Given here rather than left for the caller to work out, because the fit is\n * already being computed to draw the line and doing it twice invites the two\n * answers to disagree.\n *\n * It fires when the numbers change, not on every render that produced the\n * same ones, so putting the fit straight into state is safe.\n */\n onFit?: (fit: { slope: number; intercept: number; r: number }) => void;\n color?: string;\n strokeWidth?: number;\n /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * The straight line that fits the cloud best, drawn across the plot.\n *\n * It is dashed and drawn under the circles, because it is not data — it is a\n * summary of the data, and a solid rule through the middle of a field of\n * bubbles reads as a value somebody plotted.\n *\n * The fit is least squares on the raw values, so it moves with the data rather\n * than with the frame: resizing the chart never changes the line's meaning.\n * Fewer than two bubbles, or every bubble on one vertical, has no line to draw\n * and none is drawn.\n */\nfunction BubbleChartTrend({\n onFit,\n color,\n strokeWidth = 1.5,\n dashArray = '6,5',\n opacity = 0.7,\n}: BubbleChartTrendProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =\n useChart('BubbleChart.Trend');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');\n\n const fit = useMemo(() => {\n const n = bubbles.length;\n if (n < 2) return null;\n let sumX = 0;\n let sumY = 0;\n for (const bubble of bubbles) {\n sumX += bubble.x;\n sumY += bubble.y;\n }\n const meanX = sumX / n;\n const meanY = sumY / n;\n let sxy = 0;\n let sxx = 0;\n let syy = 0;\n for (const bubble of bubbles) {\n const dx = bubble.x - meanX;\n const dy = bubble.y - meanY;\n sxy += dx * dy;\n sxx += dx * dx;\n syy += dy * dy;\n }\n // Every bubble on one vertical: the best fit is that vertical, which has no\n // slope and nothing useful to draw.\n if (sxx === 0) return null;\n const slope = sxy / sxx;\n const denominator = Math.sqrt(sxx * syy);\n return {\n slope,\n intercept: meanY - slope * meanX,\n r: denominator === 0 ? 0 : Math.abs(sxy / denominator),\n };\n }, [bubbles]);\n\n const fitRef = useRef(onFit);\n useEffect(() => {\n fitRef.current = onFit;\n });\n\n /*\n * Reported when the numbers change, not when the object does.\n *\n * `bubbles` is rebuilt whenever any of its inputs changes identity, and\n * `sizeRange={[14, 30]}` written at a call site is a new array every render —\n * so the fit is a new object every render even when the data has not moved.\n * Handing that to a caller who puts it in state is a render loop, and the\n * caller has no way to see that coming.\n */\n const reported = useRef<{ slope: number; intercept: number; r: number } | null>(null);\n useEffect(() => {\n if (!fit) return;\n const last = reported.current;\n if (\n last &&\n last.slope === fit.slope &&\n last.intercept === fit.intercept &&\n last.r === fit.r\n ) {\n return;\n }\n reported.current = fit;\n fitRef.current?.(fit);\n }, [fit]);\n\n const slope = fit?.slope ?? 0;\n const intercept = fit?.intercept ?? 0;\n\n const animatedProps = useAnimatedProps(() => {\n const x0 = xMin.value;\n const x1 = xMax.value;\n const lowY = yMin.value;\n const highY = yMax.value;\n\n /*\n * Solved in data space and then clipped there, rather than drawn across the\n * plot and clipped by the frame: a line that leaves the top of the chart\n * has to stop where it leaves it, and the x of that point is only knowable\n * from the equation.\n */\n let ax = x0;\n let bx = x1;\n if (slope !== 0) {\n const atLow = (lowY - intercept) / slope;\n const atHigh = (highY - intercept) / slope;\n const enter = Math.min(atLow, atHigh);\n const exit = Math.max(atLow, atHigh);\n ax = Math.max(ax, enter);\n bx = Math.min(bx, exit);\n }\n if (bx < ax) {\n // The line never crosses the visible box.\n return { x1: 0, x2: 0, y1: 0, y2: 0, opacity: 0 };\n }\n\n // Drawn out from the middle as the bubbles land, so the line arrives with\n // the field rather than being there waiting for it.\n const grown = Math.max(0, Math.min(1, reveal.value));\n const midpoint = (ax + bx) / 2;\n const half = ((bx - ax) / 2) * grown;\n\n const startX = midpoint - half;\n const endX = midpoint + half;\n return {\n x1: xAt(startX, plot, x0, x1),\n x2: xAt(endX, plot, x0, x1),\n y1: yOf(intercept + slope * startX, plot, lowY, highY),\n y2: yOf(intercept + slope * endX, plot, lowY, highY),\n opacity: opacity * grown,\n };\n });\n\n if (status === 'loading' || !fit) return null;\n\n return (\n \n );\n}\nBubbleChartTrend.displayName = 'BubbleChart.Trend';\nBubbleChartTrend.layer = 'svg' as Layer;\n\nexport interface BubbleChartBubblesProps {\n /**\n * Fill opacity. Below 1 by default so that overlapping bubbles read as\n * denser rather than hiding each other — in a crowded corner that overlap\n * *is* the finding, and opaque circles erase it.\n */\n opacity?: number;\n /** One colour for every bubble, overriding the per-row ramp. */\n color?: string;\n}\n\n/** The circles. */\nfunction BubbleChartBubbles({ opacity = 0.9, color }: BubbleChartBubblesProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal, activeIndex } =\n useChart('BubbleChart.Bubbles');\n\n if (status === 'loading') return null;\n\n return (\n \n {bubbles.map((bubble, order) => (\n \n ))}\n \n );\n}\nBubbleChartBubbles.displayName = 'BubbleChart.Bubbles';\nBubbleChartBubbles.layer = 'svg' as Layer;\n\n/**\n * One bubble. Its position follows both domain tweens, so a data change moves\n * the whole field to the new scale rather than cutting to it.\n *\n * It arrives by growing in place, on its own slice of the shared reveal. A wipe\n * across the plot — which is what the line and area charts do — gives the\n * reader a direction to read the arrival in, and a field of bubbles has none:\n * position is the whole message, so a bubble may only ever appear where it\n * belongs.\n */\nfunction Bubble({\n bubble,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n fill,\n opacity,\n activeIndex,\n reveal,\n order,\n total,\n}: {\n bubble: ResolvedBubble;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n fill: string;\n opacity: number;\n activeIndex: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n}) {\n const { index, x, y, r } = bubble;\n\n /*\n * The selection, as something that moves. A hard switch made the bubble it\n * named jump between one frame and the next while every neighbour stayed put,\n * which reads as a glitch rather than as a response to the finger.\n */\n const selected = useDerivedValue(() =>\n withTiming(activeIndex.value === index ? 1 : 0, { duration: SELECT_DURATION })\n );\n\n // Where in the reveal this bubble starts. Spread over `STAGGER`, so the field\n // settles as a field rather than switching on all at once.\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const animatedProps = useAnimatedProps(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n // The selected bubble swells and goes solid. Both, rather than one: a size\n // change alone is easy to miss among neighbours, and an opacity change\n // alone is invisible wherever the circles already overlap.\n const swell = 1 + 0.12 * selected.value;\n return {\n cx: xAt(x, plot, xMin.value, xMax.value),\n cy: yOf(y, plot, yMin.value, yMax.value),\n r: r * landing(arrived) * swell,\n // Ahead of the size, so a bubble is legible by the time it stops moving\n // rather than fading in for the whole of its arrival.\n fillOpacity:\n Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),\n };\n });\n\n return ;\n}\n\nexport interface BubbleChartSkeletonProps {\n /** How many placeholder circles to scatter. */\n count?: number;\n color?: string;\n}\n\n/**\n * The loading state: a still field of muted circles where the data will be.\n *\n * Deliberately still. A shimmer over a field of circles reads as them *moving*,\n * which is the one thing this chart must never appear to do — position is the\n * entire message, and a loading state that implies it is changing is a loading\n * state that lies.\n *\n * The layout is deterministic rather than random, so it does not reshuffle on\n * every render of a component that may re-render several times while waiting.\n * It dissolves as the real bubbles grow in, and outlives the status change by\n * exactly that long: cut at the frame the data lands, the placeholder would\n * disappear before anything had replaced it.\n */\nfunction BubbleChartSkeleton({ count = 7, color }: BubbleChartSkeletonProps) {\n const { plot, status } = useChart('BubbleChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');\n\n const circles = useMemo(\n () =>\n // Two irrational-ish strides that do not share a factor, so the circles\n // spread instead of falling into a lattice.\n Array.from({ length: count }, (_unused, index) => ({\n key: index,\n fx: ((index * 0.618) % 1) * 0.84 + 0.08,\n fy: ((index * 0.379) % 1) * 0.84 + 0.08,\n r: 16 + ((index * 0.472) % 1) * 12,\n })),\n [count]\n );\n\n const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));\n\n if (!mounted) return null;\n\n return (\n \n {circles.map((circle) => (\n \n ))}\n \n );\n}\nBubbleChartSkeleton.displayName = 'BubbleChart.Skeleton';\nBubbleChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartLabelsProps {\n /**\n * Smallest radius a bubble may have and still be given its label. Below it\n * the name is wider than the circle it names.\n */\n minRadius?: number;\n /** Turn a bubble into its label. Defaults to the value at `labelKey`. */\n format?: (point: BubbleChartPoint) => string;\n className?: string;\n}\n\n/**\n * The names, written inside the circles.\n *\n * Real text over the plot rather than SVG text, so they follow the theme's font\n * and the platform's text scaling. Each one rides the same domain tweens the\n * circle under it does, so a label never lags the bubble it belongs to.\n *\n * A bubble too small to hold its name is left without one. Shrinking the text\n * to fit would make it unreadable on exactly the bubbles the reader is\n * squinting at already; the readout names those instead.\n */\nfunction BubbleChartLabels({\n minRadius = LABEL_MIN_RADIUS,\n format,\n className,\n}: BubbleChartLabelsProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =\n useChart('BubbleChart.Labels');\n\n if (status === 'loading') return null;\n\n return (\n \n {bubbles\n .filter((bubble) => bubble.r >= minRadius && (format || bubble.label))\n .map((bubble, order) => (\n \n ))}\n \n );\n}\nBubbleChartLabels.displayName = 'BubbleChart.Labels';\nBubbleChartLabels.layer = 'overlay' as Layer;\n\nexport interface BubbleChartQuadrantsProps {\n /** Where the vertical rule stands. Defaults to the mean of the x values. */\n x?: number;\n /** Where the horizontal rule lies. Defaults to the mean of the y values. */\n y?: number;\n /** A word for each corner, written in the corner it belongs to. */\n labels?: {\n topLeft?: string;\n topRight?: string;\n bottomLeft?: string;\n bottomRight?: string;\n };\n /** Tint the high-high and low-low corners. On by default. */\n tint?: boolean;\n color?: string;\n className?: string;\n}\n\n/**\n * A crosshair splitting the plot into four, with a name for each corner.\n *\n * A field of bubbles is usually read as four groups rather than as a cloud —\n * which of these is doing well on both counts, which on neither — and without\n * a divider the reader draws that line by eye, in a different place each time.\n * Putting it on the chart makes it one line everybody sees.\n *\n * It stands at the mean of each axis by default, because that is the split the\n * data itself argues for. Pass `x` and `y` for a target, a budget or last\n * year's number — a threshold somebody decided rather than one the data\n * produced.\n *\n * The tint marks the two corners a reading usually ends at. Turn it off where\n * all four corners matter equally.\n */\nfunction BubbleChartQuadrants({\n x,\n y,\n labels,\n tint = true,\n color,\n className,\n}: BubbleChartQuadrantsProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax } =\n useChart('BubbleChart.Quadrants');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');\n\n const centre = useMemo(() => {\n if (!bubbles.length) return null;\n let sumX = 0;\n let sumY = 0;\n for (const bubble of bubbles) {\n sumX += bubble.x;\n sumY += bubble.y;\n }\n return { x: x ?? sumX / bubbles.length, y: y ?? sumY / bubbles.length };\n }, [bubbles, x, y]);\n\n const atX = centre?.x ?? 0;\n const atY = centre?.y ?? 0;\n\n const verticalStyle = useAnimatedStyle(() => ({\n left: xAt(atX, plot, xMin.value, xMax.value),\n }));\n const horizontalStyle = useAnimatedStyle(() => ({\n top: yOf(atY, plot, yMin.value, yMax.value),\n }));\n /*\n * Two rectangles rather than four: the pair that is tinted is the pair the\n * reader is being pointed at, and shading all four would only be a checked\n * background.\n */\n const highStyle = useAnimatedStyle(() => {\n const cx = xAt(atX, plot, xMin.value, xMax.value);\n const cy = yOf(atY, plot, yMin.value, yMax.value);\n return {\n left: cx,\n top: plot.top,\n width: Math.max(plot.left + plot.width - cx, 0),\n height: Math.max(cy - plot.top, 0),\n };\n });\n const lowStyle = useAnimatedStyle(() => {\n const cx = xAt(atX, plot, xMin.value, xMax.value);\n const cy = yOf(atY, plot, yMin.value, yMax.value);\n return {\n left: plot.left,\n top: cy,\n width: Math.max(cx - plot.left, 0),\n height: Math.max(plot.top + plot.height - cy, 0),\n };\n });\n\n if (status === 'loading' || !centre) return null;\n\n const corner = {\n position: 'absolute' as const,\n width: plot.width / 2 - QUADRANT_LABEL_INSET,\n };\n\n return (\n \n {tint ? (\n <>\n \n \n \n ) : null}\n {/*\n The same weight `Trend` draws at, because it is the same kind of mark:\n a reference laid over the field rather than a reading taken from it.\n At 0.4 of a mid-grey the split was a line you had to already know was\n there, which leaves four labelled corners and nothing dividing them.\n */}\n \n \n {/*\n Pinned to the plot's corners rather than to the crosshair. A caption\n names the region, and a region's name belongs at the far end of it —\n following the rules it would crowd them as the split moved.\n */}\n {labels?.topLeft ? (\n \n {labels.topLeft}\n \n ) : null}\n {labels?.topRight ? (\n \n {labels.topRight}\n \n ) : null}\n {labels?.bottomLeft ? (\n \n {labels.bottomLeft}\n \n ) : null}\n {labels?.bottomRight ? (\n \n {labels.bottomRight}\n \n ) : null}\n \n );\n}\nBubbleChartQuadrants.displayName = 'BubbleChart.Quadrants';\nBubbleChartQuadrants.layer = 'overlay' as Layer;\n\nexport interface BubbleChartSizeKeyProps {\n /** Which corner of the plot it sits in. */\n placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** A word for what the area means — \"people\", \"revenue\". */\n label?: string;\n className?: string;\n}\n\n/**\n * Three nested circles saying what a bubble's area is worth.\n *\n * A bubble chart's third quantity is the one it cannot state: position can be\n * read off the axes, but area has no axis, so a reader can see that one circle\n * is bigger than another and has no way to know by how much. This is the only\n * part of the chart that answers that.\n *\n * Nested and sharing a baseline, which is how a difference in area is compared\n * — three circles in a row are three sizes, three circles inside one another\n * are one scale.\n *\n * It needs a `sizeKey` on the chart. Without one every bubble is the same size\n * and there is no scale to key.\n */\nfunction BubbleChartSizeKey({\n placement = 'bottom-right',\n format,\n label,\n className,\n}: BubbleChartSizeKeyProps) {\n const { plot, status, sizeExtent, sizeRange } = useChart('BubbleChart.SizeKey');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = typeof token === 'string' ? token : 'rgba(128,128,128,0.8)';\n\n const steps = useMemo(() => {\n if (!sizeExtent) return null;\n const [min, max] = sizeExtent;\n const middle = (min + max) / 2;\n // Largest first, so the smallest is drawn last and stays visible inside it.\n return [max, middle, min].map((value) => ({\n value,\n r: bubbleRadius(value, sizeExtent, sizeRange),\n }));\n }, [sizeExtent, sizeRange]);\n\n if (status === 'loading' || !steps) return null;\n\n const outer = steps[0]!.r;\n const width = outer * 2 + SIZE_KEY_LABEL_WIDTH;\n const height = outer * 2 + (label ? AXIS_LABEL_HEIGHT : 0);\n const top = placement.startsWith('top')\n ? plot.top\n : plot.top + plot.height - height;\n const left = placement.endsWith('left')\n ? plot.left\n : plot.left + plot.width - width;\n\n return (\n \n {label ? (\n \n {label}\n \n ) : null}\n \n {steps.map((step) => (\n \n ))}\n {steps.map((step) => (\n \n {format ? format(step.value) : compactNumber(step.value)}\n \n ))}\n \n \n );\n}\nBubbleChartSizeKey.displayName = 'BubbleChart.SizeKey';\nBubbleChartSizeKey.layer = 'overlay' as Layer;\n\nfunction BubbleLabel({\n bubble,\n text,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n reveal,\n order,\n total,\n className,\n}: {\n bubble: ResolvedBubble;\n text: string;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n className?: string;\n}) {\n const { x, y, r } = bubble;\n const width = r * 2;\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const style = useAnimatedStyle(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n return {\n opacity: Math.max(0, arrived * 2 - 1),\n transform: [\n { translateX: xAt(x, plot, xMin.value, xMax.value) - width / 2 },\n { translateY: yOf(y, plot, yMin.value, yMax.value) - BUBBLE_LABEL_HEIGHT / 2 },\n ],\n };\n });\n\n return (\n \n \n {text}\n \n \n );\n}\n\nexport interface BubbleChartXAxisProps {\n /**\n * How many intervals to divide the axis into. Yields `ticks + 1` labels.\n *\n * Four, and the domain is rounded out to four steps to match, so the numbers\n * come out round. Fewer leaves most of the grid unnamed — a line with nothing\n * beside it is a line the reader has to count their way to.\n */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** What the axis measures, written under the numbers. */\n label?: string;\n className?: string;\n}\n\n/**\n * The x labels, evenly along the axis.\n *\n * Evenly spaced, because this axis is a continuous scale rather than a list of\n * rows. There is no bubble for a label to sit under.\n */\nfunction BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXAxisProps) {\n const { plot, xExtent } = useChart('BubbleChart.XAxis');\n\n const labels = useMemo(() => {\n const [min, max] = xExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = min + ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [xExtent, ticks, format]);\n\n return (\n \n {label ? (\n \n {label}\n \n ) : null}\n {labels.map((tick) => (\n \n {tick.text}\n \n ))}\n \n );\n}\nBubbleChartXAxis.displayName = 'BubbleChart.XAxis';\nBubbleChartXAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room under the numbers before it lays\n// the plot out.\nBubbleChartXAxis.axis = 'x' as const;\n\nexport interface BubbleChartYAxisProps {\n /**\n * How many intervals to divide the axis into. Yields `ticks + 1` labels.\n *\n * Four, matching the four steps the domain is rounded out to and every second\n * line of the default grid.\n */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** What the axis measures, written up the side of it. */\n label?: string;\n className?: string;\n}\n\n/**\n * Value labels down the side, evenly over the axis, and the gutter they sit in.\n *\n * They land on every second line of the default grid rather than on all of\n * them: a number beside every line of a grid fine enough to read against is a\n * column of numbers, and the reader stops seeing the chart.\n */\nfunction BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYAxisProps) {\n const { plot, yExtent } = useChart('BubbleChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = yExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [yExtent, ticks, format]);\n\n const titleWidth = label ? AXIS_TITLE_WIDTH : 0;\n\n return (\n <>\n {label ? (\n /*\n * Turned on its side, which is the only way a word fits in a gutter\n * sized for numbers. The box is laid out as tall as the plot and then\n * rotated about its own centre, so the text runs the length of the axis\n * it names rather than of whatever it happens to say.\n */\n \n \n {label}\n \n \n ) : null}\n \n {labels.map((tick) => (\n \n {tick.text}\n \n ))}\n \n \n );\n}\nBubbleChartYAxis.displayName = 'BubbleChart.YAxis';\nBubbleChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nBubbleChartYAxis.axis = 'y' as const;\n\nexport interface BubbleChartTooltipProps {\n /** Float a small readout beside the selected bubble. On by default. */\n showLabel?: boolean;\n /** Format the x value for the readout. Defaults to a compact number. */\n formatX?: (value: number) => string;\n /** Format the y value for the readout. Defaults to a compact number. */\n formatY?: (value: number) => string;\n /** Format the size value for the readout. Defaults to a compact number. */\n formatSize?: (value: number) => string;\n /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */\n hitRadius?: number;\n className?: string;\n}\n\n/**\n * The touch target, the selection it drives, and the readout that follows it.\n *\n * A touch picks the nearest bubble whose own circle — or the `hitRadius` floor,\n * whichever is larger — reaches the finger. Nearest rather than topmost,\n * because where bubbles overlap the one drawn last is not the one being aimed\n * at.\n *\n * The search runs on the UI thread over flat arrays of already-projected\n * coordinates, and only the *index* of the winner crosses back into JS, and\n * only when it changes. A drag across the plot therefore costs a handful of\n * re-renders rather than one per frame.\n *\n * Distances are compared squared. The nearest bubble by distance is the nearest\n * by distance-squared, and a square root per bubble per frame buys nothing.\n */\nfunction BubbleChartTooltip({\n showLabel = true,\n formatX,\n formatY,\n formatSize,\n hitRadius = HIT_RADIUS,\n className,\n}: BubbleChartTooltipProps) {\n const {\n bubbles,\n plot,\n xExtent,\n yExtent,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n status,\n } = useChart('BubbleChart.Tooltip');\n\n /*\n * Every bubble, projected once, as parallel arrays.\n *\n * Parallel arrays rather than an array of objects because this is read inside\n * a worklet: Reanimated has to copy whatever the gesture captures across to\n * the UI thread, and four number arrays cross far more cheaply than a few\n * hundred small objects.\n *\n * Projected against the *settled* extents rather than the tweening shared\n * values. Hit-testing against a moving scale would mean rebuilding this on\n * every frame of a domain animation, and a bubble being half a second stale\n * during a transition is not something a finger can notice.\n */\n const hit = useMemo(() => {\n const xs: number[] = [];\n const ys: number[] = [];\n const rs: number[] = [];\n const limits: number[] = [];\n const indices: number[] = [];\n for (const bubble of bubbles) {\n xs.push(xAt(bubble.x, plot, xExtent[0], xExtent[1]));\n ys.push(yOf(bubble.y, plot, yExtent[0], yExtent[1]));\n rs.push(bubble.r);\n const reach = Math.max(bubble.r, hitRadius);\n limits.push(reach * reach);\n indices.push(bubble.index);\n }\n return { xs, ys, rs, limits, indices };\n }, [bubbles, plot, xExtent, yExtent, hitRadius]);\n\n const select = useMemo(\n () => (index: number) => {\n if (index < 0) {\n setActivePoint(null);\n return;\n }\n setActivePoint(bubbles.find((bubble) => bubble.index === index) ?? null);\n },\n [bubbles, setActivePoint]\n );\n\n /*\n * Built in one closure, and everything it captures is a plain array, a number\n * or a shared value. A worklet may only call another worklet, and the rule is\n * enforced by crashing rather than by warning — so the resolver is declared\n * here, next to its callers, rather than as a helper elsewhere in the file\n * where it would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const xs = hit.xs;\n const ys = hit.ys;\n const limits = hit.limits;\n const indices = hit.indices;\n\n const resolve = (px: number, py: number) => {\n 'worklet';\n let bestIndex = -1;\n let best = Infinity;\n for (let i = 0; i < xs.length; i += 1) {\n const dx = xs[i]! - px;\n const dy = ys[i]! - py;\n const distance = dx * dx + dy * dy;\n // Each bubble reaches as far as it is big, so a large one is not\n // beaten by a small one that happens to be marginally nearer.\n if (distance <= limits[i]! && distance < best) {\n best = distance;\n bestIndex = indices[i]!;\n }\n }\n if (bestIndex === activeIndex.value) return;\n activeIndex.value = bestIndex;\n runOnJS(select)(bestIndex);\n };\n\n const clear = () => {\n 'worklet';\n if (activeIndex.value === -1) return;\n activeIndex.value = -1;\n runOnJS(select)(-1);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n clear();\n });\n }, [hit, activeIndex, select]);\n\n /*\n * The readout's own height, measured rather than assumed. It decides whether\n * the readout fits above the bubble, and how tall it is depends on whether\n * the row has a label and a size — a constant would either overlap a\n * three-line readout or reserve room a one-line one never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Above the bubble, clear of its edge rather than of its centre, and clamped\n * inside the plot. Lifted by a constant it landed *on* the larger circles —\n * which are exactly the ones a finger is most likely to be resting on, so the\n * readout was hidden under the hand that summoned it.\n *\n * Where there is no room above, it goes below instead. Sliding it down to the\n * top edge of the plot would leave it over the bubble again.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const at = hit.indices.indexOf(index);\n if (at < 0) return { opacity: 0 };\n const x = hit.xs[at]!;\n const y = hit.ys[at]!;\n const r = hit.rs[at]!;\n const half = LABEL_WIDTH / 2;\n const tall = labelHeight.value;\n\n const above = y - r - LABEL_GAP - tall;\n const below = y + r + LABEL_GAP;\n const top = above >= plot.top ? above : below;\n\n return {\n opacity: 1,\n transform: [\n {\n translateX:\n Math.min(plot.left + plot.width - half, Math.max(plot.left + half, x)) - half,\n },\n { translateY: top },\n ],\n };\n });\n\n const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;\n const fmtX = formatX ?? compactNumber;\n const fmtY = formatY ?? compactNumber;\n const fmtSize = formatSize ?? compactNumber;\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLabel ? (\n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n {active.label ? (\n \n \n \n {active.label}\n \n \n ) : null}\n \n {fmtX(active.x)}, {fmtY(active.y)}\n \n {active.size !== null ? (\n \n {fmtSize(active.size)}\n \n ) : null}\n \n ) : null}\n \n ) : null}\n \n \n );\n}\nBubbleChartTooltip.displayName = 'BubbleChart.Tooltip';\nBubbleChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BubbleChartLegendProps extends ViewProps {\n className?: string;\n /** Cap on how many bubbles are named. The rest are left to the readout. */\n limit?: number;\n}\n\n/**\n * A swatch and a name per bubble, for a chart whose circles are too small to\n * carry their own labels.\n *\n * Drawn **under** the plot rather than floating in a corner of it. A key that\n * overlays the drawing area competes with the bubbles for the space they are\n * plotted in, and on a square chart there is no corner that is reliably empty —\n * the position of a bubble is the data, so nowhere can be reserved for it.\n *\n * It lists rows rather than series, because in this chart a row *is* a\n * category. Use it instead of `BubbleChart.Labels`, not beside it — the same\n * names twice is the legend telling the reader what the plot already says.\n */\nfunction BubbleChartLegend({ className, limit = 8, ...props }: BubbleChartLegendProps) {\n const { bubbles } = useChart('BubbleChart.Legend');\n const shown = bubbles.filter((bubble) => bubble.label).slice(0, limit);\n if (!shown.length) return null;\n\n return (\n \n {shown.map((bubble) => (\n \n \n \n {bubble.label}\n \n \n ))}\n \n );\n}\nBubbleChartLegend.displayName = 'BubbleChart.Legend';\nBubbleChartLegend.layer = 'footer' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — what the area means, usually. */\n caption?: string;\n /** Trailing slot — a control, a badge, a range picker. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the size of a circle means.\n *\n * The caption earns its place here more than on most charts. Two axes and an\n * area is three quantities, and a reader who is not told what the area is has\n * no way to work it out from the picture.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActivePointChange` and pass the\n * formatted string down.\n */\nfunction BubbleChartHeader({\n className,\n title,\n value,\n caption,\n children,\n ...props\n}: BubbleChartHeaderProps) {\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n control takes the width it wants and the caption underneath the value\n wraps to two lines to make room for it. */}\n {children ? {children} : null}\n \n );\n}\nBubbleChartHeader.displayName = 'BubbleChart.Header';\nBubbleChartHeader.layer = 'header' as Layer;\n\nexport const BubbleChart = Object.assign(BubbleChartRoot, {\n Header: BubbleChartHeader,\n Grid: BubbleChartGrid,\n Quadrants: BubbleChartQuadrants,\n Trend: BubbleChartTrend,\n Bubbles: BubbleChartBubbles,\n Labels: BubbleChartLabels,\n SizeKey: BubbleChartSizeKey,\n Skeleton: BubbleChartSkeleton,\n XAxis: BubbleChartXAxis,\n YAxis: BubbleChartYAxis,\n Tooltip: BubbleChartTooltip,\n Legend: BubbleChartLegend,\n});\n" + "content": "/**\n * BubbleChart — one labelled circle per row, on two measured axes, with a third\n * quantity on each circle's area.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## When this and not a scatter plot\n *\n * `ScatterChart` also maps a third quantity onto point area, through `sizeKey`,\n * and it is the right component for a *series* of observations: many points of\n * one colour, where the shape of the cloud is the finding and no single dot\n * needs a name.\n *\n * This one is for a handful of named things. Each row is its own circle with\n * its own colour and its own label written inside it, so the chart can be read\n * entity by entity rather than as a distribution. Eight teams, twelve products,\n * six regions — where the reader wants to find one of them and see where it\n * sits.\n *\n * ## Area, not radius\n *\n * `sizeKey` maps to a circle's area. Doubling a radius quadruples the ink, so a\n * chart that scaled the radius would show a doubled value as four times the\n * size, and the reader would believe the picture. The scale runs over the whole\n * data set, so one bubble's size means the same thing as another's.\n *\n * ## Labels are text, not SVG\n *\n * The names inside the bubbles are React Native `Text` in a layer over the\n * plot, so they follow the theme's font and the platform's text scaling — SVG\n * text does neither. A bubble too small to hold its own label is left without\n * one rather than given an unreadable one; the readout still names it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n bubbleRadius,\n compactNumber,\n niceDomain,\n useSeriesColor,\n xAt,\n yOf,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedG = Animated.createAnimatedComponent(G);\nconst AnimatedLine = Animated.createAnimatedComponent(SvgLine);\n\n/**\n * How much of the reveal is spent handing out the bubbles' start times. The\n * rest is the window each one gets, so the whole field still lands inside the\n * one duration however many there are.\n */\nconst STAGGER = 0.4;\n\n/** Milliseconds for a bubble to swell as it is selected, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/**\n * A bubble arriving: up past its size and back to it.\n *\n * A circle that simply grows to its radius reads as the chart still loading\n * right up to the last frame. The small overshoot is what makes it read as\n * landing.\n */\nfunction landing(t: number): number {\n 'worklet';\n const back = 1.3;\n const u = t - 1;\n return 1 + (back + 1) * u * u * u + back * u * u;\n}\n\n/** Room left around the plot for the axis labels and the outermost bubbles. */\nconst PADDING = { top: 18, right: 18, bottom: 22, left: 18 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each axis label is centred in, so a long number is ellipsised not shoved. */\nconst AXIS_LABEL_WIDTH = 56;\n\n/** Width of the readout that floats by the selected bubble. */\nconst LABEL_WIDTH = 132;\n\n/** Gap between the readout and the edge of the bubble it describes. */\nconst LABEL_GAP = 10;\n\n/** Line box a bubble's own label is laid out in. */\nconst BUBBLE_LABEL_HEIGHT = 16;\n\n/**\n * Smallest radius a bubble may have and still be given its label. Below this\n * the name is wider than the circle and reads as text lying on the plot rather\n * than as the bubble's own.\n *\n * Ten points, which is a twenty-point circle: enough for the initial or the\n * short code a bubble chart's labels usually are. Set higher and the smallest\n * bubble in an ordinary set silently loses its name, which reads as a bug\n * rather than as a decision.\n */\nconst LABEL_MIN_RADIUS = 10;\n\n/** Floor on the touch target, for a chart whose smallest bubbles are tiny. */\nconst HIT_RADIUS = 22;\n\n/** How many colours the ramp cycles through. */\nconst PALETTE_SIZE = 5;\n\n/** Steps each axis is rounded out to. Matches the labels an axis draws. */\nconst AXIS_STEPS = 4;\n\n/** Room under the numbers for an axis's own name, when it is given one. */\nconst AXIS_TITLE_HEIGHT = 18;\n\n/** Gap between a quadrant's caption and the corner it is written in. */\nconst QUADRANT_LABEL_INSET = 6;\n\n/**\n * How strongly the overlays draw: the quadrant crosshair, and the size key's\n * rings.\n *\n * Both are furniture rather than data, and both were faint enough to be missed\n * — the crosshair at 0.4 and the rings at 0.6, on a token that is already a\n * mid-grey chosen to stay behind the circles. The restraint has to come from\n * the token, not from a second reduction on top of it, or the mark is one\n * nobody finds. `Trend` has drawn at 0.7 all along and reads correctly in both\n * themes, so that is the number.\n */\nconst CROSSHAIR_OPACITY = 0.7;\nconst KEY_RING_OPACITY = 0.85;\n\n/** Column the size key's values are written in, beside its circles. */\nconst SIZE_KEY_LABEL_WIDTH = 44;\n\n/** Gap between the size key's circles and the values naming them. */\nconst SIZE_KEY_GAP = 6;\n\n/** Room beside the numbers for the y axis's name, which is turned on its side. */\nconst AXIS_TITLE_WIDTH = 18;\n\ntype Layer = 'svg' | 'overlay' | 'header' | 'footer';\n\nexport type BubbleChartStatus = 'loading' | 'ready';\n\nexport type BubbleChartDatum = Record;\n\n/** One bubble, resolved back to the row it came from. */\nexport interface BubbleChartPoint {\n /** Index into `data`. */\n index: number;\n x: number;\n y: number;\n /** The value behind the area, when `sizeKey` is set. */\n size: number | null;\n /** The name written inside the circle, when `labelKey` is set. */\n label: string;\n /** The colour it was drawn in. */\n color: string;\n datum: BubbleChartDatum;\n}\n\n/** A bubble with its geometry resolved. Shared by every part. */\ninterface ResolvedBubble extends BubbleChartPoint {\n /** Radius in points, off the area scale. */\n r: number;\n}\n\ninterface BubbleChartContextValue {\n data: BubbleChartDatum[];\n xDataKey: string;\n yDataKey: string;\n labelKey: string | undefined;\n plot: Plot;\n status: BubbleChartStatus;\n bubbles: ResolvedBubble[];\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n /** The settled domains, for the parts that draw text rather than geometry. */\n xExtent: [number, number];\n yExtent: [number, number];\n /** Lowest and highest value behind the areas, or null without a `sizeKey`. */\n sizeExtent: [number, number] | null;\n /** The radii those two values map onto. */\n sizeRange: [number, number];\n /** 0 to 1 as the bubbles grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActivePoint: (point: BubbleChartPoint | null) => void;\n}\n\nconst BubbleChartContext = createContext(null);\n\nfunction useChart(component: string): BubbleChartContextValue {\n const context = useContext(BubbleChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The bubble under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActivePointChange` for that.\n */\nexport function useBubbleChart() {\n const { bubbles, activeIndexJS } = useChart('useBubbleChart');\n const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;\n return { activeIndex: activeIndexJS, activePoint: active };\n}\n\n/**\n * An extent widened out to round numbers, and a usable one for the degenerate\n * cases — no data at all, or every reading identical. A domain of zero width\n * divides by zero and puts every bubble on the same edge.\n *\n * Rounded rather than padded by a fraction: a fraction of the data's own span\n * ends the axis at 52.7, which is true and which nobody was looking for. Two\n * steps, matching the labels each axis draws by default, so the middle one is\n * round as well as the ends.\n */\nfunction padExtent(min: number, max: number): [number, number] {\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n return niceDomain(min, max, AXIS_STEPS);\n}\n\nexport interface BubbleChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. One bubble each. */\n data: BubbleChartDatum[];\n /** Key holding the horizontal value. */\n xDataKey?: string;\n /** Key holding the vertical value. */\n yDataKey?: string;\n /**\n * Key holding the third quantity, mapped to each bubble's *area*. Without it\n * every bubble is drawn at the middle of `sizeRange` and the chart is a\n * scatter plot with names on it.\n */\n sizeKey?: string;\n /** Key holding the name written inside the circle. */\n labelKey?: string;\n /**\n * Key holding a colour for the row — either a CSS colour or a number from 1\n * to 5 naming a `--color-chart-*` token. Without it the ramp cycles by row.\n */\n colorKey?: string;\n /**\n * Smallest and largest radius `sizeKey` maps onto, in points. The largest is\n * also what the plot holds back at every edge, so raising it costs room.\n */\n sizeRange?: [number, number];\n /**\n * `loading` shows a still field of muted circles and dissolves it as the real\n * bubbles grow in. One component throughout, rather than a spinner swapped\n * for a chart — swapping loses the transition. Add a `BubbleChart.Skeleton`\n * for something to stand in the plot meanwhile.\n */\n status?: BubbleChartStatus;\n /** Width ÷ height. `1` is the square shape a bubble field reads best in. */\n aspectRatio?: number;\n /** Milliseconds for the bubbles to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the axes to settle after the data changes. */\n domainDuration?: number;\n /** Fix the horizontal axis instead of deriving it. */\n xDomain?: [number, number];\n /** Fix the vertical axis instead of deriving it. */\n yDomain?: [number, number];\n /** The bubble under the finger, and `null` when it lifts. */\n onActivePointChange?: (point: BubbleChartPoint | null) => void;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface BubbleChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n const footer: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const into =\n layer === 'header' ? header : layer === 'footer' ? footer : layer === 'overlay' ? overlay : svg;\n into.push(slot);\n });\n return { svg, overlay, header, footer };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst BubbleChartRoot = forwardRef(\n function BubbleChartRoot(\n {\n className,\n data,\n xDataKey = 'x',\n yDataKey = 'y',\n sizeKey,\n labelKey,\n colorKey,\n sizeRange = [10, 28],\n status = 'ready',\n aspectRatio = 1,\n animationDuration = 800,\n domainDuration = 500,\n xDomain,\n yDomain,\n onActivePointChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const xMin = useSharedValue(0);\n const xMax = useSharedValue(0);\n const yMin = useSharedValue(0);\n const yMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n /*\n * The whole ramp, resolved once. Every bubble is its own category here\n * rather than a member of a series, so the colour is chosen by row index\n * and there is nothing to register.\n */\n const chart1 = useSeriesColor(undefined, 1);\n const chart2 = useSeriesColor(undefined, 2);\n const chart3 = useSeriesColor(undefined, 3);\n const chart4 = useSeriesColor(undefined, 4);\n const chart5 = useSeriesColor(undefined, 5);\n const palette = useMemo(\n () => [chart1, chart2, chart3, chart4, chart5],\n [chart1, chart2, chart3, chart4, chart5]\n );\n\n /*\n * What the axes are going to need before anything has been laid out. The\n * gutter for the y labels and the strip under the x ones are the plot's\n * padding, so they have to be known here rather than by the parts that draw\n * them — a part that reserved its own room would be positioned against a\n * plot that had already been sized without it.\n */\n const axes = useMemo(() => {\n let y = false;\n let yTitle = false;\n let xTitle = false;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const axis = (child.type as { axis?: string }).axis;\n const labelled = Boolean((child.props as { label?: string }).label);\n if (axis === 'y') {\n y = true;\n if (labelled) yTitle = true;\n }\n if (axis === 'x' && labelled) xTitle = true;\n });\n return { y, yTitle, xTitle };\n }, [children]);\n\n /*\n * A circle is drawn about its centre, so every edge of the plot has to hold\n * back the largest radius or the outermost bubble is cropped by it — and\n * the largest bubble is the one carrying the largest value, which is the\n * last one that should be half missing. `sizeRange` is the ceiling, so this\n * is known before anything is measured.\n */\n const reach = sizeRange[1];\n const pad = {\n top: Math.max(PADDING.top, reach),\n right: Math.max(PADDING.right, reach),\n bottom:\n Math.max(PADDING.bottom, reach) + (axes.xTitle ? AXIS_TITLE_HEIGHT : 0),\n left: Math.max(\n axes.y ? Y_AXIS_WIDTH + (axes.yTitle ? AXIS_TITLE_WIDTH : 0) : PADDING.left,\n reach\n ),\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const extents = useMemo(() => {\n let lowX = Infinity;\n let highX = -Infinity;\n let lowY = Infinity;\n let highY = -Infinity;\n for (const row of data) {\n const x = row[xDataKey];\n const y = row[yDataKey];\n // A row missing either coordinate is not a bubble at all, and must not\n // stretch the axes towards an origin it never had.\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n if (x < lowX) lowX = x;\n if (x > highX) highX = x;\n if (y < lowY) lowY = y;\n if (y > highY) highY = y;\n }\n return {\n x: xDomain ?? padExtent(lowX, highX),\n y: yDomain ?? padExtent(lowY, highY),\n };\n }, [data, xDataKey, yDataKey, xDomain, yDomain]);\n\n /*\n * The size scale runs over the whole data set, so one bubble's area means\n * the same thing as another's. Without a `sizeKey` there is nothing to\n * scale and every bubble takes the middle of the range — which is a\n * scatter plot with names on it, and an honest one.\n */\n const sizeExtent = useMemo<[number, number] | null>(() => {\n if (!sizeKey) return null;\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n const value = row[sizeKey];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n return min === Infinity ? null : [min, max];\n }, [data, sizeKey]);\n\n /*\n * Resolved once, in the root, because four parts need exactly this list and\n * three of them would otherwise derive it again: the circles, the labels\n * over them, the hit test under them and the legend beside them all have to\n * agree about where a bubble is and how big it is.\n */\n const bubbles = useMemo(() => {\n const middle = (sizeRange[0] + sizeRange[1]) / 2;\n const out: ResolvedBubble[] = [];\n data.forEach((datum, index) => {\n const x = datum[xDataKey];\n const y = datum[yDataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) return;\n if (typeof y !== 'number' || Number.isNaN(y)) return;\n\n const raw = sizeKey ? datum[sizeKey] : undefined;\n const value = typeof raw === 'number' && !Number.isNaN(raw) ? raw : null;\n const r =\n sizeExtent && value !== null\n ? bubbleRadius(value, sizeExtent, sizeRange)\n : middle;\n\n const explicit = colorKey ? datum[colorKey] : undefined;\n const color =\n typeof explicit === 'string'\n ? explicit\n : typeof explicit === 'number'\n ? (palette[(Math.round(explicit) - 1 + PALETTE_SIZE) % PALETTE_SIZE] ??\n palette[0]!)\n : (palette[index % PALETTE_SIZE] ?? palette[0]!);\n\n out.push({\n index,\n x,\n y,\n r,\n size: value,\n label: labelKey ? String(datum[labelKey] ?? '') : '',\n color,\n datum,\n });\n });\n return out;\n }, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, sizeExtent, sizeRange, palette]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [x0, x1] = extents.x;\n const [y0, y1] = extents.y;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first =\n xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;\n if (first || reducedMotion) {\n xMin.value = x0;\n xMax.value = x1;\n yMin.value = y0;\n yMax.value = y1;\n return;\n }\n xMin.value = withTiming(x0, { duration: domainDuration });\n xMax.value = withTiming(x1, { duration: domainDuration });\n yMin.value = withTiming(y0, { duration: domainDuration });\n yMax.value = withTiming(y1, { duration: domainDuration });\n }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n /*\n * Eased out rather than in and out. Each bubble is given a slice of\n * this one clock, so an ease that dawdles at the start spends it on the\n * first few and leaves the rest to arrive in a rush.\n */\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !bubbles.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, bubbles.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n // One place the selection lands, so the chart's own children and a readout\n // outside it never disagree about which bubble is active.\n const setActivePoint = useMemo(\n () => (point: BubbleChartPoint | null) => {\n setActiveIndexJS(point ? point.index : -1);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n yDataKey,\n labelKey,\n plot,\n status,\n bubbles,\n xMin,\n xMax,\n yMin,\n yMax,\n xExtent: extents.x,\n yExtent: extents.y,\n sizeExtent,\n sizeRange,\n reveal,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n yDataKey,\n labelKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n bubbles,\n xMin,\n xMax,\n yMin,\n yMax,\n extents,\n sizeExtent,\n sizeRange,\n reveal,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n ]\n );\n\n const { svg, overlay, header, footer } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n {\n const pairs: [string, unknown][] = [\n [xDataKey, datum[xDataKey]],\n [yDataKey, datum[yDataKey]],\n ];\n if (labelKey) pairs.unshift([labelKey, datum[labelKey]]);\n if (sizeKey) pairs.push([sizeKey, datum[sizeKey]]);\n return pairs;\n }}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n {footer}\n \n \n );\n }\n);\nBubbleChartRoot.displayName = 'BubbleChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartGridProps {\n /**\n * Horizontal rules across the plot.\n *\n * Eight, which is twice the four intervals an axis is divided into by\n * default, so every second line carries a number and the ones between it are\n * halves of a labelled step rather than an unrelated rhythm. Squares this\n * size recede behind the circles; the coarse grid a smaller number draws\n * reads as blocks laid over the plot.\n */\n rows?: number;\n /** Vertical rules up it. Both axes are measured, so both earn lines. */\n columns?: number;\n /** Dash pattern for the rules. Pass `undefined` for solid ones. */\n dashArray?: string;\n color?: string;\n opacity?: number;\n}\n\n/** Reference lines both ways, so a bubble can be placed against two numbers. */\nfunction BubbleChartGrid({\n rows = 8,\n columns = 8,\n dashArray = '4,6',\n color,\n opacity = 1,\n}: BubbleChartGridProps) {\n const { plot } = useChart('BubbleChart.Grid');\n const token = useCSSVariable('--color-border');\n // The fallback is grey rather than black: it stands in when the theme cannot\n // be read, and a black hairline is invisible on a dark background.\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const horizontals = Array.from({ length: rows + 1 }, (_unused, i) => i / rows);\n const verticals = Array.from({ length: columns + 1 }, (_unused, i) => i / columns);\n\n return (\n \n {horizontals.map((fraction) => (\n \n ))}\n {verticals.map((fraction) => (\n \n ))}\n \n );\n}\nBubbleChartGrid.displayName = 'BubbleChart.Grid';\nBubbleChartGrid.layer = 'svg' as Layer;\n\nexport interface BubbleChartTrendProps {\n /**\n * The line's slope and intercept, and how tightly the cloud sits on it, once\n * they have been computed. `r` runs 0 to 1: 1 is every bubble on the line,\n * 0 is a cloud with no direction at all.\n *\n * Given here rather than left for the caller to work out, because the fit is\n * already being computed to draw the line and doing it twice invites the two\n * answers to disagree.\n *\n * It fires when the numbers change, not on every render that produced the\n * same ones, so putting the fit straight into state is safe.\n */\n onFit?: (fit: { slope: number; intercept: number; r: number }) => void;\n color?: string;\n strokeWidth?: number;\n /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * The straight line that fits the cloud best, drawn across the plot.\n *\n * It is dashed and drawn under the circles, because it is not data — it is a\n * summary of the data, and a solid rule through the middle of a field of\n * bubbles reads as a value somebody plotted.\n *\n * The fit is least squares on the raw values, so it moves with the data rather\n * than with the frame: resizing the chart never changes the line's meaning.\n * Fewer than two bubbles, or every bubble on one vertical, has no line to draw\n * and none is drawn.\n */\nfunction BubbleChartTrend({\n onFit,\n color,\n strokeWidth = 1.5,\n dashArray = '6,5',\n opacity = 0.7,\n}: BubbleChartTrendProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =\n useChart('BubbleChart.Trend');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');\n\n const fit = useMemo(() => {\n const n = bubbles.length;\n if (n < 2) return null;\n let sumX = 0;\n let sumY = 0;\n for (const bubble of bubbles) {\n sumX += bubble.x;\n sumY += bubble.y;\n }\n const meanX = sumX / n;\n const meanY = sumY / n;\n let sxy = 0;\n let sxx = 0;\n let syy = 0;\n for (const bubble of bubbles) {\n const dx = bubble.x - meanX;\n const dy = bubble.y - meanY;\n sxy += dx * dy;\n sxx += dx * dx;\n syy += dy * dy;\n }\n // Every bubble on one vertical: the best fit is that vertical, which has no\n // slope and nothing useful to draw.\n if (sxx === 0) return null;\n const slope = sxy / sxx;\n const denominator = Math.sqrt(sxx * syy);\n return {\n slope,\n intercept: meanY - slope * meanX,\n r: denominator === 0 ? 0 : Math.abs(sxy / denominator),\n };\n }, [bubbles]);\n\n const fitRef = useRef(onFit);\n useEffect(() => {\n fitRef.current = onFit;\n });\n\n /*\n * Reported when the numbers change, not when the object does.\n *\n * `bubbles` is rebuilt whenever any of its inputs changes identity, and\n * `sizeRange={[14, 30]}` written at a call site is a new array every render —\n * so the fit is a new object every render even when the data has not moved.\n * Handing that to a caller who puts it in state is a render loop, and the\n * caller has no way to see that coming.\n */\n const reported = useRef<{ slope: number; intercept: number; r: number } | null>(null);\n useEffect(() => {\n if (!fit) return;\n const last = reported.current;\n if (\n last &&\n last.slope === fit.slope &&\n last.intercept === fit.intercept &&\n last.r === fit.r\n ) {\n return;\n }\n reported.current = fit;\n fitRef.current?.(fit);\n }, [fit]);\n\n const slope = fit?.slope ?? 0;\n const intercept = fit?.intercept ?? 0;\n\n const animatedProps = useAnimatedProps(() => {\n const x0 = xMin.value;\n const x1 = xMax.value;\n const lowY = yMin.value;\n const highY = yMax.value;\n\n /*\n * Solved in data space and then clipped there, rather than drawn across the\n * plot and clipped by the frame: a line that leaves the top of the chart\n * has to stop where it leaves it, and the x of that point is only knowable\n * from the equation.\n */\n let ax = x0;\n let bx = x1;\n if (slope !== 0) {\n const atLow = (lowY - intercept) / slope;\n const atHigh = (highY - intercept) / slope;\n const enter = Math.min(atLow, atHigh);\n const exit = Math.max(atLow, atHigh);\n ax = Math.max(ax, enter);\n bx = Math.min(bx, exit);\n }\n if (bx < ax) {\n // The line never crosses the visible box.\n return { x1: 0, x2: 0, y1: 0, y2: 0, opacity: 0 };\n }\n\n // Drawn out from the middle as the bubbles land, so the line arrives with\n // the field rather than being there waiting for it.\n const grown = Math.max(0, Math.min(1, reveal.value));\n const midpoint = (ax + bx) / 2;\n const half = ((bx - ax) / 2) * grown;\n\n const startX = midpoint - half;\n const endX = midpoint + half;\n return {\n x1: xAt(startX, plot, x0, x1),\n x2: xAt(endX, plot, x0, x1),\n y1: yOf(intercept + slope * startX, plot, lowY, highY),\n y2: yOf(intercept + slope * endX, plot, lowY, highY),\n opacity: opacity * grown,\n };\n });\n\n if (status === 'loading' || !fit) return null;\n\n return (\n \n );\n}\nBubbleChartTrend.displayName = 'BubbleChart.Trend';\nBubbleChartTrend.layer = 'svg' as Layer;\n\nexport interface BubbleChartBubblesProps {\n /**\n * Fill opacity. Below 1 by default so that overlapping bubbles read as\n * denser rather than hiding each other — in a crowded corner that overlap\n * *is* the finding, and opaque circles erase it.\n */\n opacity?: number;\n /** One colour for every bubble, overriding the per-row ramp. */\n color?: string;\n}\n\n/** The circles. */\nfunction BubbleChartBubbles({ opacity = 0.9, color }: BubbleChartBubblesProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal, activeIndex } =\n useChart('BubbleChart.Bubbles');\n\n if (status === 'loading') return null;\n\n return (\n \n {bubbles.map((bubble, order) => (\n \n ))}\n \n );\n}\nBubbleChartBubbles.displayName = 'BubbleChart.Bubbles';\nBubbleChartBubbles.layer = 'svg' as Layer;\n\n/**\n * One bubble. Its position follows both domain tweens, so a data change moves\n * the whole field to the new scale rather than cutting to it.\n *\n * It arrives by growing in place, on its own slice of the shared reveal. A wipe\n * across the plot — which is what the line and area charts do — gives the\n * reader a direction to read the arrival in, and a field of bubbles has none:\n * position is the whole message, so a bubble may only ever appear where it\n * belongs.\n */\nfunction Bubble({\n bubble,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n fill,\n opacity,\n activeIndex,\n reveal,\n order,\n total,\n}: {\n bubble: ResolvedBubble;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n fill: string;\n opacity: number;\n activeIndex: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n}) {\n const { index, x, y, r } = bubble;\n\n /*\n * The selection, as something that moves. A hard switch made the bubble it\n * named jump between one frame and the next while every neighbour stayed put,\n * which reads as a glitch rather than as a response to the finger.\n */\n const selected = useDerivedValue(() =>\n withTiming(activeIndex.value === index ? 1 : 0, { duration: SELECT_DURATION })\n );\n\n // Where in the reveal this bubble starts. Spread over `STAGGER`, so the field\n // settles as a field rather than switching on all at once.\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const animatedProps = useAnimatedProps(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n // The selected bubble swells and goes solid. Both, rather than one: a size\n // change alone is easy to miss among neighbours, and an opacity change\n // alone is invisible wherever the circles already overlap.\n const swell = 1 + 0.12 * selected.value;\n return {\n cx: xAt(x, plot, xMin.value, xMax.value),\n cy: yOf(y, plot, yMin.value, yMax.value),\n r: r * landing(arrived) * swell,\n // Ahead of the size, so a bubble is legible by the time it stops moving\n // rather than fading in for the whole of its arrival.\n fillOpacity:\n Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),\n };\n });\n\n return ;\n}\n\nexport interface BubbleChartSkeletonProps {\n /** How many placeholder circles to scatter. */\n count?: number;\n color?: string;\n}\n\n/**\n * The loading state: a still field of muted circles where the data will be.\n *\n * Deliberately still. A shimmer over a field of circles reads as them *moving*,\n * which is the one thing this chart must never appear to do — position is the\n * entire message, and a loading state that implies it is changing is a loading\n * state that lies.\n *\n * The layout is deterministic rather than random, so it does not reshuffle on\n * every render of a component that may re-render several times while waiting.\n * It dissolves as the real bubbles grow in, and outlives the status change by\n * exactly that long: cut at the frame the data lands, the placeholder would\n * disappear before anything had replaced it.\n */\nfunction BubbleChartSkeleton({ count = 7, color }: BubbleChartSkeletonProps) {\n const { plot, status } = useChart('BubbleChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');\n\n const circles = useMemo(\n () =>\n // Two irrational-ish strides that do not share a factor, so the circles\n // spread instead of falling into a lattice.\n Array.from({ length: count }, (_unused, index) => ({\n key: index,\n fx: ((index * 0.618) % 1) * 0.84 + 0.08,\n fy: ((index * 0.379) % 1) * 0.84 + 0.08,\n r: 16 + ((index * 0.472) % 1) * 12,\n })),\n [count]\n );\n\n const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));\n\n if (!mounted) return null;\n\n return (\n \n {circles.map((circle) => (\n \n ))}\n \n );\n}\nBubbleChartSkeleton.displayName = 'BubbleChart.Skeleton';\nBubbleChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartLabelsProps {\n /**\n * Smallest radius a bubble may have and still be given its label. Below it\n * the name is wider than the circle it names.\n */\n minRadius?: number;\n /** Turn a bubble into its label. Defaults to the value at `labelKey`. */\n format?: (point: BubbleChartPoint) => string;\n className?: string;\n}\n\n/**\n * The names, written inside the circles.\n *\n * Real text over the plot rather than SVG text, so they follow the theme's font\n * and the platform's text scaling. Each one rides the same domain tweens the\n * circle under it does, so a label never lags the bubble it belongs to.\n *\n * A bubble too small to hold its name is left without one. Shrinking the text\n * to fit would make it unreadable on exactly the bubbles the reader is\n * squinting at already; the readout names those instead.\n */\nfunction BubbleChartLabels({\n minRadius = LABEL_MIN_RADIUS,\n format,\n className,\n}: BubbleChartLabelsProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax, reveal } =\n useChart('BubbleChart.Labels');\n\n if (status === 'loading') return null;\n\n return (\n \n {bubbles\n .filter((bubble) => bubble.r >= minRadius && (format || bubble.label))\n .map((bubble, order) => (\n \n ))}\n \n );\n}\nBubbleChartLabels.displayName = 'BubbleChart.Labels';\nBubbleChartLabels.layer = 'overlay' as Layer;\n\nexport interface BubbleChartQuadrantsProps {\n /** Where the vertical rule stands. Defaults to the mean of the x values. */\n x?: number;\n /** Where the horizontal rule lies. Defaults to the mean of the y values. */\n y?: number;\n /** A word for each corner, written in the corner it belongs to. */\n labels?: {\n topLeft?: string;\n topRight?: string;\n bottomLeft?: string;\n bottomRight?: string;\n };\n /** Tint the high-high and low-low corners. On by default. */\n tint?: boolean;\n color?: string;\n className?: string;\n}\n\n/**\n * A crosshair splitting the plot into four, with a name for each corner.\n *\n * A field of bubbles is usually read as four groups rather than as a cloud —\n * which of these is doing well on both counts, which on neither — and without\n * a divider the reader draws that line by eye, in a different place each time.\n * Putting it on the chart makes it one line everybody sees.\n *\n * It stands at the mean of each axis by default, because that is the split the\n * data itself argues for. Pass `x` and `y` for a target, a budget or last\n * year's number — a threshold somebody decided rather than one the data\n * produced.\n *\n * The tint marks the two corners a reading usually ends at. Turn it off where\n * all four corners matter equally.\n */\nfunction BubbleChartQuadrants({\n x,\n y,\n labels,\n tint = true,\n color,\n className,\n}: BubbleChartQuadrantsProps) {\n const { bubbles, plot, status, xMin, xMax, yMin, yMax } =\n useChart('BubbleChart.Quadrants');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.8)');\n\n const centre = useMemo(() => {\n if (!bubbles.length) return null;\n let sumX = 0;\n let sumY = 0;\n for (const bubble of bubbles) {\n sumX += bubble.x;\n sumY += bubble.y;\n }\n return { x: x ?? sumX / bubbles.length, y: y ?? sumY / bubbles.length };\n }, [bubbles, x, y]);\n\n const atX = centre?.x ?? 0;\n const atY = centre?.y ?? 0;\n\n const verticalStyle = useAnimatedStyle(() => ({\n left: xAt(atX, plot, xMin.value, xMax.value),\n }));\n const horizontalStyle = useAnimatedStyle(() => ({\n top: yOf(atY, plot, yMin.value, yMax.value),\n }));\n /*\n * Two rectangles rather than four: the pair that is tinted is the pair the\n * reader is being pointed at, and shading all four would only be a checked\n * background.\n */\n const highStyle = useAnimatedStyle(() => {\n const cx = xAt(atX, plot, xMin.value, xMax.value);\n const cy = yOf(atY, plot, yMin.value, yMax.value);\n return {\n left: cx,\n top: plot.top,\n width: Math.max(plot.left + plot.width - cx, 0),\n height: Math.max(cy - plot.top, 0),\n };\n });\n const lowStyle = useAnimatedStyle(() => {\n const cx = xAt(atX, plot, xMin.value, xMax.value);\n const cy = yOf(atY, plot, yMin.value, yMax.value);\n return {\n left: plot.left,\n top: cy,\n width: Math.max(cx - plot.left, 0),\n height: Math.max(plot.top + plot.height - cy, 0),\n };\n });\n\n if (status === 'loading' || !centre) return null;\n\n const corner = {\n position: 'absolute' as const,\n width: plot.width / 2 - QUADRANT_LABEL_INSET,\n };\n\n return (\n \n {tint ? (\n <>\n \n \n \n ) : null}\n {/*\n The same weight `Trend` draws at, because it is the same kind of mark:\n a reference laid over the field rather than a reading taken from it.\n At 0.4 of a mid-grey the split was a line you had to already know was\n there, which leaves four labelled corners and nothing dividing them.\n */}\n \n \n {/*\n Pinned to the plot's corners rather than to the crosshair. A caption\n names the region, and a region's name belongs at the far end of it —\n following the rules it would crowd them as the split moved.\n */}\n {labels?.topLeft ? (\n \n {labels.topLeft}\n \n ) : null}\n {labels?.topRight ? (\n \n {labels.topRight}\n \n ) : null}\n {labels?.bottomLeft ? (\n \n {labels.bottomLeft}\n \n ) : null}\n {labels?.bottomRight ? (\n \n {labels.bottomRight}\n \n ) : null}\n \n );\n}\nBubbleChartQuadrants.displayName = 'BubbleChart.Quadrants';\nBubbleChartQuadrants.layer = 'overlay' as Layer;\n\nexport interface BubbleChartSizeKeyProps {\n /** Which corner of the plot it sits in. */\n placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** A word for what the area means — \"people\", \"revenue\". */\n label?: string;\n className?: string;\n}\n\n/**\n * Three nested circles saying what a bubble's area is worth.\n *\n * A bubble chart's third quantity is the one it cannot state: position can be\n * read off the axes, but area has no axis, so a reader can see that one circle\n * is bigger than another and has no way to know by how much. This is the only\n * part of the chart that answers that.\n *\n * Nested and sharing a baseline, which is how a difference in area is compared\n * — three circles in a row are three sizes, three circles inside one another\n * are one scale.\n *\n * It needs a `sizeKey` on the chart. Without one every bubble is the same size\n * and there is no scale to key.\n */\nfunction BubbleChartSizeKey({\n placement = 'bottom-right',\n format,\n label,\n className,\n}: BubbleChartSizeKeyProps) {\n const { plot, status, sizeExtent, sizeRange } = useChart('BubbleChart.SizeKey');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = typeof token === 'string' ? token : 'rgba(128,128,128,0.8)';\n\n const steps = useMemo(() => {\n if (!sizeExtent) return null;\n const [min, max] = sizeExtent;\n const middle = (min + max) / 2;\n // Largest first, so the smallest is drawn last and stays visible inside it.\n return [max, middle, min].map((value) => ({\n value,\n r: bubbleRadius(value, sizeExtent, sizeRange),\n }));\n }, [sizeExtent, sizeRange]);\n\n if (status === 'loading' || !steps) return null;\n\n const outer = steps[0]!.r;\n const width = outer * 2 + SIZE_KEY_LABEL_WIDTH;\n const height = outer * 2 + (label ? AXIS_LABEL_HEIGHT : 0);\n const top = placement.startsWith('top')\n ? plot.top\n : plot.top + plot.height - height;\n const left = placement.endsWith('left')\n ? plot.left\n : plot.left + plot.width - width;\n\n return (\n \n {label ? (\n \n {label}\n \n ) : null}\n \n {steps.map((step) => (\n \n ))}\n {steps.map((step) => (\n \n {format ? format(step.value) : compactNumber(step.value)}\n \n ))}\n \n \n );\n}\nBubbleChartSizeKey.displayName = 'BubbleChart.SizeKey';\nBubbleChartSizeKey.layer = 'overlay' as Layer;\n\nfunction BubbleLabel({\n bubble,\n text,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n reveal,\n order,\n total,\n className,\n}: {\n bubble: ResolvedBubble;\n text: string;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n className?: string;\n}) {\n const { x, y, r } = bubble;\n const width = r * 2;\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const style = useAnimatedStyle(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n return {\n opacity: Math.max(0, arrived * 2 - 1),\n transform: [\n { translateX: xAt(x, plot, xMin.value, xMax.value) - width / 2 },\n { translateY: yOf(y, plot, yMin.value, yMax.value) - BUBBLE_LABEL_HEIGHT / 2 },\n ],\n };\n });\n\n return (\n \n \n {text}\n \n \n );\n}\n\nexport interface BubbleChartXAxisProps {\n /**\n * How many intervals to divide the axis into. Yields `ticks + 1` labels.\n *\n * Four, and the domain is rounded out to four steps to match, so the numbers\n * come out round. Fewer leaves most of the grid unnamed — a line with nothing\n * beside it is a line the reader has to count their way to.\n */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** What the axis measures, written under the numbers. */\n label?: string;\n className?: string;\n}\n\n/**\n * The x labels, evenly along the axis.\n *\n * Evenly spaced, because this axis is a continuous scale rather than a list of\n * rows. There is no bubble for a label to sit under.\n */\nfunction BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXAxisProps) {\n const { plot, xExtent } = useChart('BubbleChart.XAxis');\n\n const labels = useMemo(() => {\n const [min, max] = xExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = min + ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [xExtent, ticks, format]);\n\n return (\n \n {label ? (\n \n {label}\n \n ) : null}\n {labels.map((tick) => (\n \n {tick.text}\n \n ))}\n \n );\n}\nBubbleChartXAxis.displayName = 'BubbleChart.XAxis';\nBubbleChartXAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room under the numbers before it lays\n// the plot out.\nBubbleChartXAxis.axis = 'x' as const;\n\nexport interface BubbleChartYAxisProps {\n /**\n * How many intervals to divide the axis into. Yields `ticks + 1` labels.\n *\n * Four, matching the four steps the domain is rounded out to and every second\n * line of the default grid.\n */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n /** What the axis measures, written up the side of it. */\n label?: string;\n className?: string;\n}\n\n/**\n * Value labels down the side, evenly over the axis, and the gutter they sit in.\n *\n * They land on every second line of the default grid rather than on all of\n * them: a number beside every line of a grid fine enough to read against is a\n * column of numbers, and the reader stops seeing the chart.\n */\nfunction BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYAxisProps) {\n const { plot, yExtent } = useChart('BubbleChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = yExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [yExtent, ticks, format]);\n\n const titleWidth = label ? AXIS_TITLE_WIDTH : 0;\n\n return (\n <>\n {label ? (\n /*\n * Turned on its side, which is the only way a word fits in a gutter\n * sized for numbers. The box is laid out as tall as the plot and then\n * rotated about its own centre, so the text runs the length of the axis\n * it names rather than of whatever it happens to say.\n */\n \n \n {label}\n \n \n ) : null}\n \n {labels.map((tick) => (\n \n {tick.text}\n \n ))}\n \n \n );\n}\nBubbleChartYAxis.displayName = 'BubbleChart.YAxis';\nBubbleChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nBubbleChartYAxis.axis = 'y' as const;\n\nexport interface BubbleChartTooltipProps {\n /** Float a small readout beside the selected bubble. On by default. */\n showLabel?: boolean;\n /** Format the x value for the readout. Defaults to a compact number. */\n formatX?: (value: number) => string;\n /** Format the y value for the readout. Defaults to a compact number. */\n formatY?: (value: number) => string;\n /** Format the size value for the readout. Defaults to a compact number. */\n formatSize?: (value: number) => string;\n /** Floor on the touch target, for a chart whose smallest bubbles are tiny. */\n hitRadius?: number;\n className?: string;\n}\n\n/**\n * The touch target, the selection it drives, and the readout that follows it.\n *\n * A touch picks the nearest bubble whose own circle — or the `hitRadius` floor,\n * whichever is larger — reaches the finger. Nearest rather than topmost,\n * because where bubbles overlap the one drawn last is not the one being aimed\n * at.\n *\n * The search runs on the UI thread over flat arrays of already-projected\n * coordinates, and only the *index* of the winner crosses back into JS, and\n * only when it changes. A drag across the plot therefore costs a handful of\n * re-renders rather than one per frame.\n *\n * Distances are compared squared. The nearest bubble by distance is the nearest\n * by distance-squared, and a square root per bubble per frame buys nothing.\n */\nfunction BubbleChartTooltip({\n showLabel = true,\n formatX,\n formatY,\n formatSize,\n hitRadius = HIT_RADIUS,\n className,\n}: BubbleChartTooltipProps) {\n const {\n bubbles,\n plot,\n xExtent,\n yExtent,\n activeIndex,\n activeIndexJS,\n setActivePoint,\n status,\n } = useChart('BubbleChart.Tooltip');\n\n /*\n * Every bubble, projected once, as parallel arrays.\n *\n * Parallel arrays rather than an array of objects because this is read inside\n * a worklet: Reanimated has to copy whatever the gesture captures across to\n * the UI thread, and four number arrays cross far more cheaply than a few\n * hundred small objects.\n *\n * Projected against the *settled* extents rather than the tweening shared\n * values. Hit-testing against a moving scale would mean rebuilding this on\n * every frame of a domain animation, and a bubble being half a second stale\n * during a transition is not something a finger can notice.\n */\n const hit = useMemo(() => {\n const xs: number[] = [];\n const ys: number[] = [];\n const rs: number[] = [];\n const limits: number[] = [];\n const indices: number[] = [];\n for (const bubble of bubbles) {\n xs.push(xAt(bubble.x, plot, xExtent[0], xExtent[1]));\n ys.push(yOf(bubble.y, plot, yExtent[0], yExtent[1]));\n rs.push(bubble.r);\n const reach = Math.max(bubble.r, hitRadius);\n limits.push(reach * reach);\n indices.push(bubble.index);\n }\n return { xs, ys, rs, limits, indices };\n }, [bubbles, plot, xExtent, yExtent, hitRadius]);\n\n const select = useMemo(\n () => (index: number) => {\n if (index < 0) {\n setActivePoint(null);\n return;\n }\n setActivePoint(bubbles.find((bubble) => bubble.index === index) ?? null);\n },\n [bubbles, setActivePoint]\n );\n\n /*\n * Built in one closure, and everything it captures is a plain array, a number\n * or a shared value. A worklet may only call another worklet, and the rule is\n * enforced by crashing rather than by warning — so the resolver is declared\n * here, next to its callers, rather than as a helper elsewhere in the file\n * where it would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const xs = hit.xs;\n const ys = hit.ys;\n const limits = hit.limits;\n const indices = hit.indices;\n\n const resolve = (px: number, py: number) => {\n 'worklet';\n let bestIndex = -1;\n let best = Infinity;\n for (let i = 0; i < xs.length; i += 1) {\n const dx = xs[i]! - px;\n const dy = ys[i]! - py;\n const distance = dx * dx + dy * dy;\n // Each bubble reaches as far as it is big, so a large one is not\n // beaten by a small one that happens to be marginally nearer.\n if (distance <= limits[i]! && distance < best) {\n best = distance;\n bestIndex = indices[i]!;\n }\n }\n if (bestIndex === activeIndex.value) return;\n activeIndex.value = bestIndex;\n runOnJS(select)(bestIndex);\n };\n\n const clear = () => {\n 'worklet';\n if (activeIndex.value === -1) return;\n activeIndex.value = -1;\n runOnJS(select)(-1);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n clear();\n });\n }, [hit, activeIndex, select]);\n\n /*\n * The readout's own height, measured rather than assumed. It decides whether\n * the readout fits above the bubble, and how tall it is depends on whether\n * the row has a label and a size — a constant would either overlap a\n * three-line readout or reserve room a one-line one never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Above the bubble, clear of its edge rather than of its centre, and clamped\n * inside the plot. Lifted by a constant it landed *on* the larger circles —\n * which are exactly the ones a finger is most likely to be resting on, so the\n * readout was hidden under the hand that summoned it.\n *\n * Where there is no room above, it goes below instead. Sliding it down to the\n * top edge of the plot would leave it over the bubble again.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const at = hit.indices.indexOf(index);\n if (at < 0) return { opacity: 0 };\n const x = hit.xs[at]!;\n const y = hit.ys[at]!;\n const r = hit.rs[at]!;\n const half = LABEL_WIDTH / 2;\n const tall = labelHeight.value;\n\n const above = y - r - LABEL_GAP - tall;\n const below = y + r + LABEL_GAP;\n const top = above >= plot.top ? above : below;\n\n return {\n opacity: 1,\n transform: [\n {\n translateX:\n Math.min(plot.left + plot.width - half, Math.max(plot.left + half, x)) - half,\n },\n { translateY: top },\n ],\n };\n });\n\n const active = bubbles.find((bubble) => bubble.index === activeIndexJS) ?? null;\n const fmtX = formatX ?? compactNumber;\n const fmtY = formatY ?? compactNumber;\n const fmtSize = formatSize ?? compactNumber;\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLabel ? (\n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n {active.label ? (\n \n \n \n {active.label}\n \n \n ) : null}\n \n {fmtX(active.x)}, {fmtY(active.y)}\n \n {active.size !== null ? (\n \n {fmtSize(active.size)}\n \n ) : null}\n \n ) : null}\n \n ) : null}\n \n \n );\n}\nBubbleChartTooltip.displayName = 'BubbleChart.Tooltip';\nBubbleChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BubbleChartLegendProps extends ViewProps {\n className?: string;\n /** Cap on how many bubbles are named. The rest are left to the readout. */\n limit?: number;\n}\n\n/**\n * A swatch and a name per bubble, for a chart whose circles are too small to\n * carry their own labels.\n *\n * Drawn **under** the plot rather than floating in a corner of it. A key that\n * overlays the drawing area competes with the bubbles for the space they are\n * plotted in, and on a square chart there is no corner that is reliably empty —\n * the position of a bubble is the data, so nowhere can be reserved for it.\n *\n * It lists rows rather than series, because in this chart a row *is* a\n * category. Use it instead of `BubbleChart.Labels`, not beside it — the same\n * names twice is the legend telling the reader what the plot already says.\n */\nfunction BubbleChartLegend({ className, limit = 8, ...props }: BubbleChartLegendProps) {\n const { bubbles } = useChart('BubbleChart.Legend');\n const shown = bubbles.filter((bubble) => bubble.label).slice(0, limit);\n if (!shown.length) return null;\n\n return (\n \n {shown.map((bubble) => (\n \n \n \n {bubble.label}\n \n \n ))}\n \n );\n}\nBubbleChartLegend.displayName = 'BubbleChart.Legend';\nBubbleChartLegend.layer = 'footer' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BubbleChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — what the area means, usually. */\n caption?: string;\n /** Trailing slot — a control, a badge, a range picker. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the size of a circle means.\n *\n * The caption earns its place here more than on most charts. Two axes and an\n * area is three quantities, and a reader who is not told what the area is has\n * no way to work it out from the picture.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActivePointChange` and pass the\n * formatted string down.\n */\nfunction BubbleChartHeader({\n className,\n title,\n value,\n caption,\n children,\n ...props\n}: BubbleChartHeaderProps) {\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n control takes the width it wants and the caption underneath the value\n wraps to two lines to make room for it. */}\n {children ? {children} : null}\n \n );\n}\nBubbleChartHeader.displayName = 'BubbleChart.Header';\nBubbleChartHeader.layer = 'header' as Layer;\n\nexport const BubbleChart = Object.assign(BubbleChartRoot, {\n Header: BubbleChartHeader,\n Grid: BubbleChartGrid,\n Quadrants: BubbleChartQuadrants,\n Trend: BubbleChartTrend,\n Bubbles: BubbleChartBubbles,\n Labels: BubbleChartLabels,\n SizeKey: BubbleChartSizeKey,\n Skeleton: BubbleChartSkeleton,\n XAxis: BubbleChartXAxis,\n YAxis: BubbleChartYAxis,\n Tooltip: BubbleChartTooltip,\n Legend: BubbleChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/bump-chart.json b/apps/docs/public/r/bump-chart.json index e88dd697b..f4e6f8130 100644 --- a/apps/docs/public/r/bump-chart.json +++ b/apps/docs/public/r/bump-chart.json @@ -21,7 +21,7 @@ { "path": "ui/bump-chart.tsx", "type": "registry:ui", - "content": "/**\n * BumpChart — how a set of things ranked against each other over time.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Positions, not values\n *\n * The y-axis is the rank, so the rows are evenly spaced whatever the gap\n * between the underlying scores. That is the point of the chart: who passed\n * whom, and when. A line chart of the same scores answers how far apart they\n * were, and loses the order wherever two lines run close together.\n *\n * Pass the ranks directly, or pass the scores with `values=\"score\"` and each\n * column is ranked for you, highest first. `bumpRanks` is exported so a header\n * or a table beside the chart can read the same order the chart drew.\n *\n * ## One line at a time\n *\n * Past three or four series, every line in its own colour is a tangle. The\n * chart reads best with one line picked out: `highlight` draws that one in its\n * colour and on top, and the rest in a muted grey. Tapping a name in\n * `BumpChart.Labels` picks that line, and tapping it again clears it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Rect, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { finiteChartNumber } from '@/components/ui/finite-chart';\nimport {\n bumpSegment,\n colorStop,\n dotsPath,\n useSeriesColor,\n xOf,\n type ChartPoint,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 8, right: 10, bottom: 32, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for `#1`…`#12`. */\nconst Y_AXIS_WIDTH = 34;\n\n/** Gap between the rank labels and the first column. */\nconst Y_AXIS_GUTTER = 8;\n\n/** Gap between the last column and the series names. */\nconst LABELS_GAP = 12;\n\n/** Box each x label is centred in. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the row it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Width of the scrub readout. */\nconst READOUT_WIDTH = 148;\n\n/** How far the lines that are not picked out fade back. */\nconst DIMMED_OPACITY = 0.5;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type BumpChartStatus = 'loading' | 'ready';\nexport type BumpChartDatum = Record;\n\n/** Ranks per series key, one entry per row. `null` where a series has none. */\nexport type BumpRanks = Record;\n\n/**\n * Each series' rank in every row.\n *\n * With `values: 'rank'` the rows already hold ranks and they are only\n * validated. With `values: 'score'` each row is ranked highest first; a tie\n * keeps the order the keys are passed in, so two equal scores never draw on\n * top of each other.\n */\nexport function bumpRanks(\n data: BumpChartDatum[],\n keys: string[],\n options: { values?: 'rank' | 'score' } = {}\n): BumpRanks {\n const ranks: BumpRanks = {};\n for (const key of keys) ranks[key] = [];\n\n for (const row of data) {\n if (options.values === 'score') {\n const scored = keys\n .map((key, order) => ({ key, order, score: finiteChartNumber(row[key]) }))\n .filter((entry): entry is { key: string; order: number; score: number } =>\n entry.score !== undefined\n )\n .sort((a, b) => b.score - a.score || a.order - b.order);\n const place = new Map(scored.map((entry, index) => [entry.key, index + 1]));\n for (const key of keys) ranks[key]!.push(place.get(key) ?? null);\n } else {\n for (const key of keys) {\n const rank = finiteChartNumber(row[key]);\n ranks[key]!.push(rank !== undefined && rank >= 1 ? rank : null);\n }\n }\n }\n return ranks;\n}\n\n/**\n * Ranks as a fraction of the plot's height: `0` is the top row, `1` the\n * bottom. Stored this way so a line can travel between two charts with a\n * different number of rows without a jump.\n */\nfunction rankFractions(ranks: BumpRanks, rows: number): BumpRanks {\n const out: BumpRanks = {};\n for (const key of Object.keys(ranks)) {\n out[key] = ranks[key]!.map((rank) =>\n rank === null ? null : rows <= 1 ? 0.5 : (rank - 1) / (rows - 1)\n );\n }\n return out;\n}\n\n/** A line's points at `progress` of the way between two sets of positions. */\nfunction pointsAt(\n key: string,\n from: BumpRanks,\n to: BumpRanks,\n progress: number,\n plot: Plot\n): ChartPoint[][] {\n 'worklet';\n const target = to[key];\n if (!target) return [];\n const origin = from[key];\n const runs: ChartPoint[][] = [];\n let run: ChartPoint[] = [];\n for (let i = 0; i < target.length; i += 1) {\n const end = target[i];\n if (end === null || end === undefined) {\n if (run.length) runs.push(run);\n run = [];\n continue;\n }\n const start = origin?.[i];\n const fraction =\n start === null || start === undefined ? end : start + (end - start) * progress;\n run.push({ x: xOf(i, target.length, plot), y: plot.top + fraction * plot.height });\n }\n if (run.length) runs.push(run);\n return runs;\n}\n\ninterface BumpSeries {\n key: string;\n color: string;\n label: string;\n strokeWidth: number;\n showDots: boolean;\n}\n\ninterface BumpChartContextValue {\n data: BumpChartDatum[];\n xDataKey: string;\n plot: Plot;\n pad: typeof PADDING;\n status: BumpChartStatus;\n series: BumpSeries[];\n ranks: BumpRanks;\n rows: number;\n registerSeries: (series: BumpSeries) => void;\n unregisterSeries: (key: string) => void;\n from: SharedValue;\n to: SharedValue;\n morph: SharedValue;\n highlight: string | null;\n setHighlight: (key: string | null) => void;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst BumpChartContext = createContext(null);\n\nfunction useChart(component: string): BumpChartContextValue {\n const context = useContext(BumpChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The row under the scrub, and which line is picked out. */\nexport function useBumpChart() {\n const { data, activeIndexJS, xDataKey, ranks, highlight, setHighlight } =\n useChart('useBumpChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n ranks,\n highlight,\n setHighlight,\n };\n}\n\nexport interface BumpChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a column: a week, a round, a release. */\n data: BumpChartDatum[];\n /** Key holding the column's label. */\n xDataKey?: string;\n /**\n * What the series columns hold. `rank` (the default) takes them as places,\n * `1` at the top. `score` ranks every row for you, highest first.\n */\n values?: 'rank' | 'score';\n /**\n * The series drawn in its colour and on top, with the rest muted. `null`\n * picks none, and every line keeps its own colour. Controlled — pair it with\n * `onHighlightChange`.\n */\n highlight?: string | null;\n /** The series picked out on first render, when `highlight` is not passed. */\n defaultHighlight?: string | null;\n /** Called when a name in `BumpChart.Labels` is tapped. */\n onHighlightChange?: (key: string | null) => void;\n /**\n * `loading` hides the lines and shows `BumpChart.Skeleton` if there is one.\n * The lines are revealed when it turns `ready`.\n */\n status?: BumpChartStatus;\n /** Width ÷ height. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the lines to move to new places when the data changes. */\n morphDuration?: number;\n /**\n * The column under the scrub as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: BumpChartDatum | null) => void;\n /** Drop the axis padding, for a chart with no axes. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface BumpChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\n/**\n * What the root needs to know about its children before it lays the plot out:\n * whether an axis or the names want room at the sides, and the order the lines\n * were declared in.\n */\nfunction survey(children: ReactNode) {\n let hasYAxis = false;\n let labelsWidth = 0;\n const order: string[] = [];\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; labels?: boolean; line?: boolean };\n const props = child.props as { dataKey?: string; width?: number };\n if (type.axis === 'y') hasYAxis = true;\n if (type.labels) labelsWidth = props.width ?? DEFAULT_LABELS_WIDTH;\n if (type.line && typeof props.dataKey === 'string') order.push(props.dataKey);\n });\n return { hasYAxis, labelsWidth, order };\n}\n\nconst BumpChartRoot = forwardRef(function BumpChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n values = 'rank',\n highlight: highlightProp,\n defaultHighlight = null,\n onHighlightChange,\n status = 'ready',\n aspectRatio = 1.7,\n animationDuration = 700,\n morphDuration = 500,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registered, setRegistered] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const morph = useSharedValue(1);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const { value: highlight, setValue: setHighlight } = useControllableState({\n value: highlightProp,\n defaultValue: defaultHighlight,\n onChange: onHighlightChange,\n });\n\n const registerSeries = useMemo(\n () => (entry: BumpSeries) =>\n setRegistered((current) => {\n const existing = current.find((item) => item.key === entry.key);\n if (\n existing &&\n existing.color === entry.color &&\n existing.label === entry.label &&\n existing.strokeWidth === entry.strokeWidth &&\n existing.showDots === entry.showDots\n ) {\n return current;\n }\n return [...current.filter((item) => item.key !== entry.key), entry];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setRegistered((current) => current.filter((item) => item.key !== key)),\n []\n );\n\n const { hasYAxis, labelsWidth, order } = survey(children);\n const orderKey = order.join('|');\n\n /*\n * Declaration order, not registration order. A line re-registers when its\n * colour resolves or its label changes, which would otherwise move it to the\n * end of the list — and in `score` mode the order breaks ties.\n */\n const series = useMemo(() => {\n const declared = orderKey ? orderKey.split('|') : [];\n const position = (key: string) => {\n const index = declared.indexOf(key);\n return index === -1 ? declared.length : index;\n };\n return [...registered].sort((a, b) => position(a.key) - position(b.key));\n }, [registered, orderKey]);\n\n const pad = compact\n ? { top: 6, right: 6, bottom: 6, left: 6 }\n : {\n ...PADDING,\n left: hasYAxis ? Y_AXIS_WIDTH + Y_AXIS_GUTTER : PADDING.left,\n right: labelsWidth ? labelsWidth + LABELS_GAP : PADDING.right,\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map((item) => item.key).join('|');\n const ranks = useMemo(\n () => bumpRanks(data, seriesKeys ? seriesKeys.split('|') : [], { values }),\n [data, seriesKeys, values]\n );\n\n /** How many places there are: the lowest rank anybody holds. */\n const rows = useMemo(() => {\n let most = 0;\n for (const key of Object.keys(ranks)) {\n for (const rank of ranks[key]!) {\n if (rank !== null && rank > most) most = rank;\n }\n }\n return Math.max(most, 1);\n }, [ranks]);\n\n const fractions = useMemo(() => rankFractions(ranks, rows), [ranks, rows]);\n const from = useSharedValue(fractions);\n const to = useSharedValue(fractions);\n\n /*\n * A change of data moves every line from where it is drawn now to its new\n * place. \"Where it is drawn now\" is read on the UI thread, inside the same\n * worklet that starts the move, so a change landing mid-move carries on from\n * the lines' actual positions instead of snapping back to the last target.\n */\n const loading = status === 'loading';\n const settledOnce = useRef(false);\n useEffect(() => {\n if (!settledOnce.current || reducedMotion) {\n settledOnce.current = true;\n from.value = fractions;\n to.value = fractions;\n morph.value = 1;\n return;\n }\n runOnUI((next: BumpRanks, duration: number) => {\n 'worklet';\n const progress = morph.value;\n const current: BumpRanks = {};\n const previousTo = to.value;\n const previousFrom = from.value;\n for (const key of Object.keys(next)) {\n const target = previousTo[key];\n const origin = previousFrom[key];\n current[key] = next[key]!.map((_unused, index) => {\n const end = target?.[index];\n if (end === null || end === undefined) return null;\n const start = origin?.[index];\n return start === null || start === undefined ? end : start + (end - start) * progress;\n });\n }\n from.value = current;\n to.value = next;\n morph.value = 0;\n morph.value = withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) });\n })(fractions, morphDuration);\n }, [fractions, reducedMotion, morphDuration, from, to, morph]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n // Going back to `loading` arms the reveal again, so a refetched chart\n // draws itself in rather than appearing complete on the frame it lands.\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n /*\n * The reveal widens past the plot's right edge by the dots' radius, so the\n * last column's dots are not cut in half when it finishes.\n */\n const revealWidth = plot.width + 8;\n const revealStyle = useAnimatedStyle(() => ({ width: revealWidth * reveal.value + 8 }));\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n pad,\n status,\n series,\n ranks,\n rows,\n registerSeries,\n unregisterSeries,\n from,\n to,\n morph,\n highlight,\n setHighlight,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` and `pad` are rebuilt every render from `size`, so they are\n // compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n pad.right,\n pad.bottom,\n status,\n series,\n ranks,\n rows,\n registerSeries,\n unregisterSeries,\n from,\n to,\n morph,\n highlight,\n setHighlight,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n return (\n \n \n {header}\n {\n const index = data.indexOf(datum);\n return [\n [xDataKey, datum[xDataKey]],\n ...series.map((item) => {\n const rank = ranks[item.key]?.[index];\n return [item.label, rank == null ? null : `rank ${rank}`] as [string, unknown];\n }),\n ];\n }}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path: animated\n * props on a clip inside `` never reach the native clip on\n * Android, and the lines would simply appear complete there.\n * Started 8pt left of the plot so the first column's dots are\n * whole from the first frame.\n */}\n {loading ? null : (\n \n \n {seriesLayer}\n \n \n \n )}\n {overlay}\n \n ) : null}\n \n \n \n );\n});\nBumpChartRoot.displayName = 'BumpChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartGridProps {\n /** A guide down every column. */\n vertical?: boolean;\n /** A guide across every rank. */\n horizontal?: boolean;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/** Guides down each column, so a dot can be read against the label below it. */\nfunction BumpChartGrid({\n vertical = true,\n horizontal = false,\n color,\n dashArray = '3,5',\n opacity = 1,\n}: BumpChartGridProps) {\n const { plot, data, rows } = useChart('BumpChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n return (\n \n {vertical\n ? data.map((_unused, index) => {\n const x = xOf(index, data.length, plot);\n return (\n \n );\n })\n : null}\n {horizontal\n ? Array.from({ length: rows }, (_unused, index) => {\n const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));\n return (\n \n );\n })\n : null}\n \n );\n}\nBumpChartGrid.displayName = 'BumpChart.Grid';\nBumpChartGrid.layer = 'svg' as Layer;\n\nexport interface BumpChartLineProps {\n /** Column in the data holding this series' rank, or its score with `values=\"score\"`. */\n dataKey: string;\n /** The name shown by `Labels`, the tooltip and the legend. Defaults to `dataKey`. */\n label?: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Thickness of the line. The picked-out line is drawn one point thicker. */\n strokeWidth?: number;\n /** Draw a dot at every column. */\n showDots?: boolean;\n}\n\n/**\n * One series. It draws nothing itself: the lines are drawn together, so the\n * one picked out can be drawn last and sit on top of every crossing.\n */\nfunction BumpChartLine({\n dataKey,\n label,\n color,\n colorIndex = 1,\n strokeWidth = 1.5,\n showDots = true,\n}: BumpChartLineProps) {\n const { registerSeries, unregisterSeries } = useChart('BumpChart.Line');\n const stroke = useSeriesColor(color, colorIndex);\n const name = label ?? dataKey;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: stroke, label: name, strokeWidth, showDots });\n }, [dataKey, stroke, name, strokeWidth, showDots, registerSeries]);\n\n useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);\n\n return null;\n}\nBumpChartLine.displayName = 'BumpChart.Line';\nBumpChartLine.layer = 'series' as Layer;\n// Read by the root, for the order the lines were declared in.\nBumpChartLine.line = true as const;\n\n/** Every line, the picked-out one last. */\nfunction BumpLines() {\n const { series, highlight } = useChart('BumpChart');\n const muted = useCSSVariable('--color-muted-foreground');\n const mutedColor = typeof muted === 'string' ? muted : '#737373';\n\n const ordered = highlight\n ? [...series.filter((item) => item.key !== highlight), ...series.filter((item) => item.key === highlight)]\n : series;\n\n return (\n \n {ordered.map((item) => {\n const picked = highlight === item.key;\n const dimmed = highlight !== null && !picked;\n return (\n \n );\n })}\n \n );\n}\n\nfunction BumpLine({\n series,\n color,\n opacity,\n strokeWidth,\n dotRadius,\n}: {\n series: BumpSeries;\n color: string;\n opacity: number;\n strokeWidth: number;\n dotRadius: number;\n}) {\n const { plot, from, to, morph } = useChart('BumpChart.Line');\n const key = series.key;\n\n const lineProps = useAnimatedProps(() => ({\n d: pointsAt(key, from.value, to.value, morph.value, plot)\n .map((run) => bumpSegment(run))\n .join(' '),\n }));\n\n const dotProps = useAnimatedProps(() => {\n let d = '';\n for (const run of pointsAt(key, from.value, to.value, morph.value, plot)) {\n d += dotsPath(run, dotRadius);\n }\n return { d };\n });\n\n return (\n \n \n {series.showDots ? : null}\n \n );\n}\n\nexport interface BumpChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n /** How many rank rows to stand in for. */\n rows?: number;\n}\n\n/**\n * The loading state: a thin bar on every rank row with a highlight travelling\n * across them. Flat, so it says where the lines will be and nothing about\n * where they go.\n */\nfunction BumpChartSkeleton({ duration = 1400, color, rows: rowsProp }: BumpChartSkeletonProps) {\n const { plot, status, rows: dataRows } = useChart('BumpChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n const rows = rowsProp ?? Math.max(dataRows, 4);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const gradientId = 'panelui-bump-skeleton';\n const thickness = 4;\n\n return (\n \n \n \n \n \n \n \n \n {Array.from({ length: rows }, (_unused, index) => {\n const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));\n return (\n \n );\n })}\n \n );\n}\nBumpChartSkeleton.displayName = 'BumpChart.Skeleton';\nBumpChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartXAxisProps {\n /** How many columns to label, spread across the run. */\n ticks?: number;\n format?: (datum: BumpChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The column labels. Real text, so they follow the theme's font and text scaling. */\nfunction BumpChartXAxis({ ticks = 4, format, className }: BumpChartXAxisProps) {\n const { data, xDataKey, plot, pad } = useChart('BumpChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nBumpChartXAxis.displayName = 'BumpChart.XAxis';\nBumpChartXAxis.layer = 'overlay' as Layer;\n\nexport interface BumpChartYAxisProps {\n /** Format a rank. Defaults to `#1`, `#2`… */\n format?: (rank: number) => string;\n className?: string;\n}\n\n/** The ranks down the side, one on every row. */\nfunction BumpChartYAxis({ format, className }: BumpChartYAxisProps) {\n const { plot, rows } = useChart('BumpChart.YAxis');\n\n return (\n \n {Array.from({ length: rows }, (_unused, index) => (\n \n {format ? format(index + 1) : `#${index + 1}`}\n \n ))}\n \n );\n}\nBumpChartYAxis.displayName = 'BumpChart.YAxis';\nBumpChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which leaves room for the ranks before laying the plot out.\nBumpChartYAxis.axis = 'y' as const;\n\nconst DEFAULT_LABELS_WIDTH = 84;\n\nexport interface BumpChartLabelsProps {\n /** Room kept to the right of the plot for the names. Longer names are cut short. */\n width?: number;\n /** Let a tap on a name pick that line out, and a second tap clear it. */\n pressable?: boolean;\n className?: string;\n}\n\n/**\n * Each series' name beside its last point, so the lines can be told apart\n * without a legend to look across to. The names move with their lines when\n * the data changes.\n */\nfunction BumpChartLabels({ pressable = true, className }: BumpChartLabelsProps) {\n const { series, plot, pad, highlight, setHighlight, from, to, morph, status } =\n useChart('BumpChart.Labels');\n if (status === 'loading') return null;\n\n return (\n \n {series.map((item) => (\n setHighlight(highlight === item.key ? null : item.key) : undefined\n }\n className={className}\n />\n ))}\n \n );\n}\nBumpChartLabels.displayName = 'BumpChart.Labels';\nBumpChartLabels.layer = 'overlay' as Layer;\n// Read by the root, which keeps room for the names to the right of the plot.\nBumpChartLabels.labels = true as const;\n\nfunction BumpLabel({\n series,\n left,\n width,\n plot,\n from,\n to,\n morph,\n picked,\n dimmed,\n onPress,\n className,\n}: {\n series: BumpSeries;\n left: number;\n width: number;\n plot: Plot;\n from: SharedValue;\n to: SharedValue;\n morph: SharedValue;\n picked: boolean;\n dimmed: boolean;\n onPress?: () => void;\n className?: string;\n}) {\n const key = series.key;\n\n // The name sits level with the line's last point, wherever that is drawn.\n const style = useAnimatedStyle(() => {\n const runs = pointsAt(key, from.value, to.value, morph.value, plot);\n const run = runs[runs.length - 1];\n const last = run?.[run.length - 1];\n if (!last) return { opacity: 0, transform: [{ translateY: 0 }] };\n return { opacity: 1, transform: [{ translateY: last.y - LABEL_ROW / 2 }] };\n });\n\n return (\n \n \n \n {series.label}\n \n \n \n );\n}\n\n/** Height of one name's hit box. */\nconst LABEL_ROW = 20;\n\nexport interface BumpChartTooltipProps {\n color?: string;\n /** Format a rank in the readout. Defaults to `#1`, `#2`… */\n formatRank?: (rank: number) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: BumpChartDatum) => string;\n className?: string;\n}\n\n/**\n * A scrub across the columns, and a readout listing every series in the order\n * it stood at the column under the finger.\n */\nfunction BumpChartTooltip({ color, formatRank, formatX, className }: BumpChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n ranks,\n highlight,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('BumpChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const muted = useCSSVariable('--color-muted-foreground');\n const mutedColor = typeof muted === 'string' ? muted : '#737373';\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n // Declared inside the memo, next to its callers: a worklet may only call\n // another worklet.\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = total < 2 ? 0 : (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n /*\n * The readout sits beside the crosshair rather than over it, on whichever\n * side has room, so it never hides the column it is describing.\n */\n const readoutStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const gap = 10;\n const right = x + gap;\n const fits = right + READOUT_WIDTH <= plot.left + plot.width;\n const at = fits ? right : Math.max(plot.left, x - gap - READOUT_WIDTH);\n return { opacity: 1, transform: [{ translateX: at }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const standings = useMemo(() => {\n if (activeIndexJS < 0) return [];\n return series\n .map((item) => ({ item, rank: ranks[item.key]?.[activeIndexJS] ?? null }))\n .filter((entry): entry is { item: BumpSeries; rank: number } => entry.rank !== null)\n .sort((a, b) => a.rank - b.rank);\n }, [series, ranks, activeIndexJS]);\n\n const fmtRank = formatRank ?? ((rank: number) => `#${rank}`);\n const fmtX = formatX ?? ((datum: BumpChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n {/* Stops short of the names, so a tap there picks a line rather than\n starting a scrub. */}\n \n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {standings.map(({ item, rank }) => (\n \n \n {fmtRank(rank)}\n \n {/* The colour the line is drawn in, so a muted line has a\n muted swatch rather than its unused series colour. */}\n \n \n {item.label}\n \n \n ))}\n \n ) : null}\n \n \n \n );\n}\nBumpChartTooltip.displayName = 'BumpChart.Tooltip';\nBumpChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BumpChartLegendProps extends ViewProps {\n className?: string;\n}\n\n/** A swatch and a name per series, in declaration order. */\nfunction BumpChartLegend({ className, ...props }: BumpChartLegendProps) {\n const { series } = useChart('BumpChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map((item) => (\n \n ))}\n \n );\n}\nBumpChartLegend.displayName = 'BumpChart.Legend';\nBumpChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Draw a swatch and a name per series along the trailing edge. */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of and what it currently reads.\n * The value is not derived here — pass the formatted string, from\n * `onActiveIndexChange` or `onHighlightChange` if it follows the chart.\n */\nfunction BumpChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n children,\n ...props\n}: BumpChartHeaderProps) {\n const { series } = useChart('BumpChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map((item) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {trailing ? {trailing} : null}\n \n );\n}\nBumpChartHeader.displayName = 'BumpChart.Header';\nBumpChartHeader.layer = 'header' as Layer;\n\nexport const BumpChart = Object.assign(BumpChartRoot, {\n Header: BumpChartHeader,\n Grid: BumpChartGrid,\n Line: BumpChartLine,\n Skeleton: BumpChartSkeleton,\n XAxis: BumpChartXAxis,\n YAxis: BumpChartYAxis,\n Labels: BumpChartLabels,\n Tooltip: BumpChartTooltip,\n Legend: BumpChartLegend,\n});\n" + "content": "/**\n * BumpChart — how a set of things ranked against each other over time.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Positions, not values\n *\n * The y-axis is the rank, so the rows are evenly spaced whatever the gap\n * between the underlying scores. That is the point of the chart: who passed\n * whom, and when. A line chart of the same scores answers how far apart they\n * were, and loses the order wherever two lines run close together.\n *\n * Pass the ranks directly, or pass the scores with `values=\"score\"` and each\n * column is ranked for you, highest first. `bumpRanks` is exported so a header\n * or a table beside the chart can read the same order the chart drew.\n *\n * ## One line at a time\n *\n * Past three or four series, every line in its own colour is a tangle. The\n * chart reads best with one line picked out: `highlight` draws that one in its\n * colour and on top, and the rest in a muted grey. Tapping a name in\n * `BumpChart.Labels` picks that line, and tapping it again clears it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Rect, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { finiteChartNumber } from '@/components/ui/finite-chart';\nimport {\n bumpSegment,\n colorStop,\n dotsPath,\n useSeriesColor,\n xOf,\n type ChartPoint,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 8, right: 10, bottom: 32, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for `#1`…`#12`. */\nconst Y_AXIS_WIDTH = 34;\n\n/** Gap between the rank labels and the first column. */\nconst Y_AXIS_GUTTER = 8;\n\n/** Gap between the last column and the series names. */\nconst LABELS_GAP = 12;\n\n/** Box each x label is centred in. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the row it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Width of the scrub readout. */\nconst READOUT_WIDTH = 148;\n\n/** How far the lines that are not picked out fade back. */\nconst DIMMED_OPACITY = 0.5;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type BumpChartStatus = 'loading' | 'ready';\nexport type BumpChartDatum = Record;\n\n/** Ranks per series key, one entry per row. `null` where a series has none. */\nexport type BumpRanks = Record;\n\n/**\n * Each series' rank in every row.\n *\n * With `values: 'rank'` the rows already hold ranks and they are only\n * validated. With `values: 'score'` each row is ranked highest first; a tie\n * keeps the order the keys are passed in, so two equal scores never draw on\n * top of each other.\n */\nexport function bumpRanks(\n data: BumpChartDatum[],\n keys: string[],\n options: { values?: 'rank' | 'score' } = {}\n): BumpRanks {\n const ranks: BumpRanks = {};\n for (const key of keys) ranks[key] = [];\n\n for (const row of data) {\n if (options.values === 'score') {\n const scored = keys\n .map((key, order) => ({ key, order, score: finiteChartNumber(row[key]) }))\n .filter((entry): entry is { key: string; order: number; score: number } =>\n entry.score !== undefined\n )\n .sort((a, b) => b.score - a.score || a.order - b.order);\n const place = new Map(scored.map((entry, index) => [entry.key, index + 1]));\n for (const key of keys) ranks[key]!.push(place.get(key) ?? null);\n } else {\n for (const key of keys) {\n const rank = finiteChartNumber(row[key]);\n ranks[key]!.push(rank !== undefined && rank >= 1 ? rank : null);\n }\n }\n }\n return ranks;\n}\n\n/**\n * Ranks as a fraction of the plot's height: `0` is the top row, `1` the\n * bottom. Stored this way so a line can travel between two charts with a\n * different number of rows without a jump.\n */\nfunction rankFractions(ranks: BumpRanks, rows: number): BumpRanks {\n const out: BumpRanks = {};\n for (const key of Object.keys(ranks)) {\n out[key] = ranks[key]!.map((rank) =>\n rank === null ? null : rows <= 1 ? 0.5 : (rank - 1) / (rows - 1)\n );\n }\n return out;\n}\n\n/** A line's points at `progress` of the way between two sets of positions. */\nfunction pointsAt(\n key: string,\n from: BumpRanks,\n to: BumpRanks,\n progress: number,\n plot: Plot\n): ChartPoint[][] {\n 'worklet';\n const target = to[key];\n if (!target) return [];\n const origin = from[key];\n const runs: ChartPoint[][] = [];\n let run: ChartPoint[] = [];\n for (let i = 0; i < target.length; i += 1) {\n const end = target[i];\n if (end === null || end === undefined) {\n if (run.length) runs.push(run);\n run = [];\n continue;\n }\n const start = origin?.[i];\n const fraction =\n start === null || start === undefined ? end : start + (end - start) * progress;\n run.push({ x: xOf(i, target.length, plot), y: plot.top + fraction * plot.height });\n }\n if (run.length) runs.push(run);\n return runs;\n}\n\ninterface BumpSeries {\n key: string;\n color: string;\n label: string;\n strokeWidth: number;\n showDots: boolean;\n}\n\ninterface BumpChartContextValue {\n data: BumpChartDatum[];\n xDataKey: string;\n plot: Plot;\n pad: typeof PADDING;\n status: BumpChartStatus;\n series: BumpSeries[];\n ranks: BumpRanks;\n rows: number;\n registerSeries: (series: BumpSeries) => void;\n unregisterSeries: (key: string) => void;\n from: SharedValue;\n to: SharedValue;\n morph: SharedValue;\n highlight: string | null;\n setHighlight: (key: string | null) => void;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst BumpChartContext = createContext(null);\n\nfunction useChart(component: string): BumpChartContextValue {\n const context = useContext(BumpChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The row under the scrub, and which line is picked out. */\nexport function useBumpChart() {\n const { data, activeIndexJS, xDataKey, ranks, highlight, setHighlight } =\n useChart('useBumpChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n ranks,\n highlight,\n setHighlight,\n };\n}\n\nexport interface BumpChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a column: a week, a round, a release. */\n data: BumpChartDatum[];\n /** Key holding the column's label. */\n xDataKey?: string;\n /**\n * What the series columns hold. `rank` (the default) takes them as places,\n * `1` at the top. `score` ranks every row for you, highest first.\n */\n values?: 'rank' | 'score';\n /**\n * The series drawn in its colour and on top, with the rest muted. `null`\n * picks none, and every line keeps its own colour. Controlled — pair it with\n * `onHighlightChange`.\n */\n highlight?: string | null;\n /** The series picked out on first render, when `highlight` is not passed. */\n defaultHighlight?: string | null;\n /** Called when a name in `BumpChart.Labels` is tapped. */\n onHighlightChange?: (key: string | null) => void;\n /**\n * `loading` hides the lines and shows `BumpChart.Skeleton` if there is one.\n * The lines are revealed when it turns `ready`.\n */\n status?: BumpChartStatus;\n /** Width ÷ height. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the lines to move to new places when the data changes. */\n morphDuration?: number;\n /**\n * The column under the scrub as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: BumpChartDatum | null) => void;\n /** Drop the axis padding, for a chart with no axes. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface BumpChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\n/**\n * What the root needs to know about its children before it lays the plot out:\n * whether an axis or the names want room at the sides, and the order the lines\n * were declared in.\n */\nfunction survey(children: ReactNode) {\n let hasYAxis = false;\n let labelsWidth = 0;\n const order: string[] = [];\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; labels?: boolean; line?: boolean };\n const props = child.props as { dataKey?: string; width?: number };\n if (type.axis === 'y') hasYAxis = true;\n if (type.labels) labelsWidth = props.width ?? DEFAULT_LABELS_WIDTH;\n if (type.line && typeof props.dataKey === 'string') order.push(props.dataKey);\n });\n return { hasYAxis, labelsWidth, order };\n}\n\nconst BumpChartRoot = forwardRef(function BumpChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n values = 'rank',\n highlight: highlightProp,\n defaultHighlight = null,\n onHighlightChange,\n status = 'ready',\n aspectRatio = 1.7,\n animationDuration = 700,\n morphDuration = 500,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registered, setRegistered] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const morph = useSharedValue(1);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const { value: highlight, setValue: setHighlight } = useControllableState({\n value: highlightProp,\n defaultValue: defaultHighlight,\n onChange: onHighlightChange,\n });\n\n const registerSeries = useMemo(\n () => (entry: BumpSeries) =>\n setRegistered((current) => {\n const existing = current.find((item) => item.key === entry.key);\n if (\n existing &&\n existing.color === entry.color &&\n existing.label === entry.label &&\n existing.strokeWidth === entry.strokeWidth &&\n existing.showDots === entry.showDots\n ) {\n return current;\n }\n return [...current.filter((item) => item.key !== entry.key), entry];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setRegistered((current) => current.filter((item) => item.key !== key)),\n []\n );\n\n const { hasYAxis, labelsWidth, order } = survey(children);\n const orderKey = order.join('|');\n\n /*\n * Declaration order, not registration order. A line re-registers when its\n * colour resolves or its label changes, which would otherwise move it to the\n * end of the list — and in `score` mode the order breaks ties.\n */\n const series = useMemo(() => {\n const declared = orderKey ? orderKey.split('|') : [];\n const position = (key: string) => {\n const index = declared.indexOf(key);\n return index === -1 ? declared.length : index;\n };\n return [...registered].sort((a, b) => position(a.key) - position(b.key));\n }, [registered, orderKey]);\n\n const pad = compact\n ? { top: 6, right: 6, bottom: 6, left: 6 }\n : {\n ...PADDING,\n left: hasYAxis ? Y_AXIS_WIDTH + Y_AXIS_GUTTER : PADDING.left,\n right: labelsWidth ? labelsWidth + LABELS_GAP : PADDING.right,\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map((item) => item.key).join('|');\n const ranks = useMemo(\n () => bumpRanks(data, seriesKeys ? seriesKeys.split('|') : [], { values }),\n [data, seriesKeys, values]\n );\n\n /** How many places there are: the lowest rank anybody holds. */\n const rows = useMemo(() => {\n let most = 0;\n for (const key of Object.keys(ranks)) {\n for (const rank of ranks[key]!) {\n if (rank !== null && rank > most) most = rank;\n }\n }\n return Math.max(most, 1);\n }, [ranks]);\n\n const fractions = useMemo(() => rankFractions(ranks, rows), [ranks, rows]);\n const from = useSharedValue(fractions);\n const to = useSharedValue(fractions);\n\n /*\n * A change of data moves every line from where it is drawn now to its new\n * place. \"Where it is drawn now\" is read on the UI thread, inside the same\n * worklet that starts the move, so a change landing mid-move carries on from\n * the lines' actual positions instead of snapping back to the last target.\n */\n const loading = status === 'loading';\n const settledOnce = useRef(false);\n useEffect(() => {\n if (!settledOnce.current || reducedMotion) {\n settledOnce.current = true;\n from.value = fractions;\n to.value = fractions;\n morph.value = 1;\n return;\n }\n runOnUI((next: BumpRanks, duration: number) => {\n 'worklet';\n const progress = morph.value;\n const current: BumpRanks = {};\n const previousTo = to.value;\n const previousFrom = from.value;\n for (const key of Object.keys(next)) {\n const target = previousTo[key];\n const origin = previousFrom[key];\n current[key] = next[key]!.map((_unused, index) => {\n const end = target?.[index];\n if (end === null || end === undefined) return null;\n const start = origin?.[index];\n return start === null || start === undefined ? end : start + (end - start) * progress;\n });\n }\n from.value = current;\n to.value = next;\n morph.value = 0;\n morph.value = withTiming(1, { duration, easing: Easing.inOut(Easing.cubic) });\n })(fractions, morphDuration);\n }, [fractions, reducedMotion, morphDuration, from, to, morph]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n // Going back to `loading` arms the reveal again, so a refetched chart\n // draws itself in rather than appearing complete on the frame it lands.\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n /*\n * The reveal widens past the plot's right edge by the dots' radius, so the\n * last column's dots are not cut in half when it finishes.\n */\n const revealWidth = plot.width + 8;\n const revealStyle = useAnimatedStyle(() => ({ width: revealWidth * reveal.value + 8 }));\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n pad,\n status,\n series,\n ranks,\n rows,\n registerSeries,\n unregisterSeries,\n from,\n to,\n morph,\n highlight,\n setHighlight,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` and `pad` are rebuilt every render from `size`, so they are\n // compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n pad.right,\n pad.bottom,\n status,\n series,\n ranks,\n rows,\n registerSeries,\n unregisterSeries,\n from,\n to,\n morph,\n highlight,\n setHighlight,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n return (\n \n \n {header}\n {\n const index = data.indexOf(datum);\n return [\n [xDataKey, datum[xDataKey]],\n ...series.map((item) => {\n const rank = ranks[item.key]?.[index];\n return [item.label, rank == null ? null : `rank ${rank}`] as [string, unknown];\n }),\n ];\n }}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path: animated\n * props on a clip inside `` never reach the native clip on\n * Android, and the lines would simply appear complete there.\n * Started 8pt left of the plot so the first column's dots are\n * whole from the first frame.\n */}\n {loading ? null : (\n \n \n {seriesLayer}\n \n \n \n )}\n {overlay}\n \n ) : null}\n \n \n \n );\n});\nBumpChartRoot.displayName = 'BumpChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartGridProps {\n /** A guide down every column. */\n vertical?: boolean;\n /** A guide across every rank. */\n horizontal?: boolean;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/** Guides down each column, so a dot can be read against the label below it. */\nfunction BumpChartGrid({\n vertical = true,\n horizontal = false,\n color,\n dashArray = '3,5',\n opacity = 1,\n}: BumpChartGridProps) {\n const { plot, data, rows } = useChart('BumpChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n return (\n \n {vertical\n ? data.map((_unused, index) => {\n const x = xOf(index, data.length, plot);\n return (\n \n );\n })\n : null}\n {horizontal\n ? Array.from({ length: rows }, (_unused, index) => {\n const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));\n return (\n \n );\n })\n : null}\n \n );\n}\nBumpChartGrid.displayName = 'BumpChart.Grid';\nBumpChartGrid.layer = 'svg' as Layer;\n\nexport interface BumpChartLineProps {\n /** Column in the data holding this series' rank, or its score with `values=\"score\"`. */\n dataKey: string;\n /** The name shown by `Labels`, the tooltip and the legend. Defaults to `dataKey`. */\n label?: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /** Thickness of the line. The picked-out line is drawn one point thicker. */\n strokeWidth?: number;\n /** Draw a dot at every column. */\n showDots?: boolean;\n}\n\n/**\n * One series. It draws nothing itself: the lines are drawn together, so the\n * one picked out can be drawn last and sit on top of every crossing.\n */\nfunction BumpChartLine({\n dataKey,\n label,\n color,\n colorIndex = 1,\n strokeWidth = 1.5,\n showDots = true,\n}: BumpChartLineProps) {\n const { registerSeries, unregisterSeries } = useChart('BumpChart.Line');\n const stroke = useSeriesColor(color, colorIndex);\n const name = label ?? dataKey;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: stroke, label: name, strokeWidth, showDots });\n }, [dataKey, stroke, name, strokeWidth, showDots, registerSeries]);\n\n useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);\n\n return null;\n}\nBumpChartLine.displayName = 'BumpChart.Line';\nBumpChartLine.layer = 'series' as Layer;\n// Read by the root, for the order the lines were declared in.\nBumpChartLine.line = true as const;\n\n/** Every line, the picked-out one last. */\nfunction BumpLines() {\n const { series, highlight } = useChart('BumpChart');\n const muted = useCSSVariable('--color-muted-foreground');\n const mutedColor = typeof muted === 'string' ? muted : '#737373';\n\n const ordered = highlight\n ? [...series.filter((item) => item.key !== highlight), ...series.filter((item) => item.key === highlight)]\n : series;\n\n return (\n \n {ordered.map((item) => {\n const picked = highlight === item.key;\n const dimmed = highlight !== null && !picked;\n return (\n \n );\n })}\n \n );\n}\n\nfunction BumpLine({\n series,\n color,\n opacity,\n strokeWidth,\n dotRadius,\n}: {\n series: BumpSeries;\n color: string;\n opacity: number;\n strokeWidth: number;\n dotRadius: number;\n}) {\n const { plot, from, to, morph } = useChart('BumpChart.Line');\n const key = series.key;\n\n const lineProps = useAnimatedProps(() => ({\n d: pointsAt(key, from.value, to.value, morph.value, plot)\n .map((run) => bumpSegment(run))\n .join(' '),\n }));\n\n const dotProps = useAnimatedProps(() => {\n let d = '';\n for (const run of pointsAt(key, from.value, to.value, morph.value, plot)) {\n d += dotsPath(run, dotRadius);\n }\n return { d };\n });\n\n return (\n \n \n {series.showDots ? : null}\n \n );\n}\n\nexport interface BumpChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n /** How many rank rows to stand in for. */\n rows?: number;\n}\n\n/**\n * The loading state: a thin bar on every rank row with a highlight travelling\n * across them. Flat, so it says where the lines will be and nothing about\n * where they go.\n */\nfunction BumpChartSkeleton({ duration = 1400, color, rows: rowsProp }: BumpChartSkeletonProps) {\n const { plot, status, rows: dataRows } = useChart('BumpChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n const rows = rowsProp ?? Math.max(dataRows, 4);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const gradientId = 'panelui-bump-skeleton';\n const thickness = 4;\n\n return (\n \n \n \n \n \n \n \n \n {Array.from({ length: rows }, (_unused, index) => {\n const y = plot.top + (rows <= 1 ? plot.height / 2 : (plot.height * index) / (rows - 1));\n return (\n \n );\n })}\n \n );\n}\nBumpChartSkeleton.displayName = 'BumpChart.Skeleton';\nBumpChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartXAxisProps {\n /** How many columns to label, spread across the run. */\n ticks?: number;\n format?: (datum: BumpChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The column labels. Real text, so they follow the theme's font and text scaling. */\nfunction BumpChartXAxis({ ticks = 4, format, className }: BumpChartXAxisProps) {\n const { data, xDataKey, plot, pad } = useChart('BumpChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nBumpChartXAxis.displayName = 'BumpChart.XAxis';\nBumpChartXAxis.layer = 'overlay' as Layer;\n\nexport interface BumpChartYAxisProps {\n /** Format a rank. Defaults to `#1`, `#2`… */\n format?: (rank: number) => string;\n className?: string;\n}\n\n/** The ranks down the side, one on every row. */\nfunction BumpChartYAxis({ format, className }: BumpChartYAxisProps) {\n const { plot, rows } = useChart('BumpChart.YAxis');\n\n return (\n \n {Array.from({ length: rows }, (_unused, index) => (\n \n {format ? format(index + 1) : `#${index + 1}`}\n \n ))}\n \n );\n}\nBumpChartYAxis.displayName = 'BumpChart.YAxis';\nBumpChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which leaves room for the ranks before laying the plot out.\nBumpChartYAxis.axis = 'y' as const;\n\nconst DEFAULT_LABELS_WIDTH = 84;\n\nexport interface BumpChartLabelsProps {\n /** Room kept to the right of the plot for the names. Longer names are cut short. */\n width?: number;\n /** Let a tap on a name pick that line out, and a second tap clear it. */\n pressable?: boolean;\n className?: string;\n}\n\n/**\n * Each series' name beside its last point, so the lines can be told apart\n * without a legend to look across to. The names move with their lines when\n * the data changes.\n */\nfunction BumpChartLabels({ pressable = true, className }: BumpChartLabelsProps) {\n const { series, plot, pad, highlight, setHighlight, from, to, morph, status } =\n useChart('BumpChart.Labels');\n if (status === 'loading') return null;\n\n return (\n \n {series.map((item) => (\n setHighlight(highlight === item.key ? null : item.key) : undefined\n }\n className={className}\n />\n ))}\n \n );\n}\nBumpChartLabels.displayName = 'BumpChart.Labels';\nBumpChartLabels.layer = 'overlay' as Layer;\n// Read by the root, which keeps room for the names to the right of the plot.\nBumpChartLabels.labels = true as const;\n\nfunction BumpLabel({\n series,\n left,\n width,\n plot,\n from,\n to,\n morph,\n picked,\n dimmed,\n onPress,\n className,\n}: {\n series: BumpSeries;\n left: number;\n width: number;\n plot: Plot;\n from: SharedValue;\n to: SharedValue;\n morph: SharedValue;\n picked: boolean;\n dimmed: boolean;\n onPress?: () => void;\n className?: string;\n}) {\n const key = series.key;\n\n // The name sits level with the line's last point, wherever that is drawn.\n const style = useAnimatedStyle(() => {\n const runs = pointsAt(key, from.value, to.value, morph.value, plot);\n const run = runs[runs.length - 1];\n const last = run?.[run.length - 1];\n if (!last) return { opacity: 0, transform: [{ translateY: 0 }] };\n return { opacity: 1, transform: [{ translateY: last.y - LABEL_ROW / 2 }] };\n });\n\n return (\n \n \n \n {series.label}\n \n \n \n );\n}\n\n/** Height of one name's hit box. */\nconst LABEL_ROW = 20;\n\nexport interface BumpChartTooltipProps {\n color?: string;\n /** Format a rank in the readout. Defaults to `#1`, `#2`… */\n formatRank?: (rank: number) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: BumpChartDatum) => string;\n className?: string;\n}\n\n/**\n * A scrub across the columns, and a readout listing every series in the order\n * it stood at the column under the finger.\n */\nfunction BumpChartTooltip({ color, formatRank, formatX, className }: BumpChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n ranks,\n highlight,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('BumpChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const muted = useCSSVariable('--color-muted-foreground');\n const mutedColor = typeof muted === 'string' ? muted : '#737373';\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n // Declared inside the memo, next to its callers: a worklet may only call\n // another worklet.\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = total < 2 ? 0 : (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n /*\n * The readout sits beside the crosshair rather than over it, on whichever\n * side has room, so it never hides the column it is describing.\n */\n const readoutStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const gap = 10;\n const right = x + gap;\n const fits = right + READOUT_WIDTH <= plot.left + plot.width;\n const at = fits ? right : Math.max(plot.left, x - gap - READOUT_WIDTH);\n return { opacity: 1, transform: [{ translateX: at }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const standings = useMemo(() => {\n if (activeIndexJS < 0) return [];\n return series\n .map((item) => ({ item, rank: ranks[item.key]?.[activeIndexJS] ?? null }))\n .filter((entry): entry is { item: BumpSeries; rank: number } => entry.rank !== null)\n .sort((a, b) => a.rank - b.rank);\n }, [series, ranks, activeIndexJS]);\n\n const fmtRank = formatRank ?? ((rank: number) => `#${rank}`);\n const fmtX = formatX ?? ((datum: BumpChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n {/* Stops short of the names, so a tap there picks a line rather than\n starting a scrub. */}\n \n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {standings.map(({ item, rank }) => (\n \n \n {fmtRank(rank)}\n \n {/* The colour the line is drawn in, so a muted line has a\n muted swatch rather than its unused series colour. */}\n \n \n {item.label}\n \n \n ))}\n \n ) : null}\n \n \n \n );\n}\nBumpChartTooltip.displayName = 'BumpChart.Tooltip';\nBumpChartTooltip.layer = 'overlay' as Layer;\n\nexport interface BumpChartLegendProps extends ViewProps {\n className?: string;\n}\n\n/** A swatch and a name per series, in declaration order. */\nfunction BumpChartLegend({ className, ...props }: BumpChartLegendProps) {\n const { series } = useChart('BumpChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map((item) => (\n \n ))}\n \n );\n}\nBumpChartLegend.displayName = 'BumpChart.Legend';\nBumpChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface BumpChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Draw a swatch and a name per series along the trailing edge. */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of and what it currently reads.\n * The value is not derived here — pass the formatted string, from\n * `onActiveIndexChange` or `onHighlightChange` if it follows the chart.\n */\nfunction BumpChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n children,\n ...props\n}: BumpChartHeaderProps) {\n const { series } = useChart('BumpChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map((item) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {trailing ? {trailing} : null}\n \n );\n}\nBumpChartHeader.displayName = 'BumpChart.Header';\nBumpChartHeader.layer = 'header' as Layer;\n\nexport const BumpChart = Object.assign(BumpChartRoot, {\n Header: BumpChartHeader,\n Grid: BumpChartGrid,\n Line: BumpChartLine,\n Skeleton: BumpChartSkeleton,\n XAxis: BumpChartXAxis,\n YAxis: BumpChartYAxis,\n Labels: BumpChartLabels,\n Tooltip: BumpChartTooltip,\n Legend: BumpChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/candlestick-chart.json b/apps/docs/public/r/candlestick-chart.json index 2f5f44ee8..0c05966e4 100644 --- a/apps/docs/public/r/candlestick-chart.json +++ b/apps/docs/public/r/candlestick-chart.json @@ -19,7 +19,7 @@ { "path": "ui/candlestick-chart.tsx", "type": "registry:ui", - "content": "/**\n * CandlestickChart — four numbers a period, drawn as one mark.\n *\n * Composed the same way every chart here is: the grid, the candles, the axes\n * and the readout are separate children, so a chart that wants no grid simply\n * does not have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## One mark, four numbers\n *\n * A row is `{ open, high, low, close }`. The body spans open to close and is\n * filled by direction — rising when the close is at or above the open, falling\n * when it is below — and the wick spans low to high behind it. Colour is\n * therefore the *sign* of the period rather than a series identity, which is\n * why this chart has no series list and no legend: there is only ever one\n * thing plotted, and the two colours are its two states.\n *\n * ## The baseline is not zero, and must not be\n *\n * A bar compares lengths, so its axis has to reach zero or twice as tall stops\n * meaning twice as much. A candle compares nothing to zero. What is being read\n * is the distance between four numbers that sit close together and far from the\n * origin — a share at 180 that moved between 178 and 183 is a chart of that\n * five-point span, and forcing zero onto the axis turns every candle in it into\n * a dash. So the domain is derived from the lowest low and the highest high,\n * with a margin so the extremes are not drawn on the frame.\n *\n * ## Two paths, not two per candle\n *\n * Every rising body is a subpath of one path and every falling body of another,\n * and the wicks likewise — four animated props a frame whether the chart holds\n * twenty candles or two hundred. A candle also cannot be drawn as a rectangle\n * element and animated: the grow-in scales each body about its own middle, and\n * that is a path rebuilt per frame on the UI thread rather than a transform\n * that would take the wick and the neighbours with it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Path } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { bandOf, compactNumber, yOf, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 148;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 48;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/**\n * Closest two date labels may be placed, in points.\n *\n * Wider than the text needs, and that is the whole job of it. A candlestick\n * chart holds far more periods than a bar chart holds categories — thirty\n * sessions across a phone is eleven points each — so the axis is always\n * thinning, and how much it thins is set here. Spacing them at the width of the\n * text alone leaves every label touching its neighbour, which reads as one long\n * smear rather than as a row of dates.\n */\nconst MIN_BAND_LABEL = 68;\n\n/**\n * Width one date label is drawn in.\n *\n * Deliberately narrower than the spacing above, so there is always a gap\n * between one label and the next. It is also what each is centred in, so a\n * label sits over its own candle rather than over the slice it shares.\n */\nconst LABEL_BOX = 52;\n\n/** Thinnest a wick is drawn, in points. Below about this it stops being visible. */\nconst WICK_WIDTH = 1.5;\n\n/**\n * Shortest a body is drawn, in points.\n *\n * A period that opened and closed at the same number is the one case where the\n * body has no height of its own, and it is not the same event as no period at\n * all — a flat body says the price came back to where it started, which is\n * worth seeing. It is also what a doji is.\n */\nconst MIN_BODY_HEIGHT = 1.5;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\n/** Green and red before the stylesheet has been read. */\nconst FALLBACK_RISING = '#10b981';\nconst FALLBACK_FALLING = '#ef4444';\n\n/**\n * A candle's colour, which is its *direction* rather than a series identity.\n *\n * The chart-series tokens are wrong here on purpose. They are a palette meant\n * to tell several things apart, and are picked to be distinguishable rather\n * than to mean anything; up and down are not two series, they are two states of\n * one, and the convention for them is older than any palette. So this reaches\n * for the success and destructive tokens, which are the theme's green and red\n * and already carry that sense everywhere else in the library.\n */\nfunction useDirectionColor(\n explicit: string | undefined,\n direction: 'rising' | 'falling'\n): string {\n const token = useCSSVariable(\n direction === 'rising' ? '--color-success' : '--color-destructive'\n );\n const fallback = direction === 'rising' ? FALLBACK_RISING : FALLBACK_FALLING;\n return explicit ?? (typeof token === 'string' ? token : fallback);\n}\n\nexport type CandlestickChartStatus = 'loading' | 'ready';\n\n/**\n * One period. `open`, `high`, `low` and `close` are required to be numbers —\n * unlike a line, a candle cannot be drawn from some of them.\n */\nexport interface CandlestickChartDatum {\n [key: string]: string | number | null | undefined;\n}\n\ninterface CandlestickChartContextValue {\n data: CandlestickChartDatum[];\n xDataKey: string;\n openKey: string;\n highKey: string;\n lowKey: string;\n closeKey: string;\n plot: Plot;\n status: CandlestickChartStatus;\n candleGap: number;\n candleWidth: number | undefined;\n fadedOpacity: number;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the candles grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst CandlestickChartContext = createContext(null);\n\nfunction useChart(component: string): CandlestickChartContextValue {\n const context = useContext(CandlestickChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The candle under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useCandlestickChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useCandlestickChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\n/** Reads one row's four numbers, or `null` if any of them is missing. */\nfunction ohlcOf(\n row: CandlestickChartDatum | undefined,\n openKey: string,\n highKey: string,\n lowKey: string,\n closeKey: string\n): { open: number; high: number; low: number; close: number } | null {\n 'worklet';\n if (!row) return null;\n const open = row[openKey];\n const high = row[highKey];\n const low = row[lowKey];\n const close = row[closeKey];\n if (\n typeof open !== 'number' ||\n typeof high !== 'number' ||\n typeof low !== 'number' ||\n typeof close !== 'number' ||\n Number.isNaN(open) ||\n Number.isNaN(high) ||\n Number.isNaN(low) ||\n Number.isNaN(close)\n ) {\n return null;\n }\n return { open, high, low, close };\n}\n\nexport interface CandlestickChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a period along the x-axis. */\n data: CandlestickChartDatum[];\n /** Key holding the period label. Used by the axis and the readout. */\n xDataKey?: string;\n /** Key holding the opening price. */\n openDataKey?: string;\n /** Key holding the period's high. */\n highDataKey?: string;\n /** Key holding the period's low. */\n lowDataKey?: string;\n /** Key holding the closing price. */\n closeDataKey?: string;\n /**\n * `loading` leaves the plot empty — the frame, the grid and the header stay,\n * and no candles are drawn. Turning `ready` grows them in left to right.\n *\n * Nothing stands in for the candles while they are missing. A placeholder\n * candle is four made-up prices, and a reader has no way to tell an invented\n * one from a real one until it changes under them.\n */\n status?: CandlestickChartStatus;\n /** Width ÷ height. `1.6` suits a chart this dense better than `2`. */\n aspectRatio?: number;\n /** Milliseconds for the candles to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the price axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the price axis instead of deriving it from the lows and highs. Note\n * that the derived domain deliberately does *not* include zero — see the\n * notes on why a candle's axis is not a bar's.\n */\n yDomain?: [number, number];\n /**\n * Fraction of each period's slice left empty, `0` to `1`. A fraction rather\n * than a pixel gap so the proportions hold at any width.\n */\n candleGap?: number;\n /** Fixed body width in points. Derived from the slice when omitted. */\n candleWidth?: number;\n /** Opacity of the candles that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The candle under the finger as it moves, and `-1`/`null` when it lifts.\n * This is how a readout in the card's header gets its value — that header is\n * outside the chart, so it cannot use `useCandlestickChart`.\n *\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: CandlestickChartDatum | null) => void;\n /** Drop the axis padding, for a dense strip with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface CandlestickChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst CandlestickChartRoot = forwardRef(\n function CandlestickChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n openDataKey = 'open',\n highDataKey = 'high',\n lowDataKey = 'low',\n closeDataKey = 'close',\n status = 'ready',\n aspectRatio = 1.6,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n candleGap = 0.3,\n candleWidth,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * The lowest low to the highest high, with a tenth of the span as margin at\n * each end so the extremes are not drawn on the frame.\n *\n * Nothing here reaches for zero, and that is the point. A candle's four\n * numbers sit close together and usually far from the origin; including\n * zero would compress the whole chart into a band at the top and every\n * candle in it into a dash.\n */\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n let min = Number.POSITIVE_INFINITY;\n let max = Number.NEGATIVE_INFINITY;\n\n for (const row of data) {\n const ohlc = ohlcOf(row, openDataKey, highDataKey, lowDataKey, closeDataKey);\n if (!ohlc) continue;\n if (ohlc.low < min) min = ohlc.low;\n if (ohlc.high > max) max = ohlc.high;\n }\n\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n // A span of nothing — one period, or a price that never moved — still\n // needs a domain to divide by, so it is given a little room either side.\n if (min === max) {\n const nudge = Math.abs(min) * 0.05 || 1;\n return [min - nudge, max + nudge];\n }\n const margin = (max - min) * 0.1;\n return [min - margin, max + margin];\n }, [data, yDomain, openDataKey, highDataKey, lowDataKey, closeDataKey]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart\n * that is refetched comes back fully drawn on the frame the data lands,\n * which reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n openKey: openDataKey,\n highKey: highDataKey,\n lowKey: lowDataKey,\n closeKey: closeDataKey,\n plot,\n status,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n openDataKey,\n highDataKey,\n lowDataKey,\n closeDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n [openDataKey, datum[openDataKey]],\n [highDataKey, datum[highDataKey]],\n [lowDataKey, datum[lowDataKey]],\n [closeDataKey, datum[closeDataKey]],\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nCandlestickChartRoot.displayName = 'CandlestickChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartGridProps {\n /** How many lines to draw across the price axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the price axis, so a candle can be read against a number rather\n * than only against the candle beside it.\n */\nfunction CandlestickChartGrid({\n rows = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: CandlestickChartGridProps) {\n const { plot } = useChart('CandlestickChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nCandlestickChartGrid.displayName = 'CandlestickChart.Grid';\nCandlestickChartGrid.layer = 'svg' as Layer;\n\nexport interface CandlestickChartCandlesProps {\n /** Colour of a period that closed at or above its open. Green by default. */\n risingColor?: string;\n /** Colour of a period that closed below its open. Red by default. */\n fallingColor?: string;\n /** Corner radius on a body. */\n cornerRadius?: number;\n}\n\n/**\n * The candles.\n *\n * Four paths, not four per candle: rising bodies, falling bodies, and the wicks\n * behind each. A chart of two hundred periods is the same four animated props\n * a frame as a chart of twenty, and the split by direction is what lets each\n * half carry its own fill without a fill per mark.\n *\n * The wicks are drawn first so the bodies sit over them, which is what makes a\n * body with a wick behind it read as one mark rather than as a line crossing a\n * rectangle.\n */\nfunction CandlestickChartCandles({\n risingColor,\n fallingColor,\n cornerRadius = 1.5,\n}: CandlestickChartCandlesProps) {\n const {\n data,\n openKey,\n highKey,\n lowKey,\n closeKey,\n plot,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n status,\n } = useChart('CandlestickChart.Candles');\n\n const rising = useDirectionColor(risingColor, 'rising');\n const falling = useDirectionColor(fallingColor, 'falling');\n\n const total = data.length;\n const slice = total > 0 ? plot.width / total : 0;\n const width = Math.max(\n 1,\n Math.min(candleWidth ?? slice * (1 - candleGap), Math.max(slice - 1, 1))\n );\n\n /*\n * Pulled out of the worklet as plain arrays. A worklet may close over numbers\n * and arrays of them freely, but reading `data`'s rows — objects of mixed\n * types, keyed by strings the caller chose — inside one every frame is the\n * kind of work that belongs on the JS side once rather than on the UI thread\n * sixty times a second.\n */\n const columns = useMemo(() => {\n const opens: number[] = [];\n const highs: number[] = [];\n const lows: number[] = [];\n const closes: number[] = [];\n const usable: boolean[] = [];\n for (const row of data) {\n const ohlc = ohlcOf(row, openKey, highKey, lowKey, closeKey);\n opens.push(ohlc?.open ?? 0);\n highs.push(ohlc?.high ?? 0);\n lows.push(ohlc?.low ?? 0);\n closes.push(ohlc?.close ?? 0);\n usable.push(ohlc !== null);\n }\n return { opens, highs, lows, closes, usable };\n }, [data, openKey, highKey, lowKey, closeKey]);\n\n /*\n * The four builders share this. Declared as a worklet at this scope so each\n * `useAnimatedProps` below can call it — a worklet may only call another\n * worklet, and the rule is enforced by crashing rather than by warning.\n */\n const build = (wantRising: boolean, wicks: boolean) => () => {\n 'worklet';\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || total === 0 || plot.width <= 0) return { d: '' };\n\n const grow = reveal.value;\n let path = '';\n\n for (let index = 0; index < total; index += 1) {\n if (!columns.usable[index]) continue;\n const open = columns.opens[index] ?? 0;\n const close = columns.closes[index] ?? 0;\n const up = close >= open;\n if (up !== wantRising) continue;\n\n /*\n * Staggered, so the chart fills in left to right rather than every\n * candle inflating at once. Each one gets the back half of the run,\n * which leaves the last candle finishing with the animation.\n */\n const start = total > 1 ? (index / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n if (eased <= 0) continue;\n\n const centre = bandOf(index, total, plot);\n const half = width / 2;\n\n if (wicks) {\n const high = columns.highs[index] ?? 0;\n const low = columns.lows[index] ?? 0;\n const yHigh = yOf(high, plot, min, max);\n const yLow = yOf(low, plot, min, max);\n // Grown about the body's middle rather than from the low, so the wick\n // opens out in both directions the way the body does.\n const mid = (yHigh + yLow) / 2;\n const top = mid + (yHigh - mid) * eased;\n const bottom = mid + (yLow - mid) * eased;\n const x = centre - WICK_WIDTH / 2;\n path += `M${x} ${top}h${WICK_WIDTH}V${bottom}h${-WICK_WIDTH}Z`;\n continue;\n }\n\n const yOpen = yOf(open, plot, min, max);\n const yClose = yOf(close, plot, min, max);\n const bodyTop = Math.min(yOpen, yClose);\n const bodyBottom = Math.max(yOpen, yClose);\n const height = Math.max(bodyBottom - bodyTop, MIN_BODY_HEIGHT);\n const mid = bodyTop + height / 2;\n const grown = height * eased;\n const top = mid - grown / 2;\n\n // The radius can never exceed half the shortest side, or the two corners\n // on one edge overlap and the arc turns itself inside out.\n const r = Math.max(0, Math.min(cornerRadius, width / 2, grown / 2));\n const x = centre - half;\n\n if (r <= 0) {\n path += `M${x} ${top}h${width}v${grown}h${-width}Z`;\n } else {\n path +=\n `M${x + r} ${top}` +\n `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` +\n `v${grown - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` +\n `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` +\n `v${-(grown - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;\n }\n }\n\n return { d: path };\n };\n\n /*\n * The builder is curried and its result handed straight to\n * `useAnimatedProps`, rather than being called from inside another worklet.\n * That is not a style choice: the hook works out what to re-run on by reading\n * the closure of the worklet it is given, and a shared value read one level\n * down is not in that closure. Wrapped, the paths were built once and then\n * never again — the chart drew nothing until some unrelated re-render\n * happened to rebuild them, which looked like a chart that only appeared once\n * it was touched.\n */\n const risingBodies = useAnimatedProps(build(true, false));\n const fallingBodies = useAnimatedProps(build(false, false));\n const risingWicks = useAnimatedProps(build(true, true));\n const fallingWicks = useAnimatedProps(build(false, true));\n\n /*\n * Dimming is a group opacity driven from the active index, rather than a\n * second pair of paths for the candle under the finger. A candle is one mark\n * of one colour, so there is nothing to keep at full ink separately — the\n * whole group fades and the active candle is redrawn over it at full\n * strength by `Highlight` below.\n */\n const dim = useAnimatedProps(() => ({\n opacity: activeIndex.value >= 0 ? fadedOpacity : 1,\n }));\n\n if (status === 'loading') return null;\n\n return (\n <>\n \n \n \n \n \n \n \n \n );\n}\nCandlestickChartCandles.displayName = 'CandlestickChart.Candles';\nCandlestickChartCandles.layer = 'svg' as Layer;\n\nconst AnimatedGroup = Animated.createAnimatedComponent(G);\n\n/**\n * The candle under the finger, drawn again at full strength over the dimmed\n * rest.\n *\n * Redrawing one mark is cheaper than splitting every path in two and rebuilding\n * both whenever the finger moves to the next candle, and it keeps the grow-in\n * builder above to a single shape.\n */\nfunction CandlestickHighlight({\n rising,\n falling,\n width,\n cornerRadius,\n columns,\n}: {\n rising: string;\n falling: string;\n width: number;\n cornerRadius: number;\n columns: {\n opens: number[];\n highs: number[];\n lows: number[];\n closes: number[];\n usable: boolean[];\n };\n}) {\n const { data, plot, domainMin, domainMax, activeIndex } = useChart(\n 'CandlestickChart.Candles'\n );\n const total = data.length;\n\n /*\n * The fill cannot be crossfaded between two colours on the UI thread, so both\n * paths are drawn and the one that does not apply is emptied. An empty path\n * costs nothing to render, and the swap never touches JS.\n */\n const build = (wantRising: boolean) => () => {\n 'worklet';\n const index = activeIndex.value;\n const min = domainMin.value;\n const max = domainMax.value;\n if (index < 0 || index >= total || max === min || !columns.usable[index]) {\n return { d: '' };\n }\n\n const open = columns.opens[index] ?? 0;\n const close = columns.closes[index] ?? 0;\n if (close >= open !== wantRising) return { d: '' };\n\n const high = columns.highs[index] ?? 0;\n const low = columns.lows[index] ?? 0;\n\n const centre = bandOf(index, total, plot);\n const yOpen = yOf(open, plot, min, max);\n const yClose = yOf(close, plot, min, max);\n const bodyTop = Math.min(yOpen, yClose);\n const height = Math.max(Math.abs(yClose - yOpen), MIN_BODY_HEIGHT);\n const x = centre - width / 2;\n const r = Math.max(0, Math.min(cornerRadius, width / 2, height / 2));\n\n const wickX = centre - WICK_WIDTH / 2;\n const wick =\n `M${wickX} ${yOf(high, plot, min, max)}h${WICK_WIDTH}` +\n `V${yOf(low, plot, min, max)}h${-WICK_WIDTH}Z`;\n\n const body =\n r <= 0\n ? `M${x} ${bodyTop}h${width}v${height}h${-width}Z`\n : `M${x + r} ${bodyTop}` +\n `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` +\n `v${height - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` +\n `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` +\n `v${-(height - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;\n\n return { d: `${wick}${body}` };\n };\n\n const risingShape = useAnimatedProps(build(true));\n const fallingShape = useAnimatedProps(build(false));\n\n return (\n <>\n \n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartXAxisProps {\n /** How many labels to show. Derived from the room available when omitted. */\n ticks?: number;\n /** Format a row's label. Defaults to the value at `xDataKey`. */\n format?: (datum: CandlestickChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The period labels, one under each candle it has room for. Real text rather\n * than SVG text, so they follow the theme's font and the platform's text\n * scaling — SVG text does neither.\n */\nfunction CandlestickChartXAxis({\n ticks,\n format,\n className,\n}: CandlestickChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('CandlestickChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n\n // The axis asks the plot how much room there is and only thins when the\n // answer is not enough, rather than dropping labels that would have fitted.\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, data.length));\n\n // Every nth period, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same one twice and skips its neighbour.\n const stride = Math.ceil(data.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < data.length; index += stride) {\n const datum = data[index];\n if (!datum) continue;\n picked.push({\n key: index,\n text: format ? format(datum, index) : String(datum[xDataKey] ?? ''),\n });\n }\n return picked;\n }, [data, ticks, format, xDataKey, plot.width]);\n\n /*\n * Each label is boxed at a readable width and centred on its own candle, not\n * at the width of the slice that candle occupies.\n *\n * A bar chart can tile its labels one band each, because a chart of eight\n * months has bands wide enough to write a month in. Thirty sessions across a\n * phone gives each one about eleven points, and a date boxed at eleven points\n * is a date clipped to nothing — which is what was happening. The axis has\n * already thinned the labels so the ones it kept are at least this far apart,\n * so a box this wide cannot collide with its neighbour; it is clamped inside\n * the plot so the first and last do not hang off the ends.\n */\n const boxes = useMemo(() => {\n const half = LABEL_BOX / 2;\n return labels.map((label) => {\n const centre = bandOf(label.key, data.length, plot);\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, centre)\n );\n return { ...label, left: clamped - half };\n });\n }, [labels, data.length, plot]);\n\n return (\n \n {boxes.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nCandlestickChartXAxis.displayName = 'CandlestickChart.XAxis';\nCandlestickChartXAxis.layer = 'overlay' as Layer;\n\nexport interface CandlestickChartYAxisProps {\n /** How many labels to show along the price axis. */\n ticks?: number;\n /** Format a price for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Price labels down the side, aligned to the grid lines. */\nfunction CandlestickChartYAxis({\n ticks = 4,\n format,\n className,\n}: CandlestickChartYAxisProps) {\n const { plot, extent } = useChart('CandlestickChart.YAxis');\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === max) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nCandlestickChartYAxis.displayName = 'CandlestickChart.YAxis';\nCandlestickChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out — an axis drawn over the plot is unreadable, and makes what it is\n// drawn over unreadable too.\nCandlestickChartYAxis.axis = 'y' as const;\n\nexport interface CandlestickChartTooltipProps {\n /** Format one of the four prices. Defaults to a compact number. */\n formatValue?: (value: number, field: 'open' | 'high' | 'low' | 'close') => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: CandlestickChartDatum) => string;\n /** Show the period's change from open to close under the four prices. */\n showChange?: boolean;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A candle is already the thing being pointed at, so\n * highlighting it and dimming the rest says the same thing without drawing a\n * line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on a candle to\n * summon is a readout nobody finds — and candles are thin.\n */\nfunction CandlestickChartTooltip({\n formatValue,\n formatX,\n showChange = true,\n className,\n}: CandlestickChartTooltipProps) {\n const {\n data,\n xDataKey,\n openKey,\n highKey,\n lowKey,\n closeKey,\n plot,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('CandlestickChart.Tooltip');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (!total) return;\n const offset = (x - left) / (width || 1);\n // Slices, not points: the finger is inside whichever one it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n // The readout centres over its candle and is clamped inside the plot, so it\n // never runs off the edge at the first or last one.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const slice = plot.width / total;\n const centre = plot.left + slice * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, centre)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const ohlc = ohlcOf(active ?? undefined, openKey, highKey, lowKey, closeKey);\n const fmt = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: CandlestickChartDatum) => String(datum[xDataKey] ?? ''));\n\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n if (status === 'loading') return null;\n\n const change = ohlc ? ohlc.close - ohlc.open : 0;\n const percent = ohlc && ohlc.open !== 0 ? (change / ohlc.open) * 100 : 0;\n\n return (\n \n \n \n {active && ohlc ? (\n \n \n {fmtX(active)}\n \n {/* Two columns of two. Four prices stacked one per line makes a\n readout taller than the plot it is floating over. */}\n \n \n \n \n \n \n \n \n \n \n {showChange ? (\n = 0 ? rising : falling }}\n >\n {change >= 0 ? '+' : ''}\n {compactNumber(change)} ({percent >= 0 ? '+' : ''}\n {percent.toFixed(2)}%)\n \n ) : null}\n \n ) : null}\n \n \n \n );\n}\nCandlestickChartTooltip.displayName = 'CandlestickChart.Tooltip';\nCandlestickChartTooltip.layer = 'overlay' as Layer;\n\n/** One of the four prices in the readout, named by its initial. */\nfunction OhlcRow({ label, value }: { label: string; value: string }) {\n return (\n \n \n {label}\n \n \n {value}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a change. */\n caption?: string;\n /** Name the two colours, for a reader who has not met the convention. */\n legend?: boolean;\n /** What the rising colour is called. */\n risingLabel?: string;\n /** What the falling colour is called. */\n fallingLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the two colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the candles. The\n * card's header is a caption on the tray the chart sits in; this is the chart\n * introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show the latest close when nothing\n * is pressed and a period's close when something is.\n */\nfunction CandlestickChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n risingLabel = 'Up',\n fallingLabel = 'Down',\n children,\n ...props\n}: CandlestickChartHeaderProps) {\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n const trailing =\n children ??\n (legend ? (\n \n \n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, the\n key takes the width it wants and the caption under the value wraps to\n two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nCandlestickChartHeader.displayName = 'CandlestickChart.Header';\nCandlestickChartHeader.layer = 'header' as Layer;\n\n/** One colour and what it means. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\nexport interface CandlestickChartLegendProps extends ViewProps {\n className?: string;\n /** What the rising colour is called. */\n risingLabel?: string;\n /** What the falling colour is called. */\n fallingLabel?: string;\n}\n\n/** The two colours and what they mean, floated over the plot. */\nfunction CandlestickChartLegend({\n className,\n risingLabel = 'Up',\n fallingLabel = 'Down',\n ...props\n}: CandlestickChartLegendProps) {\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n return (\n \n \n \n \n );\n}\nCandlestickChartLegend.displayName = 'CandlestickChart.Legend';\nCandlestickChartLegend.layer = 'overlay' as Layer;\n\nexport const CandlestickChart = Object.assign(CandlestickChartRoot, {\n Header: CandlestickChartHeader,\n Grid: CandlestickChartGrid,\n Candles: CandlestickChartCandles,\n XAxis: CandlestickChartXAxis,\n YAxis: CandlestickChartYAxis,\n Tooltip: CandlestickChartTooltip,\n Legend: CandlestickChartLegend,\n});\n" + "content": "/**\n * CandlestickChart — four numbers a period, drawn as one mark.\n *\n * Composed the same way every chart here is: the grid, the candles, the axes\n * and the readout are separate children, so a chart that wants no grid simply\n * does not have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## One mark, four numbers\n *\n * A row is `{ open, high, low, close }`. The body spans open to close and is\n * filled by direction — rising when the close is at or above the open, falling\n * when it is below — and the wick spans low to high behind it. Colour is\n * therefore the *sign* of the period rather than a series identity, which is\n * why this chart has no series list and no legend: there is only ever one\n * thing plotted, and the two colours are its two states.\n *\n * ## The baseline is not zero, and must not be\n *\n * A bar compares lengths, so its axis has to reach zero or twice as tall stops\n * meaning twice as much. A candle compares nothing to zero. What is being read\n * is the distance between four numbers that sit close together and far from the\n * origin — a share at 180 that moved between 178 and 183 is a chart of that\n * five-point span, and forcing zero onto the axis turns every candle in it into\n * a dash. So the domain is derived from the lowest low and the highest high,\n * with a margin so the extremes are not drawn on the frame.\n *\n * ## Two paths, not two per candle\n *\n * Every rising body is a subpath of one path and every falling body of another,\n * and the wicks likewise — four animated props a frame whether the chart holds\n * twenty candles or two hundred. A candle also cannot be drawn as a rectangle\n * element and animated: the grow-in scales each body about its own middle, and\n * that is a path rebuilt per frame on the UI thread rather than a transform\n * that would take the wick and the neighbours with it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Path } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { bandOf, compactNumber, yOf, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 148;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 48;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/**\n * Closest two date labels may be placed, in points.\n *\n * Wider than the text needs, and that is the whole job of it. A candlestick\n * chart holds far more periods than a bar chart holds categories — thirty\n * sessions across a phone is eleven points each — so the axis is always\n * thinning, and how much it thins is set here. Spacing them at the width of the\n * text alone leaves every label touching its neighbour, which reads as one long\n * smear rather than as a row of dates.\n */\nconst MIN_BAND_LABEL = 68;\n\n/**\n * Width one date label is drawn in.\n *\n * Deliberately narrower than the spacing above, so there is always a gap\n * between one label and the next. It is also what each is centred in, so a\n * label sits over its own candle rather than over the slice it shares.\n */\nconst LABEL_BOX = 52;\n\n/** Thinnest a wick is drawn, in points. Below about this it stops being visible. */\nconst WICK_WIDTH = 1.5;\n\n/**\n * Shortest a body is drawn, in points.\n *\n * A period that opened and closed at the same number is the one case where the\n * body has no height of its own, and it is not the same event as no period at\n * all — a flat body says the price came back to where it started, which is\n * worth seeing. It is also what a doji is.\n */\nconst MIN_BODY_HEIGHT = 1.5;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\n/** Green and red before the stylesheet has been read. */\nconst FALLBACK_RISING = '#10b981';\nconst FALLBACK_FALLING = '#ef4444';\n\n/**\n * A candle's colour, which is its *direction* rather than a series identity.\n *\n * The chart-series tokens are wrong here on purpose. They are a palette meant\n * to tell several things apart, and are picked to be distinguishable rather\n * than to mean anything; up and down are not two series, they are two states of\n * one, and the convention for them is older than any palette. So this reaches\n * for the success and destructive tokens, which are the theme's green and red\n * and already carry that sense everywhere else in the library.\n */\nfunction useDirectionColor(\n explicit: string | undefined,\n direction: 'rising' | 'falling'\n): string {\n const token = useCSSVariable(\n direction === 'rising' ? '--color-success' : '--color-destructive'\n );\n const fallback = direction === 'rising' ? FALLBACK_RISING : FALLBACK_FALLING;\n return explicit ?? (typeof token === 'string' ? token : fallback);\n}\n\nexport type CandlestickChartStatus = 'loading' | 'ready';\n\n/**\n * One period. `open`, `high`, `low` and `close` are required to be numbers —\n * unlike a line, a candle cannot be drawn from some of them.\n */\nexport interface CandlestickChartDatum {\n [key: string]: string | number | null | undefined;\n}\n\ninterface CandlestickChartContextValue {\n data: CandlestickChartDatum[];\n xDataKey: string;\n openKey: string;\n highKey: string;\n lowKey: string;\n closeKey: string;\n plot: Plot;\n status: CandlestickChartStatus;\n candleGap: number;\n candleWidth: number | undefined;\n fadedOpacity: number;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the candles grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst CandlestickChartContext = createContext(null);\n\nfunction useChart(component: string): CandlestickChartContextValue {\n const context = useContext(CandlestickChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The candle under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useCandlestickChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useCandlestickChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\n/** Reads one row's four numbers, or `null` if any of them is missing. */\nfunction ohlcOf(\n row: CandlestickChartDatum | undefined,\n openKey: string,\n highKey: string,\n lowKey: string,\n closeKey: string\n): { open: number; high: number; low: number; close: number } | null {\n 'worklet';\n if (!row) return null;\n const open = row[openKey];\n const high = row[highKey];\n const low = row[lowKey];\n const close = row[closeKey];\n if (\n typeof open !== 'number' ||\n typeof high !== 'number' ||\n typeof low !== 'number' ||\n typeof close !== 'number' ||\n Number.isNaN(open) ||\n Number.isNaN(high) ||\n Number.isNaN(low) ||\n Number.isNaN(close)\n ) {\n return null;\n }\n return { open, high, low, close };\n}\n\nexport interface CandlestickChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a period along the x-axis. */\n data: CandlestickChartDatum[];\n /** Key holding the period label. Used by the axis and the readout. */\n xDataKey?: string;\n /** Key holding the opening price. */\n openDataKey?: string;\n /** Key holding the period's high. */\n highDataKey?: string;\n /** Key holding the period's low. */\n lowDataKey?: string;\n /** Key holding the closing price. */\n closeDataKey?: string;\n /**\n * `loading` leaves the plot empty — the frame, the grid and the header stay,\n * and no candles are drawn. Turning `ready` grows them in left to right.\n *\n * Nothing stands in for the candles while they are missing. A placeholder\n * candle is four made-up prices, and a reader has no way to tell an invented\n * one from a real one until it changes under them.\n */\n status?: CandlestickChartStatus;\n /** Width ÷ height. `1.6` suits a chart this dense better than `2`. */\n aspectRatio?: number;\n /** Milliseconds for the candles to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the price axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the price axis instead of deriving it from the lows and highs. Note\n * that the derived domain deliberately does *not* include zero — see the\n * notes on why a candle's axis is not a bar's.\n */\n yDomain?: [number, number];\n /**\n * Fraction of each period's slice left empty, `0` to `1`. A fraction rather\n * than a pixel gap so the proportions hold at any width.\n */\n candleGap?: number;\n /** Fixed body width in points. Derived from the slice when omitted. */\n candleWidth?: number;\n /** Opacity of the candles that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The candle under the finger as it moves, and `-1`/`null` when it lifts.\n * This is how a readout in the card's header gets its value — that header is\n * outside the chart, so it cannot use `useCandlestickChart`.\n *\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: CandlestickChartDatum | null) => void;\n /** Drop the axis padding, for a dense strip with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface CandlestickChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst CandlestickChartRoot = forwardRef(\n function CandlestickChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n openDataKey = 'open',\n highDataKey = 'high',\n lowDataKey = 'low',\n closeDataKey = 'close',\n status = 'ready',\n aspectRatio = 1.6,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n candleGap = 0.3,\n candleWidth,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * The lowest low to the highest high, with a tenth of the span as margin at\n * each end so the extremes are not drawn on the frame.\n *\n * Nothing here reaches for zero, and that is the point. A candle's four\n * numbers sit close together and usually far from the origin; including\n * zero would compress the whole chart into a band at the top and every\n * candle in it into a dash.\n */\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n let min = Number.POSITIVE_INFINITY;\n let max = Number.NEGATIVE_INFINITY;\n\n for (const row of data) {\n const ohlc = ohlcOf(row, openDataKey, highDataKey, lowDataKey, closeDataKey);\n if (!ohlc) continue;\n if (ohlc.low < min) min = ohlc.low;\n if (ohlc.high > max) max = ohlc.high;\n }\n\n if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];\n // A span of nothing — one period, or a price that never moved — still\n // needs a domain to divide by, so it is given a little room either side.\n if (min === max) {\n const nudge = Math.abs(min) * 0.05 || 1;\n return [min - nudge, max + nudge];\n }\n const margin = (max - min) * 0.1;\n return [min - margin, max + margin];\n }, [data, yDomain, openDataKey, highDataKey, lowDataKey, closeDataKey]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart\n * that is refetched comes back fully drawn on the frame the data lands,\n * which reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n openKey: openDataKey,\n highKey: highDataKey,\n lowKey: lowDataKey,\n closeKey: closeDataKey,\n plot,\n status,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n openDataKey,\n highDataKey,\n lowDataKey,\n closeDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n [openDataKey, datum[openDataKey]],\n [highDataKey, datum[highDataKey]],\n [lowDataKey, datum[lowDataKey]],\n [closeDataKey, datum[closeDataKey]],\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nCandlestickChartRoot.displayName = 'CandlestickChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartGridProps {\n /** How many lines to draw across the price axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the price axis, so a candle can be read against a number rather\n * than only against the candle beside it.\n */\nfunction CandlestickChartGrid({\n rows = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: CandlestickChartGridProps) {\n const { plot } = useChart('CandlestickChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nCandlestickChartGrid.displayName = 'CandlestickChart.Grid';\nCandlestickChartGrid.layer = 'svg' as Layer;\n\nexport interface CandlestickChartCandlesProps {\n /** Colour of a period that closed at or above its open. Green by default. */\n risingColor?: string;\n /** Colour of a period that closed below its open. Red by default. */\n fallingColor?: string;\n /** Corner radius on a body. */\n cornerRadius?: number;\n}\n\n/**\n * The candles.\n *\n * Four paths, not four per candle: rising bodies, falling bodies, and the wicks\n * behind each. A chart of two hundred periods is the same four animated props\n * a frame as a chart of twenty, and the split by direction is what lets each\n * half carry its own fill without a fill per mark.\n *\n * The wicks are drawn first so the bodies sit over them, which is what makes a\n * body with a wick behind it read as one mark rather than as a line crossing a\n * rectangle.\n */\nfunction CandlestickChartCandles({\n risingColor,\n fallingColor,\n cornerRadius = 1.5,\n}: CandlestickChartCandlesProps) {\n const {\n data,\n openKey,\n highKey,\n lowKey,\n closeKey,\n plot,\n candleGap,\n candleWidth,\n fadedOpacity,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n status,\n } = useChart('CandlestickChart.Candles');\n\n const rising = useDirectionColor(risingColor, 'rising');\n const falling = useDirectionColor(fallingColor, 'falling');\n\n const total = data.length;\n const slice = total > 0 ? plot.width / total : 0;\n const width = Math.max(\n 1,\n Math.min(candleWidth ?? slice * (1 - candleGap), Math.max(slice - 1, 1))\n );\n\n /*\n * Pulled out of the worklet as plain arrays. A worklet may close over numbers\n * and arrays of them freely, but reading `data`'s rows — objects of mixed\n * types, keyed by strings the caller chose — inside one every frame is the\n * kind of work that belongs on the JS side once rather than on the UI thread\n * sixty times a second.\n */\n const columns = useMemo(() => {\n const opens: number[] = [];\n const highs: number[] = [];\n const lows: number[] = [];\n const closes: number[] = [];\n const usable: boolean[] = [];\n for (const row of data) {\n const ohlc = ohlcOf(row, openKey, highKey, lowKey, closeKey);\n opens.push(ohlc?.open ?? 0);\n highs.push(ohlc?.high ?? 0);\n lows.push(ohlc?.low ?? 0);\n closes.push(ohlc?.close ?? 0);\n usable.push(ohlc !== null);\n }\n return { opens, highs, lows, closes, usable };\n }, [data, openKey, highKey, lowKey, closeKey]);\n\n /*\n * The four builders share this. Declared as a worklet at this scope so each\n * `useAnimatedProps` below can call it — a worklet may only call another\n * worklet, and the rule is enforced by crashing rather than by warning.\n */\n const build = (wantRising: boolean, wicks: boolean) => () => {\n 'worklet';\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || total === 0 || plot.width <= 0) return { d: '' };\n\n const grow = reveal.value;\n let path = '';\n\n for (let index = 0; index < total; index += 1) {\n if (!columns.usable[index]) continue;\n const open = columns.opens[index] ?? 0;\n const close = columns.closes[index] ?? 0;\n const up = close >= open;\n if (up !== wantRising) continue;\n\n /*\n * Staggered, so the chart fills in left to right rather than every\n * candle inflating at once. Each one gets the back half of the run,\n * which leaves the last candle finishing with the animation.\n */\n const start = total > 1 ? (index / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n if (eased <= 0) continue;\n\n const centre = bandOf(index, total, plot);\n const half = width / 2;\n\n if (wicks) {\n const high = columns.highs[index] ?? 0;\n const low = columns.lows[index] ?? 0;\n const yHigh = yOf(high, plot, min, max);\n const yLow = yOf(low, plot, min, max);\n // Grown about the body's middle rather than from the low, so the wick\n // opens out in both directions the way the body does.\n const mid = (yHigh + yLow) / 2;\n const top = mid + (yHigh - mid) * eased;\n const bottom = mid + (yLow - mid) * eased;\n const x = centre - WICK_WIDTH / 2;\n path += `M${x} ${top}h${WICK_WIDTH}V${bottom}h${-WICK_WIDTH}Z`;\n continue;\n }\n\n const yOpen = yOf(open, plot, min, max);\n const yClose = yOf(close, plot, min, max);\n const bodyTop = Math.min(yOpen, yClose);\n const bodyBottom = Math.max(yOpen, yClose);\n const height = Math.max(bodyBottom - bodyTop, MIN_BODY_HEIGHT);\n const mid = bodyTop + height / 2;\n const grown = height * eased;\n const top = mid - grown / 2;\n\n // The radius can never exceed half the shortest side, or the two corners\n // on one edge overlap and the arc turns itself inside out.\n const r = Math.max(0, Math.min(cornerRadius, width / 2, grown / 2));\n const x = centre - half;\n\n if (r <= 0) {\n path += `M${x} ${top}h${width}v${grown}h${-width}Z`;\n } else {\n path +=\n `M${x + r} ${top}` +\n `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` +\n `v${grown - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` +\n `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` +\n `v${-(grown - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;\n }\n }\n\n return { d: path };\n };\n\n /*\n * The builder is curried and its result handed straight to\n * `useAnimatedProps`, rather than being called from inside another worklet.\n * That is not a style choice: the hook works out what to re-run on by reading\n * the closure of the worklet it is given, and a shared value read one level\n * down is not in that closure. Wrapped, the paths were built once and then\n * never again — the chart drew nothing until some unrelated re-render\n * happened to rebuild them, which looked like a chart that only appeared once\n * it was touched.\n */\n const risingBodies = useAnimatedProps(build(true, false));\n const fallingBodies = useAnimatedProps(build(false, false));\n const risingWicks = useAnimatedProps(build(true, true));\n const fallingWicks = useAnimatedProps(build(false, true));\n\n /*\n * Dimming is a group opacity driven from the active index, rather than a\n * second pair of paths for the candle under the finger. A candle is one mark\n * of one colour, so there is nothing to keep at full ink separately — the\n * whole group fades and the active candle is redrawn over it at full\n * strength by `Highlight` below.\n */\n const dim = useAnimatedProps(() => ({\n opacity: activeIndex.value >= 0 ? fadedOpacity : 1,\n }));\n\n if (status === 'loading') return null;\n\n return (\n <>\n \n \n \n \n \n \n \n \n );\n}\nCandlestickChartCandles.displayName = 'CandlestickChart.Candles';\nCandlestickChartCandles.layer = 'svg' as Layer;\n\nconst AnimatedGroup = Animated.createAnimatedComponent(G);\n\n/**\n * The candle under the finger, drawn again at full strength over the dimmed\n * rest.\n *\n * Redrawing one mark is cheaper than splitting every path in two and rebuilding\n * both whenever the finger moves to the next candle, and it keeps the grow-in\n * builder above to a single shape.\n */\nfunction CandlestickHighlight({\n rising,\n falling,\n width,\n cornerRadius,\n columns,\n}: {\n rising: string;\n falling: string;\n width: number;\n cornerRadius: number;\n columns: {\n opens: number[];\n highs: number[];\n lows: number[];\n closes: number[];\n usable: boolean[];\n };\n}) {\n const { data, plot, domainMin, domainMax, activeIndex } = useChart(\n 'CandlestickChart.Candles'\n );\n const total = data.length;\n\n /*\n * The fill cannot be crossfaded between two colours on the UI thread, so both\n * paths are drawn and the one that does not apply is emptied. An empty path\n * costs nothing to render, and the swap never touches JS.\n */\n const build = (wantRising: boolean) => () => {\n 'worklet';\n const index = activeIndex.value;\n const min = domainMin.value;\n const max = domainMax.value;\n if (index < 0 || index >= total || max === min || !columns.usable[index]) {\n return { d: '' };\n }\n\n const open = columns.opens[index] ?? 0;\n const close = columns.closes[index] ?? 0;\n if (close >= open !== wantRising) return { d: '' };\n\n const high = columns.highs[index] ?? 0;\n const low = columns.lows[index] ?? 0;\n\n const centre = bandOf(index, total, plot);\n const yOpen = yOf(open, plot, min, max);\n const yClose = yOf(close, plot, min, max);\n const bodyTop = Math.min(yOpen, yClose);\n const height = Math.max(Math.abs(yClose - yOpen), MIN_BODY_HEIGHT);\n const x = centre - width / 2;\n const r = Math.max(0, Math.min(cornerRadius, width / 2, height / 2));\n\n const wickX = centre - WICK_WIDTH / 2;\n const wick =\n `M${wickX} ${yOf(high, plot, min, max)}h${WICK_WIDTH}` +\n `V${yOf(low, plot, min, max)}h${-WICK_WIDTH}Z`;\n\n const body =\n r <= 0\n ? `M${x} ${bodyTop}h${width}v${height}h${-width}Z`\n : `M${x + r} ${bodyTop}` +\n `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` +\n `v${height - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` +\n `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` +\n `v${-(height - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;\n\n return { d: `${wick}${body}` };\n };\n\n const risingShape = useAnimatedProps(build(true));\n const fallingShape = useAnimatedProps(build(false));\n\n return (\n <>\n \n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartXAxisProps {\n /** How many labels to show. Derived from the room available when omitted. */\n ticks?: number;\n /** Format a row's label. Defaults to the value at `xDataKey`. */\n format?: (datum: CandlestickChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The period labels, one under each candle it has room for. Real text rather\n * than SVG text, so they follow the theme's font and the platform's text\n * scaling — SVG text does neither.\n */\nfunction CandlestickChartXAxis({\n ticks,\n format,\n className,\n}: CandlestickChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('CandlestickChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n\n // The axis asks the plot how much room there is and only thins when the\n // answer is not enough, rather than dropping labels that would have fitted.\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, data.length));\n\n // Every nth period, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same one twice and skips its neighbour.\n const stride = Math.ceil(data.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < data.length; index += stride) {\n const datum = data[index];\n if (!datum) continue;\n picked.push({\n key: index,\n text: format ? format(datum, index) : String(datum[xDataKey] ?? ''),\n });\n }\n return picked;\n }, [data, ticks, format, xDataKey, plot.width]);\n\n /*\n * Each label is boxed at a readable width and centred on its own candle, not\n * at the width of the slice that candle occupies.\n *\n * A bar chart can tile its labels one band each, because a chart of eight\n * months has bands wide enough to write a month in. Thirty sessions across a\n * phone gives each one about eleven points, and a date boxed at eleven points\n * is a date clipped to nothing — which is what was happening. The axis has\n * already thinned the labels so the ones it kept are at least this far apart,\n * so a box this wide cannot collide with its neighbour; it is clamped inside\n * the plot so the first and last do not hang off the ends.\n */\n const boxes = useMemo(() => {\n const half = LABEL_BOX / 2;\n return labels.map((label) => {\n const centre = bandOf(label.key, data.length, plot);\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, centre)\n );\n return { ...label, left: clamped - half };\n });\n }, [labels, data.length, plot]);\n\n return (\n \n {boxes.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nCandlestickChartXAxis.displayName = 'CandlestickChart.XAxis';\nCandlestickChartXAxis.layer = 'overlay' as Layer;\n\nexport interface CandlestickChartYAxisProps {\n /** How many labels to show along the price axis. */\n ticks?: number;\n /** Format a price for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Price labels down the side, aligned to the grid lines. */\nfunction CandlestickChartYAxis({\n ticks = 4,\n format,\n className,\n}: CandlestickChartYAxisProps) {\n const { plot, extent } = useChart('CandlestickChart.YAxis');\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === max) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nCandlestickChartYAxis.displayName = 'CandlestickChart.YAxis';\nCandlestickChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out — an axis drawn over the plot is unreadable, and makes what it is\n// drawn over unreadable too.\nCandlestickChartYAxis.axis = 'y' as const;\n\nexport interface CandlestickChartTooltipProps {\n /** Format one of the four prices. Defaults to a compact number. */\n formatValue?: (value: number, field: 'open' | 'high' | 'low' | 'close') => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: CandlestickChartDatum) => string;\n /** Show the period's change from open to close under the four prices. */\n showChange?: boolean;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A candle is already the thing being pointed at, so\n * highlighting it and dimming the rest says the same thing without drawing a\n * line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on a candle to\n * summon is a readout nobody finds — and candles are thin.\n */\nfunction CandlestickChartTooltip({\n formatValue,\n formatX,\n showChange = true,\n className,\n}: CandlestickChartTooltipProps) {\n const {\n data,\n xDataKey,\n openKey,\n highKey,\n lowKey,\n closeKey,\n plot,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('CandlestickChart.Tooltip');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (!total) return;\n const offset = (x - left) / (width || 1);\n // Slices, not points: the finger is inside whichever one it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n // The readout centres over its candle and is clamped inside the plot, so it\n // never runs off the edge at the first or last one.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const slice = plot.width / total;\n const centre = plot.left + slice * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, centre)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const ohlc = ohlcOf(active ?? undefined, openKey, highKey, lowKey, closeKey);\n const fmt = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: CandlestickChartDatum) => String(datum[xDataKey] ?? ''));\n\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n if (status === 'loading') return null;\n\n const change = ohlc ? ohlc.close - ohlc.open : 0;\n const percent = ohlc && ohlc.open !== 0 ? (change / ohlc.open) * 100 : 0;\n\n return (\n \n \n \n {active && ohlc ? (\n \n \n {fmtX(active)}\n \n {/* Two columns of two. Four prices stacked one per line makes a\n readout taller than the plot it is floating over. */}\n \n \n \n \n \n \n \n \n \n \n {showChange ? (\n = 0 ? rising : falling }}\n >\n {change >= 0 ? '+' : ''}\n {compactNumber(change)} ({percent >= 0 ? '+' : ''}\n {percent.toFixed(2)}%)\n \n ) : null}\n \n ) : null}\n \n \n \n );\n}\nCandlestickChartTooltip.displayName = 'CandlestickChart.Tooltip';\nCandlestickChartTooltip.layer = 'overlay' as Layer;\n\n/** One of the four prices in the readout, named by its initial. */\nfunction OhlcRow({ label, value }: { label: string; value: string }) {\n return (\n \n \n {label}\n \n \n {value}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface CandlestickChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a change. */\n caption?: string;\n /** Name the two colours, for a reader who has not met the convention. */\n legend?: boolean;\n /** What the rising colour is called. */\n risingLabel?: string;\n /** What the falling colour is called. */\n fallingLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the two colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the candles. The\n * card's header is a caption on the tray the chart sits in; this is the chart\n * introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show the latest close when nothing\n * is pressed and a period's close when something is.\n */\nfunction CandlestickChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n risingLabel = 'Up',\n fallingLabel = 'Down',\n children,\n ...props\n}: CandlestickChartHeaderProps) {\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n const trailing =\n children ??\n (legend ? (\n \n \n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, the\n key takes the width it wants and the caption under the value wraps to\n two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nCandlestickChartHeader.displayName = 'CandlestickChart.Header';\nCandlestickChartHeader.layer = 'header' as Layer;\n\n/** One colour and what it means. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\nexport interface CandlestickChartLegendProps extends ViewProps {\n className?: string;\n /** What the rising colour is called. */\n risingLabel?: string;\n /** What the falling colour is called. */\n fallingLabel?: string;\n}\n\n/** The two colours and what they mean, floated over the plot. */\nfunction CandlestickChartLegend({\n className,\n risingLabel = 'Up',\n fallingLabel = 'Down',\n ...props\n}: CandlestickChartLegendProps) {\n const rising = useDirectionColor(undefined, 'rising');\n const falling = useDirectionColor(undefined, 'falling');\n\n return (\n \n \n \n \n );\n}\nCandlestickChartLegend.displayName = 'CandlestickChart.Legend';\nCandlestickChartLegend.layer = 'overlay' as Layer;\n\nexport const CandlestickChart = Object.assign(CandlestickChartRoot, {\n Header: CandlestickChartHeader,\n Grid: CandlestickChartGrid,\n Candles: CandlestickChartCandles,\n XAxis: CandlestickChartXAxis,\n YAxis: CandlestickChartYAxis,\n Tooltip: CandlestickChartTooltip,\n Legend: CandlestickChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/chart-accessibility.json b/apps/docs/public/r/chart-accessibility.json index d7b412620..1360d96a4 100644 --- a/apps/docs/public/r/chart-accessibility.json +++ b/apps/docs/public/r/chart-accessibility.json @@ -9,7 +9,7 @@ { "path": "ui/chart-accessibility.ts", "type": "registry:ui", - "content": "import { createElement } from 'react';\n\nexport interface ChartAccessibilityProps {\n /** Spoken summary. Defaults to the chart family and number of data entries. */\n accessibilityLabel?: string;\n /** Additional guidance spoken after the chart summary. */\n accessibilityHint?: string;\n /** Overrides the spoken label for one data entry. */\n accessibilityLabelForDatum?: (datum: Datum, index: number) => string;\n /** Makes each data entry actionable without making the SVG geometry focusable. */\n onAccessibilityDatumPress?: (datum: Datum, index: number) => void;\n}\n\nexport interface ChartAccessibilityItem {\n datum: Datum;\n index: number;\n label: string;\n}\n\nfunction spoken(value: unknown): string | null {\n if (value instanceof Date) return value.toLocaleDateString();\n if (typeof value === 'string' || typeof value === 'number') return String(value);\n return null;\n}\n\n/** Pure semantic projection, shared by tests and the native accessibility tree. */\nexport function chartAccessibilityModel(\n chart: string,\n data: Datum[],\n valueOf: (datum: Datum) => [string, unknown][],\n labelForDatum?: (datum: Datum, index: number) => string\n): { summary: string; items: ChartAccessibilityItem[] } {\n return {\n summary: `${chart}, ${data.length} ${data.length === 1 ? 'data entry' : 'data entries'}`,\n items: data.map((datum, index) => ({\n datum,\n index,\n label:\n labelForDatum?.(datum, index) ??\n valueOf(datum)\n .map(([label, value]) => {\n const text = spoken(value);\n return text === null ? null : `${label}, ${text}`;\n })\n .filter((part): part is string => part !== null)\n .join('. '),\n })),\n };\n}\n\nexport interface ChartAccessibilityDataProps\n extends ChartAccessibilityProps {\n chart: string;\n data: Datum[];\n valueOf: (datum: Datum) => [string, unknown][];\n disabled?: boolean;\n}\n\n/**\n * A compact semantic sibling to the visual plot. The SVG remains one\n * decorative subtree while screen readers receive one summary and one entry\n * per datum, rather than every path, grid line and marker.\n */\nexport function ChartAccessibilityData({\n chart,\n data,\n valueOf,\n disabled = false,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n}: ChartAccessibilityDataProps) {\n if (disabled) return null;\n // Keep the pure semantic projection importable by Node tests without asking\n // Node to parse React Native's Flow entry point.\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { Pressable, View } = require('react-native') as typeof import('react-native');\n const model = chartAccessibilityModel(chart, data, valueOf, accessibilityLabelForDatum);\n\n return createElement(\n View,\n { style: { position: 'absolute', left: -10_000, width: 1, height: 1 } },\n createElement(View, {\n accessible: true,\n accessibilityRole: 'image',\n accessibilityLabel: accessibilityLabel ?? model.summary,\n accessibilityHint,\n }),\n ...model.items.map((item) =>\n createElement(Pressable, {\n key: item.index,\n accessible: true,\n accessibilityRole: onAccessibilityDatumPress ? 'button' : 'text',\n accessibilityLabel: item.label,\n onPress: onAccessibilityDatumPress\n ? () => onAccessibilityDatumPress(item.datum, item.index)\n : undefined,\n })\n )\n );\n}\n" + "content": "import { createElement } from 'react';\n\n/**\n * The largest text scale a chart's axis labels follow: iOS's biggest size before\n * the accessibility range. Axis labels sit in fixed-width gutters at fixed\n * steps, so past this they overlap each other or clip. Screen readers still get\n * every value through the chart's accessibility data.\n */\nexport const CHART_AXIS_MAX_FONT_SCALE = 1.353;\n\nexport interface ChartAccessibilityProps {\n /** Spoken summary. Defaults to the chart family and number of data entries. */\n accessibilityLabel?: string;\n /** Additional guidance spoken after the chart summary. */\n accessibilityHint?: string;\n /** Overrides the spoken label for one data entry. */\n accessibilityLabelForDatum?: (datum: Datum, index: number) => string;\n /** Makes each data entry actionable without making the SVG geometry focusable. */\n onAccessibilityDatumPress?: (datum: Datum, index: number) => void;\n}\n\nexport interface ChartAccessibilityItem {\n datum: Datum;\n index: number;\n label: string;\n}\n\nfunction spoken(value: unknown): string | null {\n if (value instanceof Date) return value.toLocaleDateString();\n if (typeof value === 'string' || typeof value === 'number') return String(value);\n return null;\n}\n\n/** Pure semantic projection, shared by tests and the native accessibility tree. */\nexport function chartAccessibilityModel(\n chart: string,\n data: Datum[],\n valueOf: (datum: Datum) => [string, unknown][],\n labelForDatum?: (datum: Datum, index: number) => string\n): { summary: string; items: ChartAccessibilityItem[] } {\n return {\n summary: `${chart}, ${data.length} ${data.length === 1 ? 'data entry' : 'data entries'}`,\n items: data.map((datum, index) => ({\n datum,\n index,\n label:\n labelForDatum?.(datum, index) ??\n valueOf(datum)\n .map(([label, value]) => {\n const text = spoken(value);\n return text === null ? null : `${label}, ${text}`;\n })\n .filter((part): part is string => part !== null)\n .join('. '),\n })),\n };\n}\n\nexport interface ChartAccessibilityDataProps\n extends ChartAccessibilityProps {\n chart: string;\n data: Datum[];\n valueOf: (datum: Datum) => [string, unknown][];\n disabled?: boolean;\n}\n\n/**\n * A compact semantic sibling to the visual plot. The SVG remains one\n * decorative subtree while screen readers receive one summary and one entry\n * per datum, rather than every path, grid line and marker.\n */\nexport function ChartAccessibilityData({\n chart,\n data,\n valueOf,\n disabled = false,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n}: ChartAccessibilityDataProps) {\n if (disabled) return null;\n // Keep the pure semantic projection importable by Node tests without asking\n // Node to parse React Native's Flow entry point.\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { Pressable, View } = require('react-native') as typeof import('react-native');\n const model = chartAccessibilityModel(chart, data, valueOf, accessibilityLabelForDatum);\n\n return createElement(\n View,\n { style: { position: 'absolute', left: -10_000, width: 1, height: 1 } },\n createElement(View, {\n accessible: true,\n accessibilityRole: 'image',\n accessibilityLabel: accessibilityLabel ?? model.summary,\n accessibilityHint,\n }),\n ...model.items.map((item) =>\n createElement(Pressable, {\n key: item.index,\n accessible: true,\n accessibilityRole: onAccessibilityDatumPress ? 'button' : 'text',\n accessibilityLabel: item.label,\n onPress: onAccessibilityDatumPress\n ? () => onAccessibilityDatumPress(item.datum, item.index)\n : undefined,\n })\n )\n );\n}\n" } ] } diff --git a/apps/docs/public/r/heatmap-chart.json b/apps/docs/public/r/heatmap-chart.json index 25bc13862..d143f574f 100644 --- a/apps/docs/public/r/heatmap-chart.json +++ b/apps/docs/public/r/heatmap-chart.json @@ -19,7 +19,7 @@ { "path": "ui/heatmap-chart.tsx", "type": "registry:ui", - "content": "/**\n * HeatmapChart — a calendar of bins, shaded by how much happened in each.\n *\n * The contribution grid: one column per period (usually a week), one row per\n * bin inside it (usually a weekday), and a colour ramp carrying the count. It\n * answers \"when was this busy\" at a glance, which no line can — a year of daily\n * numbers plotted as a series is a hairball, and as a grid it is a pattern.\n *\n * Composed rather than configured, so a chart that wants no axis simply does\n * not have one:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * The parts sort themselves into a real layout rather than stacking over the\n * plot. That is the difference from a line chart, where the axis floats over\n * the drawing: here the labels and the legend sit *beside* and *below* the\n * grid, so they take up room, and the grid is sized with them accounted for.\n * Only the cells and the rules between them are SVG; every label is a React\n * Native view, because SVG text ignores the platform's text scaling and the\n * theme's font.\n *\n * The ramp is one colour at five opacities rather than five colours. A heatmap\n * reads as *more* and *less* of one thing, and five distinct hues read as five\n * different things — which is what the `--color-chart-*` tokens are for, and\n * why they are not used here. The base is `--color-chart-1`, so the ramp\n * follows the theme, and `levelColors` replaces it outright when a brand needs\n * its own.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { cn } from '@/lib/cn';\nimport { normalizeWeekStart, startOfDay } from '@/lib/date';\n\n\n/**\n * Rows in a week — the default, and what the calendar helper and the weekday\n * labels assume. The grid itself takes a `rows` prop, so a grid whose bins are\n * hours rather than days is the same component with a different number.\n */\nconst DAYS_IN_WEEK = 7;\n\n/** Opacity of the base colour at each activity level. Index 0 is \"nothing\". */\nconst LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;\n\n/**\n * A theme token rather than a colour. Tokens are named, not written, so the\n * leading `--` tells the two apart without the caller having to say which\n * kind they passed.\n */\nfunction isToken(value: string | undefined): value is string {\n return typeof value === 'string' && value.startsWith('--');\n}\n\n/** Room left for the weekday labels when the y-axis does not ask for its own. */\nconst DEFAULT_AXIS_WIDTH = 26;\n\n/**\n * Height assumed for the readout when placing it above a cell. It is a single\n * line of `xs` text in a padded box, so it does not vary — and measuring it\n * would put the tooltip a frame behind the finger.\n */\nconst TOOLTIP_HEIGHT = 26;\n\n/**\n * How long the readout waits before claiming the touch, in milliseconds.\n *\n * Long enough that a swipe scrolls the chart instead of reading it, short\n * enough that a deliberate press does not feel like it was ignored.\n */\nconst DEFAULT_HOLD = 180;\n\n/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */\nconst WEEKDAYS = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n] as const;\n\nconst MONTHS = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/**\n * Where a part belongs in the layout. Read off the component itself, so\n * composition stays a flat list of children instead of four nested slots the\n * caller has to remember the order of.\n */\ntype Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';\n\nexport type HeatmapLayout = 'fluid' | 'fill';\n\n/** One bin inside a column — usually a single day. */\nexport interface HeatmapBin {\n /** Row index within the column, `0` to `6`. */\n bin: number;\n /** How much happened. The ramp is derived from these across the whole chart. */\n count: number;\n /** The day this bin stands for. Used by the axis labels and the tooltip. */\n date?: Date;\n}\n\n/** One column — usually a week. Missing bins are drawn as empty cells. */\nexport interface HeatmapColumn {\n /** Column index across the chart. */\n bin: number;\n bins: HeatmapBin[];\n}\n\n/** A cell resolved to its place in the grid, as the tooltip receives it. */\nexport interface HeatmapCell {\n column: number;\n row: number;\n count: number;\n level: number;\n date?: Date;\n}\n\ninterface Grid {\n /** Side of one cell, in pixels. */\n size: number;\n gap: number;\n columns: number;\n rows: number;\n width: number;\n height: number;\n}\n\ninterface HeatmapContextValue {\n data: HeatmapColumn[];\n grid: Grid;\n /** `[level0, level1, …level4]`, already resolved to colours. */\n ramp: string[];\n /** Opacity to paint each level's colour at. All ones for a supplied ramp. */\n opacities: number[];\n levelOf: (count: number) => number;\n cellAt: (column: number, row: number) => HeatmapCell;\n cornerRadius: number;\n inactiveOpacity: number;\n weekStartDay: number;\n activeCell: HeatmapCell | null;\n setActiveCell: (cell: HeatmapCell | null) => void;\n}\n\nconst HeatmapContext = createContext(null);\n\nfunction useHeatmap(component: string): HeatmapContextValue {\n const context = useContext(HeatmapContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The cell under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveCellChange` for that.\n */\nexport function useHeatmapChart() {\n const { activeCell } = useHeatmap('useHeatmapChart');\n return { activeCell };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Data helpers */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Build a year of columns from a flat list of dated counts.\n *\n * Every heatmap starts as \"I have some dates and some numbers\", and the\n * bucketing into weeks is the same arithmetic every time — including the two\n * parts that are easy to get wrong: the leading blanks before the first day of\n * the first week, and days with no entry at all, which must still be drawn as\n * empty cells or the calendar develops holes.\n */\nexport function buildHeatmapCalendar(\n entries: { date: Date; count: number }[],\n options: { start?: Date; end?: Date; weekStartDay?: number } = {}\n): HeatmapColumn[] {\n const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);\n if (!entries.length && !options.start) return [];\n\n const times = entries.map((entry) => entry.date.getTime());\n // Both bounds at local midnight, and both the same way. Comparing a\n // normalised lower bound against a raw upper one drops the last day whenever\n // the caller's `end` carries a time earlier than the cell being tested.\n const start = startOfDay(options.start ?? new Date(Math.min(...times)));\n const end = startOfDay(options.end ?? new Date(Math.max(...times)));\n\n const byDay = new Map();\n for (const entry of entries) {\n const key = dayKey(entry.date);\n byDay.set(key, (byDay.get(key) ?? 0) + entry.count);\n }\n\n // Back up to the first day of the week the range starts in, so column 0 is a\n // whole week and every row lines up with a weekday for the rest of the chart.\n const cursor = startOfDay(start);\n cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));\n\n const columns: HeatmapColumn[] = [];\n let column: HeatmapBin[] = [];\n\n while (cursor <= end || column.length) {\n const row = (cursor.getDay() - weekStartDay + 7) % 7;\n const date = new Date(cursor);\n // Before the range starts and after it ends the cell exists but has no\n // reading — `count: 0` and no date, so the tooltip stays quiet on it.\n const inRange = date >= start && date <= end;\n column.push({\n bin: row,\n count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,\n date: inRange ? date : undefined,\n });\n\n if (row === DAYS_IN_WEEK - 1) {\n columns.push({ bin: columns.length, bins: column });\n column = [];\n if (cursor > end) break;\n }\n cursor.setDate(cursor.getDate() + 1);\n }\n\n return columns;\n}\n\nfunction dayKey(date: Date) {\n return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n}\n\n/**\n * Four thresholds from the data, so a chart of single-digit counts and a chart\n * of thousands both use the whole ramp. Quartiles of the *non-zero* counts:\n * zero is its own level, and counting it would drag every threshold down to\n * nothing on a sparse chart.\n */\nfunction deriveLevels(data: HeatmapColumn[]): number[] {\n const counts: number[] = [];\n for (const column of data) {\n for (const bin of column.bins) {\n if (bin.count > 0) counts.push(bin.count);\n }\n }\n if (!counts.length) return [1, 2, 3, 4];\n counts.sort((a, b) => a - b);\n const at = (fraction: number) =>\n counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;\n const thresholds = [1, at(0.25), at(0.5), at(0.75)];\n // Ties collapse the ramp — nudge each threshold past the one below it so\n // four distinct levels stay four distinct levels.\n for (let i = 1; i < thresholds.length; i++) {\n if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;\n }\n return thresholds;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** One column per period, with its row bins inside. */\n data: HeatmapColumn[];\n /**\n * `fluid` draws cells at `binSize` and lets the grid be as wide as it needs\n * to be — put it in a horizontal `ScrollView` for a full year. `fill`\n * divides the available width between the columns instead.\n */\n layout?: HeatmapLayout;\n /** Side of one cell in `fluid` layout, in pixels. */\n binSize?: number;\n /** Space between cells, in pixels. */\n gap?: number;\n /** Corner radius of a cell. */\n cornerRadius?: number;\n /** Which weekday is the top row. `0` is Sunday. Labels follow it. */\n weekStartDay?: number;\n /**\n * Rows per column. Seven for a calendar; use another number when the bins\n * are not weekdays — twenty-four for a grid of hours.\n */\n rows?: number;\n /**\n * The four counts at which the ramp steps up. Derived from the data's own\n * quartiles when omitted, so a chart of single digits and a chart of\n * thousands both use the whole ramp.\n */\n levels?: number[];\n /**\n * Five colours — empty, then the four activity levels. Replaces the derived\n * ramp outright. Omit it and the ramp is `--color-chart-1` at five\n * opacities, which follows the theme.\n */\n levelColors?: string[];\n /**\n * Base colour for the derived ramp — the colour the busiest cells are drawn\n * in, with the quieter levels the same colour at lower opacity.\n *\n * Takes a theme token by name as well as a literal, so `\"--color-chart-3\"`\n * recolours the chart and keeps following the theme through light and dark.\n * Defaults to `--color-chart-1`.\n */\n color?: string;\n /**\n * Colour of a cell with nothing in it. Takes a token name too. Defaults to\n * `--color-muted`, which is the right weight for \"measured, and empty\" —\n * override it for a chart that should read as denser or fainter than that.\n */\n emptyColor?: string;\n /**\n * Opacity of the base colour at each of the five levels, quietest first.\n * The way to retune the ramp's contrast without having to name five colours.\n * Ignored when `levelColors` is given, which sets the colours outright.\n */\n levelOpacity?: number[];\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Opacity of every cell that is not the one under the finger. */\n inactiveOpacity?: number;\n /**\n * The cell under the finger as it moves, and `null` when it lifts. This is\n * how a readout above the chart gets its value — that readout is outside the\n * chart, so it cannot use `useHeatmapChart`.\n */\n onActiveCellChange?: (cell: HeatmapCell | null) => void;\n children?: ReactNode;\n}\n\nconst HeatmapChartRoot = forwardRef(function HeatmapChartRoot(\n {\n className,\n data,\n layout = 'fluid',\n binSize = 12,\n gap = 3,\n cornerRadius = 2,\n weekStartDay = 0,\n rows = DAYS_IN_WEEK,\n levels,\n levelColors,\n color,\n emptyColor,\n levelOpacity,\n animationDuration = 900,\n inactiveOpacity = 1,\n onActiveCellChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n) {\n const [width, setWidth] = useState(0);\n const [activeCell, setActiveCellState] = useState(null);\n\n const reveal = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /*\n * `color` and `emptyColor` take a token name as readily as a literal. A\n * literal is a colour frozen at the moment it was written — it cannot follow\n * the theme into dark mode — so naming the token is almost always what the\n * caller meant, and having to resolve it themselves is the reason they did\n * not. The hooks run unconditionally; which of the two answers is used is\n * decided after.\n */\n const baseToken = useCSSVariable(isToken(color) ? color : '--color-chart-1');\n const emptyToken = useCSSVariable(\n isToken(emptyColor) ? emptyColor : '--color-muted'\n );\n const base =\n (isToken(color) ? undefined : color) ??\n (typeof baseToken === 'string' ? baseToken : '#262626');\n const empty =\n (isToken(emptyColor) ? undefined : emptyColor) ??\n (typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)');\n\n const parts = useMemo(() => splitParts(children), [children]);\n // The y-axis is laid out beside the grid rather than over it, so its width\n // has to come out of the grid's before the cells are sized.\n const axisWidth = parts.y ? (parts.y.props.width ?? DEFAULT_AXIS_WIDTH) : 0;\n\n const grid = useMemo(() => {\n const count = data.length;\n if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };\n\n const available = Math.max(width - axisWidth, 0);\n const size =\n layout === 'fill' && available > 0\n ? Math.max((available - (count - 1) * gap) / count, 1)\n : binSize;\n\n return {\n size,\n gap,\n columns: count,\n rows,\n width: count * size + (count - 1) * gap,\n height: rows * size + (rows - 1) * gap,\n };\n }, [data.length, width, axisWidth, layout, binSize, gap, rows]);\n\n const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);\n\n /*\n * One colour at five opacities, unless the caller supplied five colours — in\n * which case the opacities are dropped, since dimming a colour someone chose\n * on purpose is not a ramp, it is a bug.\n */\n const { ramp, opacities } = useMemo(\n () =>\n levelColors\n ? { ramp: levelColors, opacities: levelColors.map(() => 1) }\n : {\n ramp: [empty, base, base, base, base],\n opacities: levelOpacity ?? [...LEVEL_OPACITY],\n },\n [levelColors, levelOpacity, empty, base]\n );\n\n const levelOf = useMemo(\n () => (count: number) => {\n if (count <= 0) return 0;\n let level = 1;\n for (let i = 1; i < thresholds.length; i++) {\n if (count >= thresholds[i]!) level = i + 1;\n }\n return Math.min(level, 4);\n },\n [thresholds]\n );\n\n const cellAt = useMemo(\n () => (columnIndex: number, row: number): HeatmapCell => {\n const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);\n const count = bin?.count ?? 0;\n return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };\n },\n [data, levelOf]\n );\n\n const accessibilityCells = useMemo(\n () =>\n data.flatMap((column) =>\n column.bins.map((bin) => ({\n column: column.bin,\n row: bin.bin,\n count: bin.count,\n level: levelOf(bin.count),\n date: bin.date,\n }))\n ),\n [data, levelOf]\n );\n\n const setActiveCell = useMemo(\n () => (cell: HeatmapCell | null) => {\n setActiveCellState(cell);\n onActiveCellChange?.(cell);\n },\n [onActiveCellChange]\n );\n\n // Plays once, when there is both a grid to reveal and data to reveal in it.\n const revealed = useRef(false);\n useEffect(() => {\n if (revealed.current || grid.width <= 0) return;\n revealed.current = true;\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n }, [grid.width, reducedMotion, animationDuration, reveal]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n setWidth((current) => (Math.abs(current - next) < 1 ? current : next));\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n }),\n [\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n ]\n );\n\n /*\n * One clip wiping left to right, rather than an animation per column. The\n * effect is the same — columns arriving in order — and it costs one animated\n * value instead of one per week, which for a year is fifty-two.\n */\n const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));\n\n return (\n \n \n {/* Above the axis gutter as well as the grid: the header is about the\n whole chart, so it starts at the chart's edge, not the grid's. */}\n {parts.header}\n\n [\n ['date', cell.date],\n ['column', cell.column],\n ['row', cell.row],\n ['count', cell.count],\n ]}\n />\n\n {parts.x ? (\n \n {parts.x}\n \n ) : null}\n\n \n {parts.y}\n \n {grid.width > 0 ? (\n <>\n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip, so\n * the grid drew complete and the reveal did not play at all. A\n * view with `overflow: 'hidden'` is clipped by the platform\n * itself, which both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. This\n * view is absolutely positioned and its only child is an\n * `` with an explicit width and height, so it is one node\n * and nothing around it moves.\n */}\n \n \n {parts.rules}\n {parts.cells}\n \n \n {parts.tooltip}\n \n ) : null}\n \n \n\n {parts.legend}\n \n \n );\n});\n\ninterface Parts {\n cells: ReactNode[];\n rules: ReactNode[];\n x: ReactNode | null;\n y: React.ReactElement<{ width?: number }> | null;\n tooltip: ReactNode[];\n legend: ReactNode[];\n header: ReactNode[];\n columns: number;\n}\n\n/** Sorts the children into the places the layout has for them. */\nfunction splitParts(children: ReactNode): Parts {\n const parts: Parts = {\n cells: [],\n rules: [],\n x: null,\n y: null,\n tooltip: [],\n legend: [],\n header: [],\n columns: 0,\n };\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const slot = (child.type as { slot?: Slot }).slot ?? 'cells';\n const keyed = {child};\n\n if (slot === 'x-axis') parts.x = keyed;\n else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;\n else if (slot === 'rules') parts.rules.push(keyed);\n else if (slot === 'tooltip') parts.tooltip.push(keyed);\n else if (slot === 'legend') parts.legend.push(keyed);\n else if (slot === 'header') parts.header.push(keyed);\n else parts.cells.push(keyed);\n });\n\n return parts;\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cells */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapCellsProps {\n /** Corner radius of a cell. Falls back to the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The grid itself. Every row of every column is drawn, including the ones with\n * nothing in them — a calendar with holes in it stops being a calendar.\n */\nfunction HeatmapCells({ cornerRadius }: HeatmapCellsProps) {\n const chart = useHeatmap('HeatmapChart.Cells');\n const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;\n const radius = cornerRadius ?? chart.cornerRadius;\n const step = grid.size + grid.gap;\n\n return (\n \n {data.map((column, columnIndex) =>\n Array.from({ length: grid.rows }, (_unused, row) => {\n const bin = column.bins.find((candidate) => candidate.bin === row);\n const level = levelOf(bin?.count ?? 0);\n const dimmed =\n activeCell !== null &&\n !(activeCell.column === columnIndex && activeCell.row === row);\n\n return (\n \n );\n })\n )}\n \n );\n}\nHeatmapCells.slot = 'cells' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Rules */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapSeparatorProps {\n /**\n * `quarter` draws a rule every thirteen columns; a number draws one every\n * that many columns.\n */\n every?: 'quarter' | number;\n color?: string;\n /** Dash pattern, e.g. `\"2,4\"`. Omit for a solid rule. */\n dashArray?: string;\n}\n\n/** Vertical rules grouping the columns — quarters, months, sprints. */\nfunction HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {\n const { grid } = useHeatmap('HeatmapChart.Separator');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');\n const interval = every === 'quarter' ? 13 : every;\n const step = grid.size + grid.gap;\n\n if (interval <= 0) return null;\n\n const lines: number[] = [];\n for (let column = interval; column < grid.columns; column += interval) {\n lines.push(column);\n }\n\n return (\n \n {lines.map((column) => {\n const x = column * step - grid.gap / 2;\n return (\n \n );\n })}\n \n );\n}\nHeatmapSeparator.slot = 'rules' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Axes */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapXAxisProps {\n className?: string;\n /**\n * Label a column. Given the first dated bin in it, so a month name can be\n * derived. Return an empty string to leave the column unlabelled.\n */\n formatLabel?: (date: Date, column: number) => string;\n /**\n * Column labels, left to right. Overrides the month names — for a grid whose\n * columns are not weeks, where there is no month to change and so nothing to\n * emit a label on.\n */\n labels?: string[];\n}\n\n/**\n * Month labels above the grid.\n *\n * A label is emitted where the month changes rather than at a fixed interval,\n * because months are not the same length — spacing them evenly puts \"Mar\" over\n * a week in February. A grid whose columns are not weeks has no such signal, so\n * it passes `labels` and gets one over every column.\n */\nfunction HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {\n const { data, grid } = useHeatmap('HeatmapChart.XAxis');\n const step = grid.size + grid.gap;\n\n const labels = useMemo(() => {\n if (given) {\n return given\n .slice(0, data.length)\n .map((label, column) => ({ column, label }))\n .filter((entry) => entry.label);\n }\n\n const out: { column: number; label: string }[] = [];\n let lastMonth = -1;\n\n data.forEach((column, index) => {\n const date = column.bins.find((bin) => bin.date)?.date;\n if (!date) return;\n const month = date.getMonth();\n if (month === lastMonth) return;\n lastMonth = month;\n const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;\n if (label) out.push({ column: index, label });\n });\n\n // The first month is usually a stub of a week or two, and a label over it\n // collides with the next one. Drop it when it has no room.\n if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();\n return out;\n }, [data, formatLabel, given]);\n\n return (\n \n {labels.map(({ column, label }) => (\n \n {label}\n \n ))}\n \n );\n}\nHeatmapXAxis.slot = 'x-axis' as Slot;\n\nexport interface HeatmapYAxisProps {\n className?: string;\n /** Width reserved for the labels. The grid is sized around it. */\n width?: number;\n /** Which rows get a label. Every other row is the usual choice. */\n tickFilter?: 'all' | 'odd' | 'even';\n /** `initial` is the single letter; `full` is the abbreviated name. */\n labelFormat?: 'initial' | 'full';\n /**\n * Row labels, top to bottom. Overrides the weekday names — for a grid whose\n * rows are not days.\n */\n labels?: string[];\n}\n\n/** Weekday labels down the left of the grid. */\nfunction HeatmapYAxis({\n className,\n width = DEFAULT_AXIS_WIDTH,\n tickFilter = 'odd',\n labelFormat = 'full',\n labels,\n}: HeatmapYAxisProps) {\n const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');\n const step = grid.size + grid.gap;\n\n return (\n \n {Array.from({ length: grid.rows }, (_unused, row) => {\n if (tickFilter === 'odd' && row % 2 === 0) return null;\n if (tickFilter === 'even' && row % 2 === 1) return null;\n\n const name = labels\n ? (labels[row] ?? '')\n : WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;\n return (\n \n {labels\n ? name\n : labelFormat === 'initial'\n ? name.slice(0, 1)\n : name.slice(0, 3)}\n \n );\n })}\n \n );\n}\nHeatmapYAxis.slot = 'y-axis' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapTooltipProps {\n className?: string;\n /** The line shown for a cell. Defaults to the count and the date. */\n formatLabel?: (cell: HeatmapCell) => string;\n /**\n * How long a press has to be held before the readout takes over, in\n * milliseconds.\n *\n * It is not zero, and cannot be: a full year of columns lives inside a\n * horizontal scroller, and a readout that claims the touch on the first pixel\n * of movement means the chart can never be scrolled. Holding first is what\n * separates \"I am moving the chart\" from \"I am reading it\". Set `0` only for\n * a chart that is not inside a scroll view at all.\n */\n activateAfterLongPress?: number;\n}\n\n/**\n * A readout following the finger across the grid.\n *\n * It lives in the view layer, over the SVG: a gesture handler cannot be\n * attached to an SVG node, and SVG text ignores the platform's text scaling.\n * The cell under the finger is resolved on the UI thread and only crosses back\n * into JS when it changes, so a drag across a year costs a handful of\n * re-renders rather than one per frame.\n */\nfunction HeatmapTooltip({\n className,\n formatLabel,\n activateAfterLongPress = DEFAULT_HOLD,\n}: HeatmapTooltipProps) {\n const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(\n 'HeatmapChart.Tooltip'\n );\n const step = grid.size + grid.gap;\n\n // Mirrored for the worklet, which cannot read the JS closure's latest value.\n const lastKey = useSharedValue(-1);\n\n const resolve = useMemo(\n () => (column: number, row: number) => setActiveCell(cellAt(column, row)),\n [cellAt, setActiveCell]\n );\n const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);\n\n /*\n * The cell is resolved here, on the UI thread, and only crosses into JS when\n * it *changes*. Sending every frame's coordinates over and picking the cell\n * in React would cost a re-render per frame for a value that changes maybe\n * fifty times across a whole drag.\n */\n const pick = (x: number, y: number) => {\n 'worklet';\n const column = Math.floor(x / step);\n const row = Math.floor(y / step);\n if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;\n const key = column * grid.rows + row;\n if (key === lastKey.value) return;\n lastKey.value = key;\n runOnJS(resolve)(column, row);\n };\n\n /*\n * The readout takes over only once the press has been held.\n *\n * `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on\n * touch-down whatever happens next, so picking there would light a cell and\n * dim the rest of the grid for the first moment of every scroll swipe — the\n * gesture would yield correctly and still leave a flicker behind it.\n */\n const pan = Gesture.Pan()\n .minDistance(0)\n .activateAfterLongPress(activateAfterLongPress)\n .onStart((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n // Fires whether or not the gesture ever activated, so a swipe that only\n // scrolled must not report a cell change it never made.\n if (lastKey.value === -1) return;\n lastKey.value = -1;\n runOnJS(clear)();\n });\n\n const label = activeCell\n ? formatLabel\n ? formatLabel(activeCell)\n : defaultTooltipLabel(activeCell)\n : null;\n\n // Flipped to the left of the finger past the halfway mark, so the readout\n // never runs off the edge it is closest to — and dropped below the cell in\n // the top two rows, where there is no room above it inside the chart.\n const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;\n const flipped = anchorX > grid.width / 2;\n const below = (activeCell?.row ?? 0) < 2;\n const anchorY = activeCell\n ? below\n ? activeCell.row * step + grid.size + 6\n : activeCell.row * step - TOOLTIP_HEIGHT - 6\n : 0;\n\n return (\n <>\n \n \n \n\n {activeCell && label ? (\n \n \n {label}\n \n \n ) : null}\n \n );\n}\nHeatmapTooltip.slot = 'tooltip' as Slot;\n\n/*\n * A cell with no date is not a cell with no data — it is a cell in a grid that\n * is not a calendar, and it has a count like any other. Saying \"No data\" over\n * it reports a hole in the data that is not there. Without a date there is\n * simply nothing to say after the number, so the number is the whole label,\n * and a grid that wants a noun for it passes `formatLabel`.\n */\nfunction defaultTooltipLabel(cell: HeatmapCell) {\n if (!cell.date) return `${cell.count}`;\n const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;\n const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;\n return `${count} on ${date}`;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Legend */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapLegendProps {\n className?: string;\n /** Text at the low end of the ramp. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Side of a swatch, in pixels. Defaults to the chart's cell size. */\n swatchSize?: number;\n}\n\n/** The `Less ▢▢▢▢▢ More` key, under the grid. */\nfunction HeatmapLegend({\n className,\n lessLabel = 'Less',\n moreLabel = 'More',\n swatchSize,\n}: HeatmapLegendProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');\n const size = swatchSize ?? Math.max(grid.size, 10);\n\n return (\n \n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n );\n}\nHeatmapLegend.slot = 'legend' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the grid is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a total, the held cell. */\n caption?: string;\n /**\n * Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a\n * grid that scrolls sideways, where `HeatmapChart.Legend` under the cells\n * would scroll away with them.\n */\n legend?: boolean;\n /** Text at the low end of the ramp, when `legend` is set. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the grid: what the chart is of, what it currently reads, and\n * what the shading means.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *grid* — the number changes as a finger moves across the cells, and the\n * ramp is the scale the chart itself derived. The card's header is a caption on\n * the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. Take it from `onActiveCellChange` and pass the\n * formatted string down, so one header can show a total when nothing is held\n * and a day's own count when something is.\n */\nfunction HeatmapHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n lessLabel = 'Less',\n moreLabel = 'More',\n children,\n ...props\n}: HeatmapHeaderProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');\n // Small enough to sit on one line beside the text, whatever the cells are.\n const size = Math.max(Math.min(grid.size, 12), 8);\n\n const trailing =\n children ??\n (legend ? (\n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native — held rigid, the\n ramp takes the width it wants and the caption wraps around it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nHeatmapHeader.displayName = 'HeatmapChart.Header';\nHeatmapHeader.slot = 'header' as Slot;\n\nexport const HeatmapChart = Object.assign(HeatmapChartRoot, {\n Header: HeatmapHeader,\n Cells: HeatmapCells,\n Separator: HeatmapSeparator,\n XAxis: HeatmapXAxis,\n YAxis: HeatmapYAxis,\n Tooltip: HeatmapTooltip,\n Legend: HeatmapLegend,\n});\n" + "content": "/**\n * HeatmapChart — a calendar of bins, shaded by how much happened in each.\n *\n * The contribution grid: one column per period (usually a week), one row per\n * bin inside it (usually a weekday), and a colour ramp carrying the count. It\n * answers \"when was this busy\" at a glance, which no line can — a year of daily\n * numbers plotted as a series is a hairball, and as a grid it is a pattern.\n *\n * Composed rather than configured, so a chart that wants no axis simply does\n * not have one:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * The parts sort themselves into a real layout rather than stacking over the\n * plot. That is the difference from a line chart, where the axis floats over\n * the drawing: here the labels and the legend sit *beside* and *below* the\n * grid, so they take up room, and the grid is sized with them accounted for.\n * Only the cells and the rules between them are SVG; every label is a React\n * Native view, because SVG text ignores the platform's text scaling and the\n * theme's font.\n *\n * The ramp is one colour at five opacities rather than five colours. A heatmap\n * reads as *more* and *less* of one thing, and five distinct hues read as five\n * different things — which is what the `--color-chart-*` tokens are for, and\n * why they are not used here. The base is `--color-chart-1`, so the ramp\n * follows the theme, and `levelColors` replaces it outright when a brand needs\n * its own.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n StyleSheet,\n useWindowDimensions,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { cn } from '@/lib/cn';\nimport { normalizeWeekStart, startOfDay } from '@/lib/date';\n\n\n/**\n * Rows in a week — the default, and what the calendar helper and the weekday\n * labels assume. The grid itself takes a `rows` prop, so a grid whose bins are\n * hours rather than days is the same component with a different number.\n */\nconst DAYS_IN_WEEK = 7;\n\n/** Opacity of the base colour at each activity level. Index 0 is \"nothing\". */\nconst LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;\n\n/**\n * A theme token rather than a colour. Tokens are named, not written, so the\n * leading `--` tells the two apart without the caller having to say which\n * kind they passed.\n */\nfunction isToken(value: string | undefined): value is string {\n return typeof value === 'string' && value.startsWith('--');\n}\n\n/** Room left for the weekday labels when the y-axis does not ask for its own. */\nconst DEFAULT_AXIS_WIDTH = 26;\n\n/** Height of the month row at the default text size: one line of `xs` text. */\nconst X_AXIS_HEIGHT = 16;\n\n/**\n * How much larger than default the axis labels are drawn, from 1 up to the\n * axis cap. The heatmap's gutters are only just wide enough for a weekday at\n * the default size, so they grow by the same factor or a larger label wraps\n * and runs into the cells. Below the default size they stay put.\n */\nfunction useAxisLabelScale() {\n const { fontScale } = useWindowDimensions();\n return Math.min(Math.max(fontScale, 1), CHART_AXIS_MAX_FONT_SCALE);\n}\n\n/**\n * Height assumed for the readout when placing it above a cell. It is a single\n * line of `xs` text in a padded box, so it does not vary — and measuring it\n * would put the tooltip a frame behind the finger.\n */\nconst TOOLTIP_HEIGHT = 26;\n\n/**\n * How long the readout waits before claiming the touch, in milliseconds.\n *\n * Long enough that a swipe scrolls the chart instead of reading it, short\n * enough that a deliberate press does not feel like it was ignored.\n */\nconst DEFAULT_HOLD = 180;\n\n/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */\nconst WEEKDAYS = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n] as const;\n\nconst MONTHS = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/**\n * Where a part belongs in the layout. Read off the component itself, so\n * composition stays a flat list of children instead of four nested slots the\n * caller has to remember the order of.\n */\ntype Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';\n\nexport type HeatmapLayout = 'fluid' | 'fill';\n\n/** One bin inside a column — usually a single day. */\nexport interface HeatmapBin {\n /** Row index within the column, `0` to `6`. */\n bin: number;\n /** How much happened. The ramp is derived from these across the whole chart. */\n count: number;\n /** The day this bin stands for. Used by the axis labels and the tooltip. */\n date?: Date;\n}\n\n/** One column — usually a week. Missing bins are drawn as empty cells. */\nexport interface HeatmapColumn {\n /** Column index across the chart. */\n bin: number;\n bins: HeatmapBin[];\n}\n\n/** A cell resolved to its place in the grid, as the tooltip receives it. */\nexport interface HeatmapCell {\n column: number;\n row: number;\n count: number;\n level: number;\n date?: Date;\n}\n\ninterface Grid {\n /** Side of one cell, in pixels. */\n size: number;\n gap: number;\n columns: number;\n rows: number;\n width: number;\n height: number;\n}\n\ninterface HeatmapContextValue {\n data: HeatmapColumn[];\n grid: Grid;\n /** `[level0, level1, …level4]`, already resolved to colours. */\n ramp: string[];\n /** Opacity to paint each level's colour at. All ones for a supplied ramp. */\n opacities: number[];\n levelOf: (count: number) => number;\n cellAt: (column: number, row: number) => HeatmapCell;\n cornerRadius: number;\n inactiveOpacity: number;\n weekStartDay: number;\n activeCell: HeatmapCell | null;\n setActiveCell: (cell: HeatmapCell | null) => void;\n}\n\nconst HeatmapContext = createContext(null);\n\nfunction useHeatmap(component: string): HeatmapContextValue {\n const context = useContext(HeatmapContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The cell under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveCellChange` for that.\n */\nexport function useHeatmapChart() {\n const { activeCell } = useHeatmap('useHeatmapChart');\n return { activeCell };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Data helpers */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Build a year of columns from a flat list of dated counts.\n *\n * Every heatmap starts as \"I have some dates and some numbers\", and the\n * bucketing into weeks is the same arithmetic every time — including the two\n * parts that are easy to get wrong: the leading blanks before the first day of\n * the first week, and days with no entry at all, which must still be drawn as\n * empty cells or the calendar develops holes.\n */\nexport function buildHeatmapCalendar(\n entries: { date: Date; count: number }[],\n options: { start?: Date; end?: Date; weekStartDay?: number } = {}\n): HeatmapColumn[] {\n const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);\n if (!entries.length && !options.start) return [];\n\n const times = entries.map((entry) => entry.date.getTime());\n // Both bounds at local midnight, and both the same way. Comparing a\n // normalised lower bound against a raw upper one drops the last day whenever\n // the caller's `end` carries a time earlier than the cell being tested.\n const start = startOfDay(options.start ?? new Date(Math.min(...times)));\n const end = startOfDay(options.end ?? new Date(Math.max(...times)));\n\n const byDay = new Map();\n for (const entry of entries) {\n const key = dayKey(entry.date);\n byDay.set(key, (byDay.get(key) ?? 0) + entry.count);\n }\n\n // Back up to the first day of the week the range starts in, so column 0 is a\n // whole week and every row lines up with a weekday for the rest of the chart.\n const cursor = startOfDay(start);\n cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));\n\n const columns: HeatmapColumn[] = [];\n let column: HeatmapBin[] = [];\n\n while (cursor <= end || column.length) {\n const row = (cursor.getDay() - weekStartDay + 7) % 7;\n const date = new Date(cursor);\n // Before the range starts and after it ends the cell exists but has no\n // reading — `count: 0` and no date, so the tooltip stays quiet on it.\n const inRange = date >= start && date <= end;\n column.push({\n bin: row,\n count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,\n date: inRange ? date : undefined,\n });\n\n if (row === DAYS_IN_WEEK - 1) {\n columns.push({ bin: columns.length, bins: column });\n column = [];\n if (cursor > end) break;\n }\n cursor.setDate(cursor.getDate() + 1);\n }\n\n return columns;\n}\n\nfunction dayKey(date: Date) {\n return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n}\n\n/**\n * Four thresholds from the data, so a chart of single-digit counts and a chart\n * of thousands both use the whole ramp. Quartiles of the *non-zero* counts:\n * zero is its own level, and counting it would drag every threshold down to\n * nothing on a sparse chart.\n */\nfunction deriveLevels(data: HeatmapColumn[]): number[] {\n const counts: number[] = [];\n for (const column of data) {\n for (const bin of column.bins) {\n if (bin.count > 0) counts.push(bin.count);\n }\n }\n if (!counts.length) return [1, 2, 3, 4];\n counts.sort((a, b) => a - b);\n const at = (fraction: number) =>\n counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;\n const thresholds = [1, at(0.25), at(0.5), at(0.75)];\n // Ties collapse the ramp — nudge each threshold past the one below it so\n // four distinct levels stay four distinct levels.\n for (let i = 1; i < thresholds.length; i++) {\n if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;\n }\n return thresholds;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** One column per period, with its row bins inside. */\n data: HeatmapColumn[];\n /**\n * `fluid` draws cells at `binSize` and lets the grid be as wide as it needs\n * to be — put it in a horizontal `ScrollView` for a full year. `fill`\n * divides the available width between the columns instead.\n */\n layout?: HeatmapLayout;\n /** Side of one cell in `fluid` layout, in pixels. */\n binSize?: number;\n /** Space between cells, in pixels. */\n gap?: number;\n /** Corner radius of a cell. */\n cornerRadius?: number;\n /** Which weekday is the top row. `0` is Sunday. Labels follow it. */\n weekStartDay?: number;\n /**\n * Rows per column. Seven for a calendar; use another number when the bins\n * are not weekdays — twenty-four for a grid of hours.\n */\n rows?: number;\n /**\n * The four counts at which the ramp steps up. Derived from the data's own\n * quartiles when omitted, so a chart of single digits and a chart of\n * thousands both use the whole ramp.\n */\n levels?: number[];\n /**\n * Five colours — empty, then the four activity levels. Replaces the derived\n * ramp outright. Omit it and the ramp is `--color-chart-1` at five\n * opacities, which follows the theme.\n */\n levelColors?: string[];\n /**\n * Base colour for the derived ramp — the colour the busiest cells are drawn\n * in, with the quieter levels the same colour at lower opacity.\n *\n * Takes a theme token by name as well as a literal, so `\"--color-chart-3\"`\n * recolours the chart and keeps following the theme through light and dark.\n * Defaults to `--color-chart-1`.\n */\n color?: string;\n /**\n * Colour of a cell with nothing in it. Takes a token name too. Defaults to\n * `--color-muted`, which is the right weight for \"measured, and empty\" —\n * override it for a chart that should read as denser or fainter than that.\n */\n emptyColor?: string;\n /**\n * Opacity of the base colour at each of the five levels, quietest first.\n * The way to retune the ramp's contrast without having to name five colours.\n * Ignored when `levelColors` is given, which sets the colours outright.\n */\n levelOpacity?: number[];\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Opacity of every cell that is not the one under the finger. */\n inactiveOpacity?: number;\n /**\n * The cell under the finger as it moves, and `null` when it lifts. This is\n * how a readout above the chart gets its value — that readout is outside the\n * chart, so it cannot use `useHeatmapChart`.\n */\n onActiveCellChange?: (cell: HeatmapCell | null) => void;\n children?: ReactNode;\n}\n\nconst HeatmapChartRoot = forwardRef(function HeatmapChartRoot(\n {\n className,\n data,\n layout = 'fluid',\n binSize = 12,\n gap = 3,\n cornerRadius = 2,\n weekStartDay = 0,\n rows = DAYS_IN_WEEK,\n levels,\n levelColors,\n color,\n emptyColor,\n levelOpacity,\n animationDuration = 900,\n inactiveOpacity = 1,\n onActiveCellChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n) {\n const [width, setWidth] = useState(0);\n const [activeCell, setActiveCellState] = useState(null);\n\n const reveal = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /*\n * `color` and `emptyColor` take a token name as readily as a literal. A\n * literal is a colour frozen at the moment it was written — it cannot follow\n * the theme into dark mode — so naming the token is almost always what the\n * caller meant, and having to resolve it themselves is the reason they did\n * not. The hooks run unconditionally; which of the two answers is used is\n * decided after.\n */\n const baseToken = useCSSVariable(isToken(color) ? color : '--color-chart-1');\n const emptyToken = useCSSVariable(\n isToken(emptyColor) ? emptyColor : '--color-muted'\n );\n const base =\n (isToken(color) ? undefined : color) ??\n (typeof baseToken === 'string' ? baseToken : '#262626');\n const empty =\n (isToken(emptyColor) ? undefined : emptyColor) ??\n (typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)');\n\n const parts = useMemo(() => splitParts(children), [children]);\n const labelScale = useAxisLabelScale();\n // The y-axis is laid out beside the grid rather than over it, so its width\n // has to come out of the grid's before the cells are sized.\n const axisWidth = parts.y\n ? (parts.y.props.width ?? Math.ceil(DEFAULT_AXIS_WIDTH * labelScale))\n : 0;\n\n const grid = useMemo(() => {\n const count = data.length;\n if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };\n\n const available = Math.max(width - axisWidth, 0);\n const size =\n layout === 'fill' && available > 0\n ? Math.max((available - (count - 1) * gap) / count, 1)\n : binSize;\n\n return {\n size,\n gap,\n columns: count,\n rows,\n width: count * size + (count - 1) * gap,\n height: rows * size + (rows - 1) * gap,\n };\n }, [data.length, width, axisWidth, layout, binSize, gap, rows]);\n\n const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);\n\n /*\n * One colour at five opacities, unless the caller supplied five colours — in\n * which case the opacities are dropped, since dimming a colour someone chose\n * on purpose is not a ramp, it is a bug.\n */\n const { ramp, opacities } = useMemo(\n () =>\n levelColors\n ? { ramp: levelColors, opacities: levelColors.map(() => 1) }\n : {\n ramp: [empty, base, base, base, base],\n opacities: levelOpacity ?? [...LEVEL_OPACITY],\n },\n [levelColors, levelOpacity, empty, base]\n );\n\n const levelOf = useMemo(\n () => (count: number) => {\n if (count <= 0) return 0;\n let level = 1;\n for (let i = 1; i < thresholds.length; i++) {\n if (count >= thresholds[i]!) level = i + 1;\n }\n return Math.min(level, 4);\n },\n [thresholds]\n );\n\n const cellAt = useMemo(\n () => (columnIndex: number, row: number): HeatmapCell => {\n const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);\n const count = bin?.count ?? 0;\n return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };\n },\n [data, levelOf]\n );\n\n const accessibilityCells = useMemo(\n () =>\n data.flatMap((column) =>\n column.bins.map((bin) => ({\n column: column.bin,\n row: bin.bin,\n count: bin.count,\n level: levelOf(bin.count),\n date: bin.date,\n }))\n ),\n [data, levelOf]\n );\n\n const setActiveCell = useMemo(\n () => (cell: HeatmapCell | null) => {\n setActiveCellState(cell);\n onActiveCellChange?.(cell);\n },\n [onActiveCellChange]\n );\n\n // Plays once, when there is both a grid to reveal and data to reveal in it.\n const revealed = useRef(false);\n useEffect(() => {\n if (revealed.current || grid.width <= 0) return;\n revealed.current = true;\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n }, [grid.width, reducedMotion, animationDuration, reveal]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n setWidth((current) => (Math.abs(current - next) < 1 ? current : next));\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n }),\n [\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n ]\n );\n\n /*\n * One clip wiping left to right, rather than an animation per column. The\n * effect is the same — columns arriving in order — and it costs one animated\n * value instead of one per week, which for a year is fifty-two.\n */\n const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));\n\n return (\n \n \n {/* Above the axis gutter as well as the grid: the header is about the\n whole chart, so it starts at the chart's edge, not the grid's. */}\n {parts.header}\n\n [\n ['date', cell.date],\n ['column', cell.column],\n ['row', cell.row],\n ['count', cell.count],\n ]}\n />\n\n {parts.x ? (\n \n {parts.x}\n \n ) : null}\n\n \n {parts.y}\n \n {grid.width > 0 ? (\n <>\n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip, so\n * the grid drew complete and the reveal did not play at all. A\n * view with `overflow: 'hidden'` is clipped by the platform\n * itself, which both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. This\n * view is absolutely positioned and its only child is an\n * `` with an explicit width and height, so it is one node\n * and nothing around it moves.\n */}\n \n \n {parts.rules}\n {parts.cells}\n \n \n {parts.tooltip}\n \n ) : null}\n \n \n\n {parts.legend}\n \n \n );\n});\n\ninterface Parts {\n cells: ReactNode[];\n rules: ReactNode[];\n x: ReactNode | null;\n y: React.ReactElement<{ width?: number }> | null;\n tooltip: ReactNode[];\n legend: ReactNode[];\n header: ReactNode[];\n columns: number;\n}\n\n/** Sorts the children into the places the layout has for them. */\nfunction splitParts(children: ReactNode): Parts {\n const parts: Parts = {\n cells: [],\n rules: [],\n x: null,\n y: null,\n tooltip: [],\n legend: [],\n header: [],\n columns: 0,\n };\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const slot = (child.type as { slot?: Slot }).slot ?? 'cells';\n const keyed = {child};\n\n if (slot === 'x-axis') parts.x = keyed;\n else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;\n else if (slot === 'rules') parts.rules.push(keyed);\n else if (slot === 'tooltip') parts.tooltip.push(keyed);\n else if (slot === 'legend') parts.legend.push(keyed);\n else if (slot === 'header') parts.header.push(keyed);\n else parts.cells.push(keyed);\n });\n\n return parts;\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cells */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapCellsProps {\n /** Corner radius of a cell. Falls back to the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The grid itself. Every row of every column is drawn, including the ones with\n * nothing in them — a calendar with holes in it stops being a calendar.\n */\nfunction HeatmapCells({ cornerRadius }: HeatmapCellsProps) {\n const chart = useHeatmap('HeatmapChart.Cells');\n const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;\n const radius = cornerRadius ?? chart.cornerRadius;\n const step = grid.size + grid.gap;\n\n return (\n \n {data.map((column, columnIndex) =>\n Array.from({ length: grid.rows }, (_unused, row) => {\n const bin = column.bins.find((candidate) => candidate.bin === row);\n const level = levelOf(bin?.count ?? 0);\n const dimmed =\n activeCell !== null &&\n !(activeCell.column === columnIndex && activeCell.row === row);\n\n return (\n \n );\n })\n )}\n \n );\n}\nHeatmapCells.slot = 'cells' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Rules */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapSeparatorProps {\n /**\n * `quarter` draws a rule every thirteen columns; a number draws one every\n * that many columns.\n */\n every?: 'quarter' | number;\n color?: string;\n /** Dash pattern, e.g. `\"2,4\"`. Omit for a solid rule. */\n dashArray?: string;\n}\n\n/** Vertical rules grouping the columns — quarters, months, sprints. */\nfunction HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {\n const { grid } = useHeatmap('HeatmapChart.Separator');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');\n const interval = every === 'quarter' ? 13 : every;\n const step = grid.size + grid.gap;\n\n if (interval <= 0) return null;\n\n const lines: number[] = [];\n for (let column = interval; column < grid.columns; column += interval) {\n lines.push(column);\n }\n\n return (\n \n {lines.map((column) => {\n const x = column * step - grid.gap / 2;\n return (\n \n );\n })}\n \n );\n}\nHeatmapSeparator.slot = 'rules' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Axes */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapXAxisProps {\n className?: string;\n /**\n * Label a column. Given the first dated bin in it, so a month name can be\n * derived. Return an empty string to leave the column unlabelled.\n */\n formatLabel?: (date: Date, column: number) => string;\n /**\n * Column labels, left to right. Overrides the month names — for a grid whose\n * columns are not weeks, where there is no month to change and so nothing to\n * emit a label on.\n */\n labels?: string[];\n}\n\n/**\n * Month labels above the grid.\n *\n * A label is emitted where the month changes rather than at a fixed interval,\n * because months are not the same length — spacing them evenly puts \"Mar\" over\n * a week in February. A grid whose columns are not weeks has no such signal, so\n * it passes `labels` and gets one over every column.\n */\nfunction HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {\n const { data, grid } = useHeatmap('HeatmapChart.XAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n\n const labels = useMemo(() => {\n if (given) {\n return given\n .slice(0, data.length)\n .map((label, column) => ({ column, label }))\n .filter((entry) => entry.label);\n }\n\n const out: { column: number; label: string }[] = [];\n let lastMonth = -1;\n\n data.forEach((column, index) => {\n const date = column.bins.find((bin) => bin.date)?.date;\n if (!date) return;\n const month = date.getMonth();\n if (month === lastMonth) return;\n lastMonth = month;\n const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;\n if (label) out.push({ column: index, label });\n });\n\n // The first month is usually a stub of a week or two, and a label over it\n // collides with the next one. Drop it when it has no room.\n if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();\n return out;\n }, [data, formatLabel, given]);\n\n return (\n 1 && { height: Math.ceil(X_AXIS_HEIGHT * scale) }]}\n >\n {labels.map(({ column, label }) => (\n \n {label}\n \n ))}\n \n );\n}\nHeatmapXAxis.slot = 'x-axis' as Slot;\n\nexport interface HeatmapYAxisProps {\n className?: string;\n /**\n * Width reserved for the labels. The grid is sized around it. Defaults to 26,\n * and widens with the system text size so a larger weekday does not wrap.\n */\n width?: number;\n /** Which rows get a label. Every other row is the usual choice. */\n tickFilter?: 'all' | 'odd' | 'even';\n /** `initial` is the single letter; `full` is the abbreviated name. */\n labelFormat?: 'initial' | 'full';\n /**\n * Row labels, top to bottom. Overrides the weekday names — for a grid whose\n * rows are not days.\n */\n labels?: string[];\n}\n\n/** Weekday labels down the left of the grid. */\nfunction HeatmapYAxis({\n className,\n width: widthProp,\n tickFilter = 'odd',\n labelFormat = 'full',\n labels,\n}: HeatmapYAxisProps) {\n const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n const width = widthProp ?? Math.ceil(DEFAULT_AXIS_WIDTH * scale);\n\n return (\n \n {Array.from({ length: grid.rows }, (_unused, row) => {\n if (tickFilter === 'odd' && row % 2 === 0) return null;\n if (tickFilter === 'even' && row % 2 === 1) return null;\n\n const name = labels\n ? (labels[row] ?? '')\n : WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;\n return (\n \n {labels\n ? name\n : labelFormat === 'initial'\n ? name.slice(0, 1)\n : name.slice(0, 3)}\n \n );\n })}\n \n );\n}\nHeatmapYAxis.slot = 'y-axis' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapTooltipProps {\n className?: string;\n /** The line shown for a cell. Defaults to the count and the date. */\n formatLabel?: (cell: HeatmapCell) => string;\n /**\n * How long a press has to be held before the readout takes over, in\n * milliseconds.\n *\n * It is not zero, and cannot be: a full year of columns lives inside a\n * horizontal scroller, and a readout that claims the touch on the first pixel\n * of movement means the chart can never be scrolled. Holding first is what\n * separates \"I am moving the chart\" from \"I am reading it\". Set `0` only for\n * a chart that is not inside a scroll view at all.\n */\n activateAfterLongPress?: number;\n}\n\n/**\n * A readout following the finger across the grid.\n *\n * It lives in the view layer, over the SVG: a gesture handler cannot be\n * attached to an SVG node, and SVG text ignores the platform's text scaling.\n * The cell under the finger is resolved on the UI thread and only crosses back\n * into JS when it changes, so a drag across a year costs a handful of\n * re-renders rather than one per frame.\n */\nfunction HeatmapTooltip({\n className,\n formatLabel,\n activateAfterLongPress = DEFAULT_HOLD,\n}: HeatmapTooltipProps) {\n const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(\n 'HeatmapChart.Tooltip'\n );\n const step = grid.size + grid.gap;\n\n // Mirrored for the worklet, which cannot read the JS closure's latest value.\n const lastKey = useSharedValue(-1);\n\n const resolve = useMemo(\n () => (column: number, row: number) => setActiveCell(cellAt(column, row)),\n [cellAt, setActiveCell]\n );\n const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);\n\n /*\n * The cell is resolved here, on the UI thread, and only crosses into JS when\n * it *changes*. Sending every frame's coordinates over and picking the cell\n * in React would cost a re-render per frame for a value that changes maybe\n * fifty times across a whole drag.\n */\n const pick = (x: number, y: number) => {\n 'worklet';\n const column = Math.floor(x / step);\n const row = Math.floor(y / step);\n if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;\n const key = column * grid.rows + row;\n if (key === lastKey.value) return;\n lastKey.value = key;\n runOnJS(resolve)(column, row);\n };\n\n /*\n * The readout takes over only once the press has been held.\n *\n * `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on\n * touch-down whatever happens next, so picking there would light a cell and\n * dim the rest of the grid for the first moment of every scroll swipe — the\n * gesture would yield correctly and still leave a flicker behind it.\n */\n const pan = Gesture.Pan()\n .minDistance(0)\n .activateAfterLongPress(activateAfterLongPress)\n .onStart((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n // Fires whether or not the gesture ever activated, so a swipe that only\n // scrolled must not report a cell change it never made.\n if (lastKey.value === -1) return;\n lastKey.value = -1;\n runOnJS(clear)();\n });\n\n const label = activeCell\n ? formatLabel\n ? formatLabel(activeCell)\n : defaultTooltipLabel(activeCell)\n : null;\n\n // Flipped to the left of the finger past the halfway mark, so the readout\n // never runs off the edge it is closest to — and dropped below the cell in\n // the top two rows, where there is no room above it inside the chart.\n const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;\n const flipped = anchorX > grid.width / 2;\n const below = (activeCell?.row ?? 0) < 2;\n const anchorY = activeCell\n ? below\n ? activeCell.row * step + grid.size + 6\n : activeCell.row * step - TOOLTIP_HEIGHT - 6\n : 0;\n\n return (\n <>\n \n \n \n\n {activeCell && label ? (\n \n \n {label}\n \n \n ) : null}\n \n );\n}\nHeatmapTooltip.slot = 'tooltip' as Slot;\n\n/*\n * A cell with no date is not a cell with no data — it is a cell in a grid that\n * is not a calendar, and it has a count like any other. Saying \"No data\" over\n * it reports a hole in the data that is not there. Without a date there is\n * simply nothing to say after the number, so the number is the whole label,\n * and a grid that wants a noun for it passes `formatLabel`.\n */\nfunction defaultTooltipLabel(cell: HeatmapCell) {\n if (!cell.date) return `${cell.count}`;\n const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;\n const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;\n return `${count} on ${date}`;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Legend */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapLegendProps {\n className?: string;\n /** Text at the low end of the ramp. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Side of a swatch, in pixels. Defaults to the chart's cell size. */\n swatchSize?: number;\n}\n\n/** The `Less ▢▢▢▢▢ More` key, under the grid. */\nfunction HeatmapLegend({\n className,\n lessLabel = 'Less',\n moreLabel = 'More',\n swatchSize,\n}: HeatmapLegendProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');\n const size = swatchSize ?? Math.max(grid.size, 10);\n\n return (\n \n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n );\n}\nHeatmapLegend.slot = 'legend' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the grid is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a total, the held cell. */\n caption?: string;\n /**\n * Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a\n * grid that scrolls sideways, where `HeatmapChart.Legend` under the cells\n * would scroll away with them.\n */\n legend?: boolean;\n /** Text at the low end of the ramp, when `legend` is set. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the grid: what the chart is of, what it currently reads, and\n * what the shading means.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *grid* — the number changes as a finger moves across the cells, and the\n * ramp is the scale the chart itself derived. The card's header is a caption on\n * the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. Take it from `onActiveCellChange` and pass the\n * formatted string down, so one header can show a total when nothing is held\n * and a day's own count when something is.\n */\nfunction HeatmapHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n lessLabel = 'Less',\n moreLabel = 'More',\n children,\n ...props\n}: HeatmapHeaderProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');\n // Small enough to sit on one line beside the text, whatever the cells are.\n const size = Math.max(Math.min(grid.size, 12), 8);\n\n const trailing =\n children ??\n (legend ? (\n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native — held rigid, the\n ramp takes the width it wants and the caption wraps around it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nHeatmapHeader.displayName = 'HeatmapChart.Header';\nHeatmapHeader.slot = 'header' as Slot;\n\nexport const HeatmapChart = Object.assign(HeatmapChartRoot, {\n Header: HeatmapHeader,\n Cells: HeatmapCells,\n Separator: HeatmapSeparator,\n XAxis: HeatmapXAxis,\n YAxis: HeatmapYAxis,\n Tooltip: HeatmapTooltip,\n Legend: HeatmapLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/index.json b/apps/docs/public/r/index.json index 262f02d9a..181c0af00 100644 --- a/apps/docs/public/r/index.json +++ b/apps/docs/public/r/index.json @@ -1061,6 +1061,7 @@ "description": "A reading that keeps arriving, against a window that keeps moving.", "registryDependencies": [ "chart", + "chart-accessibility", "cn", "text" ], diff --git a/apps/docs/public/r/line-chart.json b/apps/docs/public/r/line-chart.json index 8f8e66071..0f8b36b04 100644 --- a/apps/docs/public/r/line-chart.json +++ b/apps/docs/public/r/line-chart.json @@ -19,7 +19,7 @@ { "path": "ui/line-chart.tsx", "type": "registry:ui", - "content": "/**\n * LineChart — a time series, drawn and animated on the UI thread.\n *\n * The chart is composed rather than configured: the grid, each series, the\n * axis and the crosshair are separate children, so a chart that wants no grid\n * simply does not have one. A single component with twenty booleans is how\n * charts end up unreadable at the call site.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * Internally there are two layers, and the parts sort themselves into the\n * right one: the geometry is SVG, and anything with text or a gesture on it is\n * a React Native view laid over the top. That split is not a detail — SVG text\n * ignores the platform's text scaling and the theme's font, and a gesture\n * handler cannot be attached to an SVG node at all.\n *\n * Three things animate, each for a different reason:\n *\n * - **The reveal.** On mount the plot is uncovered left to right by an animated\n * clip rectangle. Everything inside shares that clip, so the line, its fill\n * and its markers arrive together rather than as three separate effects.\n * - **The y-domain.** When the data changes the *scale* is tweened rather than\n * the path swapped, so a series that grows is redrawn against a moving axis\n * instead of jumping to a new shape. The reveal does not replay — it happened\n * once, and repeating it on every refresh turns a data update into an\n * animation.\n * - **The crosshair.** A drag resolves the nearest index on the UI thread and\n * moves the line and the dots from there. Only the index crosses back into\n * JS, and only when it changes, so a drag costs a handful of re-renders\n * rather than one per frame.\n *\n * Colours come from the `--color-chart-*` tokens, so a chart follows the active\n * theme and is put on brand by overriding those five in the app's own\n * global.css. Nothing here hardcodes a hex.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport {\n areaPath,\n columnValues,\n compactNumber,\n linePath,\n useSeriesColor,\n xOf,\n yOf,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels and the marker rings. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/**\n * Which layer a part belongs to. Read off the component itself, so composition\n * stays a flat list of children instead of two nested slots the caller has to\n * remember the order of.\n */\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type LineChartStatus = 'loading' | 'ready';\nexport type LineChartCurve = 'monotone' | 'linear';\nexport type LineChartDatum = Record;\n\ninterface LineChartContextValue {\n data: LineChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: LineChartStatus;\n curve: LineChartCurve;\n /** Series registered by the `Line` children, so the tooltip can read them. */\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n /** Tweened y-domain. Read inside worklets to build the paths. */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /**\n * The domain the tween is heading for. The axis labels read this rather than\n * the shared values: a number re-rendered on every frame of a 500ms tween is\n * 30 renders of a label that lands on the same string it started on.\n */\n extent: [number, number];\n /** Index under the finger, or -1 when nothing is being touched. */\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst LineChartContext = createContext(null);\n\nfunction useChart(component: string): LineChartContextValue {\n const context = useContext(LineChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The point under the crosshair, for something rendered *inside* the chart.\n *\n * A readout usually belongs in the card's header, which is outside this\n * provider — use `onActiveIndexChange` for that. A hook cannot reach up out of\n * the subtree it is called in, and pretending otherwise is how a component\n * ends up with a context that has to wrap half the screen.\n */\nexport function useLineChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useLineChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface LineChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: LineChartDatum[];\n /** Key holding the x label. Used by the axis and the crosshair readout. */\n xDataKey?: string;\n /**\n * `loading` draws a flat skeleton with a sweep running along it, and morphs\n * into the real series when it turns `ready`. One component throughout,\n * rather than a spinner swapped for a chart — swapping loses the transition.\n */\n status?: LineChartStatus;\n /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: LineChartCurve;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when the finger\n * lifts. This is how a readout in the card's header gets its value — that\n * header is outside the chart, so it cannot use `useLineChart`.\n *\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: LineChartDatum | null) => void;\n /**\n * Drop the axis padding so the line reaches the edges — for a sparkline with\n * no grid, axis or crosshair, where the shape is the whole point.\n */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface LineChartHandle {\n replay: () => void;\n}\n\nconst LineChartRoot = forwardRef(function LineChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether a `YAxis` was declared, read off the children before anything is\n * laid out. The gutter its labels sit in has to come off the plot's width\n * *before* the plot exists — an axis given no room is drawn over the series,\n * which loses both the numbers and the shape they were meant to explain.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n // A sparkline has no axis or grid to leave room for, so the line reaches the\n // edges; a stroke of a couple of pixels still needs a hair of inset not to be\n // clipped at the very top and bottom.\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n // One extent across every registered series, so two series share one axis\n // and stay comparable — a per-series scale makes them look alike when they\n // are orders of magnitude apart.\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n if (min === Infinity) return [0, 1];\n // A flat series has no extent of its own; give it one so it lands on the\n // middle of the plot instead of dividing by zero.\n if (min === max) return [min - 1, max + 1];\n // A little headroom, so the peak is not welded to the top edge.\n const pad = (max - min) * 0.1;\n return [min - pad, max + pad];\n }, [data, yDomain, seriesKeys]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n // Plays once, when there is both a plot to reveal and data to reveal in it.\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n // `replay()` re-runs the reveal — for a control the caller wires up.\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n // One place the crosshair index lands, so the chart's own children and a\n // readout outside it never disagree about which point is active.\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the chart drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the series\n * keeps the same coordinate space as the grid underneath it —\n * otherwise every mark would shift by the y-axis gutter.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nLineChartRoot.displayName = 'LineChart';\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, series, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/** Horizontal reference lines. Drawn under everything, outside the reveal clip. */\nfunction LineChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: LineChartGridProps) {\n const { plot } = useChart('LineChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nLineChartGrid.displayName = 'LineChart.Grid';\nLineChartGrid.layer = 'svg' as Layer;\n\nexport interface LineChartLineProps {\n /** Key holding this series' y values. */\n dataKey: string;\n /**\n * Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so\n * a series follows the theme without the call site naming a colour.\n */\n color?: string;\n /** Which `--color-chart-*` token to take when `color` is not given. */\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n strokeWidth?: number;\n /** Dash pattern, e.g. `\"6,4\"` — for a projection or a secondary series. */\n dashArray?: string;\n /** A dot at every point. Best kept for short series. */\n showMarkers?: boolean;\n}\n\n/** One series. */\nfunction LineChartLine({\n dataKey,\n color,\n colorIndex = 1,\n strokeWidth = 2.5,\n dashArray,\n showMarkers = false,\n}: LineChartLineProps) {\n const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries } =\n useChart('LineChart.Line');\n const stroke = useSeriesColor(color, colorIndex);\n\n useEffect(() => {\n registerSeries(dataKey, stroke);\n return () => unregisterSeries(dataKey);\n }, [dataKey, stroke, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => ({\n d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading),\n }));\n\n return (\n \n \n {showMarkers && !loading\n ? values.map((value, index) =>\n value === null ? null : (\n \n )\n )\n : null}\n \n );\n}\nLineChartLine.displayName = 'LineChart.Line';\nLineChartLine.layer = 'series' as Layer;\n\n/** A dot at one point. Follows the y-domain tween exactly as the line does. */\nfunction PointMarker({\n index,\n total,\n value,\n plot,\n domainMin,\n domainMax,\n color,\n}: {\n index: number;\n total: number;\n value: number;\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n color: string;\n}) {\n const animatedProps = useAnimatedProps(() => ({\n cy: yOf(value, plot, domainMin.value, domainMax.value),\n }));\n\n return (\n \n );\n}\n\nexport interface LineChartAreaProps {\n dataKey: string;\n color?: string;\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n /** Opacity at the line. Fades to nothing at the baseline. */\n opacity?: number;\n}\n\n/**\n * The fill under a series. A separate child from the line, because a chart with\n * two series usually wants the fill on only one of them — two translucent\n * fills over each other make a third colour that means nothing.\n */\nfunction LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {\n const { data, plot, domainMin, domainMax, curve, status } = useChart('LineChart.Area');\n const fill = useSeriesColor(color, colorIndex);\n const gradientId = `panelui-area-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => ({\n d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading),\n }));\n\n return (\n \n \n \n \n \n \n \n \n \n );\n}\nLineChartArea.displayName = 'LineChart.Area';\nLineChartArea.layer = 'series' as Layer;\n\nexport interface LineChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a flat rule where the series will be, with a highlight\n * travelling along it. Drawn inside the same SVG rather than as an overlay, so\n * arriving data is one tree changing rather than one view replacing another —\n * which is what lets the flat line become the series instead of cutting to it.\n */\nfunction LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) {\n const { plot, status } = useChart('LineChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n // Unique per instance: a fixed id makes two of these on one screen share a\n // gradient, and the second one to mount wins.\n const gradientId = `panelui-chart-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const y = plot.top + plot.height / 2;\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nLineChartSkeleton.displayName = 'LineChart.Skeleton';\nLineChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartXAxisProps {\n /** How many labels to show. The rest are dropped, evenly. */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: LineChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The x labels. Real text rather than SVG text, so they follow the theme's font\n * and the platform's text scaling — SVG text does neither.\n */\nfunction LineChartXAxis({ ticks = 4, format, className }: LineChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('LineChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0,3,6,8,11 — so spreading them evenly put the ones in\n * between over the wrong part of the line.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nLineChartXAxis.displayName = 'LineChart.XAxis';\nLineChartXAxis.layer = 'overlay' as Layer;\n\nexport interface LineChartYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Value labels down the side, one per grid line.\n *\n * Give it the same `ticks` as the grid, or the numbers name lines that are not\n * there. Four is the default on both for that reason.\n *\n * The labels are the domain the data settles at, not the tweening one — a\n * number that counts up through every intermediate value while the axis\n * animates is noise, and the axis is the part of the chart that is supposed to\n * hold still enough to read.\n */\nfunction LineChartYAxis({ ticks = 4, format, className }: LineChartYAxisProps) {\n const { plot, extent } = useChart('LineChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nLineChartYAxis.displayName = 'LineChart.YAxis';\nLineChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nLineChartYAxis.axis = 'y' as const;\n\nexport interface LineChartTooltipProps {\n color?: string;\n /**\n * Float a small label at the crosshair showing the x-value and each series'\n * value at that point — the minimal readout a drag wants. On by default.\n */\n showLabel?: boolean;\n /** Format one series' value for the label. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the label's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: LineChartDatum) => string;\n}\n\n/**\n * The crosshair, the gesture that drives it, and the label that rides it.\n *\n * They live in the view layer: a gesture handler cannot be attached to an SVG\n * node, and once the gesture is a view the crosshair may as well be one too —\n * a 1px view moved by `translateX` costs less than re-rendering an SVG line.\n * The label follows on the UI thread the same way; only its *text* crosses back\n * into JS, and only when the active index changes.\n *\n * The hit area is the whole plot. A crosshair you have to land on the line to\n * summon is a crosshair nobody finds.\n */\nfunction LineChartTooltip({ color, showLabel = true, formatValue, formatX }: LineChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n domainMin,\n domainMax,\n series,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('LineChart.Tooltip');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Built in one closure, and everything it captures is a number or a shared\n * value. A worklet may only call another worklet, and the rule is enforced by\n * crashing the app rather than by warning — so the resolver is declared here,\n * next to its callers, instead of as a helper further down the file where it\n * would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 2 || width <= 0) return;\n const ratio = (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n // Only the index needs JS, and only when it changes — a drag across a\n // hundred points costs a hundred re-renders at most, not one per frame\n // for the length of the gesture.\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n // The label centres over the crosshair but is clamped inside the plot, so it\n // never runs off the edge at the first or last point.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return {\n opacity: 1,\n transform: [{ translateX: clamped - half }],\n };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue =\n formatValue ?? ((value: number) => compactNumber(value));\n const fmtX =\n formatX ?? ((datum: LineChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {series.map(([key, seriesColor]) => (\n \n ))}\n\n {showLabel ? (\n \n \n {active ? (\n <>\n \n {fmtX(active)}\n \n {series.map(([key, seriesColor]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n {series.length > 1 ? (\n \n ) : null}\n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n ) : null}\n \n \n );\n}\nLineChartTooltip.displayName = 'LineChart.Tooltip';\nLineChartTooltip.layer = 'overlay' as Layer;\n\nconst LABEL_WIDTH = 112;\n\n/**\n * Box each x label is centred in. Wide enough for a short label to sit over\n * its point without the neighbours colliding; a longer one is ellipsised\n * rather than allowed to push the others out of place.\n */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Diameter of the dot that rides a series under the crosshair. */\nconst DOT = 9;\n\n/** The dot riding one series under the crosshair. */\nfunction TooltipDot({\n values,\n plot,\n domainMin,\n domainMax,\n activeIndex,\n color,\n}: {\n values: (number | null)[];\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n activeIndex: SharedValue;\n color: string;\n}) {\n const style = useAnimatedStyle(() => {\n const index = activeIndex.value;\n const value = index < 0 ? null : values[index];\n if (index < 0 || value === null || value === undefined) return { opacity: 0 };\n return {\n opacity: 1,\n transform: [\n { translateX: xOf(index, values.length, plot) - DOT / 2 },\n { translateY: yOf(value, plot, domainMin.value, domainMax.value) - DOT / 2 },\n ],\n };\n });\n\n return (\n \n );\n}\n\nexport interface LineChartLegendProps extends ViewProps {\n className?: string;\n /** Label per series key. A key with no label falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per registered series. Sits in the top-left of the plot\n * by default — move it with `className`.\n */\nfunction LineChartLegend({ className, labels, ...props }: LineChartLegendProps) {\n const { series } = useChart('LineChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nLineChartLegend.displayName = 'LineChart.Legend';\nLineChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `LineChart.Legend` on a chart that has a header: the legend floats over the\n * plot, where it competes with the lines for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the line, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a point's value when something is.\n */\nfunction LineChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: LineChartHeaderProps) {\n const { series } = useChart('LineChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nLineChartHeader.displayName = 'LineChart.Header';\nLineChartHeader.layer = 'header' as Layer;\n\nexport const LineChart = Object.assign(LineChartRoot, {\n Header: LineChartHeader,\n Grid: LineChartGrid,\n Area: LineChartArea,\n Line: LineChartLine,\n Skeleton: LineChartSkeleton,\n XAxis: LineChartXAxis,\n YAxis: LineChartYAxis,\n Tooltip: LineChartTooltip,\n Legend: LineChartLegend,\n});\n" + "content": "/**\n * LineChart — a time series, drawn and animated on the UI thread.\n *\n * The chart is composed rather than configured: the grid, each series, the\n * axis and the crosshair are separate children, so a chart that wants no grid\n * simply does not have one. A single component with twenty booleans is how\n * charts end up unreadable at the call site.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * Internally there are two layers, and the parts sort themselves into the\n * right one: the geometry is SVG, and anything with text or a gesture on it is\n * a React Native view laid over the top. That split is not a detail — SVG text\n * ignores the platform's text scaling and the theme's font, and a gesture\n * handler cannot be attached to an SVG node at all.\n *\n * Three things animate, each for a different reason:\n *\n * - **The reveal.** On mount the plot is uncovered left to right by an animated\n * clip rectangle. Everything inside shares that clip, so the line, its fill\n * and its markers arrive together rather than as three separate effects.\n * - **The y-domain.** When the data changes the *scale* is tweened rather than\n * the path swapped, so a series that grows is redrawn against a moving axis\n * instead of jumping to a new shape. The reveal does not replay — it happened\n * once, and repeating it on every refresh turns a data update into an\n * animation.\n * - **The crosshair.** A drag resolves the nearest index on the UI thread and\n * moves the line and the dots from there. Only the index crosses back into\n * JS, and only when it changes, so a drag costs a handful of re-renders\n * rather than one per frame.\n *\n * Colours come from the `--color-chart-*` tokens, so a chart follows the active\n * theme and is put on brand by overriding those five in the app's own\n * global.css. Nothing here hardcodes a hex.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport {\n areaPath,\n columnValues,\n compactNumber,\n linePath,\n useSeriesColor,\n xOf,\n yOf,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels and the marker rings. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/**\n * Which layer a part belongs to. Read off the component itself, so composition\n * stays a flat list of children instead of two nested slots the caller has to\n * remember the order of.\n */\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type LineChartStatus = 'loading' | 'ready';\nexport type LineChartCurve = 'monotone' | 'linear';\nexport type LineChartDatum = Record;\n\ninterface LineChartContextValue {\n data: LineChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: LineChartStatus;\n curve: LineChartCurve;\n /** Series registered by the `Line` children, so the tooltip can read them. */\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n /** Tweened y-domain. Read inside worklets to build the paths. */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /**\n * The domain the tween is heading for. The axis labels read this rather than\n * the shared values: a number re-rendered on every frame of a 500ms tween is\n * 30 renders of a label that lands on the same string it started on.\n */\n extent: [number, number];\n /** Index under the finger, or -1 when nothing is being touched. */\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst LineChartContext = createContext(null);\n\nfunction useChart(component: string): LineChartContextValue {\n const context = useContext(LineChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The point under the crosshair, for something rendered *inside* the chart.\n *\n * A readout usually belongs in the card's header, which is outside this\n * provider — use `onActiveIndexChange` for that. A hook cannot reach up out of\n * the subtree it is called in, and pretending otherwise is how a component\n * ends up with a context that has to wrap half the screen.\n */\nexport function useLineChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useLineChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface LineChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: LineChartDatum[];\n /** Key holding the x label. Used by the axis and the crosshair readout. */\n xDataKey?: string;\n /**\n * `loading` draws a flat skeleton with a sweep running along it, and morphs\n * into the real series when it turns `ready`. One component throughout,\n * rather than a spinner swapped for a chart — swapping loses the transition.\n */\n status?: LineChartStatus;\n /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: LineChartCurve;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when the finger\n * lifts. This is how a readout in the card's header gets its value — that\n * header is outside the chart, so it cannot use `useLineChart`.\n *\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: LineChartDatum | null) => void;\n /**\n * Drop the axis padding so the line reaches the edges — for a sparkline with\n * no grid, axis or crosshair, where the shape is the whole point.\n */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface LineChartHandle {\n replay: () => void;\n}\n\nconst LineChartRoot = forwardRef(function LineChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n /*\n * Whether a `YAxis` was declared, read off the children before anything is\n * laid out. The gutter its labels sit in has to come off the plot's width\n * *before* the plot exists — an axis given no room is drawn over the series,\n * which loses both the numbers and the shape they were meant to explain.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n // A sparkline has no axis or grid to leave room for, so the line reaches the\n // edges; a stroke of a couple of pixels still needs a hair of inset not to be\n // clipped at the very top and bottom.\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n // One extent across every registered series, so two series share one axis\n // and stay comparable — a per-series scale makes them look alike when they\n // are orders of magnitude apart.\n const seriesKeys = series.map(([key]) => key).join('|');\n const extent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n if (min === Infinity) return [0, 1];\n // A flat series has no extent of its own; give it one so it lands on the\n // middle of the plot instead of dividing by zero.\n if (min === max) return [min - 1, max + 1];\n // A little headroom, so the peak is not welded to the top edge.\n const pad = (max - min) * 0.1;\n return [min - pad, max + pad];\n }, [data, yDomain, seriesKeys]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n // Plays once, when there is both a plot to reveal and data to reveal in it.\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n // `replay()` re-runs the reveal — for a control the caller wires up.\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n // One place the crosshair index lands, so the chart's own children and a\n // readout outside it never disagree about which point is active.\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n domainMin,\n domainMax,\n extent,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the chart drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the series\n * keeps the same coordinate space as the grid underneath it —\n * otherwise every mark would shift by the y-axis gutter.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nLineChartRoot.displayName = 'LineChart';\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, series, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/** Horizontal reference lines. Drawn under everything, outside the reveal clip. */\nfunction LineChartGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: LineChartGridProps) {\n const { plot } = useChart('LineChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nLineChartGrid.displayName = 'LineChart.Grid';\nLineChartGrid.layer = 'svg' as Layer;\n\nexport interface LineChartLineProps {\n /** Key holding this series' y values. */\n dataKey: string;\n /**\n * Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so\n * a series follows the theme without the call site naming a colour.\n */\n color?: string;\n /** Which `--color-chart-*` token to take when `color` is not given. */\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n strokeWidth?: number;\n /** Dash pattern, e.g. `\"6,4\"` — for a projection or a secondary series. */\n dashArray?: string;\n /** A dot at every point. Best kept for short series. */\n showMarkers?: boolean;\n}\n\n/** One series. */\nfunction LineChartLine({\n dataKey,\n color,\n colorIndex = 1,\n strokeWidth = 2.5,\n dashArray,\n showMarkers = false,\n}: LineChartLineProps) {\n const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries } =\n useChart('LineChart.Line');\n const stroke = useSeriesColor(color, colorIndex);\n\n useEffect(() => {\n registerSeries(dataKey, stroke);\n return () => unregisterSeries(dataKey);\n }, [dataKey, stroke, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => ({\n d: linePath(values, plot, domainMin.value, domainMax.value, curve, loading),\n }));\n\n return (\n \n \n {showMarkers && !loading\n ? values.map((value, index) =>\n value === null ? null : (\n \n )\n )\n : null}\n \n );\n}\nLineChartLine.displayName = 'LineChart.Line';\nLineChartLine.layer = 'series' as Layer;\n\n/** A dot at one point. Follows the y-domain tween exactly as the line does. */\nfunction PointMarker({\n index,\n total,\n value,\n plot,\n domainMin,\n domainMax,\n color,\n}: {\n index: number;\n total: number;\n value: number;\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n color: string;\n}) {\n const animatedProps = useAnimatedProps(() => ({\n cy: yOf(value, plot, domainMin.value, domainMax.value),\n }));\n\n return (\n \n );\n}\n\nexport interface LineChartAreaProps {\n dataKey: string;\n color?: string;\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n /** Opacity at the line. Fades to nothing at the baseline. */\n opacity?: number;\n}\n\n/**\n * The fill under a series. A separate child from the line, because a chart with\n * two series usually wants the fill on only one of them — two translucent\n * fills over each other make a third colour that means nothing.\n */\nfunction LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {\n const { data, plot, domainMin, domainMax, curve, status } = useChart('LineChart.Area');\n const fill = useSeriesColor(color, colorIndex);\n const gradientId = `panelui-area-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => ({\n d: areaPath(values, plot, domainMin.value, domainMax.value, curve, loading),\n }));\n\n return (\n \n \n \n \n \n \n \n \n \n );\n}\nLineChartArea.displayName = 'LineChart.Area';\nLineChartArea.layer = 'series' as Layer;\n\nexport interface LineChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a flat rule where the series will be, with a highlight\n * travelling along it. Drawn inside the same SVG rather than as an overlay, so\n * arriving data is one tree changing rather than one view replacing another —\n * which is what lets the flat line become the series instead of cutting to it.\n */\nfunction LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) {\n const { plot, status } = useChart('LineChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n // Unique per instance: a fixed id makes two of these on one screen share a\n // gradient, and the second one to mount wins.\n const gradientId = `panelui-chart-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const y = plot.top + plot.height / 2;\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nLineChartSkeleton.displayName = 'LineChart.Skeleton';\nLineChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartXAxisProps {\n /** How many labels to show. The rest are dropped, evenly. */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: LineChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The x labels. Real text rather than SVG text, so they follow the theme's font\n * and the platform's text scaling — SVG text does neither.\n */\nfunction LineChartXAxis({ ticks = 4, format, className }: LineChartXAxisProps) {\n const { data, xDataKey, plot } = useChart('LineChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0,3,6,8,11 — so spreading them evenly put the ones in\n * between over the wrong part of the line.\n *\n * The box is backed off by half its own width rather than translated by\n * `-50%`, which is not reliable across React Native versions.\n */\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nLineChartXAxis.displayName = 'LineChart.XAxis';\nLineChartXAxis.layer = 'overlay' as Layer;\n\nexport interface LineChartYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Value labels down the side, one per grid line.\n *\n * Give it the same `ticks` as the grid, or the numbers name lines that are not\n * there. Four is the default on both for that reason.\n *\n * The labels are the domain the data settles at, not the tweening one — a\n * number that counts up through every intermediate value while the axis\n * animates is noise, and the axis is the part of the chart that is supposed to\n * hold still enough to read.\n */\nfunction LineChartYAxis({ ticks = 4, format, className }: LineChartYAxisProps) {\n const { plot, extent } = useChart('LineChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nLineChartYAxis.displayName = 'LineChart.YAxis';\nLineChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nLineChartYAxis.axis = 'y' as const;\n\nexport interface LineChartTooltipProps {\n color?: string;\n /**\n * Float a small label at the crosshair showing the x-value and each series'\n * value at that point — the minimal readout a drag wants. On by default.\n */\n showLabel?: boolean;\n /** Format one series' value for the label. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the label's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: LineChartDatum) => string;\n}\n\n/**\n * The crosshair, the gesture that drives it, and the label that rides it.\n *\n * They live in the view layer: a gesture handler cannot be attached to an SVG\n * node, and once the gesture is a view the crosshair may as well be one too —\n * a 1px view moved by `translateX` costs less than re-rendering an SVG line.\n * The label follows on the UI thread the same way; only its *text* crosses back\n * into JS, and only when the active index changes.\n *\n * The hit area is the whole plot. A crosshair you have to land on the line to\n * summon is a crosshair nobody finds.\n */\nfunction LineChartTooltip({ color, showLabel = true, formatValue, formatX }: LineChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n domainMin,\n domainMax,\n series,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('LineChart.Tooltip');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n /*\n * Built in one closure, and everything it captures is a number or a shared\n * value. A worklet may only call another worklet, and the rule is enforced by\n * crashing the app rather than by warning — so the resolver is declared here,\n * next to its callers, instead of as a helper further down the file where it\n * would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 2 || width <= 0) return;\n const ratio = (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n // Only the index needs JS, and only when it changes — a drag across a\n // hundred points costs a hundred re-renders at most, not one per frame\n // for the length of the gesture.\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n return {\n opacity: index < 0 ? 0 : 0.45,\n transform: [{ translateX: index < 0 ? 0 : xOf(index, total, plot) }],\n };\n });\n\n // The label centres over the crosshair but is clamped inside the plot, so it\n // never runs off the edge at the first or last point.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return {\n opacity: 1,\n transform: [{ translateX: clamped - half }],\n };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue =\n formatValue ?? ((value: number) => compactNumber(value));\n const fmtX =\n formatX ?? ((datum: LineChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {series.map(([key, seriesColor]) => (\n \n ))}\n\n {showLabel ? (\n \n \n {active ? (\n <>\n \n {fmtX(active)}\n \n {series.map(([key, seriesColor]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n {series.length > 1 ? (\n \n ) : null}\n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n ) : null}\n \n \n ) : null}\n \n \n );\n}\nLineChartTooltip.displayName = 'LineChart.Tooltip';\nLineChartTooltip.layer = 'overlay' as Layer;\n\nconst LABEL_WIDTH = 112;\n\n/**\n * Box each x label is centred in. Wide enough for a short label to sit over\n * its point without the neighbours colliding; a longer one is ellipsised\n * rather than allowed to push the others out of place.\n */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Diameter of the dot that rides a series under the crosshair. */\nconst DOT = 9;\n\n/** The dot riding one series under the crosshair. */\nfunction TooltipDot({\n values,\n plot,\n domainMin,\n domainMax,\n activeIndex,\n color,\n}: {\n values: (number | null)[];\n plot: Plot;\n domainMin: SharedValue;\n domainMax: SharedValue;\n activeIndex: SharedValue;\n color: string;\n}) {\n const style = useAnimatedStyle(() => {\n const index = activeIndex.value;\n const value = index < 0 ? null : values[index];\n if (index < 0 || value === null || value === undefined) return { opacity: 0 };\n return {\n opacity: 1,\n transform: [\n { translateX: xOf(index, values.length, plot) - DOT / 2 },\n { translateY: yOf(value, plot, domainMin.value, domainMax.value) - DOT / 2 },\n ],\n };\n });\n\n return (\n \n );\n}\n\nexport interface LineChartLegendProps extends ViewProps {\n className?: string;\n /** Label per series key. A key with no label falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per registered series. Sits in the top-left of the plot\n * by default — move it with `className`.\n */\nfunction LineChartLegend({ className, labels, ...props }: LineChartLegendProps) {\n const { series } = useChart('LineChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nLineChartLegend.displayName = 'LineChart.Legend';\nLineChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface LineChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `LineChart.Legend` on a chart that has a header: the legend floats over the\n * plot, where it competes with the lines for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves along the line, and the\n * legend is the series list the chart itself is holding. The card's header is a\n * caption on the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show a total when nothing is pressed\n * and a point's value when something is.\n */\nfunction LineChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: LineChartHeaderProps) {\n const { series } = useChart('LineChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nLineChartHeader.displayName = 'LineChart.Header';\nLineChartHeader.layer = 'header' as Layer;\n\nexport const LineChart = Object.assign(LineChartRoot, {\n Header: LineChartHeader,\n Grid: LineChartGrid,\n Area: LineChartArea,\n Line: LineChartLine,\n Skeleton: LineChartSkeleton,\n XAxis: LineChartXAxis,\n YAxis: LineChartYAxis,\n Tooltip: LineChartTooltip,\n Legend: LineChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/live-line-chart.json b/apps/docs/public/r/live-line-chart.json index 41ffc634c..e8608610d 100644 --- a/apps/docs/public/r/live-line-chart.json +++ b/apps/docs/public/r/live-line-chart.json @@ -4,6 +4,7 @@ "description": "A reading that keeps arriving, against a window that keeps moving.", "registryDependencies": [ "chart", + "chart-accessibility", "cn", "text" ], @@ -18,7 +19,7 @@ { "path": "ui/live-line-chart.tsx", "type": "registry:ui", - "content": "/**\n * LiveLineChart — a reading that keeps arriving, against a window that keeps\n * moving.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What it is, against the line chart beside it\n *\n * `LineChart` places a point by its position in the list, which is right for a\n * series of twelve months whatever the gaps between the dates behind them. Here\n * a point is placed at the time it carries, against a domain that runs from\n * `window` seconds ago to now — so the gaps are the subject, and a reading that\n * arrived late sits where it arrived rather than one slot along.\n *\n * ## The clock, and what it costs\n *\n * This is the only thing in the library that animates without an interaction or\n * a change of data. The window is tied to the wall clock, so the line drifts\n * left whether or not anything is arriving, and a feed that stalls shows as a\n * flat run reaching back from the tip rather than as a chart that has frozen.\n * Those two look identical if the window only moves when a point lands, and\n * they mean opposite things.\n *\n * The cost is a frame callback for as long as the chart is mounted. It is\n * stopped by `paused`, by `status=\"loading\"`, while the app is backgrounded\n * and on unmount, and it is never started when the platform asks for reduced\n * motion — in that case the window advances as each point arrives instead,\n * which is the same picture sampled less often.\n *\n * Screen readers receive one image-role snapshot: its name, current or\n * selected value, direction, time window and paused state. It changes when the\n * React data changes, never on the UI-thread clock frame, and requests no live\n * announcement. The visual axes, badges and tooltip repeat that snapshot and\n * stay out of the accessibility tree; controls placed in Header remain normal\n * controls.\n *\n * ## Colour follows the recent direction\n *\n * With `momentumColors` set, the line, the fill and the tip take their colour\n * from where the reading has been going rather than from a fixed hue. It is the\n * one thing on the chart readable without looking at the axis, which is what a\n * number being watched out of the corner of an eye needs.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n AppState,\n StyleSheet,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useFrameCallback,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n ClipPath,\n Defs,\n G,\n LinearGradient,\n Path,\n Rect,\n Stop,\n Line as SvgLine,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n compactNumber,\n segment,\n useSeriesColor,\n xAt,\n yOf,\n type ChartCurve,\n type ChartPoint,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { liveLineAccessibility } from '@/components/ui/live-line-accessibility';\nimport {\n liveLineClockRuns,\n normalizeLiveLinePoints,\n normalizeLiveLineWindow,\n reconcileLiveLineActivePoint,\n} from '@/components/ui/live-line-lifecycle';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 14, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Diameter of the dot at the leading end of the line. */\nconst TIP = 8;\n\n/** How far the pulse ring grows past the dot. */\nconst PULSE = 3.2;\n\n/** Line height of an `xs` label, for centring one on the line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\nconst READOUT_WIDTH = 116;\n\n/** Which layer a part belongs to. Read off the component itself. */\ntype Layer = 'svg' | 'overlay' | 'header';\n\n/** Whether the chart is showing data or waiting for it. */\nexport type LiveLineChartStatus = 'loading' | 'ready';\n\n/** Where the reading has been going, over the last few points. */\nexport type LiveLineMomentum = 'up' | 'down' | 'flat';\n\n/** A colour per direction, for a chart that is read by its colour. */\nexport interface LiveLineMomentumColors {\n up?: string;\n down?: string;\n flat?: string;\n}\n\n/** One reading. `time` is a timestamp in milliseconds, as `Date.now()` gives. */\nexport interface LiveLinePoint {\n time: number;\n value: number;\n}\n\ninterface LiveLineChartContextValue {\n plot: Plot;\n status: LiveLineChartStatus;\n curve: ChartCurve;\n /** Timestamps and readings, as two number arrays — cheaper to hand the UI\n * thread than an array of objects, and every worklet here walks them in step. */\n times: SharedValue;\n values: SharedValue;\n /** The right-hand edge of the window, in milliseconds. */\n now: SharedValue;\n /** How much time the plot spans, in milliseconds. */\n windowMs: number;\n /** Tweened y-domain. Read inside worklets to build the paths. */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** What the tween is heading for. The axis labels read this rather than the\n * shared values, which would re-render a label on every frame of a tween. */\n extent: [number, number];\n /** The moment under the finger, or -1. Pinned to a time rather than to a\n * position, so the crosshair travels with the reading it named. */\n activeTime: SharedValue;\n activePoint: LiveLinePoint | null;\n setActivePoint: (point: LiveLinePoint | null) => void;\n /** The latest reading, for anything drawing the leading end. */\n latest: LiveLinePoint | null;\n momentum: LiveLineMomentum;\n color: string;\n clipId: string;\n}\n\nconst LiveLineChartContext = createContext(null);\n\nfunction useChart(component: string): LiveLineChartContextValue {\n const context = useContext(LiveLineChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The reading under the crosshair, for something rendered inside the chart. */\nexport function useLiveLineChart() {\n const { activePoint, latest, momentum, color } = useChart('useLiveLineChart');\n return { activePoint, latest, momentum, color };\n}\n\n/**\n * The visible run of readings, as coordinates.\n *\n * It starts one point *before* the window rather than at the first one inside\n * it, so the line enters from the left edge instead of beginning wherever the\n * oldest surviving reading happens to sit. The plot is clipped, so the part\n * hanging off the edge is never drawn.\n */\nfunction runOf(\n times: number[],\n values: number[],\n plot: Plot,\n xMin: number,\n xMax: number,\n yMin: number,\n yMax: number\n): ChartPoint[] {\n 'worklet';\n const out: ChartPoint[] = [];\n const count = times.length;\n if (!count) return out;\n\n let start = 0;\n for (let i = 0; i < count; i += 1) {\n if (times[i]! >= xMin) break;\n start = i;\n }\n\n for (let i = start; i < count; i += 1) {\n const time = times[i]!;\n if (time > xMax) break;\n out.push({\n x: xAt(time, plot, xMin, xMax),\n y: yOf(values[i]!, plot, yMin, yMax),\n });\n }\n return out;\n}\n\nexport interface LiveLineChartProps extends ViewProps {\n className?: string;\n /**\n * Names the chart's single screen-reader snapshot. Falls back to the Header\n * title, then to \"Live line chart\".\n */\n accessibilityLabel?: string;\n /** Additional guidance after the snapshot. No gesture is invented for it. */\n accessibilityHint?: string;\n /** The readings so far. Invalid values are dropped and timestamps are ordered. */\n data: LiveLinePoint[];\n /** How much time the plot spans, in seconds. Invalid values use 30. */\n window?: number;\n /** Freeze the window where it is. The readings still arrive; the clock stops. */\n paused?: boolean;\n /** Fix the y-axis instead of deriving it from what is visible. */\n yDomain?: [number, number];\n /** Milliseconds for the y-axis to settle after the range changes. */\n domainDuration?: number;\n /** `monotone` never overshoots between readings; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * The most readings kept. Older ones are dropped, since they are off the\n * window and cannot come back — an unbounded feed otherwise grows an array\n * for as long as the screen is open. Must be positive and finite.\n */\n maxPoints?: number;\n /** Width ÷ height of the plot. */\n aspectRatio?: number;\n /** `loading` draws a flat placeholder and holds the clock. */\n status?: LiveLineChartStatus;\n /** Colour per direction. Left out, the chart draws in one hue throughout. */\n momentumColors?: LiveLineMomentumColors;\n /** Overrides the `--color-chart-1` token. Ignored when `momentumColors` is set. */\n color?: string;\n /** The reading under the crosshair as it moves, and `null` when the finger lifts. */\n onActivePointChange?: (point: LiveLinePoint | null) => void;\n children?: ReactNode;\n}\n\nexport interface LiveLineChartHandle {\n /** Jump the window to the current moment — after a pause, or a background. */\n sync: () => void;\n}\n\nconst LiveLineChartRoot = forwardRef(\n function LiveLineChartRoot(\n {\n className,\n data,\n window: windowSeconds = 30,\n paused = false,\n yDomain,\n domainDuration = 420,\n curve = 'monotone',\n maxPoints = 500,\n aspectRatio = 2,\n status = 'ready',\n momentumColors,\n color,\n onActivePointChange,\n accessibilityLabel,\n accessibilityHint,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activePoint, setActivePointState] = useState(null);\n const times = useSharedValue([]);\n const values = useSharedValue([]);\n const now = useSharedValue(Date.now());\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeTime = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n const [appState, setAppState] = useState(AppState.currentState ?? 'active');\n // Stripped of punctuation: `useId` returns something like `:r1:`, and a\n // colon inside a `url(#…)` reference does not resolve — which in RN SVG is\n // a clip that silently does nothing rather than an error.\n const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const windowMs = normalizeLiveLineWindow(windowSeconds) * 1000;\n const loading = status === 'loading';\n\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const semanticParts = useMemo(() => {\n let title: string | undefined;\n let value: string | undefined;\n let headerFormat: ((reading: number) => string) | undefined;\n let tipFormat: ((reading: number) => string) | undefined;\n let tooltipFormat: ((reading: number) => string) | undefined;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const part = (child.type as { displayName?: string }).displayName;\n if (part === 'LiveLineChart.Header') {\n const header = child.props as LiveLineChartHeaderProps;\n title ??= header.title;\n value ??= header.value;\n headerFormat ??= header.formatValue;\n } else if (part === 'LiveLineChart.Tip') {\n tipFormat ??= (child.props as LiveLineChartTipProps).formatValue;\n } else if (part === 'LiveLineChart.Tooltip') {\n tooltipFormat ??= (child.props as LiveLineChartTooltipProps).formatValue;\n }\n });\n return {\n title,\n value,\n formatLatest: headerFormat ?? tipFormat,\n formatActive: tooltipFormat ?? headerFormat ?? tipFormat,\n };\n }, [children]);\n\n const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * Trimmed here rather than by the caller. A live feed is written by whoever\n * owns the socket, and asking them to also bound the array is asking for\n * the one chart nobody bounded to be the one left running overnight.\n */\n const points = useMemo(\n () => normalizeLiveLinePoints(data, maxPoints),\n [data, maxPoints]\n );\n\n useEffect(() => {\n times.value = points.map((point) => point.time);\n values.value = points.map((point) => point.value);\n }, [points, times, values]);\n\n useEffect(() => {\n const subscription = AppState.addEventListener('change', setAppState);\n return () => subscription.remove();\n }, []);\n\n const liveLatest = points.length ? points[points.length - 1]! : null;\n\n /*\n * The extent of what is *visible*, not of everything kept: a spike that has\n * scrolled off the left edge should stop holding the axis open, or a feed\n * settles back to a flat line squeezed against the bottom of a plot scaled\n * for something that happened a minute ago.\n */\n const liveExtent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const from = (liveLatest?.time ?? Date.now()) - windowMs;\n let min = Infinity;\n let max = -Infinity;\n for (const point of points) {\n if (point.time < from) continue;\n if (point.value < min) min = point.value;\n if (point.value > max) max = point.value;\n }\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n const headroom = (max - min) * 0.12;\n return [min - headroom, max + headroom];\n }, [points, liveLatest, windowMs, yDomain]);\n\n /*\n * `paused` has to hold the whole picture still, not only the clock.\n *\n * Readings keep arriving while it is held — that is the point of holding it\n * — and both of these are derived from the newest one. Left live, the axis\n * goes on rescaling under a frozen line and the tip goes on chasing a\n * reading that is now off the right-hand edge, so a held chart carries on\n * moving in two of the three ways it can.\n */\n const held = useRef<{ extent: [number, number]; latest: LiveLinePoint | null } | null>(\n null\n );\n const heldMomentum = useRef('flat');\n if (!paused) held.current = { extent: liveExtent, latest: liveLatest };\n\n const extent = paused && held.current ? held.current.extent : liveExtent;\n const latest = paused && held.current ? held.current.latest : liveLatest;\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const frame = useFrameCallback(() => {\n 'worklet';\n now.value = Date.now();\n }, false);\n\n const running = liveLineClockRuns({ paused, loading, reducedMotion, appState });\n\n useEffect(() => {\n frame.setActive(running);\n return () => frame.setActive(false);\n }, [frame, running]);\n\n /*\n * The reduced-motion path, and the pause that ends: the window still has to\n * land on the present, it just does it when something arrives rather than\n * sixty times a second.\n */\n useEffect(() => {\n if (running) return;\n if (paused) return;\n now.value = Date.now();\n }, [running, paused, points, now]);\n\n // A frame callback is suspended with the app. Land on the wall clock as\n // soon as it becomes active instead of showing the backgrounded window for\n // one frame and relying on the platform to schedule a callback promptly.\n useEffect(() => {\n if (appState === 'active' && !paused) now.value = Date.now();\n }, [appState, paused, now]);\n\n useImperativeHandle(\n ref,\n () => ({\n sync: () => {\n now.value = Date.now();\n },\n }),\n [now]\n );\n\n const liveMomentum = useMemo(() => {\n if (points.length < 2) return 'flat';\n const recent = points.slice(-6);\n const span = Math.abs(liveExtent[1] - liveExtent[0]) || 1;\n const change = (recent[recent.length - 1]!.value - recent[0]!.value) / span;\n return change > 0.04 ? 'up' : change < -0.04 ? 'down' : 'flat';\n }, [points, liveExtent]);\n\n // Held with the rest of the picture: a frozen line changing colour under\n // readings that are not on it is the same bug wearing a different coat.\n if (!paused) heldMomentum.current = liveMomentum;\n const momentum = paused ? heldMomentum.current : liveMomentum;\n\n const defaultFormat = (reading: number) => compactNumber(reading);\n const semantic = liveLineAccessibility({\n name: accessibilityLabel ?? semanticParts.title,\n status,\n latest,\n activePoint,\n momentum,\n windowSeconds: windowMs / 1000,\n paused,\n now: Date.now(),\n valueOverride: semanticParts.value,\n formatLatest: semanticParts.formatLatest ?? defaultFormat,\n formatActive: semanticParts.formatActive ?? defaultFormat,\n });\n\n const base = useSeriesColor(color, 1);\n const successToken = useCSSVariable('--color-success');\n const destructiveToken = useCSSVariable('--color-destructive');\n const up =\n momentumColors?.up ?? (typeof successToken === 'string' ? successToken : '#10b981');\n const down =\n momentumColors?.down ??\n (typeof destructiveToken === 'string' ? destructiveToken : '#ef4444');\n const resolved = momentumColors\n ? momentum === 'up'\n ? up\n : momentum === 'down'\n ? down\n : (momentumColors.flat ?? base)\n : base;\n\n const setActivePoint = useMemo(\n () => (point: LiveLinePoint | null) => {\n setActivePointState(point);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n useEffect(() => {\n const next = reconcileLiveLineActivePoint(activePoint, points);\n if (next === activePoint) return;\n if (!next) activeTime.value = -1;\n setActivePoint(next);\n }, [activePoint, points, activeTime, setActivePoint]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n plot,\n status,\n curve,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n extent,\n activeTime,\n activePoint,\n setActivePoint,\n latest,\n momentum,\n color: resolved,\n clipId,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n extent,\n activeTime,\n activePoint,\n setActivePoint,\n latest,\n momentum,\n resolved,\n clipId,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n return (\n \n \n \n {header}\n \n {plot.width > 0 ? (\n <>\n \n \n {/*\n * The run starts one reading outside the window so the line\n * enters from the edge rather than starting inside the plot.\n * This is what keeps that overhang from being drawn.\n */}\n \n \n \n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nLiveLineChartRoot.displayName = 'LiveLineChart';\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'overlay';\n const bucket = layer === 'svg' ? svg : layer === 'header' ? header : overlay;\n bucket.push({child});\n });\n\n return { svg, overlay, header };\n}\n\nfunction Slot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nexport interface LiveLineChartGridProps {\n /** How many bands the plot is divided into. */\n rows?: number;\n color?: string;\n dashArray?: string;\n}\n\n/** The horizontal rules the readings are judged against. */\nfunction LiveLineChartGrid({ rows = 4, color, dashArray = '4,6' }: LiveLineChartGridProps) {\n const { plot } = useChart('LiveLineChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nLiveLineChartGrid.displayName = 'LiveLineChart.Grid';\nLiveLineChartGrid.layer = 'svg' as Layer;\n\nexport interface LiveLineChartLineProps {\n strokeWidth?: number;\n /** Overrides the chart's colour, momentum included. */\n color?: string;\n}\n\n/** The line itself, rebuilt on the UI thread every frame the window moves. */\nfunction LiveLineChartLine({ strokeWidth = 2, color }: LiveLineChartLineProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n curve,\n status,\n color: themed,\n clipId,\n } = useChart('LiveLineChart.Line');\n\n const animatedProps = useAnimatedProps(() => {\n const xMax = now.value;\n const run = runOf(\n times.value,\n values.value,\n plot,\n xMax - windowMs,\n xMax,\n domainMin.value,\n domainMax.value\n );\n return { d: run.length > 1 ? segment(run, curve) : '' };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nLiveLineChartLine.displayName = 'LiveLineChart.Line';\nLiveLineChartLine.layer = 'svg' as Layer;\n\nexport interface LiveLineChartAreaProps {\n /** Opacity at the top of the fill, fading to nothing at the baseline. */\n opacity?: number;\n /** Overrides the chart's colour, momentum included. */\n color?: string;\n}\n\n/**\n * The fill under the line.\n *\n * Its own part rather than a flag on the line, so a chart that wants the shape\n * without the weight of a filled band simply does not have one.\n */\nfunction LiveLineChartArea({ opacity = 0.22, color }: LiveLineChartAreaProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n curve,\n status,\n color: themed,\n clipId,\n } = useChart('LiveLineChart.Area');\n const gradientId = `panelui-live-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n const fill = color ?? themed;\n\n const animatedProps = useAnimatedProps(() => {\n const xMax = now.value;\n const run = runOf(\n times.value,\n values.value,\n plot,\n xMax - windowMs,\n xMax,\n domainMin.value,\n domainMax.value\n );\n if (run.length < 2) return { d: '' };\n\n const bottom = plot.top + plot.height;\n const first = run[0]!;\n const last = run[run.length - 1]!;\n return { d: `${segment(run, curve)} L${last.x},${bottom} L${first.x},${bottom} Z` };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n \n \n \n \n \n );\n}\nLiveLineChartArea.displayName = 'LiveLineChart.Area';\nLiveLineChartArea.layer = 'svg' as Layer;\n\nexport interface LiveLineChartTipProps {\n /**\n * Show the current reading in a badge beside the dot.\n *\n * Off by default. The badge is a floating card, which is the shape a reader\n * has learnt means \"you touched something\" — sitting there unasked it reads\n * as a tooltip nobody opened. Turn it on where the chart has no header to put\n * the reading in, and it becomes the only place the number is written.\n */\n badge?: boolean;\n /** Ring the dot with a repeating pulse. */\n pulse?: boolean;\n /** Format the badge. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The dot at the leading end, and what it currently reads.\n *\n * It rides the newest reading rather than the right-hand edge, so a feed that\n * stops is a dot drifting left with the rest of the line. Pinning it to the\n * edge would hold it still and steady, which is the picture of a feed that is\n * working.\n *\n * A view rather than an SVG node: it carries text and a pulse, and SVG text\n * ignores the platform's text scaling and the theme's font.\n */\nfunction LiveLineChartTip({\n badge = false,\n pulse = true,\n formatValue,\n className,\n}: LiveLineChartTipProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n status,\n color,\n latest,\n activePoint,\n } = useChart('LiveLineChart.Tip');\n const reducedMotion = useReducedMotion();\n const beat = useSharedValue(0);\n\n useEffect(() => {\n if (!pulse || reducedMotion || status === 'loading') {\n beat.value = 0;\n return;\n }\n beat.value = 0;\n beat.value = withRepeat(withTiming(1, { duration: 1600 }), -1, false);\n }, [pulse, reducedMotion, status, beat]);\n\n const tipStyle = useAnimatedStyle(() => {\n const count = times.value.length;\n if (!count) return { opacity: 0 };\n\n const xMax = now.value;\n const xMin = xMax - windowMs;\n\n // The newest reading *on the plot*, which is not the newest reading held: a\n // paused window keeps taking them in behind its right-hand edge, and the\n // line stops at the edge, so a tip tracking the array would walk away from\n // the end of the line it is supposed to be the end of.\n let index = -1;\n for (let i = count - 1; i >= 0; i -= 1) {\n if (times.value[i]! <= xMax) {\n index = i;\n break;\n }\n }\n if (index < 0) return { opacity: 0 };\n\n const time = times.value[index]!;\n // A feed that stopped longer ago than the window is wide has nothing left\n // on the plot. The line is clipped away at that point, and a dot left\n // hanging past the edge would be the only mark still claiming otherwise.\n if (time < xMin) return { opacity: 0 };\n\n const x = xAt(time, plot, xMin, xMax);\n const y = yOf(values.value[index]!, plot, domainMin.value, domainMax.value);\n return {\n opacity: 1,\n transform: [{ translateX: x - TIP / 2 }, { translateY: y - TIP / 2 }],\n };\n });\n\n const pulseStyle = useAnimatedStyle(() => ({\n opacity: (1 - beat.value) * 0.55,\n transform: [{ scale: 1 + beat.value * (PULSE - 1) }],\n }));\n\n if (status === 'loading' || !latest) return null;\n\n const format = formatValue ?? ((value: number) => compactNumber(value));\n\n return (\n \n {pulse ? (\n \n ) : null}\n \n {/*\n * Never while the crosshair is out. That readout is the reading being\n * asked for, and a second card a finger's width away answering a\n * different question is two answers to one gesture.\n */}\n {badge && !activePoint ? (\n \n \n {format(latest.value)}\n \n \n ) : null}\n \n );\n}\nLiveLineChartTip.displayName = 'LiveLineChart.Tip';\nLiveLineChartTip.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartXAxisProps {\n /** How many labels along the bottom. */\n ticks?: number;\n /** Rewrites a label. Given how many seconds back the tick is. */\n formatTick?: (secondsAgo: number) => string;\n className?: string;\n}\n\n/**\n * How far back the plot reaches, labelled along the bottom.\n *\n * The labels are offsets from now — `-30s`, `-15s`, `now` — rather than clock\n * times, and they never change. A moving window labelled with wall-clock times\n * would rewrite every one of them on every frame, which is a row of digits\n * churning under a chart that is trying to be read.\n */\nfunction LiveLineChartXAxis({ ticks = 4, formatTick, className }: LiveLineChartXAxisProps) {\n const { plot, windowMs, status } = useChart('LiveLineChart.XAxis');\n\n if (status === 'loading' || ticks < 1) return null;\n\n const seconds = windowMs / 1000;\n const format =\n formatTick ?? ((secondsAgo: number) => (secondsAgo <= 0 ? 'now' : `-${Math.round(secondsAgo)}s`));\n\n return (\n \n {Array.from({ length: ticks + 1 }, (_, index) => {\n const fraction = index / ticks;\n const x = plot.left + plot.width * fraction;\n return (\n \n \n {format(seconds * (1 - fraction))}\n \n \n );\n })}\n \n );\n}\nLiveLineChartXAxis.displayName = 'LiveLineChart.XAxis';\nLiveLineChartXAxis.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartYAxisProps {\n /** How many labels up the side. */\n ticks?: number;\n /** Format a value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The scale up the left-hand side.\n *\n * Declaring one widens the left gutter, which the root reads off the children\n * before anything is laid out — an axis given no room is drawn over the line,\n * which loses both the numbers and the shape they were there to explain.\n *\n * The labels follow the domain the tween is heading for rather than the tween\n * itself. A number re-rendered on every frame of a settle is thirty renders\n * landing on the string it started on.\n */\nfunction LiveLineChartYAxis({ ticks = 4, formatValue, className }: LiveLineChartYAxisProps) {\n const { plot, extent, status } = useChart('LiveLineChart.YAxis');\n\n if (status === 'loading' || ticks < 1) return null;\n\n const [min, max] = extent;\n const format = formatValue ?? ((value: number) => compactNumber(value));\n\n return (\n \n {Array.from({ length: ticks + 1 }, (_, index) => {\n const fraction = index / ticks;\n const y = plot.top + plot.height * fraction;\n return (\n \n \n {format(max - (max - min) * fraction)}\n \n \n );\n })}\n \n );\n}\nLiveLineChartYAxis.displayName = 'LiveLineChart.YAxis';\nLiveLineChartYAxis.layer = 'overlay' as Layer;\nLiveLineChartYAxis.axis = 'y' as const;\n\nexport interface LiveLineChartTooltipProps {\n /** Format the value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Drag back through the window to read a reading that has already gone past.\n *\n * The crosshair is pinned to the *moment* it was put on rather than to the\n * place on screen, so it travels left with the reading it named instead of\n * sitting still while the line slides out from under it.\n */\nfunction LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n activeTime,\n activePoint,\n setActivePoint,\n status,\n color,\n } = useChart('LiveLineChart.Tooltip');\n\n const report = useMemo(\n () => (time: number, value: number) => {\n setActivePoint(Number.isFinite(time) && time > 0 ? { time, value } : null);\n },\n [setActivePoint]\n );\n\n useEffect(\n () => () => {\n activeTime.value = -1;\n setActivePoint(null);\n },\n [activeTime, setActivePoint]\n );\n\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n const count = times.value.length;\n if (!count || plot.width <= 0) return;\n\n const xMax = now.value;\n const xMin = xMax - windowMs;\n const clamped = Math.min(Math.max(x, plot.left), plot.left + plot.width);\n const at = xMin + ((clamped - plot.left) / plot.width) * windowMs;\n\n // The nearest reading to the moment touched, which on a feed with gaps in\n // it is not the same as the one nearest in screen distance.\n let best = 0;\n let bestGap = Infinity;\n for (let i = 0; i < count; i += 1) {\n const gap = Math.abs(times.value[i]! - at);\n if (gap < bestGap) {\n bestGap = gap;\n best = i;\n }\n }\n\n const time = times.value[best]!;\n if (time === activeTime.value) return;\n activeTime.value = time;\n runOnJS(report)(time, values.value[best]!);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeTime.value = -1;\n runOnJS(report)(-1, 0);\n });\n }, [times, values, now, windowMs, plot, activeTime, report]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n if (time < 0) return { opacity: 0 };\n const xMax = now.value;\n return {\n opacity: 0.45,\n transform: [{ translateX: xAt(time, plot, xMax - windowMs, xMax) }],\n };\n });\n\n const dotStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n const count = times.value.length;\n if (time < 0 || !count) return { opacity: 0 };\n\n // The reading the crosshair named can be trimmed away under it on a long\n // enough feed. Nothing to point at then, rather than the oldest one.\n let best = -1;\n for (let i = 0; i < count; i += 1) {\n if (times.value[i] === time) {\n best = i;\n break;\n }\n }\n if (best < 0) return { opacity: 0 };\n\n const xMax = now.value;\n return {\n opacity: 1,\n transform: [\n { translateX: xAt(time, plot, xMax - windowMs, xMax) - TIP / 2 },\n { translateY: yOf(values.value[best]!, plot, domainMin.value, domainMax.value) - TIP / 2 },\n ],\n };\n });\n\n const readoutStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n if (time < 0) return { opacity: 0 };\n const xMax = now.value;\n const x = xAt(time, plot, xMax - windowMs, xMax);\n const half = READOUT_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const format = formatValue ?? ((value: number) => compactNumber(value));\n const secondsAgo = activePoint ? Math.max(0, Math.round((Date.now() - activePoint.time) / 1000)) : 0;\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n {activePoint ? (\n \n \n {format(activePoint.value)}\n \n \n {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}\n \n \n ) : null}\n \n \n );\n}\nLiveLineChartTooltip.displayName = 'LiveLineChart.Tooltip';\nLiveLineChartTooltip.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartSkeletonProps {\n color?: string;\n}\n\n/**\n * The waiting state: a flat line down the middle of the plot.\n *\n * The shape the real line grows out of once readings arrive, rather than a\n * placeholder series — an invented run of readings is indistinguishable from a\n * real one until it changes under the reader.\n */\nfunction LiveLineChartSkeleton({ color }: LiveLineChartSkeletonProps) {\n const { plot, status } = useChart('LiveLineChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n if (status !== 'loading' || plot.width <= 0) return null;\n\n const y = plot.top + plot.height / 2;\n\n return (\n \n );\n}\nLiveLineChartSkeleton.displayName = 'LiveLineChart.Skeleton';\nLiveLineChartSkeleton.layer = 'svg' as Layer;\n\nexport interface LiveLineChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what is being watched. */\n title?: string;\n /** The readout. Left out, it shows the current reading. */\n value?: string;\n /** One muted line under the value. */\n caption?: string;\n /** Format the derived value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n /** Trailing slot — a pause control, a badge, a unit. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what is being watched and what it currently reads.\n *\n * The value falls back to the reading under the crosshair, then to the latest\n * one, so a drag back through the window reads out here without the caller\n * wiring anything up.\n */\nfunction LiveLineChartHeader({\n className,\n title,\n value,\n caption,\n formatValue,\n children,\n ...props\n}: LiveLineChartHeaderProps) {\n const { activePoint, latest } = useChart('LiveLineChart.Header');\n const format = formatValue ?? ((reading: number) => compactNumber(reading));\n const shown = activePoint ?? latest;\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n \n {value ?? (shown ? format(shown.value) : '—')}\n \n {caption ? (\n \n {caption}\n \n ) : null}\n \n {children ? {children} : null}\n \n );\n}\nLiveLineChartHeader.displayName = 'LiveLineChart.Header';\nLiveLineChartHeader.layer = 'header' as Layer;\n\nexport const LiveLineChart = Object.assign(LiveLineChartRoot, {\n Header: LiveLineChartHeader,\n Grid: LiveLineChartGrid,\n Area: LiveLineChartArea,\n Line: LiveLineChartLine,\n Tip: LiveLineChartTip,\n XAxis: LiveLineChartXAxis,\n YAxis: LiveLineChartYAxis,\n Tooltip: LiveLineChartTooltip,\n Skeleton: LiveLineChartSkeleton,\n});\n" + "content": "/**\n * LiveLineChart — a reading that keeps arriving, against a window that keeps\n * moving.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What it is, against the line chart beside it\n *\n * `LineChart` places a point by its position in the list, which is right for a\n * series of twelve months whatever the gaps between the dates behind them. Here\n * a point is placed at the time it carries, against a domain that runs from\n * `window` seconds ago to now — so the gaps are the subject, and a reading that\n * arrived late sits where it arrived rather than one slot along.\n *\n * ## The clock, and what it costs\n *\n * This is the only thing in the library that animates without an interaction or\n * a change of data. The window is tied to the wall clock, so the line drifts\n * left whether or not anything is arriving, and a feed that stalls shows as a\n * flat run reaching back from the tip rather than as a chart that has frozen.\n * Those two look identical if the window only moves when a point lands, and\n * they mean opposite things.\n *\n * The cost is a frame callback for as long as the chart is mounted. It is\n * stopped by `paused`, by `status=\"loading\"`, while the app is backgrounded\n * and on unmount, and it is never started when the platform asks for reduced\n * motion — in that case the window advances as each point arrives instead,\n * which is the same picture sampled less often.\n *\n * Screen readers receive one image-role snapshot: its name, current or\n * selected value, direction, time window and paused state. It changes when the\n * React data changes, never on the UI-thread clock frame, and requests no live\n * announcement. The visual axes, badges and tooltip repeat that snapshot and\n * stay out of the accessibility tree; controls placed in Header remain normal\n * controls.\n *\n * ## Colour follows the recent direction\n *\n * With `momentumColors` set, the line, the fill and the tip take their colour\n * from where the reading has been going rather than from a fixed hue. It is the\n * one thing on the chart readable without looking at the axis, which is what a\n * number being watched out of the corner of an eye needs.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n AppState,\n StyleSheet,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useFrameCallback,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n ClipPath,\n Defs,\n G,\n LinearGradient,\n Path,\n Rect,\n Stop,\n Line as SvgLine,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { CHART_AXIS_MAX_FONT_SCALE } from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n compactNumber,\n segment,\n useSeriesColor,\n xAt,\n yOf,\n type ChartCurve,\n type ChartPoint,\n type Plot,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { liveLineAccessibility } from '@/components/ui/live-line-accessibility';\nimport {\n liveLineClockRuns,\n normalizeLiveLinePoints,\n normalizeLiveLineWindow,\n reconcileLiveLineActivePoint,\n} from '@/components/ui/live-line-lifecycle';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 14, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Diameter of the dot at the leading end of the line. */\nconst TIP = 8;\n\n/** How far the pulse ring grows past the dot. */\nconst PULSE = 3.2;\n\n/** Line height of an `xs` label, for centring one on the line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\nconst READOUT_WIDTH = 116;\n\n/** Which layer a part belongs to. Read off the component itself. */\ntype Layer = 'svg' | 'overlay' | 'header';\n\n/** Whether the chart is showing data or waiting for it. */\nexport type LiveLineChartStatus = 'loading' | 'ready';\n\n/** Where the reading has been going, over the last few points. */\nexport type LiveLineMomentum = 'up' | 'down' | 'flat';\n\n/** A colour per direction, for a chart that is read by its colour. */\nexport interface LiveLineMomentumColors {\n up?: string;\n down?: string;\n flat?: string;\n}\n\n/** One reading. `time` is a timestamp in milliseconds, as `Date.now()` gives. */\nexport interface LiveLinePoint {\n time: number;\n value: number;\n}\n\ninterface LiveLineChartContextValue {\n plot: Plot;\n status: LiveLineChartStatus;\n curve: ChartCurve;\n /** Timestamps and readings, as two number arrays — cheaper to hand the UI\n * thread than an array of objects, and every worklet here walks them in step. */\n times: SharedValue;\n values: SharedValue;\n /** The right-hand edge of the window, in milliseconds. */\n now: SharedValue;\n /** How much time the plot spans, in milliseconds. */\n windowMs: number;\n /** Tweened y-domain. Read inside worklets to build the paths. */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** What the tween is heading for. The axis labels read this rather than the\n * shared values, which would re-render a label on every frame of a tween. */\n extent: [number, number];\n /** The moment under the finger, or -1. Pinned to a time rather than to a\n * position, so the crosshair travels with the reading it named. */\n activeTime: SharedValue;\n activePoint: LiveLinePoint | null;\n setActivePoint: (point: LiveLinePoint | null) => void;\n /** The latest reading, for anything drawing the leading end. */\n latest: LiveLinePoint | null;\n momentum: LiveLineMomentum;\n color: string;\n clipId: string;\n}\n\nconst LiveLineChartContext = createContext(null);\n\nfunction useChart(component: string): LiveLineChartContextValue {\n const context = useContext(LiveLineChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The reading under the crosshair, for something rendered inside the chart. */\nexport function useLiveLineChart() {\n const { activePoint, latest, momentum, color } = useChart('useLiveLineChart');\n return { activePoint, latest, momentum, color };\n}\n\n/**\n * The visible run of readings, as coordinates.\n *\n * It starts one point *before* the window rather than at the first one inside\n * it, so the line enters from the left edge instead of beginning wherever the\n * oldest surviving reading happens to sit. The plot is clipped, so the part\n * hanging off the edge is never drawn.\n */\nfunction runOf(\n times: number[],\n values: number[],\n plot: Plot,\n xMin: number,\n xMax: number,\n yMin: number,\n yMax: number\n): ChartPoint[] {\n 'worklet';\n const out: ChartPoint[] = [];\n const count = times.length;\n if (!count) return out;\n\n let start = 0;\n for (let i = 0; i < count; i += 1) {\n if (times[i]! >= xMin) break;\n start = i;\n }\n\n for (let i = start; i < count; i += 1) {\n const time = times[i]!;\n if (time > xMax) break;\n out.push({\n x: xAt(time, plot, xMin, xMax),\n y: yOf(values[i]!, plot, yMin, yMax),\n });\n }\n return out;\n}\n\nexport interface LiveLineChartProps extends ViewProps {\n className?: string;\n /**\n * Names the chart's single screen-reader snapshot. Falls back to the Header\n * title, then to \"Live line chart\".\n */\n accessibilityLabel?: string;\n /** Additional guidance after the snapshot. No gesture is invented for it. */\n accessibilityHint?: string;\n /** The readings so far. Invalid values are dropped and timestamps are ordered. */\n data: LiveLinePoint[];\n /** How much time the plot spans, in seconds. Invalid values use 30. */\n window?: number;\n /** Freeze the window where it is. The readings still arrive; the clock stops. */\n paused?: boolean;\n /** Fix the y-axis instead of deriving it from what is visible. */\n yDomain?: [number, number];\n /** Milliseconds for the y-axis to settle after the range changes. */\n domainDuration?: number;\n /** `monotone` never overshoots between readings; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * The most readings kept. Older ones are dropped, since they are off the\n * window and cannot come back — an unbounded feed otherwise grows an array\n * for as long as the screen is open. Must be positive and finite.\n */\n maxPoints?: number;\n /** Width ÷ height of the plot. */\n aspectRatio?: number;\n /** `loading` draws a flat placeholder and holds the clock. */\n status?: LiveLineChartStatus;\n /** Colour per direction. Left out, the chart draws in one hue throughout. */\n momentumColors?: LiveLineMomentumColors;\n /** Overrides the `--color-chart-1` token. Ignored when `momentumColors` is set. */\n color?: string;\n /** The reading under the crosshair as it moves, and `null` when the finger lifts. */\n onActivePointChange?: (point: LiveLinePoint | null) => void;\n children?: ReactNode;\n}\n\nexport interface LiveLineChartHandle {\n /** Jump the window to the current moment — after a pause, or a background. */\n sync: () => void;\n}\n\nconst LiveLineChartRoot = forwardRef(\n function LiveLineChartRoot(\n {\n className,\n data,\n window: windowSeconds = 30,\n paused = false,\n yDomain,\n domainDuration = 420,\n curve = 'monotone',\n maxPoints = 500,\n aspectRatio = 2,\n status = 'ready',\n momentumColors,\n color,\n onActivePointChange,\n accessibilityLabel,\n accessibilityHint,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activePoint, setActivePointState] = useState(null);\n const times = useSharedValue([]);\n const values = useSharedValue([]);\n const now = useSharedValue(Date.now());\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeTime = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n const [appState, setAppState] = useState(AppState.currentState ?? 'active');\n // Stripped of punctuation: `useId` returns something like `:r1:`, and a\n // colon inside a `url(#…)` reference does not resolve — which in RN SVG is\n // a clip that silently does nothing rather than an error.\n const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const windowMs = normalizeLiveLineWindow(windowSeconds) * 1000;\n const loading = status === 'loading';\n\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const semanticParts = useMemo(() => {\n let title: string | undefined;\n let value: string | undefined;\n let headerFormat: ((reading: number) => string) | undefined;\n let tipFormat: ((reading: number) => string) | undefined;\n let tooltipFormat: ((reading: number) => string) | undefined;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const part = (child.type as { displayName?: string }).displayName;\n if (part === 'LiveLineChart.Header') {\n const header = child.props as LiveLineChartHeaderProps;\n title ??= header.title;\n value ??= header.value;\n headerFormat ??= header.formatValue;\n } else if (part === 'LiveLineChart.Tip') {\n tipFormat ??= (child.props as LiveLineChartTipProps).formatValue;\n } else if (part === 'LiveLineChart.Tooltip') {\n tooltipFormat ??= (child.props as LiveLineChartTooltipProps).formatValue;\n }\n });\n return {\n title,\n value,\n formatLatest: headerFormat ?? tipFormat,\n formatActive: tooltipFormat ?? headerFormat ?? tipFormat,\n };\n }, [children]);\n\n const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * Trimmed here rather than by the caller. A live feed is written by whoever\n * owns the socket, and asking them to also bound the array is asking for\n * the one chart nobody bounded to be the one left running overnight.\n */\n const points = useMemo(\n () => normalizeLiveLinePoints(data, maxPoints),\n [data, maxPoints]\n );\n\n useEffect(() => {\n times.value = points.map((point) => point.time);\n values.value = points.map((point) => point.value);\n }, [points, times, values]);\n\n useEffect(() => {\n const subscription = AppState.addEventListener('change', setAppState);\n return () => subscription.remove();\n }, []);\n\n const liveLatest = points.length ? points[points.length - 1]! : null;\n\n /*\n * The extent of what is *visible*, not of everything kept: a spike that has\n * scrolled off the left edge should stop holding the axis open, or a feed\n * settles back to a flat line squeezed against the bottom of a plot scaled\n * for something that happened a minute ago.\n */\n const liveExtent = useMemo<[number, number]>(() => {\n if (yDomain) return yDomain;\n const from = (liveLatest?.time ?? Date.now()) - windowMs;\n let min = Infinity;\n let max = -Infinity;\n for (const point of points) {\n if (point.time < from) continue;\n if (point.value < min) min = point.value;\n if (point.value > max) max = point.value;\n }\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n const headroom = (max - min) * 0.12;\n return [min - headroom, max + headroom];\n }, [points, liveLatest, windowMs, yDomain]);\n\n /*\n * `paused` has to hold the whole picture still, not only the clock.\n *\n * Readings keep arriving while it is held — that is the point of holding it\n * — and both of these are derived from the newest one. Left live, the axis\n * goes on rescaling under a frozen line and the tip goes on chasing a\n * reading that is now off the right-hand edge, so a held chart carries on\n * moving in two of the three ways it can.\n */\n const held = useRef<{ extent: [number, number]; latest: LiveLinePoint | null } | null>(\n null\n );\n const heldMomentum = useRef('flat');\n if (!paused) held.current = { extent: liveExtent, latest: liveLatest };\n\n const extent = paused && held.current ? held.current.extent : liveExtent;\n const latest = paused && held.current ? held.current.latest : liveLatest;\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const frame = useFrameCallback(() => {\n 'worklet';\n now.value = Date.now();\n }, false);\n\n const running = liveLineClockRuns({ paused, loading, reducedMotion, appState });\n\n useEffect(() => {\n frame.setActive(running);\n return () => frame.setActive(false);\n }, [frame, running]);\n\n /*\n * The reduced-motion path, and the pause that ends: the window still has to\n * land on the present, it just does it when something arrives rather than\n * sixty times a second.\n */\n useEffect(() => {\n if (running) return;\n if (paused) return;\n now.value = Date.now();\n }, [running, paused, points, now]);\n\n // A frame callback is suspended with the app. Land on the wall clock as\n // soon as it becomes active instead of showing the backgrounded window for\n // one frame and relying on the platform to schedule a callback promptly.\n useEffect(() => {\n if (appState === 'active' && !paused) now.value = Date.now();\n }, [appState, paused, now]);\n\n useImperativeHandle(\n ref,\n () => ({\n sync: () => {\n now.value = Date.now();\n },\n }),\n [now]\n );\n\n const liveMomentum = useMemo(() => {\n if (points.length < 2) return 'flat';\n const recent = points.slice(-6);\n const span = Math.abs(liveExtent[1] - liveExtent[0]) || 1;\n const change = (recent[recent.length - 1]!.value - recent[0]!.value) / span;\n return change > 0.04 ? 'up' : change < -0.04 ? 'down' : 'flat';\n }, [points, liveExtent]);\n\n // Held with the rest of the picture: a frozen line changing colour under\n // readings that are not on it is the same bug wearing a different coat.\n if (!paused) heldMomentum.current = liveMomentum;\n const momentum = paused ? heldMomentum.current : liveMomentum;\n\n const defaultFormat = (reading: number) => compactNumber(reading);\n const semantic = liveLineAccessibility({\n name: accessibilityLabel ?? semanticParts.title,\n status,\n latest,\n activePoint,\n momentum,\n windowSeconds: windowMs / 1000,\n paused,\n now: Date.now(),\n valueOverride: semanticParts.value,\n formatLatest: semanticParts.formatLatest ?? defaultFormat,\n formatActive: semanticParts.formatActive ?? defaultFormat,\n });\n\n const base = useSeriesColor(color, 1);\n const successToken = useCSSVariable('--color-success');\n const destructiveToken = useCSSVariable('--color-destructive');\n const up =\n momentumColors?.up ?? (typeof successToken === 'string' ? successToken : '#10b981');\n const down =\n momentumColors?.down ??\n (typeof destructiveToken === 'string' ? destructiveToken : '#ef4444');\n const resolved = momentumColors\n ? momentum === 'up'\n ? up\n : momentum === 'down'\n ? down\n : (momentumColors.flat ?? base)\n : base;\n\n const setActivePoint = useMemo(\n () => (point: LiveLinePoint | null) => {\n setActivePointState(point);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n useEffect(() => {\n const next = reconcileLiveLineActivePoint(activePoint, points);\n if (next === activePoint) return;\n if (!next) activeTime.value = -1;\n setActivePoint(next);\n }, [activePoint, points, activeTime, setActivePoint]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n plot,\n status,\n curve,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n extent,\n activeTime,\n activePoint,\n setActivePoint,\n latest,\n momentum,\n color: resolved,\n clipId,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n curve,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n extent,\n activeTime,\n activePoint,\n setActivePoint,\n latest,\n momentum,\n resolved,\n clipId,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n return (\n \n \n \n {header}\n \n {plot.width > 0 ? (\n <>\n \n \n {/*\n * The run starts one reading outside the window so the line\n * enters from the edge rather than starting inside the plot.\n * This is what keeps that overhang from being drawn.\n */}\n \n \n \n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nLiveLineChartRoot.displayName = 'LiveLineChart';\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'overlay';\n const bucket = layer === 'svg' ? svg : layer === 'header' ? header : overlay;\n bucket.push({child});\n });\n\n return { svg, overlay, header };\n}\n\nfunction Slot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nexport interface LiveLineChartGridProps {\n /** How many bands the plot is divided into. */\n rows?: number;\n color?: string;\n dashArray?: string;\n}\n\n/** The horizontal rules the readings are judged against. */\nfunction LiveLineChartGrid({ rows = 4, color, dashArray = '4,6' }: LiveLineChartGridProps) {\n const { plot } = useChart('LiveLineChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nLiveLineChartGrid.displayName = 'LiveLineChart.Grid';\nLiveLineChartGrid.layer = 'svg' as Layer;\n\nexport interface LiveLineChartLineProps {\n strokeWidth?: number;\n /** Overrides the chart's colour, momentum included. */\n color?: string;\n}\n\n/** The line itself, rebuilt on the UI thread every frame the window moves. */\nfunction LiveLineChartLine({ strokeWidth = 2, color }: LiveLineChartLineProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n curve,\n status,\n color: themed,\n clipId,\n } = useChart('LiveLineChart.Line');\n\n const animatedProps = useAnimatedProps(() => {\n const xMax = now.value;\n const run = runOf(\n times.value,\n values.value,\n plot,\n xMax - windowMs,\n xMax,\n domainMin.value,\n domainMax.value\n );\n return { d: run.length > 1 ? segment(run, curve) : '' };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nLiveLineChartLine.displayName = 'LiveLineChart.Line';\nLiveLineChartLine.layer = 'svg' as Layer;\n\nexport interface LiveLineChartAreaProps {\n /** Opacity at the top of the fill, fading to nothing at the baseline. */\n opacity?: number;\n /** Overrides the chart's colour, momentum included. */\n color?: string;\n}\n\n/**\n * The fill under the line.\n *\n * Its own part rather than a flag on the line, so a chart that wants the shape\n * without the weight of a filled band simply does not have one.\n */\nfunction LiveLineChartArea({ opacity = 0.22, color }: LiveLineChartAreaProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n curve,\n status,\n color: themed,\n clipId,\n } = useChart('LiveLineChart.Area');\n const gradientId = `panelui-live-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n const fill = color ?? themed;\n\n const animatedProps = useAnimatedProps(() => {\n const xMax = now.value;\n const run = runOf(\n times.value,\n values.value,\n plot,\n xMax - windowMs,\n xMax,\n domainMin.value,\n domainMax.value\n );\n if (run.length < 2) return { d: '' };\n\n const bottom = plot.top + plot.height;\n const first = run[0]!;\n const last = run[run.length - 1]!;\n return { d: `${segment(run, curve)} L${last.x},${bottom} L${first.x},${bottom} Z` };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n \n \n \n \n \n );\n}\nLiveLineChartArea.displayName = 'LiveLineChart.Area';\nLiveLineChartArea.layer = 'svg' as Layer;\n\nexport interface LiveLineChartTipProps {\n /**\n * Show the current reading in a badge beside the dot.\n *\n * Off by default. The badge is a floating card, which is the shape a reader\n * has learnt means \"you touched something\" — sitting there unasked it reads\n * as a tooltip nobody opened. Turn it on where the chart has no header to put\n * the reading in, and it becomes the only place the number is written.\n */\n badge?: boolean;\n /** Ring the dot with a repeating pulse. */\n pulse?: boolean;\n /** Format the badge. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The dot at the leading end, and what it currently reads.\n *\n * It rides the newest reading rather than the right-hand edge, so a feed that\n * stops is a dot drifting left with the rest of the line. Pinning it to the\n * edge would hold it still and steady, which is the picture of a feed that is\n * working.\n *\n * A view rather than an SVG node: it carries text and a pulse, and SVG text\n * ignores the platform's text scaling and the theme's font.\n */\nfunction LiveLineChartTip({\n badge = false,\n pulse = true,\n formatValue,\n className,\n}: LiveLineChartTipProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n status,\n color,\n latest,\n activePoint,\n } = useChart('LiveLineChart.Tip');\n const reducedMotion = useReducedMotion();\n const beat = useSharedValue(0);\n\n useEffect(() => {\n if (!pulse || reducedMotion || status === 'loading') {\n beat.value = 0;\n return;\n }\n beat.value = 0;\n beat.value = withRepeat(withTiming(1, { duration: 1600 }), -1, false);\n }, [pulse, reducedMotion, status, beat]);\n\n const tipStyle = useAnimatedStyle(() => {\n const count = times.value.length;\n if (!count) return { opacity: 0 };\n\n const xMax = now.value;\n const xMin = xMax - windowMs;\n\n // The newest reading *on the plot*, which is not the newest reading held: a\n // paused window keeps taking them in behind its right-hand edge, and the\n // line stops at the edge, so a tip tracking the array would walk away from\n // the end of the line it is supposed to be the end of.\n let index = -1;\n for (let i = count - 1; i >= 0; i -= 1) {\n if (times.value[i]! <= xMax) {\n index = i;\n break;\n }\n }\n if (index < 0) return { opacity: 0 };\n\n const time = times.value[index]!;\n // A feed that stopped longer ago than the window is wide has nothing left\n // on the plot. The line is clipped away at that point, and a dot left\n // hanging past the edge would be the only mark still claiming otherwise.\n if (time < xMin) return { opacity: 0 };\n\n const x = xAt(time, plot, xMin, xMax);\n const y = yOf(values.value[index]!, plot, domainMin.value, domainMax.value);\n return {\n opacity: 1,\n transform: [{ translateX: x - TIP / 2 }, { translateY: y - TIP / 2 }],\n };\n });\n\n const pulseStyle = useAnimatedStyle(() => ({\n opacity: (1 - beat.value) * 0.55,\n transform: [{ scale: 1 + beat.value * (PULSE - 1) }],\n }));\n\n if (status === 'loading' || !latest) return null;\n\n const format = formatValue ?? ((value: number) => compactNumber(value));\n\n return (\n \n {pulse ? (\n \n ) : null}\n \n {/*\n * Never while the crosshair is out. That readout is the reading being\n * asked for, and a second card a finger's width away answering a\n * different question is two answers to one gesture.\n */}\n {badge && !activePoint ? (\n \n \n {format(latest.value)}\n \n \n ) : null}\n \n );\n}\nLiveLineChartTip.displayName = 'LiveLineChart.Tip';\nLiveLineChartTip.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartXAxisProps {\n /** How many labels along the bottom. */\n ticks?: number;\n /** Rewrites a label. Given how many seconds back the tick is. */\n formatTick?: (secondsAgo: number) => string;\n className?: string;\n}\n\n/**\n * How far back the plot reaches, labelled along the bottom.\n *\n * The labels are offsets from now — `-30s`, `-15s`, `now` — rather than clock\n * times, and they never change. A moving window labelled with wall-clock times\n * would rewrite every one of them on every frame, which is a row of digits\n * churning under a chart that is trying to be read.\n */\nfunction LiveLineChartXAxis({ ticks = 4, formatTick, className }: LiveLineChartXAxisProps) {\n const { plot, windowMs, status } = useChart('LiveLineChart.XAxis');\n\n if (status === 'loading' || ticks < 1) return null;\n\n const seconds = windowMs / 1000;\n const format =\n formatTick ?? ((secondsAgo: number) => (secondsAgo <= 0 ? 'now' : `-${Math.round(secondsAgo)}s`));\n\n return (\n \n {Array.from({ length: ticks + 1 }, (_, index) => {\n const fraction = index / ticks;\n const x = plot.left + plot.width * fraction;\n return (\n \n \n {format(seconds * (1 - fraction))}\n \n \n );\n })}\n \n );\n}\nLiveLineChartXAxis.displayName = 'LiveLineChart.XAxis';\nLiveLineChartXAxis.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartYAxisProps {\n /** How many labels up the side. */\n ticks?: number;\n /** Format a value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The scale up the left-hand side.\n *\n * Declaring one widens the left gutter, which the root reads off the children\n * before anything is laid out — an axis given no room is drawn over the line,\n * which loses both the numbers and the shape they were there to explain.\n *\n * The labels follow the domain the tween is heading for rather than the tween\n * itself. A number re-rendered on every frame of a settle is thirty renders\n * landing on the string it started on.\n */\nfunction LiveLineChartYAxis({ ticks = 4, formatValue, className }: LiveLineChartYAxisProps) {\n const { plot, extent, status } = useChart('LiveLineChart.YAxis');\n\n if (status === 'loading' || ticks < 1) return null;\n\n const [min, max] = extent;\n const format = formatValue ?? ((value: number) => compactNumber(value));\n\n return (\n \n {Array.from({ length: ticks + 1 }, (_, index) => {\n const fraction = index / ticks;\n const y = plot.top + plot.height * fraction;\n return (\n \n \n {format(max - (max - min) * fraction)}\n \n \n );\n })}\n \n );\n}\nLiveLineChartYAxis.displayName = 'LiveLineChart.YAxis';\nLiveLineChartYAxis.layer = 'overlay' as Layer;\nLiveLineChartYAxis.axis = 'y' as const;\n\nexport interface LiveLineChartTooltipProps {\n /** Format the value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Drag back through the window to read a reading that has already gone past.\n *\n * The crosshair is pinned to the *moment* it was put on rather than to the\n * place on screen, so it travels left with the reading it named instead of\n * sitting still while the line slides out from under it.\n */\nfunction LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipProps) {\n const {\n plot,\n times,\n values,\n now,\n windowMs,\n domainMin,\n domainMax,\n activeTime,\n activePoint,\n setActivePoint,\n status,\n color,\n } = useChart('LiveLineChart.Tooltip');\n\n const report = useMemo(\n () => (time: number, value: number) => {\n setActivePoint(Number.isFinite(time) && time > 0 ? { time, value } : null);\n },\n [setActivePoint]\n );\n\n useEffect(\n () => () => {\n activeTime.value = -1;\n setActivePoint(null);\n },\n [activeTime, setActivePoint]\n );\n\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n const count = times.value.length;\n if (!count || plot.width <= 0) return;\n\n const xMax = now.value;\n const xMin = xMax - windowMs;\n const clamped = Math.min(Math.max(x, plot.left), plot.left + plot.width);\n const at = xMin + ((clamped - plot.left) / plot.width) * windowMs;\n\n // The nearest reading to the moment touched, which on a feed with gaps in\n // it is not the same as the one nearest in screen distance.\n let best = 0;\n let bestGap = Infinity;\n for (let i = 0; i < count; i += 1) {\n const gap = Math.abs(times.value[i]! - at);\n if (gap < bestGap) {\n bestGap = gap;\n best = i;\n }\n }\n\n const time = times.value[best]!;\n if (time === activeTime.value) return;\n activeTime.value = time;\n runOnJS(report)(time, values.value[best]!);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeTime.value = -1;\n runOnJS(report)(-1, 0);\n });\n }, [times, values, now, windowMs, plot, activeTime, report]);\n\n const crosshairStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n if (time < 0) return { opacity: 0 };\n const xMax = now.value;\n return {\n opacity: 0.45,\n transform: [{ translateX: xAt(time, plot, xMax - windowMs, xMax) }],\n };\n });\n\n const dotStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n const count = times.value.length;\n if (time < 0 || !count) return { opacity: 0 };\n\n // The reading the crosshair named can be trimmed away under it on a long\n // enough feed. Nothing to point at then, rather than the oldest one.\n let best = -1;\n for (let i = 0; i < count; i += 1) {\n if (times.value[i] === time) {\n best = i;\n break;\n }\n }\n if (best < 0) return { opacity: 0 };\n\n const xMax = now.value;\n return {\n opacity: 1,\n transform: [\n { translateX: xAt(time, plot, xMax - windowMs, xMax) - TIP / 2 },\n { translateY: yOf(values.value[best]!, plot, domainMin.value, domainMax.value) - TIP / 2 },\n ],\n };\n });\n\n const readoutStyle = useAnimatedStyle(() => {\n const time = activeTime.value;\n if (time < 0) return { opacity: 0 };\n const xMax = now.value;\n const x = xAt(time, plot, xMax - windowMs, xMax);\n const half = READOUT_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const format = formatValue ?? ((value: number) => compactNumber(value));\n const secondsAgo = activePoint ? Math.max(0, Math.round((Date.now() - activePoint.time) / 1000)) : 0;\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n {activePoint ? (\n \n \n {format(activePoint.value)}\n \n \n {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}\n \n \n ) : null}\n \n \n );\n}\nLiveLineChartTooltip.displayName = 'LiveLineChart.Tooltip';\nLiveLineChartTooltip.layer = 'overlay' as Layer;\n\nexport interface LiveLineChartSkeletonProps {\n color?: string;\n}\n\n/**\n * The waiting state: a flat line down the middle of the plot.\n *\n * The shape the real line grows out of once readings arrive, rather than a\n * placeholder series — an invented run of readings is indistinguishable from a\n * real one until it changes under the reader.\n */\nfunction LiveLineChartSkeleton({ color }: LiveLineChartSkeletonProps) {\n const { plot, status } = useChart('LiveLineChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n if (status !== 'loading' || plot.width <= 0) return null;\n\n const y = plot.top + plot.height / 2;\n\n return (\n \n );\n}\nLiveLineChartSkeleton.displayName = 'LiveLineChart.Skeleton';\nLiveLineChartSkeleton.layer = 'svg' as Layer;\n\nexport interface LiveLineChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what is being watched. */\n title?: string;\n /** The readout. Left out, it shows the current reading. */\n value?: string;\n /** One muted line under the value. */\n caption?: string;\n /** Format the derived value. Defaults to a compact number. */\n formatValue?: (value: number) => string;\n /** Trailing slot — a pause control, a badge, a unit. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what is being watched and what it currently reads.\n *\n * The value falls back to the reading under the crosshair, then to the latest\n * one, so a drag back through the window reads out here without the caller\n * wiring anything up.\n */\nfunction LiveLineChartHeader({\n className,\n title,\n value,\n caption,\n formatValue,\n children,\n ...props\n}: LiveLineChartHeaderProps) {\n const { activePoint, latest } = useChart('LiveLineChart.Header');\n const format = formatValue ?? ((reading: number) => compactNumber(reading));\n const shown = activePoint ?? latest;\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n \n {value ?? (shown ? format(shown.value) : '—')}\n \n {caption ? (\n \n {caption}\n \n ) : null}\n \n {children ? {children} : null}\n \n );\n}\nLiveLineChartHeader.displayName = 'LiveLineChart.Header';\nLiveLineChartHeader.layer = 'header' as Layer;\n\nexport const LiveLineChart = Object.assign(LiveLineChartRoot, {\n Header: LiveLineChartHeader,\n Grid: LiveLineChartGrid,\n Area: LiveLineChartArea,\n Line: LiveLineChartLine,\n Tip: LiveLineChartTip,\n XAxis: LiveLineChartXAxis,\n YAxis: LiveLineChartYAxis,\n Tooltip: LiveLineChartTooltip,\n Skeleton: LiveLineChartSkeleton,\n});\n" }, { "path": "ui/live-line-accessibility.ts", diff --git a/apps/docs/public/r/mirror-area-chart.json b/apps/docs/public/r/mirror-area-chart.json index 1868013a9..9fb94f170 100644 --- a/apps/docs/public/r/mirror-area-chart.json +++ b/apps/docs/public/r/mirror-area-chart.json @@ -20,7 +20,7 @@ { "path": "ui/mirror-area-chart.tsx", "type": "registry:ui", - "content": "/**\n * MirrorAreaChart — two readings of one timeline, one above a baseline and one\n * below it.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Two scales, one timeline\n *\n * Each half has its own scale, because the two readings are usually in\n * different units — a count above and a percentage below, or requests above\n * and errors below. Both grow away from the baseline, so a peak in one lines\n * up with whatever the other did at the same moment, and neither is squeezed\n * into the other's range.\n *\n * Areas on the same side overlay in the order they are declared. Declare the\n * whole first and the part of it second, so the part is drawn inside the whole.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { finiteChartDomain } from '@/components/ui/finite-chart';\nimport {\n colorStop,\n columnValues,\n compactNumber,\n dotsPath,\n segment,\n useSeriesColor,\n xOf,\n type ChartCurve,\n type ChartPoint,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLine = Animated.createAnimatedComponent(SvgLine);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 12, right: 10, bottom: 30, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present. */\nconst Y_AXIS_WIDTH = 40;\n\n/** Gap between the value labels and the plot. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Box each x label is centred in. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Width of the scrub readout. */\nconst READOUT_WIDTH = 168;\n\n/** Diameter of the dot riding the top edge under the crosshair. */\nconst DOT = 9;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type MirrorAreaChartStatus = 'loading' | 'ready';\nexport type MirrorAreaChartSide = 'top' | 'bottom';\nexport type MirrorAreaChartDatum = Record;\n\ninterface MirrorSeries {\n key: string;\n color: string;\n label: string;\n side: MirrorAreaChartSide;\n}\n\ninterface MirrorAreaChartContextValue {\n data: MirrorAreaChartDatum[];\n xDataKey: string;\n plot: Plot;\n pad: typeof PADDING;\n /** The y of the line both halves grow away from. */\n baseline: number;\n status: MirrorAreaChartStatus;\n curve: ChartCurve;\n series: MirrorSeries[];\n registerSeries: (series: MirrorSeries) => void;\n unregisterSeries: (key: string) => void;\n /** The value drawn at the top and bottom edges of the plot, as they tween. */\n topMax: SharedValue;\n bottomMax: SharedValue;\n /** The same two, settled, for labels that should not count up. */\n topExtent: number;\n bottomExtent: number;\n /** `0` flat on the baseline, `1` drawn in full. */\n grow: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst MirrorAreaChartContext = createContext(null);\n\nfunction useChart(component: string): MirrorAreaChartContextValue {\n const context = useContext(MirrorAreaChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The row under the crosshair, for a header or a readout outside the chart. */\nexport function useMirrorAreaChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useMirrorAreaChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\n/**\n * A series' edge, `grow` of the way out from the baseline. `direction` is `-1`\n * for the top half and `1` for the bottom. A negative value is drawn on the\n * baseline: each half only grows away from it.\n */\nfunction edgeRuns(\n values: (number | null)[],\n plot: Plot,\n baseline: number,\n height: number,\n max: number,\n grow: number,\n direction: number\n): ChartPoint[][] {\n 'worklet';\n const runs: ChartPoint[][] = [];\n let run: ChartPoint[] = [];\n const span = max > 0 ? max : 1;\n for (let i = 0; i < values.length; i += 1) {\n const value = values[i];\n if (value === null || value === undefined) {\n if (run.length) runs.push(run);\n run = [];\n continue;\n }\n const reach = (Math.max(value, 0) / span) * height * grow;\n run.push({ x: xOf(i, values.length, plot), y: baseline + direction * reach });\n }\n if (run.length) runs.push(run);\n return runs;\n}\n\nfunction edgePath(runs: ChartPoint[][], curve: ChartCurve): string {\n 'worklet';\n let d = '';\n for (const run of runs) d += `${segment(run, curve)} `;\n return d;\n}\n\nfunction fillPath(runs: ChartPoint[][], curve: ChartCurve, baseline: number): string {\n 'worklet';\n let d = '';\n for (const run of runs) {\n const edge = segment(run, curve);\n if (!edge) continue;\n const first = run[0]!;\n const last = run[run.length - 1]!;\n d += `${edge} L${last.x},${baseline} L${first.x},${baseline} Z `;\n }\n return d;\n}\n\n/** The largest value on one side, padded so a peak does not touch the edge. */\nfunction sideExtent(\n data: MirrorAreaChartDatum[],\n keys: string[],\n explicit: [number, number] | undefined\n): number {\n if (explicit) return Math.max(explicit[1], 0) || 1;\n let max = 0;\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value === 'number' && Number.isFinite(value) && value > max) max = value;\n }\n }\n return max > 0 ? max * 1.08 : 1;\n}\n\nexport interface MirrorAreaChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: MirrorAreaChartDatum[];\n /** Key holding the x label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * How much of the plot's height sits above the baseline, from `0` to `1`.\n * Give the half you want read first the larger share.\n */\n split?: number;\n /**\n * Fix the top half's scale instead of deriving it from the data. Only the\n * upper end is used: the baseline is always zero.\n */\n topDomain?: [number, number];\n /** Fix the bottom half's scale. Only the upper end is used. */\n bottomDomain?: [number, number];\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * `loading` holds the bands flat on the baseline and grows them out when it\n * turns `ready`. Add a `MirrorAreaChart.Skeleton` to show something meanwhile.\n */\n status?: MirrorAreaChartStatus;\n /** Width ÷ height. */\n aspectRatio?: number;\n /** Milliseconds for the bands to grow out on mount. */\n animationDuration?: number;\n /** Milliseconds for either scale to settle after the data changes. */\n domainDuration?: number;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: MirrorAreaChartDatum | null) => void;\n /** Drop the axis padding so the bands reach the edges, for a sparkline. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface MirrorAreaChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\nconst MirrorAreaChartRoot = forwardRef(\n function MirrorAreaChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n split = 0.55,\n topDomain,\n bottomDomain,\n curve = 'monotone',\n status = 'ready',\n aspectRatio = 1.6,\n animationDuration = 700,\n domainDuration = 500,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registered, setRegistered] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const grow = useSharedValue(0);\n const topMax = useSharedValue(0);\n const bottomMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (entry: MirrorSeries) =>\n setRegistered((current) => {\n const existing = current.find((item) => item.key === entry.key);\n if (\n existing &&\n existing.color === entry.color &&\n existing.label === entry.label &&\n existing.side === entry.side\n ) {\n return current;\n }\n return [...current.filter((item) => item.key !== entry.key), entry];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) =>\n setRegistered((current) => current.filter((item) => item.key !== key)),\n []\n );\n\n /*\n * The order the areas were declared in, read from the children rather than\n * from registration: an area re-registers when its colour resolves, and\n * appending it again would reorder the legend and the readout.\n */\n const { hasYAxis, order } = useMemo(() => {\n let axis = false;\n const keys: string[] = [];\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; area?: boolean };\n const childProps = child.props as { dataKey?: string };\n if (type.axis === 'y') axis = true;\n if (type.area && typeof childProps.dataKey === 'string') keys.push(childProps.dataKey);\n });\n return { hasYAxis: axis, order: keys.join('|') };\n }, [children]);\n\n const series = useMemo(() => {\n const declared = order ? order.split('|') : [];\n const position = (key: string) => {\n const index = declared.indexOf(key);\n return index === -1 ? declared.length : index;\n };\n return [...registered].sort((a, b) => position(a.key) - position(b.key));\n }, [registered, order]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n const share = Math.min(Math.max(Number.isFinite(split) ? split : 0.55, 0.1), 0.9);\n const baseline = plot.top + plot.height * share;\n\n const topKeys = series.filter((item) => item.side === 'top').map((item) => item.key).join('|');\n const bottomKeys = series\n .filter((item) => item.side === 'bottom')\n .map((item) => item.key)\n .join('|');\n const safeTop = finiteChartDomain(topDomain);\n const safeBottom = finiteChartDomain(bottomDomain);\n const topExtent = useMemo(\n () => sideExtent(data, topKeys ? topKeys.split('|') : [], safeTop),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [data, topKeys, safeTop?.[0], safeTop?.[1]]\n );\n const bottomExtent = useMemo(\n () => sideExtent(data, bottomKeys ? bottomKeys.split('|') : [], safeBottom),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [data, bottomKeys, safeBottom?.[0], safeBottom?.[1]]\n );\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const first = topMax.value === 0 && bottomMax.value === 0;\n if (first || reducedMotion) {\n topMax.value = topExtent;\n bottomMax.value = bottomExtent;\n return;\n }\n topMax.value = withTiming(topExtent, { duration: domainDuration });\n bottomMax.value = withTiming(bottomExtent, { duration: domainDuration });\n }, [topExtent, bottomExtent, loading, reducedMotion, domainDuration, topMax, bottomMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n grow.value = 1;\n return;\n }\n grow.value = 0;\n grow.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, grow]\n );\n\n useEffect(() => {\n // Going back to `loading` lays the bands flat again, so a refetch grows\n // them out from the baseline instead of appearing complete.\n if (loading) {\n revealed.current = false;\n cancelAnimation(grow);\n grow.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, grow]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n pad,\n baseline,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n topExtent,\n bottomExtent,\n grow,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` and `pad` are rebuilt every render from `size`, so they are\n // compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n pad.right,\n baseline,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n topExtent,\n bottomExtent,\n grow,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map((item) => [item.label, datum[item.key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n {/*\n * One drawing, with the bands in their own group above the grid.\n * The bands grow out from the baseline by redrawing their paths\n * on the UI thread, so there is no clip to animate — and so\n * nothing that Android's SVG clip handling can drop.\n */}\n \n {svg}\n {seriesLayer}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nMirrorAreaChartRoot.displayName = 'MirrorAreaChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartGridProps {\n /** `dots` is a field of points; `lines` is dashed rules across each half. */\n variant?: 'dots' | 'lines';\n /** Distance between dots, or how many rules each half gets. */\n spacing?: number;\n color?: string;\n opacity?: number;\n}\n\n/**\n * The texture behind the bands. Dots by default, which mark the plot's extent\n * without drawing lines that compete with the baseline.\n */\nfunction MirrorAreaChartGrid({ variant = 'dots', spacing, color, opacity = 1 }: MirrorAreaChartGridProps) {\n const { plot, baseline } = useChart('MirrorAreaChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const dots = useMemo(() => {\n if (variant !== 'dots') return '';\n const gap = Math.max(spacing ?? 12, 4);\n const points: ChartPoint[] = [];\n const columns = Math.floor(plot.width / gap);\n const offsetX = plot.left + (plot.width - columns * gap) / 2;\n // Rows are laid out from the baseline in both directions, so one row\n // always lies on it and the pattern is symmetrical about it.\n for (let y = baseline; y >= plot.top - 0.5; y -= gap) {\n for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });\n }\n for (let y = baseline + gap; y <= plot.top + plot.height + 0.5; y += gap) {\n for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });\n }\n return dotsPath(points, 0.9);\n }, [variant, spacing, plot.left, plot.top, plot.width, plot.height, baseline]);\n\n if (variant === 'dots') {\n return ;\n }\n\n const rules = Math.max(Math.round(spacing ?? 2), 1);\n const lines: number[] = [];\n for (let i = 1; i <= rules; i += 1) {\n lines.push(baseline - ((baseline - plot.top) * i) / rules);\n lines.push(baseline + ((plot.top + plot.height - baseline) * i) / rules);\n }\n\n return (\n \n {lines.map((y) => (\n \n ))}\n \n );\n}\nMirrorAreaChartGrid.displayName = 'MirrorAreaChart.Grid';\nMirrorAreaChartGrid.layer = 'svg' as Layer;\n\nexport interface MirrorAreaChartBaselineProps {\n color?: string;\n strokeWidth?: number;\n opacity?: number;\n}\n\n/**\n * The line both halves grow away from. Declare it after the areas so it is\n * drawn over their bases.\n */\nfunction MirrorAreaChartBaseline({ color, strokeWidth = 1, opacity = 0.5 }: MirrorAreaChartBaselineProps) {\n const { plot, baseline } = useChart('MirrorAreaChart.Baseline');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#737373');\n\n return (\n \n );\n}\nMirrorAreaChartBaseline.displayName = 'MirrorAreaChart.Baseline';\nMirrorAreaChartBaseline.layer = 'series' as Layer;\n\nexport interface MirrorAreaChartAreaProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Which half it is drawn in. */\n side?: MirrorAreaChartSide;\n /** The name shown by the readout and the legend. Defaults to `dataKey`. */\n label?: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /**\n * Draw it in the muted foreground colour instead, for a total that the\n * coloured areas are parts of.\n */\n muted?: boolean;\n /** Opacity of the fill at the band's outer edge. */\n fillOpacity?: number;\n /** Opacity of the fill at the baseline. Defaults to `fillOpacity`, a flat fill. */\n gradientToOpacity?: number;\n /** Draw the line along the band's outer edge. */\n showLine?: boolean;\n /** Thickness of that line. */\n strokeWidth?: number;\n}\n\n/**\n * One band, growing away from the baseline on its side. Areas on one side\n * overlay in declaration order, so a whole declared first is drawn behind a\n * part of it declared second.\n */\nfunction MirrorAreaChartArea({\n dataKey,\n side = 'top',\n label,\n color,\n colorIndex = 1,\n muted = false,\n fillOpacity,\n gradientToOpacity,\n showLine = true,\n strokeWidth = 1.5,\n}: MirrorAreaChartAreaProps) {\n const {\n data,\n plot,\n baseline,\n curve,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n grow,\n } = useChart('MirrorAreaChart.Area');\n\n const seriesColor = useSeriesColor(color, colorIndex);\n const mutedToken = useCSSVariable('--color-muted-foreground');\n const stroke = muted && !color ? (typeof mutedToken === 'string' ? mutedToken : '#737373') : seriesColor;\n const name = label ?? dataKey;\n const gradientId = `panelui-mirror-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const outer = fillOpacity ?? (muted ? 0.22 : 0.3);\n const inner = gradientToOpacity ?? outer;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: stroke, label: name, side });\n }, [dataKey, stroke, name, side, registerSeries]);\n\n useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const top = side === 'top';\n const height = top ? baseline - plot.top : plot.top + plot.height - baseline;\n\n const fillProps = useAnimatedProps(() => {\n const runs = edgeRuns(\n values,\n plot,\n baseline,\n height,\n top ? topMax.value : bottomMax.value,\n grow.value,\n top ? -1 : 1\n );\n return { d: fillPath(runs, curve, baseline) };\n });\n\n const lineProps = useAnimatedProps(() => {\n const runs = edgeRuns(\n values,\n plot,\n baseline,\n height,\n top ? topMax.value : bottomMax.value,\n grow.value,\n top ? -1 : 1\n );\n // Hidden while the band is flat, or the edge lines of every area are\n // drawn along the baseline over the skeleton while the chart waits.\n return { d: edgePath(runs, curve), opacity: grow.value > 0.001 ? 1 : 0 };\n });\n\n return (\n \n \n {/* Top to bottom in the band's own box: the outer edge is at the top\n of a top band and at the bottom of a bottom one. */}\n \n \n \n \n \n \n {showLine ? (\n \n ) : null}\n \n );\n}\nMirrorAreaChartArea.displayName = 'MirrorAreaChart.Area';\nMirrorAreaChartArea.layer = 'series' as Layer;\n// Read by the root, for the order the areas were declared in.\nMirrorAreaChartArea.area = true as const;\n\nexport interface MirrorAreaChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a low band either side of the baseline with a highlight\n * travelling across it. Flat, so it says where the bands will be and nothing\n * about their shape.\n */\nfunction MirrorAreaChartSkeleton({ duration = 1400, color }: MirrorAreaChartSkeletonProps) {\n const { plot, baseline, status } = useChart('MirrorAreaChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const above = (baseline - plot.top) * 0.18;\n const below = (plot.top + plot.height - baseline) * 0.18;\n const gradientId = 'panelui-mirror-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nMirrorAreaChartSkeleton.displayName = 'MirrorAreaChart.Skeleton';\nMirrorAreaChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartXAxisProps {\n ticks?: number;\n format?: (datum: MirrorAreaChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The x labels. Real text, so they follow the theme's font and text scaling. */\nfunction MirrorAreaChartXAxis({ ticks = 4, format, className }: MirrorAreaChartXAxisProps) {\n const { data, xDataKey, plot, pad } = useChart('MirrorAreaChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nMirrorAreaChartXAxis.displayName = 'MirrorAreaChart.XAxis';\nMirrorAreaChartXAxis.layer = 'overlay' as Layer;\n\nexport interface MirrorAreaChartYAxisProps {\n /** Format a value in the top half. */\n formatTop?: (value: number) => string;\n /** Format a value in the bottom half. */\n formatBottom?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Three labels down the side: the top half's maximum, zero on the baseline,\n * and the bottom half's maximum. Two scales need two labels, and a column of\n * evenly spaced numbers would suggest one.\n */\nfunction MirrorAreaChartYAxis({ formatTop, formatBottom, className }: MirrorAreaChartYAxisProps) {\n const { plot, baseline, topExtent, bottomExtent } = useChart('MirrorAreaChart.YAxis');\n const width = Math.max(plot.left - Y_AXIS_GUTTER, 0);\n const fmtTop = formatTop ?? compactNumber;\n const fmtBottom = formatBottom ?? compactNumber;\n\n const labels = [\n { key: 'top', y: plot.top, text: fmtTop(topExtent) },\n { key: 'zero', y: baseline, text: '0' },\n { key: 'bottom', y: plot.top + plot.height, text: fmtBottom(bottomExtent) },\n ];\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nMirrorAreaChartYAxis.displayName = 'MirrorAreaChart.YAxis';\nMirrorAreaChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which leaves room for the labels before laying the plot out.\nMirrorAreaChartYAxis.axis = 'y' as const;\n\nexport interface MirrorAreaChartTooltipProps {\n /** Colour of the crosshair and the dot. Defaults to the foreground token. */\n color?: string;\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: MirrorAreaChartDatum) => string;\n /**\n * One line to show instead of a row per series — \"1,120 jobs · 78% high\".\n * Use it when the series only make sense read together.\n */\n formatSummary?: (datum: MirrorAreaChartDatum) => string;\n className?: string;\n}\n\n/**\n * A dashed crosshair, a dot on the outer edge of the top half, and a readout\n * beside them. The hit area is the whole plot.\n */\nfunction MirrorAreaChartTooltip({\n color,\n formatValue,\n formatX,\n formatSummary,\n className,\n}: MirrorAreaChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n baseline,\n series,\n topMax,\n bottomMax,\n grow,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('MirrorAreaChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const background = useCSSVariable('--color-background');\n const ring = typeof background === 'string' ? background : 'white';\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n // Declared inside the memo, next to its callers: a worklet may only call\n // another worklet.\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = total < 2 ? 0 : (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n /*\n * The dot sits on the outermost edge on the top half — the largest value of\n * any top area at that point — or on the bottom half's when there is no top.\n */\n const topSeries = series.filter((item) => item.side === 'top');\n const dotSide: MirrorAreaChartSide = topSeries.length ? 'top' : 'bottom';\n const dotValues = useMemo(() => {\n const keys = series.filter((item) => item.side === dotSide).map((item) => item.key);\n return data.map((row) => {\n let most: number | null = null;\n for (const key of keys) {\n const value = row[key];\n if (typeof value === 'number' && Number.isFinite(value) && (most === null || value > most)) {\n most = value;\n }\n }\n return most;\n });\n }, [data, series, dotSide]);\n\n const topHeight = baseline - plot.top;\n const bottomHeight = plot.top + plot.height - baseline;\n\n const dotProps = useAnimatedProps(() => {\n const index = activeIndex.value;\n const value = index >= 0 ? dotValues[index] : null;\n if (index < 0 || value === null || value === undefined) return { opacity: 0, cx: 0, cy: 0 };\n const up = dotSide === 'top';\n const max = up ? topMax.value : bottomMax.value;\n const reach = (Math.max(value, 0) / (max > 0 ? max : 1)) * (up ? topHeight : bottomHeight) * grow.value;\n return { opacity: 1, cx: xOf(index, total, plot), cy: baseline + (up ? -reach : reach) };\n });\n\n const crosshairProps = useAnimatedProps(() => {\n const index = activeIndex.value;\n const x = index < 0 ? 0 : xOf(index, total, plot);\n return { x1: x, x2: x, opacity: index < 0 ? 0 : 0.6 };\n });\n\n // Beside the crosshair, on whichever side has room, so it never covers the\n // point it describes.\n const readoutStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n // Clear of the dot's radius and its ring, not only of the crosshair.\n const gap = DOT / 2 + 10;\n const fitsLeft = x - gap - READOUT_WIDTH >= plot.left;\n const at = fitsLeft ? x - gap - READOUT_WIDTH : Math.min(x + gap, plot.left + plot.width - READOUT_WIDTH);\n return { opacity: 1, transform: [{ translateX: at }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: MirrorAreaChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n \n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {formatSummary ? (\n \n {formatSummary(active)}\n \n ) : (\n series.map((item) => {\n const value = active[item.key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, item.key)}\n \n \n {item.label}\n \n \n );\n })\n )}\n \n ) : null}\n \n \n \n );\n}\nMirrorAreaChartTooltip.displayName = 'MirrorAreaChart.Tooltip';\nMirrorAreaChartTooltip.layer = 'overlay' as Layer;\n\nexport interface MirrorAreaChartLegendProps extends ViewProps {\n className?: string;\n}\n\n/** A swatch and a name per area, top half first. */\nfunction MirrorAreaChartLegend({ className, ...props }: MirrorAreaChartLegendProps) {\n const { series } = useChart('MirrorAreaChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {sidesInOrder(series).map((item) => (\n \n ))}\n \n );\n}\nMirrorAreaChartLegend.displayName = 'MirrorAreaChart.Legend';\nMirrorAreaChartLegend.layer = 'overlay' as Layer;\n\n/** Top-half areas first, then the bottom half's, each in declaration order. */\nfunction sidesInOrder(series: MirrorSeries[]): MirrorSeries[] {\n return [\n ...series.filter((item) => item.side === 'top'),\n ...series.filter((item) => item.side === 'bottom'),\n ];\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Draw a swatch and a name per area along the trailing edge. */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of and what it currently reads.\n * The value is not derived here — pass the formatted string, from\n * `onActiveIndexChange` if it follows the finger.\n */\nfunction MirrorAreaChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n children,\n ...props\n}: MirrorAreaChartHeaderProps) {\n const { series } = useChart('MirrorAreaChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {sidesInOrder(series).map((item) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {trailing ? {trailing} : null}\n \n );\n}\nMirrorAreaChartHeader.displayName = 'MirrorAreaChart.Header';\nMirrorAreaChartHeader.layer = 'header' as Layer;\n\nexport const MirrorAreaChart = Object.assign(MirrorAreaChartRoot, {\n Header: MirrorAreaChartHeader,\n Grid: MirrorAreaChartGrid,\n Area: MirrorAreaChartArea,\n Baseline: MirrorAreaChartBaseline,\n Skeleton: MirrorAreaChartSkeleton,\n XAxis: MirrorAreaChartXAxis,\n YAxis: MirrorAreaChartYAxis,\n Tooltip: MirrorAreaChartTooltip,\n Legend: MirrorAreaChartLegend,\n});\n" + "content": "/**\n * MirrorAreaChart — two readings of one timeline, one above a baseline and one\n * below it.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Two scales, one timeline\n *\n * Each half has its own scale, because the two readings are usually in\n * different units — a count above and a percentage below, or requests above\n * and errors below. Both grow away from the baseline, so a peak in one lines\n * up with whatever the other did at the same moment, and neither is squeezed\n * into the other's range.\n *\n * Areas on the same side overlay in the order they are declared. Declare the\n * whole first and the part of it second, so the part is drawn inside the whole.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n LinearGradient,\n Path,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { finiteChartDomain } from '@/components/ui/finite-chart';\nimport {\n colorStop,\n columnValues,\n compactNumber,\n dotsPath,\n segment,\n useSeriesColor,\n xOf,\n type ChartCurve,\n type ChartPoint,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedLine = Animated.createAnimatedComponent(SvgLine);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\nconst PADDING = { top: 12, right: 10, bottom: 30, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present. */\nconst Y_AXIS_WIDTH = 40;\n\n/** Gap between the value labels and the plot. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Box each x label is centred in. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Width of the scrub readout. */\nconst READOUT_WIDTH = 168;\n\n/** Diameter of the dot riding the top edge under the crosshair. */\nconst DOT = 9;\n\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type MirrorAreaChartStatus = 'loading' | 'ready';\nexport type MirrorAreaChartSide = 'top' | 'bottom';\nexport type MirrorAreaChartDatum = Record;\n\ninterface MirrorSeries {\n key: string;\n color: string;\n label: string;\n side: MirrorAreaChartSide;\n}\n\ninterface MirrorAreaChartContextValue {\n data: MirrorAreaChartDatum[];\n xDataKey: string;\n plot: Plot;\n pad: typeof PADDING;\n /** The y of the line both halves grow away from. */\n baseline: number;\n status: MirrorAreaChartStatus;\n curve: ChartCurve;\n series: MirrorSeries[];\n registerSeries: (series: MirrorSeries) => void;\n unregisterSeries: (key: string) => void;\n /** The value drawn at the top and bottom edges of the plot, as they tween. */\n topMax: SharedValue;\n bottomMax: SharedValue;\n /** The same two, settled, for labels that should not count up. */\n topExtent: number;\n bottomExtent: number;\n /** `0` flat on the baseline, `1` drawn in full. */\n grow: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst MirrorAreaChartContext = createContext(null);\n\nfunction useChart(component: string): MirrorAreaChartContextValue {\n const context = useContext(MirrorAreaChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The row under the crosshair, for a header or a readout outside the chart. */\nexport function useMirrorAreaChart() {\n const { data, activeIndexJS, xDataKey } = useChart('useMirrorAreaChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\n/**\n * A series' edge, `grow` of the way out from the baseline. `direction` is `-1`\n * for the top half and `1` for the bottom. A negative value is drawn on the\n * baseline: each half only grows away from it.\n */\nfunction edgeRuns(\n values: (number | null)[],\n plot: Plot,\n baseline: number,\n height: number,\n max: number,\n grow: number,\n direction: number\n): ChartPoint[][] {\n 'worklet';\n const runs: ChartPoint[][] = [];\n let run: ChartPoint[] = [];\n const span = max > 0 ? max : 1;\n for (let i = 0; i < values.length; i += 1) {\n const value = values[i];\n if (value === null || value === undefined) {\n if (run.length) runs.push(run);\n run = [];\n continue;\n }\n const reach = (Math.max(value, 0) / span) * height * grow;\n run.push({ x: xOf(i, values.length, plot), y: baseline + direction * reach });\n }\n if (run.length) runs.push(run);\n return runs;\n}\n\nfunction edgePath(runs: ChartPoint[][], curve: ChartCurve): string {\n 'worklet';\n let d = '';\n for (const run of runs) d += `${segment(run, curve)} `;\n return d;\n}\n\nfunction fillPath(runs: ChartPoint[][], curve: ChartCurve, baseline: number): string {\n 'worklet';\n let d = '';\n for (const run of runs) {\n const edge = segment(run, curve);\n if (!edge) continue;\n const first = run[0]!;\n const last = run[run.length - 1]!;\n d += `${edge} L${last.x},${baseline} L${first.x},${baseline} Z `;\n }\n return d;\n}\n\n/** The largest value on one side, padded so a peak does not touch the edge. */\nfunction sideExtent(\n data: MirrorAreaChartDatum[],\n keys: string[],\n explicit: [number, number] | undefined\n): number {\n if (explicit) return Math.max(explicit[1], 0) || 1;\n let max = 0;\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value === 'number' && Number.isFinite(value) && value > max) max = value;\n }\n }\n return max > 0 ? max * 1.08 : 1;\n}\n\nexport interface MirrorAreaChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point along the x-axis. */\n data: MirrorAreaChartDatum[];\n /** Key holding the x label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * How much of the plot's height sits above the baseline, from `0` to `1`.\n * Give the half you want read first the larger share.\n */\n split?: number;\n /**\n * Fix the top half's scale instead of deriving it from the data. Only the\n * upper end is used: the baseline is always zero.\n */\n topDomain?: [number, number];\n /** Fix the bottom half's scale. Only the upper end is used. */\n bottomDomain?: [number, number];\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: ChartCurve;\n /**\n * `loading` holds the bands flat on the baseline and grows them out when it\n * turns `ready`. Add a `MirrorAreaChart.Skeleton` to show something meanwhile.\n */\n status?: MirrorAreaChartStatus;\n /** Width ÷ height. */\n aspectRatio?: number;\n /** Milliseconds for the bands to grow out on mount. */\n animationDuration?: number;\n /** Milliseconds for either scale to settle after the data changes. */\n domainDuration?: number;\n /**\n * The point under the crosshair as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: MirrorAreaChartDatum | null) => void;\n /** Drop the axis padding so the bands reach the edges, for a sparkline. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface MirrorAreaChartHandle {\n replay: () => void;\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(slot);\n });\n return { svg, series, overlay, header };\n}\n\nconst MirrorAreaChartRoot = forwardRef(\n function MirrorAreaChartRoot(\n {\n className,\n data,\n xDataKey = 'date',\n split = 0.55,\n topDomain,\n bottomDomain,\n curve = 'monotone',\n status = 'ready',\n aspectRatio = 1.6,\n animationDuration = 700,\n domainDuration = 500,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registered, setRegistered] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const grow = useSharedValue(0);\n const topMax = useSharedValue(0);\n const bottomMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (entry: MirrorSeries) =>\n setRegistered((current) => {\n const existing = current.find((item) => item.key === entry.key);\n if (\n existing &&\n existing.color === entry.color &&\n existing.label === entry.label &&\n existing.side === entry.side\n ) {\n return current;\n }\n return [...current.filter((item) => item.key !== entry.key), entry];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) =>\n setRegistered((current) => current.filter((item) => item.key !== key)),\n []\n );\n\n /*\n * The order the areas were declared in, read from the children rather than\n * from registration: an area re-registers when its colour resolves, and\n * appending it again would reorder the legend and the readout.\n */\n const { hasYAxis, order } = useMemo(() => {\n let axis = false;\n const keys: string[] = [];\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; area?: boolean };\n const childProps = child.props as { dataKey?: string };\n if (type.axis === 'y') axis = true;\n if (type.area && typeof childProps.dataKey === 'string') keys.push(childProps.dataKey);\n });\n return { hasYAxis: axis, order: keys.join('|') };\n }, [children]);\n\n const series = useMemo(() => {\n const declared = order ? order.split('|') : [];\n const position = (key: string) => {\n const index = declared.indexOf(key);\n return index === -1 ? declared.length : index;\n };\n return [...registered].sort((a, b) => position(a.key) - position(b.key));\n }, [registered, order]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n const share = Math.min(Math.max(Number.isFinite(split) ? split : 0.55, 0.1), 0.9);\n const baseline = plot.top + plot.height * share;\n\n const topKeys = series.filter((item) => item.side === 'top').map((item) => item.key).join('|');\n const bottomKeys = series\n .filter((item) => item.side === 'bottom')\n .map((item) => item.key)\n .join('|');\n const safeTop = finiteChartDomain(topDomain);\n const safeBottom = finiteChartDomain(bottomDomain);\n const topExtent = useMemo(\n () => sideExtent(data, topKeys ? topKeys.split('|') : [], safeTop),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [data, topKeys, safeTop?.[0], safeTop?.[1]]\n );\n const bottomExtent = useMemo(\n () => sideExtent(data, bottomKeys ? bottomKeys.split('|') : [], safeBottom),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [data, bottomKeys, safeBottom?.[0], safeBottom?.[1]]\n );\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const first = topMax.value === 0 && bottomMax.value === 0;\n if (first || reducedMotion) {\n topMax.value = topExtent;\n bottomMax.value = bottomExtent;\n return;\n }\n topMax.value = withTiming(topExtent, { duration: domainDuration });\n bottomMax.value = withTiming(bottomExtent, { duration: domainDuration });\n }, [topExtent, bottomExtent, loading, reducedMotion, domainDuration, topMax, bottomMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n grow.value = 1;\n return;\n }\n grow.value = 0;\n grow.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, grow]\n );\n\n useEffect(() => {\n // Going back to `loading` lays the bands flat again, so a refetch grows\n // them out from the baseline instead of appearing complete.\n if (loading) {\n revealed.current = false;\n cancelAnimation(grow);\n grow.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, grow]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n pad,\n baseline,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n topExtent,\n bottomExtent,\n grow,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` and `pad` are rebuilt every render from `size`, so they are\n // compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n pad.right,\n baseline,\n status,\n curve,\n series,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n topExtent,\n bottomExtent,\n grow,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map((item) => [item.label, datum[item.key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n {/*\n * One drawing, with the bands in their own group above the grid.\n * The bands grow out from the baseline by redrawing their paths\n * on the UI thread, so there is no clip to animate — and so\n * nothing that Android's SVG clip handling can drop.\n */}\n \n {svg}\n {seriesLayer}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nMirrorAreaChartRoot.displayName = 'MirrorAreaChart';\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartGridProps {\n /** `dots` is a field of points; `lines` is dashed rules across each half. */\n variant?: 'dots' | 'lines';\n /** Distance between dots, or how many rules each half gets. */\n spacing?: number;\n color?: string;\n opacity?: number;\n}\n\n/**\n * The texture behind the bands. Dots by default, which mark the plot's extent\n * without drawing lines that compete with the baseline.\n */\nfunction MirrorAreaChartGrid({ variant = 'dots', spacing, color, opacity = 1 }: MirrorAreaChartGridProps) {\n const { plot, baseline } = useChart('MirrorAreaChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const dots = useMemo(() => {\n if (variant !== 'dots') return '';\n const gap = Math.max(spacing ?? 12, 4);\n const points: ChartPoint[] = [];\n const columns = Math.floor(plot.width / gap);\n const offsetX = plot.left + (plot.width - columns * gap) / 2;\n // Rows are laid out from the baseline in both directions, so one row\n // always lies on it and the pattern is symmetrical about it.\n for (let y = baseline; y >= plot.top - 0.5; y -= gap) {\n for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });\n }\n for (let y = baseline + gap; y <= plot.top + plot.height + 0.5; y += gap) {\n for (let c = 0; c <= columns; c += 1) points.push({ x: offsetX + c * gap, y });\n }\n return dotsPath(points, 0.9);\n }, [variant, spacing, plot.left, plot.top, plot.width, plot.height, baseline]);\n\n if (variant === 'dots') {\n return ;\n }\n\n const rules = Math.max(Math.round(spacing ?? 2), 1);\n const lines: number[] = [];\n for (let i = 1; i <= rules; i += 1) {\n lines.push(baseline - ((baseline - plot.top) * i) / rules);\n lines.push(baseline + ((plot.top + plot.height - baseline) * i) / rules);\n }\n\n return (\n \n {lines.map((y) => (\n \n ))}\n \n );\n}\nMirrorAreaChartGrid.displayName = 'MirrorAreaChart.Grid';\nMirrorAreaChartGrid.layer = 'svg' as Layer;\n\nexport interface MirrorAreaChartBaselineProps {\n color?: string;\n strokeWidth?: number;\n opacity?: number;\n}\n\n/**\n * The line both halves grow away from. Declare it after the areas so it is\n * drawn over their bases.\n */\nfunction MirrorAreaChartBaseline({ color, strokeWidth = 1, opacity = 0.5 }: MirrorAreaChartBaselineProps) {\n const { plot, baseline } = useChart('MirrorAreaChart.Baseline');\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#737373');\n\n return (\n \n );\n}\nMirrorAreaChartBaseline.displayName = 'MirrorAreaChart.Baseline';\nMirrorAreaChartBaseline.layer = 'series' as Layer;\n\nexport interface MirrorAreaChartAreaProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Which half it is drawn in. */\n side?: MirrorAreaChartSide;\n /** The name shown by the readout and the legend. Defaults to `dataKey`. */\n label?: string;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /** Which of the five chart tokens to take. */\n colorIndex?: SeriesColorIndex;\n /**\n * Draw it in the muted foreground colour instead, for a total that the\n * coloured areas are parts of.\n */\n muted?: boolean;\n /** Opacity of the fill at the band's outer edge. */\n fillOpacity?: number;\n /** Opacity of the fill at the baseline. Defaults to `fillOpacity`, a flat fill. */\n gradientToOpacity?: number;\n /** Draw the line along the band's outer edge. */\n showLine?: boolean;\n /** Thickness of that line. */\n strokeWidth?: number;\n}\n\n/**\n * One band, growing away from the baseline on its side. Areas on one side\n * overlay in declaration order, so a whole declared first is drawn behind a\n * part of it declared second.\n */\nfunction MirrorAreaChartArea({\n dataKey,\n side = 'top',\n label,\n color,\n colorIndex = 1,\n muted = false,\n fillOpacity,\n gradientToOpacity,\n showLine = true,\n strokeWidth = 1.5,\n}: MirrorAreaChartAreaProps) {\n const {\n data,\n plot,\n baseline,\n curve,\n registerSeries,\n unregisterSeries,\n topMax,\n bottomMax,\n grow,\n } = useChart('MirrorAreaChart.Area');\n\n const seriesColor = useSeriesColor(color, colorIndex);\n const mutedToken = useCSSVariable('--color-muted-foreground');\n const stroke = muted && !color ? (typeof mutedToken === 'string' ? mutedToken : '#737373') : seriesColor;\n const name = label ?? dataKey;\n const gradientId = `panelui-mirror-fill-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const outer = fillOpacity ?? (muted ? 0.22 : 0.3);\n const inner = gradientToOpacity ?? outer;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: stroke, label: name, side });\n }, [dataKey, stroke, name, side, registerSeries]);\n\n useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const top = side === 'top';\n const height = top ? baseline - plot.top : plot.top + plot.height - baseline;\n\n const fillProps = useAnimatedProps(() => {\n const runs = edgeRuns(\n values,\n plot,\n baseline,\n height,\n top ? topMax.value : bottomMax.value,\n grow.value,\n top ? -1 : 1\n );\n return { d: fillPath(runs, curve, baseline) };\n });\n\n const lineProps = useAnimatedProps(() => {\n const runs = edgeRuns(\n values,\n plot,\n baseline,\n height,\n top ? topMax.value : bottomMax.value,\n grow.value,\n top ? -1 : 1\n );\n // Hidden while the band is flat, or the edge lines of every area are\n // drawn along the baseline over the skeleton while the chart waits.\n return { d: edgePath(runs, curve), opacity: grow.value > 0.001 ? 1 : 0 };\n });\n\n return (\n \n \n {/* Top to bottom in the band's own box: the outer edge is at the top\n of a top band and at the bottom of a bottom one. */}\n \n \n \n \n \n \n {showLine ? (\n \n ) : null}\n \n );\n}\nMirrorAreaChartArea.displayName = 'MirrorAreaChart.Area';\nMirrorAreaChartArea.layer = 'series' as Layer;\n// Read by the root, for the order the areas were declared in.\nMirrorAreaChartArea.area = true as const;\n\nexport interface MirrorAreaChartSkeletonProps {\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a low band either side of the baseline with a highlight\n * travelling across it. Flat, so it says where the bands will be and nothing\n * about their shape.\n */\nfunction MirrorAreaChartSkeleton({ duration = 1400, color }: MirrorAreaChartSkeletonProps) {\n const { plot, baseline, status } = useChart('MirrorAreaChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n if (!loading) return null;\n\n const above = (baseline - plot.top) * 0.18;\n const below = (plot.top + plot.height - baseline) * 0.18;\n const gradientId = 'panelui-mirror-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nMirrorAreaChartSkeleton.displayName = 'MirrorAreaChart.Skeleton';\nMirrorAreaChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartXAxisProps {\n ticks?: number;\n format?: (datum: MirrorAreaChartDatum, index: number) => string;\n className?: string;\n}\n\n/** The x labels. Real text, so they follow the theme's font and text scaling. */\nfunction MirrorAreaChartXAxis({ ticks = 4, format, className }: MirrorAreaChartXAxisProps) {\n const { data, xDataKey, plot, pad } = useChart('MirrorAreaChart.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_unused, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nMirrorAreaChartXAxis.displayName = 'MirrorAreaChart.XAxis';\nMirrorAreaChartXAxis.layer = 'overlay' as Layer;\n\nexport interface MirrorAreaChartYAxisProps {\n /** Format a value in the top half. */\n formatTop?: (value: number) => string;\n /** Format a value in the bottom half. */\n formatBottom?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Three labels down the side: the top half's maximum, zero on the baseline,\n * and the bottom half's maximum. Two scales need two labels, and a column of\n * evenly spaced numbers would suggest one.\n */\nfunction MirrorAreaChartYAxis({ formatTop, formatBottom, className }: MirrorAreaChartYAxisProps) {\n const { plot, baseline, topExtent, bottomExtent } = useChart('MirrorAreaChart.YAxis');\n const width = Math.max(plot.left - Y_AXIS_GUTTER, 0);\n const fmtTop = formatTop ?? compactNumber;\n const fmtBottom = formatBottom ?? compactNumber;\n\n const labels = [\n { key: 'top', y: plot.top, text: fmtTop(topExtent) },\n { key: 'zero', y: baseline, text: '0' },\n { key: 'bottom', y: plot.top + plot.height, text: fmtBottom(bottomExtent) },\n ];\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nMirrorAreaChartYAxis.displayName = 'MirrorAreaChart.YAxis';\nMirrorAreaChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which leaves room for the labels before laying the plot out.\nMirrorAreaChartYAxis.axis = 'y' as const;\n\nexport interface MirrorAreaChartTooltipProps {\n /** Colour of the crosshair and the dot. Defaults to the foreground token. */\n color?: string;\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: MirrorAreaChartDatum) => string;\n /**\n * One line to show instead of a row per series — \"1,120 jobs · 78% high\".\n * Use it when the series only make sense read together.\n */\n formatSummary?: (datum: MirrorAreaChartDatum) => string;\n className?: string;\n}\n\n/**\n * A dashed crosshair, a dot on the outer edge of the top half, and a readout\n * beside them. The hit area is the whole plot.\n */\nfunction MirrorAreaChartTooltip({\n color,\n formatValue,\n formatX,\n formatSummary,\n className,\n}: MirrorAreaChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n baseline,\n series,\n topMax,\n bottomMax,\n grow,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('MirrorAreaChart.Tooltip');\n\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const background = useCSSVariable('--color-background');\n const ring = typeof background === 'string' ? background : 'white';\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n\n // Declared inside the memo, next to its callers: a worklet may only call\n // another worklet.\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = total < 2 ? 0 : (x - left) / width;\n const next = Math.round(Math.min(1, Math.max(0, ratio)) * (total - 1));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, activeIndex, setActiveIndexJS]);\n\n /*\n * The dot sits on the outermost edge on the top half — the largest value of\n * any top area at that point — or on the bottom half's when there is no top.\n */\n const topSeries = series.filter((item) => item.side === 'top');\n const dotSide: MirrorAreaChartSide = topSeries.length ? 'top' : 'bottom';\n const dotValues = useMemo(() => {\n const keys = series.filter((item) => item.side === dotSide).map((item) => item.key);\n return data.map((row) => {\n let most: number | null = null;\n for (const key of keys) {\n const value = row[key];\n if (typeof value === 'number' && Number.isFinite(value) && (most === null || value > most)) {\n most = value;\n }\n }\n return most;\n });\n }, [data, series, dotSide]);\n\n const topHeight = baseline - plot.top;\n const bottomHeight = plot.top + plot.height - baseline;\n\n const dotProps = useAnimatedProps(() => {\n const index = activeIndex.value;\n const value = index >= 0 ? dotValues[index] : null;\n if (index < 0 || value === null || value === undefined) return { opacity: 0, cx: 0, cy: 0 };\n const up = dotSide === 'top';\n const max = up ? topMax.value : bottomMax.value;\n const reach = (Math.max(value, 0) / (max > 0 ? max : 1)) * (up ? topHeight : bottomHeight) * grow.value;\n return { opacity: 1, cx: xOf(index, total, plot), cy: baseline + (up ? -reach : reach) };\n });\n\n const crosshairProps = useAnimatedProps(() => {\n const index = activeIndex.value;\n const x = index < 0 ? 0 : xOf(index, total, plot);\n return { x1: x, x2: x, opacity: index < 0 ? 0 : 0.6 };\n });\n\n // Beside the crosshair, on whichever side has room, so it never covers the\n // point it describes.\n const readoutStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = xOf(index, total, plot);\n // Clear of the dot's radius and its ring, not only of the crosshair.\n const gap = DOT / 2 + 10;\n const fitsLeft = x - gap - READOUT_WIDTH >= plot.left;\n const at = fitsLeft ? x - gap - READOUT_WIDTH : Math.min(x + gap, plot.left + plot.width - READOUT_WIDTH);\n return { opacity: 1, transform: [{ translateX: at }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: MirrorAreaChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n \n \n \n \n {active ? (\n \n \n {fmtX(active)}\n \n {formatSummary ? (\n \n {formatSummary(active)}\n \n ) : (\n series.map((item) => {\n const value = active[item.key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, item.key)}\n \n \n {item.label}\n \n \n );\n })\n )}\n \n ) : null}\n \n \n \n );\n}\nMirrorAreaChartTooltip.displayName = 'MirrorAreaChart.Tooltip';\nMirrorAreaChartTooltip.layer = 'overlay' as Layer;\n\nexport interface MirrorAreaChartLegendProps extends ViewProps {\n className?: string;\n}\n\n/** A swatch and a name per area, top half first. */\nfunction MirrorAreaChartLegend({ className, ...props }: MirrorAreaChartLegendProps) {\n const { series } = useChart('MirrorAreaChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {sidesInOrder(series).map((item) => (\n \n ))}\n \n );\n}\nMirrorAreaChartLegend.displayName = 'MirrorAreaChart.Legend';\nMirrorAreaChartLegend.layer = 'overlay' as Layer;\n\n/** Top-half areas first, then the bottom half's, each in declaration order. */\nfunction sidesInOrder(series: MirrorSeries[]): MirrorSeries[] {\n return [\n ...series.filter((item) => item.side === 'top'),\n ...series.filter((item) => item.side === 'bottom'),\n ];\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface MirrorAreaChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Draw a swatch and a name per area along the trailing edge. */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of and what it currently reads.\n * The value is not derived here — pass the formatted string, from\n * `onActiveIndexChange` if it follows the finger.\n */\nfunction MirrorAreaChartHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n children,\n ...props\n}: MirrorAreaChartHeaderProps) {\n const { series } = useChart('MirrorAreaChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {sidesInOrder(series).map((item) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {trailing ? {trailing} : null}\n \n );\n}\nMirrorAreaChartHeader.displayName = 'MirrorAreaChart.Header';\nMirrorAreaChartHeader.layer = 'header' as Layer;\n\nexport const MirrorAreaChart = Object.assign(MirrorAreaChartRoot, {\n Header: MirrorAreaChartHeader,\n Grid: MirrorAreaChartGrid,\n Area: MirrorAreaChartArea,\n Baseline: MirrorAreaChartBaseline,\n Skeleton: MirrorAreaChartSkeleton,\n XAxis: MirrorAreaChartXAxis,\n YAxis: MirrorAreaChartYAxis,\n Tooltip: MirrorAreaChartTooltip,\n Legend: MirrorAreaChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/plot.json b/apps/docs/public/r/plot.json index 7ccdf9363..2e90b9919 100644 --- a/apps/docs/public/r/plot.json +++ b/apps/docs/public/r/plot.json @@ -20,7 +20,7 @@ { "path": "ui/plot.tsx", "type": "registry:ui", - "content": "/**\n * Plot — a chart you assemble, for the chart that is not in the box.\n *\n * The other charts in this library each answer one question and answer it\n * completely: a line chart knows it is drawing a series over time, and every\n * decision inside it follows from that. This one knows nothing. It measures a\n * box, resolves a scale, and hands both to whatever marks you put in it — so a\n * combination nothing here ships as its own component is still a chart you can\n * build rather than a chart you have to go without.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## One scale, however many marks\n *\n * Every mark reads the same plot box and the same y-domain, and the domain is\n * derived from all of them together. That is the whole reason to compose rather\n * than to stack two charts on top of each other: two scales drawn over each\n * other look like a comparison and are not one.\n *\n * ## Where your own marks go\n *\n * `Plot.Layer` drops its children into the SVG tree, and `usePlot()` gives them\n * the resolved geometry — the box, the tweening domain, the reveal, the palette.\n * The scale functions are worklets exported alongside this component, so a mark\n * of your own is rebuilt on the UI thread on the same frames these are:\n *\n * ```tsx\n * function Threshold({ value }: { value: number }) {\n * const { plot, domainMin, domainMax } = usePlot();\n * const props = useAnimatedProps(() => {\n * const y = yOf(value, plot, domainMin.value, domainMax.value);\n * return { d: `M${plot.left},${y}H${plot.left + plot.width}` };\n * });\n * return ;\n * }\n *\n * \n * \n * \n * ```\n *\n * Anything that is text or takes a touch goes in `Plot.Overlay` instead, which\n * is a React Native view over the drawing. That split is not a preference: SVG\n * text ignores the platform's text scaling and the theme's font, and a gesture\n * handler cannot be attached to an SVG node at all.\n *\n * ## What it will not do for you\n *\n * There is no `type` prop and no set of defaults that guess at one. A chart\n * assembled here is exactly the marks you wrote, in the order you wrote them —\n * which is also the order they are drawn in, so a line over bars is a line\n * written after them.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n Path,\n Rect,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport { finiteChartNumber } from '@/components/ui/finite-chart';\nimport {\n areaPath,\n bandOf,\n barPath,\n columnValues,\n compactNumber,\n linePath,\n niceDomain,\n useSeriesColor,\n xOf,\n yOf,\n type Plot as PlotBox,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport {\n registerPlotSeries,\n unregisterPlotSeries,\n visiblePlotSeries,\n type PlotSeriesRegistration,\n} from '@/components/ui/plot-series-registry';\n\n/*\n * The scale functions, re-exported so a mark written outside this file can sit\n * on the same geometry the built-in ones do. Every one of them is a worklet, so\n * a custom mark is rebuilt on the UI thread on the frames these are — which is\n * the difference between an escape hatch and a second, slower chart drawn on\n * top of the first.\n */\nexport {\n areaPath,\n bandOf,\n barPath,\n compactNumber,\n linePath,\n segment,\n xAt,\n xOf,\n yOf,\n} from '@/lib/chart';\nexport type { Plot as PlotBox, ChartPoint } from '@/lib/chart';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/** Room left around the plot for the axis labels and the markers' rings. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the rule it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each x label is centred in; a longer one is ellipsised rather than shoving. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Width of the readout that rides the cursor. */\nconst LABEL_WIDTH = 120;\n\n/**\n * Which layer a part belongs to. Read off the component itself, so composition\n * stays a flat list of children instead of three nested slots whose order the\n * caller has to remember.\n */\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type PlotStatus = 'loading' | 'ready';\nexport type PlotCurve = 'monotone' | 'linear';\nexport type PlotDatum = Record;\n\n/**\n * How the index of a row becomes an x.\n *\n * `point` puts the first and last rows on the plot's own edges, which is what a\n * series wants — the line should reach the frame. `band` gives every row an\n * equal slice and centres it in the middle of that, which is what anything with\n * width wants: a bar sitting on the edge of the plot is a bar half of which is\n * outside it.\n *\n * Resolved from the marks by default, so a chart with bars in it is banded\n * without being told.\n */\nexport type PlotScale = 'point' | 'band';\n\n/** One end of the y-domain: a number to pin it at, or `auto` to derive it. */\nexport type PlotBound = number | 'auto';\n\n/** Everything a mark needs to draw itself. Read it with `usePlot()`. */\nexport interface PlotGeometry {\n /** The rows, in order. */\n data: PlotDatum[];\n /** Key holding the x label. */\n xDataKey: string;\n /** The drawable box, after the padding and any axis gutter are taken off. */\n plot: PlotBox;\n /** How an index becomes an x. */\n xScale: PlotScale;\n /** `loading` draws nothing but the frame. */\n status: PlotStatus;\n /**\n * The y-domain, tweened. Read these inside worklets — they are what makes a\n * chart whose numbers changed redraw against a moving axis rather than jump.\n */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /**\n * The domain the tween is heading for. Labels read this rather than the shared\n * values: a number re-rendered on every frame of a tween is thirty renders of\n * a label that lands on the string it started on.\n */\n extent: [number, number];\n /** `0` to `1` as the plot is uncovered on mount. */\n reveal: SharedValue;\n /** The five theme series colours, in order of prominence. */\n palette: string[];\n /** Every mark that registered itself, as `[dataKey, colour]`. */\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string, color?: string) => void;\n /** Row under the cursor, or `-1`. On the UI thread. */\n activeIndex: SharedValue;\n /** The same index on the JS thread, for anything that has to re-render. */\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n /** The clip every mark shares, so they are revealed as one drawing. */\n}\n\nconst PlotContext = createContext(null);\n\nfunction useChart(component: string): PlotGeometry {\n const context = useContext(PlotContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The resolved geometry, for a mark of your own.\n *\n * Everything in it is either a plain number that changes on layout or a shared\n * value that changes every frame, so a mark built from it animates with the\n * rest of the chart rather than beside it.\n */\nexport function usePlot(): PlotGeometry {\n return useChart('usePlot');\n}\n\n/** The row under the cursor, for a readout drawn inside the plot. */\nexport function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum | null } {\n const { data, activeIndexJS } = useChart('usePlotCursor');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n };\n}\n\nexport interface PlotProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a position along the x-axis. */\n data: PlotDatum[];\n /** Key holding the x label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` draws the frame and nothing in it, and reveals the marks when it\n * turns `ready`. One component throughout rather than a spinner swapped for a\n * chart — swapping loses the transition.\n */\n status?: PlotStatus;\n /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */\n aspectRatio?: number;\n /** Milliseconds for the plot to be uncovered on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * The y-domain, as `[low, high]`. Either end may be a number to pin it there\n * or `auto` to take it from the data.\n *\n * Pinning one end is the case this exists for: `[0, 'auto']` keeps the\n * baseline at zero, which a chart of lengths needs — a bar cropped at the\n * bottom is a length that lies — while still letting the top follow whatever\n * arrives.\n */\n yDomain?: [PlotBound, PlotBound];\n /**\n * Round the derived ends of the y-domain out to whole numbers.\n *\n * Left off, an axis ends a tenth of the span past the largest value, so it\n * gets labelled 34,650 — true, and not a number anybody was looking for. On,\n * the ends move out to a step of 1, 2 or 5 times a power of ten, and the\n * labels become values a reader can measure against.\n *\n * It only ever widens the axis, and a pinned end is left alone.\n */\n nice?: boolean;\n /**\n * How an index becomes an x. Derived from the marks when left out: a plot\n * with bars in it is banded, and anything else is on points.\n */\n xScale?: PlotScale;\n /** How series are joined between points, unless a mark overrides it. */\n curve?: PlotCurve;\n /**\n * The row under the cursor as it moves, and `-1`/`null` when the finger\n * lifts. This is how a readout *outside* the plot gets its value — that\n * header is not inside this provider, so it cannot use `usePlotCursor`.\n */\n onActiveIndexChange?: (index: number, datum: PlotDatum | null) => void;\n /**\n * Drop the padding so the marks reach the edges — for a plot with no axis,\n * grid or cursor, where the shape is the whole point.\n */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal, for a \"replay\" control. */\nexport interface PlotHandle {\n replay: () => void;\n}\n\nconst PlotRoot = forwardRef(function PlotRoot(\n {\n className,\n data,\n xDataKey = 'label',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n nice = false,\n xScale: xScaleProp,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registrations, setRegistrations] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setRegistrations((current) => registerPlotSeries(current, key, color)),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string, color?: string) =>\n setRegistrations((current) => unregisterPlotSeries(current, key, color)),\n []\n );\n\n const series = useMemo(() => visiblePlotSeries(registrations), [registrations]);\n\n /*\n * What the children need decided before anything is laid out.\n *\n * Both have to be known *before* the plot box exists, and neither can be\n * asked of the parts themselves — a part renders into a box that has already\n * been decided. An axis given no gutter is drawn over the marks; a bar placed\n * on a point scale hangs half of itself off the frame, and a bar on an axis\n * that skips zero is drawn at a length the data does not support.\n */\n const { hasYAxis, hasBars } = useMemo(() => {\n let axis = false;\n let bars = false;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; mark?: string };\n if (type.axis === 'y') axis = true;\n if (type.mark === 'band') bars = true;\n });\n return { hasYAxis: axis, hasBars: bars };\n }, [children]);\n\n const xScale: PlotScale = xScaleProp ?? (hasBars ? 'band' : 'point');\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: PlotBox = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * One extent across every registered mark, so two series share one axis and\n * stay comparable. A scale per series makes two quantities orders of\n * magnitude apart look alike, which is the chart lying rather than the chart\n * being convenient.\n */\n const seriesKeys = useMemo(() => series.map(([key]) => key), [series]);\n const extent = useMemo<[number, number]>(() => {\n const lowPin = finiteChartNumber(yDomain?.[0]);\n const highPin = finiteChartNumber(yDomain?.[1]);\n if (lowPin !== undefined && highPin !== undefined) {\n return [lowPin, highPin];\n }\n\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n for (const key of seriesKeys) {\n const value = finiteChartNumber(row[key]);\n if (value === undefined) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n\n if (min === Infinity) {\n // Nothing to measure. Fall back to whatever was pinned, and to a unit\n // domain when nothing was — dividing by a span of zero is how a mark\n // ends up drawn at infinity.\n const low = lowPin ?? 0;\n const high = highPin ?? low + 1;\n return [low, high];\n }\n\n /*\n * A bar's length is measured from zero, so an axis that does not contain\n * zero makes every bar on it a lie about its own size — six columns of\n * near-identical height standing for numbers that differ by half. So a plot\n * with bars in it gets zero whether the data reaches it or not, and that end\n * is then left exactly where it is: headroom under a baseline lifts the bars\n * off the thing they are measured from.\n */\n let lowRoom = 1;\n let highRoom = 1;\n if (hasBars) {\n if (min > 0) min = 0;\n if (max < 0) max = 0;\n if (min === 0) lowRoom = 0;\n if (max === 0) highRoom = 0;\n }\n\n // A flat series has no extent of its own; give it one so it lands on the\n // middle of the plot instead of dividing by zero.\n if (min === max) {\n min -= 1;\n max += 1;\n }\n\n /*\n * Headroom goes on the derived ends only. A pinned zero that got a tenth of\n * the span subtracted from it is not a pinned zero, and the baseline\n * drifting off the axis is exactly what pinning it was for.\n */\n const headroom = (max - min) * 0.1;\n /*\n * `nice` replaces the headroom rather than adding to it: rounding out to a\n * whole step is already room, and doing both would leave a tenth of the\n * span of empty axis above a top the reader was told is round.\n */\n const [niceLow, niceHigh] = nice ? niceDomain(min, max) : [min, max];\n const derivedLow = nice ? niceLow : min - headroom * lowRoom;\n const derivedHigh = nice ? niceHigh : max + headroom * highRoom;\n\n return [\n lowPin ?? derivedLow,\n highPin ?? derivedHigh,\n ];\n }, [data, yDomain, seriesKeys, hasBars, nice]);\n\n const loading = status === 'loading';\n // Nothing has said what the axis is yet — no mark has registered and no\n // domain was given. Assigning one now would tween the real domain up out of a\n // placeholder on the frame the first mark mounts.\n const hasDomain = seriesKeys.length > 0 || yDomain !== undefined;\n\n useEffect(() => {\n if (loading || !hasDomain) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, hasDomain, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n // Going back to `loading` arms the reveal again, so a refetch is uncovered\n // rather than appearing whole on the frame the data lands.\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const c1 = useSeriesColor(undefined, 1);\n const c2 = useSeriesColor(undefined, 2);\n const c3 = useSeriesColor(undefined, 3);\n const c4 = useSeriesColor(undefined, 4);\n const c5 = useSeriesColor(undefined, 5);\n const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n xScale,\n status,\n domainMin,\n domainMax,\n extent,\n reveal,\n palette,\n series,\n registerSeries,\n unregisterSeries,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n xScale,\n status,\n domainMin,\n domainMax,\n extent,\n reveal,\n palette,\n series,\n registerSeries,\n unregisterSeries,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take the header in too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...seriesKeys.map((key) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * One reveal for every mark, and it is a view that grows rather\n * than an SVG clip path. Sharing it is what makes the reveal read\n * as the chart arriving, rather than as four separate things\n * animating in at once — which is the thing a composed chart is\n * most at risk of looking like.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the marks drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the marks\n * keep the same coordinate space as the grid underneath them.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nPlotRoot.displayName = 'Plot';\n\n/** Sorts the children into the SVG tree, the view layer over it, and the header. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, series, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/**\n * A mark's colour: the one it was given, else the theme token at its index.\n *\n * Registration happens here rather than in each mark, because every mark that\n * registers does it for the same two reasons — the domain has to take it in,\n * and the legend has to name it.\n */\nfunction useMark(dataKey: string, color: string | undefined, colorIndex: number) {\n const { palette, registerSeries, unregisterSeries } = useChart('Plot mark');\n const resolved = color ?? palette[(colorIndex - 1) % palette.length] ?? palette[0]!;\n\n useEffect(() => {\n registerSeries(dataKey, resolved);\n return () => unregisterSeries(dataKey, resolved);\n }, [dataKey, resolved, registerSeries, unregisterSeries]);\n\n return resolved;\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Horizontal reference lines.\n *\n * Outside the reveal clip on purpose: the grid is the frame the chart arrives\n * into, so it is already there when the marks start being uncovered.\n */\nfunction PlotGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: PlotGridProps) {\n const { plot } = useChart('Plot.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nPlotGrid.displayName = 'Plot.Grid';\nPlotGrid.layer = 'svg' as Layer;\n\nexport interface PlotSeriesProps {\n /** Column of `data` this mark draws. */\n dataKey: string;\n /** Overrides the theme token. */\n color?: string;\n /** Which `--color-chart-*` token to take, `1` to `5`. */\n colorIndex?: number;\n}\n\nexport interface PlotLineProps extends PlotSeriesProps {\n strokeWidth?: number;\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: PlotCurve;\n /** Dash pattern, e.g. `\"6,4\"` — for a forecast, or a series that is not real. */\n dashArray?: string;\n}\n\n/** A series as a stroked line. */\nfunction PlotLine({\n dataKey,\n color,\n colorIndex = 1,\n strokeWidth = 2.5,\n curve = 'monotone',\n dashArray,\n}: PlotLineProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Line');\n const stroke = useMark(dataKey, color, colorIndex);\n\n /*\n * Pulled out of the worklet as a plain array. A worklet may close over numbers\n * and arrays of them freely, but reading `data`'s rows — objects of mixed\n * types, keyed by strings the caller chose — inside one on every frame is work\n * that belongs on the JS side once.\n */\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0) return { d: '' };\n\n /*\n * `banded` moves the points to the middle of their own slice, which is\n * where a line drawn over columns has to sit — spread edge to edge it\n * starts half a slice left of the bar it is describing.\n */\n return { d: linePath(values, plot, min, max, curve, false, undefined, banded) };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotLine.displayName = 'Plot.Line';\nPlotLine.layer = 'series' as Layer;\n\nexport interface PlotAreaProps extends PlotSeriesProps {\n opacity?: number;\n curve?: PlotCurve;\n}\n\n/**\n * A series as a fill down to the baseline.\n *\n * Written before the line it belongs under, since the order the marks are\n * written is the order they are drawn.\n */\nfunction PlotArea({\n dataKey,\n color,\n colorIndex = 1,\n opacity = 0.18,\n curve = 'monotone',\n}: PlotAreaProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Area');\n const fill = useMark(dataKey, color, colorIndex);\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0) return { d: '' };\n // Band-aware for the same reason the line is: an area on a plot that also\n // carries columns has to close over the same slices they occupy.\n return { d: areaPath(values, plot, min, max, curve, false, undefined, banded) };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotArea.displayName = 'Plot.Area';\nPlotArea.layer = 'series' as Layer;\n\nexport interface PlotBarsProps extends PlotSeriesProps {\n /** Fraction of each slice left empty, `0` to `1`. */\n gap?: number;\n /** Rounds the end the bar grows towards, in points. */\n radius?: number;\n opacity?: number;\n /**\n * The value the columns grow from. Zero by default, and zero is nearly always\n * right — a bar is a length, and a length has to start where the quantity\n * does.\n *\n * Set it for the case where the reader is being shown movement rather than\n * size: temperatures against a seasonal average, a score against a pass mark.\n * Columns then run up and down from that line instead of all standing on the\n * floor. It is clamped into the axis, so a baseline the domain does not cover\n * falls back to the nearer edge.\n */\n baseline?: number;\n}\n\n/**\n * A series as columns.\n *\n * One path for all of them rather than one node per bar: a plot of two hundred\n * periods is the same single animated prop a frame as a plot of twenty.\n *\n * A bar has width, so its presence puts the whole plot on a band scale unless\n * the root was told otherwise — see `xScale`.\n */\nfunction PlotBars({\n dataKey,\n color,\n colorIndex = 1,\n gap = 0.35,\n radius = 4,\n opacity = 1,\n baseline = 0,\n}: PlotBarsProps) {\n const { data, plot, domainMin, domainMax, status } = useChart('Plot.Bars');\n const fill = useMark(dataKey, color, colorIndex);\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n const total = values.length;\n const slice = total > 0 ? plot.width / total : 0;\n const width = Math.max(1, slice * (1 - Math.min(Math.max(gap, 0), 0.95)));\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0 || total === 0) return { d: '' };\n\n /*\n * Bars grow from the baseline — zero unless the caller moved it — clamped\n * into the domain where the axis does not reach it. A bar drawn from the\n * bottom of a plot whose axis starts at 40 is a length that is not in the\n * data.\n */\n const base = yOf(Math.min(Math.max(baseline, min), max), plot, min, max);\n let path = '';\n\n for (let index = 0; index < total; index += 1) {\n const value = values[index];\n if (value === null || value === undefined) continue;\n const y = yOf(value, plot, min, max);\n const top = Math.min(y, base);\n const height = Math.abs(base - y);\n if (height <= 0) continue;\n path += barPath(\n bandOf(index, total, plot) - width / 2,\n top,\n width,\n height,\n radius,\n y <= base ? 'up' : 'down'\n );\n }\n return { d: path };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotBars.displayName = 'Plot.Bars';\nPlotBars.layer = 'series' as Layer;\n// Read by the root before it resolves the scale: a mark with width cannot sit\n// on a point scale without half of the first and last one leaving the plot.\nPlotBars.mark = 'band' as const;\n\nexport interface PlotDotsProps extends PlotSeriesProps {\n /** Radius, in points. */\n size?: number;\n /** Ring around each dot, so it reads on top of the line rather than in it. */\n ringWidth?: number;\n}\n\n/** A dot per row — for a series short enough that its points are worth marking. */\nfunction PlotDots({\n dataKey,\n color,\n colorIndex = 1,\n size = 3.5,\n ringWidth = 2,\n}: PlotDotsProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Dots');\n const fill = useMark(dataKey, color, colorIndex);\n const ringToken = useCSSVariable('--color-background');\n const ring = typeof ringToken === 'string' ? ringToken : '#000000';\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n return (\n \n {status === 'loading'\n ? null\n : values.map((value, index) =>\n value === null || value === undefined ? null : (\n \n )\n )}\n \n );\n}\nPlotDots.displayName = 'Plot.Dots';\nPlotDots.layer = 'series' as Layer;\n\n/** One dot, whose y follows the domain tween. */\nfunction Dot({\n value,\n x,\n plot,\n domainMin,\n domainMax,\n fill,\n ring,\n size,\n ringWidth,\n}: {\n value: number;\n x: number;\n plot: PlotBox;\n domainMin: SharedValue;\n domainMax: SharedValue;\n fill: string;\n ring: string;\n size: number;\n ringWidth: number;\n}) {\n const animatedProps = useAnimatedProps(() => ({\n cy: yOf(value, plot, domainMin.value, domainMax.value),\n }));\n\n return (\n \n );\n}\n\nexport interface PlotLayerProps {\n children?: ReactNode;\n}\n\n/**\n * Marks of your own, in the SVG tree.\n *\n * Whatever is inside is drawn where it is written — before the marks written\n * after it, after the ones before it — and reaches the geometry through\n * `usePlot()`. It is not given the geometry as an argument, because a mark that\n * animates has to hold hooks of its own and a render prop is not a component.\n */\nfunction PlotLayer({ children }: PlotLayerProps) {\n useChart('Plot.Layer');\n return {children};\n}\nPlotLayer.displayName = 'Plot.Layer';\nPlotLayer.layer = 'series' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotOverlayProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Anything of your own that is text or takes a touch, laid over the drawing.\n *\n * SVG text ignores the platform's text scaling and the theme's font, and a\n * gesture handler cannot be attached to an SVG node at all — so those two go\n * here rather than in `Plot.Layer`.\n */\nfunction PlotOverlay({ className, children }: PlotOverlayProps) {\n return (\n \n {children}\n \n );\n}\nPlotOverlay.displayName = 'Plot.Overlay';\nPlotOverlay.layer = 'overlay' as Layer;\n\nexport interface PlotRuleProps {\n /**\n * Where to draw it, in the data's own units. Omit it and pass `x` instead for\n * a rule down the plot rather than across it.\n */\n y?: number;\n /**\n * A row to draw a vertical rule at, by index — the release the numbers are\n * read against, the day a change landed.\n *\n * The x axis here carries positions rather than quantities, so this is which\n * row rather than what value. Exactly one of `y` and `x` is drawn; `y` wins\n * if both are given.\n */\n x?: number;\n /** A name for what the line means. Nothing is drawn without one. */\n label?: string;\n /** Overrides the line *and* its caption, so the two cannot drift apart. */\n color?: string;\n /** Thickness in points. */\n strokeWidth?: number;\n /**\n * Break the line into dashes, for a rule that should read as an annotation\n * rather than as a series the chart is drawing.\n */\n dashed?: boolean;\n /** Fades the line and its caption together. */\n opacity?: number;\n /** Which end of the rule the caption sits at. */\n labelPlacement?: 'start' | 'end';\n labelClassName?: string;\n className?: string;\n}\n\n/**\n * A reference line across the plot — a target, a limit, an average.\n *\n * A view rather than an SVG line, so its label is real text and follows the\n * theme. Its thickness is a border and its position is a transform, so it costs\n * no more than the SVG line would while gaining a legible caption.\n *\n * It is drawn at full strength in the foreground colour, and it is meant to be.\n * A reference line is the number the series is being judged against — a target\n * nobody can read is a target the chart is not actually stating — so what keeps\n * it from being mistaken for a series is that it is neutral and optionally\n * dashed, not that it is faint.\n */\nfunction PlotRule({\n y,\n x,\n label,\n color,\n strokeWidth = 1,\n dashed = false,\n opacity = 1,\n labelPlacement = 'end',\n labelClassName,\n className,\n}: PlotRuleProps) {\n const { plot, domainMin, domainMax, status, data } = useChart('Plot.Rule');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const vertical = y === undefined && x !== undefined;\n\n const horizontalStyle = useAnimatedStyle(() => {\n if (vertical || y === undefined) return { opacity: 0 };\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min) return { opacity: 0 };\n const at = yOf(y, plot, min, max);\n // Off the top or bottom of the plot is out of the chart's range, and a rule\n // pinned to the edge would claim a value the axis does not cover.\n const inside = at >= plot.top - 0.5 && at <= plot.top + plot.height + 0.5;\n return { opacity: inside ? opacity : 0, transform: [{ translateY: at }] };\n });\n\n if (status === 'loading') return null;\n\n const line = dashed\n ? {\n borderColor: stroke,\n borderStyle: 'dashed' as const,\n borderTopWidth: strokeWidth,\n }\n : { backgroundColor: stroke, height: strokeWidth };\n\n if (vertical) {\n /*\n * The x axis carries positions, not quantities, so a vertical rule is\n * placed from the row's own geometry rather than from the tweening domain —\n * there is nothing on this axis for it to tween against.\n */\n const total = data.length;\n const at =\n total > 0\n ? xOf(Math.max(0, Math.min(total - 1, x!)), total, plot)\n : plot.left + plot.width / 2;\n\n return (\n \n \n {label ? (\n \n {label}\n \n ) : null}\n \n );\n }\n\n return (\n \n \n {label ? (\n \n {label}\n \n ) : null}\n \n );\n}\nPlotRule.displayName = 'Plot.Rule';\nPlotRule.layer = 'overlay' as Layer;\n\nexport interface PlotXAxisProps {\n /** How many labels to show. The rest are dropped, evenly. */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: PlotDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The x labels. Real text rather than SVG text, so they follow the theme's font\n * and the platform's text scaling — SVG text does neither.\n */\nfunction PlotXAxis({ ticks = 4, format, className }: PlotXAxisProps) {\n const { data, xDataKey, plot, xScale } = useChart('Plot.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0, 3, 6, 8, 11 — so spreading them evenly put the ones in\n * between over the wrong part of the plot.\n */\n return (\n \n {labels.map((label) => {\n const x =\n xScale === 'band'\n ? bandOf(label.key, data.length, plot)\n : xOf(label.key, data.length, plot);\n return (\n \n {label.text}\n \n );\n })}\n \n );\n}\nPlotXAxis.displayName = 'Plot.XAxis';\nPlotXAxis.layer = 'overlay' as Layer;\n\nexport interface PlotYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Value labels down the side, one per grid line.\n *\n * Give it the same `ticks` as the grid, or the numbers name lines that are not\n * there. Four is the default on both for that reason.\n *\n * The labels are the domain the data settles at, not the tweening one — a\n * number counting through every intermediate value while the axis animates is\n * noise, and the axis is the part of a chart that has to hold still enough to\n * be read.\n */\nfunction PlotYAxis({ ticks = 4, format, className }: PlotYAxisProps) {\n const { plot, extent } = useChart('Plot.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nPlotYAxis.displayName = 'Plot.YAxis';\nPlotYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nPlotYAxis.axis = 'y' as const;\n\nexport interface PlotCursorProps {\n color?: string;\n /** Hide the vertical line and keep only the touch handling. */\n showLine?: boolean;\n}\n\n/**\n * The touch handling, and the line that follows it.\n *\n * Split from the readout next door because they are separately useful: a plot\n * whose value is shown in its own header wants this and no label, and a plot\n * that highlights a bar wants this and nothing else at all. Both read the same\n * index.\n *\n * The hit area is the whole plot. A cursor you have to land on the line to\n * summon is a cursor nobody finds.\n */\nfunction PlotCursor({ color, showLine = true }: PlotCursorProps) {\n const { data, plot, xScale, activeIndex, setActiveIndexJS, status } =\n useChart('Plot.Cursor');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n const banded = xScale === 'band';\n\n /*\n * Built in one closure, and everything it captures is a number or a shared\n * value. A worklet may only call another worklet, and the rule is enforced by\n * crashing rather than by warning — so the resolver is declared next to its\n * callers instead of as a helper further down the file where it would be easy\n * to leave un-workletised.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = (x - left) / width;\n const clamped = Math.min(1, Math.max(0, ratio));\n // A band scale divides the plot into `total` slices and the finger is in\n // one of them; a point scale has `total - 1` gaps between marks and the\n // finger rounds to the nearest.\n const next = banded\n ? Math.min(total - 1, Math.floor(clamped * total))\n : Math.round(clamped * Math.max(total - 1, 0));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n // Only the index needs JS, and only when it changes — a drag across a\n // hundred rows costs a hundred re-renders at most, not one per frame for\n // the length of the gesture.\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, banded, activeIndex, setActiveIndexJS]);\n\n const lineStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);\n return { opacity: 0.45, transform: [{ translateX: x }] };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLine ? (\n \n ) : null}\n \n \n );\n}\nPlotCursor.displayName = 'Plot.Cursor';\nPlotCursor.layer = 'overlay' as Layer;\n\nexport interface PlotTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the heading from the row. Defaults to the value at `xDataKey`. */\n formatX?: (datum: PlotDatum) => string;\n /** Draw the readout yourself, given the row under the cursor. */\n children?: (datum: PlotDatum, index: number) => ReactNode;\n className?: string;\n}\n\n/**\n * The readout that rides the cursor.\n *\n * Needs a `Plot.Cursor` beside it — the cursor owns the gesture and this only\n * reads the index it resolves. On its own it never appears, which is the right\n * failure: a label with no way to move is worse than no label.\n */\nfunction PlotTooltip({ formatValue, formatX, children, className }: PlotTooltipProps) {\n const { data, xDataKey, plot, xScale, series, activeIndex, activeIndexJS, status } =\n useChart('Plot.Tooltip');\n\n const total = data.length;\n const banded = xScale === 'band';\n\n // Centred over the cursor but clamped inside the plot, so it never runs off\n // the edge at the first or last row.\n const style = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: PlotDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n {active ? (\n children ? (\n children(active, activeIndexJS)\n ) : (\n <>\n \n {fmtX(active)}\n \n {series.map(([key, color]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n {series.length > 1 ? (\n \n ) : null}\n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n )\n ) : null}\n \n \n );\n}\nPlotTooltip.displayName = 'Plot.Tooltip';\nPlotTooltip.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotHeaderProps extends ViewProps {\n className?: string;\n /** A word for what the plot is of. */\n title?: string;\n /** The figure, large. Usually the total, or the row under the cursor. */\n value?: string;\n /** A line under the value. */\n caption?: string;\n /** Replaces the whole header, keeping only its place above the drawing. */\n children?: ReactNode;\n}\n\n/** The row above the drawing: what it is, and the one number worth reading. */\nfunction PlotHeader({\n className,\n title,\n value,\n caption,\n children,\n ...props\n}: PlotHeaderProps) {\n return (\n \n {children ?? (\n <>\n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n )}\n \n );\n}\nPlotHeader.displayName = 'Plot.Header';\nPlotHeader.layer = 'header' as Layer;\n\nexport interface PlotLegendProps extends ViewProps {\n className?: string;\n /** Names for the columns, keyed by `dataKey`. Falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per mark, taken from the marks that registered.\n *\n * In the header rather than over the drawing, because a key laid inside the\n * plot either covers a mark or is squeezed to one word a line.\n */\nfunction PlotLegend({ className, labels, ...props }: PlotLegendProps) {\n const { series } = useChart('Plot.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n \n \n {labels?.[key] ?? key}\n \n \n ))}\n \n );\n}\nPlotLegend.displayName = 'Plot.Legend';\nPlotLegend.layer = 'header' as Layer;\n\nexport const Plot = Object.assign(PlotRoot, {\n Header: PlotHeader,\n Legend: PlotLegend,\n Grid: PlotGrid,\n Area: PlotArea,\n Bars: PlotBars,\n Line: PlotLine,\n Dots: PlotDots,\n Rule: PlotRule,\n Layer: PlotLayer,\n Overlay: PlotOverlay,\n XAxis: PlotXAxis,\n YAxis: PlotYAxis,\n Cursor: PlotCursor,\n Tooltip: PlotTooltip,\n});\n" + "content": "/**\n * Plot — a chart you assemble, for the chart that is not in the box.\n *\n * The other charts in this library each answer one question and answer it\n * completely: a line chart knows it is drawing a series over time, and every\n * decision inside it follows from that. This one knows nothing. It measures a\n * box, resolves a scale, and hands both to whatever marks you put in it — so a\n * combination nothing here ships as its own component is still a chart you can\n * build rather than a chart you have to go without.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## One scale, however many marks\n *\n * Every mark reads the same plot box and the same y-domain, and the domain is\n * derived from all of them together. That is the whole reason to compose rather\n * than to stack two charts on top of each other: two scales drawn over each\n * other look like a comparison and are not one.\n *\n * ## Where your own marks go\n *\n * `Plot.Layer` drops its children into the SVG tree, and `usePlot()` gives them\n * the resolved geometry — the box, the tweening domain, the reveal, the palette.\n * The scale functions are worklets exported alongside this component, so a mark\n * of your own is rebuilt on the UI thread on the same frames these are:\n *\n * ```tsx\n * function Threshold({ value }: { value: number }) {\n * const { plot, domainMin, domainMax } = usePlot();\n * const props = useAnimatedProps(() => {\n * const y = yOf(value, plot, domainMin.value, domainMax.value);\n * return { d: `M${plot.left},${y}H${plot.left + plot.width}` };\n * });\n * return ;\n * }\n *\n * \n * \n * \n * ```\n *\n * Anything that is text or takes a touch goes in `Plot.Overlay` instead, which\n * is a React Native view over the drawing. That split is not a preference: SVG\n * text ignores the platform's text scaling and the theme's font, and a gesture\n * handler cannot be attached to an SVG node at all.\n *\n * ## What it will not do for you\n *\n * There is no `type` prop and no set of defaults that guess at one. A chart\n * assembled here is exactly the marks you wrote, in the order you wrote them —\n * which is also the order they are drawn in, so a line over bars is a line\n * written after them.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n G,\n Line as SvgLine,\n Path,\n Rect,\n} from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport { finiteChartNumber } from '@/components/ui/finite-chart';\nimport {\n areaPath,\n bandOf,\n barPath,\n columnValues,\n compactNumber,\n linePath,\n niceDomain,\n useSeriesColor,\n xOf,\n yOf,\n type Plot as PlotBox,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport {\n registerPlotSeries,\n unregisterPlotSeries,\n visiblePlotSeries,\n type PlotSeriesRegistration,\n} from '@/components/ui/plot-series-registry';\n\n/*\n * The scale functions, re-exported so a mark written outside this file can sit\n * on the same geometry the built-in ones do. Every one of them is a worklet, so\n * a custom mark is rebuilt on the UI thread on the frames these are — which is\n * the difference between an escape hatch and a second, slower chart drawn on\n * top of the first.\n */\nexport {\n areaPath,\n bandOf,\n barPath,\n compactNumber,\n linePath,\n segment,\n xAt,\n xOf,\n yOf,\n} from '@/lib/chart';\nexport type { Plot as PlotBox, ChartPoint } from '@/lib/chart';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/** Room left around the plot for the axis labels and the markers' rings. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the rule it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each x label is centred in; a longer one is ellipsised rather than shoving. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Width of the readout that rides the cursor. */\nconst LABEL_WIDTH = 120;\n\n/**\n * Which layer a part belongs to. Read off the component itself, so composition\n * stays a flat list of children instead of three nested slots whose order the\n * caller has to remember.\n */\ntype Layer = 'svg' | 'series' | 'overlay' | 'header';\n\nexport type PlotStatus = 'loading' | 'ready';\nexport type PlotCurve = 'monotone' | 'linear';\nexport type PlotDatum = Record;\n\n/**\n * How the index of a row becomes an x.\n *\n * `point` puts the first and last rows on the plot's own edges, which is what a\n * series wants — the line should reach the frame. `band` gives every row an\n * equal slice and centres it in the middle of that, which is what anything with\n * width wants: a bar sitting on the edge of the plot is a bar half of which is\n * outside it.\n *\n * Resolved from the marks by default, so a chart with bars in it is banded\n * without being told.\n */\nexport type PlotScale = 'point' | 'band';\n\n/** One end of the y-domain: a number to pin it at, or `auto` to derive it. */\nexport type PlotBound = number | 'auto';\n\n/** Everything a mark needs to draw itself. Read it with `usePlot()`. */\nexport interface PlotGeometry {\n /** The rows, in order. */\n data: PlotDatum[];\n /** Key holding the x label. */\n xDataKey: string;\n /** The drawable box, after the padding and any axis gutter are taken off. */\n plot: PlotBox;\n /** How an index becomes an x. */\n xScale: PlotScale;\n /** `loading` draws nothing but the frame. */\n status: PlotStatus;\n /**\n * The y-domain, tweened. Read these inside worklets — they are what makes a\n * chart whose numbers changed redraw against a moving axis rather than jump.\n */\n domainMin: SharedValue;\n domainMax: SharedValue;\n /**\n * The domain the tween is heading for. Labels read this rather than the shared\n * values: a number re-rendered on every frame of a tween is thirty renders of\n * a label that lands on the string it started on.\n */\n extent: [number, number];\n /** `0` to `1` as the plot is uncovered on mount. */\n reveal: SharedValue;\n /** The five theme series colours, in order of prominence. */\n palette: string[];\n /** Every mark that registered itself, as `[dataKey, colour]`. */\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string, color?: string) => void;\n /** Row under the cursor, or `-1`. On the UI thread. */\n activeIndex: SharedValue;\n /** The same index on the JS thread, for anything that has to re-render. */\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n /** The clip every mark shares, so they are revealed as one drawing. */\n}\n\nconst PlotContext = createContext(null);\n\nfunction useChart(component: string): PlotGeometry {\n const context = useContext(PlotContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The resolved geometry, for a mark of your own.\n *\n * Everything in it is either a plain number that changes on layout or a shared\n * value that changes every frame, so a mark built from it animates with the\n * rest of the chart rather than beside it.\n */\nexport function usePlot(): PlotGeometry {\n return useChart('usePlot');\n}\n\n/** The row under the cursor, for a readout drawn inside the plot. */\nexport function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum | null } {\n const { data, activeIndexJS } = useChart('usePlotCursor');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n };\n}\n\nexport interface PlotProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a position along the x-axis. */\n data: PlotDatum[];\n /** Key holding the x label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` draws the frame and nothing in it, and reveals the marks when it\n * turns `ready`. One component throughout rather than a spinner swapped for a\n * chart — swapping loses the transition.\n */\n status?: PlotStatus;\n /** Width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */\n aspectRatio?: number;\n /** Milliseconds for the plot to be uncovered on mount. */\n animationDuration?: number;\n /** Milliseconds for the y-axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * The y-domain, as `[low, high]`. Either end may be a number to pin it there\n * or `auto` to take it from the data.\n *\n * Pinning one end is the case this exists for: `[0, 'auto']` keeps the\n * baseline at zero, which a chart of lengths needs — a bar cropped at the\n * bottom is a length that lies — while still letting the top follow whatever\n * arrives.\n */\n yDomain?: [PlotBound, PlotBound];\n /**\n * Round the derived ends of the y-domain out to whole numbers.\n *\n * Left off, an axis ends a tenth of the span past the largest value, so it\n * gets labelled 34,650 — true, and not a number anybody was looking for. On,\n * the ends move out to a step of 1, 2 or 5 times a power of ten, and the\n * labels become values a reader can measure against.\n *\n * It only ever widens the axis, and a pinned end is left alone.\n */\n nice?: boolean;\n /**\n * How an index becomes an x. Derived from the marks when left out: a plot\n * with bars in it is banded, and anything else is on points.\n */\n xScale?: PlotScale;\n /** How series are joined between points, unless a mark overrides it. */\n curve?: PlotCurve;\n /**\n * The row under the cursor as it moves, and `-1`/`null` when the finger\n * lifts. This is how a readout *outside* the plot gets its value — that\n * header is not inside this provider, so it cannot use `usePlotCursor`.\n */\n onActiveIndexChange?: (index: number, datum: PlotDatum | null) => void;\n /**\n * Drop the padding so the marks reach the edges — for a plot with no axis,\n * grid or cursor, where the shape is the whole point.\n */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal, for a \"replay\" control. */\nexport interface PlotHandle {\n replay: () => void;\n}\n\nconst PlotRoot = forwardRef(function PlotRoot(\n {\n className,\n data,\n xDataKey = 'label',\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n nice = false,\n xScale: xScaleProp,\n curve = 'monotone',\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [registrations, setRegistrations] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setRegistrations((current) => registerPlotSeries(current, key, color)),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string, color?: string) =>\n setRegistrations((current) => unregisterPlotSeries(current, key, color)),\n []\n );\n\n const series = useMemo(() => visiblePlotSeries(registrations), [registrations]);\n\n /*\n * What the children need decided before anything is laid out.\n *\n * Both have to be known *before* the plot box exists, and neither can be\n * asked of the parts themselves — a part renders into a box that has already\n * been decided. An axis given no gutter is drawn over the marks; a bar placed\n * on a point scale hangs half of itself off the frame, and a bar on an axis\n * that skips zero is drawn at a length the data does not support.\n */\n const { hasYAxis, hasBars } = useMemo(() => {\n let axis = false;\n let bars = false;\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const type = child.type as { axis?: string; mark?: string };\n if (type.axis === 'y') axis = true;\n if (type.mark === 'band') bars = true;\n });\n return { hasYAxis: axis, hasBars: bars };\n }, [children]);\n\n const xScale: PlotScale = xScaleProp ?? (hasBars ? 'band' : 'point');\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: PlotBox = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n /*\n * One extent across every registered mark, so two series share one axis and\n * stay comparable. A scale per series makes two quantities orders of\n * magnitude apart look alike, which is the chart lying rather than the chart\n * being convenient.\n */\n const seriesKeys = useMemo(() => series.map(([key]) => key), [series]);\n const extent = useMemo<[number, number]>(() => {\n const lowPin = finiteChartNumber(yDomain?.[0]);\n const highPin = finiteChartNumber(yDomain?.[1]);\n if (lowPin !== undefined && highPin !== undefined) {\n return [lowPin, highPin];\n }\n\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n for (const key of seriesKeys) {\n const value = finiteChartNumber(row[key]);\n if (value === undefined) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n }\n\n if (min === Infinity) {\n // Nothing to measure. Fall back to whatever was pinned, and to a unit\n // domain when nothing was — dividing by a span of zero is how a mark\n // ends up drawn at infinity.\n const low = lowPin ?? 0;\n const high = highPin ?? low + 1;\n return [low, high];\n }\n\n /*\n * A bar's length is measured from zero, so an axis that does not contain\n * zero makes every bar on it a lie about its own size — six columns of\n * near-identical height standing for numbers that differ by half. So a plot\n * with bars in it gets zero whether the data reaches it or not, and that end\n * is then left exactly where it is: headroom under a baseline lifts the bars\n * off the thing they are measured from.\n */\n let lowRoom = 1;\n let highRoom = 1;\n if (hasBars) {\n if (min > 0) min = 0;\n if (max < 0) max = 0;\n if (min === 0) lowRoom = 0;\n if (max === 0) highRoom = 0;\n }\n\n // A flat series has no extent of its own; give it one so it lands on the\n // middle of the plot instead of dividing by zero.\n if (min === max) {\n min -= 1;\n max += 1;\n }\n\n /*\n * Headroom goes on the derived ends only. A pinned zero that got a tenth of\n * the span subtracted from it is not a pinned zero, and the baseline\n * drifting off the axis is exactly what pinning it was for.\n */\n const headroom = (max - min) * 0.1;\n /*\n * `nice` replaces the headroom rather than adding to it: rounding out to a\n * whole step is already room, and doing both would leave a tenth of the\n * span of empty axis above a top the reader was told is round.\n */\n const [niceLow, niceHigh] = nice ? niceDomain(min, max) : [min, max];\n const derivedLow = nice ? niceLow : min - headroom * lowRoom;\n const derivedHigh = nice ? niceHigh : max + headroom * highRoom;\n\n return [\n lowPin ?? derivedLow,\n highPin ?? derivedHigh,\n ];\n }, [data, yDomain, seriesKeys, hasBars, nice]);\n\n const loading = status === 'loading';\n // Nothing has said what the axis is yet — no mark has registered and no\n // domain was given. Assigning one now would tween the real domain up out of a\n // placeholder on the frame the first mark mounts.\n const hasDomain = seriesKeys.length > 0 || yDomain !== undefined;\n\n useEffect(() => {\n if (loading || !hasDomain) return;\n const [min, max] = extent;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, hasDomain, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n // Going back to `loading` arms the reveal again, so a refetch is uncovered\n // rather than appearing whole on the frame the data lands.\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const c1 = useSeriesColor(undefined, 1);\n const c2 = useSeriesColor(undefined, 2);\n const c3 = useSeriesColor(undefined, 3);\n const c4 = useSeriesColor(undefined, 4);\n const c5 = useSeriesColor(undefined, 5);\n const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n xScale,\n status,\n domainMin,\n domainMax,\n extent,\n reveal,\n palette,\n series,\n registerSeries,\n unregisterSeries,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n xScale,\n status,\n domainMin,\n domainMax,\n extent,\n reveal,\n palette,\n series,\n registerSeries,\n unregisterSeries,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));\n\n const { svg, series: seriesLayer, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take the header in too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...seriesKeys.map((key) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {/*\n * One reveal for every mark, and it is a view that grows rather\n * than an SVG clip path. Sharing it is what makes the reveal read\n * as the chart arriving, rather than as four separate things\n * animating in at once — which is the thing a composed chart is\n * most at risk of looking like.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip — the\n * rect keeps whatever width was declared on it, so the marks drew\n * complete and the reveal simply did not play. A view with\n * `overflow: 'hidden'` is clipped by the platform itself, which\n * both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. Here the\n * view is absolutely positioned and its only child is an ``\n * with an explicit width and height, so the work is one node and\n * nothing around it moves.\n *\n * The inner `` is pulled back by `plot.left` so the marks\n * keep the same coordinate space as the grid underneath them.\n */}\n \n \n {seriesLayer}\n \n \n {overlay}\n \n ) : null}\n \n \n \n );\n});\nPlotRoot.displayName = 'Plot';\n\n/** Sorts the children into the SVG tree, the view layer over it, and the header. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const series: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const bucket =\n layer === 'header'\n ? header\n : layer === 'overlay'\n ? overlay\n : layer === 'series'\n ? series\n : svg;\n bucket.push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, series, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/**\n * A mark's colour: the one it was given, else the theme token at its index.\n *\n * Registration happens here rather than in each mark, because every mark that\n * registers does it for the same two reasons — the domain has to take it in,\n * and the legend has to name it.\n */\nfunction useMark(dataKey: string, color: string | undefined, colorIndex: number) {\n const { palette, registerSeries, unregisterSeries } = useChart('Plot mark');\n const resolved = color ?? palette[(colorIndex - 1) % palette.length] ?? palette[0]!;\n\n useEffect(() => {\n registerSeries(dataKey, resolved);\n return () => unregisterSeries(dataKey, resolved);\n }, [dataKey, resolved, registerSeries, unregisterSeries]);\n\n return resolved;\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Horizontal reference lines.\n *\n * Outside the reveal clip on purpose: the grid is the frame the chart arrives\n * into, so it is already there when the marks start being uncovered.\n */\nfunction PlotGrid({ rows = 4, color, dashArray = '4,6', opacity = 1 }: PlotGridProps) {\n const { plot } = useChart('Plot.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n \n );\n}\nPlotGrid.displayName = 'Plot.Grid';\nPlotGrid.layer = 'svg' as Layer;\n\nexport interface PlotSeriesProps {\n /** Column of `data` this mark draws. */\n dataKey: string;\n /** Overrides the theme token. */\n color?: string;\n /** Which `--color-chart-*` token to take, `1` to `5`. */\n colorIndex?: number;\n}\n\nexport interface PlotLineProps extends PlotSeriesProps {\n strokeWidth?: number;\n /** `monotone` never overshoots between points; `linear` joins them straight. */\n curve?: PlotCurve;\n /** Dash pattern, e.g. `\"6,4\"` — for a forecast, or a series that is not real. */\n dashArray?: string;\n}\n\n/** A series as a stroked line. */\nfunction PlotLine({\n dataKey,\n color,\n colorIndex = 1,\n strokeWidth = 2.5,\n curve = 'monotone',\n dashArray,\n}: PlotLineProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Line');\n const stroke = useMark(dataKey, color, colorIndex);\n\n /*\n * Pulled out of the worklet as a plain array. A worklet may close over numbers\n * and arrays of them freely, but reading `data`'s rows — objects of mixed\n * types, keyed by strings the caller chose — inside one on every frame is work\n * that belongs on the JS side once.\n */\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0) return { d: '' };\n\n /*\n * `banded` moves the points to the middle of their own slice, which is\n * where a line drawn over columns has to sit — spread edge to edge it\n * starts half a slice left of the bar it is describing.\n */\n return { d: linePath(values, plot, min, max, curve, false, undefined, banded) };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotLine.displayName = 'Plot.Line';\nPlotLine.layer = 'series' as Layer;\n\nexport interface PlotAreaProps extends PlotSeriesProps {\n opacity?: number;\n curve?: PlotCurve;\n}\n\n/**\n * A series as a fill down to the baseline.\n *\n * Written before the line it belongs under, since the order the marks are\n * written is the order they are drawn.\n */\nfunction PlotArea({\n dataKey,\n color,\n colorIndex = 1,\n opacity = 0.18,\n curve = 'monotone',\n}: PlotAreaProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Area');\n const fill = useMark(dataKey, color, colorIndex);\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0) return { d: '' };\n // Band-aware for the same reason the line is: an area on a plot that also\n // carries columns has to close over the same slices they occupy.\n return { d: areaPath(values, plot, min, max, curve, false, undefined, banded) };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotArea.displayName = 'Plot.Area';\nPlotArea.layer = 'series' as Layer;\n\nexport interface PlotBarsProps extends PlotSeriesProps {\n /** Fraction of each slice left empty, `0` to `1`. */\n gap?: number;\n /** Rounds the end the bar grows towards, in points. */\n radius?: number;\n opacity?: number;\n /**\n * The value the columns grow from. Zero by default, and zero is nearly always\n * right — a bar is a length, and a length has to start where the quantity\n * does.\n *\n * Set it for the case where the reader is being shown movement rather than\n * size: temperatures against a seasonal average, a score against a pass mark.\n * Columns then run up and down from that line instead of all standing on the\n * floor. It is clamped into the axis, so a baseline the domain does not cover\n * falls back to the nearer edge.\n */\n baseline?: number;\n}\n\n/**\n * A series as columns.\n *\n * One path for all of them rather than one node per bar: a plot of two hundred\n * periods is the same single animated prop a frame as a plot of twenty.\n *\n * A bar has width, so its presence puts the whole plot on a band scale unless\n * the root was told otherwise — see `xScale`.\n */\nfunction PlotBars({\n dataKey,\n color,\n colorIndex = 1,\n gap = 0.35,\n radius = 4,\n opacity = 1,\n baseline = 0,\n}: PlotBarsProps) {\n const { data, plot, domainMin, domainMax, status } = useChart('Plot.Bars');\n const fill = useMark(dataKey, color, colorIndex);\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n const total = values.length;\n const slice = total > 0 ? plot.width / total : 0;\n const width = Math.max(1, slice * (1 - Math.min(Math.max(gap, 0), 0.95)));\n\n const animatedProps = useAnimatedProps(() => {\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min || plot.width <= 0 || total === 0) return { d: '' };\n\n /*\n * Bars grow from the baseline — zero unless the caller moved it — clamped\n * into the domain where the axis does not reach it. A bar drawn from the\n * bottom of a plot whose axis starts at 40 is a length that is not in the\n * data.\n */\n const base = yOf(Math.min(Math.max(baseline, min), max), plot, min, max);\n let path = '';\n\n for (let index = 0; index < total; index += 1) {\n const value = values[index];\n if (value === null || value === undefined) continue;\n const y = yOf(value, plot, min, max);\n const top = Math.min(y, base);\n const height = Math.abs(base - y);\n if (height <= 0) continue;\n path += barPath(\n bandOf(index, total, plot) - width / 2,\n top,\n width,\n height,\n radius,\n y <= base ? 'up' : 'down'\n );\n }\n return { d: path };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n );\n}\nPlotBars.displayName = 'Plot.Bars';\nPlotBars.layer = 'series' as Layer;\n// Read by the root before it resolves the scale: a mark with width cannot sit\n// on a point scale without half of the first and last one leaving the plot.\nPlotBars.mark = 'band' as const;\n\nexport interface PlotDotsProps extends PlotSeriesProps {\n /** Radius, in points. */\n size?: number;\n /** Ring around each dot, so it reads on top of the line rather than in it. */\n ringWidth?: number;\n}\n\n/** A dot per row — for a series short enough that its points are worth marking. */\nfunction PlotDots({\n dataKey,\n color,\n colorIndex = 1,\n size = 3.5,\n ringWidth = 2,\n}: PlotDotsProps) {\n const { data, plot, xScale, domainMin, domainMax, status } =\n useChart('Plot.Dots');\n const fill = useMark(dataKey, color, colorIndex);\n const ringToken = useCSSVariable('--color-background');\n const ring = typeof ringToken === 'string' ? ringToken : '#000000';\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n const banded = xScale === 'band';\n\n return (\n \n {status === 'loading'\n ? null\n : values.map((value, index) =>\n value === null || value === undefined ? null : (\n \n )\n )}\n \n );\n}\nPlotDots.displayName = 'Plot.Dots';\nPlotDots.layer = 'series' as Layer;\n\n/** One dot, whose y follows the domain tween. */\nfunction Dot({\n value,\n x,\n plot,\n domainMin,\n domainMax,\n fill,\n ring,\n size,\n ringWidth,\n}: {\n value: number;\n x: number;\n plot: PlotBox;\n domainMin: SharedValue;\n domainMax: SharedValue;\n fill: string;\n ring: string;\n size: number;\n ringWidth: number;\n}) {\n const animatedProps = useAnimatedProps(() => ({\n cy: yOf(value, plot, domainMin.value, domainMax.value),\n }));\n\n return (\n \n );\n}\n\nexport interface PlotLayerProps {\n children?: ReactNode;\n}\n\n/**\n * Marks of your own, in the SVG tree.\n *\n * Whatever is inside is drawn where it is written — before the marks written\n * after it, after the ones before it — and reaches the geometry through\n * `usePlot()`. It is not given the geometry as an argument, because a mark that\n * animates has to hold hooks of its own and a render prop is not a component.\n */\nfunction PlotLayer({ children }: PlotLayerProps) {\n useChart('Plot.Layer');\n return {children};\n}\nPlotLayer.displayName = 'Plot.Layer';\nPlotLayer.layer = 'series' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotOverlayProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Anything of your own that is text or takes a touch, laid over the drawing.\n *\n * SVG text ignores the platform's text scaling and the theme's font, and a\n * gesture handler cannot be attached to an SVG node at all — so those two go\n * here rather than in `Plot.Layer`.\n */\nfunction PlotOverlay({ className, children }: PlotOverlayProps) {\n return (\n \n {children}\n \n );\n}\nPlotOverlay.displayName = 'Plot.Overlay';\nPlotOverlay.layer = 'overlay' as Layer;\n\nexport interface PlotRuleProps {\n /**\n * Where to draw it, in the data's own units. Omit it and pass `x` instead for\n * a rule down the plot rather than across it.\n */\n y?: number;\n /**\n * A row to draw a vertical rule at, by index — the release the numbers are\n * read against, the day a change landed.\n *\n * The x axis here carries positions rather than quantities, so this is which\n * row rather than what value. Exactly one of `y` and `x` is drawn; `y` wins\n * if both are given.\n */\n x?: number;\n /** A name for what the line means. Nothing is drawn without one. */\n label?: string;\n /** Overrides the line *and* its caption, so the two cannot drift apart. */\n color?: string;\n /** Thickness in points. */\n strokeWidth?: number;\n /**\n * Break the line into dashes, for a rule that should read as an annotation\n * rather than as a series the chart is drawing.\n */\n dashed?: boolean;\n /** Fades the line and its caption together. */\n opacity?: number;\n /** Which end of the rule the caption sits at. */\n labelPlacement?: 'start' | 'end';\n labelClassName?: string;\n className?: string;\n}\n\n/**\n * A reference line across the plot — a target, a limit, an average.\n *\n * A view rather than an SVG line, so its label is real text and follows the\n * theme. Its thickness is a border and its position is a transform, so it costs\n * no more than the SVG line would while gaining a legible caption.\n *\n * It is drawn at full strength in the foreground colour, and it is meant to be.\n * A reference line is the number the series is being judged against — a target\n * nobody can read is a target the chart is not actually stating — so what keeps\n * it from being mistaken for a series is that it is neutral and optionally\n * dashed, not that it is faint.\n */\nfunction PlotRule({\n y,\n x,\n label,\n color,\n strokeWidth = 1,\n dashed = false,\n opacity = 1,\n labelPlacement = 'end',\n labelClassName,\n className,\n}: PlotRuleProps) {\n const { plot, domainMin, domainMax, status, data } = useChart('Plot.Rule');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n const vertical = y === undefined && x !== undefined;\n\n const horizontalStyle = useAnimatedStyle(() => {\n if (vertical || y === undefined) return { opacity: 0 };\n const min = domainMin.value;\n const max = domainMax.value;\n if (max === min) return { opacity: 0 };\n const at = yOf(y, plot, min, max);\n // Off the top or bottom of the plot is out of the chart's range, and a rule\n // pinned to the edge would claim a value the axis does not cover.\n const inside = at >= plot.top - 0.5 && at <= plot.top + plot.height + 0.5;\n return { opacity: inside ? opacity : 0, transform: [{ translateY: at }] };\n });\n\n if (status === 'loading') return null;\n\n const line = dashed\n ? {\n borderColor: stroke,\n borderStyle: 'dashed' as const,\n borderTopWidth: strokeWidth,\n }\n : { backgroundColor: stroke, height: strokeWidth };\n\n if (vertical) {\n /*\n * The x axis carries positions, not quantities, so a vertical rule is\n * placed from the row's own geometry rather than from the tweening domain —\n * there is nothing on this axis for it to tween against.\n */\n const total = data.length;\n const at =\n total > 0\n ? xOf(Math.max(0, Math.min(total - 1, x!)), total, plot)\n : plot.left + plot.width / 2;\n\n return (\n \n \n {label ? (\n \n {label}\n \n ) : null}\n \n );\n }\n\n return (\n \n \n {label ? (\n \n {label}\n \n ) : null}\n \n );\n}\nPlotRule.displayName = 'Plot.Rule';\nPlotRule.layer = 'overlay' as Layer;\n\nexport interface PlotXAxisProps {\n /** How many labels to show. The rest are dropped, evenly. */\n ticks?: number;\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: PlotDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The x labels. Real text rather than SVG text, so they follow the theme's font\n * and the platform's text scaling — SVG text does neither.\n */\nfunction PlotXAxis({ ticks = 4, format, className }: PlotXAxisProps) {\n const { data, xDataKey, plot, xScale } = useChart('Plot.XAxis');\n\n const labels = useMemo(() => {\n if (!data.length) return [];\n const count = Math.min(ticks, data.length);\n const step = count > 1 ? (data.length - 1) / (count - 1) : 0;\n return Array.from({ length: count }, (_, index) => {\n const dataIndex = Math.round(index * step);\n const datum = data[dataIndex];\n if (!datum) return null;\n return {\n key: dataIndex,\n text: format ? format(datum, dataIndex) : String(datum[xDataKey] ?? ''),\n };\n }).filter((label): label is { key: number; text: string } => label !== null);\n }, [data, ticks, format, xDataKey]);\n\n /*\n * Each label sits on its own point rather than being spread along the axis.\n * The indices `ticks` picks are not evenly spaced — twelve months shown five\n * at a time gives 0, 3, 6, 8, 11 — so spreading them evenly put the ones in\n * between over the wrong part of the plot.\n */\n return (\n \n {labels.map((label) => {\n const x =\n xScale === 'band'\n ? bandOf(label.key, data.length, plot)\n : xOf(label.key, data.length, plot);\n return (\n \n {label.text}\n \n );\n })}\n \n );\n}\nPlotXAxis.displayName = 'Plot.XAxis';\nPlotXAxis.layer = 'overlay' as Layer;\n\nexport interface PlotYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * Value labels down the side, one per grid line.\n *\n * Give it the same `ticks` as the grid, or the numbers name lines that are not\n * there. Four is the default on both for that reason.\n *\n * The labels are the domain the data settles at, not the tweening one — a\n * number counting through every intermediate value while the axis animates is\n * noise, and the axis is the part of a chart that has to hold still enough to\n * be read.\n */\nfunction PlotYAxis({ ticks = 4, format, className }: PlotYAxisProps) {\n const { plot, extent } = useChart('Plot.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nPlotYAxis.displayName = 'Plot.YAxis';\nPlotYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nPlotYAxis.axis = 'y' as const;\n\nexport interface PlotCursorProps {\n color?: string;\n /** Hide the vertical line and keep only the touch handling. */\n showLine?: boolean;\n}\n\n/**\n * The touch handling, and the line that follows it.\n *\n * Split from the readout next door because they are separately useful: a plot\n * whose value is shown in its own header wants this and no label, and a plot\n * that highlights a bar wants this and nothing else at all. Both read the same\n * index.\n *\n * The hit area is the whole plot. A cursor you have to land on the line to\n * summon is a cursor nobody finds.\n */\nfunction PlotCursor({ color, showLine = true }: PlotCursorProps) {\n const { data, plot, xScale, activeIndex, setActiveIndexJS, status } =\n useChart('Plot.Cursor');\n const token = useCSSVariable('--color-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : '#888888');\n\n const total = data.length;\n const left = plot.left;\n const width = plot.width;\n const banded = xScale === 'band';\n\n /*\n * Built in one closure, and everything it captures is a number or a shared\n * value. A worklet may only call another worklet, and the rule is enforced by\n * crashing rather than by warning — so the resolver is declared next to its\n * callers instead of as a helper further down the file where it would be easy\n * to leave un-workletised.\n */\n const pan = useMemo(() => {\n const resolve = (x: number) => {\n 'worklet';\n if (total < 1 || width <= 0) return;\n const ratio = (x - left) / width;\n const clamped = Math.min(1, Math.max(0, ratio));\n // A band scale divides the plot into `total` slices and the finger is in\n // one of them; a point scale has `total - 1` gaps between marks and the\n // finger rounds to the nearest.\n const next = banded\n ? Math.min(total - 1, Math.floor(clamped * total))\n : Math.round(clamped * Math.max(total - 1, 0));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n // Only the index needs JS, and only when it changes — a drag across a\n // hundred rows costs a hundred re-renders at most, not one per frame for\n // the length of the gesture.\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, width, banded, activeIndex, setActiveIndexJS]);\n\n const lineStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);\n return { opacity: 0.45, transform: [{ translateX: x }] };\n });\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLine ? (\n \n ) : null}\n \n \n );\n}\nPlotCursor.displayName = 'Plot.Cursor';\nPlotCursor.layer = 'overlay' as Layer;\n\nexport interface PlotTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the heading from the row. Defaults to the value at `xDataKey`. */\n formatX?: (datum: PlotDatum) => string;\n /** Draw the readout yourself, given the row under the cursor. */\n children?: (datum: PlotDatum, index: number) => ReactNode;\n className?: string;\n}\n\n/**\n * The readout that rides the cursor.\n *\n * Needs a `Plot.Cursor` beside it — the cursor owns the gesture and this only\n * reads the index it resolves. On its own it never appears, which is the right\n * failure: a label with no way to move is worse than no label.\n */\nfunction PlotTooltip({ formatValue, formatX, children, className }: PlotTooltipProps) {\n const { data, xDataKey, plot, xScale, series, activeIndex, activeIndexJS, status } =\n useChart('Plot.Tooltip');\n\n const total = data.length;\n const banded = xScale === 'band';\n\n // Centred over the cursor but clamped inside the plot, so it never runs off\n // the edge at the first or last row.\n const style = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0) return { opacity: 0 };\n const x = banded ? bandOf(index, total, plot) : xOf(index, total, plot);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: PlotDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n {active ? (\n children ? (\n children(active, activeIndexJS)\n ) : (\n <>\n \n {fmtX(active)}\n \n {series.map(([key, color]) => {\n const value = active[key];\n if (typeof value !== 'number') return null;\n return (\n \n {series.length > 1 ? (\n \n ) : null}\n \n {fmtValue(value, key)}\n \n \n );\n })}\n \n )\n ) : null}\n \n \n );\n}\nPlotTooltip.displayName = 'Plot.Tooltip';\nPlotTooltip.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotHeaderProps extends ViewProps {\n className?: string;\n /** A word for what the plot is of. */\n title?: string;\n /** The figure, large. Usually the total, or the row under the cursor. */\n value?: string;\n /** A line under the value. */\n caption?: string;\n /** Replaces the whole header, keeping only its place above the drawing. */\n children?: ReactNode;\n}\n\n/** The row above the drawing: what it is, and the one number worth reading. */\nfunction PlotHeader({\n className,\n title,\n value,\n caption,\n children,\n ...props\n}: PlotHeaderProps) {\n return (\n \n {children ?? (\n <>\n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n )}\n \n );\n}\nPlotHeader.displayName = 'Plot.Header';\nPlotHeader.layer = 'header' as Layer;\n\nexport interface PlotLegendProps extends ViewProps {\n className?: string;\n /** Names for the columns, keyed by `dataKey`. Falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per mark, taken from the marks that registered.\n *\n * In the header rather than over the drawing, because a key laid inside the\n * plot either covers a mark or is squeezed to one word a line.\n */\nfunction PlotLegend({ className, labels, ...props }: PlotLegendProps) {\n const { series } = useChart('Plot.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n \n \n {labels?.[key] ?? key}\n \n \n ))}\n \n );\n}\nPlotLegend.displayName = 'Plot.Legend';\nPlotLegend.layer = 'header' as Layer;\n\nexport const Plot = Object.assign(PlotRoot, {\n Header: PlotHeader,\n Legend: PlotLegend,\n Grid: PlotGrid,\n Area: PlotArea,\n Bars: PlotBars,\n Line: PlotLine,\n Dots: PlotDots,\n Rule: PlotRule,\n Layer: PlotLayer,\n Overlay: PlotOverlay,\n XAxis: PlotXAxis,\n YAxis: PlotYAxis,\n Cursor: PlotCursor,\n Tooltip: PlotTooltip,\n});\n" }, { "path": "ui/plot-series-registry.ts", diff --git a/apps/docs/public/r/pyramid-chart.json b/apps/docs/public/r/pyramid-chart.json index d61d96cd6..bbe847ae5 100644 --- a/apps/docs/public/r/pyramid-chart.json +++ b/apps/docs/public/r/pyramid-chart.json @@ -19,7 +19,7 @@ { "path": "ui/pyramid-chart.tsx", "type": "registry:ui", - "content": "/**\n * PyramidChart — two series mirrored about a centre line, drawn and animated on\n * the UI thread.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## The two wings share one scale\n *\n * This is the whole contract, and everything else here follows from it. The\n * domain is derived from the larger of the two series' extents and used by\n * both, so a bar twice as long as the one facing it means twice as much.\n * Scaling each side to its own maximum produces a chart whose halves cannot be\n * compared — which is the only thing a pyramid is for.\n *\n * For the same reason the centre is zero and stays there. There is no `yDomain`\n * that starts anywhere else, because a wing cropped at its base is a length\n * that lies.\n *\n * ## Lengths, not signed values\n *\n * Which side a series is on comes from `side`, not from the sign of its\n * numbers, so a value is a distance outward from the centre. A negative one has\n * no direction left to grow in and is drawn as nothing; it still appears in the\n * readout, so a data error shows up as a gap rather than as a bar pointing the\n * wrong way.\n *\n * ## Where the category names go\n *\n * `labelPlacement=\"above\"`, the default, gives each row a line of its own over\n * its pair of bars. That leaves the two wings meeting in the middle with\n * nothing standing between them, and puts the name where it is read before the\n * lengths it belongs to rather than beside them.\n *\n * `\"center\"` puts the names in a gutter between the wings instead — taken off\n * the bars rather than off the edges, so both wings stay equal — and `\"start\"`\n * puts them down the left, for a chart with room to spare on that side.\n *\n * ## Every series is one path\n *\n * A `Bar` draws all its rectangles as subpaths of a single animated path, split\n * in two so the row under the finger keeps full ink while the rest fade. Thirty\n * rows is two animated props a frame rather than thirty.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport {\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n barPath,\n columnValues,\n compactNumber,\n niceDomain,\n useSeriesColor,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the value labels along the bottom. */\nconst PADDING = { top: 6, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved for the category names when they sit at the start. */\nconst CATEGORY_GUTTER = 68;\n\n/**\n * Middle gutter reserved for them when they sit between the wings. Taken off\n * the bars rather than off the edges, so the two wings stay equal.\n */\nconst CENTRE_GUTTER = 72;\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 132;\n\n/**\n * Box each value label is centred in. Narrow, because the outermost tick on\n * each wing sits on the plot's own edge — a box centred there hangs half its\n * width off the side of the chart, and the number is cropped away.\n */\nconst AXIS_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the tick it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/**\n * Steps the value axis is rounded out to. Four rather than the two labels each\n * wing draws, so the halfway label lands on a round number too.\n */\nconst DOMAIN_STEPS = 4;\n\n/**\n * Height taken off the top of every band for the name sitting over it. An `xs`\n * line plus the air that keeps it off the bar it belongs to.\n */\nconst LABEL_ROW = 20;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type PyramidChartStatus = 'loading' | 'ready';\n\n/** Which wing a series grows into. */\nexport type PyramidChartSide = 'start' | 'end';\n\n/** Where the category names sit. */\nexport type PyramidChartLabelPlacement = 'above' | 'center' | 'start';\n\nexport type PyramidChartDatum = Record;\n\ninterface PyramidSeries {\n key: string;\n color: string;\n side: PyramidChartSide;\n}\n\ninterface PyramidChartContextValue {\n data: PyramidChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: PyramidChartStatus;\n /** Points held back in the middle for the category names. Zero otherwise. */\n gutter: number;\n /** Points taken off the top of every band for a name sitting over it. */\n labelInset: number;\n labelPlacement: PyramidChartLabelPlacement;\n barGap: number;\n barWidth: number | undefined;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n series: PyramidSeries[];\n registerSeries: (series: PyramidSeries) => void;\n unregisterSeries: (key: string) => void;\n /** The far end of the shared scale. The near end is zero, always. */\n domainMax: SharedValue;\n /** The settled maximum, for the parts that draw text rather than geometry. */\n extentMax: number;\n /** 0 to 1 as the bars grow out. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst PyramidChartContext = createContext(null);\n\nfunction useChart(component: string): PyramidChartContextValue {\n const context = useContext(PyramidChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The row under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function usePyramidChart() {\n const { data, activeIndexJS, xDataKey } = useChart('usePyramidChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface PyramidChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a band across the chart, with a wing either side. */\n data: PyramidChartDatum[];\n /** Key holding the category label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bars at the centre and grows them out into the real\n * ones when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `PyramidChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: PyramidChartStatus;\n /** Width ÷ height. `1.2` suits three or four rows in a card. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow out on mount. */\n animationDuration?: number;\n /** Milliseconds for the scale to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the far end of the shared scale instead of deriving it. The near end is\n * zero either way — a pyramid measures outward from its centre.\n */\n maxValue?: number;\n /**\n * Where the category names sit. `above`, the default, gives each row a line\n * of its own over its pair of bars, so the two wings meet in the middle with\n * nothing standing between them. `center` puts the names in a gutter between\n * the wings instead, and `start` down the left edge.\n */\n labelPlacement?: PyramidChartLabelPlacement;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any height.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Corner radius on the outward end of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A value that rounds\n * to nothing still happened, and a bar of zero length says it did not.\n */\n minBarLength?: number;\n /** Opacity of the rows that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The row under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: PyramidChartDatum | null) => void;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-out, for a \"replay\" control. */\nexport interface PyramidChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst PyramidChartRoot = forwardRef(\n function PyramidChartRoot(\n {\n className,\n data,\n xDataKey = 'name',\n status = 'ready',\n aspectRatio = 1.2,\n animationDuration = 700,\n domainDuration = 500,\n maxValue,\n labelPlacement = 'above',\n barGap = 0.25,\n barWidth,\n cornerRadius = 4,\n minBarLength = 0,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (next: PyramidSeries) =>\n setSeries((current) => {\n const existing = current.find((entry) => entry.key === next.key);\n if (existing?.color === next.color && existing.side === next.side) return current;\n return [...current.filter((entry) => entry.key !== next.key), next];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) =>\n setSeries((current) => current.filter((entry) => entry.key !== key)),\n []\n );\n\n /*\n * Whether the category names are asking for room. It has to be known before\n * the plot is laid out, and only the root sees the children early enough to\n * ask — the axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n /*\n * Three places a name can go, and each takes its room from somewhere\n * different: `above` off the top of every band, `center` out of the middle\n * of the plot, `start` off the left edge.\n */\n const gutter = hasYAxis && labelPlacement === 'center' ? CENTRE_GUTTER : 0;\n const labelInset = hasYAxis && labelPlacement === 'above' ? LABEL_ROW : 0;\n const pad = {\n ...PADDING,\n left: hasYAxis && labelPlacement === 'start' ? CATEGORY_GUTTER : PADDING.left,\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map((entry) => entry.key).join('|');\n const extentMax = useMemo(() => {\n if (maxValue !== undefined && maxValue > 0) return maxValue;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let max = 0;\n /*\n * One number for both wings. Taking a maximum per side is what makes a\n * pyramid whose halves cannot be compared, so the largest value anywhere\n * in either series sets the scale for all of them.\n */\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value > max) max = value;\n }\n }\n /*\n * Rounded out to a step a reader can divide in their head, rather than\n * padded by a fraction. A tenth added to 73 ends the axis at 80.3 and\n * labels the wings 40.2 and 80.3 — numbers that are true and that nobody\n * was looking for. The centre is left exactly where it is: rounding it\n * would lift the bars off their own baseline.\n *\n * Four steps, not two, so the halfway label the axis draws by default is\n * a round number as well as the end one.\n */\n return max === 0 ? 1 : niceDomain(0, max, DOMAIN_STEPS)[1];\n }, [data, maxValue, seriesKeys]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const first = domainMax.value === 0;\n if (first || reducedMotion) {\n domainMax.value = extentMax;\n return;\n }\n domainMax.value = withTiming(extentMax, { duration: domainDuration });\n }, [extentMax, loading, reducedMotion, domainDuration, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart\n * that is refetched comes back fully drawn on the frame the data lands,\n * which reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n gutter,\n labelInset,\n labelPlacement,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMax,\n extentMax,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n gutter,\n labelInset,\n labelPlacement,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMax,\n extentMax,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map((entry) => [entry.key, datum[entry.key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nPyramidChartRoot.displayName = 'PyramidChart';\n\n/**\n * Where the centre line is, and how much room one wing has. Every part needs\n * both and none of them should work it out twice.\n */\nfunction geometry(plot: Plot, gutter: number) {\n 'worklet';\n const centre = plot.left + plot.width / 2;\n return {\n centre,\n /** The inner edge of the start wing — where a left-growing bar begins. */\n innerStart: centre - gutter / 2,\n /** The inner edge of the end wing. */\n innerEnd: centre + gutter / 2,\n wing: Math.max((plot.width - gutter) / 2, 0),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartGridProps {\n /** How many lines to draw per wing, not counting the centre. */\n columns?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n /** Draw the solid line down the middle the wings are measured from. */\n centreLine?: boolean;\n}\n\n/**\n * Lines up the value axis, mirrored, so a bar can be read against a number\n * rather than only against the bar facing it.\n *\n * The centre line is drawn solid and undashed where the others are dashed: it\n * is not a tick, it is the zero both wings are measured from.\n */\nfunction PyramidChartGrid({\n columns = 2,\n color,\n dashArray = '4,6',\n opacity = 1,\n centreLine = true,\n}: PyramidChartGridProps) {\n const { plot, gutter } = useChart('PyramidChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);\n\n const ticks = Array.from({ length: columns }, (_unused, index) => (index + 1) / columns);\n\n return (\n \n {ticks.map((fraction) => (\n \n \n \n \n ))}\n {centreLine ? (\n \n ) : null}\n \n );\n}\nPyramidChartGrid.displayName = 'PyramidChart.Grid';\nPyramidChartGrid.layer = 'svg' as Layer;\n\nexport interface PyramidChartBarProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Which wing it grows into. */\n side?: PyramidChartSide;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /**\n * Which of the five chart tokens to take. Defaults to a different one per\n * side, so two bars declared with nothing but a `dataKey` and a `side` are\n * already told apart.\n */\n colorIndex?: SeriesColorIndex;\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * One wing.\n *\n * Drawn as two paths rather than one rectangle per row: the row under the\n * finger, and everything else. That is the fewest animated props that can still\n * dim the rest — one path could not, since a path has one opacity, and a view\n * per bar would be one animated prop per bar for the same picture.\n */\nfunction PyramidChartBar({\n dataKey,\n side = 'end',\n color,\n colorIndex,\n cornerRadius,\n}: PyramidChartBarProps) {\n const {\n data,\n plot,\n status,\n gutter,\n labelInset,\n barGap,\n barWidth,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n registerSeries,\n unregisterSeries,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('PyramidChart.Bar');\n\n const index: SeriesColorIndex = colorIndex ?? (side === 'start' ? 2 : 1);\n const fill = useSeriesColor(color, index);\n const radius = cornerRadius ?? chartRadius;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: fill, side });\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, side, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n const loading = status === 'loading';\n const total = data.length;\n const towards = side === 'start' ? 'left' : 'right';\n\n /*\n * Both paths come out of one builder, filtered by whether the row is the\n * active one. Two passes over the data a frame is still cheaper than the\n * bookkeeping needed to build both at once, and it keeps the geometry in\n * exactly one place.\n */\n const build = (wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n const { innerStart, innerEnd, wing } = geometry(plot, gutter);\n const band = plot.height / total;\n // What is left of a band once the name over it has taken its line.\n const usable = Math.max(band - labelInset, 0);\n const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));\n\n const max = domainMax.value || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n if ((i === active) !== wantActive) continue;\n\n const value = values[i];\n if (value === null || value === undefined) continue;\n\n /*\n * Staggered by row, but every bar still finishes inside the one duration:\n * the window each gets is what is left after the stagger, so a chart of\n * twenty rows does not take twenty times as long to arrive.\n */\n const start = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n // A length, so a negative number has no direction left to grow in. It is\n // still in the readout; it just cannot be drawn.\n const shown = loading ? 0 : Math.max(value, 0) * eased;\n\n let length = (shown / max) * wing;\n if (minBarLength > 0 && shown > 0 && length < minBarLength) length = minBarLength;\n if (length <= 0) continue;\n\n const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;\n d +=\n side === 'start'\n ? barPath(innerStart - length, lead, length, thickness, radius, towards)\n : barPath(innerEnd, lead, length, thickness, radius, towards);\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const restProps = useAnimatedProps(build(false));\n const activeProps = useAnimatedProps(build(true));\n\n return (\n \n \n \n \n );\n}\nPyramidChartBar.displayName = 'PyramidChart.Bar';\nPyramidChartBar.layer = 'svg' as Layer;\n\n/** How much of one wing a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.35;\n\n/** Rows to draw when there is no data yet to count them from. */\nconst SKELETON_ROWS = 5;\n\nexport interface PyramidChartSkeletonProps {\n /**\n * How many placeholder rows to draw. Defaults to one per row of data, and to\n * five when the data has not arrived — the count is the one thing a loading\n * chart can be honest about only if it already has the rows.\n */\n rows?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: equal stubs either side of the centre, with a highlight\n * travelling across them.\n *\n * Equal on purpose. Placeholder wings of differing lengths are a distribution\n * the reader has no way to tell from the real one until it changes under them,\n * so these say only how many rows there will be and where the centre is.\n */\nfunction PyramidChartSkeleton({ rows, duration = 1400, color }: PyramidChartSkeletonProps) {\n const { plot, status, gutter, labelInset, data, barGap, barWidth, cornerRadius } =\n useChart('PyramidChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const total = Math.max(1, rows ?? (data.length || SKELETON_ROWS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n const { innerStart, innerEnd, wing } = geometry(plot, gutter);\n const band = plot.height / total;\n const usable = Math.max(band - labelInset, 0);\n const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));\n const length = wing * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;\n path += barPath(innerStart - length, lead, length, thickness, cornerRadius, 'left');\n path += barPath(innerEnd, lead, length, thickness, cornerRadius, 'right');\n }\n return path;\n }, [plot, gutter, labelInset, total, barGap, barWidth, cornerRadius]);\n\n if (!loading || !d) return null;\n\n const gradientId = 'panelui-pyramid-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nPyramidChartSkeleton.displayName = 'PyramidChart.Skeleton';\nPyramidChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartXAxisProps {\n /** How many labels per wing, not counting the zero in the middle. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The value labels along the bottom, mirrored either side of a zero in the\n * middle — the same magnitudes twice, because both wings are read outward from\n * the centre rather than along one continuous axis.\n *\n * Real text rather than SVG text, so the labels follow the theme's font and the\n * platform's text scaling. SVG text does neither.\n */\nfunction PyramidChartXAxis({ ticks = 2, format, className }: PyramidChartXAxisProps) {\n const { plot, gutter, extentMax } = useChart('PyramidChart.XAxis');\n const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);\n const fmt = format ?? compactNumber;\n\n const labels = useMemo(() => {\n const out: { key: string; text: string; x: number }[] = [\n { key: 'centre', text: fmt(0), x: centre },\n ];\n for (let index = 1; index <= ticks; index += 1) {\n const fraction = index / ticks;\n const text = fmt(extentMax * fraction);\n out.push({ key: `start-${index}`, text, x: innerStart - wing * fraction });\n out.push({ key: `end-${index}`, text, x: innerEnd + wing * fraction });\n }\n return out;\n }, [ticks, fmt, extentMax, innerStart, innerEnd, wing, centre]);\n\n if (wing <= 0) return null;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nPyramidChartXAxis.displayName = 'PyramidChart.XAxis';\nPyramidChartXAxis.layer = 'overlay' as Layer;\n\nexport interface PyramidChartYAxisProps {\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: PyramidChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The category names, one per row.\n *\n * `above` puts each name on its own line over the pair of bars it belongs to,\n * which leaves the two wings meeting in the middle — the name is read first and\n * the bars are then read outward from a centre nothing is standing in.\n *\n * One box per band rather than a spaced column, in every placement: a row owns\n * a *band*, so names spread evenly would be half a band out at the top and\n * bottom.\n */\nfunction PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) {\n const { data, xDataKey, plot, gutter, labelInset, labelPlacement } =\n useChart('PyramidChart.YAxis');\n const { centre } = geometry(plot, gutter);\n\n if (!data.length) return null;\n\n const label = (datum: PyramidChartDatum, index: number) =>\n format ? format(datum, index) : String(datum[xDataKey] ?? '');\n\n if (labelPlacement === 'above') {\n const band = plot.height / data.length;\n return (\n \n {data.map((datum, index) => (\n \n \n {label(datum, index)}\n \n \n ))}\n \n );\n }\n\n const centred = labelPlacement === 'center';\n return (\n \n {data.map((datum, index) => (\n \n \n {label(datum, index)}\n \n \n ))}\n \n );\n}\nPyramidChartYAxis.displayName = 'PyramidChart.YAxis';\nPyramidChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the names before it lays the\n// plot out — a name drawn over a bar is unreadable, and makes the bar it is\n// drawn over unreadable too.\nPyramidChartYAxis.axis = 'y' as const;\n\nexport interface PyramidChartTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: PyramidChartDatum) => string;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A bar is already the thing being pointed at, so\n * highlighting its row and dimming the rest says the same thing without drawing\n * a line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds.\n */\nfunction PyramidChartTooltip({\n formatValue,\n formatX,\n className,\n}: PyramidChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('PyramidChart.Tooltip');\n\n const total = data.length;\n const top = plot.top;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. It has to be\n * clamped inside the plot, and how tall it is depends on how many series are\n * listed in it — a constant would either let a three-series readout hang off\n * the bottom or reserve room a one-series readout never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (y: number) => {\n 'worklet';\n if (!total) return;\n const offset = (y - top) / (height || 1);\n // Bands, not points: the finger is inside whichever row it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, top, height, activeIndex, setActiveIndexJS]);\n\n /*\n * The readout slides down to the row it is describing and sits centred over\n * the plot. Held at the top instead it would name the row under the finger\n * while covering the first one, which is the row a reader checks it against.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = plot.height / total;\n const centre = plot.top + band * (index + 0.5);\n const x = plot.left + plot.width / 2 - LABEL_WIDTH / 2;\n\n // Until the first measurement lands the height is zero, which clamps to the\n // top of the plot — the same place it used to sit, rather than a jump from\n // somewhere it never was.\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return { opacity: 1, transform: [{ translateX: x }, { translateY: y }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: PyramidChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {fmtX(active)}\n \n {series.map((entry) => {\n const value = active[entry.key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, entry.key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nPyramidChartTooltip.displayName = 'PyramidChart.Tooltip';\nPyramidChartTooltip.layer = 'overlay' as Layer;\n\nexport interface PyramidChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/** A swatch and a name per series, in the order the series were declared. */\nfunction PyramidChartLegend({ className, labels, ...props }: PyramidChartLegendProps) {\n const { series } = useChart('PyramidChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map((entry) => (\n \n ))}\n \n );\n}\nPyramidChartLegend.displayName = 'PyramidChart.Legend';\nPyramidChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `PyramidChart.Legend` on a chart that has a header: the legend floats over\n * the plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the two colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves down the rows, and the\n * legend is the series list the chart itself is holding.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down.\n */\nfunction PyramidChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: PyramidChartHeaderProps) {\n const { series } = useChart('PyramidChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map((entry) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n two-series key takes the width it wants and the caption underneath the\n value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nPyramidChartHeader.displayName = 'PyramidChart.Header';\nPyramidChartHeader.layer = 'header' as Layer;\n\nexport const PyramidChart = Object.assign(PyramidChartRoot, {\n Header: PyramidChartHeader,\n Grid: PyramidChartGrid,\n Bar: PyramidChartBar,\n Skeleton: PyramidChartSkeleton,\n XAxis: PyramidChartXAxis,\n YAxis: PyramidChartYAxis,\n Tooltip: PyramidChartTooltip,\n Legend: PyramidChartLegend,\n});\n" + "content": "/**\n * PyramidChart — two series mirrored about a centre line, drawn and animated on\n * the UI thread.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## The two wings share one scale\n *\n * This is the whole contract, and everything else here follows from it. The\n * domain is derived from the larger of the two series' extents and used by\n * both, so a bar twice as long as the one facing it means twice as much.\n * Scaling each side to its own maximum produces a chart whose halves cannot be\n * compared — which is the only thing a pyramid is for.\n *\n * For the same reason the centre is zero and stays there. There is no `yDomain`\n * that starts anywhere else, because a wing cropped at its base is a length\n * that lies.\n *\n * ## Lengths, not signed values\n *\n * Which side a series is on comes from `side`, not from the sign of its\n * numbers, so a value is a distance outward from the centre. A negative one has\n * no direction left to grow in and is drawn as nothing; it still appears in the\n * readout, so a data error shows up as a gap rather than as a bar pointing the\n * wrong way.\n *\n * ## Where the category names go\n *\n * `labelPlacement=\"above\"`, the default, gives each row a line of its own over\n * its pair of bars. That leaves the two wings meeting in the middle with\n * nothing standing between them, and puts the name where it is read before the\n * lengths it belongs to rather than beside them.\n *\n * `\"center\"` puts the names in a gutter between the wings instead — taken off\n * the bars rather than off the edges, so both wings stay equal — and `\"start\"`\n * puts them down the left, for a chart with room to spare on that side.\n *\n * ## Every series is one path\n *\n * A `Bar` draws all its rectangles as subpaths of a single animated path, split\n * in two so the row under the finger keeps full ink while the rest fade. Thirty\n * rows is two animated props a frame rather than thirty.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n barPath,\n columnValues,\n compactNumber,\n niceDomain,\n useSeriesColor,\n type Plot,\n type SeriesColorIndex,\n} from '@/lib/chart';\nimport { cn } from '@/lib/cn';\n\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the value labels along the bottom. */\nconst PADDING = { top: 6, right: 10, bottom: 22, left: 10 };\n\n/** Left gutter reserved for the category names when they sit at the start. */\nconst CATEGORY_GUTTER = 68;\n\n/**\n * Middle gutter reserved for them when they sit between the wings. Taken off\n * the bars rather than off the edges, so the two wings stay equal.\n */\nconst CENTRE_GUTTER = 72;\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 132;\n\n/**\n * Box each value label is centred in. Narrow, because the outermost tick on\n * each wing sits on the plot's own edge — a box centred there hangs half its\n * width off the side of the chart, and the number is cropped away.\n */\nconst AXIS_LABEL_WIDTH = 56;\n\n/** Line height of an `xs` label, for centring one on the tick it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/**\n * Steps the value axis is rounded out to. Four rather than the two labels each\n * wing draws, so the halfway label lands on a round number too.\n */\nconst DOMAIN_STEPS = 4;\n\n/**\n * Height taken off the top of every band for the name sitting over it. An `xs`\n * line plus the air that keeps it off the bar it belongs to.\n */\nconst LABEL_ROW = 20;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type PyramidChartStatus = 'loading' | 'ready';\n\n/** Which wing a series grows into. */\nexport type PyramidChartSide = 'start' | 'end';\n\n/** Where the category names sit. */\nexport type PyramidChartLabelPlacement = 'above' | 'center' | 'start';\n\nexport type PyramidChartDatum = Record;\n\ninterface PyramidSeries {\n key: string;\n color: string;\n side: PyramidChartSide;\n}\n\ninterface PyramidChartContextValue {\n data: PyramidChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: PyramidChartStatus;\n /** Points held back in the middle for the category names. Zero otherwise. */\n gutter: number;\n /** Points taken off the top of every band for a name sitting over it. */\n labelInset: number;\n labelPlacement: PyramidChartLabelPlacement;\n barGap: number;\n barWidth: number | undefined;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n series: PyramidSeries[];\n registerSeries: (series: PyramidSeries) => void;\n unregisterSeries: (key: string) => void;\n /** The far end of the shared scale. The near end is zero, always. */\n domainMax: SharedValue;\n /** The settled maximum, for the parts that draw text rather than geometry. */\n extentMax: number;\n /** 0 to 1 as the bars grow out. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst PyramidChartContext = createContext(null);\n\nfunction useChart(component: string): PyramidChartContextValue {\n const context = useContext(PyramidChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The row under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function usePyramidChart() {\n const { data, activeIndexJS, xDataKey } = useChart('usePyramidChart');\n return {\n activeIndex: activeIndexJS,\n activePoint: activeIndexJS >= 0 ? (data[activeIndexJS] ?? null) : null,\n xDataKey,\n };\n}\n\nexport interface PyramidChartProps\n extends ViewProps,\n ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a band across the chart, with a wing either side. */\n data: PyramidChartDatum[];\n /** Key holding the category label. Used by the axis and the readout. */\n xDataKey?: string;\n /**\n * `loading` holds the bars at the centre and grows them out into the real\n * ones when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `PyramidChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: PyramidChartStatus;\n /** Width ÷ height. `1.2` suits three or four rows in a card. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow out on mount. */\n animationDuration?: number;\n /** Milliseconds for the scale to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the far end of the shared scale instead of deriving it. The near end is\n * zero either way — a pyramid measures outward from its centre.\n */\n maxValue?: number;\n /**\n * Where the category names sit. `above`, the default, gives each row a line\n * of its own over its pair of bars, so the two wings meet in the middle with\n * nothing standing between them. `center` puts the names in a gutter between\n * the wings instead, and `start` down the left edge.\n */\n labelPlacement?: PyramidChartLabelPlacement;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any height.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Corner radius on the outward end of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A value that rounds\n * to nothing still happened, and a bar of zero length says it did not.\n */\n minBarLength?: number;\n /** Opacity of the rows that are not under the finger. */\n fadedOpacity?: number;\n /**\n * The row under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, datum: PyramidChartDatum | null) => void;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-out, for a \"replay\" control. */\nexport interface PyramidChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst PyramidChartRoot = forwardRef(\n function PyramidChartRoot(\n {\n className,\n data,\n xDataKey = 'name',\n status = 'ready',\n aspectRatio = 1.2,\n animationDuration = 700,\n domainDuration = 500,\n maxValue,\n labelPlacement = 'above',\n barGap = 0.25,\n barWidth,\n cornerRadius = 4,\n minBarLength = 0,\n fadedOpacity = 0.3,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState([]);\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (next: PyramidSeries) =>\n setSeries((current) => {\n const existing = current.find((entry) => entry.key === next.key);\n if (existing?.color === next.color && existing.side === next.side) return current;\n return [...current.filter((entry) => entry.key !== next.key), next];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) =>\n setSeries((current) => current.filter((entry) => entry.key !== key)),\n []\n );\n\n /*\n * Whether the category names are asking for room. It has to be known before\n * the plot is laid out, and only the root sees the children early enough to\n * ask — the axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n /*\n * Three places a name can go, and each takes its room from somewhere\n * different: `above` off the top of every band, `center` out of the middle\n * of the plot, `start` off the left edge.\n */\n const gutter = hasYAxis && labelPlacement === 'center' ? CENTRE_GUTTER : 0;\n const labelInset = hasYAxis && labelPlacement === 'above' ? LABEL_ROW : 0;\n const pad = {\n ...PADDING,\n left: hasYAxis && labelPlacement === 'start' ? CATEGORY_GUTTER : PADDING.left,\n };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map((entry) => entry.key).join('|');\n const extentMax = useMemo(() => {\n if (maxValue !== undefined && maxValue > 0) return maxValue;\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let max = 0;\n /*\n * One number for both wings. Taking a maximum per side is what makes a\n * pyramid whose halves cannot be compared, so the largest value anywhere\n * in either series sets the scale for all of them.\n */\n for (const row of data) {\n for (const key of keys) {\n const value = row[key];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value > max) max = value;\n }\n }\n /*\n * Rounded out to a step a reader can divide in their head, rather than\n * padded by a fraction. A tenth added to 73 ends the axis at 80.3 and\n * labels the wings 40.2 and 80.3 — numbers that are true and that nobody\n * was looking for. The centre is left exactly where it is: rounding it\n * would lift the bars off their own baseline.\n *\n * Four steps, not two, so the halfway label the axis draws by default is\n * a round number as well as the end one.\n */\n return max === 0 ? 1 : niceDomain(0, max, DOMAIN_STEPS)[1];\n }, [data, maxValue, seriesKeys]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const first = domainMax.value === 0;\n if (first || reducedMotion) {\n domainMax.value = extentMax;\n return;\n }\n domainMax.value = withTiming(extentMax, { duration: domainDuration });\n }, [extentMax, loading, reducedMotion, domainDuration, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart\n * that is refetched comes back fully drawn on the frame the data lands,\n * which reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n },\n [onActiveIndexChange, data]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n gutter,\n labelInset,\n labelPlacement,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMax,\n extentMax,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n gutter,\n labelInset,\n labelPlacement,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n series,\n registerSeries,\n unregisterSeries,\n domainMax,\n extentMax,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map((entry) => [entry.key, datum[entry.key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nPyramidChartRoot.displayName = 'PyramidChart';\n\n/**\n * Where the centre line is, and how much room one wing has. Every part needs\n * both and none of them should work it out twice.\n */\nfunction geometry(plot: Plot, gutter: number) {\n 'worklet';\n const centre = plot.left + plot.width / 2;\n return {\n centre,\n /** The inner edge of the start wing — where a left-growing bar begins. */\n innerStart: centre - gutter / 2,\n /** The inner edge of the end wing. */\n innerEnd: centre + gutter / 2,\n wing: Math.max((plot.width - gutter) / 2, 0),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartGridProps {\n /** How many lines to draw per wing, not counting the centre. */\n columns?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n /** Draw the solid line down the middle the wings are measured from. */\n centreLine?: boolean;\n}\n\n/**\n * Lines up the value axis, mirrored, so a bar can be read against a number\n * rather than only against the bar facing it.\n *\n * The centre line is drawn solid and undashed where the others are dashed: it\n * is not a tick, it is the zero both wings are measured from.\n */\nfunction PyramidChartGrid({\n columns = 2,\n color,\n dashArray = '4,6',\n opacity = 1,\n centreLine = true,\n}: PyramidChartGridProps) {\n const { plot, gutter } = useChart('PyramidChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);\n\n const ticks = Array.from({ length: columns }, (_unused, index) => (index + 1) / columns);\n\n return (\n \n {ticks.map((fraction) => (\n \n \n \n \n ))}\n {centreLine ? (\n \n ) : null}\n \n );\n}\nPyramidChartGrid.displayName = 'PyramidChart.Grid';\nPyramidChartGrid.layer = 'svg' as Layer;\n\nexport interface PyramidChartBarProps {\n /** Column in the data holding this series' values. */\n dataKey: string;\n /** Which wing it grows into. */\n side?: PyramidChartSide;\n /** Explicit colour. Defaults to the `--color-chart-*` token for `colorIndex`. */\n color?: string;\n /**\n * Which of the five chart tokens to take. Defaults to a different one per\n * side, so two bars declared with nothing but a `dataKey` and a `side` are\n * already told apart.\n */\n colorIndex?: SeriesColorIndex;\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * One wing.\n *\n * Drawn as two paths rather than one rectangle per row: the row under the\n * finger, and everything else. That is the fewest animated props that can still\n * dim the rest — one path could not, since a path has one opacity, and a view\n * per bar would be one animated prop per bar for the same picture.\n */\nfunction PyramidChartBar({\n dataKey,\n side = 'end',\n color,\n colorIndex,\n cornerRadius,\n}: PyramidChartBarProps) {\n const {\n data,\n plot,\n status,\n gutter,\n labelInset,\n barGap,\n barWidth,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n registerSeries,\n unregisterSeries,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('PyramidChart.Bar');\n\n const index: SeriesColorIndex = colorIndex ?? (side === 'start' ? 2 : 1);\n const fill = useSeriesColor(color, index);\n const radius = cornerRadius ?? chartRadius;\n\n useEffect(() => {\n registerSeries({ key: dataKey, color: fill, side });\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, side, registerSeries, unregisterSeries]);\n\n const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);\n\n const loading = status === 'loading';\n const total = data.length;\n const towards = side === 'start' ? 'left' : 'right';\n\n /*\n * Both paths come out of one builder, filtered by whether the row is the\n * active one. Two passes over the data a frame is still cheaper than the\n * bookkeeping needed to build both at once, and it keeps the geometry in\n * exactly one place.\n */\n const build = (wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n const { innerStart, innerEnd, wing } = geometry(plot, gutter);\n const band = plot.height / total;\n // What is left of a band once the name over it has taken its line.\n const usable = Math.max(band - labelInset, 0);\n const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));\n\n const max = domainMax.value || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n if ((i === active) !== wantActive) continue;\n\n const value = values[i];\n if (value === null || value === undefined) continue;\n\n /*\n * Staggered by row, but every bar still finishes inside the one duration:\n * the window each gets is what is left after the stagger, so a chart of\n * twenty rows does not take twenty times as long to arrive.\n */\n const start = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));\n // A length, so a negative number has no direction left to grow in. It is\n // still in the readout; it just cannot be drawn.\n const shown = loading ? 0 : Math.max(value, 0) * eased;\n\n let length = (shown / max) * wing;\n if (minBarLength > 0 && shown > 0 && length < minBarLength) length = minBarLength;\n if (length <= 0) continue;\n\n const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;\n d +=\n side === 'start'\n ? barPath(innerStart - length, lead, length, thickness, radius, towards)\n : barPath(innerEnd, lead, length, thickness, radius, towards);\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const restProps = useAnimatedProps(build(false));\n const activeProps = useAnimatedProps(build(true));\n\n return (\n \n \n \n \n );\n}\nPyramidChartBar.displayName = 'PyramidChart.Bar';\nPyramidChartBar.layer = 'svg' as Layer;\n\n/** How much of one wing a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.35;\n\n/** Rows to draw when there is no data yet to count them from. */\nconst SKELETON_ROWS = 5;\n\nexport interface PyramidChartSkeletonProps {\n /**\n * How many placeholder rows to draw. Defaults to one per row of data, and to\n * five when the data has not arrived — the count is the one thing a loading\n * chart can be honest about only if it already has the rows.\n */\n rows?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: equal stubs either side of the centre, with a highlight\n * travelling across them.\n *\n * Equal on purpose. Placeholder wings of differing lengths are a distribution\n * the reader has no way to tell from the real one until it changes under them,\n * so these say only how many rows there will be and where the centre is.\n */\nfunction PyramidChartSkeleton({ rows, duration = 1400, color }: PyramidChartSkeletonProps) {\n const { plot, status, gutter, labelInset, data, barGap, barWidth, cornerRadius } =\n useChart('PyramidChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlight = useSeriesColor(undefined, 1);\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const total = Math.max(1, rows ?? (data.length || SKELETON_ROWS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n const { innerStart, innerEnd, wing } = geometry(plot, gutter);\n const band = plot.height / total;\n const usable = Math.max(band - labelInset, 0);\n const thickness = Math.min(barWidth ?? usable * (1 - barGap), usable * (1 - barGap));\n const length = wing * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = plot.top + i * band + labelInset + (usable - thickness) / 2;\n path += barPath(innerStart - length, lead, length, thickness, cornerRadius, 'left');\n path += barPath(innerEnd, lead, length, thickness, cornerRadius, 'right');\n }\n return path;\n }, [plot, gutter, labelInset, total, barGap, barWidth, cornerRadius]);\n\n if (!loading || !d) return null;\n\n const gradientId = 'panelui-pyramid-skeleton';\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nPyramidChartSkeleton.displayName = 'PyramidChart.Skeleton';\nPyramidChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartXAxisProps {\n /** How many labels per wing, not counting the zero in the middle. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The value labels along the bottom, mirrored either side of a zero in the\n * middle — the same magnitudes twice, because both wings are read outward from\n * the centre rather than along one continuous axis.\n *\n * Real text rather than SVG text, so the labels follow the theme's font and the\n * platform's text scaling. SVG text does neither.\n */\nfunction PyramidChartXAxis({ ticks = 2, format, className }: PyramidChartXAxisProps) {\n const { plot, gutter, extentMax } = useChart('PyramidChart.XAxis');\n const { innerStart, innerEnd, wing, centre } = geometry(plot, gutter);\n const fmt = format ?? compactNumber;\n\n const labels = useMemo(() => {\n const out: { key: string; text: string; x: number }[] = [\n { key: 'centre', text: fmt(0), x: centre },\n ];\n for (let index = 1; index <= ticks; index += 1) {\n const fraction = index / ticks;\n const text = fmt(extentMax * fraction);\n out.push({ key: `start-${index}`, text, x: innerStart - wing * fraction });\n out.push({ key: `end-${index}`, text, x: innerEnd + wing * fraction });\n }\n return out;\n }, [ticks, fmt, extentMax, innerStart, innerEnd, wing, centre]);\n\n if (wing <= 0) return null;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nPyramidChartXAxis.displayName = 'PyramidChart.XAxis';\nPyramidChartXAxis.layer = 'overlay' as Layer;\n\nexport interface PyramidChartYAxisProps {\n /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n format?: (datum: PyramidChartDatum, index: number) => string;\n className?: string;\n}\n\n/**\n * The category names, one per row.\n *\n * `above` puts each name on its own line over the pair of bars it belongs to,\n * which leaves the two wings meeting in the middle — the name is read first and\n * the bars are then read outward from a centre nothing is standing in.\n *\n * One box per band rather than a spaced column, in every placement: a row owns\n * a *band*, so names spread evenly would be half a band out at the top and\n * bottom.\n */\nfunction PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) {\n const { data, xDataKey, plot, gutter, labelInset, labelPlacement } =\n useChart('PyramidChart.YAxis');\n const { centre } = geometry(plot, gutter);\n\n if (!data.length) return null;\n\n const label = (datum: PyramidChartDatum, index: number) =>\n format ? format(datum, index) : String(datum[xDataKey] ?? '');\n\n if (labelPlacement === 'above') {\n const band = plot.height / data.length;\n return (\n \n {data.map((datum, index) => (\n \n \n {label(datum, index)}\n \n \n ))}\n \n );\n }\n\n const centred = labelPlacement === 'center';\n return (\n \n {data.map((datum, index) => (\n \n \n {label(datum, index)}\n \n \n ))}\n \n );\n}\nPyramidChartYAxis.displayName = 'PyramidChart.YAxis';\nPyramidChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the names before it lays the\n// plot out — a name drawn over a bar is unreadable, and makes the bar it is\n// drawn over unreadable too.\nPyramidChartYAxis.axis = 'y' as const;\n\nexport interface PyramidChartTooltipProps {\n /** Format one series' value. Defaults to a compact number. */\n formatValue?: (value: number, key: string) => string;\n /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n formatX?: (datum: PyramidChartDatum) => string;\n className?: string;\n}\n\n/**\n * The readout, and the gesture that drives it.\n *\n * There is no crosshair. A bar is already the thing being pointed at, so\n * highlighting its row and dimming the rest says the same thing without drawing\n * a line through the chart.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds.\n */\nfunction PyramidChartTooltip({\n formatValue,\n formatX,\n className,\n}: PyramidChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('PyramidChart.Tooltip');\n\n const total = data.length;\n const top = plot.top;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. It has to be\n * clamped inside the plot, and how tall it is depends on how many series are\n * listed in it — a constant would either let a three-series readout hang off\n * the bottom or reserve room a one-series readout never uses.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (y: number) => {\n 'worklet';\n if (!total) return;\n const offset = (y - top) / (height || 1);\n // Bands, not points: the finger is inside whichever row it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, top, height, activeIndex, setActiveIndexJS]);\n\n /*\n * The readout slides down to the row it is describing and sits centred over\n * the plot. Held at the top instead it would name the row under the finger\n * while covering the first one, which is the row a reader checks it against.\n */\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = plot.height / total;\n const centre = plot.top + band * (index + 0.5);\n const x = plot.left + plot.width / 2 - LABEL_WIDTH / 2;\n\n // Until the first measurement lands the height is zero, which clamps to the\n // top of the plot — the same place it used to sit, rather than a jump from\n // somewhere it never was.\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return { opacity: 1, transform: [{ translateX: x }, { translateY: y }] };\n });\n\n const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;\n const fmtValue = formatValue ?? ((value: number) => compactNumber(value));\n const fmtX = formatX ?? ((datum: PyramidChartDatum) => String(datum[xDataKey] ?? ''));\n\n if (status === 'loading') return null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {fmtX(active)}\n \n {series.map((entry) => {\n const value = active[entry.key];\n if (typeof value !== 'number') return null;\n return (\n \n \n \n {fmtValue(value, entry.key)}\n \n \n );\n })}\n \n ) : null}\n \n \n \n );\n}\nPyramidChartTooltip.displayName = 'PyramidChart.Tooltip';\nPyramidChartTooltip.layer = 'overlay' as Layer;\n\nexport interface PyramidChartLegendProps extends ViewProps {\n className?: string;\n /** Prettier names for the series keys. */\n labels?: Record;\n}\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/** A swatch and a name per series, in the order the series were declared. */\nfunction PyramidChartLegend({ className, labels, ...props }: PyramidChartLegendProps) {\n const { series } = useChart('PyramidChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map((entry) => (\n \n ))}\n \n );\n}\nPyramidChartLegend.displayName = 'PyramidChart.Legend';\nPyramidChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PyramidChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `PyramidChart.Legend` on a chart that has a header: the legend floats over\n * the plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the two colours mean.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *plot* — the number changes as a finger moves down the rows, and the\n * legend is the series list the chart itself is holding.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down.\n */\nfunction PyramidChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: PyramidChartHeaderProps) {\n const { series } = useChart('PyramidChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map((entry) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n two-series key takes the width it wants and the caption underneath the\n value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nPyramidChartHeader.displayName = 'PyramidChart.Header';\nPyramidChartHeader.layer = 'header' as Layer;\n\nexport const PyramidChart = Object.assign(PyramidChartRoot, {\n Header: PyramidChartHeader,\n Grid: PyramidChartGrid,\n Bar: PyramidChartBar,\n Skeleton: PyramidChartSkeleton,\n XAxis: PyramidChartXAxis,\n YAxis: PyramidChartYAxis,\n Tooltip: PyramidChartTooltip,\n Legend: PyramidChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/scatter-chart.json b/apps/docs/public/r/scatter-chart.json index df2c51651..48a69a377 100644 --- a/apps/docs/public/r/scatter-chart.json +++ b/apps/docs/public/r/scatter-chart.json @@ -20,7 +20,7 @@ { "path": "ui/scatter-chart.tsx", "type": "registry:ui", - "content": "/**\n * ScatterChart — two quantities against each other, drawn on the UI thread.\n *\n * Every other chart in this library spaces its points evenly along the x-axis,\n * because their x is a position: twelve months are twelve equal steps whatever\n * the gaps between the dates behind them. A scatter plot is the one shape where\n * that is wrong. Both coordinates are *measured*, and the reader is being asked\n * to look for a relationship between them — spread the points evenly and the\n * relationship is the one thing you have thrown away.\n *\n * So this chart carries an x-domain as well as a y-domain, and both are tweened\n * when the data changes.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * As elsewhere, there are two layers and the parts sort themselves into the\n * right one: the geometry is SVG, and anything with text or a gesture on it is\n * a React Native view over the top. SVG text ignores the platform's text\n * scaling and the theme's font, and a gesture handler cannot be attached to an\n * SVG node at all.\n *\n * **Finding a point.** A crosshair that snaps to an x index — the way a line\n * chart's does — has nothing to snap to here, because there is no shared x and\n * two points can sit at the same one. Instead the nearest point to the finger\n * is resolved by distance, on the UI thread, and only within a radius: a touch\n * in an empty corner of the plot selects nothing rather than lighting up\n * whichever point happens to be least far away. The radius is generous, because\n * the points are a few pixels across and a fingertip is not.\n *\n * Colours come from the `--color-chart-*` tokens, so a chart follows the active\n * theme. Nothing here hardcodes a hex.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedG = Animated.createAnimatedComponent(G);\n\n/**\n * How much of the reveal is spent handing out the points' start times. The rest\n * is the window each one gets, so the whole field still lands inside the one\n * duration however many points there are.\n */\nconst STAGGER = 0.4;\n\n/** Milliseconds for a point to swell as it is selected, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/**\n * A point arriving: up past its size and back to it.\n *\n * A dot that simply grows to its radius reads as the chart still loading right\n * up to the last frame. The small overshoot is what makes it read as landing.\n */\nfunction landing(t: number): number {\n 'worklet';\n const back = 1.3;\n const u = t - 1;\n return 1 + (back + 1) * u * u * u + back * u * u;\n}\n\n/** Room left around the plot for the axis labels and the outermost dots. */\nconst PADDING = { top: 14, right: 14, bottom: 22, left: 14 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each x label is centred in, so a long number is ellipsised not shoved. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Width of the readout that floats by the selected point. */\nconst LABEL_WIDTH = 132;\n\n/** How far the readout is lifted, to clear the point it describes. */\nconst LABEL_HEIGHT = 52;\n\n/**\n * How far from a point a touch still counts as being on it, in points.\n *\n * Sized for a fingertip rather than for the dot. Apple and Android both put the\n * minimum comfortable target at around 44pt, and a scatter point is nearer 7 —\n * without a hit radius the chart is only usable with a mouse it will never see.\n */\nconst HIT_RADIUS = 32;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type ScatterChartStatus = 'loading' | 'ready';\nexport type ScatterChartDatum = Record;\n\n/** One plotted point, resolved back to the row it came from. */\nexport interface ScatterChartPoint {\n /** Index into `data`. */\n index: number;\n /** The series key this point belongs to. */\n dataKey: string;\n x: number;\n y: number;\n datum: ScatterChartDatum;\n}\n\ninterface ScatterChartContextValue {\n data: ScatterChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: ScatterChartStatus;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n /** Tweened domains. Read inside worklets to place the points. */\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n /** The domains the tweens are heading for, for the axis labels. */\n xExtent: [number, number];\n yExtent: [number, number];\n /** The selected point, as `\":\"`, or `''` for none. */\n activeId: SharedValue;\n activePoint: ScatterChartPoint | null;\n setActivePoint: (point: ScatterChartPoint | null) => void;\n /** 0 to 1 as the field arrives. Each point reads its own slice of it. */\n reveal: SharedValue;\n}\n\nconst ScatterChartContext = createContext(null);\n\nfunction useChart(component: string): ScatterChartContextValue {\n const context = useContext(ScatterChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The selected point, for something rendered *inside* the chart.\n *\n * A readout usually belongs in the card's header, which is outside this\n * provider — use `onActivePointChange` for that. A hook cannot reach up out of\n * the subtree it is called in.\n */\nexport function useScatterChart() {\n const { activePoint, xDataKey } = useChart('useScatterChart');\n return { activePoint, xDataKey };\n}\n\nexport interface ScatterChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point, placed by two of its values. */\n data: ScatterChartDatum[];\n /** Key holding the x value. Unlike the other charts, this must be a number. */\n xDataKey?: string;\n /**\n * `loading` draws a still field of muted dots and settles into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition.\n */\n status?: ScatterChartStatus;\n /** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. Defaults to `650`. */\n animationDuration?: number;\n /** Milliseconds for the axes to settle after the data changes. */\n domainDuration?: number;\n /** Fix the x-axis instead of deriving it from the data. */\n xDomain?: [number, number];\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /**\n * The point under the finger, and `null` when it lifts. This is how a readout\n * in the card's header gets its value — that header is outside the chart, so\n * it cannot use `useScatterChart`.\n *\n * Fires when the selection changes, not per frame.\n */\n onActivePointChange?: (point: ScatterChartPoint | null) => void;\n /** Drop the axis padding so the field reaches the edges, for a thumbnail. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface ScatterChartHandle {\n replay: () => void;\n}\n\nconst ScatterChartRoot = forwardRef(\n function ScatterChartRoot(\n {\n className,\n data,\n xDataKey = 'x',\n status = 'ready',\n aspectRatio = 1,\n animationDuration = 650,\n domainDuration = 500,\n xDomain,\n yDomain,\n onActivePointChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activePoint, setActivePointState] = useState(null);\n\n const reveal = useSharedValue(0);\n const xMin = useSharedValue(0);\n const xMax = useSharedValue(0);\n const yMin = useSharedValue(0);\n const yMax = useSharedValue(0);\n const activeId = useSharedValue('');\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 4, right: 4, bottom: 4, left: 4 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n\n /*\n * Both extents in one pass. A scatter plot's x is a measured quantity, so\n * unlike the rest of the family it needs a domain of its own rather than a\n * count of positions.\n *\n * Neither axis is floored at zero. An area chart is floored because a\n * filled region floating above the baseline reads as a shape rather than a\n * quantity — but a scatter plot's subject is the *spread*, and forcing a\n * cluster of values between 80 and 90 to share a frame with zero squashes\n * it into a smudge in one corner and hides the very thing being plotted.\n */\n const extents = useMemo<{ x: [number, number]; y: [number, number] }>(() => {\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let lowX = Infinity;\n let highX = -Infinity;\n let lowY = Infinity;\n let highY = -Infinity;\n\n for (const row of data) {\n const x = row[xDataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n for (const key of keys) {\n const y = row[key];\n // A row with no reading for this series is not a point at the\n // origin — it is not a point at all, and must not stretch the axes.\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n if (x < lowX) lowX = x;\n if (x > highX) highX = x;\n if (y < lowY) lowY = y;\n if (y > highY) highY = y;\n }\n }\n\n return {\n x: xDomain ?? padExtent(lowX, highX),\n y: yDomain ?? padExtent(lowY, highY),\n };\n }, [data, xDataKey, seriesKeys, xDomain, yDomain]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [x0, x1] = extents.x;\n const [y0, y1] = extents.y;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;\n if (first || reducedMotion) {\n xMin.value = x0;\n xMax.value = x1;\n yMin.value = y0;\n yMax.value = y1;\n return;\n }\n xMin.value = withTiming(x0, { duration: domainDuration });\n xMax.value = withTiming(x1, { duration: domainDuration });\n yMin.value = withTiming(y0, { duration: domainDuration });\n yMax.value = withTiming(y1, { duration: domainDuration });\n }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n /*\n * Eased out rather than in and out. Each point is given a slice of this\n * one clock, so an ease that dawdles at the start spends it on the\n * first few points and leaves the rest to arrive in a rush.\n */\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n /*\n * Armed again every time the chart goes back to loading, so a chart that\n * reloads animates on the second pass as well as the first. Left latched,\n * the guard made \"loading\" a state the chart could only leave once.\n */\n useEffect(() => {\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n // One place the selection lands, so the chart's own children and a readout\n // outside it never disagree about which point is active.\n const setActivePoint = useMemo(\n () => (point: ScatterChartPoint | null) => {\n setActivePointState(point);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n series,\n registerSeries,\n unregisterSeries,\n xMin,\n xMax,\n yMin,\n yMax,\n xExtent: extents.x,\n yExtent: extents.y,\n activeId,\n activePoint,\n setActivePoint,\n reveal,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n series,\n registerSeries,\n unregisterSeries,\n xMin,\n xMax,\n yMin,\n yMax,\n extents,\n activeId,\n activePoint,\n setActivePoint,\n reveal,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nScatterChartRoot.displayName = 'ScatterChart';\n\n/**\n * An extent with a little air around it, and a usable one for the degenerate\n * cases — no data at all, or every reading identical. A domain of zero width\n * divides by zero and puts every point on the same edge.\n */\nfunction padExtent(min: number, max: number): [number, number] {\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n const pad = (max - min) * 0.08;\n return [min - pad, max + pad];\n}\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n /**\n * Vertical rules down it. A scatter plot's x is a quantity, so it earns a\n * grid in both directions — a line chart's does not, because its x is a\n * label and a rule under a label divides nothing.\n */\n columns?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/** Reference lines both ways. Drawn under everything, and not part of the reveal. */\nfunction ScatterChartGrid({\n rows = 4,\n columns = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: ScatterChartGridProps) {\n const { plot } = useChart('ScatterChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n {Array.from({ length: columns + 1 }, (_unused, index) => {\n const x = plot.left + (plot.width / columns) * index;\n return (\n \n );\n })}\n \n );\n}\nScatterChartGrid.displayName = 'ScatterChart.Grid';\nScatterChartGrid.layer = 'svg' as Layer;\n\nexport interface ScatterChartPointsProps {\n /** Key holding this series' y values. */\n dataKey: string;\n /**\n * Fill colour. Defaults to the `--color-chart-*` token at `colorIndex`, so a\n * series follows the theme without the call site naming a colour.\n */\n color?: string;\n /** Which `--color-chart-*` token to take when `color` is not given. */\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n /** Radius of a point, in points. Ignored when `sizeKey` is given. */\n size?: number;\n /**\n * Key holding a third quantity, mapped to each point's *area* — a bubble\n * chart. Area rather than radius, because doubling a radius quadruples the\n * ink and the reader sees four times the value that is there.\n */\n sizeKey?: string;\n /** Smallest and largest radius `sizeKey` maps onto. */\n sizeRange?: [number, number];\n /**\n * Fill opacity. Below 1 by default so that overlapping points read as denser\n * rather than hiding each other — in a crowded region that overlap *is* the\n * finding, and opaque dots erase it.\n */\n opacity?: number;\n}\n\n/** One series, as a field of dots. */\nfunction ScatterChartPoints({\n dataKey,\n color,\n colorIndex = 1,\n size = 4.5,\n sizeKey,\n sizeRange = [3, 14],\n opacity = 0.75,\n}: ScatterChartPointsProps) {\n const {\n data,\n xDataKey,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n status,\n activeId,\n registerSeries,\n unregisterSeries,\n reveal,\n } = useChart('ScatterChart.Points');\n const fill = useSeriesColor(color, colorIndex);\n\n useEffect(() => {\n registerSeries(dataKey, fill);\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, registerSeries, unregisterSeries]);\n\n const loading = status === 'loading';\n\n // The size scale is over the whole series, so one row's bubble means the same\n // thing as another's. Recomputed only when the data or the key changes.\n const sizeExtent = useMemo<[number, number] | null>(() => {\n if (!sizeKey) return null;\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n const value = row[sizeKey];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n return min === Infinity ? null : [min, max];\n }, [data, sizeKey]);\n\n const points = useMemo(\n () =>\n data\n .map((row, index) => {\n const x = row[xDataKey];\n const y = row[dataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) return null;\n if (typeof y !== 'number' || Number.isNaN(y)) return null;\n return { index, x, y, r: radiusFor(row[sizeKey ?? ''], sizeExtent, sizeRange, size) };\n })\n .filter((point): point is { index: number; x: number; y: number; r: number } =>\n point !== null\n ),\n [data, xDataKey, dataKey, sizeKey, sizeExtent, sizeRange, size]\n );\n\n if (loading) return null;\n\n return (\n \n {points.map((point, order) => (\n \n ))}\n \n );\n}\nScatterChartPoints.displayName = 'ScatterChart.Points';\nScatterChartPoints.layer = 'svg' as Layer;\n\n/**\n * A value's radius on the bubble scale.\n *\n * The value maps to *area* and the radius is taken from it, so a point holding\n * twice the value carries twice the ink rather than four times it.\n */\nfunction radiusFor(\n value: string | number | null | undefined,\n extent: [number, number] | null,\n range: [number, number],\n fallback: number\n): number {\n if (!extent || typeof value !== 'number' || Number.isNaN(value)) return fallback;\n const [min, max] = extent;\n const [rMin, rMax] = range;\n const ratio = max === min ? 1 : (value - min) / (max - min);\n const area = rMin * rMin + ratio * (rMax * rMax - rMin * rMin);\n return Math.sqrt(area);\n}\n\n/**\n * One point. Its position follows both domain tweens, so a data change moves\n * the whole field to the new scale rather than cutting to it.\n *\n * It arrives by growing in place, on its own slice of the shared reveal. The\n * alternative — sweeping a clip across the plot, which is what the line and\n * area charts do — is right for a series read along the x-axis and wrong here:\n * a wipe gives the reader a direction to read the arrival in, and a scatter\n * plot has none. Position is the whole message, so a point may only ever appear\n * where it belongs.\n */\nfunction Dot({\n id,\n x,\n y,\n r,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n fill,\n opacity,\n activeId,\n reveal,\n order,\n total,\n}: {\n id: string;\n x: number;\n y: number;\n r: number;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n fill: string;\n opacity: number;\n activeId: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n}) {\n /*\n * The selection, as something that moves. It was a hard switch on\n * `activeId`, so the point it named jumped half as big again between one\n * frame and the next while every neighbour stayed put — read as a glitch\n * rather than as a response to the finger.\n */\n const selected = useDerivedValue(() =>\n withTiming(activeId.value === id ? 1 : 0, { duration: SELECT_DURATION })\n );\n\n // Where in the reveal this point starts. Spread over `STAGGER`, so the field\n // settles as a field rather than switching on all at once.\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const animatedProps = useAnimatedProps(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n // The selected point swells and goes solid. Both, rather than one: a size\n // change alone is easy to miss among neighbours, and an opacity change\n // alone is invisible wherever the points already overlap.\n const swell = 1 + 0.5 * selected.value;\n return {\n cx: xAt(x, plot, xMin.value, xMax.value),\n cy: yOf(y, plot, yMin.value, yMax.value),\n r: r * landing(arrived) * swell,\n // Ahead of the size, so a point is legible by the time it stops moving\n // rather than fading in for the whole of its arrival.\n fillOpacity:\n Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),\n };\n });\n\n return ;\n}\n\nexport interface ScatterChartSkeletonProps {\n /** How many placeholder dots to scatter. */\n count?: number;\n color?: string;\n}\n\n/**\n * The loading state: a still field of muted dots where the data will be.\n *\n * Deliberately still. A shimmer over a field of dots reads as the points\n * *moving*, which is the one thing a scatter plot must never appear to do —\n * position is the entire message, and a loading state that implies it is\n * changing is a loading state that lies.\n *\n * The layout is deterministic rather than random, so it does not reshuffle on\n * every render of a component that may re-render several times while waiting.\n *\n * Still is not the same as abrupt, though. It dissolves as the real points grow\n * in, and outlives the status change by exactly that long — cut at the frame the\n * data lands, the placeholder disappears before anything has replaced it and the\n * plot is briefly empty.\n */\nfunction ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps) {\n const { plot, status } = useChart('ScatterChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');\n\n const dots = useMemo(() => {\n // A cheap deterministic scatter: two irrational-ish strides that do not\n // share a factor, so the points spread instead of falling into a lattice.\n return Array.from({ length: count }, (_unused, index) => ({\n key: index,\n fx: ((index * 0.618) % 1) * 0.92 + 0.04,\n fy: ((index * 0.379) % 1) * 0.92 + 0.04,\n }));\n }, [count]);\n\n const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));\n\n if (!mounted) return null;\n\n return (\n \n {dots.map((dot) => (\n \n ))}\n \n );\n}\nScatterChartSkeleton.displayName = 'ScatterChart.Skeleton';\nScatterChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartXAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The x labels, evenly along the axis.\n *\n * Evenly spaced here — unlike a line chart's, where each label sits on the point\n * it names — because this axis is a continuous scale rather than a list of\n * rows. There is no point to sit on.\n */\nfunction ScatterChartXAxis({ ticks = 4, format, className }: ScatterChartXAxisProps) {\n const { plot, xExtent } = useChart('ScatterChart.XAxis');\n\n const labels = useMemo(() => {\n const [min, max] = xExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = min + ((max - min) * index) / ticks;\n return { key: index, value, text: format ? format(value) : compactNumber(value) };\n });\n }, [xExtent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nScatterChartXAxis.displayName = 'ScatterChart.XAxis';\nScatterChartXAxis.layer = 'overlay' as Layer;\n\nexport interface ScatterChartYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, one per grid line. */\nfunction ScatterChartYAxis({ ticks = 4, format, className }: ScatterChartYAxisProps) {\n const { plot, yExtent } = useChart('ScatterChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = yExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [yExtent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nScatterChartYAxis.displayName = 'ScatterChart.YAxis';\nScatterChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nScatterChartYAxis.axis = 'y' as const;\n\nexport interface ScatterChartTooltipProps {\n /** Float a small readout beside the selected point. On by default. */\n showLabel?: boolean;\n /** Format the x value for the readout. Defaults to a compact number. */\n formatX?: (value: number) => string;\n /** Format the y value for the readout. Defaults to a compact number. */\n formatY?: (value: number, key: string) => string;\n /** A heading for the readout, from the row — a name, a label, a category. */\n formatTitle?: (datum: ScatterChartDatum) => string;\n /** How far from a point a touch still counts as being on it, in points. */\n hitRadius?: number;\n}\n\n/**\n * The touch target, the selection it drives, and the readout that follows it.\n *\n * A line chart's crosshair snaps to an x index. That is not available here:\n * there is no shared x, and two points can sit on the same one. So the nearest\n * point is found by distance instead — and only within `hitRadius`, so a touch\n * in an empty corner selects nothing rather than lighting up whichever point is\n * least far away.\n *\n * The search runs on the UI thread over a flat array of already-projected\n * coordinates, and only the *identity* of the winner crosses back into JS, and\n * only when it changes. A drag across the plot therefore costs a handful of\n * re-renders rather than one per frame.\n *\n * Distances are compared squared. The nearest point by distance is the nearest\n * by distance-squared, and a square root per point per frame buys nothing.\n */\nfunction ScatterChartTooltip({\n showLabel = true,\n formatX,\n formatY,\n formatTitle,\n hitRadius = HIT_RADIUS,\n}: ScatterChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n xExtent,\n yExtent,\n activeId,\n activePoint,\n setActivePoint,\n status,\n } = useChart('ScatterChart.Tooltip');\n\n const seriesKeys = series.map(([key]) => key).join('|');\n\n /*\n * Every point in the chart, projected once, as parallel arrays.\n *\n * Parallel arrays rather than an array of objects because this is read inside\n * a worklet: Reanimated has to copy whatever the gesture captures across to\n * the UI thread, and three number arrays cross far more cheaply than a few\n * hundred small objects.\n *\n * Projected against the *settled* extents rather than the tweening shared\n * values. Hit-testing against a moving scale would mean rebuilding this on\n * every frame of a domain animation, and a point being half a second stale\n * during a transition is not something a finger can notice.\n */\n const hit = useMemo(() => {\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n const xs: number[] = [];\n const ys: number[] = [];\n const ids: string[] = [];\n const indices: number[] = [];\n const owners: string[] = [];\n\n for (const key of keys) {\n for (let index = 0; index < data.length; index += 1) {\n const row = data[index]!;\n const x = row[xDataKey];\n const y = row[key];\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n xs.push(xAt(x, plot, xExtent[0], xExtent[1]));\n ys.push(yOf(y, plot, yExtent[0], yExtent[1]));\n ids.push(`${key}:${index}`);\n indices.push(index);\n owners.push(key);\n }\n }\n\n return { xs, ys, ids, indices, owners };\n }, [data, xDataKey, seriesKeys, plot, xExtent, yExtent]);\n\n // Resolves an id, which is all the worklet can cheaply hand back. JS turns it\n // into the row it came from.\n const select = useMemo(\n () => (id: string) => {\n if (!id) {\n setActivePoint(null);\n return;\n }\n const at = hit.ids.indexOf(id);\n if (at < 0) {\n setActivePoint(null);\n return;\n }\n const index = hit.indices[at]!;\n const key = hit.owners[at]!;\n const datum = data[index];\n if (!datum) {\n setActivePoint(null);\n return;\n }\n setActivePoint({\n index,\n dataKey: key,\n x: datum[xDataKey] as number,\n y: datum[key] as number,\n datum,\n });\n },\n [hit, data, xDataKey, setActivePoint]\n );\n\n /*\n * Built in one closure, and everything it captures is a plain array, a number\n * or a shared value. A worklet may only call another worklet, and the rule is\n * enforced by crashing rather than by warning — so the resolver is declared\n * here, next to its callers, rather than as a helper elsewhere in the file\n * where it would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const xs = hit.xs;\n const ys = hit.ys;\n const ids = hit.ids;\n const limit = hitRadius * hitRadius;\n\n const resolve = (px: number, py: number) => {\n 'worklet';\n let bestId = '';\n let best = limit;\n for (let i = 0; i < xs.length; i += 1) {\n const dx = xs[i]! - px;\n const dy = ys[i]! - py;\n const distance = dx * dx + dy * dy;\n if (distance <= best) {\n best = distance;\n bestId = ids[i]!;\n }\n }\n if (bestId === activeId.value) return;\n activeId.value = bestId;\n runOnJS(select)(bestId);\n };\n\n const clear = () => {\n 'worklet';\n if (activeId.value === '') return;\n activeId.value = '';\n runOnJS(select)('');\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n clear();\n });\n }, [hit, hitRadius, activeId, select]);\n\n // The readout sits above the point and is clamped inside the plot, so it\n // never runs off an edge at an extreme value.\n const labelStyle = useAnimatedStyle(() => {\n const id = activeId.value;\n if (!id) return { opacity: 0 };\n const at = hit.ids.indexOf(id);\n if (at < 0) return { opacity: 0 };\n const x = hit.xs[at]!;\n const y = hit.ys[at]!;\n const half = LABEL_WIDTH / 2;\n return {\n opacity: 1,\n transform: [\n {\n translateX: Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n ) - half,\n },\n // Above the point, and pushed below it near the top of the plot where\n // there is no room above.\n { translateY: y - plot.top < LABEL_HEIGHT ? y + 16 : y - LABEL_HEIGHT },\n ],\n };\n });\n\n const fmtX = formatX ?? ((value: number) => compactNumber(value));\n const fmtY = formatY ?? ((value: number) => compactNumber(value));\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLabel ? (\n \n \n {activePoint ? (\n <>\n {formatTitle ? (\n \n {formatTitle(activePoint.datum)}\n \n ) : null}\n \n {series.length > 1 ? (\n key === activePoint.dataKey)?.[1] ?? undefined,\n }}\n className=\"h-1.5 w-1.5 rounded-full\"\n />\n ) : null}\n \n {fmtX(activePoint.x)} · {fmtY(activePoint.y, activePoint.dataKey)}\n \n \n \n ) : null}\n \n \n ) : null}\n \n \n );\n}\nScatterChartTooltip.displayName = 'ScatterChart.Tooltip';\nScatterChartTooltip.layer = 'overlay' as Layer;\n\nexport interface ScatterChartLegendProps extends ViewProps {\n className?: string;\n /** Label per series key. A key with no label falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per registered series. Sits in the top-left of the plot\n * by default — move it with `className`.\n */\nfunction ScatterChartLegend({ className, labels, ...props }: ScatterChartLegendProps) {\n const { series } = useChart('ScatterChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nScatterChartLegend.displayName = 'ScatterChart.Legend';\nScatterChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `ScatterChart.Legend` on a chart that has a header: the legend floats over\n * the plot, where it competes with the points for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActivePointChange` and pass the\n * formatted string down, so one header can show a summary when nothing is\n * pressed and a point's values when something is.\n */\nfunction ScatterChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: ScatterChartHeaderProps) {\n const { series } = useChart('ScatterChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nScatterChartHeader.displayName = 'ScatterChart.Header';\nScatterChartHeader.layer = 'header' as Layer;\n\nexport const ScatterChart = Object.assign(ScatterChartRoot, {\n Header: ScatterChartHeader,\n Grid: ScatterChartGrid,\n Points: ScatterChartPoints,\n Skeleton: ScatterChartSkeleton,\n XAxis: ScatterChartXAxis,\n YAxis: ScatterChartYAxis,\n Tooltip: ScatterChartTooltip,\n Legend: ScatterChartLegend,\n});\n" + "content": "/**\n * ScatterChart — two quantities against each other, drawn on the UI thread.\n *\n * Every other chart in this library spaces its points evenly along the x-axis,\n * because their x is a position: twelve months are twelve equal steps whatever\n * the gaps between the dates behind them. A scatter plot is the one shape where\n * that is wrong. Both coordinates are *measured*, and the reader is being asked\n * to look for a relationship between them — spread the points evenly and the\n * relationship is the one thing you have thrown away.\n *\n * So this chart carries an x-domain as well as a y-domain, and both are tweened\n * when the data changes.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * As elsewhere, there are two layers and the parts sort themselves into the\n * right one: the geometry is SVG, and anything with text or a gesture on it is\n * a React Native view over the top. SVG text ignores the platform's text\n * scaling and the theme's font, and a gesture handler cannot be attached to an\n * SVG node at all.\n *\n * **Finding a point.** A crosshair that snaps to an x index — the way a line\n * chart's does — has nothing to snap to here, because there is no shared x and\n * two points can sit at the same one. Instead the nearest point to the finger\n * is resolved by distance, on the UI thread, and only within a radius: a touch\n * in an empty corner of the plot selects nothing rather than lighting up\n * whichever point happens to be least far away. The radius is generous, because\n * the points are a few pixels across and a fingertip is not.\n *\n * Colours come from the `--color-chart-*` tokens, so a chart follows the active\n * theme. Nothing here hardcodes a hex.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\nconst AnimatedG = Animated.createAnimatedComponent(G);\n\n/**\n * How much of the reveal is spent handing out the points' start times. The rest\n * is the window each one gets, so the whole field still lands inside the one\n * duration however many points there are.\n */\nconst STAGGER = 0.4;\n\n/** Milliseconds for a point to swell as it is selected, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/**\n * A point arriving: up past its size and back to it.\n *\n * A dot that simply grows to its radius reads as the chart still loading right\n * up to the last frame. The small overshoot is what makes it read as landing.\n */\nfunction landing(t: number): number {\n 'worklet';\n const back = 1.3;\n const u = t - 1;\n return 1 + (back + 1) * u * u * u + back * u * u;\n}\n\n/** Room left around the plot for the axis labels and the outermost dots. */\nconst PADDING = { top: 14, right: 14, bottom: 22, left: 14 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Box each x label is centred in, so a long number is ellipsised not shoved. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** Width of the readout that floats by the selected point. */\nconst LABEL_WIDTH = 132;\n\n/** How far the readout is lifted, to clear the point it describes. */\nconst LABEL_HEIGHT = 52;\n\n/**\n * How far from a point a touch still counts as being on it, in points.\n *\n * Sized for a fingertip rather than for the dot. Apple and Android both put the\n * minimum comfortable target at around 44pt, and a scatter point is nearer 7 —\n * without a hit radius the chart is only usable with a mouse it will never see.\n */\nconst HIT_RADIUS = 32;\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type ScatterChartStatus = 'loading' | 'ready';\nexport type ScatterChartDatum = Record;\n\n/** One plotted point, resolved back to the row it came from. */\nexport interface ScatterChartPoint {\n /** Index into `data`. */\n index: number;\n /** The series key this point belongs to. */\n dataKey: string;\n x: number;\n y: number;\n datum: ScatterChartDatum;\n}\n\ninterface ScatterChartContextValue {\n data: ScatterChartDatum[];\n xDataKey: string;\n plot: Plot;\n status: ScatterChartStatus;\n series: [string, string][];\n registerSeries: (key: string, color: string) => void;\n unregisterSeries: (key: string) => void;\n /** Tweened domains. Read inside worklets to place the points. */\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n /** The domains the tweens are heading for, for the axis labels. */\n xExtent: [number, number];\n yExtent: [number, number];\n /** The selected point, as `\":\"`, or `''` for none. */\n activeId: SharedValue;\n activePoint: ScatterChartPoint | null;\n setActivePoint: (point: ScatterChartPoint | null) => void;\n /** 0 to 1 as the field arrives. Each point reads its own slice of it. */\n reveal: SharedValue;\n}\n\nconst ScatterChartContext = createContext(null);\n\nfunction useChart(component: string): ScatterChartContextValue {\n const context = useContext(ScatterChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The selected point, for something rendered *inside* the chart.\n *\n * A readout usually belongs in the card's header, which is outside this\n * provider — use `onActivePointChange` for that. A hook cannot reach up out of\n * the subtree it is called in.\n */\nexport function useScatterChart() {\n const { activePoint, xDataKey } = useChart('useScatterChart');\n return { activePoint, xDataKey };\n}\n\nexport interface ScatterChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The rows. Each one is a point, placed by two of its values. */\n data: ScatterChartDatum[];\n /** Key holding the x value. Unlike the other charts, this must be a number. */\n xDataKey?: string;\n /**\n * `loading` draws a still field of muted dots and settles into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition.\n */\n status?: ScatterChartStatus;\n /** Width ÷ height. `1` suits a scatter plot: neither axis is the important one. */\n aspectRatio?: number;\n /** Milliseconds for the reveal on mount. Defaults to `650`. */\n animationDuration?: number;\n /** Milliseconds for the axes to settle after the data changes. */\n domainDuration?: number;\n /** Fix the x-axis instead of deriving it from the data. */\n xDomain?: [number, number];\n /** Fix the y-axis instead of deriving it from the data. */\n yDomain?: [number, number];\n /**\n * The point under the finger, and `null` when it lifts. This is how a readout\n * in the card's header gets its value — that header is outside the chart, so\n * it cannot use `useScatterChart`.\n *\n * Fires when the selection changes, not per frame.\n */\n onActivePointChange?: (point: ScatterChartPoint | null) => void;\n /** Drop the axis padding so the field reaches the edges, for a thumbnail. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the reveal on demand, for a \"replay\" control. */\nexport interface ScatterChartHandle {\n replay: () => void;\n}\n\nconst ScatterChartRoot = forwardRef(\n function ScatterChartRoot(\n {\n className,\n data,\n xDataKey = 'x',\n status = 'ready',\n aspectRatio = 1,\n animationDuration = 650,\n domainDuration = 500,\n xDomain,\n yDomain,\n onActivePointChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [series, setSeries] = useState<[string, string][]>([]);\n const [activePoint, setActivePointState] = useState(null);\n\n const reveal = useSharedValue(0);\n const xMin = useSharedValue(0);\n const xMax = useSharedValue(0);\n const yMin = useSharedValue(0);\n const yMax = useSharedValue(0);\n const activeId = useSharedValue('');\n const reducedMotion = useReducedMotion();\n\n const registerSeries = useMemo(\n () => (key: string, color: string) =>\n setSeries((current) => {\n const existing = current.find(([k]) => k === key);\n if (existing?.[1] === color) return current;\n return [...current.filter(([k]) => k !== key), [key, color]];\n }),\n []\n );\n\n const unregisterSeries = useMemo(\n () => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),\n []\n );\n\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 4, right: 4, bottom: 4, left: 4 }\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const seriesKeys = series.map(([key]) => key).join('|');\n\n /*\n * Both extents in one pass. A scatter plot's x is a measured quantity, so\n * unlike the rest of the family it needs a domain of its own rather than a\n * count of positions.\n *\n * Neither axis is floored at zero. An area chart is floored because a\n * filled region floating above the baseline reads as a shape rather than a\n * quantity — but a scatter plot's subject is the *spread*, and forcing a\n * cluster of values between 80 and 90 to share a frame with zero squashes\n * it into a smudge in one corner and hides the very thing being plotted.\n */\n const extents = useMemo<{ x: [number, number]; y: [number, number] }>(() => {\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n let lowX = Infinity;\n let highX = -Infinity;\n let lowY = Infinity;\n let highY = -Infinity;\n\n for (const row of data) {\n const x = row[xDataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n for (const key of keys) {\n const y = row[key];\n // A row with no reading for this series is not a point at the\n // origin — it is not a point at all, and must not stretch the axes.\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n if (x < lowX) lowX = x;\n if (x > highX) highX = x;\n if (y < lowY) lowY = y;\n if (y > highY) highY = y;\n }\n }\n\n return {\n x: xDomain ?? padExtent(lowX, highX),\n y: yDomain ?? padExtent(lowY, highY),\n };\n }, [data, xDataKey, seriesKeys, xDomain, yDomain]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [x0, x1] = extents.x;\n const [y0, y1] = extents.y;\n // The first domain lands without a tween: there is no previous scale to\n // move from, and animating up from zero reads as the numbers changing.\n const first = xMin.value === 0 && xMax.value === 0 && yMin.value === 0 && yMax.value === 0;\n if (first || reducedMotion) {\n xMin.value = x0;\n xMax.value = x1;\n yMin.value = y0;\n yMax.value = y1;\n return;\n }\n xMin.value = withTiming(x0, { duration: domainDuration });\n xMax.value = withTiming(x1, { duration: domainDuration });\n yMin.value = withTiming(y0, { duration: domainDuration });\n yMax.value = withTiming(y1, { duration: domainDuration });\n }, [extents, loading, reducedMotion, domainDuration, xMin, xMax, yMin, yMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n /*\n * Eased out rather than in and out. Each point is given a slice of this\n * one clock, so an ease that dawdles at the start spends it on the\n * first few points and leaves the rest to arrive in a rush.\n */\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n /*\n * Armed again every time the chart goes back to loading, so a chart that\n * reloads animates on the second pass as well as the first. Left latched,\n * the guard made \"loading\" a state the chart could only leave once.\n */\n useEffect(() => {\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !data.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, data.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n // One place the selection lands, so the chart's own children and a readout\n // outside it never disagree about which point is active.\n const setActivePoint = useMemo(\n () => (point: ScatterChartPoint | null) => {\n setActivePointState(point);\n onActivePointChange?.(point);\n },\n [onActivePointChange]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n xDataKey,\n plot,\n status,\n series,\n registerSeries,\n unregisterSeries,\n xMin,\n xMax,\n yMin,\n yMax,\n xExtent: extents.x,\n yExtent: extents.y,\n activeId,\n activePoint,\n setActivePoint,\n reveal,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n xDataKey,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n series,\n registerSeries,\n unregisterSeries,\n xMin,\n xMax,\n yMin,\n yMax,\n extents,\n activeId,\n activePoint,\n setActivePoint,\n reveal,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n [xDataKey, datum[xDataKey]],\n ...series.map(([key]) => [key, datum[key]] as [string, unknown]),\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nScatterChartRoot.displayName = 'ScatterChart';\n\n/**\n * An extent with a little air around it, and a usable one for the degenerate\n * cases — no data at all, or every reading identical. A domain of zero width\n * divides by zero and puts every point on the same edge.\n */\nfunction padExtent(min: number, max: number): [number, number] {\n if (min === Infinity) return [0, 1];\n if (min === max) return [min - 1, max + 1];\n const pad = (max - min) * 0.08;\n return [min - pad, max + pad];\n}\n\n/** Sorts the children into the SVG tree and the view layer over it. */\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(\n // Children of a `Children.forEach` need keys of their own once they are\n // put into a new array.\n {child}\n );\n });\n\n return { svg, overlay, header };\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartGridProps {\n /** Horizontal rules across the plot. */\n rows?: number;\n /**\n * Vertical rules down it. A scatter plot's x is a quantity, so it earns a\n * grid in both directions — a line chart's does not, because its x is a\n * label and a rule under a label divides nothing.\n */\n columns?: number;\n color?: string;\n /** Dash pattern, e.g. `\"4,6\"`. Omit for a solid rule. */\n dashArray?: string;\n opacity?: number;\n}\n\n/** Reference lines both ways. Drawn under everything, and not part of the reveal. */\nfunction ScatterChartGrid({\n rows = 4,\n columns = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: ScatterChartGridProps) {\n const { plot } = useChart('ScatterChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n return (\n \n {Array.from({ length: rows + 1 }, (_unused, index) => {\n const y = plot.top + (plot.height / rows) * index;\n return (\n \n );\n })}\n {Array.from({ length: columns + 1 }, (_unused, index) => {\n const x = plot.left + (plot.width / columns) * index;\n return (\n \n );\n })}\n \n );\n}\nScatterChartGrid.displayName = 'ScatterChart.Grid';\nScatterChartGrid.layer = 'svg' as Layer;\n\nexport interface ScatterChartPointsProps {\n /** Key holding this series' y values. */\n dataKey: string;\n /**\n * Fill colour. Defaults to the `--color-chart-*` token at `colorIndex`, so a\n * series follows the theme without the call site naming a colour.\n */\n color?: string;\n /** Which `--color-chart-*` token to take when `color` is not given. */\n colorIndex?: 1 | 2 | 3 | 4 | 5;\n /** Radius of a point, in points. Ignored when `sizeKey` is given. */\n size?: number;\n /**\n * Key holding a third quantity, mapped to each point's *area* — a bubble\n * chart. Area rather than radius, because doubling a radius quadruples the\n * ink and the reader sees four times the value that is there.\n */\n sizeKey?: string;\n /** Smallest and largest radius `sizeKey` maps onto. */\n sizeRange?: [number, number];\n /**\n * Fill opacity. Below 1 by default so that overlapping points read as denser\n * rather than hiding each other — in a crowded region that overlap *is* the\n * finding, and opaque dots erase it.\n */\n opacity?: number;\n}\n\n/** One series, as a field of dots. */\nfunction ScatterChartPoints({\n dataKey,\n color,\n colorIndex = 1,\n size = 4.5,\n sizeKey,\n sizeRange = [3, 14],\n opacity = 0.75,\n}: ScatterChartPointsProps) {\n const {\n data,\n xDataKey,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n status,\n activeId,\n registerSeries,\n unregisterSeries,\n reveal,\n } = useChart('ScatterChart.Points');\n const fill = useSeriesColor(color, colorIndex);\n\n useEffect(() => {\n registerSeries(dataKey, fill);\n return () => unregisterSeries(dataKey);\n }, [dataKey, fill, registerSeries, unregisterSeries]);\n\n const loading = status === 'loading';\n\n // The size scale is over the whole series, so one row's bubble means the same\n // thing as another's. Recomputed only when the data or the key changes.\n const sizeExtent = useMemo<[number, number] | null>(() => {\n if (!sizeKey) return null;\n let min = Infinity;\n let max = -Infinity;\n for (const row of data) {\n const value = row[sizeKey];\n if (typeof value !== 'number' || Number.isNaN(value)) continue;\n if (value < min) min = value;\n if (value > max) max = value;\n }\n return min === Infinity ? null : [min, max];\n }, [data, sizeKey]);\n\n const points = useMemo(\n () =>\n data\n .map((row, index) => {\n const x = row[xDataKey];\n const y = row[dataKey];\n if (typeof x !== 'number' || Number.isNaN(x)) return null;\n if (typeof y !== 'number' || Number.isNaN(y)) return null;\n return { index, x, y, r: radiusFor(row[sizeKey ?? ''], sizeExtent, sizeRange, size) };\n })\n .filter((point): point is { index: number; x: number; y: number; r: number } =>\n point !== null\n ),\n [data, xDataKey, dataKey, sizeKey, sizeExtent, sizeRange, size]\n );\n\n if (loading) return null;\n\n return (\n \n {points.map((point, order) => (\n \n ))}\n \n );\n}\nScatterChartPoints.displayName = 'ScatterChart.Points';\nScatterChartPoints.layer = 'svg' as Layer;\n\n/**\n * A value's radius on the bubble scale.\n *\n * The value maps to *area* and the radius is taken from it, so a point holding\n * twice the value carries twice the ink rather than four times it.\n */\nfunction radiusFor(\n value: string | number | null | undefined,\n extent: [number, number] | null,\n range: [number, number],\n fallback: number\n): number {\n if (!extent || typeof value !== 'number' || Number.isNaN(value)) return fallback;\n const [min, max] = extent;\n const [rMin, rMax] = range;\n const ratio = max === min ? 1 : (value - min) / (max - min);\n const area = rMin * rMin + ratio * (rMax * rMax - rMin * rMin);\n return Math.sqrt(area);\n}\n\n/**\n * One point. Its position follows both domain tweens, so a data change moves\n * the whole field to the new scale rather than cutting to it.\n *\n * It arrives by growing in place, on its own slice of the shared reveal. The\n * alternative — sweeping a clip across the plot, which is what the line and\n * area charts do — is right for a series read along the x-axis and wrong here:\n * a wipe gives the reader a direction to read the arrival in, and a scatter\n * plot has none. Position is the whole message, so a point may only ever appear\n * where it belongs.\n */\nfunction Dot({\n id,\n x,\n y,\n r,\n plot,\n xMin,\n xMax,\n yMin,\n yMax,\n fill,\n opacity,\n activeId,\n reveal,\n order,\n total,\n}: {\n id: string;\n x: number;\n y: number;\n r: number;\n plot: Plot;\n xMin: SharedValue;\n xMax: SharedValue;\n yMin: SharedValue;\n yMax: SharedValue;\n fill: string;\n opacity: number;\n activeId: SharedValue;\n reveal: SharedValue;\n order: number;\n total: number;\n}) {\n /*\n * The selection, as something that moves. It was a hard switch on\n * `activeId`, so the point it named jumped half as big again between one\n * frame and the next while every neighbour stayed put — read as a glitch\n * rather than as a response to the finger.\n */\n const selected = useDerivedValue(() =>\n withTiming(activeId.value === id ? 1 : 0, { duration: SELECT_DURATION })\n );\n\n // Where in the reveal this point starts. Spread over `STAGGER`, so the field\n // settles as a field rather than switching on all at once.\n const start = total > 1 ? (order / total) * STAGGER : 0;\n\n const animatedProps = useAnimatedProps(() => {\n const arrived = Math.max(0, Math.min(1, (reveal.value - start) / (1 - STAGGER)));\n // The selected point swells and goes solid. Both, rather than one: a size\n // change alone is easy to miss among neighbours, and an opacity change\n // alone is invisible wherever the points already overlap.\n const swell = 1 + 0.5 * selected.value;\n return {\n cx: xAt(x, plot, xMin.value, xMax.value),\n cy: yOf(y, plot, yMin.value, yMax.value),\n r: r * landing(arrived) * swell,\n // Ahead of the size, so a point is legible by the time it stops moving\n // rather than fading in for the whole of its arrival.\n fillOpacity:\n Math.min(1, arrived * 2) * (opacity + (1 - opacity) * selected.value),\n };\n });\n\n return ;\n}\n\nexport interface ScatterChartSkeletonProps {\n /** How many placeholder dots to scatter. */\n count?: number;\n color?: string;\n}\n\n/**\n * The loading state: a still field of muted dots where the data will be.\n *\n * Deliberately still. A shimmer over a field of dots reads as the points\n * *moving*, which is the one thing a scatter plot must never appear to do —\n * position is the entire message, and a loading state that implies it is\n * changing is a loading state that lies.\n *\n * The layout is deterministic rather than random, so it does not reshuffle on\n * every render of a component that may re-render several times while waiting.\n *\n * Still is not the same as abrupt, though. It dissolves as the real points grow\n * in, and outlives the status change by exactly that long — cut at the frame the\n * data lands, the placeholder disappears before anything has replaced it and the\n * plot is briefly empty.\n */\nfunction ScatterChartSkeleton({ count = 24, color }: ScatterChartSkeletonProps) {\n const { plot, status } = useChart('ScatterChart.Skeleton');\n const token = useCSSVariable('--color-skeleton');\n const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n\n const { mounted, opacity: fade } = useSkeletonHandoff(status === 'loading');\n\n const dots = useMemo(() => {\n // A cheap deterministic scatter: two irrational-ish strides that do not\n // share a factor, so the points spread instead of falling into a lattice.\n return Array.from({ length: count }, (_unused, index) => ({\n key: index,\n fx: ((index * 0.618) % 1) * 0.92 + 0.04,\n fy: ((index * 0.379) % 1) * 0.92 + 0.04,\n }));\n }, [count]);\n\n const animatedProps = useAnimatedProps(() => ({ opacity: fade.value }));\n\n if (!mounted) return null;\n\n return (\n \n {dots.map((dot) => (\n \n ))}\n \n );\n}\nScatterChartSkeleton.displayName = 'ScatterChart.Skeleton';\nScatterChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartXAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/**\n * The x labels, evenly along the axis.\n *\n * Evenly spaced here — unlike a line chart's, where each label sits on the point\n * it names — because this axis is a continuous scale rather than a list of\n * rows. There is no point to sit on.\n */\nfunction ScatterChartXAxis({ ticks = 4, format, className }: ScatterChartXAxisProps) {\n const { plot, xExtent } = useChart('ScatterChart.XAxis');\n\n const labels = useMemo(() => {\n const [min, max] = xExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = min + ((max - min) * index) / ticks;\n return { key: index, value, text: format ? format(value) : compactNumber(value) };\n });\n }, [xExtent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nScatterChartXAxis.displayName = 'ScatterChart.XAxis';\nScatterChartXAxis.layer = 'overlay' as Layer;\n\nexport interface ScatterChartYAxisProps {\n /** How many intervals to divide the axis into. Yields `ticks + 1` labels. */\n ticks?: number;\n /** Turn a value into its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, one per grid line. */\nfunction ScatterChartYAxis({ ticks = 4, format, className }: ScatterChartYAxisProps) {\n const { plot, yExtent } = useChart('ScatterChart.YAxis');\n\n const labels = useMemo(() => {\n const [min, max] = yExtent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [yExtent, ticks, format]);\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nScatterChartYAxis.displayName = 'ScatterChart.YAxis';\nScatterChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out.\nScatterChartYAxis.axis = 'y' as const;\n\nexport interface ScatterChartTooltipProps {\n /** Float a small readout beside the selected point. On by default. */\n showLabel?: boolean;\n /** Format the x value for the readout. Defaults to a compact number. */\n formatX?: (value: number) => string;\n /** Format the y value for the readout. Defaults to a compact number. */\n formatY?: (value: number, key: string) => string;\n /** A heading for the readout, from the row — a name, a label, a category. */\n formatTitle?: (datum: ScatterChartDatum) => string;\n /** How far from a point a touch still counts as being on it, in points. */\n hitRadius?: number;\n}\n\n/**\n * The touch target, the selection it drives, and the readout that follows it.\n *\n * A line chart's crosshair snaps to an x index. That is not available here:\n * there is no shared x, and two points can sit on the same one. So the nearest\n * point is found by distance instead — and only within `hitRadius`, so a touch\n * in an empty corner selects nothing rather than lighting up whichever point is\n * least far away.\n *\n * The search runs on the UI thread over a flat array of already-projected\n * coordinates, and only the *identity* of the winner crosses back into JS, and\n * only when it changes. A drag across the plot therefore costs a handful of\n * re-renders rather than one per frame.\n *\n * Distances are compared squared. The nearest point by distance is the nearest\n * by distance-squared, and a square root per point per frame buys nothing.\n */\nfunction ScatterChartTooltip({\n showLabel = true,\n formatX,\n formatY,\n formatTitle,\n hitRadius = HIT_RADIUS,\n}: ScatterChartTooltipProps) {\n const {\n data,\n xDataKey,\n plot,\n series,\n xExtent,\n yExtent,\n activeId,\n activePoint,\n setActivePoint,\n status,\n } = useChart('ScatterChart.Tooltip');\n\n const seriesKeys = series.map(([key]) => key).join('|');\n\n /*\n * Every point in the chart, projected once, as parallel arrays.\n *\n * Parallel arrays rather than an array of objects because this is read inside\n * a worklet: Reanimated has to copy whatever the gesture captures across to\n * the UI thread, and three number arrays cross far more cheaply than a few\n * hundred small objects.\n *\n * Projected against the *settled* extents rather than the tweening shared\n * values. Hit-testing against a moving scale would mean rebuilding this on\n * every frame of a domain animation, and a point being half a second stale\n * during a transition is not something a finger can notice.\n */\n const hit = useMemo(() => {\n const keys = seriesKeys ? seriesKeys.split('|') : [];\n const xs: number[] = [];\n const ys: number[] = [];\n const ids: string[] = [];\n const indices: number[] = [];\n const owners: string[] = [];\n\n for (const key of keys) {\n for (let index = 0; index < data.length; index += 1) {\n const row = data[index]!;\n const x = row[xDataKey];\n const y = row[key];\n if (typeof x !== 'number' || Number.isNaN(x)) continue;\n if (typeof y !== 'number' || Number.isNaN(y)) continue;\n xs.push(xAt(x, plot, xExtent[0], xExtent[1]));\n ys.push(yOf(y, plot, yExtent[0], yExtent[1]));\n ids.push(`${key}:${index}`);\n indices.push(index);\n owners.push(key);\n }\n }\n\n return { xs, ys, ids, indices, owners };\n }, [data, xDataKey, seriesKeys, plot, xExtent, yExtent]);\n\n // Resolves an id, which is all the worklet can cheaply hand back. JS turns it\n // into the row it came from.\n const select = useMemo(\n () => (id: string) => {\n if (!id) {\n setActivePoint(null);\n return;\n }\n const at = hit.ids.indexOf(id);\n if (at < 0) {\n setActivePoint(null);\n return;\n }\n const index = hit.indices[at]!;\n const key = hit.owners[at]!;\n const datum = data[index];\n if (!datum) {\n setActivePoint(null);\n return;\n }\n setActivePoint({\n index,\n dataKey: key,\n x: datum[xDataKey] as number,\n y: datum[key] as number,\n datum,\n });\n },\n [hit, data, xDataKey, setActivePoint]\n );\n\n /*\n * Built in one closure, and everything it captures is a plain array, a number\n * or a shared value. A worklet may only call another worklet, and the rule is\n * enforced by crashing rather than by warning — so the resolver is declared\n * here, next to its callers, rather than as a helper elsewhere in the file\n * where it would be easy to leave un-workletised.\n */\n const pan = useMemo(() => {\n const xs = hit.xs;\n const ys = hit.ys;\n const ids = hit.ids;\n const limit = hitRadius * hitRadius;\n\n const resolve = (px: number, py: number) => {\n 'worklet';\n let bestId = '';\n let best = limit;\n for (let i = 0; i < xs.length; i += 1) {\n const dx = xs[i]! - px;\n const dy = ys[i]! - py;\n const distance = dx * dx + dy * dy;\n if (distance <= best) {\n best = distance;\n bestId = ids[i]!;\n }\n }\n if (bestId === activeId.value) return;\n activeId.value = bestId;\n runOnJS(select)(bestId);\n };\n\n const clear = () => {\n 'worklet';\n if (activeId.value === '') return;\n activeId.value = '';\n runOnJS(select)('');\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n clear();\n });\n }, [hit, hitRadius, activeId, select]);\n\n // The readout sits above the point and is clamped inside the plot, so it\n // never runs off an edge at an extreme value.\n const labelStyle = useAnimatedStyle(() => {\n const id = activeId.value;\n if (!id) return { opacity: 0 };\n const at = hit.ids.indexOf(id);\n if (at < 0) return { opacity: 0 };\n const x = hit.xs[at]!;\n const y = hit.ys[at]!;\n const half = LABEL_WIDTH / 2;\n return {\n opacity: 1,\n transform: [\n {\n translateX: Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, x)\n ) - half,\n },\n // Above the point, and pushed below it near the top of the plot where\n // there is no room above.\n { translateY: y - plot.top < LABEL_HEIGHT ? y + 16 : y - LABEL_HEIGHT },\n ],\n };\n });\n\n const fmtX = formatX ?? ((value: number) => compactNumber(value));\n const fmtY = formatY ?? ((value: number) => compactNumber(value));\n\n if (status === 'loading') return null;\n\n return (\n \n \n {showLabel ? (\n \n \n {activePoint ? (\n <>\n {formatTitle ? (\n \n {formatTitle(activePoint.datum)}\n \n ) : null}\n \n {series.length > 1 ? (\n key === activePoint.dataKey)?.[1] ?? undefined,\n }}\n className=\"h-1.5 w-1.5 rounded-full\"\n />\n ) : null}\n \n {fmtX(activePoint.x)} · {fmtY(activePoint.y, activePoint.dataKey)}\n \n \n \n ) : null}\n \n \n ) : null}\n \n \n );\n}\nScatterChartTooltip.displayName = 'ScatterChart.Tooltip';\nScatterChartTooltip.layer = 'overlay' as Layer;\n\nexport interface ScatterChartLegendProps extends ViewProps {\n className?: string;\n /** Label per series key. A key with no label falls back to the key itself. */\n labels?: Record;\n}\n\n/**\n * A swatch and a name per registered series. Sits in the top-left of the plot\n * by default — move it with `className`.\n */\nfunction ScatterChartLegend({ className, labels, ...props }: ScatterChartLegendProps) {\n const { series } = useChart('ScatterChart.Legend');\n if (!series.length) return null;\n\n return (\n \n {series.map(([key, color]) => (\n \n ))}\n \n );\n}\nScatterChartLegend.displayName = 'ScatterChart.Legend';\nScatterChartLegend.layer = 'overlay' as Layer;\n\n/** One series' colour and name. Shared by the legend and the header. */\nfunction SeriesSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface ScatterChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the chart is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Prettier names for the series keys, as the legend takes. */\n labels?: Record;\n /**\n * Draw a swatch and a name per series along the trailing edge. Prefer this to\n * `ScatterChart.Legend` on a chart that has a header: the legend floats over\n * the plot, where it competes with the points for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it currently reads, and\n * what the colours mean.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActivePointChange` and pass the\n * formatted string down, so one header can show a summary when nothing is\n * pressed and a point's values when something is.\n */\nfunction ScatterChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: ScatterChartHeaderProps) {\n const { series } = useChart('ScatterChart.Header');\n const trailing =\n children ??\n (legend && series.length ? (\n \n {series.map(([key, color]) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-series key takes the width it wants and the caption underneath\n the value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nScatterChartHeader.displayName = 'ScatterChart.Header';\nScatterChartHeader.layer = 'header' as Layer;\n\nexport const ScatterChart = Object.assign(ScatterChartRoot, {\n Header: ScatterChartHeader,\n Grid: ScatterChartGrid,\n Points: ScatterChartPoints,\n Skeleton: ScatterChartSkeleton,\n XAxis: ScatterChartXAxis,\n YAxis: ScatterChartYAxis,\n Tooltip: ScatterChartTooltip,\n Legend: ScatterChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/waterfall-chart.json b/apps/docs/public/r/waterfall-chart.json index cd58d2ecb..0e7c33040 100644 --- a/apps/docs/public/r/waterfall-chart.json +++ b/apps/docs/public/r/waterfall-chart.json @@ -21,7 +21,7 @@ { "path": "ui/waterfall-chart.tsx", "type": "registry:ui", - "content": "/**\n * WaterfallChart — how a run of changes carried one total to another.\n *\n * Composed the same way the other charts are: the grid, the bars, the\n * connectors, the axes and the readout are separate children, so a chart that\n * wants no grid simply does not have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What the shape is asserting\n *\n * **Every bar but a total floats.** A step's bar starts where the previous one\n * ended and reaches as far as its own value carries it, so the gap under it is\n * the running total it is acting on. That floating is the entire point: a bar\n * chart of the same numbers would compare the changes against each other, and\n * this compares each of them against the balance it moved.\n *\n * **A total is anchored to zero.** It is a reading rather than a change, so it\n * is measured from the baseline like an ordinary bar and drawn in a neutral\n * colour. Marking the opening and closing steps `total` is what gives the run\n * two ends to be a bridge between.\n *\n * **Three colours, and no more.** Up, down, and total. A fourth would have to\n * mean something the reader has to be told, and the one thing this chart has\n * going for it is that the direction of a bar is legible before its label is.\n *\n * **The connectors are the sequence.** Without them the bars are a row of\n * floating rectangles at unexplained heights; the line from one bar's end to\n * the next bar's start is what says the second continues the first.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { ChartAccessibilityData, type ChartAccessibilityProps } from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n finiteChartDomain,\n finiteChartNumber,\n} from '@/components/ui/finite-chart';\nimport { barPath, compactNumber, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\n\nconst AnimatedG = Animated.createAnimatedComponent(G);\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/**\n * Sideways, the step names run down the left instead of along the bottom, so\n * the room has to come off that side. Reserved rather than overlaid: a name\n * drawn on top of the bars is unreadable against them and makes the bars\n * unreadable too.\n */\nconst PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 76 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 148;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Fallbacks, only reached if the theme CSS was never imported. */\nconst FALLBACK_RISE = '#10b981';\nconst FALLBACK_FALL = '#ef4444';\nconst FALLBACK_TOTAL = '#262626';\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type WaterfallChartStatus = 'loading' | 'ready';\nexport type WaterfallChartOrientation = 'vertical' | 'horizontal';\n\n/** Which of the three roles a step's bar is drawn in. */\nexport type WaterfallKind = 'rise' | 'fall' | 'total';\n\nexport interface WaterfallDatum {\n /** Name of the step, as the axis and the readout show it. */\n label: string;\n /**\n * The change this step makes to the running total.\n *\n * On a `total` step it is added to the running total *before* the bar is\n * drawn, so `0` reads the balance as it stands and a non-zero one opens the\n * run at a starting balance.\n */\n value: number;\n /**\n * Draw this step as a reading rather than a change: measured from the\n * baseline, in the neutral colour, and counted in the legend as a total.\n */\n total?: boolean;\n /** Explicit colour for this one bar, overriding the role's. */\n color?: string;\n}\n\n/** One step, resolved against the running total it acts on. */\nexport interface WaterfallStep {\n datum: WaterfallDatum;\n label: string;\n value: number;\n kind: WaterfallKind;\n /** Value the bar is measured from — the running total before this step. */\n start: number;\n /** Value the bar reaches — the running total after it. */\n end: number;\n}\n\ninterface WaterfallChartContextValue {\n data: WaterfallDatum[];\n steps: WaterfallStep[];\n plot: Plot;\n status: WaterfallChartStatus;\n orientation: WaterfallChartOrientation;\n barGap: number;\n barWidth: number | undefined;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n colors: Record;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst WaterfallChartContext = createContext(null);\n\nfunction useChart(component: string): WaterfallChartContextValue {\n const context = useContext(WaterfallChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The step under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useWaterfallChart() {\n const { steps, activeIndexJS } = useChart('useWaterfallChart');\n return {\n activeIndex: activeIndexJS,\n activeStep: activeIndexJS >= 0 ? (steps[activeIndexJS] ?? null) : null,\n };\n}\n\n/**\n * The running totals every step sits on.\n *\n * Split out because it is the one piece of the chart that is pure arithmetic\n * over the data, and every part that draws anything needs the same answer —\n * two parts deriving it separately is two chances for the bars and the\n * connectors to disagree about where a step ended.\n */\nexport function waterfallSteps(data: WaterfallDatum[]): WaterfallStep[] {\n const steps: WaterfallStep[] = [];\n let running = 0;\n\n for (const datum of data) {\n const value = finiteChartNumber(datum.value) ?? 0;\n\n if (datum.total) {\n // A total is a reading, so it is measured from the baseline and the\n // running figure becomes whatever it reads.\n running += value;\n steps.push({\n datum,\n label: datum.label,\n value: running,\n kind: 'total',\n start: 0,\n end: running,\n });\n continue;\n }\n\n const start = running;\n running += value;\n steps.push({\n datum,\n label: datum.label,\n value,\n kind: value < 0 ? 'fall' : 'rise',\n start,\n end: running,\n });\n }\n\n return steps;\n}\n\nexport interface WaterfallChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The steps, in the order they happen. */\n data: WaterfallDatum[];\n /**\n * `loading` holds the bars at the baseline and grows them into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `WaterfallChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: WaterfallChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the value axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the value axis instead of deriving it. The derived domain always\n * includes zero, and one that does not is a run whose bars cannot be\n * compared — pass this only when you mean it.\n */\n yDomain?: [number, number];\n /** `vertical` stands the bars up; `horizontal` lays the run down the side. */\n orientation?: WaterfallChartOrientation;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any width.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Corner radius on the ends of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A step that rounds\n * to nothing still happened, and a bar of zero length says it did not.\n */\n minBarLength?: number;\n /** Opacity of the bars that are not under the finger. */\n fadedOpacity?: number;\n /** Colour of a step that adds. Defaults to the success token. */\n riseColor?: string;\n /** Colour of a step that subtracts. Defaults to the destructive token. */\n fallColor?: string;\n /** Colour of a `total` step. Defaults to the first chart token. */\n totalColor?: string;\n /**\n * The step under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, step: WaterfallStep | null) => void;\n /** Drop the axis padding, for a run with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface WaterfallChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst WaterfallChartRoot = forwardRef(\n function WaterfallChartRoot(\n {\n className,\n data,\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n orientation = 'vertical',\n barGap = 0.34,\n barWidth,\n cornerRadius = 4,\n minBarLength = 2,\n fadedOpacity = 0.3,\n riseColor,\n fallColor,\n totalColor,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const riseToken = useCSSVariable('--color-success');\n const fallToken = useCSSVariable('--color-destructive');\n const totalToken = useCSSVariable('--color-chart-1');\n\n const colors = useMemo>(\n () => ({\n rise: riseColor ?? (typeof riseToken === 'string' ? riseToken : FALLBACK_RISE),\n fall: fallColor ?? (typeof fallToken === 'string' ? fallToken : FALLBACK_FALL),\n total: totalColor ?? (typeof totalToken === 'string' ? totalToken : FALLBACK_TOTAL),\n }),\n [riseColor, fallColor, totalColor, riseToken, fallToken, totalToken]\n );\n\n const steps = useMemo(() => waterfallSteps(data), [data]);\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : orientation === 'horizontal'\n ? PADDING_SIDEWAYS\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const extent = useMemo<[number, number]>(() => {\n const explicit = finiteChartDomain(yDomain);\n if (explicit) return explicit;\n\n /*\n * Both ends of every bar, not just the values. A step's bar occupies the\n * span between two running totals, and a domain taken from the changes\n * alone would be the range of the *deltas* — which on any run that\n * climbs before it falls is a fraction of the height the bars need.\n */\n let min = 0;\n let max = 0;\n for (const step of steps) {\n const low = Math.min(step.start, step.end);\n const high = Math.max(step.start, step.end);\n if (low < min) min = low;\n if (high > max) max = high;\n }\n\n if (min === 0 && max === 0) return [0, 1];\n // Headroom past the furthest bar only. The zero end is left exactly where\n // it is: padding it would lift the run off its own baseline.\n return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];\n }, [steps, yDomain]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !steps.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, steps.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (steps[index] ?? null) : null);\n },\n [onActiveIndexChange, steps]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n steps,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n ['label', step.label],\n ['change', step.value],\n ['kind', step.kind],\n ['starts at', step.start],\n ['ends at', step.end],\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nWaterfallChartRoot.displayName = 'WaterfallChart';\n\n/* -------------------------------------------------------------------------- */\n/* Geometry */\n/* -------------------------------------------------------------------------- */\n\n/** The band measurements every drawn part reads the same way. */\ninterface Bands {\n along: number;\n across: number;\n alongStart: number;\n acrossStart: number;\n band: number;\n thickness: number;\n}\n\n/**\n * How the plot divides between the steps.\n *\n * A worklet because the bars rebuild on the UI thread every frame the domain is\n * tweening, and shared with the parts that only run on JS so a label and the\n * bar it names can never land on different centres.\n */\nfunction bandsOf(\n plot: Plot,\n total: number,\n horizontal: boolean,\n barGap: number,\n barWidth: number | undefined\n): Bands {\n 'worklet';\n const along = horizontal ? plot.height : plot.width;\n const across = horizontal ? plot.width : plot.height;\n const band = along / Math.max(1, total);\n const usable = band * (1 - barGap);\n return {\n along,\n across,\n alongStart: horizontal ? plot.top : plot.left,\n acrossStart: horizontal ? plot.left : plot.top,\n band,\n thickness: Math.min(barWidth ?? usable, usable),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface WaterfallChartGridProps {\n /** How many lines to draw across the value axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the value axis, so a bar can be read against a number rather\n * than only against the bar beside it.\n */\nfunction WaterfallChartGrid({\n rows = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: WaterfallChartGridProps) {\n const { plot, orientation } = useChart('WaterfallChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);\n\n return (\n \n {lines.map((fraction) =>\n orientation === 'vertical' ? (\n \n ) : (\n \n )\n )}\n \n );\n}\nWaterfallChartGrid.displayName = 'WaterfallChart.Grid';\nWaterfallChartGrid.layer = 'svg' as Layer;\n\nexport interface WaterfallChartBarsProps {\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The bars.\n *\n * Six animated paths a frame rather than one per step: one per role, so the\n * three colours can be three fills, and each of those split into the bar under\n * the finger and the rest, so the others can dim without every bar carrying its\n * own opacity. A run of forty steps costs the same as a run of four.\n *\n * Each bar grows from its own `start` towards its `end` rather than up from the\n * baseline. A step is a movement from one balance to another, and growing it\n * from zero would animate a quantity the chart is not claiming.\n */\nfunction WaterfallChartBars({ cornerRadius }: WaterfallChartBarsProps) {\n const {\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('WaterfallChart.Bars');\n\n const radius = cornerRadius ?? chartRadius;\n const loading = status === 'loading';\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n\n /*\n * All six paths come out of one builder, filtered by role and by whether the\n * bar is the active one. Six passes over the data a frame is still cheaper\n * than the bookkeeping needed to build them all at once, and it keeps the\n * geometry in exactly one place.\n */\n const build = (kind: WaterfallKind, wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n /*\n * Where a value sits along the value axis. Vertical counts down from the\n * top, horizontal counts up from the left — the same scale read in\n * opposite directions, which is the only thing orientation changes.\n */\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? bands.acrossStart + fraction * bands.across\n : bands.acrossStart + bands.across - fraction * bands.across;\n };\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n const step = steps[i];\n if (!step || step.kind !== kind) continue;\n if ((i === active) !== wantActive) continue;\n\n /*\n * Staggered by step, but every bar still finishes inside the one\n * duration: the window each gets is what is left after the stagger, so a\n * run of forty steps does not take forty times as long to arrive.\n */\n const startAt = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));\n const reached = loading ? step.start : step.start + (step.end - step.start) * eased;\n\n const from = project(step.start);\n const to = project(reached);\n let length = Math.abs(to - from);\n // A step that rounds to nothing still happened, and a bar of zero length\n // says it did not. Held back until the grow-in has actually started, so\n // the stub does not appear before the bar it belongs to.\n if (minBarLength > 0 && eased > 0 && !loading && length < minBarLength) {\n length = minBarLength;\n }\n if (length <= 0) continue;\n\n const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n // Which way the bar points is the direction of the change, not the sign\n // of the value — a fall from 900 to 400 points down whatever those two\n // numbers are, and a total always points away from the baseline.\n const forward = reached >= step.start;\n\n d += horizontal\n ? barPath(\n forward ? from : from - length,\n lead,\n length,\n bands.thickness,\n radius,\n forward ? 'right' : 'left'\n )\n : barPath(\n lead,\n forward ? from - length : from,\n bands.thickness,\n length,\n radius,\n forward ? 'up' : 'down'\n );\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const riseRest = useAnimatedProps(build('rise', false));\n const riseActive = useAnimatedProps(build('rise', true));\n const fallRest = useAnimatedProps(build('fall', false));\n const fallActive = useAnimatedProps(build('fall', true));\n const totalRest = useAnimatedProps(build('total', false));\n const totalActive = useAnimatedProps(build('total', true));\n\n return (\n \n \n \n \n \n \n \n \n );\n}\nWaterfallChartBars.displayName = 'WaterfallChart.Bars';\nWaterfallChartBars.layer = 'svg' as Layer;\n\nexport interface WaterfallChartConnectorsProps {\n color?: string;\n dashArray?: string;\n strokeWidth?: number;\n opacity?: number;\n}\n\n/**\n * The lines from each bar's end to the next bar's start.\n *\n * Drawn under the bars, and reaching the full width of both bands rather than\n * only the gap between them, so the ends are hidden behind the bars they touch\n * and the line reads as passing behind the run instead of stopping short of it.\n *\n * They arrive with the reveal, each one held back until the bar on its left has\n * finished growing — a connector drawn to a bar that is not there yet points at\n * nothing.\n */\nfunction WaterfallChartConnectors({\n color,\n dashArray = '3,4',\n strokeWidth = 1,\n opacity = 1,\n}: WaterfallChartConnectorsProps) {\n const {\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n domainMin,\n domainMax,\n reveal,\n } = useChart('WaterfallChart.Connectors');\n\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.4)');\n\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => {\n 'worklet';\n if (total < 2 || plot.width <= 0 || loading) return { d: '' };\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? bands.acrossStart + fraction * bands.across\n : bands.acrossStart + bands.across - fraction * bands.across;\n };\n\n let d = '';\n\n for (let i = 0; i < total - 1; i++) {\n const step = steps[i];\n if (!step) continue;\n\n // The same window the bar on the left is growing in. The connector only\n // starts once that bar has arrived at the level it is drawn at.\n const startAt = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));\n if (eased <= 0) continue;\n\n const level = project(step.end);\n const from = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n const to = bands.alongStart + (i + 2) * bands.band - (bands.band - bands.thickness) / 2;\n // Extends as the next bar arrives, so the line and the bar it is\n // reaching for grow together rather than the line waiting drawn.\n const reach = from + (to - from) * eased;\n\n d += horizontal\n ? `M${level},${from}L${level},${reach}`\n : `M${from},${level}L${reach},${level}`;\n }\n\n return { d };\n });\n\n if (total < 2) return null;\n\n return (\n \n );\n}\nWaterfallChartConnectors.displayName = 'WaterfallChart.Connectors';\nWaterfallChartConnectors.layer = 'svg' as Layer;\n\n/** How much of the value axis a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.18;\n\n/** Steps to draw when there is no data yet to count them from. */\nconst SKELETON_BARS = 6;\n\nexport interface WaterfallChartSkeletonProps {\n /**\n * How many placeholder bars to draw. Defaults to one per step, and to six\n * when the data has not arrived — the count is the one thing a loading chart\n * can be honest about only if it already has the steps.\n */\n bars?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a row of short, equal stubs on the baseline, with a\n * highlight travelling across them.\n *\n * Equal and on the baseline on purpose. Placeholder bars at differing heights\n * are a run the reader has no way to tell from the real one until it changes\n * under them, and floating them would invent a set of running totals — which is\n * the one thing this chart exists to report.\n */\nfunction WaterfallChartSkeleton({\n bars,\n duration = 1400,\n color,\n}: WaterfallChartSkeletonProps) {\n const { plot, status, orientation, steps, barGap, barWidth, cornerRadius } = useChart(\n 'WaterfallChart.Skeleton'\n );\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlightToken = useCSSVariable('--color-chart-1');\n const highlight = typeof highlightToken === 'string' ? highlightToken : FALLBACK_TOTAL;\n // Unique per instance: a fixed id makes two of these on one screen share a\n // gradient, and the second one to mount wins.\n const gradientId = `panelui-waterfall-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const horizontal = orientation === 'horizontal';\n const total = Math.max(1, bars ?? (steps.length || SKELETON_BARS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const length = bands.across * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n path += horizontal\n ? barPath(plot.left, lead, length, bands.thickness, cornerRadius, 'right')\n : barPath(\n lead,\n plot.top + plot.height - length,\n bands.thickness,\n length,\n cornerRadius,\n 'up'\n );\n }\n return path;\n }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);\n\n // Held through the fade rather than to the frame the data lands: the bars\n // grow out of the stubs, and cutting the stubs at the status change leaves\n // the panel empty until the first bar has length.\n const { mounted, opacity } = useSkeletonHandoff(loading);\n const fadeProps = useAnimatedProps(() => ({ opacity: opacity.value }));\n\n if (!mounted || !d) return null;\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nWaterfallChartSkeleton.displayName = 'WaterfallChart.Skeleton';\nWaterfallChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Narrowest a step label may be drawn before the axis starts dropping some.\n * Roughly three characters at `xs`, which is what a short name needs.\n */\nconst MIN_BAND_LABEL = 34;\n\nexport interface WaterfallChartXAxisProps {\n /**\n * How many labels to show. Every step by default, thinned only when the bands\n * get too narrow to read — pass a number to force it lower.\n */\n ticks?: number;\n /** Turn a step into its label. Defaults to its `label`. */\n format?: (step: WaterfallStep, index: number) => string;\n className?: string;\n}\n\n/**\n * The step names, one under each band it has room for. Real text rather than\n * SVG text, so they follow the theme's font and the platform's text scaling —\n * SVG text does neither.\n */\nfunction WaterfallChartXAxis({ ticks, format, className }: WaterfallChartXAxisProps) {\n const { steps, plot, orientation } = useChart('WaterfallChart.XAxis');\n\n const labels = useMemo(() => {\n if (!steps.length) return [];\n\n /*\n * Every step, unless they will not fit. The axis asks the plot how much\n * room there is and only thins when the answer is not enough — a fixed tick\n * count drops names that had room to be drawn.\n */\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, steps.length));\n\n // Every nth band, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same band twice and skips its neighbour.\n const stride = Math.ceil(steps.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < steps.length; index += stride) {\n const step = steps[index];\n if (!step) continue;\n picked.push({ key: index, text: format ? format(step, index) : step.label });\n }\n return picked;\n }, [steps, ticks, format, plot.width]);\n\n if (orientation === 'horizontal') return null;\n\n /*\n * One box per band, exactly the band's width. Tiling them rather than giving\n * each a fixed width means they can never overlap each other and the first\n * and last can never hang off the ends of the plot — the row of labels\n * occupies precisely the space the bars do.\n */\n const bandWidth = steps.length > 0 ? plot.width / steps.length : 0;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nWaterfallChartXAxis.displayName = 'WaterfallChart.XAxis';\nWaterfallChartXAxis.layer = 'overlay' as Layer;\n\nexport interface WaterfallChartYAxisProps {\n /** How many labels to show along the value axis. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction WaterfallChartYAxis({ ticks = 4, format, className }: WaterfallChartYAxisProps) {\n const { plot, steps, orientation, extent } = useChart('WaterfallChart.YAxis');\n\n const horizontal = orientation === 'horizontal';\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const labels = useMemo(() => {\n if (horizontal) {\n // Sideways, the side of the chart is the step axis.\n return steps.map((step, index) => ({ key: index, text: step.label }));\n }\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format, horizontal, steps]);\n\n /*\n * Sideways the labels sit in the gutter the plot already left for them, one\n * band each — `flex-1` per row rather than spacing them edge to edge, so\n * every name lands beside its own bar instead of only the first and last\n * doing so. Upright there are no bands to line up with, so the ticks space\n * themselves against the grid.\n */\n return (\n \n {labels.map((label) => (\n \n \n {label.text}\n \n \n ))}\n \n );\n}\nWaterfallChartYAxis.displayName = 'WaterfallChart.YAxis';\nWaterfallChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out — an axis drawn over the plot is unreadable, and makes what it is\n// drawn over unreadable too.\nWaterfallChartYAxis.axis = 'y' as const;\n\nexport interface WaterfallChartValuesProps {\n /** Format a step's number. Defaults to a signed compact number. */\n format?: (step: WaterfallStep, index: number) => string;\n className?: string;\n}\n\n/**\n * The change each step made, written at the far end of its bar.\n *\n * Signed, because on this chart the sign is the reading: a bar's direction\n * already says which way it went, and a label that drops the sign makes the\n * two directions look like the same number twice.\n *\n * Only drawn upright. Sideways the bars run across a plot whose width is a\n * phone's, and a number at the end of one has nowhere to go that is not on top\n * of the bar or off the chart.\n */\nfunction WaterfallChartValues({ format, className }: WaterfallChartValuesProps) {\n const { steps, plot, orientation, status, barGap, barWidth, extent } =\n useChart('WaterfallChart.Values');\n\n if (orientation === 'horizontal' || status === 'loading' || !steps.length) return null;\n if (plot.width <= 0 || plot.height <= 0) return null;\n\n const bands = bandsOf(plot, steps.length, false, barGap, barWidth);\n const [min, max] = extent;\n const range = max - min || 1;\n\n return (\n \n {steps.map((step, index) => {\n const fraction = (step.end - min) / range;\n const y = plot.top + plot.height - fraction * plot.height;\n const forward = step.end >= step.start;\n const text = format\n ? format(step, index)\n : step.kind === 'total'\n ? compactNumber(step.value)\n : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`;\n\n return (\n \n {text}\n \n );\n })}\n \n );\n}\nWaterfallChartValues.displayName = 'WaterfallChart.Values';\nWaterfallChartValues.layer = 'overlay' as Layer;\n\nexport interface WaterfallChartTooltipProps {\n /** Format the step's change. Defaults to a signed compact number. */\n formatValue?: (step: WaterfallStep) => string;\n /** Format the running total line. Return `null` to drop it. */\n formatTotal?: (step: WaterfallStep) => string | null;\n className?: string;\n}\n\n/**\n * The readout, and the drag that drives it.\n *\n * It reports two numbers, because a step on this chart has two: what it changed\n * by, and what the balance stood at afterwards. The second is the one a bar's\n * position encodes and its length does not, so a readout that only gave the\n * change would leave the reader converting the height back by eye.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds — and the bars here are narrower than a bar\n * chart's, since the gap between them is what the connectors run through.\n */\nfunction WaterfallChartTooltip({\n formatValue,\n formatTotal,\n className,\n}: WaterfallChartTooltipProps) {\n const {\n steps,\n plot,\n orientation,\n colors,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('WaterfallChart.Tooltip');\n\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n const left = plot.left;\n const top = plot.top;\n const width = plot.width;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. Sideways it has to\n * be clamped inside the plot vertically, and how tall it is depends on\n * whether the running total line is shown.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number, y: number) => {\n 'worklet';\n if (!total) return;\n const span = horizontal ? height : width;\n const offset = (horizontal ? y - top : x - left) / (span || 1);\n // Bands, not points: the finger is inside whichever slice it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);\n\n // The readout centres over its band and is clamped inside the plot, so it\n // never runs off the edge at the first or last one. Which axis it follows is\n // the axis the bands run along.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = (horizontal ? plot.height : plot.width) / total;\n const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)\n );\n\n if (!horizontal) {\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n }\n\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }, { translateY: y }] };\n });\n\n const active = activeIndexJS >= 0 ? steps[activeIndexJS] : null;\n const fmtValue =\n formatValue ??\n ((step: WaterfallStep) =>\n step.kind === 'total'\n ? compactNumber(step.value)\n : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`);\n const fmtTotal =\n formatTotal ??\n ((step: WaterfallStep) => (step.kind === 'total' ? null : compactNumber(step.end)));\n\n if (status === 'loading') return null;\n\n const runningTotal = active ? fmtTotal(active) : null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {active.label}\n \n \n \n \n {fmtValue(active)}\n \n \n {runningTotal ? (\n \n {runningTotal}\n \n ) : null}\n \n ) : null}\n \n \n \n );\n}\nWaterfallChartTooltip.displayName = 'WaterfallChart.Tooltip';\nWaterfallChartTooltip.layer = 'overlay' as Layer;\n\n/** One role's colour and name. Shared by the legend and the header. */\nfunction RoleSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\nexport interface WaterfallChartLegendProps extends ViewProps {\n className?: string;\n /** Names for the three roles. */\n labels?: Partial>;\n}\n\n/**\n * A swatch and a name for each role the run actually contains.\n *\n * Three entries at most, and only the ones present — a run with no totals in it\n * listing a \"Total\" colour is a key to a colour that is not on the chart.\n */\nfunction WaterfallChartLegend({ className, labels, ...props }: WaterfallChartLegendProps) {\n const { steps, colors } = useChart('WaterfallChart.Legend');\n\n const present = useMemo(() => {\n const order: WaterfallKind[] = ['rise', 'fall', 'total'];\n const seen = new Set(steps.map((step) => step.kind));\n return order.filter((kind) => seen.has(kind));\n }, [steps]);\n\n if (!present.length) return null;\n\n const names: Record = {\n rise: labels?.rise ?? 'Increase',\n fall: labels?.fall ?? 'Decrease',\n total: labels?.total ?? 'Total',\n };\n\n return (\n \n {present.map((kind) => (\n \n ))}\n \n );\n}\nWaterfallChartLegend.displayName = 'WaterfallChart.Legend';\nWaterfallChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface WaterfallChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the run is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Names for the three roles, as the legend takes. */\n labels?: Partial>;\n /**\n * Draw a swatch and a name per role along the trailing edge. Prefer this to\n * `WaterfallChart.Legend` on a chart that has a header: the legend floats\n * over the plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the run is of, what it currently reads, and\n * what the colours mean.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show the closing balance when\n * nothing is pressed and a step's change when something is.\n */\nfunction WaterfallChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: WaterfallChartHeaderProps) {\n const { steps, colors } = useChart('WaterfallChart.Header');\n\n const present = useMemo(() => {\n const order: WaterfallKind[] = ['rise', 'fall', 'total'];\n const seen = new Set(steps.map((step) => step.kind));\n return order.filter((kind) => seen.has(kind));\n }, [steps]);\n\n const names: Record = {\n rise: labels?.rise ?? 'Increase',\n fall: labels?.fall ?? 'Decrease',\n total: labels?.total ?? 'Total',\n };\n\n const trailing =\n children ??\n (legend && present.length ? (\n \n {present.map((kind) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-role key takes the width it wants and the caption underneath the\n value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nWaterfallChartHeader.displayName = 'WaterfallChart.Header';\nWaterfallChartHeader.layer = 'header' as Layer;\n\nexport const WaterfallChart = Object.assign(WaterfallChartRoot, {\n Header: WaterfallChartHeader,\n Grid: WaterfallChartGrid,\n Connectors: WaterfallChartConnectors,\n Bars: WaterfallChartBars,\n Values: WaterfallChartValues,\n Skeleton: WaterfallChartSkeleton,\n XAxis: WaterfallChartXAxis,\n YAxis: WaterfallChartYAxis,\n Tooltip: WaterfallChartTooltip,\n Legend: WaterfallChartLegend,\n});\n" + "content": "/**\n * WaterfallChart — how a run of changes carried one total to another.\n *\n * Composed the same way the other charts are: the grid, the bars, the\n * connectors, the axes and the readout are separate children, so a chart that\n * wants no grid simply does not have one.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## What the shape is asserting\n *\n * **Every bar but a total floats.** A step's bar starts where the previous one\n * ended and reaches as far as its own value carries it, so the gap under it is\n * the running total it is acting on. That floating is the entire point: a bar\n * chart of the same numbers would compare the changes against each other, and\n * this compares each of them against the balance it moved.\n *\n * **A total is anchored to zero.** It is a reading rather than a change, so it\n * is measured from the baseline like an ordinary bar and drawn in a neutral\n * colour. Marking the opening and closing steps `total` is what gives the run\n * two ends to be a bridge between.\n *\n * **Three colours, and no more.** Up, down, and total. A fourth would have to\n * mean something the reader has to be told, and the one thing this chart has\n * going for it is that the direction of a bar is legible before its label is.\n *\n * **The connectors are the sequence.** Without them the bars are a row of\n * floating rectangles at unexplained heights; the line from one bar's end to\n * the next bar's start is what says the second continues the first.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { Text } from '@/components/ui/text';\nimport {\n finiteChartDomain,\n finiteChartNumber,\n} from '@/components/ui/finite-chart';\nimport { barPath, compactNumber, type Plot } from '@/lib/chart';\nimport { cn } from '@/lib/cn';\nimport { useSkeletonHandoff } from '@/hooks/use-skeleton-handoff';\n\nconst AnimatedG = Animated.createAnimatedComponent(G);\nconst AnimatedPath = Animated.createAnimatedComponent(Path);\nconst AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);\n\n/** Room left around the plot for the axis labels. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\n\n/**\n * Sideways, the step names run down the left instead of along the bottom, so\n * the room has to come off that side. Reserved rather than overlaid: a name\n * drawn on top of the bars is unreadable against them and makes the bars\n * unreadable too.\n */\nconst PADDING_SIDEWAYS = { top: 6, right: 10, bottom: 6, left: 76 };\n\n/** Width the readout is laid out at, so it can be clamped inside the plot. */\nconst LABEL_WIDTH = 148;\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Line height of an `xs` label, for centring one on the grid line it names. */\nconst AXIS_LABEL_HEIGHT = 16;\n\n/** Fallbacks, only reached if the theme CSS was never imported. */\nconst FALLBACK_RISE = '#10b981';\nconst FALLBACK_FALL = '#ef4444';\nconst FALLBACK_TOTAL = '#262626';\n\ntype Layer = 'svg' | 'overlay' | 'header';\n\nexport type WaterfallChartStatus = 'loading' | 'ready';\nexport type WaterfallChartOrientation = 'vertical' | 'horizontal';\n\n/** Which of the three roles a step's bar is drawn in. */\nexport type WaterfallKind = 'rise' | 'fall' | 'total';\n\nexport interface WaterfallDatum {\n /** Name of the step, as the axis and the readout show it. */\n label: string;\n /**\n * The change this step makes to the running total.\n *\n * On a `total` step it is added to the running total *before* the bar is\n * drawn, so `0` reads the balance as it stands and a non-zero one opens the\n * run at a starting balance.\n */\n value: number;\n /**\n * Draw this step as a reading rather than a change: measured from the\n * baseline, in the neutral colour, and counted in the legend as a total.\n */\n total?: boolean;\n /** Explicit colour for this one bar, overriding the role's. */\n color?: string;\n}\n\n/** One step, resolved against the running total it acts on. */\nexport interface WaterfallStep {\n datum: WaterfallDatum;\n label: string;\n value: number;\n kind: WaterfallKind;\n /** Value the bar is measured from — the running total before this step. */\n start: number;\n /** Value the bar reaches — the running total after it. */\n end: number;\n}\n\ninterface WaterfallChartContextValue {\n data: WaterfallDatum[];\n steps: WaterfallStep[];\n plot: Plot;\n status: WaterfallChartStatus;\n orientation: WaterfallChartOrientation;\n barGap: number;\n barWidth: number | undefined;\n cornerRadius: number;\n minBarLength: number;\n fadedOpacity: number;\n colors: Record;\n domainMin: SharedValue;\n domainMax: SharedValue;\n /** The settled domain, for the parts that draw text rather than geometry. */\n extent: [number, number];\n /** 0 to 1 as the bars grow in. Shared, so they arrive as one chart. */\n reveal: SharedValue;\n activeIndex: SharedValue;\n activeIndexJS: number;\n setActiveIndexJS: (index: number) => void;\n}\n\nconst WaterfallChartContext = createContext(null);\n\nfunction useChart(component: string): WaterfallChartContextValue {\n const context = useContext(WaterfallChartContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The step under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveIndexChange` for that.\n */\nexport function useWaterfallChart() {\n const { steps, activeIndexJS } = useChart('useWaterfallChart');\n return {\n activeIndex: activeIndexJS,\n activeStep: activeIndexJS >= 0 ? (steps[activeIndexJS] ?? null) : null,\n };\n}\n\n/**\n * The running totals every step sits on.\n *\n * Split out because it is the one piece of the chart that is pure arithmetic\n * over the data, and every part that draws anything needs the same answer —\n * two parts deriving it separately is two chances for the bars and the\n * connectors to disagree about where a step ended.\n */\nexport function waterfallSteps(data: WaterfallDatum[]): WaterfallStep[] {\n const steps: WaterfallStep[] = [];\n let running = 0;\n\n for (const datum of data) {\n const value = finiteChartNumber(datum.value) ?? 0;\n\n if (datum.total) {\n // A total is a reading, so it is measured from the baseline and the\n // running figure becomes whatever it reads.\n running += value;\n steps.push({\n datum,\n label: datum.label,\n value: running,\n kind: 'total',\n start: 0,\n end: running,\n });\n continue;\n }\n\n const start = running;\n running += value;\n steps.push({\n datum,\n label: datum.label,\n value,\n kind: value < 0 ? 'fall' : 'rise',\n start,\n end: running,\n });\n }\n\n return steps;\n}\n\nexport interface WaterfallChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** The steps, in the order they happen. */\n data: WaterfallDatum[];\n /**\n * `loading` holds the bars at the baseline and grows them into the real ones\n * when it turns `ready`. One component throughout, rather than a spinner\n * swapped for a chart — swapping loses the transition. Add a\n * `WaterfallChart.Skeleton` for something to stand in the plot meanwhile.\n */\n status?: WaterfallChartStatus;\n /** Width ÷ height. `2` is the wide card shape. */\n aspectRatio?: number;\n /** Milliseconds for the bars to grow in on mount. */\n animationDuration?: number;\n /** Milliseconds for the value axis to settle after the data changes. */\n domainDuration?: number;\n /**\n * Fix the value axis instead of deriving it. The derived domain always\n * includes zero, and one that does not is a run whose bars cannot be\n * compared — pass this only when you mean it.\n */\n yDomain?: [number, number];\n /** `vertical` stands the bars up; `horizontal` lays the run down the side. */\n orientation?: WaterfallChartOrientation;\n /**\n * Fraction of each band left empty, `0` to `1`. A fraction rather than a\n * pixel gap so the proportions hold at any width.\n */\n barGap?: number;\n /** Fixed bar thickness in points. Derived from the band when omitted. */\n barWidth?: number;\n /** Corner radius on the ends of a bar. */\n cornerRadius?: number;\n /**\n * Smallest length a non-zero bar is drawn at, in points. A step that rounds\n * to nothing still happened, and a bar of zero length says it did not.\n */\n minBarLength?: number;\n /** Opacity of the bars that are not under the finger. */\n fadedOpacity?: number;\n /** Colour of a step that adds. Defaults to the success token. */\n riseColor?: string;\n /** Colour of a step that subtracts. Defaults to the destructive token. */\n fallColor?: string;\n /** Colour of a `total` step. Defaults to the first chart token. */\n totalColor?: string;\n /**\n * The step under the finger as it moves, and `-1`/`null` when it lifts.\n * Fires when the index changes, not per frame.\n */\n onActiveIndexChange?: (index: number, step: WaterfallStep | null) => void;\n /** Drop the axis padding, for a run with no axis or readout. */\n compact?: boolean;\n children?: ReactNode;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface WaterfallChartHandle {\n replay: () => void;\n}\n\nfunction partition(children: ReactNode) {\n const svg: ReactNode[] = [];\n const overlay: ReactNode[] = [];\n const header: ReactNode[] = [];\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const layer = (child.type as { layer?: Layer }).layer ?? 'svg';\n const slot = {child};\n (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);\n });\n return { svg, overlay, header };\n}\n\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\nconst WaterfallChartRoot = forwardRef(\n function WaterfallChartRoot(\n {\n className,\n data,\n status = 'ready',\n aspectRatio = 2,\n animationDuration = 700,\n domainDuration = 500,\n yDomain,\n orientation = 'vertical',\n barGap = 0.34,\n barWidth,\n cornerRadius = 4,\n minBarLength = 2,\n fadedOpacity = 0.3,\n riseColor,\n fallColor,\n totalColor,\n onActiveIndexChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n compact = false,\n children,\n ...props\n },\n ref\n ) {\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [activeIndexJS, setActiveIndexJS] = useState(-1);\n\n const reveal = useSharedValue(0);\n const domainMin = useSharedValue(0);\n const domainMax = useSharedValue(0);\n const activeIndex = useSharedValue(-1);\n const reducedMotion = useReducedMotion();\n\n const riseToken = useCSSVariable('--color-success');\n const fallToken = useCSSVariable('--color-destructive');\n const totalToken = useCSSVariable('--color-chart-1');\n\n const colors = useMemo>(\n () => ({\n rise: riseColor ?? (typeof riseToken === 'string' ? riseToken : FALLBACK_RISE),\n fall: fallColor ?? (typeof fallToken === 'string' ? fallToken : FALLBACK_FALL),\n total: totalColor ?? (typeof totalToken === 'string' ? totalToken : FALLBACK_TOTAL),\n }),\n [riseColor, fallColor, totalColor, riseToken, fallToken, totalToken]\n );\n\n const steps = useMemo(() => waterfallSteps(data), [data]);\n\n /*\n * Whether an axis is asking for room. It has to be known before the plot is\n * laid out, and only the root sees the children early enough to ask — the\n * axis itself renders into a box that has already been decided.\n */\n const hasYAxis = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as { axis?: string }).axis === 'y') {\n found = true;\n }\n });\n return found;\n }, [children]);\n\n const pad = compact\n ? { top: 2, right: 1, bottom: 2, left: 1 }\n : orientation === 'horizontal'\n ? PADDING_SIDEWAYS\n : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n const plot: Plot = {\n left: pad.left,\n top: pad.top,\n width: Math.max(size.width - pad.left - pad.right, 0),\n height: Math.max(size.height - pad.top - pad.bottom, 0),\n };\n\n const extent = useMemo<[number, number]>(() => {\n const explicit = finiteChartDomain(yDomain);\n if (explicit) return explicit;\n\n /*\n * Both ends of every bar, not just the values. A step's bar occupies the\n * span between two running totals, and a domain taken from the changes\n * alone would be the range of the *deltas* — which on any run that\n * climbs before it falls is a fraction of the height the bars need.\n */\n let min = 0;\n let max = 0;\n for (const step of steps) {\n const low = Math.min(step.start, step.end);\n const high = Math.max(step.start, step.end);\n if (low < min) min = low;\n if (high > max) max = high;\n }\n\n if (min === 0 && max === 0) return [0, 1];\n // Headroom past the furthest bar only. The zero end is left exactly where\n // it is: padding it would lift the run off its own baseline.\n return [min === 0 ? 0 : min * 1.1, max === 0 ? 0 : max * 1.1];\n }, [steps, yDomain]);\n\n const loading = status === 'loading';\n\n useEffect(() => {\n if (loading) return;\n const [min, max] = extent;\n const first = domainMin.value === 0 && domainMax.value === 0;\n if (first || reducedMotion) {\n domainMin.value = min;\n domainMax.value = max;\n return;\n }\n domainMin.value = withTiming(min, { duration: domainDuration });\n domainMax.value = withTiming(max, { duration: domainDuration });\n }, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);\n\n const revealed = useRef(false);\n const playReveal = useMemo(\n () => () => {\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = 0;\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n },\n [reducedMotion, animationDuration, reveal]\n );\n\n useEffect(() => {\n /*\n * Going back to `loading` arms the reveal again. Without this a chart that\n * is refetched comes back fully drawn on the frame the data lands, which\n * reads as the loading state having been for nothing.\n */\n if (loading) {\n revealed.current = false;\n reveal.value = 0;\n return;\n }\n if (revealed.current || plot.width <= 0 || !steps.length) return;\n revealed.current = true;\n playReveal();\n }, [loading, plot.width, steps.length, playReveal, reveal]);\n\n useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n const handleActiveIndex = useMemo(\n () => (index: number) => {\n setActiveIndexJS(index);\n onActiveIndexChange?.(index, index >= 0 ? (steps[index] ?? null) : null);\n },\n [onActiveIndexChange, steps]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((current) =>\n Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height }\n );\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS: handleActiveIndex,\n }),\n // `plot` is rebuilt every render from `size`, so it is compared by value.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n data,\n steps,\n plot.width,\n plot.height,\n plot.left,\n plot.top,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n extent,\n reveal,\n activeIndex,\n activeIndexJS,\n handleActiveIndex,\n ]\n );\n\n const { svg, overlay, header } = partition(children);\n\n /*\n * Two views, because the header is not part of the plot. `aspectRatio` and\n * the layout measurement belong to the drawing area alone — measured on the\n * outer view they would take in the header too, and the plot would lose as\n * much height as the readout took while still claiming the shape asked for.\n */\n return (\n \n \n {header}\n [\n ['label', step.label],\n ['change', step.value],\n ['kind', step.kind],\n ['starts at', step.start],\n ['ends at', step.end],\n ]}\n />\n \n {plot.width > 0 ? (\n <>\n \n {svg}\n \n {overlay}\n \n ) : null}\n \n \n \n );\n }\n);\nWaterfallChartRoot.displayName = 'WaterfallChart';\n\n/* -------------------------------------------------------------------------- */\n/* Geometry */\n/* -------------------------------------------------------------------------- */\n\n/** The band measurements every drawn part reads the same way. */\ninterface Bands {\n along: number;\n across: number;\n alongStart: number;\n acrossStart: number;\n band: number;\n thickness: number;\n}\n\n/**\n * How the plot divides between the steps.\n *\n * A worklet because the bars rebuild on the UI thread every frame the domain is\n * tweening, and shared with the parts that only run on JS so a label and the\n * bar it names can never land on different centres.\n */\nfunction bandsOf(\n plot: Plot,\n total: number,\n horizontal: boolean,\n barGap: number,\n barWidth: number | undefined\n): Bands {\n 'worklet';\n const along = horizontal ? plot.height : plot.width;\n const across = horizontal ? plot.width : plot.height;\n const band = along / Math.max(1, total);\n const usable = band * (1 - barGap);\n return {\n along,\n across,\n alongStart: horizontal ? plot.top : plot.left,\n acrossStart: horizontal ? plot.left : plot.top,\n band,\n thickness: Math.min(barWidth ?? usable, usable),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* SVG layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface WaterfallChartGridProps {\n /** How many lines to draw across the value axis. */\n rows?: number;\n color?: string;\n dashArray?: string;\n opacity?: number;\n}\n\n/**\n * Lines across the value axis, so a bar can be read against a number rather\n * than only against the bar beside it.\n */\nfunction WaterfallChartGrid({\n rows = 4,\n color,\n dashArray = '4,6',\n opacity = 1,\n}: WaterfallChartGridProps) {\n const { plot, orientation } = useChart('WaterfallChart.Grid');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.1)');\n\n const lines = Array.from({ length: rows + 1 }, (_unused, index) => index / rows);\n\n return (\n \n {lines.map((fraction) =>\n orientation === 'vertical' ? (\n \n ) : (\n \n )\n )}\n \n );\n}\nWaterfallChartGrid.displayName = 'WaterfallChart.Grid';\nWaterfallChartGrid.layer = 'svg' as Layer;\n\nexport interface WaterfallChartBarsProps {\n /** Corner radius, overriding the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The bars.\n *\n * Six animated paths a frame rather than one per step: one per role, so the\n * three colours can be three fills, and each of those split into the bar under\n * the finger and the rest, so the others can dim without every bar carrying its\n * own opacity. A run of forty steps costs the same as a run of four.\n *\n * Each bar grows from its own `start` towards its `end` rather than up from the\n * baseline. A step is a movement from one balance to another, and growing it\n * from zero would animate a quantity the chart is not claiming.\n */\nfunction WaterfallChartBars({ cornerRadius }: WaterfallChartBarsProps) {\n const {\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n cornerRadius: chartRadius,\n minBarLength,\n fadedOpacity,\n colors,\n domainMin,\n domainMax,\n reveal,\n activeIndex,\n } = useChart('WaterfallChart.Bars');\n\n const radius = cornerRadius ?? chartRadius;\n const loading = status === 'loading';\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n\n /*\n * All six paths come out of one builder, filtered by role and by whether the\n * bar is the active one. Six passes over the data a frame is still cheaper\n * than the bookkeeping needed to build them all at once, and it keeps the\n * geometry in exactly one place.\n */\n const build = (kind: WaterfallKind, wantActive: boolean) => () => {\n 'worklet';\n if (!total || plot.width <= 0) {\n return { d: '', opacity: 1 };\n }\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n const active = activeIndex.value;\n\n /*\n * Where a value sits along the value axis. Vertical counts down from the\n * top, horizontal counts up from the left — the same scale read in\n * opposite directions, which is the only thing orientation changes.\n */\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? bands.acrossStart + fraction * bands.across\n : bands.acrossStart + bands.across - fraction * bands.across;\n };\n\n let d = '';\n\n for (let i = 0; i < total; i++) {\n const step = steps[i];\n if (!step || step.kind !== kind) continue;\n if ((i === active) !== wantActive) continue;\n\n /*\n * Staggered by step, but every bar still finishes inside the one\n * duration: the window each gets is what is left after the stagger, so a\n * run of forty steps does not take forty times as long to arrive.\n */\n const startAt = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));\n const reached = loading ? step.start : step.start + (step.end - step.start) * eased;\n\n const from = project(step.start);\n const to = project(reached);\n let length = Math.abs(to - from);\n // A step that rounds to nothing still happened, and a bar of zero length\n // says it did not. Held back until the grow-in has actually started, so\n // the stub does not appear before the bar it belongs to.\n if (minBarLength > 0 && eased > 0 && !loading && length < minBarLength) {\n length = minBarLength;\n }\n if (length <= 0) continue;\n\n const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n // Which way the bar points is the direction of the change, not the sign\n // of the value — a fall from 900 to 400 points down whatever those two\n // numbers are, and a total always points away from the baseline.\n const forward = reached >= step.start;\n\n d += horizontal\n ? barPath(\n forward ? from : from - length,\n lead,\n length,\n bands.thickness,\n radius,\n forward ? 'right' : 'left'\n )\n : barPath(\n lead,\n forward ? from - length : from,\n bands.thickness,\n length,\n radius,\n forward ? 'up' : 'down'\n );\n }\n\n // Dimming only happens while something *is* active; with nothing under the\n // finger every bar is at full ink, which is the resting state.\n const dim = !wantActive && active >= 0 ? fadedOpacity : 1;\n return { d, opacity: dim };\n };\n\n const riseRest = useAnimatedProps(build('rise', false));\n const riseActive = useAnimatedProps(build('rise', true));\n const fallRest = useAnimatedProps(build('fall', false));\n const fallActive = useAnimatedProps(build('fall', true));\n const totalRest = useAnimatedProps(build('total', false));\n const totalActive = useAnimatedProps(build('total', true));\n\n return (\n \n \n \n \n \n \n \n \n );\n}\nWaterfallChartBars.displayName = 'WaterfallChart.Bars';\nWaterfallChartBars.layer = 'svg' as Layer;\n\nexport interface WaterfallChartConnectorsProps {\n color?: string;\n dashArray?: string;\n strokeWidth?: number;\n opacity?: number;\n}\n\n/**\n * The lines from each bar's end to the next bar's start.\n *\n * Drawn under the bars, and reaching the full width of both bands rather than\n * only the gap between them, so the ends are hidden behind the bars they touch\n * and the line reads as passing behind the run instead of stopping short of it.\n *\n * They arrive with the reveal, each one held back until the bar on its left has\n * finished growing — a connector drawn to a bar that is not there yet points at\n * nothing.\n */\nfunction WaterfallChartConnectors({\n color,\n dashArray = '3,4',\n strokeWidth = 1,\n opacity = 1,\n}: WaterfallChartConnectorsProps) {\n const {\n steps,\n plot,\n status,\n orientation,\n barGap,\n barWidth,\n domainMin,\n domainMax,\n reveal,\n } = useChart('WaterfallChart.Connectors');\n\n const token = useCSSVariable('--color-muted-foreground');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(0,0,0,0.4)');\n\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n const loading = status === 'loading';\n\n const animatedProps = useAnimatedProps(() => {\n 'worklet';\n if (total < 2 || plot.width <= 0 || loading) return { d: '' };\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const min = domainMin.value;\n const max = domainMax.value;\n const range = max - min || 1;\n const grow = reveal.value;\n\n const project = (value: number) => {\n 'worklet';\n const fraction = (value - min) / range;\n return horizontal\n ? bands.acrossStart + fraction * bands.across\n : bands.acrossStart + bands.across - fraction * bands.across;\n };\n\n let d = '';\n\n for (let i = 0; i < total - 1; i++) {\n const step = steps[i];\n if (!step) continue;\n\n // The same window the bar on the left is growing in. The connector only\n // starts once that bar has arrived at the level it is drawn at.\n const startAt = total > 1 ? (i / total) * 0.45 : 0;\n const eased = Math.max(0, Math.min(1, (grow - startAt) / 0.55));\n if (eased <= 0) continue;\n\n const level = project(step.end);\n const from = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n const to = bands.alongStart + (i + 2) * bands.band - (bands.band - bands.thickness) / 2;\n // Extends as the next bar arrives, so the line and the bar it is\n // reaching for grow together rather than the line waiting drawn.\n const reach = from + (to - from) * eased;\n\n d += horizontal\n ? `M${level},${from}L${level},${reach}`\n : `M${from},${level}L${reach},${level}`;\n }\n\n return { d };\n });\n\n if (total < 2) return null;\n\n return (\n \n );\n}\nWaterfallChartConnectors.displayName = 'WaterfallChart.Connectors';\nWaterfallChartConnectors.layer = 'svg' as Layer;\n\n/** How much of the value axis a placeholder bar takes. */\nconst SKELETON_LENGTH = 0.18;\n\n/** Steps to draw when there is no data yet to count them from. */\nconst SKELETON_BARS = 6;\n\nexport interface WaterfallChartSkeletonProps {\n /**\n * How many placeholder bars to draw. Defaults to one per step, and to six\n * when the data has not arrived — the count is the one thing a loading chart\n * can be honest about only if it already has the steps.\n */\n bars?: number;\n /** Milliseconds for one pass of the sweep. */\n duration?: number;\n color?: string;\n}\n\n/**\n * The loading state: a row of short, equal stubs on the baseline, with a\n * highlight travelling across them.\n *\n * Equal and on the baseline on purpose. Placeholder bars at differing heights\n * are a run the reader has no way to tell from the real one until it changes\n * under them, and floating them would invent a set of running totals — which is\n * the one thing this chart exists to report.\n */\nfunction WaterfallChartSkeleton({\n bars,\n duration = 1400,\n color,\n}: WaterfallChartSkeletonProps) {\n const { plot, status, orientation, steps, barGap, barWidth, cornerRadius } = useChart(\n 'WaterfallChart.Skeleton'\n );\n const token = useCSSVariable('--color-skeleton');\n const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');\n const highlightToken = useCSSVariable('--color-chart-1');\n const highlight = typeof highlightToken === 'string' ? highlightToken : FALLBACK_TOTAL;\n // Unique per instance: a fixed id makes two of these on one screen share a\n // gradient, and the second one to mount wins.\n const gradientId = `panelui-waterfall-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n const sweep = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n const loading = status === 'loading';\n\n useEffect(() => {\n if (!loading || reducedMotion) {\n cancelAnimation(sweep);\n sweep.value = 0;\n return;\n }\n sweep.value = 0;\n sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);\n return () => cancelAnimation(sweep);\n }, [loading, reducedMotion, duration, sweep]);\n\n // The band travels by moving the gradient's own endpoints, so the whole\n // effect is two numbers changing on the UI thread.\n const animatedProps = useAnimatedProps(() => ({\n x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,\n x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,\n }));\n\n const horizontal = orientation === 'horizontal';\n const total = Math.max(1, bars ?? (steps.length || SKELETON_BARS));\n\n const d = useMemo(() => {\n if (plot.width <= 0 || plot.height <= 0) return '';\n\n const bands = bandsOf(plot, total, horizontal, barGap, barWidth);\n const length = bands.across * SKELETON_LENGTH;\n\n let path = '';\n for (let i = 0; i < total; i += 1) {\n const lead = bands.alongStart + i * bands.band + (bands.band - bands.thickness) / 2;\n path += horizontal\n ? barPath(plot.left, lead, length, bands.thickness, cornerRadius, 'right')\n : barPath(\n lead,\n plot.top + plot.height - length,\n bands.thickness,\n length,\n cornerRadius,\n 'up'\n );\n }\n return path;\n }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);\n\n // Held through the fade rather than to the frame the data lands: the bars\n // grow out of the stubs, and cutting the stubs at the status change leaves\n // the panel empty until the first bar has length.\n const { mounted, opacity } = useSkeletonHandoff(loading);\n const fadeProps = useAnimatedProps(() => ({ opacity: opacity.value }));\n\n if (!mounted || !d) return null;\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\nWaterfallChartSkeleton.displayName = 'WaterfallChart.Skeleton';\nWaterfallChartSkeleton.layer = 'svg' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Overlay layer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Narrowest a step label may be drawn before the axis starts dropping some.\n * Roughly three characters at `xs`, which is what a short name needs.\n */\nconst MIN_BAND_LABEL = 34;\n\nexport interface WaterfallChartXAxisProps {\n /**\n * How many labels to show. Every step by default, thinned only when the bands\n * get too narrow to read — pass a number to force it lower.\n */\n ticks?: number;\n /** Turn a step into its label. Defaults to its `label`. */\n format?: (step: WaterfallStep, index: number) => string;\n className?: string;\n}\n\n/**\n * The step names, one under each band it has room for. Real text rather than\n * SVG text, so they follow the theme's font and the platform's text scaling —\n * SVG text does neither.\n */\nfunction WaterfallChartXAxis({ ticks, format, className }: WaterfallChartXAxisProps) {\n const { steps, plot, orientation } = useChart('WaterfallChart.XAxis');\n\n const labels = useMemo(() => {\n if (!steps.length) return [];\n\n /*\n * Every step, unless they will not fit. The axis asks the plot how much\n * room there is and only thins when the answer is not enough — a fixed tick\n * count drops names that had room to be drawn.\n */\n const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));\n const count = Math.max(1, Math.min(ticks ?? room, steps.length));\n\n // Every nth band, rather than a fractional step rounded to the nearest\n // index — rounding lands on the same band twice and skips its neighbour.\n const stride = Math.ceil(steps.length / count);\n const picked: { key: number; text: string }[] = [];\n for (let index = 0; index < steps.length; index += stride) {\n const step = steps[index];\n if (!step) continue;\n picked.push({ key: index, text: format ? format(step, index) : step.label });\n }\n return picked;\n }, [steps, ticks, format, plot.width]);\n\n if (orientation === 'horizontal') return null;\n\n /*\n * One box per band, exactly the band's width. Tiling them rather than giving\n * each a fixed width means they can never overlap each other and the first\n * and last can never hang off the ends of the plot — the row of labels\n * occupies precisely the space the bars do.\n */\n const bandWidth = steps.length > 0 ? plot.width / steps.length : 0;\n\n return (\n \n {labels.map((label) => (\n \n {label.text}\n \n ))}\n \n );\n}\nWaterfallChartXAxis.displayName = 'WaterfallChart.XAxis';\nWaterfallChartXAxis.layer = 'overlay' as Layer;\n\nexport interface WaterfallChartYAxisProps {\n /** How many labels to show along the value axis. */\n ticks?: number;\n /** Format a value for its label. Defaults to a compact number. */\n format?: (value: number) => string;\n className?: string;\n}\n\n/** Value labels down the side, aligned to the grid lines. */\nfunction WaterfallChartYAxis({ ticks = 4, format, className }: WaterfallChartYAxisProps) {\n const { plot, steps, orientation, extent } = useChart('WaterfallChart.YAxis');\n\n const horizontal = orientation === 'horizontal';\n\n /*\n * Read off the settled domain rather than the shared values the paths use.\n * A label is text, and text is JS — following the tween would re-render on\n * every frame of it to redraw a number nobody can read while it moves.\n */\n const labels = useMemo(() => {\n if (horizontal) {\n // Sideways, the side of the chart is the step axis.\n return steps.map((step, index) => ({ key: index, text: step.label }));\n }\n const [min, max] = extent;\n if (min === 0 && max === 0) return [];\n return Array.from({ length: ticks + 1 }, (_unused, index) => {\n const value = max - ((max - min) * index) / ticks;\n return { key: index, text: format ? format(value) : compactNumber(value) };\n });\n }, [extent, ticks, format, horizontal, steps]);\n\n /*\n * Sideways the labels sit in the gutter the plot already left for them, one\n * band each — `flex-1` per row rather than spacing them edge to edge, so\n * every name lands beside its own bar instead of only the first and last\n * doing so. Upright there are no bands to line up with, so the ticks space\n * themselves against the grid.\n */\n return (\n \n {labels.map((label) => (\n \n \n {label.text}\n \n \n ))}\n \n );\n}\nWaterfallChartYAxis.displayName = 'WaterfallChart.YAxis';\nWaterfallChartYAxis.layer = 'overlay' as Layer;\n// Read by the root, which has to leave room for the labels before it lays the\n// plot out — an axis drawn over the plot is unreadable, and makes what it is\n// drawn over unreadable too.\nWaterfallChartYAxis.axis = 'y' as const;\n\nexport interface WaterfallChartValuesProps {\n /** Format a step's number. Defaults to a signed compact number. */\n format?: (step: WaterfallStep, index: number) => string;\n className?: string;\n}\n\n/**\n * The change each step made, written at the far end of its bar.\n *\n * Signed, because on this chart the sign is the reading: a bar's direction\n * already says which way it went, and a label that drops the sign makes the\n * two directions look like the same number twice.\n *\n * Only drawn upright. Sideways the bars run across a plot whose width is a\n * phone's, and a number at the end of one has nowhere to go that is not on top\n * of the bar or off the chart.\n */\nfunction WaterfallChartValues({ format, className }: WaterfallChartValuesProps) {\n const { steps, plot, orientation, status, barGap, barWidth, extent } =\n useChart('WaterfallChart.Values');\n\n if (orientation === 'horizontal' || status === 'loading' || !steps.length) return null;\n if (plot.width <= 0 || plot.height <= 0) return null;\n\n const bands = bandsOf(plot, steps.length, false, barGap, barWidth);\n const [min, max] = extent;\n const range = max - min || 1;\n\n return (\n \n {steps.map((step, index) => {\n const fraction = (step.end - min) / range;\n const y = plot.top + plot.height - fraction * plot.height;\n const forward = step.end >= step.start;\n const text = format\n ? format(step, index)\n : step.kind === 'total'\n ? compactNumber(step.value)\n : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`;\n\n return (\n \n {text}\n \n );\n })}\n \n );\n}\nWaterfallChartValues.displayName = 'WaterfallChart.Values';\nWaterfallChartValues.layer = 'overlay' as Layer;\n\nexport interface WaterfallChartTooltipProps {\n /** Format the step's change. Defaults to a signed compact number. */\n formatValue?: (step: WaterfallStep) => string;\n /** Format the running total line. Return `null` to drop it. */\n formatTotal?: (step: WaterfallStep) => string | null;\n className?: string;\n}\n\n/**\n * The readout, and the drag that drives it.\n *\n * It reports two numbers, because a step on this chart has two: what it changed\n * by, and what the balance stood at afterwards. The second is the one a bar's\n * position encodes and its length does not, so a readout that only gave the\n * change would leave the reader converting the height back by eye.\n *\n * The hit area is the whole plot. A readout you have to land on the bar to\n * summon is a readout nobody finds — and the bars here are narrower than a bar\n * chart's, since the gap between them is what the connectors run through.\n */\nfunction WaterfallChartTooltip({\n formatValue,\n formatTotal,\n className,\n}: WaterfallChartTooltipProps) {\n const {\n steps,\n plot,\n orientation,\n colors,\n activeIndex,\n activeIndexJS,\n setActiveIndexJS,\n status,\n } = useChart('WaterfallChart.Tooltip');\n\n const total = steps.length;\n const horizontal = orientation === 'horizontal';\n const left = plot.left;\n const top = plot.top;\n const width = plot.width;\n const height = plot.height;\n\n /*\n * The readout's own height, measured rather than assumed. Sideways it has to\n * be clamped inside the plot vertically, and how tall it is depends on\n * whether the running total line is shown.\n */\n const labelHeight = useSharedValue(0);\n\n /*\n * Declared inside the memo, next to its callers: a worklet may only call\n * another worklet, and the rule is enforced by crashing rather than warning.\n */\n const pan = useMemo(() => {\n const resolve = (x: number, y: number) => {\n 'worklet';\n if (!total) return;\n const span = horizontal ? height : width;\n const offset = (horizontal ? y - top : x - left) / (span || 1);\n // Bands, not points: the finger is inside whichever slice it lands on,\n // which is a floor rather than a round to the nearest centre.\n const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));\n if (next === activeIndex.value) return;\n activeIndex.value = next;\n runOnJS(setActiveIndexJS)(next);\n };\n\n return Gesture.Pan()\n .minDistance(0)\n .onBegin((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n resolve(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n activeIndex.value = -1;\n runOnJS(setActiveIndexJS)(-1);\n });\n }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);\n\n // The readout centres over its band and is clamped inside the plot, so it\n // never runs off the edge at the first or last one. Which axis it follows is\n // the axis the bands run along.\n const labelStyle = useAnimatedStyle(() => {\n const index = activeIndex.value;\n if (index < 0 || !total) return { opacity: 0 };\n const band = (horizontal ? plot.height : plot.width) / total;\n const centre = (horizontal ? plot.top : plot.left) + band * (index + 0.5);\n const half = LABEL_WIDTH / 2;\n const clamped = Math.min(\n plot.left + plot.width - half,\n Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)\n );\n\n if (!horizontal) {\n return { opacity: 1, transform: [{ translateX: clamped - half }] };\n }\n\n const tall = labelHeight.value;\n const y = Math.min(\n plot.top + Math.max(plot.height - tall, 0),\n Math.max(plot.top, centre - tall / 2)\n );\n return { opacity: 1, transform: [{ translateX: clamped - half }, { translateY: y }] };\n });\n\n const active = activeIndexJS >= 0 ? steps[activeIndexJS] : null;\n const fmtValue =\n formatValue ??\n ((step: WaterfallStep) =>\n step.kind === 'total'\n ? compactNumber(step.value)\n : `${step.value > 0 ? '+' : ''}${compactNumber(step.value)}`);\n const fmtTotal =\n formatTotal ??\n ((step: WaterfallStep) => (step.kind === 'total' ? null : compactNumber(step.end)));\n\n if (status === 'loading') return null;\n\n const runningTotal = active ? fmtTotal(active) : null;\n\n return (\n \n \n \n {active ? (\n {\n labelHeight.value = event.nativeEvent.layout.height;\n }}\n className={cn(\n 'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',\n className\n )}\n >\n \n {active.label}\n \n \n \n \n {fmtValue(active)}\n \n \n {runningTotal ? (\n \n {runningTotal}\n \n ) : null}\n \n ) : null}\n \n \n \n );\n}\nWaterfallChartTooltip.displayName = 'WaterfallChart.Tooltip';\nWaterfallChartTooltip.layer = 'overlay' as Layer;\n\n/** One role's colour and name. Shared by the legend and the header. */\nfunction RoleSwatch({ color, label }: { color: string; label: string }) {\n return (\n \n \n \n {label}\n \n \n );\n}\n\nexport interface WaterfallChartLegendProps extends ViewProps {\n className?: string;\n /** Names for the three roles. */\n labels?: Partial>;\n}\n\n/**\n * A swatch and a name for each role the run actually contains.\n *\n * Three entries at most, and only the ones present — a run with no totals in it\n * listing a \"Total\" colour is a key to a colour that is not on the chart.\n */\nfunction WaterfallChartLegend({ className, labels, ...props }: WaterfallChartLegendProps) {\n const { steps, colors } = useChart('WaterfallChart.Legend');\n\n const present = useMemo(() => {\n const order: WaterfallKind[] = ['rise', 'fall', 'total'];\n const seen = new Set(steps.map((step) => step.kind));\n return order.filter((kind) => seen.has(kind));\n }, [steps]);\n\n if (!present.length) return null;\n\n const names: Record = {\n rise: labels?.rise ?? 'Increase',\n fall: labels?.fall ?? 'Decrease',\n total: labels?.total ?? 'Total',\n };\n\n return (\n \n {present.map((kind) => (\n \n ))}\n \n );\n}\nWaterfallChartLegend.displayName = 'WaterfallChart.Legend';\nWaterfallChartLegend.layer = 'overlay' as Layer;\n\n/* -------------------------------------------------------------------------- */\n/* Header layer */\n/* -------------------------------------------------------------------------- */\n\nexport interface WaterfallChartHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the run is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a comparison, a total. */\n caption?: string;\n /** Names for the three roles, as the legend takes. */\n labels?: Partial>;\n /**\n * Draw a swatch and a name per role along the trailing edge. Prefer this to\n * `WaterfallChart.Legend` on a chart that has a header: the legend floats\n * over the plot, where it competes with the bars for the same corner.\n */\n legend?: boolean;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the run is of, what it currently reads, and\n * what the colours mean.\n *\n * The value is not derived here. A readout that follows the finger belongs to\n * whoever owns the data — take it from `onActiveIndexChange` and pass the\n * formatted string down, so one header can show the closing balance when\n * nothing is pressed and a step's change when something is.\n */\nfunction WaterfallChartHeader({\n className,\n title,\n value,\n caption,\n labels,\n legend = false,\n children,\n ...props\n}: WaterfallChartHeaderProps) {\n const { steps, colors } = useChart('WaterfallChart.Header');\n\n const present = useMemo(() => {\n const order: WaterfallKind[] = ['rise', 'fall', 'total'];\n const seen = new Set(steps.map((step) => step.kind));\n return order.filter((kind) => seen.has(kind));\n }, [steps]);\n\n const names: Record = {\n rise: labels?.rise ?? 'Increase',\n fall: labels?.fall ?? 'Decrease',\n total: labels?.total ?? 'Total',\n };\n\n const trailing =\n children ??\n (legend && present.length ? (\n \n {present.map((kind) => (\n \n ))}\n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native. Held rigid, a\n three-role key takes the width it wants and the caption underneath the\n value wraps to two lines to make room for it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nWaterfallChartHeader.displayName = 'WaterfallChart.Header';\nWaterfallChartHeader.layer = 'header' as Layer;\n\nexport const WaterfallChart = Object.assign(WaterfallChartRoot, {\n Header: WaterfallChartHeader,\n Grid: WaterfallChartGrid,\n Connectors: WaterfallChartConnectors,\n Bars: WaterfallChartBars,\n Values: WaterfallChartValues,\n Skeleton: WaterfallChartSkeleton,\n XAxis: WaterfallChartXAxis,\n YAxis: WaterfallChartYAxis,\n Tooltip: WaterfallChartTooltip,\n Legend: WaterfallChartLegend,\n});\n" } ] } diff --git a/apps/docs/public/skills/panelui/components/heatmap-chart.md b/apps/docs/public/skills/panelui/components/heatmap-chart.md index 400bb6886..21988c744 100644 --- a/apps/docs/public/skills/panelui/components/heatmap-chart.md +++ b/apps/docs/public/skills/panelui/components/heatmap-chart.md @@ -85,7 +85,7 @@ Extends `ViewProps, ChartAccessibilityProps`. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `className` | `string` | — | — | -| `width` | `number` | `26` | Width reserved for the labels. The grid is sized around it. | +| `width` | `number` | — | Width reserved for the labels. The grid is sized around it. Defaults to 26, and widens with the system text size so a larger weekday does not wrap. | | `tickFilter` | `'all' \| 'odd' \| 'even'` | `odd` | Which rows get a label. Every other row is the usual choice. | | `labelFormat` | `'initial' \| 'full'` | `full` | `initial` is the single letter; `full` is the abbreviated name. | | `labels` | `string[]` | — | Row labels, top to bottom. Overrides the weekday names — for a grid whose rows are not days. | diff --git a/packages/panelui/README.md b/packages/panelui/README.md index e5b325c78..e887243ee 100644 --- a/packages/panelui/README.md +++ b/packages/panelui/README.md @@ -193,8 +193,8 @@ import { Scrim } from 'panelui-native/primitives/scrim'; ``` The component subpath is the slug from its documentation URL. The public utilities are `cn`, -`color` and `time`; the other foundation leaves are `animated-pressable`, `keyboard-avoider` and -`scroll-progress` under `panelui-native/primitives/`. +`color` and `time`; the other foundation leaves are `animated-pressable`, `glass`, +`keyboard-avoider`, `portal` and `scroll-progress` under `panelui-native/primitives/`. **Prefer the subpath for anything you ship.** The root entry re-exports all 122 components and Metro does not tree-shake, so one name from it loads every component and everything they import — diff --git a/packages/panelui/package.json b/packages/panelui/package.json index 5574b66ab..9d9f2e3c7 100644 --- a/packages/panelui/package.json +++ b/packages/panelui/package.json @@ -47,11 +47,21 @@ "react-native": "./src/primitives/animated-pressable.tsx", "default": "./lib/module/primitives/animated-pressable.js" }, + "./primitives/glass": { + "types": "./lib/typescript/src/primitives/glass.d.ts", + "react-native": "./src/primitives/glass.tsx", + "default": "./lib/module/primitives/glass.js" + }, "./primitives/keyboard-avoider": { "types": "./lib/typescript/src/primitives/keyboard-avoider.d.ts", "react-native": "./src/primitives/keyboard-avoider.tsx", "default": "./lib/module/primitives/keyboard-avoider.js" }, + "./primitives/portal": { + "types": "./lib/typescript/src/primitives/portal.d.ts", + "react-native": "./src/primitives/portal.tsx", + "default": "./lib/module/primitives/portal.js" + }, "./primitives/scrim": { "types": "./lib/typescript/src/primitives/scrim.d.ts", "react-native": "./src/primitives/scrim.tsx", diff --git a/packages/panelui/scripts/generate-subpath-exports.mjs b/packages/panelui/scripts/generate-subpath-exports.mjs index 6f033fd51..e543e847f 100644 --- a/packages/panelui/scripts/generate-subpath-exports.mjs +++ b/packages/panelui/scripts/generate-subpath-exports.mjs @@ -49,7 +49,9 @@ const leaves = [ ['./icons', 'icons/index.tsx'], ['./primitives/text', 'primitives/text.tsx'], ['./primitives/animated-pressable', 'primitives/animated-pressable.tsx'], + ['./primitives/glass', 'primitives/glass.tsx'], ['./primitives/keyboard-avoider', 'primitives/keyboard-avoider.tsx'], + ['./primitives/portal', 'primitives/portal.tsx'], ['./primitives/scrim', 'primitives/scrim.tsx'], ['./primitives/scroll-progress', 'primitives/scroll-progress.tsx'], ['./utils/cn', 'utils/cn.ts'], diff --git a/packages/panelui/src/components/area-chart/index.tsx b/packages/panelui/src/components/area-chart/index.tsx index f1ba19a1c..894d65e50 100644 --- a/packages/panelui/src/components/area-chart/index.tsx +++ b/packages/panelui/src/components/area-chart/index.tsx @@ -69,7 +69,11 @@ import Svg, { } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { areaPath, columnValues, @@ -829,6 +833,7 @@ function AreaChartXAxis({ ticks = 4, format, className }: AreaChartXAxisProps) { width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -877,7 +882,13 @@ function AreaChartYAxis({ ticks = 4, format, className }: AreaChartYAxisProps) { className={cn('items-end justify-between', className)} > {labels.map((label) => ( - + {label.text} ))} diff --git a/packages/panelui/src/components/bar-chart/index.tsx b/packages/panelui/src/components/bar-chart/index.tsx index e4bdfd0e5..84b87d395 100644 --- a/packages/panelui/src/components/bar-chart/index.tsx +++ b/packages/panelui/src/components/bar-chart/index.tsx @@ -65,7 +65,11 @@ import Animated, { import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { bandOf, barPath, @@ -980,6 +984,7 @@ function BarChartXAxis({ ticks, format, className }: BarChartXAxisProps) { width: bandWidth, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1053,7 +1058,7 @@ function BarChartYAxis({ ticks = 4, format, className }: BarChartYAxisProps) { key={label.key} className={horizontal ? 'flex-1 justify-center' : undefined} > - + {label.text} diff --git a/packages/panelui/src/components/bubble-chart/index.tsx b/packages/panelui/src/components/bubble-chart/index.tsx index 330f4696a..24cabf9e9 100644 --- a/packages/panelui/src/components/bubble-chart/index.tsx +++ b/packages/panelui/src/components/bubble-chart/index.tsx @@ -71,6 +71,7 @@ import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff'; import { + CHART_AXIS_MAX_FONT_SCALE, ChartAccessibilityData, type ChartAccessibilityProps, } from '../../primitives/chart-accessibility'; @@ -1649,6 +1650,7 @@ function BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXA width: plot.width, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label} @@ -1676,6 +1678,7 @@ function BubbleChartXAxis({ ticks = 4, format, label, className }: BubbleChartXA width: AXIS_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {tick.text} @@ -1745,7 +1748,13 @@ function BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYA transform: [{ rotate: '-90deg' }], }} > - + {label} @@ -1765,7 +1774,13 @@ function BubbleChartYAxis({ ticks = 4, format, label, className }: BubbleChartYA className={cn('items-end justify-between', className)} > {labels.map((tick) => ( - + {tick.text} ))} diff --git a/packages/panelui/src/components/bump-chart/index.tsx b/packages/panelui/src/components/bump-chart/index.tsx index e80183da8..0c56e637a 100644 --- a/packages/panelui/src/components/bump-chart/index.tsx +++ b/packages/panelui/src/components/bump-chart/index.tsx @@ -68,7 +68,11 @@ import Animated, { 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 { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { useControllableState } from '../../primitives/controllable-state'; import { finiteChartNumber } from '../../primitives/finite-chart'; import { @@ -997,6 +1001,7 @@ function BumpChartXAxis({ ticks = 4, format, className }: BumpChartXAxisProps) { width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1030,7 +1035,13 @@ function BumpChartYAxis({ format, className }: BumpChartYAxisProps) { className={cn(rows <= 1 ? 'items-end justify-center' : 'items-end justify-between', className)} > {Array.from({ length: rows }, (_unused, index) => ( - + {format ? format(index + 1) : `#${index + 1}`} ))} diff --git a/packages/panelui/src/components/candlestick-chart/index.tsx b/packages/panelui/src/components/candlestick-chart/index.tsx index 4fbf0338f..bbef49064 100644 --- a/packages/panelui/src/components/candlestick-chart/index.tsx +++ b/packages/panelui/src/components/candlestick-chart/index.tsx @@ -70,7 +70,11 @@ import Animated, { import Svg, { G, Line as SvgLine, Path } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { bandOf, compactNumber, yOf, type Plot } from '../../utils/chart'; import { cn } from '../../utils/cn'; @@ -1009,6 +1013,7 @@ function CandlestickChartXAxis({ width: LABEL_BOX, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1067,7 +1072,14 @@ function CandlestickChartYAxis({ className={cn(className)} > {labels.map((label) => ( - + {label.text} ))} diff --git a/packages/panelui/src/components/heatmap-chart/index.tsx b/packages/panelui/src/components/heatmap-chart/index.tsx index 1f55533ae..22c39b277 100644 --- a/packages/panelui/src/components/heatmap-chart/index.tsx +++ b/packages/panelui/src/components/heatmap-chart/index.tsx @@ -48,7 +48,13 @@ import { useState, type ReactNode, } from 'react'; -import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; +import { + StyleSheet, + useWindowDimensions, + View, + type LayoutChangeEvent, + type ViewProps, +} from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, @@ -62,7 +68,11 @@ import Animated, { import Svg, { G, Line as SvgLine, Rect } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { cn } from '../../utils/cn'; import { normalizeWeekStart, startOfDay } from '../../utils/date'; @@ -89,6 +99,20 @@ function isToken(value: string | undefined): value is string { /** Room left for the weekday labels when the y-axis does not ask for its own. */ const DEFAULT_AXIS_WIDTH = 26; +/** Height of the month row at the default text size: one line of `xs` text. */ +const X_AXIS_HEIGHT = 16; + +/** + * How much larger than default the axis labels are drawn, from 1 up to the + * axis cap. The heatmap's gutters are only just wide enough for a weekday at + * the default size, so they grow by the same factor or a larger label wraps + * and runs into the cells. Below the default size they stay put. + */ +function useAxisLabelScale() { + const { fontScale } = useWindowDimensions(); + return Math.min(Math.max(fontScale, 1), CHART_AXIS_MAX_FONT_SCALE); +} + /** * Height assumed for the readout when placing it above a cell. It is a single * line of `xs` text in a padded box, so it does not vary — and measuring it @@ -432,9 +456,12 @@ const HeatmapChartRoot = forwardRef(function HeatmapCha (typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)'); const parts = useMemo(() => splitParts(children), [children]); + const labelScale = useAxisLabelScale(); // The y-axis is laid out beside the grid rather than over it, so its width // has to come out of the grid's before the cells are sized. - const axisWidth = parts.y ? (parts.y.props.width ?? DEFAULT_AXIS_WIDTH) : 0; + const axisWidth = parts.y + ? (parts.y.props.width ?? Math.ceil(DEFAULT_AXIS_WIDTH * labelScale)) + : 0; const grid = useMemo(() => { const count = data.length; @@ -836,6 +863,7 @@ export interface HeatmapXAxisProps { function HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) { const { data, grid } = useHeatmap('HeatmapChart.XAxis'); const step = grid.size + grid.gap; + const scale = useAxisLabelScale(); const labels = useMemo(() => { if (given) { @@ -865,7 +893,10 @@ function HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisPro }, [data, formatLabel, given]); return ( - + 1 && { height: Math.ceil(X_AXIS_HEIGHT * scale) }]} + > {labels.map(({ column, label }) => ( {label} @@ -884,7 +916,10 @@ HeatmapXAxis.slot = 'x-axis' as Slot; export interface HeatmapYAxisProps { className?: string; - /** Width reserved for the labels. The grid is sized around it. */ + /** + * Width reserved for the labels. The grid is sized around it. Defaults to 26, + * and widens with the system text size so a larger weekday does not wrap. + */ width?: number; /** Which rows get a label. Every other row is the usual choice. */ tickFilter?: 'all' | 'odd' | 'even'; @@ -900,13 +935,15 @@ export interface HeatmapYAxisProps { /** Weekday labels down the left of the grid. */ function HeatmapYAxis({ className, - width = DEFAULT_AXIS_WIDTH, + width: widthProp, tickFilter = 'odd', labelFormat = 'full', labels, }: HeatmapYAxisProps) { const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis'); const step = grid.size + grid.gap; + const scale = useAxisLabelScale(); + const width = widthProp ?? Math.ceil(DEFAULT_AXIS_WIDTH * scale); return ( @@ -922,10 +959,12 @@ function HeatmapYAxis({ key={row} size="xs" muted + numberOfLines={1} className="absolute" // Centred on the cell rather than aligned to its top edge, so a // label reads as belonging to the row it sits beside. - style={{ top: row * step + grid.size / 2 - 7 }} + style={{ top: row * step + grid.size / 2 - 7 * scale }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {labels ? name diff --git a/packages/panelui/src/components/line-chart/index.tsx b/packages/panelui/src/components/line-chart/index.tsx index 741a9ae66..bc0a2823d 100644 --- a/packages/panelui/src/components/line-chart/index.tsx +++ b/packages/panelui/src/components/line-chart/index.tsx @@ -80,7 +80,11 @@ import Svg, { } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { areaPath, columnValues, @@ -870,6 +874,7 @@ function LineChartXAxis({ ticks = 4, format, className }: LineChartXAxisProps) { width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -930,7 +935,13 @@ function LineChartYAxis({ ticks = 4, format, className }: LineChartYAxisProps) { className={cn('items-end justify-between', className)} > {labels.map((label) => ( - + {label.text} ))} diff --git a/packages/panelui/src/components/live-line-chart/index.tsx b/packages/panelui/src/components/live-line-chart/index.tsx index c57dde1bc..56dccbd79 100644 --- a/packages/panelui/src/components/live-line-chart/index.tsx +++ b/packages/panelui/src/components/live-line-chart/index.tsx @@ -93,6 +93,7 @@ import Svg, { Line as SvgLine, } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; +import { CHART_AXIS_MAX_FONT_SCALE } from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { compactNumber, @@ -1027,7 +1028,12 @@ function LiveLineChartXAxis({ ticks = 4, formatTick, className }: LiveLineChartX }} className={cn('items-center', className)} > - + {format(seconds * (1 - fraction))} @@ -1082,7 +1088,12 @@ function LiveLineChartYAxis({ ticks = 4, formatValue, className }: LiveLineChart }} className={cn('items-end', className)} > - + {format(max - (max - min) * fraction)} diff --git a/packages/panelui/src/components/mirror-area-chart/index.tsx b/packages/panelui/src/components/mirror-area-chart/index.tsx index e1363b387..a7380c280 100644 --- a/packages/panelui/src/components/mirror-area-chart/index.tsx +++ b/packages/panelui/src/components/mirror-area-chart/index.tsx @@ -64,7 +64,11 @@ import Svg, { } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { finiteChartDomain } from '../../primitives/finite-chart'; import { colorStop, @@ -934,6 +938,7 @@ function MirrorAreaChartXAxis({ ticks = 4, format, className }: MirrorAreaChartX width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -984,6 +989,7 @@ function MirrorAreaChartYAxis({ formatTop, formatBottom, className }: MirrorArea width, textAlign: 'right', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} diff --git a/packages/panelui/src/components/plot/index.tsx b/packages/panelui/src/components/plot/index.tsx index 436744e5b..2b8cf889b 100644 --- a/packages/panelui/src/components/plot/index.tsx +++ b/packages/panelui/src/components/plot/index.tsx @@ -96,7 +96,11 @@ import Svg, { Rect, } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { finiteChartNumber } from '../../primitives/finite-chart'; import { @@ -1371,6 +1375,7 @@ function PlotXAxis({ ticks = 4, format, className }: PlotXAxisProps) { width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1429,7 +1434,13 @@ function PlotYAxis({ ticks = 4, format, className }: PlotYAxisProps) { className={cn('items-end justify-between', className)} > {labels.map((label) => ( - + {label.text} ))} diff --git a/packages/panelui/src/components/pyramid-chart/index.tsx b/packages/panelui/src/components/pyramid-chart/index.tsx index 035c67c2c..fd9545989 100644 --- a/packages/panelui/src/components/pyramid-chart/index.tsx +++ b/packages/panelui/src/components/pyramid-chart/index.tsx @@ -80,6 +80,7 @@ import Animated, { import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { + CHART_AXIS_MAX_FONT_SCALE, ChartAccessibilityData, type ChartAccessibilityProps, } from '../../primitives/chart-accessibility'; @@ -956,6 +957,7 @@ function PyramidChartXAxis({ ticks = 2, format, className }: PyramidChartXAxisPr width: AXIS_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1013,7 +1015,13 @@ function PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) { }} className="justify-center" > - + {label(datum, index)} @@ -1037,7 +1045,7 @@ function PyramidChartYAxis({ format, className }: PyramidChartYAxisProps) { > {data.map((datum, index) => ( - + {label(datum, index)} diff --git a/packages/panelui/src/components/scatter-chart/index.tsx b/packages/panelui/src/components/scatter-chart/index.tsx index 795e59f94..c006399d1 100644 --- a/packages/panelui/src/components/scatter-chart/index.tsx +++ b/packages/panelui/src/components/scatter-chart/index.tsx @@ -67,7 +67,11 @@ import Animated, { import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { Text } from '../../primitives/text'; -import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility'; +import { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart'; import { cn } from '../../utils/cn'; import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff'; @@ -957,6 +961,7 @@ function ScatterChartXAxis({ ticks = 4, format, className }: ScatterChartXAxisPr width: POINT_LABEL_WIDTH, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1004,7 +1009,13 @@ function ScatterChartYAxis({ ticks = 4, format, className }: ScatterChartYAxisPr className={cn('items-end justify-between', className)} > {labels.map((label) => ( - + {label.text} ))} diff --git a/packages/panelui/src/components/waterfall-chart/index.tsx b/packages/panelui/src/components/waterfall-chart/index.tsx index 822b27c76..ff3975c22 100644 --- a/packages/panelui/src/components/waterfall-chart/index.tsx +++ b/packages/panelui/src/components/waterfall-chart/index.tsx @@ -66,7 +66,11 @@ import Animated, { } 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 { + CHART_AXIS_MAX_FONT_SCALE, + ChartAccessibilityData, + type ChartAccessibilityProps, +} from '../../primitives/chart-accessibility'; import { Text } from '../../primitives/text'; import { finiteChartDomain, @@ -1142,6 +1146,7 @@ function WaterfallChartXAxis({ ticks, format, className }: WaterfallChartXAxisPr width: bandWidth, textAlign: 'center', }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label.text} @@ -1209,7 +1214,7 @@ function WaterfallChartYAxis({ ticks = 4, format, className }: WaterfallChartYAx > {labels.map((label) => ( - + {label.text} diff --git a/packages/panelui/src/primitives/chart-accessibility.ts b/packages/panelui/src/primitives/chart-accessibility.ts index 9ba889212..60544afc1 100644 --- a/packages/panelui/src/primitives/chart-accessibility.ts +++ b/packages/panelui/src/primitives/chart-accessibility.ts @@ -1,5 +1,13 @@ import { createElement } from 'react'; +/** + * The largest text scale a chart's axis labels follow: iOS's biggest size before + * the accessibility range. Axis labels sit in fixed-width gutters at fixed + * steps, so past this they overlap each other or clip. Screen readers still get + * every value through the chart's accessibility data. + */ +export const CHART_AXIS_MAX_FONT_SCALE = 1.353; + export interface ChartAccessibilityProps { /** Spoken summary. Defaults to the chart family and number of data entries. */ accessibilityLabel?: string; diff --git a/packages/panelui/test/subpath-exports.test.mjs b/packages/panelui/test/subpath-exports.test.mjs index 877458309..5fbeb56c0 100644 --- a/packages/panelui/test/subpath-exports.test.mjs +++ b/packages/panelui/test/subpath-exports.test.mjs @@ -71,7 +71,9 @@ test('reviewed provider, theme and foundation leaves have exact conditions', () provider: 'providers/panel-ui-provider.tsx', theme: 'theme/use-theme.ts', 'primitives/animated-pressable': 'primitives/animated-pressable.tsx', + 'primitives/glass': 'primitives/glass.tsx', 'primitives/keyboard-avoider': 'primitives/keyboard-avoider.tsx', + 'primitives/portal': 'primitives/portal.tsx', 'primitives/scrim': 'primitives/scrim.tsx', 'primitives/scroll-progress': 'primitives/scroll-progress.tsx', }; diff --git a/skills/panelui/components/heatmap-chart.md b/skills/panelui/components/heatmap-chart.md index 400bb6886..21988c744 100644 --- a/skills/panelui/components/heatmap-chart.md +++ b/skills/panelui/components/heatmap-chart.md @@ -85,7 +85,7 @@ Extends `ViewProps, ChartAccessibilityProps`. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `className` | `string` | — | — | -| `width` | `number` | `26` | Width reserved for the labels. The grid is sized around it. | +| `width` | `number` | — | Width reserved for the labels. The grid is sized around it. Defaults to 26, and widens with the system text size so a larger weekday does not wrap. | | `tickFilter` | `'all' \| 'odd' \| 'even'` | `odd` | Which rows get a label. Every other row is the usual choice. | | `labelFormat` | `'initial' \| 'full'` | `full` | `initial` is the single letter; `full` is the abbreviated name. | | `labels` | `string[]` | — | Row labels, top to bottom. Overrides the weekday names — for a grid whose rows are not days. |