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 (
-
+
+
+
+
+ Deposits
+
+ {depositsSummary}
+
+
+
+
+
+
+ Balance
+
+ {balanceSummary}
+
+
+
+
+
+
+
-
-
- );
- }
-
- 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 (
{
const { getSeriesData, dataLength } = 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 seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -313,7 +318,7 @@ function CustomBeaconLabel() {
return (
{
- 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 beaconOpacity = useDerivedValue(
() => (scrubberPosition.value !== undefined ? 1 : 0),
[scrubberPosition],
@@ -660,7 +670,12 @@ function HiddenScrubberWhenIdle() {
);
const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => {
- 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 labelOpacity = useDerivedValue(
() => (scrubberPosition.value !== undefined ? 1 : 0),
[scrubberPosition],
@@ -671,7 +686,7 @@ function HiddenScrubberWhenIdle() {
return (
@@ -714,7 +729,12 @@ function PercentageBeaconLabels() {
const PercentageScrubberBeaconLabel = memo(
({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, series, fontProvider } = 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 seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -801,7 +821,7 @@ function PercentageBeaconLabels() {
return (
{
const { getSeriesData, series, fontProvider } = 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 seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -942,7 +967,7 @@ function MatchupBeaconLabels() {
return (
{
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = highlight[0]?.dataIndex;
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -318,7 +319,7 @@ function CustomBeaconLabel() {
return (
{
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = highlight[0]?.dataIndex;
const isScrubbing = scrubberPosition !== undefined;
return ;
@@ -566,7 +568,8 @@ function HiddenScrubberWhenIdle() {
);
const MyScrubberBeaconLabel = memo((props) => {
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = highlight[0]?.dataIndex;
const isScrubbing = scrubberPosition !== undefined;
return ;
@@ -574,7 +577,7 @@ function HiddenScrubberWhenIdle() {
return (
{
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = highlight[0]?.dataIndex;
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -680,7 +684,7 @@ function PercentageBeaconLabels() {
return (
{
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = highlight[0]?.dataIndex;
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -859,7 +864,7 @@ function MatchupBeaconLabels() {
return (
{(classNames) => (
& {
+type ChartCanvasProps = Pick & {
+ accessibilityLabel?: string;
children: React.ReactNode;
style?: StyleProp;
};
@@ -68,8 +69,8 @@ const ChartCanvas = memo(
},
);
-export type CartesianChartBaseProps = Omit &
- Pick & {
+export type CartesianChartBaseProps = Omit &
+ Pick & {
/**
* Configuration objects that define how to visualize the data.
* Each series contains its own data array.
@@ -120,11 +121,44 @@ export type CartesianChartBaseProps = Omit &
* @default 'Legend'
*/
legendAccessibilityLabel?: string;
+ /**
+ * Accessibility label for the chart.
+ * - When a string: Used as a static label for the chart element
+ * - When a function: Called with the current highlighted items
+ */
+ accessibilityLabel?: string | ((items: HighlightedItem[]) => string);
+ /**
+ * The accessibility mode for the chart.
+ * - 'chunked': Divides chart into N accessible regions (default for line charts)
+ * - 'item': Each data point is an accessible region (default for bar charts)
+ * @default 'chunked'
+ */
+ accessibilityMode?: 'chunked' | 'item';
+ /**
+ * Number of accessible chunks when accessibilityMode is 'chunked'.
+ * @default 10
+ */
+ accessibilityChunkCount?: number;
+ /**
+ * Controls what aspects of the data can be highlighted.
+ * @default { dataIndex: true, series: false }
+ */
+ highlightScope?: HighlightScope;
+ /**
+ * @deprecated Use `enableHighlighting` instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v5
+ */
+ enableScrubbing?: boolean;
+ /**
+ * @deprecated Use `onHighlightChange` instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v5
+ */
+ onScrubberPositionChange?: (index: number | undefined) => void;
};
export type CartesianChartProps = CartesianChartBaseProps &
- Pick &
- Omit & {
+ Pick &
+ Omit & {
/**
* Default font families to use within ChartText.
* If not provided, will be the default for the system.
@@ -173,6 +207,8 @@ export const CartesianChart = memo(
children,
layout = 'vertical',
animate = true,
+ accessibilityMode,
+ accessibilityChunkCount,
enableScrubbing,
getScrubberAccessibilityLabel,
scrubberAccessibilityLabelStep,
@@ -180,6 +216,10 @@ export const CartesianChart = memo(
yAxis: yAxisConfigProp,
inset,
onScrubberPositionChange,
+ enableHighlighting = enableScrubbing,
+ highlightScope = defaultCartesianChartHighlightScope,
+ highlight,
+ onHighlightChange,
legend,
legendPosition = 'bottom',
legendAccessibilityLabel,
@@ -519,6 +559,7 @@ export const CartesianChart = memo(
const contextValue: CartesianChartContextValue = useMemo(
() => ({
+ type: 'cartesian',
layout,
series: series ?? [],
getSeries,
@@ -568,6 +609,19 @@ export const CartesianChart = memo(
return [style, styles?.root];
}, [style, styles?.root]);
+ // Wrap onHighlightChange to also call legacy onScrubberPositionChange.
+ const handleHighlightChange = useCallback(
+ (items: HighlightedItem[]) => {
+ onHighlightChange?.(items);
+
+ if (onScrubberPositionChange) {
+ const idx = items[0]?.dataIndex;
+ onScrubberPositionChange(typeof idx === 'number' ? idx : undefined);
+ }
+ },
+ [onHighlightChange, onScrubberPositionChange],
+ );
+
const legendElement = useMemo(() => {
if (!legend) return;
@@ -596,10 +650,15 @@ export const CartesianChart = memo(
return (
-
{legend ? (
)}
-
+
);
},
diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx
index 89db490bf8..c84d29e732 100644
--- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx
+++ b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx
@@ -8,7 +8,7 @@ import * as React from 'react';
import type ReactReconciler from 'react-reconciler';
import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider';
-import { ScrubberContext } from './utils/context';
+import { HighlightContext, ScrubberContext } from './utils/context';
import { CartesianChartContext } from './ChartProvider';
/**
@@ -20,6 +20,7 @@ const BRIDGED_CONTEXTS: React.Context[] = [
ThemeContext,
CartesianChartContext,
ScrubberContext,
+ HighlightContext,
];
/**
diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile-visualization/src/chart/ChartProvider.tsx
index 7491d0989a..b466dc3ce1 100644
--- a/packages/mobile-visualization/src/chart/ChartProvider.tsx
+++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx
@@ -1,11 +1,29 @@
import { createContext, useContext } from 'react';
-import type { CartesianChartContextValue } from './utils';
+import type { CartesianChartContextValue, ChartContextValue } from './utils';
export const CartesianChartContext = createContext(
undefined,
);
+/**
+ * Hook to access the generic chart context.
+ * Works with any chart type.
+ */
+export const useChartContext = (): ChartContextValue => {
+ const context = useContext(CartesianChartContext);
+ if (!context) {
+ throw new Error(
+ 'useChartContext must be used within a Chart component. See http://cds.coinbase.com/components/graphs/CartesianChart.',
+ );
+ }
+ return context;
+};
+
+/**
+ * Hook to access the cartesian chart context.
+ * Provides access to cartesian-specific features.
+ */
export const useCartesianChartContext = (): CartesianChartContextValue => {
const context = useContext(CartesianChartContext);
if (!context) {
diff --git a/packages/mobile-visualization/src/chart/HighlightProvider.tsx b/packages/mobile-visualization/src/chart/HighlightProvider.tsx
new file mode 100644
index 0000000000..1db8f727f7
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/HighlightProvider.tsx
@@ -0,0 +1,548 @@
+import React, { memo, useCallback, useMemo, useRef } from 'react';
+import { Platform, StyleSheet, View } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import {
+ runOnJS,
+ type SharedValue,
+ useAnimatedReaction,
+ useDerivedValue,
+ useSharedValue,
+} from 'react-native-reanimated';
+import { Haptics } from '@coinbase/cds-mobile/utils/haptics';
+
+import type { BarBounds, HighlightedItem, HighlightScope } from './utils/highlight';
+import { getPointOnSerializableScale } from './utils/point';
+import { useCartesianChartContext } from './ChartProvider';
+import {
+ HighlightContext,
+ type HighlightContextValue,
+ invertSerializableScale,
+ ScrubberContext,
+ type ScrubberContextValue,
+} from './utils';
+
+export type HighlightProviderProps = {
+ children: React.ReactNode;
+ /**
+ * Allows continuous gestures on the chart to continue outside the bounds of the chart element.
+ */
+ allowOverflowGestures?: boolean;
+ /**
+ * Whether highlighting is enabled.
+ */
+ enableHighlighting?: boolean;
+ /**
+ * Controls what aspects of the data can be highlighted.
+ */
+ highlightScope?: HighlightScope;
+ /**
+ * Pass a value to override the internal highlight state.
+ */
+ highlight?: HighlightedItem[];
+ /**
+ * Callback fired when highlighting changes during interaction.
+ */
+ onHighlightChange?: (items: HighlightedItem[]) => void;
+ /**
+ * Accessibility label for the chart.
+ * - When a string: Used as a static label for the chart element
+ * - When a function: Called with the highlighted item to generate dynamic labels during interaction
+ */
+ accessibilityLabel?: string | ((items: HighlightedItem[]) => string);
+ /**
+ * The accessibility mode for the chart.
+ * - 'chunked': Divides chart into N accessible regions (default for line charts)
+ * - 'item': Each data point is an accessible region (default for bar charts)
+ * @default 'chunked'
+ */
+ accessibilityMode?: 'chunked' | 'item';
+ /**
+ * Number of accessible chunks when accessibilityMode is 'chunked'.
+ * @default 10
+ */
+ accessibilityChunkCount?: number;
+};
+
+const DEFAULT_ITEM: HighlightedItem = {};
+
+/**
+ * Sentinel pointer ID used in onStart (before real touch IDs are available from onTouchesMove).
+ * Cleared once onTouchesMove fires with real IDs.
+ */
+const INITIAL_TOUCH_ID = -1;
+
+/**
+ * HighlightProvider manages chart highlighting state and gesture handling for mobile.
+ * Uses per-pointer state tracking for multi-touch support.
+ */
+export const HighlightProvider = memo(
+ ({
+ children,
+ allowOverflowGestures,
+ enableHighlighting = false,
+ highlightScope: scopeProp,
+ highlight: controlledHighlight,
+ onHighlightChange,
+ accessibilityLabel,
+ accessibilityMode = 'chunked',
+ accessibilityChunkCount = 10,
+ }: HighlightProviderProps) => {
+ const chartContext = useCartesianChartContext();
+
+ if (!chartContext) {
+ throw new Error('HighlightProvider must be used within a ChartContext');
+ }
+
+ const { layout, getXSerializableScale, getYSerializableScale, getXAxis, getYAxis, dataLength } =
+ chartContext;
+
+ const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]);
+ const categoryAxis = useMemo(
+ () => (categoryAxisIsX ? getXAxis() : getYAxis()),
+ [categoryAxisIsX, getXAxis, getYAxis],
+ );
+ const categoryScale = useMemo(
+ () => (categoryAxisIsX ? getXSerializableScale() : getYSerializableScale()),
+ [categoryAxisIsX, getXSerializableScale, getYSerializableScale],
+ );
+
+ const scope: HighlightScope = useMemo(
+ () => ({
+ dataIndex: scopeProp?.dataIndex ?? false,
+ series: scopeProp?.series ?? false,
+ }),
+ [scopeProp],
+ );
+
+ // Bar registry for hit testing
+ const barsRef = useRef([]);
+
+ const registerBar = useCallback((bounds: BarBounds) => {
+ barsRef.current.push(bounds);
+ }, []);
+
+ const unregisterBar = useCallback((seriesId: string, dataIndex: number) => {
+ barsRef.current = barsRef.current.filter(
+ (bar) => !(bar.seriesId === seriesId && bar.dataIndex === dataIndex),
+ );
+ }, []);
+
+ const findBarAtPoint = useCallback((touchX: number, touchY: number): BarBounds | null => {
+ const bars = barsRef.current;
+ for (let i = bars.length - 1; i >= 0; i--) {
+ const bar = bars[i];
+ if (
+ touchX >= bar.x &&
+ touchX <= bar.x + bar.width &&
+ touchY >= bar.y &&
+ touchY <= bar.y + bar.height
+ ) {
+ return bar;
+ }
+ }
+ return null;
+ }, []);
+
+ const isControlled = controlledHighlight !== undefined;
+
+ // Per-pointer state. Ref is used because updates come from gesture worklets via runOnJS.
+ // The derived SharedValue (internalHighlight) drives Skia rendering reactively.
+ const pointerMapRef = useRef>({});
+ const internalHighlight = useSharedValue([]);
+
+ const syncInternalHighlight = useCallback(() => {
+ internalHighlight.value = Object.values(pointerMapRef.current);
+ }, [internalHighlight]);
+
+ // The exposed highlight SharedValue
+ const highlight: SharedValue = useMemo(() => {
+ if (isControlled) {
+ return {
+ get value() {
+ return controlledHighlight ?? [];
+ },
+ set value(_newValue: HighlightedItem[]) {
+ // In controlled mode, don't update internal state
+ },
+ addListener: internalHighlight.addListener.bind(internalHighlight),
+ removeListener: internalHighlight.removeListener.bind(internalHighlight),
+ modify: internalHighlight.modify.bind(internalHighlight),
+ } as SharedValue;
+ }
+ return internalHighlight;
+ }, [isControlled, controlledHighlight, internalHighlight]);
+
+ const getDataIndexFromCategoryAxisPosition = useCallback(
+ (touchPosition: number): number => {
+ 'worklet';
+
+ if (!categoryScale || !categoryAxis) return 0;
+
+ if (categoryScale.type === 'band') {
+ const [domainMin, domainMax] = categoryScale.domain;
+ const categoryCount = domainMax - domainMin + 1;
+ let closestIndex = 0;
+ let closestDistance = Infinity;
+
+ for (let i = 0; i < categoryCount; i++) {
+ const categoryPos = getPointOnSerializableScale(i, categoryScale);
+ if (categoryPos !== undefined) {
+ const distance = Math.abs(touchPosition - categoryPos);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestIndex = i;
+ }
+ }
+ }
+ return closestIndex;
+ }
+
+ const axisData = categoryAxis.data;
+ if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') {
+ const numericData = axisData as number[];
+ let closestIndex = 0;
+ let closestDistance = Infinity;
+
+ for (let i = 0; i < numericData.length; i++) {
+ const dataValue = numericData[i];
+ const categoryPos = getPointOnSerializableScale(dataValue, categoryScale);
+ if (categoryPos !== undefined) {
+ const distance = Math.abs(touchPosition - categoryPos);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestIndex = i;
+ }
+ }
+ }
+ return closestIndex;
+ }
+
+ const dataValue = invertSerializableScale(touchPosition, categoryScale);
+ const dataIndex = Math.round(dataValue);
+ const domain = categoryAxis.domain;
+ return Math.max(domain.min ?? 0, Math.min(dataIndex, domain.max ?? 0));
+ },
+ [categoryAxis, categoryScale],
+ );
+
+ // Haptic feedback
+ const handleStartEndHaptics = useCallback(() => {
+ void Haptics.lightImpact();
+ }, []);
+
+ // Fire onHighlightChange when highlight SharedValue changes
+ const handleHighlightChangeJS = useCallback(
+ (items: HighlightedItem[]) => {
+ onHighlightChange?.(items);
+ },
+ [onHighlightChange],
+ );
+
+ useAnimatedReaction(
+ () => highlight.value,
+ (currentValue, previousValue) => {
+ if (currentValue !== previousValue) {
+ runOnJS(handleHighlightChangeJS)(currentValue);
+ }
+ },
+ [handleHighlightChangeJS],
+ );
+
+ // Full replacement of highlight state (keyboard, accessibility, external)
+ const setHighlight = useCallback(
+ (newItems: HighlightedItem[]) => {
+ const newMap: Record = {};
+ newItems.forEach((item, i) => {
+ newMap[i] = item;
+ });
+ pointerMapRef.current = newMap;
+ if (!isControlled) {
+ syncInternalHighlight();
+ }
+ onHighlightChange?.(newItems);
+ },
+ [isControlled, syncInternalHighlight, onHighlightChange],
+ );
+
+ // Partial merge into one pointer's entry
+ const updatePointerHighlight = useCallback(
+ (pointerId: number, partial: Partial) => {
+ const current = pointerMapRef.current[pointerId] ?? DEFAULT_ITEM;
+ const updated = { ...current, ...partial };
+ if (current.dataIndex === updated.dataIndex && current.seriesId === updated.seriesId)
+ return;
+ pointerMapRef.current[pointerId] = updated;
+ if (!isControlled) {
+ syncInternalHighlight();
+ }
+ },
+ [isControlled, syncInternalHighlight],
+ );
+
+ // Remove a pointer
+ const removePointer = useCallback(
+ (pointerId: number) => {
+ if (!(pointerId in pointerMapRef.current)) return;
+ delete pointerMapRef.current[pointerId];
+ if (!isControlled) {
+ syncInternalHighlight();
+ }
+ },
+ [isControlled, syncInternalHighlight],
+ );
+
+ // Per-touch highlight handler (called from gesture worklets via runOnJS)
+ const handleTouchHighlight = useCallback(
+ (touchId: number, x: number, y: number, dataIndex: number | null | undefined) => {
+ const seriesId = scope.series ? (findBarAtPoint(x, y)?.seriesId ?? null) : undefined;
+ updatePointerHighlight(touchId, { dataIndex, seriesId });
+ },
+ [scope.series, findBarAtPoint, updatePointerHighlight],
+ );
+
+ const handleTouchRemove = useCallback(
+ (touchId: number) => {
+ removePointer(touchId);
+ },
+ [removePointer],
+ );
+
+ const handleGestureEnd = useCallback(() => {
+ pointerMapRef.current = {};
+ if (!isControlled) {
+ internalHighlight.value = [];
+ }
+ onHighlightChange?.([]);
+ }, [internalHighlight, isControlled, onHighlightChange]);
+
+ const handleClearInitialTouch = useCallback(() => {
+ if (INITIAL_TOUCH_ID in pointerMapRef.current) {
+ removePointer(INITIAL_TOUCH_ID);
+ }
+ }, [removePointer]);
+
+ // Gesture: Pan with activateAfterLongPress for the activation gate,
+ // plus touch callbacks for per-pointer tracking.
+ const isGestureActive = useSharedValue(false);
+
+ const gesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .activateAfterLongPress(110)
+ .shouldCancelWhenOutside(!allowOverflowGestures)
+ .onStart(function onStart(event) {
+ isGestureActive.value = true;
+ runOnJS(handleStartEndHaptics)();
+
+ // Process initial position with sentinel ID.
+ // onTouchesDown already fired but was skipped (gesture wasn't active yet).
+ // This entry will be replaced once onTouchesMove fires with real IDs.
+ const pointerPosition = categoryAxisIsX ? event.x : event.y;
+ const dataIndex = scope.dataIndex
+ ? getDataIndexFromCategoryAxisPosition(pointerPosition)
+ : undefined;
+ runOnJS(handleTouchHighlight)(INITIAL_TOUCH_ID, event.x, event.y, dataIndex);
+ })
+ .onTouchesDown(function onTouchesDown(event) {
+ if (!isGestureActive.value) return;
+ for (let i = 0; i < event.changedTouches.length; i++) {
+ const touch = event.changedTouches[i];
+ const pointerPosition = categoryAxisIsX ? touch.x : touch.y;
+ const dataIndex = scope.dataIndex
+ ? getDataIndexFromCategoryAxisPosition(pointerPosition)
+ : undefined;
+ runOnJS(handleTouchHighlight)(touch.id, touch.x, touch.y, dataIndex);
+ }
+ })
+ .onTouchesMove(function onTouchesMove(event) {
+ if (!isGestureActive.value) return;
+ // Clear the sentinel entry from onStart on first move
+ runOnJS(handleClearInitialTouch)();
+ for (let i = 0; i < event.allTouches.length; i++) {
+ const touch = event.allTouches[i];
+ const pointerPosition = categoryAxisIsX ? touch.x : touch.y;
+ const dataIndex = scope.dataIndex
+ ? getDataIndexFromCategoryAxisPosition(pointerPosition)
+ : undefined;
+ runOnJS(handleTouchHighlight)(touch.id, touch.x, touch.y, dataIndex);
+ }
+ })
+ .onTouchesUp(function onTouchesUp(event) {
+ if (!isGestureActive.value) return;
+ for (let i = 0; i < event.changedTouches.length; i++) {
+ const touch = event.changedTouches[i];
+ runOnJS(handleTouchRemove)(touch.id);
+ }
+ })
+ .onEnd(function onEnd() {
+ isGestureActive.value = false;
+ runOnJS(handleStartEndHaptics)();
+ runOnJS(handleGestureEnd)();
+ })
+ .onTouchesCancelled(function onTouchesCancelled() {
+ isGestureActive.value = false;
+ runOnJS(handleGestureEnd)();
+ }),
+ [
+ allowOverflowGestures,
+ isGestureActive,
+ handleStartEndHaptics,
+ getDataIndexFromCategoryAxisPosition,
+ categoryAxisIsX,
+ scope.dataIndex,
+ handleTouchHighlight,
+ handleTouchRemove,
+ handleClearInitialTouch,
+ handleGestureEnd,
+ ],
+ );
+
+ const contextValue: HighlightContextValue = useMemo(
+ () => ({
+ enabled: enableHighlighting,
+ scope,
+ highlight,
+ setHighlight,
+ updatePointerHighlight,
+ removePointer,
+ registerBar,
+ unregisterBar,
+ }),
+ [
+ enableHighlighting,
+ scope,
+ highlight,
+ setHighlight,
+ updatePointerHighlight,
+ removePointer,
+ registerBar,
+ unregisterBar,
+ ],
+ );
+
+ // ScrubberContext bridge for backwards compatibility
+ const scrubberPosition = useDerivedValue(() => {
+ if (!enableHighlighting) return undefined;
+ const items = internalHighlight.value;
+ if (!items || items.length === 0) return undefined;
+ const idx = items[0]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [enableHighlighting, internalHighlight]);
+
+ const scrubberContextValue: ScrubberContextValue = useMemo(
+ () => ({
+ enableScrubbing: enableHighlighting,
+ scrubberPosition,
+ }),
+ [enableHighlighting, scrubberPosition],
+ );
+
+ // Accessibility
+ const getAccessibilityLabelForItems = useCallback(
+ (items: HighlightedItem[]): string => {
+ if (typeof accessibilityLabel === 'string') {
+ return accessibilityLabel;
+ }
+ if (typeof accessibilityLabel === 'function') {
+ return accessibilityLabel(items);
+ }
+ return '';
+ },
+ [accessibilityLabel],
+ );
+
+ const accessibilityRegions = useMemo(() => {
+ if (!enableHighlighting || !accessibilityLabel || typeof accessibilityLabel === 'string') {
+ return null;
+ }
+
+ const regions: Array<{
+ key: string;
+ flex: number;
+ label: string;
+ highlightedItem: HighlightedItem;
+ }> = [];
+
+ if (accessibilityMode === 'chunked') {
+ const chunkSize = Math.ceil(dataLength / accessibilityChunkCount);
+ for (let i = 0; i < accessibilityChunkCount && i * chunkSize < dataLength; i++) {
+ const startIndex = i * chunkSize;
+ const endIndex = Math.min((i + 1) * chunkSize, dataLength);
+ const chunkLength = endIndex - startIndex;
+ const item: HighlightedItem = { dataIndex: startIndex };
+
+ regions.push({
+ key: `chunk-${i}`,
+ flex: chunkLength,
+ label: getAccessibilityLabelForItems([item]),
+ highlightedItem: item,
+ });
+ }
+ } else if (accessibilityMode === 'item') {
+ for (let i = 0; i < dataLength; i++) {
+ const item: HighlightedItem = { dataIndex: i };
+ regions.push({
+ key: `item-${i}`,
+ flex: 1,
+ label: getAccessibilityLabelForItems([item]),
+ highlightedItem: item,
+ });
+ }
+ }
+
+ return regions;
+ }, [
+ enableHighlighting,
+ accessibilityLabel,
+ accessibilityMode,
+ accessibilityChunkCount,
+ dataLength,
+ getAccessibilityLabelForItems,
+ ]);
+
+ const content = (
+
+
+ {children}
+ {accessibilityRegions && (
+
+ {accessibilityRegions.map((region) => (
+ {
+ setHighlight([region.highlightedItem]);
+ setTimeout(() => {
+ setHighlight([]);
+ }, 100);
+ }}
+ style={{ flex: region.flex }}
+ />
+ ))}
+
+ )}
+
+
+ );
+
+ if (enableHighlighting) {
+ return {content};
+ }
+
+ return content;
+ },
+);
+
+const styles = StyleSheet.create({
+ accessibilityContainer: {
+ flexDirection: 'row',
+ flex: 1,
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile-visualization/src/chart/Path.tsx
index 37dacc4d13..ee8fbda10d 100644
--- a/packages/mobile-visualization/src/chart/Path.tsx
+++ b/packages/mobile-visualization/src/chart/Path.tsx
@@ -46,11 +46,11 @@ export type PathBaseProps = {
/**
* Opacity for the path fill.
*/
- fillOpacity?: number;
+ fillOpacity?: AnimatedProp;
/**
* Stroke color for the path.
- * When provided, will render a fill with the given color.
- * If not provided, will not render a fill.
+ * When provided, will render a stroke with the given color.
+ * If not provided, will not render a stroke.
*/
stroke?: string;
/**
diff --git a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx
index abad603bd2..4449e8bd6e 100644
--- a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx
+++ b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx
@@ -16,7 +16,7 @@ import { Line } from '../line/Line';
import { Point } from '../point/Point';
import { Scrubber } from '../scrubber/Scrubber';
import { ChartText } from '../text';
-import { type GradientDefinition, isCategoricalScale } from '../utils';
+import { type GradientDefinition, type HighlightedItem, isCategoricalScale } from '../utils';
import { CartesianChart, DottedArea, ReferenceLine, SolidLine, type SolidLineProps } from '../';
const defaultChartHeight = 250;
@@ -231,9 +231,9 @@ const currentDate = btcDates[displayIndex];
const PriceWithVolumeChart = memo(
({
- onScrubberPositionChange,
+ onHighlightChange,
}: {
- onScrubberPositionChange: (index: number | undefined) => void;
+ onHighlightChange: (items: HighlightedItem[]) => void;
}) => {
const theme = useTheme();
@@ -279,11 +279,11 @@ const PriceWithVolumeChart = memo(
return (
{
const [currentIndex, setCurrentIndex] = useState();
+ const onHighlightChange = useCallback((items: HighlightedItem[]) => {
+ setCurrentIndex(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
-
+
);
});
diff --git a/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx
index 09cc7603c1..e0b4935265 100644
--- a/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx
+++ b/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx
@@ -39,7 +39,7 @@ const BasicLineChart = memo(function BasicLineChart() {
return (
Bitcoin}
/>
{
return (
{
return (
{
return (
{
return (
{
{
{
{
{
{
return (
{
return (
{
return (
{
return (
(
minSize,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const theme = useTheme();
const { layout } = useCartesianChartContext();
@@ -147,6 +152,7 @@ export const Bar = memo(
d={barPath}
dataX={dataX}
dataY={dataY}
+ fadeOnHighlight={fadeOnHighlight}
fill={fill ?? theme.color.fgPrimary}
fillOpacity={fillOpacity}
height={height}
diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx
index c842564fe3..de553eac83 100644
--- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx
@@ -38,6 +38,7 @@ export type BarChartBaseProps = Omit<
| 'stackMinSize'
| 'transitions'
| 'transition'
+ | 'fadeOnHighlight'
> & {
/**
* Configuration objects that define how to visualize the data.
@@ -113,6 +114,7 @@ export const BarChart = memo(
stackMinSize,
transitions,
transition,
+ fadeOnHighlight,
...chartProps
},
ref,
@@ -217,6 +219,7 @@ export const BarChart = memo(
barMinSize={barMinSize}
barPadding={barPadding}
borderRadius={borderRadius}
+ fadeOnHighlight={fadeOnHighlight}
fillOpacity={fillOpacity}
roundBaseline={roundBaseline}
seriesIds={seriesIds}
diff --git a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx
index ae97522803..f5b244d68a 100644
--- a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx
@@ -24,6 +24,7 @@ export type BarPlotBaseProps = Pick<
| 'barMinSize'
| 'stackMinSize'
| 'BarStackComponent'
+ | 'fadeOnHighlight'
> & {
/**
* Array of series IDs to render.
@@ -63,6 +64,7 @@ export const BarPlot = memo(
stackMinSize,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const { animate, series: allSeries, drawingArea } = useCartesianChartContext();
@@ -128,6 +130,7 @@ export const BarPlot = memo(
barMinSize={barMinSize}
barPadding={barPadding}
borderRadius={defaultBorderRadius}
+ fadeOnHighlight={fadeOnHighlight}
fillOpacity={defaultFillOpacity}
roundBaseline={roundBaseline}
series={group.series}
diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx
index 890c114a50..ebe8e7acea 100644
--- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx
@@ -23,7 +23,7 @@ export type BarSeries = Series & {
export type BarStackBaseProps = Pick<
BarBaseProps,
- 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius'
+ 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight'
> & {
/**
* Array of series configurations that belong to this stack.
@@ -160,6 +160,7 @@ export const BarStack = memo(
roundBaseline,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const theme = useTheme();
const { layout, getSeriesData, getXAxis, getYAxis } = useCartesianChartContext();
@@ -299,6 +300,7 @@ export const BarStack = memo(
borderRadius={bar.borderRadius}
dataX={bar.dataX}
dataY={bar.dataY}
+ fadeOnHighlight={fadeOnHighlight}
fill={bar.fill}
fillOpacity={bar.fillOpacity}
height={bar.height}
diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx
index 5579c6e1fe..b3d0b39b05 100644
--- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx
@@ -19,6 +19,7 @@ export type BarStackGroupProps = Pick<
| 'BarStackComponent'
| 'transitions'
| 'transition'
+ | 'fadeOnHighlight'
> &
Pick & {
/**
diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx
index 420f9228e4..e12cc74357 100644
--- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx
+++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx
@@ -1,5 +1,7 @@
-import { memo, useMemo } from 'react';
+import { memo, useEffect, useMemo } from 'react';
+import { Easing, useDerivedValue } from 'react-native-reanimated';
import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme';
+import { Group } from '@shopify/react-native-skia';
import { useCartesianChartContext } from '../ChartProvider';
import { Path } from '../Path';
@@ -10,14 +12,27 @@ import {
withStaggerDelayTransition,
} from '../utils';
import { type BarTransition, getNormalizedStagger } from '../utils/bar';
-import { defaultTransition, getTransition } from '../utils/transition';
+import { useHighlightContext } from '../utils/context';
+import {
+ buildTransition,
+ defaultTransition,
+ getTransition,
+ type Transition,
+} from '../utils/transition';
import type { BarComponentProps } from './Bar';
export type DefaultBarProps = BarComponentProps;
+const fadeOpacity = 0.3;
+const fadeTransition: Transition = {
+ type: 'timing',
+ duration: 100,
+ easing: Easing.out(Easing.ease),
+};
+
/**
- * Default bar component that renders a solid bar with animation support.
+ * Default bar component that renders a solid bar with animation and highlighting support.
*/
export const DefaultBar = memo(
({
@@ -31,18 +46,92 @@ export const DefaultBar = memo(
d,
fill,
fillOpacity = 1,
+ dataX,
+ dataY,
+ seriesId,
stroke,
strokeWidth,
origin,
minSize = 1,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const { animate, drawingArea, layout } = useCartesianChartContext();
+ const highlightContext = useHighlightContext();
const theme = useTheme();
+ const { enabled: highlightEnabled, scope, registerBar, unregisterBar } = highlightContext;
+
+ const dataIndex = useMemo(() => {
+ if (typeof dataX === 'number') return dataX;
+ if (typeof dataY === 'number') return dataY;
+ return null;
+ }, [dataX, dataY]);
const defaultFill = fill || theme.color.fgPrimary;
+ // Register bar bounds for hit testing when series highlighting is enabled.
+ useEffect(() => {
+ if (!highlightEnabled || !scope.series || !seriesId) return;
+
+ const index = dataIndex ?? 0;
+
+ registerBar({
+ x,
+ y,
+ width,
+ height,
+ dataIndex: index,
+ seriesId,
+ });
+
+ return () => {
+ unregisterBar(seriesId, index);
+ };
+ }, [
+ highlightEnabled,
+ scope.series,
+ seriesId,
+ registerBar,
+ unregisterBar,
+ x,
+ y,
+ width,
+ height,
+ dataIndex,
+ ]);
+
+ const highlightByDataIndex = scope.dataIndex ?? false;
+ const highlightBySeries = scope.series ?? false;
+
+ const highlightOpacity = useDerivedValue(() => {
+ if (!fadeOnHighlight || !highlightEnabled) return 1;
+
+ const items = highlightContext.highlight.value;
+ let opacity = 1;
+
+ if (items.length > 0) {
+ const isHighlighted = items.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 : fadeOpacity;
+ }
+
+ return buildTransition(opacity, fadeTransition);
+ }, [
+ fadeOnHighlight,
+ highlightEnabled,
+ highlightByDataIndex,
+ highlightBySeries,
+ dataIndex,
+ seriesId,
+ ]);
+
const normalizedStagger = useMemo(
() => getNormalizedStagger(layout, x, y, drawingArea),
[layout, x, y, drawingArea],
@@ -129,7 +218,7 @@ export const DefaultBar = memo(
minSize,
]);
- return (
+ const path = (
(
}}
/>
);
+
+ if (fadeOnHighlight) return {path};
+
+ return path;
},
);
diff --git a/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx b/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx
index 3b1b8a8a0c..0023539964 100644
--- a/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx
+++ b/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx
@@ -26,6 +26,11 @@ export type PercentageBarChartBaseProps = Omit<
| 'inset'
| 'enableScrubbing'
| 'onScrubberPositionChange'
+ | 'enableHighlighting'
+ | 'onHighlightChange'
+ | 'highlight'
+ | 'highlightScope'
+ | 'fadeOnHighlight'
> & {
/**
* Configuration objects that define how to visualize the data.
@@ -75,6 +80,11 @@ export type PercentageBarChartProps = PercentageBarChartBaseProps &
| 'inset'
| 'enableScrubbing'
| 'onScrubberPositionChange'
+ | 'enableHighlighting'
+ | 'onHighlightChange'
+ | 'highlight'
+ | 'highlightScope'
+ | 'fadeOnHighlight'
>;
export const PercentageBarChart = memo(
diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
index c5f28a3186..e4d58eca13 100644
--- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
+++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
-import { useDerivedValue } from 'react-native-reanimated';
+import { Easing, useDerivedValue } from 'react-native-reanimated';
import { assets } from '@coinbase/cds-common/internal/data/assets';
import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles';
import { Button, IconButton } from '@coinbase/cds-mobile/buttons';
@@ -7,15 +7,16 @@ import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme';
import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout';
import { Text } from '@coinbase/cds-mobile/typography';
-import { Line as SkiaLine, Rect } from '@shopify/react-native-skia';
+import { Group, Line as SkiaLine, Rect } from '@shopify/react-native-skia';
import { XAxis, YAxis } from '../../axis';
import { CartesianChart, type CartesianChartProps } from '../../CartesianChart';
import { useCartesianChartContext } from '../../ChartProvider';
import { DefaultLegendEntry } from '../../legend';
-import { type LineComponentProps, ReferenceLine, SolidLine, type SolidLineProps } from '../../line';
-import { Scrubber } from '../../scrubber';
-import { getPointOnSerializableScale, unwrapAnimatedValue, useScrubberContext } from '../../utils';
+import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line';
+import type { HighlightedItem } from '../../utils';
+import { useHighlightContext } from '../../utils/context';
+import { buildTransition, type Transition } from '../../utils/transition';
import type { BarComponentProps } from '../Bar';
import { Bar } from '../Bar';
import { BarChart, type BarChartProps } from '../BarChart';
@@ -25,6 +26,13 @@ import { DefaultBarStack } from '../DefaultBarStack';
const ThinSolidLine = memo((props: SolidLineProps) => );
+const candlestickHighlightFadeOpacity = 0.3;
+const candlestickHighlightFadeTransition: Transition = {
+ type: 'timing',
+ duration: 100,
+ easing: Easing.out(Easing.ease),
+};
+
const defaultChartHeight = 250;
const baselineThresholdData = [40, 28, 21, 5, 48, 5, 28, 2, 29, 48, 18, 30, 29, 8].map(
(value) => value + 50,
@@ -668,12 +676,11 @@ const CandlesticksHeader = memo(({ currentIndex }: { currentIndex: number | unde
const CandlesticksChart = memo(
({
infoTextId,
- onScrubberPositionChange,
+ onHighlightChange,
}: {
infoTextId: string;
- onScrubberPositionChange: (index: number | undefined) => void;
+ onHighlightChange: (items: HighlightedItem[]) => void;
}) => {
- const theme = useTheme();
const min = useMemo(
() => Math.min(...candlestickStockData.map((data) => parseFloat(data.low))),
[],
@@ -683,41 +690,6 @@ const CandlesticksChart = memo(
));
- const BandwidthHighlight = memo(({ stroke }: LineComponentProps) => {
- const { getXSerializableScale, drawingArea } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
- const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]);
-
- const rectWidth = useMemo(() => {
- if (xScale !== undefined && xScale.type === 'band') {
- return xScale.bandwidth;
- }
- return 0;
- }, [xScale]);
-
- const xPos = useDerivedValue(() => {
- const position = unwrapAnimatedValue(scrubberPosition);
- const xPos =
- position !== undefined && xScale
- ? getPointOnSerializableScale(position, xScale)
- : undefined;
- return xPos !== undefined ? xPos - rectWidth / 2 : 0;
- }, [scrubberPosition, xScale]);
-
- const opacity = useDerivedValue(() => (xPos.value !== undefined ? 1 : 0), [xPos]);
-
- return (
-
- );
- });
-
const candlesData = useMemo(
() =>
candlestickStockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]) as [
@@ -728,9 +700,49 @@ const CandlesticksChart = memo(
);
const CandlestickBarComponent = memo(
- ({ x, y, width, height, dataX, ...props }) => {
+ ({ x, y, width, height, dataX, dataY, fadeOnHighlight, seriesId }) => {
const { getYScale } = useCartesianChartContext();
+ const highlightContext = useHighlightContext();
const yScale = getYScale();
+ const theme = useTheme();
+
+ const dataIndex = useMemo(() => {
+ if (typeof dataX === 'number') return dataX;
+ if (typeof dataY === 'number') return dataY;
+ return null;
+ }, [dataX, dataY]);
+
+ const { enabled: highlightEnabled, scope } = highlightContext;
+ const highlightByDataIndex = scope.dataIndex ?? false;
+ const highlightBySeries = scope.series ?? false;
+
+ const highlightOpacity = useDerivedValue(() => {
+ if (!fadeOnHighlight || !highlightEnabled) return 1;
+
+ const items = highlightContext.highlight.value;
+ let opacity = 1;
+
+ if (items.length > 0) {
+ const isHighlighted = items.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 : candlestickHighlightFadeOpacity;
+ }
+
+ return buildTransition(opacity, candlestickHighlightFadeTransition);
+ }, [
+ fadeOnHighlight,
+ highlightEnabled,
+ highlightByDataIndex,
+ highlightBySeries,
+ dataIndex,
+ seriesId,
+ ]);
const wickX = x + width / 2;
@@ -740,7 +752,6 @@ const CandlesticksChart = memo(
const close = parseFloat(timePeriodValue.close);
const bullish = open < close;
- const theme = useTheme();
const color = bullish ? theme.color.fgPositive : theme.color.fgNegative;
const openY = yScale?.(open) ?? 0;
const closeY = yScale?.(close) ?? 0;
@@ -748,7 +759,7 @@ const CandlesticksChart = memo(
const bodyHeight = Math.abs(openY - closeY);
const bodyY = openY < closeY ? openY : closeY;
- return (
+ const candlestick = (
<>
>
);
+
+ if (fadeOnHighlight) {
+ return {candlestick};
+ }
+
+ return candlestick;
},
);
@@ -782,25 +799,37 @@ const CandlesticksChart = memo(
});
}, []);
- const getScrubberAccessibilityLabel = useCallback(
- (index: number) => {
- const candle = candlestickStockData[index];
- return `${formatTime(index)}: O ${formatThousandsPriceNumber(parseFloat(candle.open))} H ${formatThousandsPriceNumber(parseFloat(candle.high))} L ${formatThousandsPriceNumber(parseFloat(candle.low))} C ${formatThousandsPriceNumber(parseFloat(candle.close))}`;
+ const accessibilityLabel = useCallback(
+ (items: HighlightedItem[]) => {
+ if (items.length === 0) {
+ return `Candlestick chart with ${candlesData.length} data points. Swipe to navigate.`;
+ }
+ const labels = items
+ .map((item) => {
+ const idx = item.dataIndex;
+ if (typeof idx !== 'number' || idx < 0 || idx >= candlestickStockData.length) return null;
+ const candle = candlestickStockData[idx];
+ return `${formatTime(idx)}: O ${formatThousandsPriceNumber(parseFloat(candle.open))} H ${formatThousandsPriceNumber(parseFloat(candle.high))} L ${formatThousandsPriceNumber(parseFloat(candle.low))} C ${formatThousandsPriceNumber(parseFloat(candle.close))}`;
+ })
+ .filter(Boolean);
+ return (
+ labels.join('; ') ||
+ `Candlestick chart with ${candlesData.length} data points. Swipe to navigate.`
+ );
},
- [formatTime, formatThousandsPriceNumber],
+ [candlesData.length, formatTime, formatThousandsPriceNumber],
);
return (
-
<>{children}>}
/>
@@ -840,10 +864,19 @@ const Candlesticks = () => {
const infoTextId = useId();
const [currentIndex, setCurrentIndex] = useState();
+ const handleHighlightChange = useCallback((items: HighlightedItem[]) => {
+ if (items.length === 0) {
+ setCurrentIndex(undefined);
+ return;
+ }
+ const idx = items[0]?.dataIndex;
+ setCurrentIndex(typeof idx === 'number' ? idx : undefined);
+ }, []);
+
return (
-
+
);
};
@@ -987,6 +1020,8 @@ const HorizontalBarChart = () => {
return (
{
return (
{
[seriesConfig, dataIndex],
);
+ const onHighlightChange = useCallback((items: HighlightedItem[]) => {
+ setScrubberPosition(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
}
legendPosition="top"
- onScrubberPositionChange={setScrubberPosition}
+ onHighlightChange={onHighlightChange}
series={seriesConfig}
width="100%"
xAxis={{
diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
index a2a66f4a0a..e4c28fe4d4 100644
--- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
+++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
@@ -52,10 +52,11 @@ import {
type AxisBounds,
buildTransition,
defaultTransition,
+ type HighlightedItem,
projectPointWithSerializableScale,
type Transition,
unwrapAnimatedValue,
- useScrubberContext,
+ useHighlightContext,
} from '../../utils';
import {
DottedLine,
@@ -92,7 +93,7 @@ function MultipleLine() {
return (
{
+ setScrubberPosition(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
@@ -340,12 +345,12 @@ function Interaction() {
: 'Not scrubbing'}
@@ -501,7 +506,7 @@ function Transitions() {
return (
Bitcoin}
/>
void;
+ onHighlightChange: (items: HighlightedItem[]) => void;
}) => {
const theme = useTheme();
@@ -1300,7 +1304,7 @@ const PerformanceChart = memo(
return (
{
+ setScrubberPosition(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
-
+
);
@@ -1557,7 +1565,7 @@ function MonotoneAssetPrice() {
return (
{
- 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 idleScrubberOpacity = useDerivedValue(
() => (scrubberPosition.value === undefined ? 1 : 0),
@@ -1799,7 +1812,7 @@ function ForecastAssetPrice() {
return (
`Point ${index + 1}`}
diff --git a/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx
index 94203d3197..11249f0777 100644
--- a/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx
+++ b/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx
@@ -7,7 +7,7 @@ import { VStack } from '@coinbase/cds-mobile/layout';
import { useCartesianChartContext } from '../../ChartProvider';
import { Scrubber } from '../../scrubber';
-import { getPointOnSerializableScale, useScrubberContext } from '../../utils';
+import { getPointOnSerializableScale, useHighlightContext } from '../../utils';
import {
DefaultReferenceLineLabel,
type DefaultReferenceLineLabelProps,
@@ -158,7 +158,12 @@ const FADE_ZONE = 128;
const StartPriceLabel = memo((props: DefaultReferenceLineLabelProps) => {
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]);
@@ -194,7 +199,7 @@ function StartPriceReferenceLine() {
return (
;
export type ScrubberLabelProps = ReferenceLineLabelComponentProps;
export type ScrubberLabelComponent = React.FC;
-export type ScrubberBaseProps = Pick &
+export type ScrubberBaseProps = Pick &
Pick &
Pick &
Pick & {
@@ -273,6 +273,7 @@ export const Scrubber = memo(
labelBoundsInset,
beaconLabelFont,
idlePulse,
+ highlightIndex = 0,
beaconTransitions,
transitions = beaconTransitions,
beaconStroke,
@@ -282,7 +283,13 @@ export const Scrubber = memo(
const theme = useTheme();
const beaconGroupRef = React.useRef(null);
- const { scrubberPosition } = useScrubberContext();
+ const { highlight, enabled } = useHighlightContext();
+ const scrubberPosition = useDerivedValue(() => {
+ if (!enabled) return undefined;
+ const items = highlight.value;
+ const idx = items[highlightIndex]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [highlight, enabled, highlightIndex]);
const {
layout,
getXSerializableScale,
@@ -461,6 +468,7 @@ export const Scrubber = memo(
(
(
- { seriesIds, idlePulse, transitions, BeaconComponent = DefaultScrubberBeacon, stroke },
+ {
+ seriesIds,
+ highlightIndex = 0,
+ idlePulse,
+ transitions,
+ BeaconComponent = DefaultScrubberBeacon,
+ stroke,
+ },
ref,
) => {
const ScrubberBeaconRefs = useRefMap();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight, enabled } = useHighlightContext();
+ const scrubberPosition = useDerivedValue(() => {
+ if (!enabled) return undefined;
+ const items = highlight.value;
+ const idx = items[highlightIndex]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [highlight, enabled, highlightIndex]);
const { layout, getXAxis, getYAxis, series, dataLength, animate } =
useCartesianChartContext();
diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
index 0e49c8be5e..0b543590c2 100644
--- a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
+++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
@@ -5,7 +5,7 @@ import type { AnimatedProp } from '@shopify/react-native-skia';
import { useCartesianChartContext } from '../ChartProvider';
import type { ChartTextChildren, ChartTextProps } from '../text';
-import { applySerializableScale, unwrapAnimatedValue, useScrubberContext } from '../utils';
+import { applySerializableScale, unwrapAnimatedValue, useHighlightContext } from '../utils';
import {
calculateLabelYPositions,
getLabelPosition,
@@ -26,6 +26,7 @@ import type {
ScrubberBeaconLabelProps,
ScrubberBeaconProps,
} from './Scrubber';
+import type { ScrubberBeaconGroupBaseProps } from './ScrubberBeaconGroup';
const PositionedLabel = memo<{
index: number;
@@ -111,7 +112,7 @@ const PositionedLabel = memo<{
},
);
-export type ScrubberBeaconLabelGroupBaseProps = {
+export type ScrubberBeaconLabelGroupBaseProps = Pick & {
/**
* Labels to be displayed.
*/
@@ -153,6 +154,7 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps &
export const ScrubberBeaconLabelGroup = memo(
({
labels,
+ highlightIndex = 0,
labelMinGap = 4,
labelHorizontalOffset = 16,
labelFont,
@@ -170,7 +172,13 @@ export const ScrubberBeaconLabelGroup = memo(
dataLength,
animate,
} = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight, enabled } = useHighlightContext();
+ const scrubberPosition = useDerivedValue(() => {
+ if (!enabled) return undefined;
+ const items = highlight.value;
+ const idx = items[highlightIndex]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [highlight, enabled, highlightIndex]);
const isIdle = useDerivedValue(() => {
return scrubberPosition.value === undefined;
diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx
index 0989ae598d..3e6cab9b00 100644
--- a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx
+++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx
@@ -8,6 +8,10 @@ import { useCartesianChartContext } from '../ChartProvider';
import { invertSerializableScale, ScrubberContext, type ScrubberContextValue } from '../utils';
import { getPointOnSerializableScale } from '../utils/point';
+/**
+ * @deprecated Use `HighlightProvider` props (`enableHighlighting`, `onHighlightChange`, `allowOverflowGestures`, etc.) instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export type ScrubberProviderProps = Partial> & {
children: React.ReactNode;
/**
@@ -24,6 +28,9 @@ export type ScrubberProviderProps = Partial = ({
children,
diff --git a/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx b/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
index 4f3a63f782..d871a3b4b5 100644
--- a/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
+++ b/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
@@ -14,7 +14,7 @@ import {
getLineData,
type ScrubberLabelPosition,
unwrapAnimatedValue,
- useScrubberContext,
+ useHighlightContext,
} from '../../utils';
import {
DefaultScrubberBeacon,
@@ -27,6 +27,16 @@ import {
type ScrubberRef,
} from '..';
+/** Data index for touch slot `highlightIndex`, mirroring scrubber / `ScrubberContext` bridge. */
+function useHighlightScrubberDataIndex(highlightIndex = 0) {
+ const { highlight, enabled } = useHighlightContext();
+ return useDerivedValue(() => {
+ if (!enabled) return undefined;
+ const idx = highlight.value[highlightIndex]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [highlight, enabled, highlightIndex]);
+}
+
const sampleData = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58];
const chartAccessibilityLabel = `Price chart with ${sampleData.length} data points. Swipe to navigate.`;
@@ -35,7 +45,7 @@ const getScrubberAccessibilityLabel = (index: number) => `Point ${index + 1}: ${
const BasicScrubber = () => {
return (
{
return (
{
const WithLabels = () => {
return (
{
return (
{
return (
{
return (
{
return (
{
const MyScrubberBeaconLabel = memo(
({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, series } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const scrubberPosition = useHighlightScrubberDataIndex();
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -300,7 +310,7 @@ const CustomBeaconLabel = () => {
return (
{
const PercentageScrubberBeaconLabel = memo(
({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, series, fontProvider } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const scrubberPosition = useHighlightScrubberDataIndex();
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -453,7 +463,7 @@ const PercentageBeaconLabels = () => {
{
{
return (
{
const LabelElevated = () => {
return (
{
return (
{
return (
{
return (
{
{
const CustomLine = () => {
return (
{
const HiddenScrubberWhenIdle = () => {
const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => {
- const { scrubberPosition } = useScrubberContext();
+ const scrubberPosition = useHighlightScrubberDataIndex();
const beaconOpacity = useDerivedValue(
() => (scrubberPosition.value !== undefined ? 1 : 0),
[scrubberPosition],
@@ -722,7 +732,7 @@ const HiddenScrubberWhenIdle = () => {
});
const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => {
- const { scrubberPosition } = useScrubberContext();
+ const scrubberPosition = useHighlightScrubberDataIndex();
const labelOpacity = useDerivedValue(
() => (scrubberPosition.value !== undefined ? 1 : 0),
[scrubberPosition],
@@ -733,7 +743,7 @@ const HiddenScrubberWhenIdle = () => {
return (
{
const HideOverlay = () => {
return (
{
const MatchupScrubberBeaconLabel = memo(
({ seriesId, color, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, series, fontProvider } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const scrubberPosition = useHighlightScrubberDataIndex();
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -877,7 +887,7 @@ const MatchupBeaconLabels = () => {
return (
{
});
});
-describe('defaultChartInset', () => {
+describe('defaultVerticalLayoutChartInset', () => {
it('should have correct default values', () => {
- expect(defaultChartInset).toEqual({
+ expect(defaultVerticalLayoutChartInset).toEqual({
top: 32,
left: 16,
bottom: 16,
@@ -573,6 +575,23 @@ describe('defaultChartInset', () => {
});
});
+describe('defaultHorizontalLayoutChartInset', () => {
+ it('should reserve additional right label room', () => {
+ expect(defaultHorizontalLayoutChartInset).toEqual({
+ top: 16,
+ left: 16,
+ bottom: 16,
+ right: 48,
+ });
+ });
+});
+
+describe('deprecated chart inset aliases', () => {
+ it('maps defaultChartInset to defaultVerticalLayoutChartInset', () => {
+ expect(defaultChartInset).toEqual(defaultVerticalLayoutChartInset);
+ });
+});
+
describe('getChartInset', () => {
describe('with numeric inset', () => {
it('should apply same value to all sides when given a number', () => {
diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts
index af03e28fe1..3343368ed9 100644
--- a/packages/mobile-visualization/src/chart/utils/chart.ts
+++ b/packages/mobile-visualization/src/chart/utils/chart.ts
@@ -56,8 +56,8 @@ export type Series = {
label?: string;
/**
* Color for the series.
- * If gradient is provided, that will be used for chart components
- * Color will still be used by scrubber beacon labels
+ * If gradient is provided, that will be used for chart components.
+ * Color will still be used by scrubber beacon labels.
*/
color?: string;
/**
@@ -397,18 +397,12 @@ export const defaultHorizontalLayoutChartInset: ChartInset = {
};
/**
- * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts or. This will be removed in a future major release.
+ * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts, or
+ * `defaultHorizontalLayoutChartInset` for horizontal layout charts. This will be removed in a future major release.
* @deprecationExpectedRemoval v4
- * `defaultHorizontalLayoutChartInset` for horizontal layout charts.
*/
export const defaultChartInset: ChartInset = defaultVerticalLayoutChartInset;
-/**
- * Normalize padding to include all sides with a value.
- * @param padding - The padding to get.
- * @param defaults - Optional complete default values to use instead of 0.
- * @returns The calculated padding.
- */
/**
* Normalize inset to include all sides with a value.
* @param inset - The inset to get.
diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts
index dbc1bdf92a..c8e24af2fb 100644
--- a/packages/mobile-visualization/src/chart/utils/context.ts
+++ b/packages/mobile-visualization/src/chart/utils/context.ts
@@ -5,6 +5,7 @@ import type { SkTypefaceFontProvider } from '@shopify/react-native-skia';
import type { CartesianAxisConfig } from './axis';
import type { Series } from './chart';
+import type { BarBounds, HighlightedItem, HighlightScope } from './highlight';
import type { ChartScaleFunction, SerializableScale } from './scale';
/**
@@ -15,20 +16,39 @@ import type { ChartScaleFunction, SerializableScale } from './scale';
*/
export type CartesianChartLayout = 'horizontal' | 'vertical';
-/**
- * Context value for Cartesian (X/Y) coordinate charts.
- * Contains axis-specific methods and properties for rectangular coordinate systems.
- */
-export type CartesianChartContextValue = {
+export type ChartType = 'cartesian';
+
+export type ChartContextValue = {
/**
- * Chart layout - describes the direction bars/areas grow.
- * @default 'vertical'
+ * Whether to animate the chart.
*/
- layout: CartesianChartLayout;
+ animate: boolean;
+ /**
+ * The chart type.
+ */
+ type: ChartType;
/**
* The series data for the chart.
*/
series: Series[];
+ /**
+ * Width of the chart SVG.
+ */
+ width: number;
+ /**
+ * Height of the chart SVG.
+ */
+ height: number;
+ /**
+ * Drawing area of the chart.
+ */
+ drawingArea: Rect;
+ /**
+ * Length of the data domain.
+ * This is equal to the length of xAxis.data or the longest series data length
+ * This equals the number of possible scrubber positions
+ */
+ dataLength: number;
/**
* Returns the series which matches the seriesId or undefined.
* @param seriesId - A series' id
@@ -40,18 +60,6 @@ export type CartesianChartContextValue = {
* @returns data for series, if series exists
*/
getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined;
- /**
- * Whether to animate the chart.
- */
- animate: boolean;
- /**
- * Width of the chart SVG.
- */
- width: number;
- /**
- * Height of the chart SVG.
- */
- height: number;
/**
* Default font families to use within ChartText.
* When not set, should use the default for the system.
@@ -61,6 +69,18 @@ export type CartesianChartContextValue = {
* Skia font provider.
*/
fontProvider: SkTypefaceFontProvider;
+};
+
+/**
+ * Context value for Cartesian (X/Y) coordinate charts.
+ * Contains axis-specific methods and properties for rectangular coordinate systems.
+ */
+export type CartesianChartContextValue = ChartContextValue & {
+ /**
+ * Chart layout - describes the direction bars/areas grow.
+ * @default 'vertical'
+ */
+ layout: CartesianChartLayout;
/**
* Get x-axis configuration by ID.
* @param id - The axis ID. Defaults to defaultAxisId.
@@ -91,16 +111,6 @@ export type CartesianChartContextValue = {
* @param id - The axis ID. Defaults to defaultAxisId.
*/
getYSerializableScale: (id?: string) => SerializableScale | undefined;
- /**
- * Drawing area of the chart.
- */
- drawingArea: Rect;
- /**
- * Length of the data domain.
- * This is equal to the length of xAxis.data or the longest series data length
- * This equals the number of possible scrubber positions
- */
- dataLength: number;
/**
* Registers an axis.
* Used by axis components to reserve space in the chart, preventing overlap with the drawing area.
@@ -120,6 +130,12 @@ export type CartesianChartContextValue = {
getAxisBounds: (id: string) => Rect | undefined;
};
+/**
+ * Context value for scrubber interaction state and position.
+ *
+ * @deprecated Use `useHighlightContext` and `HighlightContext`, and enable chart interaction with `enableHighlighting` instead of `enableScrubbing`. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export type ScrubberContextValue = {
/**
* Enables scrubbing interactions.
@@ -132,8 +148,16 @@ export type ScrubberContextValue = {
scrubberPosition: SharedValue;
};
+/**
+ * @deprecated Use `HighlightContext` instead. Prefer `useHighlightContext` and `enableHighlighting` over this context and `enableScrubbing`. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export const ScrubberContext = createContext(undefined);
+/**
+ * @deprecated Use `useHighlightContext` instead. Prefer `enableHighlighting` over `enableScrubbing` on charts. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export const useScrubberContext = (): ScrubberContextValue => {
const context = useContext(ScrubberContext);
if (!context) {
@@ -141,3 +165,55 @@ export const useScrubberContext = (): ScrubberContextValue => {
}
return context;
};
+
+/**
+ * Context value for chart highlighting state.
+ */
+export type HighlightContextValue = {
+ /**
+ * Whether highlighting is enabled.
+ */
+ enabled: boolean;
+ /**
+ * The highlight scope configuration.
+ */
+ scope: HighlightScope;
+ /**
+ * The current highlighted item(s) during interaction.
+ */
+ highlight: SharedValue;
+ /**
+ * Set the highlighted items.
+ */
+ setHighlight: (items: HighlightedItem[]) => void;
+ /**
+ * Update a highlighted item for a specific pointer.
+ */
+ updatePointerHighlight: (pointerId: number, item: HighlightedItem) => void;
+ /**
+ * Remove a specific pointer's entry from highlight state.
+ */
+ removePointer: (pointerId: number) => void;
+ /**
+ * Register a bar element for hit testing.
+ */
+ registerBar: (bounds: BarBounds) => void;
+ /**
+ * Unregister a bar element.
+ */
+ unregisterBar: (seriesId: string, dataIndex: number) => void;
+};
+
+export const HighlightContext = createContext(undefined);
+
+/**
+ * Hook to access the highlight context.
+ * @throws Error if used outside of a HighlightProvider
+ */
+export const useHighlightContext = (): HighlightContextValue => {
+ const context = useContext(HighlightContext);
+ if (!context) {
+ throw new Error('useHighlightContext must be used within a HighlightProvider');
+ }
+ return context;
+};
diff --git a/packages/mobile-visualization/src/chart/utils/highlight.ts b/packages/mobile-visualization/src/chart/utils/highlight.ts
new file mode 100644
index 0000000000..6a3317ef46
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/utils/highlight.ts
@@ -0,0 +1,55 @@
+/**
+ * Controls what aspects of the data can be highlighted.
+ */
+export type HighlightScope = {
+ /**
+ * Whether highlighting tracks data index.
+ * @default true
+ */
+ dataIndex?: boolean;
+ /**
+ * Whether highlighting tracks specific series.
+ */
+ series?: boolean;
+};
+
+/**
+ * Default highlight scope for cartesian charts.
+ * Highlights by data index, not by series.
+ */
+export const defaultCartesianChartHighlightScope: HighlightScope = {
+ dataIndex: true,
+ series: false,
+};
+
+/**
+ * Represents a single highlighted item during interaction.
+ * - `null` values mean the user is interacting but not over a specific item/series
+ */
+export type HighlightedItem = {
+ /**
+ * The data index being highlighted.
+ * `null` when interacting but not over a data point.
+ * `undefined` when data index is not enabled in scope.
+ */
+ dataIndex?: number | null;
+ /**
+ * The series ID being highlighted.
+ * `null` when series scope is disabled or not over a specific series.
+ * `undefined` when series is not enabled in scope.
+ */
+ seriesId?: string | null;
+};
+
+/**
+ * Bounds of a bar element for hit testing.
+ * @note Hit testing follows this rectangle, not the rounded bar silhouette (`borderRadius` is ignored).
+ */
+export type BarBounds = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ dataIndex: number;
+ seriesId: string;
+};
diff --git a/packages/mobile-visualization/src/chart/utils/index.ts b/packages/mobile-visualization/src/chart/utils/index.ts
index 0bf7ad9537..ff6f8088a5 100644
--- a/packages/mobile-visualization/src/chart/utils/index.ts
+++ b/packages/mobile-visualization/src/chart/utils/index.ts
@@ -4,6 +4,7 @@ export * from './bar';
export * from './chart';
export * from './context';
export * from './gradient';
+export * from './highlight';
export * from './path';
export * from './point';
export * from './scale';
diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx
index 6dd7c42cc7..203a4dd2a9 100644
--- a/packages/web-visualization/src/chart/CartesianChart.tsx
+++ b/packages/web-visualization/src/chart/CartesianChart.tsx
@@ -5,8 +5,8 @@ import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions';
import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout';
import { css } from '@linaria/core';
-import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider';
import { CartesianChartProvider } from './ChartProvider';
+import { HighlightProvider, type HighlightProviderProps } from './HighlightProvider';
import { Legend } from './legend';
import {
type CartesianAxisConfig,
@@ -16,6 +16,7 @@ import {
type ChartInset,
type ChartScaleFunction,
defaultAxisId,
+ defaultCartesianChartHighlightScope,
defaultHorizontalLayoutChartInset,
defaultVerticalLayoutChartInset,
getAxisConfig,
@@ -24,6 +25,8 @@ import {
getCartesianAxisScale,
getChartInset,
getStackedSeriesData as calculateStackedSeriesData,
+ type HighlightedItem,
+ type HighlightScope,
type LegendPosition,
type Series,
useTotalAxisPadding,
@@ -39,8 +42,8 @@ const focusStylesCss = css`
}
`;
-export type CartesianChartBaseProps = BoxBaseProps &
- Pick & {
+export type CartesianChartBaseProps = Omit &
+ Pick & {
/**
* Configuration objects that define how to visualize the data.
* Each series contains its own data array.
@@ -91,9 +94,30 @@ export type CartesianChartBaseProps = BoxBaseProps &
* @default 'Legend'
*/
legendAccessibilityLabel?: string;
+ /**
+ * Accessibility label for the chart.
+ * - When a string: Used as a static label for the chart element
+ * - When a function: Called with the current highlighted items
+ */
+ accessibilityLabel?: string | ((items: HighlightedItem[]) => string);
+ /**
+ * Controls what aspects of the data can be highlighted.
+ * @default { dataIndex: true, series: false }
+ */
+ highlightScope?: HighlightScope;
+ /**
+ * @deprecated Use `enableHighlighting={false}` instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v5
+ */
+ enableScrubbing?: boolean;
+ /**
+ * @deprecated Use `onHighlightChange` instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v5
+ */
+ onScrubberPositionChange?: (index: number | undefined) => void;
};
-export type CartesianChartProps = Omit, 'title'> &
+export type CartesianChartProps = Omit, 'title' | 'accessibilityLabel'> &
CartesianChartBaseProps & {
/**
* Custom class name for the root element.
@@ -144,6 +168,10 @@ export const CartesianChart = memo(
inset,
enableScrubbing,
onScrubberPositionChange,
+ enableHighlighting = enableScrubbing,
+ highlightScope = defaultCartesianChartHighlightScope,
+ highlight,
+ onHighlightChange,
legend,
legendPosition = 'bottom',
legendAccessibilityLabel,
@@ -435,6 +463,7 @@ export const CartesianChart = memo(
const contextValue: CartesianChartContextValue = useMemo(
() => ({
+ type: 'cartesian',
layout,
series: series ?? [],
getSeries,
@@ -478,6 +507,19 @@ export const CartesianChart = memo(
);
const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]);
+ // Wrap onHighlightChange to also call legacy onScrubberPositionChange.
+ const handleHighlightChange = useCallback(
+ (items: HighlightedItem[]) => {
+ onHighlightChange?.(items);
+
+ if (onScrubberPositionChange) {
+ const idx = items[0]?.dataIndex;
+ onScrubberPositionChange(typeof idx === 'number' ? idx : undefined);
+ }
+ },
+ [onHighlightChange, onScrubberPositionChange],
+ );
+
const legendElement = useMemo(() => {
if (!legend) return;
@@ -526,13 +568,15 @@ export const CartesianChart = memo(
}
}
}}
- accessibilityLabel={accessibilityLabel}
+ accessibilityLabel={
+ typeof accessibilityLabel === 'string' ? accessibilityLabel : undefined
+ }
aria-live="polite"
as="svg"
- className={cx(enableScrubbing && focusStylesCss, classNames?.chart)}
+ className={cx(enableHighlighting && focusStylesCss, classNames?.chart)}
height="100%"
style={styles?.chart}
- tabIndex={enableScrubbing ? 0 : undefined}
+ tabIndex={enableHighlighting ? 0 : undefined}
width="100%"
>
{children}
@@ -542,9 +586,12 @@ export const CartesianChart = memo(
return (
-
{legend ? (
@@ -561,7 +608,7 @@ export const CartesianChart = memo(
) : (
{chartContent}
)}
-
+
);
},
diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx
index 192421a5d0..14c7d2a532 100644
--- a/packages/web-visualization/src/chart/ChartProvider.tsx
+++ b/packages/web-visualization/src/chart/ChartProvider.tsx
@@ -1,11 +1,29 @@
import { createContext, useContext } from 'react';
-import type { CartesianChartContextValue } from './utils/context';
+import type { CartesianChartContextValue, ChartContextValue } from './utils/context';
export const CartesianChartContext = createContext(
undefined,
);
+/**
+ * Hook to access the generic chart context.
+ * Works with any chart type (cartesian, polar, etc.).
+ */
+export const useChartContext = (): ChartContextValue => {
+ const context = useContext(CartesianChartContext);
+ if (!context) {
+ throw new Error(
+ 'useChartContext must be used within a Chart component. See http://cds.coinbase.com/components/graphs/CartesianChart.',
+ );
+ }
+ return context;
+};
+
+/**
+ * Hook to access the cartesian chart context.
+ * Provides access to cartesian-specific features.
+ */
export const useCartesianChartContext = (): CartesianChartContextValue => {
const context = useContext(CartesianChartContext);
if (!context) {
diff --git a/packages/web-visualization/src/chart/HighlightProvider.tsx b/packages/web-visualization/src/chart/HighlightProvider.tsx
new file mode 100644
index 0000000000..a9a7b0623a
--- /dev/null
+++ b/packages/web-visualization/src/chart/HighlightProvider.tsx
@@ -0,0 +1,449 @@
+import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import type { HighlightedItem, HighlightScope } from './utils/highlight';
+import { useCartesianChartContext } from './ChartProvider';
+import {
+ type ChartScaleFunction,
+ HighlightContext,
+ type HighlightContextValue,
+ isCategoricalScale,
+ ScrubberContext,
+ type ScrubberContextValue,
+} from './utils';
+
+export type HighlightProviderProps = {
+ children: React.ReactNode;
+ /**
+ * A reference to the root SVG element, where interaction event handlers will be attached.
+ */
+ svgRef: React.RefObject | null;
+ /**
+ * Whether highlighting is enabled.
+ */
+ enableHighlighting?: boolean;
+ /**
+ * Controls what aspects of the data can be highlighted.
+ */
+ highlightScope?: HighlightScope;
+ /**
+ * Pass a value to override the internal highlight state.
+ */
+ highlight?: HighlightedItem[];
+ /**
+ * Callback fired when the highlight changes during interaction.
+ */
+ onHighlightChange?: (items: HighlightedItem[]) => void;
+ /**
+ * Accessibility label for the chart.
+ * - When a string: Used as a static label for the chart element
+ * - When a function: Called with the highlighted item to generate dynamic labels during interaction
+ */
+ accessibilityLabel?: string | ((items: HighlightedItem[]) => string);
+};
+
+/**
+ * HighlightProvider manages chart highlight state and input handling.
+ */
+export const HighlightProvider = memo(
+ ({
+ children,
+ svgRef,
+ enableHighlighting: enableHighlightingProp,
+ highlightScope: scopeProp,
+ highlight: controlledHighlight,
+ onHighlightChange,
+ accessibilityLabel,
+ }: HighlightProviderProps) => {
+ const chartContext = useCartesianChartContext();
+
+ if (!chartContext) {
+ throw new Error('HighlightProvider must be used within a ChartContext');
+ }
+
+ const { layout, getXScale, getYScale, getXAxis, getYAxis, series } = chartContext;
+
+ const enabled = enableHighlightingProp ?? false;
+
+ const scope: HighlightScope = useMemo(
+ () => ({
+ dataIndex: scopeProp?.dataIndex ?? false,
+ series: scopeProp?.series ?? false,
+ }),
+ [scopeProp],
+ );
+
+ const isControlled = controlledHighlight !== undefined;
+
+ // Per-pointer state keyed by pointerId.
+ // Each pointer event (mouse or touch) independently tracks its own HighlightedItem entry.
+ // The functional updater bails out (returns prev) when nothing changed, so React
+ // skips re-renders for redundant pointermove events within the same data index.
+ const [pointerMap, setPointerMap] = useState>({});
+
+ // Derived array from per-pointer map
+ const internalHighlight = useMemo(() => Object.values(pointerMap), [pointerMap]);
+
+ /**
+ * Highlights for context, SVG a11y, and keyboard scrubbing: empty while interaction is off so
+ * the chart behaves as if nothing is selected (internal pointer map is unchanged).
+ */
+ const highlight = useMemo(() => {
+ const items: HighlightedItem[] = isControlled
+ ? (controlledHighlight ?? [])
+ : internalHighlight;
+ return enabled ? items : [];
+ }, [enabled, isControlled, controlledHighlight, internalHighlight]);
+
+ // Fire onHighlightChange when internal highlight state changes.
+ // Uses ref comparison to skip the initial render and avoid firing when
+ // onHighlightChange itself changes.
+ const prevInternalHighlightRef = useRef(internalHighlight);
+ useEffect(() => {
+ if (prevInternalHighlightRef.current === internalHighlight) return;
+ prevInternalHighlightRef.current = internalHighlight;
+ onHighlightChange?.(internalHighlight);
+ }, [internalHighlight, onHighlightChange]);
+
+ // Full replacement of highlight state.
+ // Used by keyboard navigation, ScrubberContext bridge, and external consumers.
+ const setHighlight = useCallback((items: HighlightedItem[]) => {
+ const newMap: Record = {};
+ items.forEach((item, i) => {
+ newMap[i] = item;
+ });
+ setPointerMap(newMap);
+ }, []);
+
+ // Partial merge into a specific pointer's entry.
+ // Only re-renders when the values actually change for that pointer.
+ const updatePointerHighlight = useCallback(
+ (pointerId: number, partial: Partial) => {
+ setPointerMap((prev) => {
+ const current = prev[pointerId];
+ const updated: HighlightedItem = { ...current, ...partial };
+ if (current?.dataIndex === updated.dataIndex && current?.seriesId === updated.seriesId) {
+ return prev;
+ }
+ return { ...prev, [pointerId]: updated };
+ });
+ },
+ [],
+ );
+
+ // Remove a pointer entirely from highlight state.
+ const removePointer = useCallback((pointerId: number) => {
+ setPointerMap((prev) => {
+ if (!(pointerId in prev)) return prev;
+ const { [pointerId]: _, ...rest } = prev;
+ return rest;
+ });
+ }, []);
+
+ const getDataIndexFromCategoryAxisPosition = useCallback(
+ (mousePosition: number): number => {
+ const categoryAxisIsX = layout !== 'horizontal';
+ const categoryScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction;
+ const categoryAxis = categoryAxisIsX ? getXAxis() : getYAxis();
+
+ if (!categoryScale || !categoryAxis) return 0;
+
+ if (isCategoricalScale(categoryScale)) {
+ const categories = categoryScale.domain?.() ?? categoryAxis.data ?? [];
+ const bandwidth = categoryScale.bandwidth?.() ?? 0;
+ let closestIndex = 0;
+ let closestDistance = Infinity;
+ for (let i = 0; i < categories.length; i++) {
+ const pos = categoryScale(i);
+ if (pos !== undefined) {
+ const distance = Math.abs(mousePosition - (pos + bandwidth / 2));
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestIndex = i;
+ }
+ }
+ }
+ return closestIndex;
+ }
+
+ const axisData = categoryAxis.data;
+ if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') {
+ const numericData = axisData as number[];
+ let closestIndex = 0;
+ let closestDistance = Infinity;
+
+ for (let i = 0; i < numericData.length; i++) {
+ const dataValue = numericData[i];
+ const pos = categoryScale(dataValue);
+ if (pos !== undefined) {
+ const distance = Math.abs(mousePosition - pos);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestIndex = i;
+ }
+ }
+ }
+ return closestIndex;
+ }
+
+ const dataValue = categoryScale.invert(mousePosition);
+ const dataIndexVal = Math.round(dataValue);
+ const domain = categoryAxis.domain;
+ return Math.max(domain.min ?? 0, Math.min(dataIndexVal, domain.max ?? 0));
+ },
+ [layout, getXScale, getYScale, getXAxis, getYAxis],
+ );
+
+ // --- Pointer Event handlers ---
+
+ const handlePointerDown = useCallback(
+ (event: PointerEvent) => {
+ if (!enabled) return;
+ // Release pointer capture so pointerenter/pointerleave fire on bar elements
+ // as the touch drags across them (same technique used by MUI X Charts).
+ if (event.target instanceof Element) {
+ try {
+ event.target.releasePointerCapture(event.pointerId);
+ } catch {
+ // releasePointerCapture throws if the element doesn't have capture — safe to ignore
+ }
+ }
+ },
+ [enabled],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: PointerEvent) => {
+ if (!enabled || !series || series.length === 0) return;
+ const svg = event.currentTarget as SVGSVGElement;
+ const rect = svg.getBoundingClientRect();
+ const position =
+ layout === 'horizontal' ? event.clientY - rect.top : event.clientX - rect.left;
+ const dataIndex = scope.dataIndex
+ ? getDataIndexFromCategoryAxisPosition(position)
+ : undefined;
+ updatePointerHighlight(event.pointerId, { dataIndex });
+ },
+ [
+ enabled,
+ series,
+ layout,
+ scope.dataIndex,
+ getDataIndexFromCategoryAxisPosition,
+ updatePointerHighlight,
+ ],
+ );
+
+ const handlePointerUp = useCallback(
+ (event: PointerEvent) => {
+ if (!enabled) return;
+ removePointer(event.pointerId);
+ },
+ [enabled, removePointer],
+ );
+
+ const handlePointerLeave = useCallback(
+ (event: PointerEvent) => {
+ if (!enabled) return;
+ removePointer(event.pointerId);
+ },
+ [enabled, removePointer],
+ );
+
+ // --- Keyboard navigation ---
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!enabled) return;
+ if (!scope.dataIndex) return;
+
+ const categoryAxisIsX = layout !== 'horizontal';
+ const categoryScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction;
+ const categoryAxis = categoryAxisIsX ? getXAxis() : getYAxis();
+
+ if (!categoryScale || !categoryAxis) return;
+
+ const isBand = isCategoricalScale(categoryScale);
+
+ let minIndex: number;
+ let maxIndex: number;
+
+ if (isBand) {
+ const categories = categoryScale.domain?.() ?? categoryAxis.data ?? [];
+ minIndex = 0;
+ maxIndex = Math.max(0, categories.length - 1);
+ } else {
+ const axisData = categoryAxis.data;
+ if (axisData && Array.isArray(axisData)) {
+ minIndex = 0;
+ maxIndex = Math.max(0, axisData.length - 1);
+ } else {
+ const domain = categoryAxis.domain;
+ minIndex = domain.min ?? 0;
+ maxIndex = domain.max ?? 0;
+ }
+ }
+
+ const currentItem = highlight[0];
+ const currentDataIndex = currentItem?.dataIndex;
+ const currentIndex = typeof currentDataIndex === 'number' ? currentDataIndex : minIndex;
+ const dataRange = maxIndex - minIndex;
+
+ const multiSkip = event.shiftKey;
+ const stepSize = multiSkip ? Math.min(10, Math.max(1, Math.floor(dataRange * 0.1))) : 1;
+
+ let newIndex: number | undefined;
+
+ switch (event.key) {
+ case categoryAxisIsX ? 'ArrowLeft' : 'ArrowUp':
+ event.preventDefault();
+ newIndex = Math.max(minIndex, currentIndex - stepSize);
+ break;
+ case categoryAxisIsX ? 'ArrowRight' : 'ArrowDown':
+ event.preventDefault();
+ newIndex = Math.min(maxIndex, currentIndex + stepSize);
+ break;
+ case 'Home':
+ event.preventDefault();
+ newIndex = minIndex;
+ break;
+ case 'End':
+ event.preventDefault();
+ newIndex = maxIndex;
+ break;
+ case 'Escape':
+ event.preventDefault();
+ setHighlight([]);
+ return;
+ default:
+ return;
+ }
+
+ if (newIndex !== currentItem?.dataIndex) {
+ const newItem: HighlightedItem = {
+ dataIndex: newIndex,
+ seriesId: currentItem?.seriesId,
+ };
+ setHighlight([newItem]);
+ }
+ },
+ [
+ enabled,
+ scope.dataIndex,
+ layout,
+ getXScale,
+ getYScale,
+ getXAxis,
+ getYAxis,
+ highlight,
+ setHighlight,
+ ],
+ );
+
+ const handleBlur = useCallback(() => {
+ if (highlight.length === 0) return;
+ setHighlight([]);
+ }, [highlight, setHighlight]);
+
+ // --- Attach event listeners ---
+
+ useEffect(() => {
+ if (!svgRef?.current || !enabled) return;
+
+ const svg = svgRef.current;
+
+ svg.addEventListener('pointerdown', handlePointerDown);
+ svg.addEventListener('pointermove', handlePointerMove);
+ svg.addEventListener('pointerup', handlePointerUp);
+ svg.addEventListener('pointercancel', handlePointerUp);
+ svg.addEventListener('pointerleave', handlePointerLeave);
+ svg.addEventListener('keydown', handleKeyDown);
+ svg.addEventListener('blur', handleBlur);
+
+ return () => {
+ svg.removeEventListener('pointerdown', handlePointerDown);
+ svg.removeEventListener('pointermove', handlePointerMove);
+ svg.removeEventListener('pointerup', handlePointerUp);
+ svg.removeEventListener('pointercancel', handlePointerUp);
+ svg.removeEventListener('pointerleave', handlePointerLeave);
+ svg.removeEventListener('keydown', handleKeyDown);
+ svg.removeEventListener('blur', handleBlur);
+ };
+ }, [
+ svgRef,
+ enabled,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ handlePointerLeave,
+ handleKeyDown,
+ handleBlur,
+ ]);
+
+ // --- Accessibility ---
+
+ useEffect(() => {
+ if (!svgRef?.current || !accessibilityLabel) return;
+
+ const svg = svgRef.current;
+
+ if (typeof accessibilityLabel === 'string') {
+ svg.setAttribute('aria-label', accessibilityLabel);
+ return;
+ }
+
+ if (!enabled) {
+ svg.removeAttribute('aria-label');
+ return;
+ }
+
+ const label = accessibilityLabel(highlight);
+ if (label) {
+ svg.setAttribute('aria-label', label);
+ } else {
+ svg.removeAttribute('aria-label');
+ }
+ }, [svgRef, enabled, highlight, accessibilityLabel]);
+
+ // --- Context values ---
+
+ const contextValue: HighlightContextValue = useMemo(
+ () => ({
+ enabled,
+ scope,
+ highlight,
+ setHighlight,
+ updatePointerHighlight,
+ removePointer,
+ }),
+ [enabled, scope, highlight, setHighlight, updatePointerHighlight, removePointer],
+ );
+
+ // ScrubberContext bridge for backwards compatibility
+ const scrubberPosition = useMemo(() => {
+ const idx = highlight[0]?.dataIndex;
+ return typeof idx === 'number' ? idx : undefined;
+ }, [highlight]);
+
+ const scrubberContextValue: ScrubberContextValue = useMemo(
+ () => ({
+ enableScrubbing: enabled,
+ scrubberPosition,
+ onScrubberPositionChange: (index: number | undefined) => {
+ if (!enabled) return;
+ if (index === undefined) {
+ setHighlight([]);
+ } else {
+ setHighlight([{ dataIndex: index }]);
+ }
+ },
+ }),
+ [enabled, scrubberPosition, setHighlight],
+ );
+
+ return (
+
+ {children}
+
+ );
+ },
+);
diff --git a/packages/web-visualization/src/chart/Path.tsx b/packages/web-visualization/src/chart/Path.tsx
index 0aebd8c546..69553f7974 100644
--- a/packages/web-visualization/src/chart/Path.tsx
+++ b/packages/web-visualization/src/chart/Path.tsx
@@ -27,17 +27,31 @@ export type PathBaseProps = SharedProps & {
initialPath?: string;
/**
* Fill color for the path.
+ * When omitted, standard SVG fill defaults apply.
*/
fill?: string;
/**
* Opacity for the path fill.
*/
fillOpacity?: number;
+ /**
+ * Stroke color for the path.
+ * When omitted, standard SVG stroke defaults apply.
+ */
+ stroke?: string;
+ /**
+ * Opacity for the path stroke.
+ */
+ strokeOpacity?: number;
};
export type PathProps = PathBaseProps &
Omit<
SVGProps,
+ | 'fill'
+ | 'fillOpacity'
+ | 'stroke'
+ | 'strokeOpacity'
| 'onAnimationStart'
| 'onAnimationEnd'
| 'onAnimationIteration'
diff --git a/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx
index 35819a4e93..fb53e76aef 100644
--- a/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx
+++ b/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx
@@ -15,7 +15,7 @@ import { useCartesianChartContext } from '../ChartProvider';
import { ReferenceLine, SolidLine, type SolidLineProps } from '../line';
import { Line } from '../line/Line';
import { LineChart } from '../line/LineChart';
-import { isCategoricalScale } from '../utils';
+import { type HighlightedItem,isCategoricalScale } from '../utils';
import { BarPlot, CartesianChart, type ChartTextChildren, PeriodSelector, Scrubber } from '../';
export default {
@@ -72,7 +72,7 @@ const PredictionRow = ({
{seriesData.label}
{
[eaglesData.length],
);
+ const onHighlightChange = useCallback(
+ (items: HighlightedItem[]) => {
+ const idx = items[0]?.dataIndex;
+ updateScrubberLabel(typeof idx === 'number' ? idx : undefined);
+ },
+ [updateScrubberLabel],
+ );
+
const getScrubberAccessibilityLabel = useCallback(
(dataIndex: number) => {
const teamA = eaglesData[dataIndex];
@@ -209,11 +217,11 @@ const PredictionMarket = () => {
{
const headerId = useId();
+ const onHighlightChange = useCallback((items: HighlightedItem[]) => {
+ setScrubIndex(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
{
title={Bitcoin}
/>
;
- enableScrubbing?: boolean;
+ enableHighlighting?: boolean;
points?: LineProps['points'];
}>(
({
@@ -90,18 +90,18 @@ const TransitionLineChart = memo<{
animate: animateProp,
idlePulse,
scrubberRef,
- enableScrubbing = true,
+ enableHighlighting = true,
points,
}) => (
- {enableScrubbing && (
+ {enableHighlighting && (
}
hideOverlay
@@ -120,7 +120,7 @@ const TransitionAreaChart = memo<{
scrubberRef?: RefObject;
}>(({ data, transitions, idlePulse, scrubberRef }) => (
(({ data1, data2, transitions }) => (
{
>
{
}}
>
{
expect(labelledBy).toBe('chart-heading');
});
- it('adds keyboard focus tabIndex when enableScrubbing is true', () => {
+ it('adds keyboard focus tabIndex when enableHighlighting is true', () => {
const root = renderCartesianChart({
testID: 'cartesian-scrubbing-focus',
- chartProps: { enableScrubbing: true },
+ chartProps: { enableHighlighting: true },
});
const svg = root.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg?.getAttribute('tabindex')).toBe('0');
});
- it('does not add keyboard focus tabIndex when enableScrubbing is false', () => {
+ it('does not add keyboard focus tabIndex when enableHighlighting is false', () => {
const root = renderCartesianChart({
testID: 'cartesian-no-scrubbing-focus',
- chartProps: { enableScrubbing: false },
+ chartProps: { enableHighlighting: false },
});
const svg = root.querySelector('svg');
expect(svg).toBeInTheDocument();
@@ -682,7 +682,7 @@ describe('CartesianChart', () => {
const svg = renderCartesianChart({
testID: 'cartesian-scrubber-default',
chartProps: {
- enableScrubbing: true,
+ enableHighlighting: true,
},
children: (
<>
@@ -700,7 +700,7 @@ describe('CartesianChart', () => {
const svg = renderCartesianChart({
testID: 'cartesian-scrubber-no-overlay',
chartProps: {
- enableScrubbing: true,
+ enableHighlighting: true,
},
children: (
<>
@@ -717,7 +717,7 @@ describe('CartesianChart', () => {
testID: 'cartesian-scrubber-series-filter',
series: multiSeries,
chartProps: {
- enableScrubbing: true,
+ enableHighlighting: true,
},
children: (
<>
@@ -737,7 +737,7 @@ describe('CartesianChart', () => {
testID: 'cartesian-scrubber-all-series',
series: multiSeries,
chartProps: {
- enableScrubbing: true,
+ enableHighlighting: true,
},
children: (
<>
diff --git a/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx b/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx
index 1f3c32b99d..fa813e5292 100644
--- a/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx
+++ b/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx
@@ -83,7 +83,7 @@ const CustomBaseline = () => {
return (
{
{
{
{
{
{
return (
{
return (
{
const MultipleYAxesExample = () => (
{
return (
(
minSize,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const { layout } = useCartesianChartContext();
@@ -148,6 +153,7 @@ export const Bar = memo(
d={barPath}
dataX={dataX}
dataY={dataY}
+ fadeOnHighlight={fadeOnHighlight}
fill={fill}
fillOpacity={fillOpacity}
height={height}
diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx
index 5b56e45312..5496a61ede 100644
--- a/packages/web-visualization/src/chart/bar/BarChart.tsx
+++ b/packages/web-visualization/src/chart/bar/BarChart.tsx
@@ -6,12 +6,7 @@ import {
type CartesianChartBaseProps,
type CartesianChartProps,
} from '../CartesianChart';
-import {
- type CartesianAxisConfigProps,
- defaultStackId,
- type Series,
- withBaselineDomain,
-} from '../utils';
+import { type CartesianAxisConfigProps, defaultStackId, withBaselineDomain } from '../utils';
import { BarPlot, type BarPlotProps } from './BarPlot';
import type { BarSeries } from './BarStack';
@@ -42,6 +37,7 @@ export type BarChartBaseProps = Omit<
| 'stackMinSize'
| 'transitions'
| 'transition'
+ | 'fadeOnHighlight'
> & {
/**
* Configuration objects that define how to visualize the data.
@@ -117,6 +113,7 @@ export const BarChart = memo(
stackMinSize,
transitions,
transition,
+ fadeOnHighlight,
...chartProps
},
ref,
@@ -221,6 +218,7 @@ export const BarChart = memo(
barMinSize={barMinSize}
barPadding={barPadding}
borderRadius={borderRadius}
+ fadeOnHighlight={fadeOnHighlight}
fillOpacity={fillOpacity}
roundBaseline={roundBaseline}
seriesIds={seriesIds}
diff --git a/packages/web-visualization/src/chart/bar/BarPlot.tsx b/packages/web-visualization/src/chart/bar/BarPlot.tsx
index 34b87c3b89..7690796616 100644
--- a/packages/web-visualization/src/chart/bar/BarPlot.tsx
+++ b/packages/web-visualization/src/chart/bar/BarPlot.tsx
@@ -21,6 +21,7 @@ export type BarPlotBaseProps = Pick<
| 'barMinSize'
| 'stackMinSize'
| 'BarStackComponent'
+ | 'fadeOnHighlight'
> & {
/**
* Array of series IDs to render.
@@ -54,6 +55,7 @@ export const BarPlot = memo(
stackMinSize,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const { animate, series: allSeries, drawingArea } = useCartesianChartContext();
const clipPathId = useId();
@@ -106,6 +108,7 @@ export const BarPlot = memo(
barMinSize={barMinSize}
barPadding={barPadding}
borderRadius={defaultBorderRadius}
+ fadeOnHighlight={fadeOnHighlight}
fillOpacity={defaultFillOpacity}
roundBaseline={roundBaseline}
series={group.series}
diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx
index c29fd42f73..efc4351a3b 100644
--- a/packages/web-visualization/src/chart/bar/BarStack.tsx
+++ b/packages/web-visualization/src/chart/bar/BarStack.tsx
@@ -22,7 +22,7 @@ export type BarSeries = Series & {
export type BarStackBaseProps = Pick<
BarBaseProps,
- 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius'
+ 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight'
> & {
/**
* Array of series configurations that belong to this stack.
@@ -172,6 +172,7 @@ export const BarStack = memo(
roundBaseline,
transitions,
transition,
+ fadeOnHighlight,
}) => {
const { layout, getSeriesData, getXAxis, getYAxis, getXScale, getYScale } =
useCartesianChartContext();
@@ -305,6 +306,7 @@ export const BarStack = memo(
borderRadius={bar.borderRadius}
dataX={bar.dataX}
dataY={bar.dataY}
+ fadeOnHighlight={fadeOnHighlight}
fill={bar.fill}
fillOpacity={bar.fillOpacity}
height={bar.height}
diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx
index 4179f0c35f..aa1940332a 100644
--- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx
+++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx
@@ -21,6 +21,7 @@ export type BarStackGroupProps = Pick<
| 'BarStackComponent'
| 'transitions'
| 'transition'
+ | 'fadeOnHighlight'
> &
Pick & {
/**
diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx
index 7f93a10889..6cf5af56a9 100644
--- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx
+++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx
@@ -1,4 +1,6 @@
-import React, { memo, useMemo } from 'react';
+import React, { memo, useCallback, useMemo } from 'react';
+import { cx } from '@coinbase/cds-web';
+import { m as motion, type Transition } from 'framer-motion';
import { useCartesianChartContext } from '../ChartProvider';
import { Path } from '../Path';
@@ -11,9 +13,13 @@ import {
withStaggerDelayTransition,
} from '../utils';
import { type BarTransition, getNormalizedStagger } from '../utils/bar';
+import { useHighlightContext } from '../utils/context';
import type { BarComponentProps } from './Bar';
+const fadeOpacity = 0.3;
+const fadeTransition: Transition = { duration: 0.1, ease: 'easeOut' };
+
export type DefaultBarProps = BarComponentProps & {
/**
* Custom class name for the bar.
@@ -26,7 +32,7 @@ export type DefaultBarProps = BarComponentProps & {
};
/**
- * Default bar component that renders a solid bar with animation.
+ * Default bar component that renders a solid bar with animation and highlighting support.
*/
export const DefaultBar = memo(
({
@@ -47,9 +53,65 @@ export const DefaultBar = memo(
minSize = 1,
transitions,
transition,
+ fadeOnHighlight,
...props
}) => {
const { animate, drawingArea, layout } = useCartesianChartContext();
+ const highlightContext = useHighlightContext();
+ const { enabled: highlightEnabled, highlight, scope } = highlightContext;
+
+ const dataIndex = useMemo(() => {
+ if (typeof dataX === 'number') return dataX;
+ if (typeof dataY === 'number') return dataY;
+ return null;
+ }, [dataX, dataY]);
+
+ const highlightByDataIndex = scope.dataIndex ?? false;
+ const highlightBySeries = scope.series ?? false;
+
+ const highlightOpacity = 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);
+ // When seriesId is null/undefined (pointer between bars), all series at this index match.
+ // Only narrow to a specific series when one is identified.
+ const seriesMatch =
+ !highlightBySeries || item.seriesId == null || item.seriesId === seriesId;
+ return indexMatch && seriesMatch;
+ });
+ opacity = isHighlighted ? 1 : fadeOpacity;
+ }
+ return opacity;
+ }, [
+ fadeOnHighlight,
+ highlightEnabled,
+ highlight,
+ highlightByDataIndex,
+ highlightBySeries,
+ dataIndex,
+ seriesId,
+ ]);
+
+ const handlePointerEnter = useCallback(
+ (event: React.PointerEvent) => {
+ if (!highlightEnabled || !highlightBySeries) return;
+ highlightContext.updatePointerHighlight(event.pointerId, { seriesId });
+ },
+ [highlightContext, highlightEnabled, highlightBySeries, seriesId],
+ );
+
+ const handlePointerLeave = useCallback(
+ (event: React.PointerEvent) => {
+ if (!highlightEnabled || !highlightBySeries) return;
+ highlightContext.updatePointerHighlight(event.pointerId, { seriesId: null });
+ },
+ [highlightContext, highlightEnabled, highlightBySeries],
+ );
const normalizedStagger = useMemo(
() => getNormalizedStagger(layout, x, y, drawingArea),
@@ -137,7 +199,7 @@ export const DefaultBar = memo(
minSize,
]);
- return (
+ const path = (
(
fill={fill}
fillOpacity={fillOpacity}
initialPath={initialPath}
+ onPointerEnter={highlightEnabled && highlightBySeries ? handlePointerEnter : undefined}
+ onPointerLeave={highlightEnabled && highlightBySeries ? handlePointerLeave : undefined}
transitions={{
enter: enterTransitionWithStagger,
enterOpacity: enterOpacityTransitionWithStagger,
@@ -153,5 +217,19 @@ export const DefaultBar = memo(
}}
/>
);
+
+ if (fadeOnHighlight) {
+ return (
+
+ {path}
+
+ );
+ }
+
+ return path;
},
);
diff --git a/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx b/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx
index 9fc261b801..069c038833 100644
--- a/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx
+++ b/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx
@@ -25,6 +25,11 @@ export type PercentageBarChartBaseProps = Omit<
| 'inset'
| 'enableScrubbing'
| 'onScrubberPositionChange'
+ | 'enableHighlighting'
+ | 'onHighlightChange'
+ | 'highlight'
+ | 'highlightScope'
+ | 'fadeOnHighlight'
> & {
/**
* Configuration objects that define how to visualize the data.
@@ -74,6 +79,11 @@ export type PercentageBarChartProps = PercentageBarChartBaseProps &
| 'inset'
| 'enableScrubbing'
| 'onScrubberPositionChange'
+ | 'enableHighlighting'
+ | 'onHighlightChange'
+ | 'highlight'
+ | 'highlightScope'
+ | 'fadeOnHighlight'
>;
export const PercentageBarChart = memo(
diff --git a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
index 5f882d90dc..57b801ce5a 100644
--- a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
+++ b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx
@@ -8,10 +8,13 @@ import { m as motion, type Transition } from 'framer-motion';
import { CartesianChart } from '../..';
import { XAxis, YAxis } from '../../axis';
import { useCartesianChartContext } from '../../ChartProvider';
-import { type LineComponentProps, ReferenceLine, SolidLine, type SolidLineProps } from '../../line';
+import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line';
import { PeriodSelector } from '../../PeriodSelector';
-import { Scrubber } from '../../scrubber';
-import { isCategoricalScale, ScrubberContext, useScrubberContext } from '../../utils';
+import {
+ type HighlightedItem,
+ isCategoricalScale,
+ useHighlightContext,
+} from '../../utils';
import { BarChart } from '../BarChart';
import { BarPlot } from '../BarPlot';
import { type BarStackComponentProps } from '../BarStack';
@@ -192,7 +195,11 @@ const tabs: TimePeriodTab[] = [
const ScrubberRect = memo(() => {
const { getXScale, getYScale } = useCartesianChartContext();
- const { scrubberPosition } = React.useContext(ScrubberContext) ?? {};
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const xScale = getXScale();
const yScale = getYScale();
@@ -244,81 +251,106 @@ const Candlesticks = () => {
.reverse();
const min = Math.min(...stockData.map((data) => parseFloat(data.low)));
- // Custom line component that renders a rect to highlight the entire bandwidth
- const BandwidthHighlight = memo(({ stroke }) => {
- const { getXScale, drawingArea } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
- const xScale = getXScale();
-
- if (!xScale || scrubberPosition === undefined) return null;
-
- const xPos = xScale(scrubberPosition);
-
- if (xPos === undefined) return null;
-
- // Type guard to check if scale has bandwidth (band scale)
- const bandwidth = 'bandwidth' in xScale ? xScale.bandwidth() : 0;
-
- return (
-
- );
- });
-
const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]) as [
number,
number,
][];
- const CandlestickBarComponent = memo(({ x, y, width, height, dataX }) => {
- const { getYScale, drawingArea } = useCartesianChartContext();
- const yScale = getYScale();
+ const CandlestickBarComponent = memo(
+ ({ x, y, width, height, dataX, dataY, fadeOnHighlight, seriesId }) => {
+ const { getYScale, drawingArea } = useCartesianChartContext();
+ const highlightContext = useHighlightContext();
+ const yScale = getYScale();
+
+ const dataIndex = 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 = 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 = useMemo(
+ () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0),
+ [x, drawingArea.x, drawingArea.width],
+ );
- const normalizedX = useMemo(
- () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0),
- [x, drawingArea.x, drawingArea.width],
- );
+ const transition: Transition = useMemo(
+ () => ({
+ type: 'tween',
+ duration: 0.325,
+ delay: normalizedX * staggerDelay,
+ }),
+ [normalizedX],
+ );
- const transition: Transition = useMemo(
- () => ({
- type: 'tween',
- duration: 0.325,
- delay: normalizedX * staggerDelay,
- }),
- [normalizedX],
- );
+ const fadeTransition: Transition = { duration: 0.1, ease: 'easeOut' };
- const wickX = x + width / 2;
+ const wickX = x + width / 2;
- const timePeriodValue = stockData[dataX as number];
+ const timePeriodValue = stockData[dataX as number];
- 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: number) => {
return new Intl.NumberFormat('en-US', {
@@ -350,21 +382,28 @@ const Candlesticks = () => {
[stockData],
);
- // Memoize the update function to avoid recreation on each render
const updateInfoText = React.useCallback(
- (index: number | undefined) => {
+ (items: HighlightedItem[]) => {
if (!infoTextRef.current) return;
+ const fallbackText = formatPrice(parseFloat(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 !== undefined
+ typeof index === 'number' && index >= 0 && index < stockData.length
? `Open: ${formatPrice(parseFloat(stockData[index].open))}, Close: ${formatPrice(
parseFloat(stockData[index].close),
)}, Volume: ${formatVolume(stockData[index].volume)}`
- : formatPrice(parseFloat(stockData[stockData.length - 1].close));
+ : fallbackText;
- // Direct DOM manipulation - no React re-render
infoTextRef.current.textContent = text;
- selectedIndexRef.current = index;
+ selectedIndexRef.current = typeof index === 'number' ? index : undefined;
},
[stockData, formatPrice, formatVolume],
);
@@ -374,7 +413,9 @@ const Candlesticks = () => {
// Update text when stockData changes (on timePeriod change)
React.useEffect(() => {
- updateInfoText(selectedIndexRef.current);
+ updateInfoText(
+ selectedIndexRef.current !== undefined ? [{ dataIndex: selectedIndexRef.current }] : [],
+ );
}, [stockData, updateInfoText]);
const infoTextId = useId();
@@ -385,7 +426,8 @@ const Candlesticks = () => {
{initialInfo}
{
aria-labelledby={infoTextId}
borderRadius={0}
height={400}
- onScrubberPositionChange={updateInfoText}
+ onHighlightChange={updateInfoText}
series={[
{
id: 'stock-prices',
@@ -410,14 +452,7 @@ const Candlesticks = () => {
showGrid: true,
GridLineComponent: ThinSolidLine,
}}
- >
-
-
+ />
{
{
return (
{
return (
{
color,
shape,
}: LegendEntryProps) {
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const { series, dataLength } = useCartesianChartContext();
const dataIndex = scrubberPosition ?? dataLength - 1;
@@ -400,7 +404,7 @@ const DynamicData = () => {
return (
();
+ const onHighlightChange = useCallback((items: HighlightedItem[]) => {
+ setScrubberPosition(items[0]?.dataIndex ?? undefined);
+ }, []);
+
return (
@@ -420,10 +425,10 @@ function Interaction() {
: 'Not scrubbing'}
Bitcoin}
/>
{
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const isScrubbing = scrubberPosition !== undefined;
// We need a fade in animation for the Scrubber
return (
@@ -1489,7 +1498,7 @@ function ForecastAssetPrice() {
return (
{
>((props) => {
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const { getXScale, drawingArea } = useCartesianChartContext();
const isScrubbing = scrubberPosition !== undefined;
@@ -355,7 +359,7 @@ const StartPriceReferenceLine = () => {
return (
;
export type ScrubberBaseProps = SharedProps &
- Pick &
+ Pick &
Pick &
Pick &
Pick & {
@@ -291,6 +291,7 @@ export type ScrubberRef = ScrubberBeaconGroupRef;
/**
* Unified component that manages all scrubber elements (beacons, line, labels).
+ * Must be used within a `CartesianChart` with `enableHighlighting` enabled.
*/
export const Scrubber = memo(
forwardRef(
@@ -322,12 +323,17 @@ export const Scrubber = memo(
beaconStroke,
styles,
classNames,
+ highlightIndex = 0,
},
ref,
) => {
const beaconGroupRef = React.useRef(null);
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberDataIndex = useMemo(
+ () => highlight[highlightIndex]?.dataIndex ?? undefined,
+ [highlight, highlightIndex],
+ );
const {
layout,
getXScale,
@@ -360,7 +366,7 @@ export const Scrubber = memo(
const indexAxis = categoryAxisIsX ? getXAxis() : getYAxis();
if (!indexScale) return { dataValue: undefined, dataIndex: undefined };
- const dataIndex = scrubberPosition ?? Math.max(0, dataLength - 1);
+ const dataIndex = scrubberDataIndex ?? Math.max(0, dataLength - 1);
// Convert index to actual data value if axis has data
let dataValue: number;
@@ -376,7 +382,7 @@ export const Scrubber = memo(
}
return { dataValue, dataIndex };
- }, [getXScale, getYScale, getXAxis, getYAxis, scrubberPosition, dataLength, layout]);
+ }, [getXScale, getYScale, getXAxis, getYAxis, scrubberDataIndex, dataLength, layout]);
// Compute resolved accessibility label
const resolvedAccessibilityLabel = useMemo(() => {
@@ -439,7 +445,7 @@ export const Scrubber = memo(
}
: {})}
>
- {!hideOverlay && scrubberPosition !== undefined && pixelPos !== undefined && (
+ {!hideOverlay && scrubberDataIndex !== undefined && pixelPos !== undefined && (
)}
{!hideLine &&
- scrubberPosition !== undefined &&
+ scrubberDataIndex !== undefined &&
dataValue !== undefined &&
dataIndex !== undefined && (
{
const ScrubberBeaconRefs = useRefMap();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[highlightIndex]?.dataIndex ?? undefined,
+ [highlight, highlightIndex],
+ );
const { layout, getXScale, getYScale, getXAxis, getYAxis, dataLength, series, animate } =
useCartesianChartContext();
diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
index ad268f68f1..59710d1dae 100644
--- a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
+++ b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx
@@ -10,7 +10,7 @@ import {
getPointOnScale,
getTransition,
instantTransition,
- useScrubberContext,
+ useHighlightContext,
} from '../utils';
import {
calculateLabelYPositions,
@@ -21,6 +21,7 @@ import {
} from '../utils/scrubber';
import { DefaultScrubberBeaconLabel } from './DefaultScrubberBeaconLabel';
+import type { ScrubberBeaconGroupBaseProps } from './ScrubberBeaconGroup';
import type {
ScrubberBeaconLabelComponent,
ScrubberBeaconLabelProps,
@@ -88,7 +89,8 @@ const PositionedLabel = memo<{
},
);
-export type ScrubberBeaconLabelGroupBaseProps = SharedProps & {
+export type ScrubberBeaconLabelGroupBaseProps = SharedProps &
+ Pick & {
/**
* Labels to be displayed.
*/
@@ -138,6 +140,7 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps &
export const ScrubberBeaconLabelGroup = memo(
({
labels,
+ highlightIndex = 0,
labelMinGap = 4,
labelHorizontalOffset = 16,
labelFont,
@@ -157,7 +160,11 @@ export const ScrubberBeaconLabelGroup = memo(
dataLength,
animate,
} = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[highlightIndex]?.dataIndex ?? undefined,
+ [highlight, highlightIndex],
+ );
const isIdle = scrubberPosition === undefined;
diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx
index e47a3881bc..6dce113190 100644
--- a/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx
+++ b/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx
@@ -8,6 +8,10 @@ import {
type ScrubberContextValue,
} from '../utils';
+/**
+ * @deprecated Use `HighlightProvider` props (`enableHighlighting`, `svgRef`, `onHighlightChange`, etc.) instead. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export type ScrubberProviderProps = Partial<
Pick
> & {
@@ -21,6 +25,9 @@ export type ScrubberProviderProps = Partial<
/**
* A component which encapsulates the ScrubberContext.
* It depends on a ChartContext in order to provide accurate mouse tracking.
+ *
+ * @deprecated Use `HighlightProvider` with `enableHighlighting`, `svgRef`, and `onHighlightChange` instead of `ScrubberProvider`. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
*/
export const ScrubberProvider: React.FC = ({
children,
diff --git a/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx b/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
index c001e402de..e337e49232 100644
--- a/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
+++ b/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx
@@ -21,7 +21,7 @@ import {
type ScrubberLabelProps,
type ScrubberRef,
useCartesianChartContext,
- useScrubberContext,
+ useHighlightContext,
} from '../..';
import { LineChart, SolidLine } from '../../line';
import { Scrubber } from '../Scrubber';
@@ -50,7 +50,7 @@ const Example: React.FC<
const BasicScrubber = () => {
return (
{
const SeriesFilter = () => {
return (
{
const WithLabels = () => {
return (
{
const IdlePulse = () => {
return (
{
return (
{
return (
{
return (
{
const MyScrubberBeaconLabel = memo(
({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -269,7 +273,7 @@ const CustomBeaconLabel = () => {
return (
{
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -357,7 +365,7 @@ const PercentageBeaconLabels = ({ preferredSide }: { preferredSide?: ScrubberLab
return (
{
return (
{
const LabelElevated = () => {
return (
{
return (
{
const LabelFonts = () => {
return (
{
{
{
const CustomLine = () => {
return (
{
const HiddenScrubberWhenIdle = () => {
const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => {
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const isScrubbing = scrubberPosition !== undefined;
return ;
});
const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => {
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const isScrubbing = scrubberPosition !== undefined;
return ;
@@ -595,7 +611,7 @@ const HiddenScrubberWhenIdle = () => {
return (
{
const HideOverlay = () => {
return (
{
const MatchupScrubberBeaconLabel = memo(
({ seriesId, color, ...props }: ScrubberBeaconLabelProps) => {
const { getSeriesData, dataLength } = useCartesianChartContext();
- const { scrubberPosition } = useScrubberContext();
+ const { highlight } = useHighlightContext();
+ const scrubberPosition = useMemo(
+ () => highlight[0]?.dataIndex ?? undefined,
+ [highlight],
+ );
const seriesData = useMemo(
() => getLineData(getSeriesData(seriesId)),
@@ -777,7 +797,7 @@ const MatchupBeaconLabels = () => {
return (
Array<[number, number] | null> | undefined;
+};
+
+/**
+ * Context value for Cartesian (X/Y) coordinate charts.
+ * Contains axis-specific methods and properties for rectangular coordinate systems.
+ */
+export type CartesianChartContextValue = ChartContextValue & {
/**
- * Whether to animate the chart.
- */
- animate: boolean;
- /**
- * Width of the chart SVG.
- */
- width: number;
- /**
- * Height of the chart SVG.
+ * Chart layout - describes the direction bars/areas grow.
+ * @default 'vertical'
*/
- height: number;
+ layout: CartesianChartLayout;
/**
* Get x-axis configuration by ID.
* @param id - The axis ID. Defaults to defaultAxisId.
@@ -72,16 +90,6 @@ export type CartesianChartContextValue = {
* @param id - The axis ID. Defaults to defaultAxisId.
*/
getYScale: (id?: string) => ChartScaleFunction | undefined;
- /**
- * Drawing area of the chart.
- */
- drawingArea: Rect;
- /**
- * Length of the data domain.
- * This is equal to the length of xAxis.data or the longest series data length
- * This equals the number of possible scrubber positions
- */
- dataLength: number;
/**
* Registers an axis.
* Used by axis components to reserve space in the chart, preventing overlap with the drawing area.
@@ -101,6 +109,12 @@ export type CartesianChartContextValue = {
getAxisBounds: (id: string) => Rect | undefined;
};
+/**
+ * Context value for scrubber interaction state and position.
+ *
+ * @deprecated Use `useHighlightContext` and `HighlightContext`, and enable chart interaction with `enableHighlighting` instead of `enableScrubbing`. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export type ScrubberContextValue = {
/**
* Enables scrubbing interactions.
@@ -118,8 +132,16 @@ export type ScrubberContextValue = {
onScrubberPositionChange: (index: number | undefined) => void;
};
+/**
+ * @deprecated Use `HighlightContext` instead. Prefer `useHighlightContext` and `enableHighlighting` over this context and `enableScrubbing`. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export const ScrubberContext = createContext(undefined);
+/**
+ * @deprecated Use `useHighlightContext` instead. Prefer `enableHighlighting` over `enableScrubbing` on charts. This will be removed in a future major release.
+ * @deprecationExpectedRemoval v4
+ */
export const useScrubberContext = (): ScrubberContextValue => {
const context = useContext(ScrubberContext);
if (!context) {
@@ -127,3 +149,47 @@ export const useScrubberContext = (): ScrubberContextValue => {
}
return context;
};
+
+/**
+ * Context value for chart highlight state.
+ */
+export type HighlightContextValue = {
+ /**
+ * Whether highlighting is enabled.
+ */
+ enabled: boolean;
+ /**
+ * The highlight scope configuration.
+ */
+ scope: HighlightScope;
+ /**
+ * The currently highlighted items.
+ */
+ highlight: HighlightedItem[];
+ /**
+ * Set the highlighted items.
+ */
+ setHighlight: (items: HighlightedItem[]) => void;
+ /**
+ * Update a highlighted item for a specific pointer.
+ */
+ updatePointerHighlight: (pointerId: number, item: HighlightedItem) => void;
+ /**
+ * Remove a specific pointer's entry from highlight state.
+ */
+ removePointer: (pointerId: number) => void;
+};
+
+export const HighlightContext = createContext(undefined);
+
+/**
+ * Hook to access the highlight context.
+ * @throws Error if used outside of a HighlightProvider
+ */
+export const useHighlightContext = (): HighlightContextValue => {
+ const context = useContext(HighlightContext);
+ if (!context) {
+ throw new Error('useHighlightContext must be used within a HighlightProvider');
+ }
+ return context;
+};
diff --git a/packages/web-visualization/src/chart/utils/highlight.ts b/packages/web-visualization/src/chart/utils/highlight.ts
new file mode 100644
index 0000000000..34ed5db9b4
--- /dev/null
+++ b/packages/web-visualization/src/chart/utils/highlight.ts
@@ -0,0 +1,42 @@
+/**
+ * Controls what aspects of the data can be highlighted.
+ */
+export type HighlightScope = {
+ /**
+ * Whether highlighting tracks data index.
+ * @default true
+ */
+ dataIndex?: boolean;
+ /**
+ * Whether highlighting tracks specific series.
+ */
+ series?: boolean;
+};
+
+/**
+ * Default highlight scope for cartesian charts.
+ * Highlights by data index, not by series.
+ */
+export const defaultCartesianChartHighlightScope: HighlightScope = {
+ dataIndex: true,
+ series: false,
+};
+
+/**
+ * Represents a single highlighted item during interaction.
+ * - `null` values mean the user is interacting but not over a specific item/series
+ */
+export type HighlightedItem = {
+ /**
+ * The data index being highlighted.
+ * `null` when interacting but not over a data point.
+ * `undefined` when data index is not enabled in scope.
+ */
+ dataIndex?: number | null;
+ /**
+ * The series ID being highlighted.
+ * `null` when series scope is disabled or not over a specific series.
+ * `undefined` when series is not enabled in scope.
+ */
+ seriesId?: string | null;
+};
diff --git a/packages/web-visualization/src/chart/utils/index.ts b/packages/web-visualization/src/chart/utils/index.ts
index a02719070a..28d4decf50 100644
--- a/packages/web-visualization/src/chart/utils/index.ts
+++ b/packages/web-visualization/src/chart/utils/index.ts
@@ -4,6 +4,7 @@ export * from './bar';
export * from './chart';
export * from './context';
export * from './gradient';
+export * from './highlight';
export * from './interpolate';
export * from './path';
export * from './point';