diff --git a/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx index 9af1442003..b4b9439ae6 100644 --- a/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx @@ -193,7 +193,7 @@ function CustomBaseline() { return ( string` to label invisible regions the chart overlays for swipe/tap navigation. The array is the current highlight state: multi-touch includes one entry per pointer, and VoiceOver region mode invokes the function with a **single-element** array for that region’s bucket (for example `[{ dataIndex: 2 }]`). Handle `items.length === 0` for summaries, and check `typeof item.dataIndex === 'number'` on each entry before indexing your data. Set `accessibilityMode` to `'item'` for one region per category (typical for bars) or leave the default `'chunked'` for fewer regions on long series. + +You do not need a [Scrubber](/components/charts/Scrubber) for screen reader support. + +`getScrubberAccessibilityLabel` is still supported as an alternative: it drives the separate sampled segments from `ScrubberAccessibilityView` (by `dataIndex`), if you prefer that stepping model instead of `accessibilityLabel` as a function. ```tsx +import type { HighlightedItem } from '@coinbase/cds-mobile-visualization/chart'; + function AccessibleBarChart() { const categories = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); const values = useMemo(() => [40, 65, 55, 80, 72, 90], []); - const getScrubberAccessibilityLabel = useCallback( - (index: number) => `${categories[index]}: ${values[index]}`, + const accessibilityLabel = useCallback( + (items: HighlightedItem[]) => { + if (items.length === 0) { + return `Bar chart, ${values.length} months. Swipe to navigate.`; + } + const parts = items + .map((item) => { + if (typeof item.dataIndex !== 'number') return null; + const i = item.dataIndex; + return `${categories[i]}: ${values[i]}`; + }) + .filter((line): line is string => line !== null); + return ( + parts.join('; ') || `Bar chart, ${values.length} months. Swipe to navigate.` + ); + }, [categories, values], ); return ( { const { getXSerializableScale, drawingArea } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); + const { highlight, enabled } = useHighlightContext(); + const scrubberPosition = useDerivedValue(() => { + if (!enabled) return undefined; + const idx = highlight.value[0]?.dataIndex; + return typeof idx === 'number' ? idx : undefined; + }, [highlight, enabled]); const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); const rectWidth = useMemo(() => { @@ -1009,6 +1038,10 @@ function Candlesticks() { [stockData], ); + const onHighlightChange = useCallback((items) => { + setCurrentIndex(items[0]?.dataIndex ?? undefined); + }, []); + return ( @@ -1017,13 +1050,13 @@ function Candlesticks() { : formatThousandsPrice(parseFloat(stockData[stockData.length - 1].close))} ); - // Custom line component that renders a rect to highlight the entire bandwidth - const BandwidthHighlight = memo(({ d, stroke }) => { - const { getXScale, drawingArea, getXAxis } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - const xScale = getXScale(); - const xAxis = getXAxis(); - - if (!xScale || scrubberPosition === undefined) return; - - const xPos = xScale(scrubberPosition); - - if (xPos === undefined) return; - - return ( - - ); - }); - const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]); const staggerDelay = 0.25; - const CandlestickBarComponent = memo(({ x, y, width, height, originY, dataX, ...props }) => { - const { getYScale, drawingArea } = useCartesianChartContext(); - const yScale = getYScale(); + const CandlestickBarComponent = memo( + ({ x, y, width, height, originY, dataX, dataY, fadeOnHighlight, seriesId, ...props }) => { + const { getYScale, drawingArea } = useCartesianChartContext(); + const highlightContext = useHighlightContext(); + const yScale = getYScale(); + + const dataIndex = React.useMemo(() => { + if (typeof dataX === 'number') return dataX; + if (typeof dataY === 'number') return dataY; + return null; + }, [dataX, dataY]); + + const { enabled: highlightEnabled, highlight, scope } = highlightContext; + const highlightByDataIndex = scope.dataIndex ?? false; + const highlightBySeries = scope.series ?? false; + + const highlightOpacity = React.useMemo(() => { + if (!fadeOnHighlight || !highlightEnabled) return 1; + + let opacity = 1; + if (highlight.length > 0) { + const isHighlighted = highlight.some((item) => { + const indexMatch = + !highlightByDataIndex || + (typeof item.dataIndex === 'number' && item.dataIndex === dataIndex); + const seriesMatch = + !highlightBySeries || item.seriesId == null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + opacity = isHighlighted ? 1 : 0.3; + } + return opacity; + }, [ + fadeOnHighlight, + highlightEnabled, + highlight, + highlightByDataIndex, + highlightBySeries, + dataIndex, + seriesId, + ]); + + const normalizedX = React.useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); - const normalizedX = React.useMemo( - () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), - [x, drawingArea.x, drawingArea.width], - ); + const transition = React.useMemo( + () => ({ + type: 'tween', + duration: 0.325, + delay: normalizedX * staggerDelay, + }), + [normalizedX], + ); - const transition = React.useMemo( - () => ({ - type: 'tween', - duration: 0.325, - delay: normalizedX * staggerDelay, - }), - [normalizedX], - ); + const fadeTransition = { duration: 0.1, ease: 'easeOut' }; - const wickX = x + width / 2; + const wickX = x + width / 2; - const timePeriodValue = stockData[dataX]; + const timePeriodValue = stockData[dataX]; - const open = parseFloat(timePeriodValue.open); - const close = parseFloat(timePeriodValue.close); + const open = parseFloat(timePeriodValue.open); + const close = parseFloat(timePeriodValue.close); - const bullish = open < close; - const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; - const openY = yScale?.(open) ?? 0; - const closeY = yScale?.(close) ?? 0; + const bullish = open < close; + const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + const openY = yScale?.(open) ?? 0; + const closeY = yScale?.(close) ?? 0; - const bodyHeight = Math.abs(openY - closeY); - const bodyY = openY < closeY ? openY : closeY; + const bodyHeight = Math.abs(openY - closeY); + const bodyY = openY < closeY ? openY : closeY; - return ( - - - - - ); - }); + const candlestick = ( + + + + + ); + + if (fadeOnHighlight) { + return ( + + {candlestick} + + ); + } + + return candlestick; + }, + ); const formatPrice = React.useCallback((price) => { return new Intl.NumberFormat('en-US', { @@ -1043,20 +1072,29 @@ function Candlesticks() { ); const updateInfoText = React.useCallback( - (index) => { + (items) => { if (!infoTextRef.current) return; + const fallbackText = formatPrice(stockData[stockData.length - 1].close); + + if (items.length === 0) { + infoTextRef.current.textContent = fallbackText; + selectedIndexRef.current = undefined; + return; + } + + const index = items[0]?.dataIndex; const text = - index !== null && index !== undefined + typeof index === 'number' && index >= 0 && index < stockData.length ? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice( stockData[index].close, )}, Volume: ${(parseFloat(stockData[index].volume) / 1000).toFixed(2)}k` - : formatPrice(stockData[stockData.length - 1].close); + : fallbackText; infoTextRef.current.textContent = text; - selectedIndexRef.current = index; + selectedIndexRef.current = typeof index === 'number' ? index : undefined; }, - [stockData, formatPrice, formatVolume], + [stockData, formatPrice, formatThousandsPrice], ); const initialInfo = formatPrice(stockData[stockData.length - 1].close); @@ -1066,7 +1104,8 @@ function Candlesticks() { {initialInfo} - - + /> ); } diff --git a/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx index 1ed1a7224b..ace8f3ae6f 100644 --- a/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx @@ -7,7 +7,7 @@ CartesianChart is a customizable, `@shopify/react-native-skia` based component t ```jsx Custom inset Default inset { - // Do a light impact when the scrubber position changes - // An initial and final impact is already configured by the chart - if (scrubIndex !== undefined && index !== undefined) { - void Haptics.lightImpact(); - } - setScrubIndex(index); - }, [scrubIndex]); + const onHighlightChange = useCallback( + (items) => { + const index = items[0]?.dataIndex ?? undefined; + // Do a light impact when the highlighted index changes + // An initial and final impact is already configured by the chart + if (scrubIndex !== undefined && index !== undefined && scrubIndex !== index) { + void Haptics.lightImpact(); + } + setScrubIndex(index); + }, + [scrubIndex], + ); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {scrubIndex ?? 'none'} @@ -409,269 +412,229 @@ function Scrubbing() { } ``` -### Allow Overflow Gestures - -By default, the scrubber will not allow overflow gestures. You can allow overflow gestures by setting the `allowOverflowGestures` prop to `true`. - -```jsx - - ... - -``` - -## Animations - -CartesianChart delegates transition control to its child components. Each `Line`, `Area`, and `Bar` accepts a `transitions` prop with `enter` (reveal animation) and `update` (data-change animation) keys. Set either to `null` to disable that phase. You can also disable all animations chart-wide by passing `animate={false}` on CartesianChart. - -Because transitions live on the children, a single chart can mix behaviors — for example a Line that morphs smoothly while a Bar snaps instantly. +### Controlled Highlight -### Enter Only - -Disable the update morph animation while keeping a slow enter reveal. Data changes snap instantly but the initial chart appearance animates. Useful when new data arrives frequently and morphing would be distracting. +You can also control the highlight state by setting `highlight`, such as to enable highlight across multiple charts. ```tsx -function EnterAnimationOnly() { - const dataCount = 15; - const updateInterval = 2500; - - function generateNextValue(prev: number) { - const step = Math.random() * 30 - 15; - return Math.max(0, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } - - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +function FiatAndStablecoinBalance() { + const theme = useTheme(); + const [highlight, setHighlight] = useState([]); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + + const usd = [ + 20, 20, 20, 20, 20, 40, 60, 60, 80, 120, 200, 240, 240, 240, 240, 240, 240, 240, 240, 60, 30, + 20, 25, 5, 0, 0, 0, 0, 80, 120, 150, + ]; + const usdc = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 60, 260, 260, 240, 220, 180, 160, 200, 240, 220, 0, 0, 0, 0, 0, 0, + 250, 250, 250, 250, 250, 250, + ]; + const brl = [ + 0, 0, 0, 0, 0, 0, 0, 20, 40, 100, 60, 60, 60, 0, 0, 0, 0, 0, 0, 160, 40, 80, 140, 180, 120, 0, + 0, 0, 30, 30, 40, + ]; + + const startBrl = 1200; + const startUsdc = 8500; + const startUsd = 2300; + + const toCumulativeWithStart = (values, start) => { + let sum = start; + return values.map((v) => { + sum += v; + return sum; + }); + }; + + const series = [ + { id: 'BRL', data: brl, color: theme.color.accentBoldGreen }, + { id: 'USDC', data: usdc, color: theme.color.accentBoldBlue }, + { id: 'USD', data: usd, color: '#5b6cff' }, + ]; + + const cumulativeBrl = toCumulativeWithStart(brl, startBrl); + const cumulativeUsdc = toCumulativeWithStart(usdc, startUsdc); + const cumulativeUsd = toCumulativeWithStart(usd, startUsd); + + const cumulativeSeries = [ + { id: 'BRL', data: cumulativeBrl, color: theme.color.accentBoldGreen }, + { id: 'USDC', data: cumulativeUsdc, color: theme.color.accentBoldBlue }, + { id: 'USD', data: cumulativeUsd, color: '#5b6cff' }, + ]; + + const formatValueAxis = (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); + + const formatCurrency = (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + + const lastIndex = categories.length - 1; + const rawIndex = highlight[0]?.dataIndex; + const hasSelectedDate = typeof rawIndex === 'number'; + const selectedIndex = hasSelectedDate ? Math.min(Math.max(0, rawIndex), lastIndex) : 0; + + const monthTotalDeposits = usd.reduce((sum, v, i) => sum + v + usdc[i] + brl[i], 0); + const endOfMonthTotalBalance = + cumulativeBrl[lastIndex] + cumulativeUsdc[lastIndex] + cumulativeUsd[lastIndex]; + + const dayDailyTotal = brl[selectedIndex] + usdc[selectedIndex] + usd[selectedIndex]; + const dayTotalBalance = + cumulativeBrl[selectedIndex] + cumulativeUsdc[selectedIndex] + cumulativeUsd[selectedIndex]; + + const depositsSummary = hasSelectedDate + ? `March ${selectedIndex + 1} - ${formatCurrency(dayDailyTotal)}` + : `March - ${formatCurrency(monthTotalDeposits)}`; + + const balanceSummary = formatCurrency(hasSelectedDate ? dayTotalBalance : endOfMonthTotalBalance); - return ( - + + + + + Deposits + + {depositsSummary} + + + + + + + Balance + + {balanceSummary} + + + + + + + - - - ); - } - - return ; + + + + + + ); } ``` -### Update Only +### Highlight Scope -Disable the enter reveal animation while keeping a slow update morph. The chart appears instantly but data changes animate smoothly. Useful when the chart is embedded in content that should not animate on load. - -```tsx -function UpdateAnimationOnly() { - const dataCount = 15; - const updateInterval = 2500; - - function generateNextValue(prev: number) { - const step = Math.random() * 30 - 15; - return Math.max(0, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } - - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +You can adjust `highlightScope` to support series and/or data index highlighting. By default only data index is enabled. - return ( - - - - ); - } - - return ; -} +```jsx + ``` -### Mixed Transitions Per Child - -Each child component can define its own transitions independently. Here, the `Line` uses a spring morph while the bars snap with no update animation. This lets you fine-tune each visual layer within a single chart. - -```tsx -function MixedTransitions() { - const theme = useTheme(); - const dataCount = 10; - const updateInterval = 2000; - - function generateNextValue(prev: number) { - const step = Math.random() * 20 - 10; - return Math.max(10, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } +### Allow Overflow Gestures - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +By default, highlighting gestures will not continue outside the chart bounds. Set `allowOverflowGestures` to `true` to allow the gesture to extend past the chart. - return ( - d * 0.3), - color: theme.color.accentBoldPurple, - yAxisId: 'bars', - }, - ]} - xAxis={{ scaleType: 'band' }} - yAxis={[ - { id: 'default' }, - { id: 'bars', range: ({ min, max }) => ({ min: max - 48, max }) }, - ]} - > - - - - ); - } - - return ; -} +```jsx + + ... + ``` -### No Animations - -You can disable all animations chart-wide by setting `animate` to `false` on CartesianChart. This is useful for static snapshots or when performance is a concern. Compare this to the animated examples above — data still updates, but changes snap instantly without any transition. - -```tsx -function DisableAnimations() { - const dataCount = 15; - const updateInterval = 2500; - - function generateNextValue(prev: number) { - const step = Math.random() * 30 - 15; - return Math.max(0, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } +## Animations - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +CartesianChart delegates transition control to its child components. +You can specify transitions on each child or disable all animations chart-wide by setting `animate={false}` on CartesianChart. - return ( - - - - ); - } - - return ; -} +```jsx + + ... + ``` -## Customization +## Composed Examples ### Price with Volume @@ -740,6 +703,10 @@ function PriceWithVolume() { [btcDates, btcPrices, formatDate, formatPrice], ); + const onHighlightChange = useCallback((items) => { + setScrubIndex(items[0]?.dataIndex ?? undefined); + }, []); + const ThinSolidLine = memo((props: SolidLineProps) => ); const headerId = useId(); @@ -764,8 +731,8 @@ function PriceWithVolume() { } /> Custom inset Default inset { - setScrubIndex(index); + const onHighlightChange = useCallback((items) => { + setScrubIndex(items[0]?.dataIndex ?? undefined); }, []); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {scrubIndex ?? 'none'} { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); - - return ( - - ); - } - - return ; -} -``` - -### Update Only - -Disable the enter reveal animation while keeping a slow update morph. The chart appears instantly but data changes animate smoothly. Useful when the chart is embedded in content that should not animate on load. - -```tsx live -function UpdateAnimationOnly() { - const dataCount = 15; - const updateInterval = 2500; - - function generateNextValue(prev: number) { - const step = Math.random() * 30 - 15; - return Math.max(0, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } - - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +function FiatAndStablecoinBalance() { + const [highlight, setHighlight] = useState([]); + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + + const usd = [ + 20, 20, 20, 20, 20, 40, 60, 60, 80, 120, 200, 240, 240, 240, 240, 240, 240, 240, 240, 60, 30, + 20, 25, 5, 0, 0, 0, 0, 80, 120, 150, + ]; + const usdc = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 60, 260, 260, 240, 220, 180, 160, 200, 240, 220, 0, 0, 0, 0, 0, 0, + 250, 250, 250, 250, 250, 250, + ]; + const brl = [ + 0, 0, 0, 0, 0, 0, 0, 20, 40, 100, 60, 60, 60, 0, 0, 0, 0, 0, 0, 160, 40, 80, 140, 180, 120, 0, + 0, 0, 30, 30, 40, + ]; + + const startBrl = 1200; + const startUsdc = 8500; + const startUsd = 2300; + + const toCumulativeWithStart = (values, start) => { + let sum = start; + return values.map((v) => { + sum += v; + return sum; + }); + }; + + const series = [ + { id: 'BRL', data: brl, color: 'var(--color-accentBoldGreen)' }, + { id: 'USDC', data: usdc, color: 'var(--color-accentBoldBlue)' }, + { id: 'USD', data: usd, color: 'var(--color-accentBoldIndigo, #5b6cff)' }, + ]; + + const cumulativeBrl = toCumulativeWithStart(brl, startBrl); + const cumulativeUsdc = toCumulativeWithStart(usdc, startUsdc); + const cumulativeUsd = toCumulativeWithStart(usd, startUsd); + + const cumulativeSeries = [ + { id: 'BRL', data: cumulativeBrl, color: 'var(--color-accentBoldGreen)' }, + { id: 'USDC', data: cumulativeUsdc, color: 'var(--color-accentBoldBlue)' }, + { id: 'USD', data: cumulativeUsd, color: 'var(--color-accentBoldIndigo, #5b6cff)' }, + ]; + + const formatValueAxis = (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); + + const formatCurrency = (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + + const lastIndex = categories.length - 1; + const rawIndex = highlight[0]?.dataIndex; + const hasSelectedDate = typeof rawIndex === 'number'; + const selectedIndex = hasSelectedDate ? Math.min(Math.max(0, rawIndex), lastIndex) : 0; + + const monthTotalDeposits = usd.reduce((sum, v, i) => sum + v + usdc[i] + brl[i], 0); + const endOfMonthTotalBalance = + cumulativeBrl[lastIndex] + cumulativeUsdc[lastIndex] + cumulativeUsd[lastIndex]; + + const dayDailyTotal = brl[selectedIndex] + usdc[selectedIndex] + usd[selectedIndex]; + const dayTotalBalance = + cumulativeBrl[selectedIndex] + cumulativeUsdc[selectedIndex] + cumulativeUsd[selectedIndex]; + + const depositsSummary = hasSelectedDate + ? `March ${selectedIndex + 1} - ${formatCurrency(dayDailyTotal)}` + : `March - ${formatCurrency(monthTotalDeposits)}`; + + const balanceSummary = formatCurrency(hasSelectedDate ? dayTotalBalance : endOfMonthTotalBalance); - return ( - - ); - } - - return ; + + + + + + ); } ``` -### Mixed Transitions Per Child - -Each child component can define its own transitions independently. Here, the `Line` uses a spring morph while the bars snap with no update animation. This lets you fine-tune each visual layer within a single chart. - -```tsx live -function MixedTransitions() { - const dataCount = 10; - const updateInterval = 2000; - - function generateNextValue(prev: number) { - const step = Math.random() * 20 - 10; - return Math.max(10, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } - - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +### Highlight Scope - return ( - d * 0.3), - color: 'var(--color-accentBoldPurple)', - yAxisId: 'bars', - }, - ]} - xAxis={{ scaleType: 'band' }} - yAxis={[ - { id: 'default' }, - { id: 'bars', range: ({ min, max }) => ({ min: max - 48, max }) }, - ]} - aria-hidden="true" - > - - - - ); - } +You can adjust `highlightScope` to support series and/or data index highlighting. By default only data index is enabled. - return ; -} +```jsx live + ``` -### No Animations - -You can disable all animations chart-wide by setting `animate` to `false` on CartesianChart. This is useful for static snapshots or when performance is a concern. Compare this to the animated examples above — data still updates, but changes snap instantly without any transition. +## Animations -```tsx live -function DisableAnimations() { - const dataCount = 15; - const updateInterval = 2500; - - function generateNextValue(prev: number) { - const step = Math.random() * 30 - 15; - return Math.max(0, Math.min(100, prev + step)); - } - - function generateInitialData() { - const data = [50]; - for (let i = 1; i < dataCount; i++) { - data.push(generateNextValue(data[i - 1])); - } - return data; - } - - function Chart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((current) => { - const last = current[current.length - 1]; - return [...current.slice(1), generateNextValue(last)]; - }); - }, updateInterval); - return () => clearInterval(intervalId); - }, []); +CartesianChart delegates transition control to its child components. +You can specify transitions on each child or disable all animations chart-wide by setting `animate={false}` on CartesianChart. - return ( - - ); - } - - return ; -} +```jsx + + ... + ``` -## Customization +## Composed Examples ### Price with Volume @@ -681,6 +638,10 @@ function PriceWithVolume() { [btcDates, btcPrices, formatDate, formatPrice], ); + const onHighlightChange = useCallback((items) => { + setScrubIndex(items[0]?.dataIndex ?? undefined); + }, []); + const ThinSolidLine = memo((props) => ); const headerId = useId(); @@ -705,8 +666,8 @@ function PriceWithVolume() { } /> { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + return ( } legendPosition="top" - onScrubberPositionChange={setScrubberPosition} + onHighlightChange={onHighlightChange} series={seriesConfig} width="100%" xAxis={{ diff --git a/apps/docs/docs/components/charts/Legend/_webExamples.mdx b/apps/docs/docs/components/charts/Legend/_webExamples.mdx index def80130fa..b28abecd63 100644 --- a/apps/docs/docs/components/charts/Legend/_webExamples.mdx +++ b/apps/docs/docs/components/charts/Legend/_webExamples.mdx @@ -29,7 +29,7 @@ function BasicLegend() { return ( { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + return ( {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + ? `Highlighted index: ${scrubberPosition}` + : 'Not interacting'} @@ -436,6 +440,8 @@ function Points() { Renders are done on JS thread, other code is in UI ```tsx +import type { HighlightedItem } from '@coinbase/cds-mobile-visualization/chart'; + function Performance() { const tabs = useMemo( () => [ @@ -466,13 +472,17 @@ function Performance() { [tabs], ); + const onHighlightChange = useCallback((items) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + return ( - + ); @@ -525,10 +535,10 @@ const PerformanceHeader = memo( const PerformanceChart = memo( ({ timePeriod, - onScrubberPositionChange, + onHighlightChange, }: { timePeriod: TabValue; - onScrubberPositionChange: (position: number | undefined) => void; + onHighlightChange: (items: HighlightedItem[]) => void; }) => { const theme = useTheme(); @@ -592,7 +602,7 @@ const PerformanceChart = memo( return ( `Point ${index + 1}`} @@ -681,26 +691,29 @@ Use `accessibilityLabel` on `LineChart` (or `CartesianChart`) to provide both: function BasicAccessible() { const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - // Chart-level accessibility label provides overview when focus lands on the chart - const chartAccessibilityLabel = useMemo( - () => - `Price chart showing trend over ${data.length} data points. Current value: ${data[data.length - 1]}. Swipe to navigate.`, - [data], - ); - - // Per-segment label announced when screen reader user taps or swipes to a segment - const getScrubberAccessibilityLabel = useCallback( - (index: number) => `Price at position ${index + 1} of ${data.length}: ${data[index]}`, + const accessibilityLabel = useCallback( + (items: HighlightedItem[]) => { + if (items.length === 0) { + return `Price chart, ${data.length} points. Swipe to navigate.`; + } + const parts = items + .map((item) => + typeof item.dataIndex === 'number' + ? `Price at position ${item.dataIndex + 1} of ${data.length}: ${data[item.dataIndex]}` + : null, + ) + .filter((line): line is string => line !== null); + return parts.join('; ') || `Price chart, ${data.length} points. Swipe to navigate.`; + }, [data], ); return ( - - + /> ); } ``` @@ -954,7 +965,7 @@ function GainLossChart() { return ( Bitcoin} /> { - return `${startYear + dataIndex}`; - }, - [startYear], +const MobileForecastAssetPriceHistoricalLine = memo((props: SolidLineProps) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); + + const historicalClipPath = useMemo(() => { + if (!xScale || !drawingArea) return null; + + const currentX = xScale(MOBILE_FORECAST_EXAMPLE_SPLIT_INDEX); + if (currentX === undefined) return null; + + const clip = Skia.Path.Make(); + clip.addRect({ + x: drawingArea.x - MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET, + y: drawingArea.y - MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET, + width: currentX + MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET - drawingArea.x, + height: drawingArea.height + MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET * 2, + }); + return clip; + }, [xScale, drawingArea]); + + if (!historicalClipPath) return null; + + return ( + + + ); +}); +MobileForecastAssetPriceHistoricalLine.displayName = 'MobileForecastAssetPriceHistoricalLine'; - const HistoricalLineComponent = memo((props: SolidLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); +const MobileForecastAssetPriceFutureLine = memo((props: DottedLineProps) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); - const historicalClipPath = useMemo(() => { - if (!xScale || !drawingArea) return null; + const forecastClipPath = useMemo(() => { + if (!xScale || !drawingArea) return null; - const currentX = xScale(currentIndex); - if (currentX === undefined) return null; + const currentX = xScale(MOBILE_FORECAST_EXAMPLE_SPLIT_INDEX); + if (currentX === undefined) return null; - // Create clip path for historical data (left side) - const clip = Skia.Path.Make(); - clip.addRect({ - x: drawingArea.x - clipOffset, - y: drawingArea.y - clipOffset, - width: currentX + clipOffset - drawingArea.x, - height: drawingArea.height + clipOffset * 2, - }); - return clip; - }, [xScale, drawingArea]); + const clip = Skia.Path.Make(); + clip.addRect({ + x: currentX, + y: drawingArea.y - MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET, + width: drawingArea.x + drawingArea.width - currentX + MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET * 2, + height: drawingArea.height + MOBILE_FORECAST_EXAMPLE_CLIP_OFFSET * 2, + }); + return clip; + }, [xScale, drawingArea]); - if (!historicalClipPath) return null; + if (!forecastClipPath) return null; - return ( - - - - ); - }); + return ( + + + + ); +}); +MobileForecastAssetPriceFutureLine.displayName = 'MobileForecastAssetPriceFutureLine'; - // Since the solid and dotted line have different curves, - // we need two separate line components. Otherwise we could - // have one line component with SolidLine and DottedLine inside - // of it and two clipPaths. - const ForecastLineComponent = memo((props: DottedLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - const forecastClipPath = useMemo(() => { - if (!xScale || !drawingArea) return null; - - const currentX = xScale(currentIndex); - if (currentX === undefined) return null; - - // Create clip path for forecast data (right side) - const clip = Skia.Path.Make(); - clip.addRect({ - x: currentX, - y: drawingArea.y - clipOffset, - width: drawingArea.x + drawingArea.width - currentX + clipOffset * 2, - height: drawingArea.height + clipOffset * 2, - }); - return clip; - }, [xScale, drawingArea]); +const MobileForecastScrubberWrap = memo(function MobileForecastScrubberWrap({ + hideLine, +}: { + hideLine: boolean; +}) { + const fadeInOpacity = useSharedValue(0); - if (!forecastClipPath) return null; + useEffect(() => { + fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); + }, [fadeInOpacity]); - return ( - - - - ); - }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); + return ( + + + + ); +}); - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], - ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); +function ForecastAssetPrice() { + const startYear = 2020; + const [userHighlight, setUserHighlight] = useState([]); - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); + const defaultHighlight = useMemo( + () => [{ dataIndex: MOBILE_FORECAST_EXAMPLE_SPLIT_INDEX } satisfies HighlightedItem], + [], + ); + const effectiveHighlight = useMemo( + () => (userHighlight.length > 0 ? userHighlight : defaultHighlight), + [userHighlight, defaultHighlight], + ); + const alwaysHideScrubberLine = false; + const isUserScrubbing = userHighlight.length > 0; + const hideScrubberLine = alwaysHideScrubberLine || !isUserScrubbing; - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); + const series = useMemo( + () => [{ id: 'price', data: MOBILE_FORECAST_EXAMPLE_CHART_DATA, color: assets.btc.color }], + [], + ); - return ( - - - - - - - - - ); - }); + const axisFormatter = useCallback( + (dataIndex: number) => { + return `${startYear + dataIndex}`; + }, + [startYear], + ); return ( - - + + - + ); } diff --git a/apps/docs/docs/components/charts/LineChart/_webExamples.mdx b/apps/docs/docs/components/charts/LineChart/_webExamples.mdx index 4dd1889daa..ee27c015f5 100644 --- a/apps/docs/docs/components/charts/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/charts/LineChart/_webExamples.mdx @@ -45,7 +45,7 @@ function MultipleLine() { return ( { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + return ( {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + ? `Highlighted index: ${scrubberPosition}` + : 'Not interacting'} Bitcoin} /> { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); - const axisFormatter = useCallback( - (dataIndex) => { - return startYear + dataIndex; - }, - [startYear], + if (!xScale || !drawingArea) return; + + const currentX = xScale(FORECAST_EXAMPLE_SPLIT_INDEX); + + if (currentX === undefined) return; + + return ( + <> + + + + + + + + + ); +}); +ForecastAssetPriceHistoricalLine.displayName = 'ForecastAssetPriceHistoricalLine'; - const HistoricalLineComponent = memo((props) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); +const ForecastAssetPriceFutureLine = memo((props) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); - if (!xScale || !drawingArea) return; + if (!xScale || !drawingArea) return; - const currentX = xScale(currentIndex); + const currentX = xScale(FORECAST_EXAMPLE_SPLIT_INDEX); - if (currentX === undefined) return; + if (currentX === undefined) return; - return ( - <> - - - - - - - - - - ); - }); + return ( + <> + + + + + + + + + + ); +}); +ForecastAssetPriceFutureLine.displayName = 'ForecastAssetPriceFutureLine'; - // Since the solid and dotted line have different curves, - // we need two separate line components. Otherwise we could - // have one line component with SolidLine and DottedLine inside - // of it and two clipPaths. - const ForecastLineComponent = memo((props) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); +const FORECAST_EXAMPLE_CHART_DATA = [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70]; +const FORECAST_EXAMPLE_CHART_HEIGHT = { base: 200, tablet: 225, desktop: 250 }; +const FORECAST_EXAMPLE_CHART_STYLE = { margin: '0 auto' }; +const FORECAST_EXAMPLE_MOTION = { duration: 0.15, delay: 0.35 }; - if (!xScale || !drawingArea) return; +const ForecastAssetPriceScrubber = memo(function ForecastAssetPriceScrubber({ hideLine }) { + return ( + + + + ); +}); - const currentX = xScale(currentIndex); +function ForecastAssetPrice() { + const startYear = 2020; + const [userHighlight, setUserHighlight] = useState([]); - if (currentX === undefined) return; + const defaultHighlight = useMemo(() => [{ dataIndex: FORECAST_EXAMPLE_SPLIT_INDEX }], []); + const effectiveHighlight = useMemo( + () => (userHighlight.length > 0 ? userHighlight : defaultHighlight), + [userHighlight, defaultHighlight], + ); + // `hideLine` also hides the line label. Set `alwaysHideScrubberLine` to always use beacons only. + const alwaysHideScrubberLine = false; + const isUserScrubbing = userHighlight.length > 0; + const hideScrubberLine = alwaysHideScrubberLine || !isUserScrubbing; - return ( - <> - - - - - - - - - - ); - }); + const series = useMemo( + () => [{ id: 'price', data: FORECAST_EXAMPLE_CHART_DATA, color: assets.btc.color }], + [], + ); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - // We need a fade in animation for the Scrubber - return ( - - - - - - - - - ); - }); + const axisFormatter = useCallback( + (dataIndex) => { + return startYear + dataIndex; + }, + [startYear], + ); return ( - - + + - + ); } diff --git a/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx b/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx index 3e0f403959..8f22c2c959 100644 --- a/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx +++ b/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx @@ -455,6 +455,10 @@ function Example() { [data, activeTab.id, getFormattingConfigForPeriod], ); + const onHighlightChange = useCallback((items) => { + setScrubIndex(items[0]?.dataIndex ?? undefined); + }, []); + const isMobile = breakpoints.isPhone || breakpoints.isTabletPortrait; return ( @@ -484,9 +488,9 @@ function Example() { } /> { const theme = useTheme(); - const { scrubberPosition } = useScrubberContext(); + const { highlight, enabled } = useHighlightContext(); + const scrubberPosition = useDerivedValue(() => { + if (!enabled) return undefined; + const idx = highlight.value[0]?.dataIndex; + return typeof idx === 'number' ? idx : undefined; + }, [highlight, enabled]); const { getXSerializableScale, drawingArea } = useCartesianChartContext(); const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); @@ -311,7 +316,7 @@ function StartPriceReferenceLine() { return ( { - const { scrubberPosition } = useScrubberContext(); + const { highlight } = useHighlightContext(); + const scrubberPosition = highlight[0]?.dataIndex; const { getXScale, drawingArea } = useCartesianChartContext(); const isScrubbing = scrubberPosition !== undefined; @@ -283,7 +284,7 @@ function StartPriceReferenceLine() { return (