diff --git a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx index 2eb5751cb..7470e1326 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx @@ -410,7 +410,7 @@ function ContinuousGradient() { ))} { + const index = items[0]?.dataIndex ?? null; + setHighlightedIndex(index); + }, []); + + return ( + + + {highlightedIndex !== null + ? `${days[highlightedIndex]}: ${data[highlightedIndex]} visits` + : 'Hover or touch to explore'} + + + + ); +} +``` + +### With Scrubber + +Adding a `Scrubber` component displays a vertical line at the highlighted position, just like in line charts. + +```jsx live +function BarChartWithScrubber() { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const revenue = [345, 510, 280, 720, 655, 410, 580, 815, 740, 910, 975, 620]; + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount), + [], + ); + + const scrubberLabel = useCallback( + (index) => `${months[index]}: ${tickFormatter(revenue[index])}`, + [months, revenue, tickFormatter], + ); + + return ( + + + + ); +} +``` + +### Multi-Series with Scrubber + +Highlighting works with multiple series. The scrubber shows beacons for each series at the highlighted data index. + +```jsx live +function MultiSeriesHighlighting() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenueData = [455, 520, 380, 455, 285, 235]; + const costData = [270, 425, 190, 380, 210, 150]; + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount), + [], + ); + + const scrubberLabel = useCallback( + (index) => + `${months[index]}: Revenue ${tickFormatter(revenueData[index])}, Cost ${tickFormatter(costData[index])}`, + [months, revenueData, costData, tickFormatter], + ); + + return ( + + + + ); +} +``` + +### Controlled Highlighting + +You can control the highlight state externally using the `highlight` and `onHighlightChange` props. +Passing `highlight` puts the chart in controlled mode — useful for synchronizing multiple charts or driving highlight from external UI. + +```jsx live +function ControlledHighlighting() { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const data = [45, 80, 120, 95, 150, 110, 85]; + const [highlight, setHighlight] = useState([]); + + const handleHighlightChange = useCallback((items) => { + setHighlight(items); + }, []); + + const highlightedIndex = highlight[0]?.dataIndex ?? null; + + return ( + + + {days.map((day, i) => ( + + ))} + + + + + + ); +} +``` + +### Series Scope Highlighting + +When `highlightScope` includes `series: true`, hovering individual bars will identify which series is being interacted with. +The `seriesId` is included in the highlight callback. + +```jsx live +function SeriesScopeHighlighting() { + const [info, setInfo] = useState('Hover a bar to see series info'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + const handleHighlightChange = useCallback( + (items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const seriesLabel = item.seriesId ? `Series: ${item.seriesId}` : 'No series detected'; + setInfo(`${months[item.dataIndex]} — ${seriesLabel}`); + } else { + setInfo('Hover a bar to see series info'); + } + }, + [months], + ); + + return ( + + {info} + + + + + ); +} +``` + +### Fade on Highlight + +Set `fadeOnHighlight` to dim non-highlighted bars during interaction. This makes the currently highlighted data index stand out. + +```jsx live +function FadeOnHighlight() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +} +``` + +When `highlightScope` includes `series: true`, the fade logic also considers which series is highlighted. +Only the bars matching both the data index and series remain at full opacity. + +```jsx live +function FadeWithSeriesScope() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const [info, setInfo] = useState('Hover a bar to highlight it'); + + const handleHighlightChange = useCallback( + (items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const series = item.seriesId ?? 'none'; + setInfo(`${months[item.dataIndex]} — Series: ${series}`); + } else { + setInfo('Hover a bar to highlight it'); + } + }, + [months], + ); + + return ( + + {info} + + + ); +} +``` + ## Customization ### Bar Spacing @@ -774,10 +1184,11 @@ function Candlesticks() { [stockData], ); - const updateInfoText = React.useCallback( - (index) => { + const handleHighlightChange = React.useCallback( + (items) => { if (!infoTextRef.current) return; + const index = items[0]?.dataIndex; const text = index !== null && index !== undefined ? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice( @@ -798,7 +1209,7 @@ function Candlesticks() { {initialInfo} 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]); +function Highlighting() { + const [highlightedIndex, setHighlightedIndex] = useState(undefined); + + const handleHighlightChange = useCallback( + (items) => { + const index = items[0]?.dataIndex; + // Do a light impact when the highlighted position changes + // An initial and final impact is already configured by the chart + if (highlightedIndex !== undefined && index !== undefined) { + void Haptics.lightImpact(); + } + setHighlightedIndex(index ?? undefined); + }, + [highlightedIndex], + ); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {highlightedIndex ?? 'none'} @@ -416,7 +419,7 @@ By default, the scrubber will not allow overflow gestures. You can allow overflo ```jsx @@ -434,7 +437,7 @@ You can showcase the price and volume of an asset over time within one chart. function PriceWithVolume() { const theme = useTheme(); - const [scrubIndex, setScrubIndex] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); const btcData = btcCandles .slice(0, 180) .reverse() @@ -468,7 +471,11 @@ function PriceWithVolume() { }); }, []); - const displayIndex = scrubIndex ?? btcPrices.length - 1; + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? null); + }, []); + + const displayIndex = highlightedIndex ?? btcPrices.length - 1; const currentPrice = btcPrices[displayIndex]; const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; @@ -477,9 +484,9 @@ function PriceWithVolume() { : 0; const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + if (highlightedIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + }, [highlightedIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); const ThinSolidLine = memo((props: SolidLineProps) => ); @@ -505,8 +512,8 @@ function PriceWithVolume() { } /> { Custom inset { Default inset { } ``` -## Scrubbing +## Highlighting -CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. +CartesianChart has built-in highlighting functionality that can be enabled with the `enableHighlighting` prop. This will then enable the usage of `onHighlightChange` to get the current highlighted position as the user interacts with the chart. ```jsx live -function Scrubbing() { - const [scrubIndex, setScrubIndex] = useState(undefined); +function Highlighting() { + const [highlightedIndex, setHighlightedIndex] = useState(undefined); - const onScrubberPositionChange = useCallback((index: number | undefined) => { - setScrubIndex(index); + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); }, []); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {highlightedIndex ?? 'none'} @@ -376,7 +375,7 @@ You can showcase the price and volume of an asset over time within one chart. ```jsx live function PriceWithVolume() { - const [scrubIndex, setScrubIndex] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); const btcData = btcCandles .slice(0, 180) .reverse() @@ -410,7 +409,11 @@ function PriceWithVolume() { }); }, []); - const displayIndex = scrubIndex ?? btcPrices.length - 1; + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? null); + }, []); + + const displayIndex = highlightedIndex ?? btcPrices.length - 1; const currentPrice = btcPrices[displayIndex]; const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; @@ -419,9 +422,9 @@ function PriceWithVolume() { : 0; const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + if (highlightedIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + }, [highlightedIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); const ThinSolidLine = memo((props: SolidLineProps) => ); @@ -447,8 +450,8 @@ function PriceWithVolume() { } /> + + +## Overview + +Chart highlighting on mobile enables users to interact with data points through touch gestures. When a user long-presses and drags on the chart, the highlighted data point is tracked with haptic feedback. + +Key features: + +- **Touch Gestures**: Long-press to activate, drag to explore data points +- **Haptic Feedback**: Light impact feedback on start and end of interaction +- **Multi-touch**: Track multiple touch points simultaneously +- **Controlled & Uncontrolled Modes**: Manage state internally or externally +- **Series Highlighting**: Optionally track which specific series is being touched +- **Accessibility**: VoiceOver support with configurable region modes + + + + + + + +## Basic Usage + +To enable highlighting, set `enableHighlighting={true}` on your chart. Use the `onHighlightChange` callback to respond to user interactions. + +```tsx +import { LineChart, Scrubber } from '@coinbase/cds-mobile-visualization'; +import type { HighlightedItem } from '@coinbase/cds-mobile-visualization'; + +function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const displayIndex = highlight[0]?.dataIndex; + const displayValue = + displayIndex !== undefined && displayIndex !== null + ? `$${data[displayIndex].toFixed(2)}` + : 'Long-press to see value'; + + return ( + + {displayValue} + + + + + ); +} +``` + +The `onHighlightChange` callback receives an array of `HighlightedItem` objects: + +```ts +type HighlightedItem = { + dataIndex: number | null; // Index of the highlighted data point + seriesId: string | null; // ID of the highlighted series (when using highlightScope.series) +}; +``` + + + + + + + +## Controlled State + +For full control over the highlighted state, use the `highlight` prop along with `onHighlightChange`. + +```tsx +function ControlledHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + + + + + + + + + ); +} +``` + +### Controlled Mode Behavior + +| `highlight` value | Behavior | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `undefined` | **Uncontrolled mode** - Chart manages its own state internally | +| `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | +| `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | + +:::tip +In controlled mode, pass an empty array `[]` (not `undefined`) to clear highlights while staying in controlled mode. Using `undefined` switches back to uncontrolled mode. +::: + + + + + + + +## Disabling Highlighting + +Set `enableHighlighting={false}` to disable all highlighting functionality. + +```tsx + +``` + + + + + + + +## Series Highlighting + +Enable series highlighting to track which specific bar or series the user is touching: + +```tsx +function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + return ( + + + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex} | Series: ${highlight[0]?.seriesId ?? 'none'}` + : 'Long-press over a bar...'} + + + + + ); +} +``` + +### highlightScope Options + +```ts +type HighlightScope = { + dataIndex?: boolean; // Track which data index is highlighted (default: true) + series?: boolean; // Track which series is highlighted (default: false) +}; +``` + + + + + + + +## Accessibility + +Mobile charts support VoiceOver with configurable accessibility modes. + +### accessibilityMode Options + +| Mode | Description | +| ----------- | ------------------------------------------------- | +| `'chunked'` | Divides chart into N accessible regions (default) | +| `'item'` | Each data point is an accessible region | + +```tsx + + item.dataIndex !== null + ? `Day ${item.dataIndex + 1}: $${data[item.dataIndex].toFixed(2)}` + : 'Interacting with price chart' + } + accessibilityMode="chunked" + accessibilityChunkCount={10} + height={200} + series={[{ id: 'prices', data }]} +> + + +``` + + + + + + + +## Using the Highlight Context + +Access the highlight state from child components using the `useHighlightContext` hook: + +```tsx +import { useHighlightContext } from '@coinbase/cds-mobile-visualization'; + +function CustomHighlightIndicator() { + const { highlight, enabled, scope, setHighlight } = useHighlightContext(); + + // highlight is a SharedValue for UI thread performance + // Use useAnimatedReaction or useDerivedValue to react to changes + + return ( + // Custom indicator implementation + ); +} +``` + +### Context Value + +```ts +type HighlightContextValue = { + enabled: boolean; // Whether highlighting is enabled + scope: HighlightScope; // What aspects are being tracked + highlight: SharedValue; // Current highlighted items (Reanimated SharedValue) + setHighlight: (items: HighlightedItem[]) => void; // Update highlight state + // ... element registration methods for hit testing +}; +``` + +:::note +On mobile, `highlight` is a Reanimated `SharedValue` for UI thread performance. Use `useAnimatedReaction` or `useDerivedValue` to react to changes efficiently. +::: + + + + + + + +## Migration from Legacy Props + +The following legacy props are still supported for backwards compatibility but are deprecated: + +| Legacy Prop | New Prop | +| -------------------------- | -------------------- | +| `enableScrubbing` | `enableHighlighting` | +| `onScrubberPositionChange` | `onHighlightChange` | + +```tsx +// Legacy (still works) + console.log(index)} + {...props} +/> + +// Recommended + console.log(items[0]?.dataIndex)} + {...props} +/> +``` + + + + + + + +## API Reference + +### Chart Props + +| Prop | Type | Default | Description | +| ------------------------- | ----------------------------------------------- | --------------------- | ----------------------------------- | +| `enableHighlighting` | `boolean` | `false` | Enable/disable highlighting | +| `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | +| `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | +| `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | +| `accessibilityLabel` | `string \| ((item: HighlightedItem) => string)` | - | Accessibility label | +| `accessibilityMode` | `'chunked' \| 'item'` | `'chunked'` | VoiceOver region mode | +| `accessibilityChunkCount` | `number` | `10` | Number of chunks for 'chunked' mode | + +### Types + +```ts +type HighlightedItem = { + dataIndex: number | null; + seriesId: string | null; +}; + +type HighlightScope = { + dataIndex?: boolean; + series?: boolean; +}; +``` + + + diff --git a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx new file mode 100644 index 000000000..42c7385af --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx @@ -0,0 +1,525 @@ +import { MDXSection } from '@site/src/components/page/MDXSection'; +import { MDXArticle } from '@site/src/components/page/MDXArticle'; + + + + +## Overview + +Chart highlighting enables users to interact with data points in Cartesian charts through mouse, touch, and keyboard input. When a user hovers over or touches the chart, the highlighted data point is tracked and can be used to display additional information like tooltips, data labels, or synchronized views across multiple charts. + +Key features: + +- **Mouse & Touch Support**: Highlight data points on hover or touch +- **Multi-touch**: Track multiple touch points simultaneously on touch devices +- **Keyboard Navigation**: Navigate between data points using arrow keys +- **Controlled & Uncontrolled Modes**: Manage state internally or externally +- **Series Highlighting**: Optionally track which specific series is being interacted with +- **Accessibility**: Dynamic `aria-label` support for screen readers + + + + + + + +## Basic Usage + +To enable highlighting, set `enableHighlighting={true}` on your chart. Use the `onHighlightChange` callback to respond to user interactions. + +```jsx live +function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const formatPrice = useCallback((value) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const displayIndex = highlight[0]?.dataIndex; + const displayValue = + displayIndex !== undefined && displayIndex !== null + ? formatPrice(data[displayIndex]) + : 'Hover to see value'; + + return ( + + + {displayValue} + + + + + + ); +} +``` + +The `onHighlightChange` callback receives an array of `HighlightedItem` objects: + +```ts +type HighlightedItem = { + dataIndex: number | null; // Index of the highlighted data point + seriesId: string | null; // ID of the highlighted series (when using highlightScope.series) +}; +``` + + + + + + + +## Controlled State + +For full control over the highlighted state, use the `highlight` prop along with `onHighlightChange`. This is useful for: + +- Programmatically selecting data points +- Synchronizing highlights across multiple charts +- Persisting highlight state + +```jsx live +function ControlledHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + + + + + + + Index: {highlight[0]?.dataIndex ?? 'none'} + + + + + + + ); +} +``` + +### Controlled Mode Behavior + +| `highlight` value | Behavior | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `undefined` | **Uncontrolled mode** - Chart manages its own state internally | +| `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | +| `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | + +:::tip +In controlled mode, pass an empty array `[]` (not `undefined`) to clear highlights while staying in controlled mode. Using `undefined` switches back to uncontrolled mode. +::: + +:::tip +Even in controlled mode, `onHighlightChange` still fires when the user interacts with the chart. This allows you to respond to user gestures without necessarily updating the controlled state. +::: + + + + + + + +## Disabling Highlighting + +Set `enableHighlighting={false}` to disable all highlighting functionality. The chart will be display-only. + +```jsx live + +``` + + + + + + + +## Series Highlighting + +By default, only the data index is tracked during interactions. To also track which specific series the user is interacting with (useful for grouped bar charts or multi-line charts), enable series highlighting with `highlightScope`: + +```jsx live +function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + const seriesColors = { + A: 'var(--color-fgPrimary)', + B: 'var(--color-fgPositive)', + C: 'var(--color-fgWarning)', + }; + + return ( + + + + {highlight.length > 0 ? ( + <> + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( + <> + {' | Series: '} + + {highlight[0].seriesId} + + + )} + + ) : ( + 'Hover over a bar...' + )} + + + + + + ); +} +``` + +### highlightScope Options + +```ts +type HighlightScope = { + dataIndex?: boolean; // Track which data index is highlighted (default: true) + series?: boolean; // Track which series is highlighted (default: false) +}; +``` + + + + + + + +## Synchronizing with UI Elements + +Use controlled state to synchronize chart highlighting with other UI elements like lists. This is useful for creating interactive experiences where users can explore data from either the chart or a list view. + +```jsx live +function SynchronizedWithList() { + const [highlight, setHighlight] = useState([]); + + const data = [ + { name: 'Bitcoin', symbol: 'BTC', price: 42150, change: 2.4 }, + { name: 'Ethereum', symbol: 'ETH', price: 2280, change: -1.2 }, + { name: 'Solana', symbol: 'SOL', price: 98, change: 5.8 }, + { name: 'Cardano', symbol: 'ADA', price: 0.52, change: -0.3 }, + { name: 'Polygon', symbol: 'MATIC', price: 0.89, change: 1.1 }, + ]; + + const chartData = data.map((item) => item.price); + const highlightedIndex = highlight?.[0]?.dataIndex; + + const formatPrice = (price) => + price >= 1 + ? `$${price.toLocaleString('en-US', { minimumFractionDigits: 2 })}` + : `$${price.toFixed(2)}`; + + return ( + + + Hover over the chart or the list items to see synchronized highlighting. + + + d.symbol) }} + > + + + + + + {data.map((item, index) => ( + 0 ? '+' : ''}${item.change}%`} + variant={item.change >= 0 ? 'positive' : 'negative'} + selected={highlightedIndex === index} + onMouseEnter={() => setHighlight([{ dataIndex: index, seriesId: null }])} + onMouseLeave={() => setHighlight([])} + /> + ))} + + + ); +} +``` + + + + + + + +## Multi-Touch Support + +On touch devices, the highlight array can contain multiple items - one for each touch point. This enables features like comparing two data points simultaneously. + +```jsx live +function MultiTouchHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + // Custom component that renders a ReferenceLine for each touch point + const MultiTouchReferenceLines = memo(() => { + const { highlight: items } = useHighlightContext(); + const colors = ['var(--color-fgPrimary)', 'var(--color-fgPositive)', 'var(--color-fgNegative)']; + + return ( + <> + {items.map((item, index) => + item.dataIndex !== null ? ( + + ) : null, + )} + + ); + }); + + return ( + + + Use multiple fingers on a touch device to see multiple reference lines. + + + + + Active touches: {highlight.length} + {highlight.length > 0 && ` (${highlight.map((item) => `#${item.dataIndex}`).join(', ')})`} + + + + + + + + ); +} +``` + + + + + + + +## Accessibility + +Provide an `accessibilityLabel` to make the chart accessible to screen readers. This can be a static string or a function that receives the current highlighted item for dynamic labels. + +### Static Label + +```jsx + + + +``` + +### Dynamic Label + +```jsx live +function DynamicAccessibilityLabel() { + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const accessibilityLabel = useCallback((item) => { + if (item.dataIndex === null) return 'Interacting with price chart'; + return `Day ${item.dataIndex + 1}: $${data[item.dataIndex].toFixed(2)}`; + }, []); + + return ( + + + + ); +} +``` + + + + + + + +## Using the Highlight Context + +For advanced use cases, access the highlight state from child components using the `useHighlightContext` hook: + +```jsx +import { useHighlightContext } from '@coinbase/cds-web-visualization'; + +function CustomHighlightIndicator() { + const { highlight, enabled, scope, setHighlight } = useHighlightContext(); + + if (!enabled || highlight.length === 0) return null; + + return ( +
+ Current index: {highlight[0]?.dataIndex} + {scope.series && ` | Series: ${highlight[0]?.seriesId}`} +
+ ); +} +``` + +### Context Value + +```ts +type HighlightContextValue = { + enabled: boolean; // Whether highlighting is enabled + scope: HighlightScope; // What aspects are being tracked + highlight: HighlightedItem[]; // Current highlighted items + setHighlight: (items: HighlightedItem[]) => void; // Update highlight state +}; +``` + +:::note +Use `useOptionalHighlightContext` if your component might be rendered outside of a chart context. It returns `undefined` instead of throwing an error. +::: + +
+
+ + + + +## Migration from Legacy Props + +The following legacy props are still supported for backwards compatibility but are deprecated: + +| Legacy Prop | New Prop | +| -------------------------- | -------------------- | +| `enableScrubbing` | `enableHighlighting` | +| `onScrubberPositionChange` | `onHighlightChange` | + +```jsx +// Legacy (still works) + console.log(index)} + {...props} +/> + +// Recommended + console.log(items[0]?.dataIndex)} + {...props} +/> +``` + + + + + + + +## API Reference + +### Chart Props + +| Prop | Type | Default | Description | +| -------------------- | ----------------------------------------------- | --------------------- | ------------------------------- | +| `enableHighlighting` | `boolean` | `false` | Enable/disable highlighting | +| `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | +| `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | +| `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | +| `accessibilityLabel` | `string \| ((item: HighlightedItem) => string)` | - | Accessibility label | + +### Types + +```ts +type HighlightedItem = { + dataIndex: number | null; + seriesId: string | null; +}; + +type HighlightScope = { + dataIndex?: boolean; + series?: boolean; +}; +``` + + + diff --git a/apps/docs/docs/components/graphs/Highlighting/index.mdx b/apps/docs/docs/components/graphs/Highlighting/index.mdx new file mode 100644 index 000000000..162601507 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/index.mdx @@ -0,0 +1,31 @@ +--- +id: highlighting +title: Chart Highlighting +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ContentHeader } from '@site/src/components/page/ContentHeader'; +import { ContentPageContainer } from '@site/src/components/page/ContentPageContainer'; + +import WebContent, { toc as webContentToc } from './_webContent.mdx'; +import MobileContent, { toc as mobileContentToc } from './_mobileContent.mdx'; + +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + mobileContent={} + webContentToc={webContentToc} + mobileContentToc={mobileContentToc} + /> + diff --git a/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json b/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json new file mode 100644 index 000000000..8154823d3 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json @@ -0,0 +1,3 @@ +{ + "description": "Interactive highlighting for Cartesian charts on mobile. Enable users to explore data points through touch gestures with haptic feedback." +} diff --git a/apps/docs/docs/components/graphs/Highlighting/webMetadata.json b/apps/docs/docs/components/graphs/Highlighting/webMetadata.json new file mode 100644 index 000000000..c7edab9b3 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/webMetadata.json @@ -0,0 +1,3 @@ +{ + "description": "Interactive highlighting for Cartesian charts. Enable users to explore data points through mouse, touch, and keyboard interaction." +} diff --git a/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx index c2b162792..93367144b 100644 --- a/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx @@ -402,7 +402,11 @@ You can use `EntryComponent` to display a label that updates as a user interacts ```jsx function DynamicLabel() { const theme = useTheme(); - const [scrubberPosition, setScrubberPosition] = useState(); + const [highlightedIndex, setHighlightedIndex] = useState(); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); const timeLabels = [ 'Jan', @@ -447,7 +451,7 @@ function DynamicLabel() { ); const dataLength = seriesConfig[0].data?.length ?? 0; - const dataIndex = scrubberPosition ?? dataLength - 1; + const dataIndex = highlightedIndex ?? dataLength - 1; const ValueLegendEntry = useCallback( ({ seriesId, label, color, shape }) => { @@ -470,12 +474,12 @@ function DynamicLabel() { return ( } legendPosition="top" - onScrubberPositionChange={setScrubberPosition} + onHighlightChange={handleHighlightChange} series={seriesConfig} width="100%" xAxis={{ diff --git a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx index d84bb9eec..f5beb2214 100644 --- a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx @@ -29,7 +29,7 @@ function BasicLegend() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + {highlightedIndex !== undefined + ? `Highlighted index: ${highlightedIndex}` + : 'Not highlighting'} (tabs[0]); - const [scrubberPosition, setScrubberPosition] = useState(); + const [highlightedIndex, setHighlightedIndex] = useState(); const sparklineTimePeriodData = useMemo(() => { return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; @@ -379,13 +383,17 @@ function Performance() { [tabs], ); + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); + return ( - + ); @@ -393,10 +401,10 @@ function Performance() { const PerformanceHeader = memo( ({ - scrubberPosition, + highlightedIndex, sparklineTimePeriodDataValues, }: { - scrubberPosition: number | undefined; + highlightedIndex: number | undefined; sparklineTimePeriodDataValues: number[]; }) => { const theme = useTheme(); @@ -411,7 +419,7 @@ const PerformanceHeader = memo( }, []); const shownPosition = - scrubberPosition !== undefined ? scrubberPosition : sparklineTimePeriodDataValues.length - 1; + highlightedIndex !== undefined ? highlightedIndex : sparklineTimePeriodDataValues.length - 1; return ( @@ -438,10 +446,10 @@ const PerformanceHeader = memo( const PerformanceChart = memo( ({ timePeriod, - onScrubberPositionChange, + onHighlightChange, }: { timePeriod: TabValue; - onScrubberPositionChange: (position: number | undefined) => void; + onHighlightChange: (items: Array<{ dataIndex: number | null; seriesId: string | null }>) => void; }) => { const theme = useTheme(); @@ -490,13 +498,13 @@ const PerformanceChart = memo( return ( @@ -649,7 +657,7 @@ function Transitions() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); // Chart-level accessibility label provides overview @@ -697,8 +705,8 @@ function BasicAccessible() { return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; }, [data]); - // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( + // Dynamic accessibility label provides specific position info + const dynamicAccessibilityLabel = useCallback( (index: number) => { return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; }, @@ -706,20 +714,24 @@ function BasicAccessible() { ); const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); + if (highlightedIndex !== undefined) { + return dynamicAccessibilityLabel(highlightedIndex); } return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); + }, [highlightedIndex, chartAccessibilityLabel, dynamicAccessibilityLabel]); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( Bitcoin} /> (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [theme.color.fg, theme.color.bg], ); return ( ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], + const Example = memo(() => { + const defaultHighlight = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [currentIndex], ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], ); - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); - - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); - return ( - - - - - - - - + + + + + + ); }); + return ; +} +``` + +### Highlighted Line Segments + +You can use `gradient` with dynamic stops to highlight specific segments of a line based on scrubber position. + +```jsx live +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleHighlightChange = useCallback((items) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x', + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x', stops }; + }, [monthStart, monthEnd, prices.length]); + return ( - - - - - - + + + ); +} +``` + +### Adaptive Detail + +You can show sampled data at rest for performance and switch to full-resolution data when the user begins scrubbing, providing an adaptive level of detail. + +```jsx live +function AdaptiveDetail() { + const BTCTab = memo( + forwardRef(({ label, ...props }, ref) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), ); + + const BTCActiveIndicator = memo(({ style, ...props }) => ( + + )); + + const chartTransition = useMemo(() => ({ duration: 150 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues = []; + const sampledTimestamps = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date, periodId) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + ); + }); + + return ; } ``` diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx index 609090ed3..e1de3a94f 100644 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx @@ -45,7 +45,7 @@ function MultipleLine() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + {highlightedIndex !== undefined + ? `Highlighted index: ${highlightedIndex}` + : 'Not highlighting'} Bitcoin} /> (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [], ); return ( { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - // We need a fade in animation for the Scrubber + const Example = memo(() => { + const defaultHighlight = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [currentIndex], + ); + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], + ); + return ( - - - - - - - - + + + + + ); }); + return ; +} +``` + +### Highlighted Line Segments + +You can use `gradient` with dynamic stops to highlight specific segments of a line based on scrubber position. + +```jsx live +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleHighlightChange = useCallback((items) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x', + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x', stops }; + }, [monthStart, monthEnd, prices.length]); + return ( - - - - - - + + + ); +} +``` + +### Adaptive Detail + +You can show sampled data at rest for performance and switch to full-resolution data when the user begins scrubbing, providing an adaptive level of detail. + +```jsx live +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef( + ({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }, + ), + ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + type MemoizedChartProps = { + highlight: HighlightedItem[]; + data: number[]; + isScrubbing: boolean; + onHighlightChange: (items: HighlightedItem[]) => void; + scrubberLabel: (index: number) => string; + }; + + const chartTransition = useMemo(() => ({ duration: 0.15 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }) => ({ min: min + 8, max: max - 8 }), + }), + [], ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues = []; + const sampledTimestamps = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date, periodId) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + = 0 ? 'rotate(0deg)' : 'rotate(90deg)' }} + /> + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + + ); + }); + + return ; } ``` diff --git a/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx b/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx index 8e8da0d2c..872b3cf32 100644 --- a/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx @@ -348,9 +348,13 @@ function CustomizableAssetPriceExample() { const [showSettings, setShowSettings] = useState(false); const [showYAxis, setShowYAxis] = useState(true); const [showXAxis, setShowXAxis] = useState(true); - const [scrubIndex, setScrubIndex] = useState(); + const [highlightedIndex, setHighlightedIndex] = useState(); const breakpoints = useBreakpoints(); + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); + const formatPrice = useCallback((price: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -381,11 +385,11 @@ function CustomizableAssetPriceExample() { const data = useMemo(() => sparklineInteractiveData[activeTab.id], [activeTab.id]); const currentPrice = useMemo(() => sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value, []); const currentTimePrice = useMemo(() => { - if (scrubIndex !== undefined) { - return data[scrubIndex].value; + if (highlightedIndex !== undefined) { + return data[highlightedIndex].value; } return currentPrice; - }, [data, scrubIndex, currentPrice]); + }, [data, highlightedIndex, currentPrice]); const formatDate = useCallback((date) => { const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); @@ -402,19 +406,19 @@ function CustomizableAssetPriceExample() { }, []); const scrubberLabel = useMemo(() => { - if (scrubIndex === undefined) return; - return formatDate(data[scrubIndex].date); - }, [scrubIndex, data, formatDate]); + if (highlightedIndex === undefined) return; + return formatDate(data[highlightedIndex].date); + }, [highlightedIndex, data, formatDate]); const accessibilityLabel = useMemo(() => { - if (scrubIndex === undefined) return; + if (highlightedIndex === undefined) return; const price = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, - }).format(data[scrubIndex].value); - const date = formatDate(data[scrubIndex].date); + }).format(data[highlightedIndex].value); + const date = formatDate(data[highlightedIndex].date); return `Asset price: ${price} USD on ${date}`; - }, [scrubIndex, data, formatDate]); + }, [highlightedIndex, data, formatDate]); const onClickSettings = useCallback(() => setShowSettings(!showSettings), [showSettings]); @@ -477,9 +481,9 @@ function CustomizableAssetPriceExample() { } /> (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [theme.color.fg, theme.color.bg], ); @@ -238,7 +240,7 @@ function OutlineBeacon() { return ( diff --git a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx index dd0718209..b55a49729 100644 --- a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx @@ -4,7 +4,7 @@ Scrubber can be used to provide horizontal interaction with a chart. As your mou ```jsx live (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [], ); @@ -248,7 +250,7 @@ function OutlineBeacon() { return ( & - Pick & { +export type CartesianChartBaseProps = Omit & + Omit & { /** * Configuration objects that define how to visualize the data. * Each series contains its own data array. */ - series?: Array; + series?: Array; /** * Whether to animate the chart. * @default true @@ -83,10 +86,40 @@ 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 highlighted item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((item: 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. Will be removed in next major version. + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onHighlightChange` instead. Will be removed in next major version. + */ + onScrubberPositionChange?: (index: number | undefined) => void; }; export type CartesianChartProps = CartesianChartBaseProps & - Pick & Omit & { /** * Default font families to use within ChartText. @@ -117,6 +150,10 @@ export type CartesianChartProps = CartesianChartBaseProps & */ chart?: StyleProp; }; + /** + * Allows continuous gestures on the chart to continue outside the bounds of the chart element. + */ + allowOverflowGestures?: boolean; }; export const CartesianChart = memo( @@ -126,10 +163,19 @@ export const CartesianChart = memo( series, children, animate = true, - enableScrubbing, xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, + // New highlighting props + enableHighlighting, + highlightScope = defaultCartesianChartHighlightScope, + highlight, + onHighlightChange, + accessibilityLabel, + accessibilityMode, + accessibilityChunkCount, + // Legacy props + enableScrubbing, onScrubberPositionChange, legend, legendPosition = 'bottom', @@ -154,7 +200,10 @@ export const CartesianChart = memo( const chartWidth = containerLayout.width; const chartHeight = containerLayout.height; - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // there can only be one x axis but the helper function always returns an array const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); @@ -404,6 +453,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -451,6 +501,25 @@ export const CartesianChart = memo( return [style, styles?.root]; }, [style, styles?.root]); + // Resolve enableHighlighting (backwards compatibility with enableScrubbing) + const resolvedEnableHighlighting = useMemo(() => { + if (enableHighlighting !== undefined) return enableHighlighting; + if (enableScrubbing !== undefined) return enableScrubbing; + return false; // Default to disabled + }, [enableHighlighting, enableScrubbing]); + + // Wrap onHighlightChange to also call legacy onScrubberPositionChange + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + // Legacy callback support + if (onScrubberPositionChange) { + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onScrubberPositionChange], + ); const legendElement = useMemo(() => { if (!legend) return; @@ -481,10 +550,15 @@ export const CartesianChart = memo( return ( - {legend ? ( {children} )} - + ); }, diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx index 89db490bf..a0c66f9c0 100644 --- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx +++ b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx @@ -10,6 +10,7 @@ import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider'; import { ScrubberContext } from './utils/context'; import { CartesianChartContext } from './ChartProvider'; +import { HighlightContext } from './HighlightProvider'; /** * Whitelist of contexts that should be bridged to the Skia canvas. @@ -20,6 +21,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 370449473..49e66786f 100644 --- a/packages/mobile-visualization/src/chart/ChartProvider.tsx +++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx @@ -1,11 +1,30 @@ 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 (cartesian, polar, etc.). + * Use this when you only need base chart properties like series, dimensions, 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 like axes and scales. + */ 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 000000000..6e41e9207 --- /dev/null +++ b/packages/mobile-visualization/src/chart/HighlightProvider.tsx @@ -0,0 +1,576 @@ +import React, { createContext, useCallback, useContext, 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 { invertSerializableScale, ScrubberContext, type ScrubberContextValue } from './utils'; + +/** + * 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; + /** + * Function to programmatically set the highlighted items. + */ + setHighlight: (items: HighlightedItem[]) => void; + /** + * Merge a partial update into a specific pointer's highlight entry. + * Only updates the fields provided, leaving other fields untouched. + */ + updatePointerHighlight: (pointerId: number, partial: Partial) => 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; +}; + +export type HighlightProps = { + /** + * 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; +}; + +export type HighlightProviderProps = HighlightProps & { + children: React.ReactNode; + /** + * Allows continuous gestures on the chart to continue outside the bounds of the chart element. + */ + allowOverflowGestures?: boolean; + /** + * 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 | ((item: 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 = { dataIndex: null, seriesId: null }; + +/** + * 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: React.FC = ({ + children, + allowOverflowGestures, + enableHighlighting = false, + highlightScope: scopeProp, + highlight: controlledHighlight, + onHighlightChange, + accessibilityLabel, + accessibilityMode = 'chunked', + accessibilityChunkCount = 10, +}) => { + const chartContext = useCartesianChartContext(); + + if (!chartContext) { + throw new Error('HighlightProvider must be used within a ChartContext'); + } + + const { getXSerializableScale, getXAxis, dataLength } = chartContext; + + 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 xAxis = useMemo(() => getXAxis(), [getXAxis]); + const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + + // Convert X coordinate to data index (worklet-compatible) + const getDataIndexFromX = useCallback( + (touchX: number): number => { + 'worklet'; + + if (!xScale || !xAxis) return 0; + + if (xScale.type === 'band') { + const [domainMin, domainMax] = xScale.domain; + const categoryCount = domainMax - domainMin + 1; + let closestIndex = 0; + let closestDistance = Infinity; + + for (let i = 0; i < categoryCount; i++) { + const xPos = getPointOnSerializableScale(i, xScale); + if (xPos !== undefined) { + const distance = Math.abs(touchX - xPos); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + } + return closestIndex; + } else { + const axisData = xAxis.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 xValue = numericData[i]; + const xPos = getPointOnSerializableScale(xValue, xScale); + if (xPos !== undefined) { + const distance = Math.abs(touchX - xPos); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + } + return closestIndex; + } else { + const xValue = invertSerializableScale(touchX, xScale); + const dataIndex = Math.round(xValue); + const domain = xAxis.domain; + return Math.max(domain.min ?? 0, Math.min(dataIndex, domain.max ?? 0)); + } + } + }, + [xAxis, xScale], + ); + + // 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) => { + const seriesId = scope.series ? (findBarAtPoint(x, y)?.seriesId ?? null) : null; + 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 dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; + 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 dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + 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 dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + 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, + getDataIndexFromX, + 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(() => { + const items = internalHighlight.value; + if (!items || items.length === 0) return undefined; + return items[0]?.dataIndex ?? undefined; + }, [internalHighlight]); + + const scrubberContextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing: enableHighlighting, + scrubberPosition, + }), + [enableHighlighting, scrubberPosition], + ); + + // Accessibility + const getAccessibilityLabelForItem = useCallback( + (item: HighlightedItem): string => { + if (typeof accessibilityLabel === 'string') { + return accessibilityLabel; + } + if (typeof accessibilityLabel === 'function') { + return accessibilityLabel(item); + } + 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, seriesId: null }; + + regions.push({ + key: `chunk-${i}`, + flex: chunkLength, + label: getAccessibilityLabelForItem(item), + highlightedItem: item, + }); + } + } else if (accessibilityMode === 'item') { + for (let i = 0; i < dataLength; i++) { + const item: HighlightedItem = { dataIndex: i, seriesId: null }; + regions.push({ + key: `item-${i}`, + flex: 1, + label: getAccessibilityLabelForItem(item), + highlightedItem: item, + }); + } + } + + return regions; + }, [ + enableHighlighting, + accessibilityLabel, + accessibilityMode, + accessibilityChunkCount, + dataLength, + getAccessibilityLabelForItem, + ]); + + 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 d0d9bfddd..6b5f00eed 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -38,9 +38,9 @@ export type PathBaseProps = { */ fill?: string; /** - * Opacity for the path fill. + * Opacity for the path fill. Accepts a static number or a SharedValue for animated opacity. */ - fillOpacity?: number; + fillOpacity?: AnimatedProp; /** * Stroke color for the path. * When provided, will render a fill with the given color. diff --git a/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx new file mode 100644 index 000000000..f7e9aa620 --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -0,0 +1,430 @@ +import { useCallback, useState } from 'react'; +import { Button } from '@coinbase/cds-mobile/buttons'; +import { Example, 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 { XAxis, YAxis } from '../axis'; +import { BarChart, BarPlot } from '../bar'; +import { CartesianChart } from '../CartesianChart'; +import { Line, LineChart } from '../line'; +import { Scrubber } from '../scrubber'; +import type { HighlightedItem } from '../utils'; + +const formatPrice = (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + }).format(value); + +// Sample data +const samplePrices = [ + 45, 52, 38, 45, 60, 55, 48, 62, 58, 65, 72, 68, 75, 80, 78, 85, 90, 88, 92, 95, 100, 98, 105, 110, + 108, 115, 120, 118, 125, 130, +]; + +const seriesA = [3, 4, 1, 6, 5]; +const seriesB = [4, 3, 1, 5, 8]; +const xAxisData = ['0', '2', '5', '10', '20']; + +/** + * Basic highlighting + */ +const BasicHighlighting = () => { + const [highlight, setHighlight] = useState([]); + const theme = useTheme(); + + return ( + + + + Active:{' '} + {highlight.length > 0 ? `dataIndex: ${highlight[0]?.dataIndex}` : 'Not interacting'} + + + + + + + + ); +}; + +/** + * Controlled state - programmatically set the highlighted item + */ +const ControlledState = () => { + const theme = useTheme(); + // undefined = uncontrolled mode + // HighlightedItem[] = controlled mode with specific highlighted items + const [highlight, setHighlight] = useState(undefined); + + return ( + + + Use buttons to programmatically select data points. Pass undefined to go back to + uncontrolled mode. + + + + + + + + + + + + Index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== undefined && + highlight[0].dataIndex !== null && + ` (${formatPrice(samplePrices[highlight[0].dataIndex])})`} + + + + + + + + ); +}; + +/** + * Series highlighting - track which specific bar is being touched + */ +const SeriesHighlighting = () => { + const theme = useTheme(); + const [highlight, setHighlight] = useState([]); + + const seriesColors: Record = { + A: theme.color.fgPrimary, + B: theme.color.fgPositive, + C: theme.color.fgWarning, + }; + + return ( + + + Long-press and drag over bars to see both dataIndex and seriesId tracked. + + + + + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex ?? 'none'}${highlight[0]?.seriesId ? ` | Series: ${highlight[0].seriesId}` : ''}` + : 'Long-press over a bar...'} + + + + + + + {Object.entries(seriesColors).map(([id, color]) => ( + + + Series {id} + + ))} + + + ); +}; + +/** + * Test overlapping bars with separate BarPlots to verify z-order behavior + */ +const OverlappingBarsZOrder = () => { + const theme = useTheme(); + const [highlight, setHighlight] = useState([]); + + const seriesColors: Record = { + revenue: theme.color.fgWarning, + profitMargin: 'rgba(0, 255, 0, 0.25)', + }; + + return ( + + + Two separate BarPlots with different y-axes. The bars overlap at the same x positions. The + second BarPlot (profitMargin/green) is rendered on top and receives touch events. + + + + + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex ?? 'none'}${highlight[0]?.seriesId ? ` | Series: ${highlight[0].seriesId}` : ''}` + : 'Long-press over a bar...'} + + + + + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + {/* First BarPlot - rendered first (underneath) */} + + {/* Second BarPlot - rendered second (on top) */} + + + + + + + Revenue (underneath) + + + + Profit Margin (on top) + + + + ); +}; + +/** + * Synchronized highlighting across multiple charts + */ +const SynchronizedCharts = () => { + const theme = useTheme(); + const [highlight, setHighlight] = useState(undefined); + + return ( + + + Interact with either chart and both will highlight the same data point. + + + + {xAxisData.map((label, index) => ( + + ))} + + + + + + Highlighted index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== null && + highlight?.[0]?.dataIndex !== undefined && + ` (A: ${seriesA[highlight[0].dataIndex]}, B: ${seriesB[highlight[0].dataIndex]})`} + + + + + + + + + + + + + + + + + + ); +}; + +/** + * Highlighting disabled + */ +const HighlightingDisabled = () => { + const theme = useTheme(); + + return ( + + + Set enableHighlighting=false to disable all highlighting. + + + + + ); +}; + +/** + * Backwards compatibility with legacy props + */ +const BackwardsCompatibility = () => { + const theme = useTheme(); + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + return ( + + + Legacy enableScrubbing and onScrubberPositionChange props still work. + + + + Scrubber Position: {scrubberPosition ?? 'none'} + + + + + + + ); +}; + +const InteractionStories = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default InteractionStories; diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index c79187070..96e7ebcdd 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -10,15 +10,15 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, - defaultChartInset, + type CartesianSeries, + defaultCartesianChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -115,12 +115,15 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index 7a156cb9d..f38214c7e 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -71,6 +71,11 @@ export type BarBaseProps = { * Component to render the bar. */ BarComponent?: BarComponent; + /** + * Whether non-highlighted bars should fade when highlighting is active. + * @default false + */ + fadeOnHighlight?: boolean; }; export type BarProps = BarBaseProps & { @@ -120,6 +125,7 @@ export const Bar = memo( roundTop = true, roundBottom = true, transition, + fadeOnHighlight, }) => { const theme = useTheme(); @@ -145,6 +151,7 @@ export const Bar = memo( d={barPath} dataX={dataX} dataY={dataY} + fadeOnHighlight={fadeOnHighlight} fill={effectiveFill} 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 e19577fda..da967267b 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -7,7 +7,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, defaultStackId, getChartInset } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + defaultStackId, + getChartInset, +} from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; import type { BarSeries } from './BarStack'; @@ -27,6 +32,7 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. @@ -91,11 +97,15 @@ export const BarChart = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, ...chartProps }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); const transformedSeries = useMemo(() => { if (!stacked || !series) return series; @@ -175,6 +185,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 7d24fefd9..8a66b39f5 100644 --- a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx @@ -21,6 +21,7 @@ export type BarPlotBaseProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'fadeOnHighlight' > & { /** * Array of series IDs to render. @@ -52,6 +53,7 @@ export const BarPlot = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); @@ -125,6 +127,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 6bc56b4a9..be0f43744 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series, Transition } from '../utils'; +import type { CartesianSeries, ChartScaleFunction, Transition } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; @@ -15,7 +15,7 @@ const EPSILON = 1e-4; /** * Extended series type that includes bar-specific properties. */ -export type BarSeries = Series & { +export type BarSeries = CartesianSeries & { /** * Custom component to render bars for this series. */ @@ -24,7 +24,7 @@ export type BarSeries = Series & { export type BarStackBaseProps = Pick< BarProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -141,6 +141,7 @@ export const BarStack = memo( stackMinSize, roundBaseline, transition, + fadeOnHighlight, }) => { const theme = useTheme(); const { getSeriesData, getXAxis, getXScale } = useCartesianChartContext(); @@ -682,6 +683,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={dataX} dataY={bar.dataY} + fadeOnHighlight={fadeOnHighlight} fill={bar.fill} fillOpacity={defaultFillOpacity} height={bar.height} diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 2ce56d065..343236114 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -18,6 +18,7 @@ export type BarStackGroupProps = Pick< | 'stackMinSize' | 'BarStackComponent' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index ff9bd8014..f752ac99b 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -1,7 +1,9 @@ -import { memo, useMemo } from 'react'; +import { memo, useEffect, useMemo } from 'react'; +import { Easing, useDerivedValue, withTiming } from 'react-native-reanimated'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; +import { useHighlightContext } from '../HighlightProvider'; import { Path } from '../Path'; import { getBarPath } from '../utils'; @@ -9,8 +11,13 @@ import type { BarComponentProps } from './Bar'; export type DefaultBarProps = BarComponentProps; +const FADED_OPACITY_FACTOR = 0.3; +const FADE_ANIMATION_CONFIG = { duration: 250, easing: Easing.inOut(Easing.ease) }; + /** * Default bar component that renders a solid bar with animation support. + * Registers bounds for series highlighting hit testing when `highlightScope.series` is enabled. + * Supports animated fade via `fadeOnHighlight` prop. */ export const DefaultBar = memo( ({ @@ -27,11 +34,57 @@ export const DefaultBar = memo( stroke, strokeWidth, originY, + dataX, + seriesId, transition, + fadeOnHighlight, }) => { const { animate } = useCartesianChartContext(); + const highlightContext = useHighlightContext(); const theme = useTheme(); + const { scope } = highlightContext; + const dataIndex = typeof dataX === 'number' ? dataX : null; + + // Register bar bounds for hit testing when series highlighting is enabled + useEffect(() => { + if (!highlightContext.scope.series || !seriesId) return; + + const idx = typeof dataX === 'number' ? dataX : 0; + + highlightContext.registerBar({ + x, + y, + width, + height, + dataIndex: idx, + seriesId, + }); + + return () => { + highlightContext.unregisterBar(seriesId, idx); + }; + }, [x, y, width, height, dataX, seriesId, highlightContext]); + + // Animated opacity based on highlight state + const effectiveOpacity = useDerivedValue(() => { + if (!fadeOnHighlight || !highlightContext.enabled) return fillOpacity; + + const items = highlightContext.highlight.value; + + let targetOpacity = fillOpacity; + if (items.length > 0) { + const isHighlighted = items.some((item) => { + const indexMatch = !scope.dataIndex || item.dataIndex === dataIndex; + const seriesMatch = !scope.series || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + targetOpacity = isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + } + + return withTiming(targetOpacity, FADE_ANIMATION_CONFIG); + }, [fadeOnHighlight, highlightContext.enabled, fillOpacity, scope, dataIndex, seriesId]); + const defaultFill = fill || theme.color.fgPrimary; const targetPath = useMemo(() => { @@ -76,7 +129,7 @@ export const DefaultBar = memo( clipPath={null} d={targetPath} fill={stroke ? 'none' : defaultFill} - fillOpacity={fillOpacity} + fillOpacity={fadeOnHighlight ? effectiveOpacity : fillOpacity} initialPath={initialPath} stroke={stroke} strokeWidth={strokeWidth} 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 ba10f6d9d..4512b2835 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { Text } from 'react-native'; import { Button } from '@coinbase/cds-mobile/buttons'; import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; @@ -6,6 +7,7 @@ import { VStack } from '@coinbase/cds-mobile/layout'; import { XAxis, YAxis } from '../../axis'; import { CartesianChart } from '../../CartesianChart'; +import type { HighlightedItem } from '../../utils/highlight'; import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line'; import { Bar } from '../Bar'; import { BarChart } from '../BarChart'; @@ -612,9 +614,191 @@ const BandGridPositionExample = ({ ); +const BasicHighlighting = () => { + const theme = useTheme(); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const data = [45, 80, 120, 95, 150, 110, 85]; + const [info, setInfo] = useState('Long press and drag to highlight'); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + setInfo(`${days[item.dataIndex]}: ${data[item.dataIndex]} visits`); + } else { + setInfo('Long press and drag to highlight'); + } + }, + [days, data], + ); + + return ( + + {info} + + + ); +}; + +const FadeOnHighlight = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +}; + +const FadeWithSeriesScope = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const [info, setInfo] = useState('Long press a bar to highlight'); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const series = item.seriesId ?? 'all'; + setInfo(`${months[item.dataIndex]} — Series: ${series}`); + } else { + setInfo('Long press a bar to highlight'); + } + }, + [months], + ); + + return ( + + {info} + + + ); +}; + +const FadeOnHighlightStacked = () => { + const theme = useTheme(); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + return ( + + ); +}; + const BarChartStories = () => { return ( + + + + + + + + + + + + diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 40620f86c..403e8f7cd 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -6,6 +6,7 @@ export * from './CartesianChart'; export * from './ChartContextBridge'; export * from './ChartProvider'; export * from './gradient'; +export * from './HighlightProvider'; export * from './legend'; export * from './line'; export * from './Path'; diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx index 5890ad80b..2c51e4890 100644 --- a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -14,7 +14,7 @@ import { BarChart, BarPlot, DefaultBar } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { LineChart } from '../../line'; import { Scrubber } from '../../scrubber'; -import type { LegendShapeVariant, Series } from '../../utils/chart'; +import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart'; import { getDottedAreaPath } from '../../utils/path'; import { DefaultLegendShape } from '../DefaultLegendShape'; import { Legend, type LegendEntryProps } from '../Legend'; @@ -231,7 +231,7 @@ const DynamicData = () => { [], ); - const seriesConfig: Series[] = useMemo( + const seriesConfig: CartesianSeries[] = useMemo( () => [ { id: 'candidate-a', diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index 2abdc2b9e..6c7c13b21 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -8,11 +8,16 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type AxisConfigProps, + type CartesianSeries, + defaultCartesianChartInset, + getChartInset, +} from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -106,12 +111,15 @@ export const LineChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, 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 96d6619a9..5ef1b7722 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { View } from 'react-native'; import { + runOnJS, useAnimatedReaction, useDerivedValue, useSharedValue, @@ -46,18 +47,14 @@ import { CartesianChart } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector'; import { Point } from '../../point'; -import { - DefaultScrubberBeacon, - Scrubber, - type ScrubberBeaconProps, - type ScrubberRef, -} from '../../scrubber'; +import { Scrubber, type ScrubberBeaconProps, type ScrubberRef } from '../../scrubber'; import { type AxisBounds, buildTransition, defaultTransition, getLineData, getPointOnSerializableScale, + type HighlightedItem, projectPointWithSerializableScale, type Transition, unwrapAnimatedValue, @@ -1891,56 +1888,434 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], + const Example = memo(() => { + const defaultHighlight: HighlightedItem[] = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [], ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], ); - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); - - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); - return ( - - - - - - - - + + + + + + ); }); + return ; +} + +// Watches scrubberPosition on the UI thread and only fires a JS callback +// when the active month segment changes, avoiding per-pixel re-renders. +const MonthTracker = memo( + ({ + dataPointsPerMonth, + onMonthChange, + }: { + dataPointsPerMonth: number; + onMonthChange: (month: number | undefined) => void; + }) => { + const { scrubberPosition } = useScrubberContext(); + + const currentSection = useDerivedValue(() => { + const pos = scrubberPosition.value; + if (pos === undefined) return -1; + return Math.floor(pos / dataPointsPerMonth); + }, [dataPointsPerMonth]); + + useAnimatedReaction( + () => currentSection.value, + (current, previous) => { + if (current !== previous) { + runOnJS(onMonthChange)(current === -1 ? undefined : current); + } + }, + ); + + return null; + }, +); + +function HighlightLineSegments() { + const chartPrices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const dataPointsPerMonth = 30; + const [currentMonth, setCurrentMonth] = useState(undefined); + + const handleMonthChange = useCallback((month: number | undefined) => { + setCurrentMonth(month); + }, []); + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, chartPrices.length - 1) + : undefined; + + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x' as const, + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: chartPrices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < chartPrices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: chartPrices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x' as const, stops }; + }, [monthStart, monthEnd, chartPrices.length]); + return ( - - - - - - + + + ); } +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + type MemoizedChartProps = { + highlight: HighlightedItem[]; + data: number[]; + isScrubbing: boolean; + onHighlightChange: (items: HighlightedItem[]) => void; + scrubberLabel: (index: number) => string; + }; + + const chartTransition = useMemo(() => ({ type: 'timing', duration: 150 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }: { min: number; max: number }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + // Controlled highlight: [] = nothing highlighted, [{dataIndex}] = highlight shown + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues: number[] = []; + const sampledTimestamps: Date[] = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + // Show full data when scrubbing, sampled when idle + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + // Refs for stable callback to avoid stale closures + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + // Entering scrubbing: dataIndex is relative to sampled data. + // Proportionally map so the scrubber stays at the same visual + // position after switching from sampled to full data. + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + // Already scrubbing: index is relative to full data + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date, periodId: string) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index: number) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + // Price display: when scrubbing, look up directly in full data by index + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + ); + }); + + return ; +} + function DataCardWithLineChart() { const { spectrum } = useTheme(); const exampleThumbnail = ( @@ -2308,6 +2683,14 @@ function ExampleNavigator() { title: 'In DataCard', component: , }, + { + title: 'Highlight Line Segments', + component: , + }, + { + title: 'Adaptive Detail', + component: , + }, ], [theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50], ); diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index c256f0460..c6d475e7d 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx @@ -122,9 +122,9 @@ export type ScrubberBeaconProps = { stroke?: string; }; -export type ScrubberBeaconComponent = React.FC< - ScrubberBeaconProps & { ref?: React.Ref } ->; +export type ScrubberBeaconComponent = ( + props: ScrubberBeaconProps & { ref?: React.Ref }, +) => React.ReactNode; export type ScrubberBeaconLabelProps = Pick & Pick< diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..eab93f7bf 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,19 +1,19 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartDomain, getChartInset, getChartRange, getStackedSeriesData, isValidBounds, - type Series, } from '../chart'; describe('getChartDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; @@ -23,7 +23,7 @@ describe('getChartDomain', () => { }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; @@ -33,14 +33,14 @@ describe('getChartDomain', () => { }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; const result = getChartDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); @@ -52,14 +52,14 @@ describe('getChartDomain', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; @@ -69,7 +69,7 @@ describe('getChartDomain', () => { }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, @@ -82,7 +82,7 @@ describe('getChartDomain', () => { describe('getStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; @@ -103,7 +103,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -125,7 +125,7 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -144,7 +144,7 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -166,7 +166,7 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; const result = getStackedSeriesData(series); @@ -179,14 +179,14 @@ describe('getStackedSeriesData', () => { }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId @@ -205,14 +205,14 @@ describe('getStackedSeriesData', () => { describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; @@ -222,7 +222,7 @@ describe('getChartRange', () => { }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -245,7 +245,7 @@ describe('getChartRange', () => { }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -260,14 +260,14 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; @@ -286,35 +286,35 @@ describe('getChartRange', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -370,9 +370,9 @@ describe('isValidBounds', () => { }); }); -describe('defaultChartInset', () => { +describe('defaultCartesianChartInset', () => { it('should have correct default values', () => { - expect(defaultChartInset).toEqual({ + expect(defaultCartesianChartInset).toEqual({ top: 32, left: 16, bottom: 16, diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 46ee06bc4..08bb0eea2 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -2,10 +2,20 @@ import { isSharedValue } from 'react-native-reanimated'; import type { AnimatedProp } from '@shopify/react-native-skia'; import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import type { HighlightScope } from './highlight'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Default highlight scope for cartesian charts. + * Highlights by data index (x-axis position), not by series. + */ +export const defaultCartesianChartHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + /** * Shape variants available for legend items. */ @@ -34,11 +44,38 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +/** + * Base series type with common properties shared across all chart types. + * Used by generic chart components like HighlightProvider. + */ export type Series = { /** - * Id of the series. + * Unique identifier for the series. */ id: string; + /** + * Label of the series. + * Used for scrubber beacon labels and legend items. + */ + label?: string; + /** + * Color of the series. + * If gradient is provided, that will be used for chart components. + * Color will still be used by scrubber beacon labels. + */ + color?: string; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; +}; + +/** + * Series type for cartesian (X/Y) charts with axis-specific properties. + */ +export type CartesianSeries = Series & { /** * Data array for this series. Use null values to create gaps in the visualization. * @@ -47,17 +84,6 @@ export type Series = { * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs */ data?: Array | Array<[number, number] | null>; - /** - * Label of the series. - * Used for scrubber beacon labels. - */ - 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 - */ - color?: string; /** * Color gradient configuration. * Takes precedence over color except for scrubber beacon labels. @@ -74,12 +100,6 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; - /** - * Shape of the legend item for this series. - * Can be a preset shape variant or a custom ReactNode. - * @default 'circle' - */ - legendShape?: LegendShape; }; /** @@ -87,7 +107,7 @@ export type Series = { * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -116,7 +136,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -132,7 +152,7 @@ const createStackKey = (series: Series): string | undefined => { * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( - series: Series[], + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -242,7 +262,7 @@ export const getLineData = ( * Handles stacking by transforming data when series have stack properties. */ export const getChartRange = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -329,7 +349,7 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultCartesianChartInset: ChartInset = { top: 32, left: 16, bottom: 16, diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index d6372574b..375699d5c 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -4,41 +4,73 @@ import type { Rect } from '@coinbase/cds-common/types'; import type { SkTypefaceFontProvider } from '@shopify/react-native-skia'; import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { CartesianSeries, Series } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; +/** + * Supported chart types. + */ +export type ChartType = 'cartesian'; + +/** + * Base context value shared by all chart types. + * Contains common properties like series and dimensions. + */ +export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + */ + series: Series[]; + /** + * Whether to animate the chart. + */ + animate: boolean; + /** + * Width of the chart. + */ + width: number; + /** + * Height of the chart. + */ + height: number; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; + /** + * Length of the data domain. + */ + dataLength: number; +}; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = { +export type CartesianChartContextValue = Omit & { + /** + * The chart type (always 'cartesian' for this context). + */ + type: 'cartesian'; /** * The series data for the chart. */ - series: Series[]; + series: CartesianSeries[]; /** * Returns the series which matches the seriesId or undefined. * @param seriesId - A series' id */ - getSeries: (seriesId?: string) => Series | undefined; + getSeries: (seriesId?: string) => CartesianSeries | undefined; /** * Returns the data for a series * @param seriesId - A series' id * @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. @@ -75,16 +107,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. 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 000000000..34277ce86 --- /dev/null +++ b/packages/mobile-visualization/src/chart/utils/highlight.ts @@ -0,0 +1,45 @@ +/** + * Controls what aspects of the data can be highlighted. + */ +export type HighlightScope = { + /** + * Whether highlighting tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether highlighting tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * 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 (x-axis position) being highlighted. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being highlighted. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; + +/** + * Bounds of a bar element for hit testing. + * Used for coordinate-based hit testing since Skia doesn't have native touch events. + */ +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 0bf7ad953..ff6f8088a 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/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index 97aaa2007..9e8e6e487 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -346,7 +346,7 @@ export const TextInput = memo( importantForAccessibility={startIconA11yLabel ? 'auto' : 'no'} onPress={handleNodePress} > - + {compact && (labelNode ? labelNode : !!label && {label})} {!!start && ( diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 2e636aa7c..b90ef945c 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,25 +5,28 @@ 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 { Legend, type LegendProps } from './legend'; +import { type HighlightProps, HighlightProvider } from './HighlightProvider'; +import { Legend } from './legend'; import { type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, + type CartesianSeries, type ChartInset, type ChartScaleFunction, defaultAxisId, - defaultChartInset, + defaultCartesianChartHighlightScope, + defaultCartesianChartInset, getAxisConfig, getAxisDomain, getAxisRange, getAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type HighlightedItem, + type HighlightScope, type LegendPosition, - type Series, useTotalAxisPadding, } from './utils'; @@ -37,13 +40,13 @@ const focusStylesCss = css` } `; -export type CartesianChartBaseProps = BoxBaseProps & - Pick & { +export type CartesianChartBaseProps = Omit & + Omit & { /** * Configuration objects that define how to visualize the data. * Each series contains its own data array. */ - series?: Array; + series?: Array; /** * Whether to animate the chart. * @default true @@ -78,9 +81,28 @@ 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 highlighted item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((item: HighlightedItem) => string); + /** + * Controls what aspects of the data can be highlighted. + * @default { dataIndex: true, series: false } + */ + highlightScope?: HighlightScope; + /** + * @deprecated Use `enableHighlighting={false}` instead. Will be removed in next major version. + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onHighlightChange` instead. Will be removed in next major version. + */ + onScrubberPositionChange?: (index: number | undefined) => void; }; -export type CartesianChartProps = Omit, 'title'> & +export type CartesianChartProps = Omit, 'title' | 'accessibilityLabel'> & CartesianChartBaseProps & { /** * Custom class name for the root element. @@ -128,8 +150,15 @@ export const CartesianChart = memo( xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, + // Highlight props + enableHighlighting, + highlightScope = defaultCartesianChartHighlightScope, + highlight, + onHighlightChange, + // Legacy props enableScrubbing, onScrubberPositionChange, + // Back to regular props legend, legendPosition = 'bottom', legendAccessibilityLabel, @@ -147,7 +176,10 @@ export const CartesianChart = memo( const { observe, width: chartWidth, height: chartHeight } = useDimensions(); const svgRef = useRef(null); - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Axis configs store the properties of each axis, such as id, scale type, domain limit, etc. // We only support 1 x axis but allow for multiple y axes. @@ -368,6 +400,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -409,6 +442,27 @@ export const CartesianChart = memo( ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); + // Resolve enableHighlighting (backwards compatibility with enableScrubbing) + const resolvedEnableHighlighting = useMemo(() => { + if (enableHighlighting !== undefined) return enableHighlighting; + if (enableScrubbing !== undefined) return enableScrubbing; + return false; // Default to disabled + }, [enableHighlighting, enableScrubbing]); + + // Wrap onHighlightChange to also call legacy onScrubberPositionChange + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + // Legacy callback support + if (onScrubberPositionChange) { + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onScrubberPositionChange], + ); + + const isHighlightingEnabled = resolvedEnableHighlighting; const legendElement = useMemo(() => { if (!legend) return; @@ -457,7 +511,6 @@ export const CartesianChart = memo( } } }} - accessibilityLabel={accessibilityLabel} aria-live="polite" as="svg" className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} @@ -471,20 +524,22 @@ export const CartesianChart = memo( ); + // Determine flex direction based on legend position + const isLegendVertical = legendPosition === 'left' || legendPosition === 'right'; + const legendFlexDirection = isLegendVertical ? 'row' : 'column'; + return ( - {legend ? ( - + {(legendPosition === 'top' || legendPosition === 'left') && legendElement} {chartContent} {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} @@ -492,7 +547,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 6cb100f2b..be47a9065 100644 --- a/packages/web-visualization/src/chart/ChartProvider.tsx +++ b/packages/web-visualization/src/chart/ChartProvider.tsx @@ -1,11 +1,30 @@ 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.). + * Use this when you only need base chart properties like series, dimensions, 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 like axes and scales. + */ 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 000000000..79c9e5cf0 --- /dev/null +++ b/packages/web-visualization/src/chart/HighlightProvider.tsx @@ -0,0 +1,477 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { useCartesianChartContext } from './ChartProvider'; +import { isCategoricalScale, ScrubberContext, type ScrubberContextValue } from './utils'; +import type { HighlightedItem, HighlightScope } from './utils/highlight'; + +/** + * 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[]; + /** + * Callback to replace the entire highlight state. + * Used by keyboard navigation and external consumers. + */ + setHighlight: (items: HighlightedItem[]) => void; + /** + * Merge a partial update into a specific pointer's highlight entry. + * Only updates the fields provided, leaving other fields untouched. + * Used by bar elements to set/clear seriesId on pointer enter/leave. + */ + updatePointerHighlight: (pointerId: number, partial: Partial) => void; + /** + * Remove a specific pointer's entry from highlight state. + * Used when a pointer leaves the chart or is released. + */ + removePointer: (pointerId: number) => void; +}; + +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; +}; + +/** + * Props for configuring chart highlight behavior. + * Used by CartesianChart and other chart components. + */ +export type HighlightProps = { + /** + * 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; +}; + +export type HighlightProviderProps = HighlightProps & { + children: React.ReactNode; + /** + * A reference to the root SVG element, where interaction event handlers will be attached. + */ + svgRef: React.RefObject | null; + /** + * 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 | ((item: HighlightedItem) => string); +}; + +const DEFAULT_ITEM: HighlightedItem = { dataIndex: null, seriesId: null }; + +/** + * HighlightProvider manages chart highlight state and input handling. + * Uses Pointer Events for unified mouse/touch interaction with per-pointer state tracking. + */ +export const HighlightProvider: React.FC = ({ + children, + svgRef, + enableHighlighting: enableHighlightingProp, + highlightScope: scopeProp, + highlight: controlledHighlight, + onHighlightChange, + accessibilityLabel, +}) => { + const chartContext = useCartesianChartContext(); + + if (!chartContext) { + throw new Error('HighlightProvider must be used within a ChartContext'); + } + + const { getXScale, getXAxis, 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]); + + const highlight: HighlightedItem[] = useMemo(() => { + if (isControlled) { + return controlledHighlight; + } + return internalHighlight; + }, [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] ?? DEFAULT_ITEM; + const updated = { ...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; + }); + }, []); + + // Convert X coordinate to data index + const getDataIndexFromX = useCallback( + (mouseX: number): number => { + const xScale = getXScale(); + const xAxis = getXAxis(); + + if (!xScale || !xAxis) return 0; + + if (isCategoricalScale(xScale)) { + const categories = xScale.domain?.() ?? xAxis.data ?? []; + const bandwidth = xScale.bandwidth?.() ?? 0; + let closestIndex = 0; + let closestDistance = Infinity; + for (let i = 0; i < categories.length; i++) { + const xPos = xScale(i); + if (xPos !== undefined) { + const distance = Math.abs(mouseX - (xPos + bandwidth / 2)); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + } + return closestIndex; + } else { + const axisData = xAxis.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 xValue = numericData[i]; + const xPos = xScale(xValue); + if (xPos !== undefined) { + const distance = Math.abs(mouseX - xPos); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + } + return closestIndex; + } else { + const xValue = xScale.invert(mouseX); + const dataIndex = Math.round(xValue); + const domain = xAxis.domain; + return Math.max(domain.min ?? 0, Math.min(dataIndex, domain.max ?? 0)); + } + } + }, + [getXScale, getXAxis], + ); + + // --- 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 x = event.clientX - rect.left; + const dataIndex = scope.dataIndex ? getDataIndexFromX(x) : null; + updatePointerHighlight(event.pointerId, { dataIndex }); + }, + [enabled, series, scope.dataIndex, getDataIndexFromX, 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; + + const xScale = getXScale(); + const xAxis = getXAxis(); + + if (!xScale || !xAxis) return; + + const isBand = isCategoricalScale(xScale); + + let minIndex: number; + let maxIndex: number; + + if (isBand) { + const categories = xScale.domain?.() ?? xAxis.data ?? []; + minIndex = 0; + maxIndex = Math.max(0, categories.length - 1); + } else { + const axisData = xAxis.data; + if (axisData && Array.isArray(axisData)) { + minIndex = 0; + maxIndex = Math.max(0, axisData.length - 1); + } else { + const domain = xAxis.domain; + minIndex = domain.min ?? 0; + maxIndex = domain.max ?? 0; + } + } + + const currentItem = highlight[0] ?? DEFAULT_ITEM; + const currentIndex = currentItem.dataIndex ?? 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 'ArrowLeft': + event.preventDefault(); + newIndex = Math.max(minIndex, currentIndex - stepSize); + break; + case 'ArrowRight': + 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, getXScale, getXAxis, highlight, setHighlight], + ); + + const handleBlur = useCallback(() => { + if (!enabled || highlight.length === 0) return; + setHighlight([]); + }, [enabled, 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) return; + + const currentItem = highlight[0]; + + if (currentItem && currentItem.dataIndex !== null) { + svg.setAttribute('aria-label', accessibilityLabel(currentItem)); + } 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(() => { + if (!enabled) return undefined; + return highlight[0]?.dataIndex ?? undefined; + }, [enabled, highlight]); + + const scrubberContextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing: enabled, + scrubberPosition, + onScrubberPositionChange: (index: number | undefined) => { + if (!enabled) return; + if (index === undefined) { + setHighlight([]); + } else { + setHighlight([{ dataIndex: index, seriesId: null }]); + } + }, + }), + [enabled, scrubberPosition, setHighlight], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx new file mode 100644 index 000000000..ecb19edb4 --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -0,0 +1,714 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { prices } from '@coinbase/cds-common/internal/data/prices'; +import { Button } from '@coinbase/cds-web/buttons'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { XAxis, YAxis } from '../axis'; +import { BarChart, BarPlot } from '../bar'; +import { CartesianChart } from '../CartesianChart'; +import { useCartesianChartContext } from '../ChartProvider'; +import { Line, LineChart, ReferenceLine, SolidLine } from '../line'; +import { Scrubber } from '../scrubber'; +import { useHighlightContext } from '../HighlightProvider'; +import type { HighlightedItem } from '../utils'; +import { useScrubberContext } from '../utils'; + +export default { + title: 'Components/Chart/Interaction', +}; + +// Sample data - convert string prices to numbers +const samplePrices = prices.slice(0, 30).map(Number); + +const formatPrice = (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + }).format(value); + +/** + * Basic highlighting with the new API + */ +export function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); + + const accessibilityLabel = useCallback((item: HighlightedItem) => { + if (item.dataIndex === null) return 'Interacting with chart'; + return `Day ${item.dataIndex + 1}: ${formatPrice(samplePrices[item.dataIndex])}`; + }, []); + + return ( + + + Basic Highlighting + + + Hover or touch the chart to see highlight state. + + + + + Active:{' '} + {highlight.length > 0 ? `dataIndex: ${highlight[0]?.dataIndex}` : 'Not interacting'} + + + + + + + + ); +} + +/** + * Controlled state - programmatically set the highlighted item + */ +export function ControlledState() { + // null = controlled mode with no highlights + // HighlightedItem[] = controlled mode with specific highlights + const [highlight, setHighlight] = useState(undefined); + + return ( + + + Controlled State + + + Use buttons to programmatically select data points. Pass null to clear without listening to + user input. + + + + + + + + + + + + Index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== undefined && + highlight[0].dataIndex !== null && + ` (${formatPrice(samplePrices[highlight[0].dataIndex])})`} + + + + + + + + ); +} + +/** + * Highlighting disabled + */ +export function HighlightingDisabled() { + return ( + + + Highlighting Disabled + + + Set enableHighlighting=false to disable all highlighting. + + + + + ); +} + +/** + * Backwards compatibility with legacy props + */ +export function BackwardsCompatibility() { + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + return ( + + + Backwards Compatibility + + + Legacy enableScrubbing and onScrubberPositionChange props still work. + + + + Scrubber Position: {scrubberPosition ?? 'none'} + + + + + + + ); +} + +/** + * Static vs Dynamic accessibility label + */ +export function AccessibilityLabels() { + return ( + + + + Static Accessibility Label (string) + + + + + + + + + Dynamic Accessibility Label (function) + + + item.dataIndex !== null + ? `Day ${item.dataIndex + 1}: ${formatPrice(samplePrices[item.dataIndex])}` + : 'Interacting with chart' + } + height={200} + series={[{ id: 'price', data: samplePrices }]} + > + + + + + ); +} + +/** + * Multi-series chart with highlighting + */ +export function MultiSeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + const series1Data = useMemo(() => samplePrices, []); + const series2Data = useMemo(() => samplePrices.map((p) => p * 0.8 + Math.random() * 1000), []); + + return ( + + + Multi-Series Highlighting + + + + + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.dataIndex !== undefined && highlight[0].dataIndex !== null && ( + <> + {' '} + | BTC: {formatPrice(series1Data[highlight[0].dataIndex])} | ETH:{' '} + {formatPrice(series2Data[highlight[0].dataIndex])} + + )} + + + + + + + + `Day ${dataIndex + 1}`} /> + + + ); +} + +/** + * Highlight callback details + */ +export function HighlightCallbackDetails() { + const [events, setEvents] = useState([]); + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + const event = item + ? `{ dataIndex: ${item.dataIndex}, seriesId: ${item.seriesId ?? 'null'} }` + : '[]'; + setEvents((prev) => [...prev.slice(-9), event]); + }, []); + + return ( + + + Highlight Callback Details + + + + + Recent events: + + {events.length === 0 ? ( + + Interact with the chart... + + ) : ( + events.map((event, i) => ( + + {event} + + )) + )} + + + + + + + ); +} + +/** + * Multi-touch highlighting with reference lines + */ +export function MultiTouchHighlighting() { + const [highlight, setHighlight] = useState([]); + + // Custom component that renders a ReferenceLine for each highlighted touch point + const MultiTouchReferenceLines = memo(() => { + const { highlight: items } = useHighlightContext(); + + // Different colors for each touch point + const colors = [ + 'var(--color-fgPrimary)', + 'var(--color-fgPositive)', + 'var(--color-fgNegative)', + 'var(--color-fgWarning)', + ]; + + return ( + <> + {items.map((item, index) => + item.dataIndex !== null ? ( + + ) : null, + )} + + ); + }); + + return ( + + + Multi-Touch Highlighting + + + Use multiple fingers on a touch device to see multiple reference lines. Each touch point + gets a different color. + + + + + Active touches: {highlight.length} + {highlight.length > 0 && + ` (${highlight.map((item) => `Day ${(item.dataIndex ?? 0) + 1}`).join(', ')})`} + + + + + + + + ); +} + +// Shared data for synchronized charts example (from MUI example) +const xAxisData = ['0', '2', '5', '10', '20']; +const seriesA = [3, 4, 1, 6, 5]; +const seriesB = [4, 3, 1, 5, 8]; + +// Custom component that highlights the entire bar bandwidth +const BandwidthHighlight = memo(() => { + const { getXScale, drawingArea } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + const xScale = getXScale(); + + if (!xScale || scrubberPosition === undefined || !drawingArea) return null; + + const xPos = xScale(scrubberPosition); + // Type guard to check if scale has bandwidth (band scale) + const bandwidth = 'bandwidth' in xScale ? xScale.bandwidth() : 0; + + if (xPos === undefined) return null; + + return ( + + ); +}); + +/** + * Synchronized highlighting across multiple charts + */ +export function SynchronizedCharts() { + const [highlight, setHighlight] = useState(undefined); + + return ( + + + Synchronized Charts + + + Interact with either chart and both will highlight the same data point. Similar to MUI + highlightedAxis behavior. + + + + {xAxisData.map((label, index) => ( + + ))} + + + + + + Highlighted index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== null && + highlight?.[0]?.dataIndex !== undefined && + ` (A: ${seriesA[highlight[0].dataIndex]}, B: ${seriesB[highlight[0].dataIndex]})`} + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Series highlighting - track which specific bar/series is being hovered + */ +export function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + const seriesColors: Record = { + A: 'var(--color-fgPrimary)', + B: 'var(--color-fgPositive)', + C: 'var(--color-fgWarning)', + }; + + return ( + + + Series Highlighting + + + Hover over individual bars to see both dataIndex and seriesId tracked. Uses InteractiveBar + component. + + + + + {highlight.length > 0 ? ( + <> + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( + <> + {' '} + | Series:{' '} + + {highlight[0].seriesId} + + + )} + + ) : ( + 'Hover over a bar...' + )} + + + + + + ); +} + +/** + * Test overlapping bars with separate BarPlots to verify z-order behavior + */ +export function OverlappingBarsZOrder() { + const [highlight, setHighlight] = useState([]); + const [eventLog, setEventLog] = useState([]); + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + setHighlight(items); + + // Log the event + if (item) { + const logEntry = `${new Date().toLocaleTimeString()}: dataIndex=${item.dataIndex}, seriesId=${item.seriesId ?? 'null'}`; + setEventLog((prev) => [logEntry, ...prev.slice(0, 9)]); + } + }, []); + + const seriesColors: Record = { + revenue: 'var(--color-fgWarning)', + profitMargin: 'rgba(0, 255, 0, 0.25)', + }; + + return ( + + + Overlapping Bars Z-Order Test + + + Two separate BarPlots with different y-axes. The bars overlap at the same x positions. Hover + to see which series is detected. The second BarPlot (profitMargin/green) is rendered on top. + + + + + {highlight.length > 0 ? ( + <> + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( + <> + {' '} + | Series:{' '} + + {highlight[0].seriesId} + + + )} + + ) : ( + 'Hover over a bar...' + )} + + + + + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + {/* First BarPlot - rendered first (underneath) */} + + {/* Second BarPlot - rendered second (on top) */} + + + + + + + Revenue (rendered first - underneath) + + + + Profit Margin (rendered second - on top) + + + + + + Event Log (most recent first): + + {eventLog.length === 0 ? ( + + Interact with the chart... + + ) : ( + eventLog.map((log, i) => ( + + {log} + + )) + )} + + + ); +} diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 7d3f9c333..8f80c5a09 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -9,15 +9,15 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, - defaultChartInset, + type CartesianSeries, + defaultCartesianChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -114,12 +114,15 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); - // Convert AreaSeries to Series for Chart context + // Convert AreaSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index dc6e5d873..0be2e188e 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -42,7 +42,7 @@ export type BarBaseProps = { */ originY?: number; /** - * The x-axis data value for this bar. + * The x-axis data index for this bar. */ dataX?: number | string; /** @@ -73,6 +73,11 @@ export type BarBaseProps = { * Component to render the bar. */ BarComponent?: BarComponent; + /** + * Whether non-highlighted bars should fade when highlighting is active. + * @default false + */ + fadeOnHighlight?: boolean; }; export type BarProps = BarBaseProps & { @@ -122,6 +127,7 @@ export const Bar = memo( roundTop = true, roundBottom = true, transition, + fadeOnHighlight, }) => { const barPath = useMemo(() => { return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); @@ -139,6 +145,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 6aa15d856..6a712d70b 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -6,7 +6,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, defaultStackId, getChartInset } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + defaultStackId, + getChartInset, +} from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; import type { BarSeries } from './BarStack'; @@ -26,6 +31,7 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. @@ -90,11 +96,15 @@ export const BarChart = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, ...chartProps }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); const transformedSeries = useMemo(() => { if (!stacked || !series) return series; @@ -174,6 +184,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 08e1c21a6..3a9fcceb5 100644 --- a/packages/web-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/web-visualization/src/chart/bar/BarPlot.tsx @@ -20,6 +20,7 @@ export type BarPlotBaseProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'fadeOnHighlight' > & { /** * Array of series IDs to render. @@ -51,6 +52,7 @@ export const BarPlot = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); @@ -120,6 +122,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 64453e980..fed996e13 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series } from '../utils'; +import type { CartesianSeries, ChartScaleFunction } from '../utils'; import { evaluateGradientAtValue, getGradientConfig } from '../utils/gradient'; import { Bar, type BarComponent, type BarProps } from './Bar'; @@ -14,7 +14,7 @@ const EPSILON = 1e-4; /** * Extended series type that includes bar-specific properties. */ -export type BarSeries = Series & { +export type BarSeries = CartesianSeries & { /** * Custom component to render bars for this series. */ @@ -23,7 +23,7 @@ export type BarSeries = Series & { export type BarStackBaseProps = Pick< BarProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -140,6 +140,7 @@ export const BarStack = memo( stackMinSize, roundBaseline, transition, + fadeOnHighlight, }) => { const { getSeriesData, getXAxis, getXScale, getSeries } = useCartesianChartContext(); @@ -691,6 +692,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={dataX} dataY={bar.dataY} + fadeOnHighlight={fadeOnHighlight} fill={bar.fill} fillOpacity={bar.fillOpacity ?? defaultFillOpacity} height={bar.height} diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 31ea2e64a..dd59a7314 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -20,6 +20,7 @@ export type BarStackGroupProps = Pick< | 'stackMinSize' | 'BarStackComponent' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index 4382b81b6..c59c4f9fd 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,11 +1,19 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { m as motion } from 'framer-motion'; +import { css } from '@linaria/core'; import { useCartesianChartContext } from '../ChartProvider'; +import { useHighlightContext } from '../HighlightProvider'; import { getBarPath } from '../utils'; import type { BarComponentProps } from './Bar'; +const fadeTransitionCss = css` + transition: fill-opacity 250ms ease-in-out; +`; + +const FADED_OPACITY_FACTOR = 0.3; + export type DefaultBarProps = BarComponentProps & { /** * Custom class name for the bar. @@ -19,6 +27,8 @@ export type DefaultBarProps = BarComponentProps & { /** * Default bar component that renders a solid bar with animation. + * Uses pointer events to report series identity to the highlight system + * when `highlightScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -35,31 +45,101 @@ export const DefaultBar = memo( dataY, seriesId, transition, + fadeOnHighlight, ...props }) => { const { animate } = useCartesianChartContext(); + const highlightContext = useHighlightContext(); + const { highlight, scope } = highlightContext; const initialPath = useMemo(() => { if (!animate) return undefined; - // Need a minimum height to allow for animation const minHeight = 1; const initialY = (originY ?? 0) - minHeight; return getBarPath(x, initialY, width, minHeight, borderRadius, !!roundTop, !!roundBottom); }, [animate, x, originY, width, borderRadius, roundTop, roundBottom]); + const dataIndex = typeof dataX === 'number' ? dataX : null; + + // Determine effective opacity based on highlight state + const effectiveOpacity = useMemo(() => { + if (!fadeOnHighlight || !highlightContext.enabled || highlight.length === 0) { + return fillOpacity; + } + + const isHighlighted = highlight.some((item) => { + const indexMatch = !scope.dataIndex || item.dataIndex === dataIndex; + // When seriesId is null (pointer between bars), all series at this index match. + // Only narrow to a specific series when one is identified. + const seriesMatch = !scope.series || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + + return isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + }, [ + fadeOnHighlight, + highlightContext.enabled, + highlight, + scope, + dataIndex, + seriesId, + fillOpacity, + ]); + + const handlePointerEnter = useCallback( + (event: React.PointerEvent) => { + if (!highlightContext.enabled || !highlightContext.scope.series) return; + highlightContext.updatePointerHighlight(event.pointerId, { + seriesId: seriesId ?? null, + }); + }, + [highlightContext, seriesId], + ); + + const handlePointerLeave = useCallback( + (event: React.PointerEvent) => { + if (!highlightContext.enabled || !highlightContext.scope.series) return; + highlightContext.updatePointerHighlight(event.pointerId, { + seriesId: null, + }); + }, + [highlightContext], + ); + + const pointerHandlers = highlightContext.scope.series + ? { + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + style: { cursor: 'pointer' }, + } + : {}; + + const className = fadeOnHighlight ? fadeTransitionCss : undefined; + if (animate && initialPath) { return ( ); } - return ; + return ( + + ); }, ); diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 7fa7b37e0..55ca22b94 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -5,6 +5,7 @@ export * from './bar/index'; export * from './CartesianChart'; export * from './ChartProvider'; export * from './gradient/index'; +export * from './HighlightProvider'; export * from './legend/index'; export * from './line/index'; export * from './Path'; diff --git a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx index c0fb924d5..c3a381830 100644 --- a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -10,7 +10,7 @@ import { useCartesianChartContext } from '../../ChartProvider'; import { LineChart } from '../../line'; import { Scrubber } from '../../scrubber'; import { useScrubberContext } from '../../utils'; -import type { LegendShapeVariant, Series } from '../../utils/chart'; +import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart'; import { DefaultLegendShape } from '../DefaultLegendShape'; import { Legend, type LegendEntryProps } from '../Legend'; @@ -345,7 +345,7 @@ const DynamicData = () => { 'Dec', ]; - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'candidate-a', label: 'Candidate A', diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index c14c98df4..d53a151e5 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -7,11 +7,16 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type AxisConfigProps, + type CartesianSeries, + defaultCartesianChartInset, + getChartInset, +} from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -108,12 +113,15 @@ export const LineChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); - // Convert LineSeries to Series for Chart context + // Convert LineSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 62dbd6690..717979d4f 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -8,6 +8,7 @@ import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { DataCard } from '@coinbase/cds-web/alpha/data-card/DataCard'; import { ListCell } from '@coinbase/cds-web/cells'; import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'; +import { Icon } from '@coinbase/cds-web/icons'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Avatar, RemoteImage } from '@coinbase/cds-web/media'; import { SectionHeader } from '@coinbase/cds-web/section-header/SectionHeader'; @@ -18,13 +19,13 @@ import { type TabComponent, type TabsActiveIndicatorProps, } from '@coinbase/cds-web/tabs'; -import { Text } from '@coinbase/cds-web/typography'; +import { Text, TextLabel1 } from '@coinbase/cds-web/typography'; import { m } from 'framer-motion'; import { type AxisBounds, - DefaultScrubberBeacon, defaultTransition, + type HighlightedItem, PeriodSelector, PeriodSelectorActiveIndicator, Point, @@ -33,7 +34,6 @@ import { type ScrubberBeaconProps, type ScrubberRef, useCartesianChartContext, - useScrubberContext, } from '../..'; import { Area, DottedArea, type DottedAreaProps, GradientArea } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; @@ -1366,40 +1366,47 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - // We need a fade in animation for the Scrubber + const Example = memo(() => { + const defaultHighlight: HighlightedItem[] = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [], + ); + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], + ); + return ( - - - - - - - - + + + + +
); }); - return ( - - - - - - - ); + return ; } function MonotoneAssetPrice() { @@ -1549,6 +1556,393 @@ function MonotoneAssetPrice() { ); } +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x' as const, + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x' as const, stops }; + }, [monthStart, monthEnd, prices.length]); + + return ( + + + + ); +} + +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef( + ({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }, + ), + ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + // Memoized chart component - only re-renders when data or isScrubbing changes + type MemoizedChartProps = { + highlight: HighlightedItem[]; + data: number[]; + isScrubbing: boolean; + onHighlightChange: (items: HighlightedItem[]) => void; + scrubberLabel: (index: number) => string; + }; + + const chartTransition = useMemo(() => ({ duration: 0.15 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }: { min: number; max: number }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + // Always controlled: [] = nothing highlighted, [{dataIndex}] = highlight shown + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + // Full data for current period + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + // Sample more points for larger time frames + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + // Create sampled data with corresponding timestamps for index mapping + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues: number[] = []; + const sampledTimestamps: Date[] = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + // Always include the last point for accuracy + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + // Show full data when scrubbing, sampled when idle + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + // Refs for stable callback - avoids stale closures in handleHighlightChange + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + // Entering scrubbing: dataIndex is relative to sampled data. + // Use proportional mapping so the pixel position stays the same + // after switching from sampled to full data. + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + console.log('[AdaptiveDetail] Entering scrubbing:', { + sampledIndex: item.dataIndex, + sampledCount, + fullCount, + proportion: `${(proportion * 100).toFixed(1)}%`, + fullIndex, + }); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + // Already scrubbing: index is relative to full data, use directly + setHighlight(items); + } + } else { + // User stopped interacting + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date, periodId: string) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index: number) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + // Price display: when scrubbing, look up directly in full data by index + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + = 0 ? 'rotate(0deg)' : 'rotate(90deg)' }} + /> + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + + ); + }); + + return ; +} + export const All = () => { return ( @@ -1747,10 +2141,19 @@ export const All = () => { + + + + + + ); }; +export const AdaptiveDetailStory = () => ; +AdaptiveDetailStory.storyName = 'Adaptive Detail'; + export const Transitions = () => { const dataCount = 20; const maxDataOffset = 15000; @@ -1884,6 +2287,7 @@ export const Transitions = () => { return ; }; + function DataCardWithLineChart() { const exampleThumbnail = ( } ->; +export type ScrubberBeaconComponent = ( + props: ScrubberBeaconProps & { ref?: React.Ref }, +) => React.ReactNode; export type ScrubberBeaconLabelProps = Pick & Pick< diff --git a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..eab93f7bf 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,19 +1,19 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartDomain, getChartInset, getChartRange, getStackedSeriesData, isValidBounds, - type Series, } from '../chart'; describe('getChartDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; @@ -23,7 +23,7 @@ describe('getChartDomain', () => { }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; @@ -33,14 +33,14 @@ describe('getChartDomain', () => { }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; const result = getChartDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); @@ -52,14 +52,14 @@ describe('getChartDomain', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; @@ -69,7 +69,7 @@ describe('getChartDomain', () => { }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, @@ -82,7 +82,7 @@ describe('getChartDomain', () => { describe('getStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; @@ -103,7 +103,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -125,7 +125,7 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -144,7 +144,7 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -166,7 +166,7 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; const result = getStackedSeriesData(series); @@ -179,14 +179,14 @@ describe('getStackedSeriesData', () => { }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId @@ -205,14 +205,14 @@ describe('getStackedSeriesData', () => { describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; @@ -222,7 +222,7 @@ describe('getChartRange', () => { }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -245,7 +245,7 @@ describe('getChartRange', () => { }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -260,14 +260,14 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; @@ -286,35 +286,35 @@ describe('getChartRange', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -370,9 +370,9 @@ describe('isValidBounds', () => { }); }); -describe('defaultChartInset', () => { +describe('defaultCartesianChartInset', () => { it('should have correct default values', () => { - expect(defaultChartInset).toEqual({ + expect(defaultCartesianChartInset).toEqual({ top: 32, left: 16, bottom: 16, diff --git a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts index 8ea8ac990..3afe1c71c 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts @@ -4,6 +4,8 @@ import { defaultTransition, usePathTransition } from '../transition'; // Mock framer-motion jest.mock('framer-motion', () => { + const { useRef } = require('react'); + const mockMotionValue = (initial: any) => { let value = initial; const listeners: Array<(v: any) => void> = []; @@ -24,7 +26,14 @@ jest.mock('framer-motion', () => { }; return { - useMotionValue: jest.fn((initial) => mockMotionValue(initial)), + // Return a stable reference across re-renders (like real useMotionValue) + useMotionValue: jest.fn((initial: any) => { + const ref = useRef(null); + if (ref.current === null) { + ref.current = mockMotionValue(initial); + } + return ref.current; + }), useTransform: jest.fn((source, transformer) => { const result = mockMotionValue(transformer(source.get())); source.onChange((v: any) => { @@ -32,9 +41,8 @@ jest.mock('framer-motion', () => { }); return result; }), - animate: jest.fn((value, target, config) => { - // Immediately set to target for testing - value.set(target); + animate: jest.fn((_from, _to, config) => { + // Immediately complete animation for testing if (config?.onComplete) { config.onComplete(); } @@ -291,6 +299,41 @@ describe('usePathTransition', () => { expect(animate.mock.calls.length).toBeGreaterThan(animateCallCount); }); + it('should snap to target path when animation is cancelled by cleanup', () => { + const { animate } = require('framer-motion'); + const cancelMock = jest.fn(); + + // Override animate to NOT immediately complete - keeps animation "in progress" + animate.mockImplementation(() => ({ + cancel: cancelMock, + stop: jest.fn(), + })); + + const path1 = 'M0,0L10,10'; + const path2 = 'M0,0L20,20'; + + const { result, unmount, rerender } = renderHook( + ({ path }) => + usePathTransition({ + currentPath: path, + }), + { + initialProps: { path: path1 }, + }, + ); + + // Start animation by changing path (animation stays "in progress") + rerender({ path: path2 }); + + // Unmount triggers cleanup which cancels the animation + unmount(); + + expect(cancelMock).toHaveBeenCalled(); + // After cancellation, animatedPath should have snapped to the target (path2) + // rather than being stuck at an intermediate interpolated value + expect(result.current.get()).toBe(path2); + }); + it('should cleanup animation on unmount', () => { const { animate } = require('framer-motion'); const cancelMock = jest.fn(); diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index 7bfee5d87..80b740e01 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -1,9 +1,19 @@ import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import type { HighlightScope } from './highlight'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Default highlight scope for cartesian charts. + * Highlights by data index (x-axis position), not by series. + */ +export const defaultCartesianChartHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + /** * Shape variants available for legend items. */ @@ -32,30 +42,46 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +/** + * Base series type with common properties shared across all chart types. + * Used by generic chart components like HighlightProvider. + */ export type Series = { /** - * Id of the series. + * Unique identifier for the series. */ id: string; - /** - * Data array for this series. Use null values to create gaps in the visualization. - * - * Can be either: - * - Array of numbers: `[10, -5, 20]` - * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs - */ - data?: Array | Array<[number, number] | null>; /** * Label of the series. - * Used for scrubber beacon labels. + * Used for scrubber beacon labels and legend items. */ label?: string; /** * Color of 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; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; +}; + +/** + * Series type for cartesian (X/Y) charts with axis-specific properties. + */ +export type CartesianSeries = Series & { + /** + * Data array for this series. Use null values to create gaps in the visualization. + * + * Can be either: + * - Array of numbers: `[10, -5, 20]` + * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs + */ + data?: Array | Array<[number, number] | null>; /** * Color gradient configuration. * Takes precedence over color except for scrubber beacon labels. @@ -72,12 +98,6 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; - /** - * Shape of the legend item for this series. - * Can be a preset shape variant or a custom ReactNode. - * @default 'circle' - */ - legendShape?: LegendShape; }; /** @@ -85,7 +105,7 @@ export type Series = { * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -114,7 +134,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -130,7 +150,7 @@ const createStackKey = (series: Series): string | undefined => { * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( - series: Series[], + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -240,7 +260,7 @@ export const getLineData = ( * Handles stacking by transforming data when series have stack properties. */ export const getChartRange = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -327,7 +347,7 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultCartesianChartInset: ChartInset = { top: 32, left: 16, bottom: 16, diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index c976ac02b..c8ea1ee00 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -2,41 +2,73 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { CartesianSeries, Series } from './chart'; import type { ChartScaleFunction } from './scale'; +/** + * Supported chart types. + */ +export type ChartType = 'cartesian'; + +/** + * Base context value shared by all chart types. + * Contains common properties like series and dimensions. + */ +export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + */ + series: Series[]; + /** + * Whether to animate the chart. + */ + animate: boolean; + /** + * Width of the chart. + */ + width: number; + /** + * Height of the chart. + */ + height: number; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; + /** + * Length of the data domain. + */ + dataLength: number; +}; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = { +export type CartesianChartContextValue = Omit & { + /** + * The chart type (always 'cartesian' for this context). + */ + type: 'cartesian'; /** * The series data for the chart. */ - series: Series[]; + series: CartesianSeries[]; /** * Returns the series which matches the seriesId or undefined. * @param seriesId - A series' id */ - getSeries: (seriesId?: string) => Series | undefined; + getSeries: (seriesId?: string) => CartesianSeries | undefined; /** * Returns the data for a series * @param seriesId - A series' id * @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; /** * Get x-axis configuration. */ @@ -55,16 +87,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. 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 000000000..2f7e3e723 --- /dev/null +++ b/packages/web-visualization/src/chart/utils/highlight.ts @@ -0,0 +1,32 @@ +/** + * Controls what aspects of the data can be highlighted. + */ +export type HighlightScope = { + /** + * Whether highlighting tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether highlighting tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * Represents a single highlighted item. + * `null` values mean the user is interacting but not over a specific item/series. + */ +export type HighlightedItem = { + /** + * The data index (x-axis position) being highlighted. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being highlighted. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; diff --git a/packages/web-visualization/src/chart/utils/index.ts b/packages/web-visualization/src/chart/utils/index.ts index a02719070..28d4decf5 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'; diff --git a/packages/web-visualization/src/chart/utils/transition.ts b/packages/web-visualization/src/chart/utils/transition.ts index f4fa56008..e7eec1322 100644 --- a/packages/web-visualization/src/chart/utils/transition.ts +++ b/packages/web-visualization/src/chart/utils/transition.ts @@ -131,6 +131,12 @@ export const usePathTransition = ({ return () => { if (animationRef.current) { animationRef.current.cancel(); + // Snap to target so the visual state stays consistent. + // Without this, a cancelled mid-flight animation leaves + // the path stuck at an intermediate interpolated value. + progress.set(1); + previousPathRef.current = targetPathRef.current; + animationRef.current = null; } }; }, [currentPath, transition, progress, interpolatedPath]);