From 2af501757fc25dd92a15aca5b82d65a30d5bcf43 Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Sun, 18 Oct 2026 10:40:09 -0400 Subject: [PATCH 1/3] feat(primitives): export glass and portal as subpaths An app importing by subpath had to fall back to the root entry for useGlassMaterial and PortalHost, which loads the whole library. Co-Authored-By: Claude Opus 5.5 --- packages/panelui/README.md | 4 ++-- packages/panelui/package.json | 10 ++++++++++ packages/panelui/scripts/generate-subpath-exports.mjs | 2 ++ packages/panelui/test/subpath-exports.test.mjs | 2 ++ 4 files changed, 16 insertions(+), 2 deletions(-) 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/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', }; From 4840901ff05fc50f512c57c208e22c96d660987d Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Sun, 18 Oct 2026 10:43:01 -0400 Subject: [PATCH 2/3] fix(charts): stop axis labels growing past their gutters Axis labels sit in fixed-width gutters at fixed steps, so at the accessibility text sizes they overlapped each other or clipped. Every chart axis now caps its labels at iOS's largest non-accessibility size (1.353x). The values stay available to screen readers through each chart's accessibility data. Co-Authored-By: Claude Opus 5.5 --- apps/docs/public/r/area-chart.json | 2 +- apps/docs/public/r/bar-chart.json | 2 +- apps/docs/public/r/bubble-chart.json | 2 +- apps/docs/public/r/bump-chart.json | 2 +- apps/docs/public/r/candlestick-chart.json | 2 +- apps/docs/public/r/chart-accessibility.json | 2 +- apps/docs/public/r/heatmap-chart.json | 2 +- apps/docs/public/r/index.json | 1 + apps/docs/public/r/line-chart.json | 2 +- apps/docs/public/r/live-line-chart.json | 3 ++- apps/docs/public/r/mirror-area-chart.json | 2 +- apps/docs/public/r/plot.json | 2 +- apps/docs/public/r/pyramid-chart.json | 2 +- apps/docs/public/r/scatter-chart.json | 2 +- apps/docs/public/r/waterfall-chart.json | 2 +- .../src/components/area-chart/index.tsx | 15 +++++++++++++-- .../src/components/bar-chart/index.tsx | 9 +++++++-- .../src/components/bubble-chart/index.tsx | 19 +++++++++++++++++-- .../src/components/bump-chart/index.tsx | 15 +++++++++++++-- .../components/candlestick-chart/index.tsx | 16 ++++++++++++++-- .../src/components/heatmap-chart/index.tsx | 8 +++++++- .../src/components/line-chart/index.tsx | 15 +++++++++++++-- .../src/components/live-line-chart/index.tsx | 15 +++++++++++++-- .../components/mirror-area-chart/index.tsx | 8 +++++++- .../panelui/src/components/plot/index.tsx | 15 +++++++++++++-- .../src/components/pyramid-chart/index.tsx | 12 ++++++++++-- .../src/components/scatter-chart/index.tsx | 15 +++++++++++++-- .../src/components/waterfall-chart/index.tsx | 9 +++++++-- .../src/primitives/chart-accessibility.ts | 8 ++++++++ 29 files changed, 171 insertions(+), 38 deletions(-) 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..e7c00bba6 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 { 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 {\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/**\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" } ] } 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/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..e8d0c5327 100644 --- a/packages/panelui/src/components/heatmap-chart/index.tsx +++ b/packages/panelui/src/components/heatmap-chart/index.tsx @@ -62,7 +62,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'; @@ -873,6 +877,7 @@ function HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisPro muted className="absolute" style={{ left: column * step }} + maxFontSizeMultiplier={CHART_AXIS_MAX_FONT_SCALE} > {label} @@ -926,6 +931,7 @@ function HeatmapYAxis({ // 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 }} + 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; From 3afc0f21b8152724b6694ad97eccd3e789f92cab Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 18 Oct 2026 21:52:13 +0300 Subject: [PATCH 3/3] fix(heatmap-chart): grow the axis gutters with the capped label size The heatmap's gutters are only just big enough for its labels at the default size: a 26px weekday column and a 16px month row. At the axis cap a weekday no longer fits, so "Tue" wrapped onto two lines and the month row ran into the first row of cells. Both gutters, and the offset that centres a weekday on its row, now scale by the same capped factor as the labels. They only grow above the default text size, so the default and smaller sizes lay out exactly as before. A weekday label is also held to one line. Co-Authored-By: Claude Opus 5.5 --- .../content/docs/charts/heatmap-chart.mdx | 2 +- apps/docs/public/r/heatmap-chart.json | 2 +- .../panelui/components/heatmap-chart.md | 2 +- .../src/components/heatmap-chart/index.tsx | 45 ++++++++++++++++--- skills/panelui/components/heatmap-chart.md | 2 +- 5 files changed, 43 insertions(+), 10 deletions(-) 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/heatmap-chart.json b/apps/docs/public/r/heatmap-chart.json index e7c00bba6..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 {\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/**\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/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/src/components/heatmap-chart/index.tsx b/packages/panelui/src/components/heatmap-chart/index.tsx index e8d0c5327..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, @@ -93,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 @@ -436,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; @@ -840,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) { @@ -869,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 }) => ( @@ -927,10 +959,11 @@ 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 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. |