diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 122bfa13a4..38b9411e4c 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -10,7 +10,11 @@ import { ScrubberAccessibilityView, type ScrubberAccessibilityViewProps, } from './scrubber/ScrubberAccessibilityView'; -import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; +import { + type HighlightProps, + type HighlightProviderProps, + HighlightProvider, +} from './HighlightProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; @@ -23,6 +27,7 @@ import { type ChartInset, type ChartScaleFunction, defaultAxisId, + defaultCartesianChartHighlightScope, defaultHorizontalLayoutChartInset, defaultVerticalLayoutChartInset, getAxisConfig, @@ -31,15 +36,15 @@ import { getCartesianAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type HighlightedItem, + type HighlightScope, type LegendPosition, - type Series, + type CartesianSeries, useTotalAxisPadding, } from './utils'; -type ChartCanvasProps = Pick< - CartesianChartProps, - 'accessible' | 'accessibilityLabel' | 'accessibilityLiveRegion' -> & { +type ChartCanvasProps = Pick & { + accessibilityLabel?: string; children: React.ReactNode; style?: StyleProp; }; @@ -68,13 +73,13 @@ const ChartCanvas = memo( }, ); -export type CartesianChartBaseProps = Omit & - 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; /** * Chart layout - describes the direction bars/areas grow. * - 'vertical' (default): Bars grow vertically. X is category axis, Y is value axis. @@ -122,11 +127,42 @@ 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 & { + Pick & + Omit & { /** * Default font families to use within ChartText. * If not provided, will be the default for the system. @@ -175,6 +211,14 @@ export const CartesianChart = memo( children, layout = 'vertical', animate = true, + // Highlight props + enableHighlighting, + highlightScope = defaultCartesianChartHighlightScope, + highlight, + onHighlightChange, + accessibilityMode, + accessibilityChunkCount, + // Legacy scrubber props enableScrubbing, getScrubberAccessibilityLabel, scrubberAccessibilityLabelStep, @@ -519,6 +563,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', layout, series: series ?? [], getSeries, @@ -568,6 +613,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; + }, [enableHighlighting, enableScrubbing]); + + // Wrap onHighlightChange to also call legacy onScrubberPositionChange. + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + if (onScrubberPositionChange) { + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onScrubberPositionChange], + ); + const legendElement = useMemo(() => { if (!legend) return; @@ -596,10 +660,15 @@ export const CartesianChart = memo( return ( - {legend ? ( )} - + ); }, diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx index 89db490bf8..a0c66f9c08 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 7491d0989a..0a14e10e35 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 0000000000..6e41e92077 --- /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 7c3f38f993..d00061a259 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -44,9 +44,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 0000000000..f7e9aa6202 --- /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 8ba6175219..7da4ca5101 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -8,11 +8,11 @@ import { type CartesianChartProps, } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; -import { type CartesianAxisConfigProps, defaultStackId, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type CartesianSeries, defaultStackId } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -128,10 +128,10 @@ export const AreaChart = memo( }, ref, ) => { - // 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/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index 6a8f6e3f61..78fe7f1f88 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -74,6 +74,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 & { @@ -154,6 +159,7 @@ export const Bar = memo( roundBottom = true, transitions, transition, + fadeOnHighlight, }) => { const theme = useTheme(); const { layout } = useCartesianChartContext(); @@ -180,6 +186,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 f424401c88..35d6f6f8d4 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -38,6 +38,7 @@ export type BarChartBaseProps = Omit< | 'stackMinSize' | 'transitions' | 'transition' + | 'fadeOnHighlight' > & { /** * Configuration objects that define how to visualize the data. @@ -113,6 +114,7 @@ export const BarChart = memo( stackMinSize, transitions, transition, + fadeOnHighlight, ...chartProps }, ref, @@ -222,6 +224,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 cbbe9802fd..d28042cb6b 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. @@ -54,6 +55,7 @@ export const BarPlot = memo( stackMinSize, transitions, transition, + fadeOnHighlight, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); @@ -129,6 +131,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 94eccb181f..f9eb2e0fea 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -24,7 +24,7 @@ export type BarSeries = Series & { export type BarStackBaseProps = Pick< BarBaseProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -161,6 +161,7 @@ export const BarStack = memo( roundBaseline, transitions, transition, + fadeOnHighlight, }) => { const theme = useTheme(); const { layout, getSeriesData, getXAxis, getYAxis } = useCartesianChartContext(); @@ -882,6 +883,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={barsGrowVertically ? categoryValue : (bar.dataY as any)} dataY={barsGrowVertically ? bar.dataY : categoryValue} + 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 5579c6e1fe..b3d0b39b05 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -19,6 +19,7 @@ export type BarStackGroupProps = Pick< | 'BarStackComponent' | 'transitions' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index d1c984fd7e..e4dda4cbe7 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 { defaultBarEnterTransition, getBarPath, withStaggerDelayTransition } from '../utils'; import { defaultTransition, getTransition } from '../utils/transition'; @@ -10,8 +12,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( ({ @@ -25,17 +32,91 @@ export const DefaultBar = memo( d, fill, fillOpacity = 1, + dataX, + dataY, + seriesId, stroke, strokeWidth, origin, transitions, transition, + fadeOnHighlight, }) => { const { animate, drawingArea, layout } = useCartesianChartContext(); + const highlightContext = useHighlightContext(); const theme = useTheme(); + const { enabled: highlightEnabled, scope, registerBar, unregisterBar } = highlightContext; + + const dataIndex = useMemo(() => { + if (typeof dataX === 'number') return dataX; + if (typeof dataY === 'number') return dataY; + return null; + }, [dataX, dataY]); const defaultFill = fill || theme.color.fgPrimary; + // Register bar bounds for hit testing when series highlighting is enabled. + useEffect(() => { + if (!highlightEnabled || !scope.series || !seriesId) return; + + const index = dataIndex ?? 0; + + registerBar({ + x, + y, + width, + height, + dataIndex: index, + seriesId, + }); + + return () => { + unregisterBar(seriesId, index); + }; + }, [ + highlightEnabled, + scope.series, + seriesId, + registerBar, + unregisterBar, + x, + y, + width, + height, + dataIndex, + ]); + + const highlightByDataIndex = scope.dataIndex ?? false; + const highlightBySeries = scope.series ?? false; + + const effectiveOpacity = useDerivedValue(() => { + if (!fadeOnHighlight || !highlightEnabled) return fillOpacity; + + const items = highlightContext.highlight.value; + let targetOpacity = fillOpacity; + + if (items.length > 0) { + const isHighlighted = items.some((item) => { + const indexMatch = !highlightByDataIndex || item.dataIndex === dataIndex; + const seriesMatch = + !highlightBySeries || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + + targetOpacity = isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + } + + return withTiming(targetOpacity, FADE_ANIMATION_CONFIG); + }, [ + fadeOnHighlight, + highlightEnabled, + fillOpacity, + highlightByDataIndex, + highlightBySeries, + dataIndex, + seriesId, + ]); + // For vertical layout, stagger by x (category axis). For horizontal, stagger by y (category axis). const normalizedStagger = useMemo(() => { const barsGrowVertically = layout !== 'horizontal'; @@ -96,7 +177,7 @@ export const DefaultBar = memo( clipPath={null} d={d} fill={stroke ? 'none' : defaultFill} - fillOpacity={fillOpacity} + fillOpacity={fadeOnHighlight ? effectiveOpacity : fillOpacity} initialPath={initialPath} stroke={stroke} strokeWidth={strokeWidth} diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 40620f86c0..403e8f7cda 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 5890ad80b5..2c51e48901 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 bf578f7e48..da9979aca3 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -8,7 +8,7 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type CartesianAxisConfigProps, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type CartesianSeries } from '../utils'; import { Line, type LineProps } from './Line'; @@ -20,7 +20,7 @@ const getDefaultScrubberAccessibilityStep = ( return Math.max(1, Math.ceil(dataLength / sampleCount)); }; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -125,10 +125,10 @@ export const LineChart = memo( }, ref, ) => { - // 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/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index 75687a5eee..fa1f8f6f78 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx @@ -23,11 +23,11 @@ import { } from '../line'; import type { ChartTextChildren, ChartTextProps } from '../text'; import { + type CartesianSeries, type ChartInset, defaultAccessoryEnterTransition, getPointOnSerializableScale, getTransition, - type Series, useScrubberContext, } from '../utils'; import type { Transition } from '../utils/transition'; @@ -59,7 +59,7 @@ export type ScrubberBeaconBaseProps = { /** * Id of the series. */ - seriesId: Series['id']; + seriesId: CartesianSeries['id']; /** * Color of the beacon. */ @@ -137,7 +137,7 @@ export type ScrubberBeaconComponent = React.FC< ScrubberBeaconProps & { ref?: React.Ref } >; -export type ScrubberBeaconLabelProps = Pick & +export type ScrubberBeaconLabelProps = Pick & Pick< ChartTextProps, 'x' | 'y' | 'dx' | 'horizontalAlignment' | 'onDimensionsChange' | 'opacity' | 'font' @@ -149,7 +149,7 @@ export type ScrubberBeaconLabelProps = Pick & /** * Id of the series. */ - seriesId: Series['id']; + seriesId: CartesianSeries['id']; }; export type ScrubberBeaconLabelComponent = React.FC; 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 ce49486a80..00bde33506 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 not stack series with different xAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', xAxisId: 'top' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', xAxisId: 'bottom' }, ]; @@ -187,7 +187,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); @@ -200,14 +200,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 @@ -226,14 +226,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] }, ]; @@ -243,7 +243,7 @@ describe('getChartRange', () => { }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -266,7 +266,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' }, ]; @@ -281,14 +281,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' }, ]; @@ -307,35 +307,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' }, ]; @@ -391,9 +391,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/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index de3080239c..ef7268bc85 100644 --- a/packages/mobile-visualization/src/chart/utils/axis.ts +++ b/packages/mobile-visualization/src/chart/utils/axis.ts @@ -4,10 +4,10 @@ import type { Rect } from '@coinbase/cds-common/types'; import { type AxisBounds, + type CartesianSeries, getChartDomain, getChartRange, isValidBounds, - type Series, } from './chart'; import type { CartesianChartLayout } from './context'; import { getPointOnScale } from './point'; @@ -278,7 +278,7 @@ export const getAxisConfig = ( */ export const getCartesianAxisDomain = ( axisParam: CartesianAxisConfigProps, - series: Series[], + series: CartesianSeries[], axisType: 'x' | 'y', layout: CartesianChartLayout = 'vertical', ): AxisBounds => { diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 48dfe256b7..4b7cce5c5c 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -3,9 +3,19 @@ import type { AnimatedProp } from '@shopify/react-native-skia'; import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; import type { GradientDefinition } from './gradient'; +import type { HighlightScope } from './highlight'; 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,7 +44,11 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; -export type Series = { +/** + * Series model for Cartesian charts. + * Designed to remain cartesian-specific as the codebase expands to other chart families (e.g. polar). + */ +export type CartesianSeries = { /** * Id of the series. */ @@ -89,12 +103,18 @@ export type Series = { legendShape?: LegendShape; }; +/** + * Backwards-compatible alias for legacy imports. + * @deprecated Prefer `CartesianSeries` in cartesian chart APIs. + */ +export type Series = CartesianSeries; + /** * Calculates the domain of a chart from series data. * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -123,7 +143,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes stack ID and axis IDs. * This ensures series with different 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 axis IDs to prevent cross-scale stacking @@ -140,7 +160,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>(); @@ -250,7 +270,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 => { @@ -351,6 +371,11 @@ export const defaultHorizontalLayoutChartInset: ChartInset = { right: 48, }; +/** + * Backwards-compatible alias used by legacy tests/docs. + */ +export const defaultCartesianChartInset: ChartInset = defaultVerticalLayoutChartInset; + /** * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts or * `defaultHorizontalLayoutChartInset` for horizontal layout charts. diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index 3a211d429f..d9697014dc 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -4,7 +4,7 @@ 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 } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; /** @@ -15,11 +15,17 @@ import type { ChartScaleFunction, SerializableScale } from './scale'; */ export type CartesianChartLayout = 'horizontal' | 'vertical'; +export type ChartType = 'cartesian'; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ export type CartesianChartContextValue = { + /** + * The chart type. + */ + type: ChartType; /** * Chart layout - describes the direction bars/areas grow. * @default 'vertical' @@ -28,12 +34,12 @@ export type CartesianChartContextValue = { /** * 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 @@ -120,6 +126,8 @@ export type CartesianChartContextValue = { getAxisBounds: (id: string) => Rect | undefined; }; +export type ChartContextValue = CartesianChartContextValue; + export type ScrubberContextValue = { /** * Enables scrubbing interactions. diff --git a/packages/mobile-visualization/src/chart/utils/highlight.ts b/packages/mobile-visualization/src/chart/utils/highlight.ts new file mode 100644 index 0000000000..34277ce86e --- /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 0bf7ad9537..ff6f8088a5 100644 --- a/packages/mobile-visualization/src/chart/utils/index.ts +++ b/packages/mobile-visualization/src/chart/utils/index.ts @@ -4,6 +4,7 @@ export * from './bar'; export * from './chart'; export * from './context'; export * from './gradient'; +export * from './highlight'; export * from './path'; export * from './point'; export * from './scale'; diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 5ed9f61c85..e553d2bc12 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,8 +5,8 @@ import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout'; import { css } from '@linaria/core'; -import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { CartesianChartProvider } from './ChartProvider'; +import { type HighlightProps, HighlightProvider } from './HighlightProvider'; import { Legend } from './legend'; import { type AxisConfig, @@ -16,6 +16,7 @@ import { type ChartInset, type ChartScaleFunction, defaultAxisId, + defaultCartesianChartHighlightScope, defaultHorizontalLayoutChartInset, defaultVerticalLayoutChartInset, getAxisConfig, @@ -24,8 +25,10 @@ import { getCartesianAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type HighlightedItem, + type HighlightScope, type LegendPosition, - type Series, + type CartesianSeries, useTotalAxisPadding, } from './utils'; @@ -39,13 +42,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; /** * Chart layout - describes the direction bars/areas grow. * - 'vertical' (default): Bars grow vertically. X is category axis, Y is value axis. @@ -93,9 +96,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. @@ -141,9 +163,15 @@ export const CartesianChart = memo( children, layout = 'vertical', animate = true, + // Highlight props + enableHighlighting, + highlightScope = defaultCartesianChartHighlightScope, + highlight, + onHighlightChange, xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, + // Legacy scrubber props enableScrubbing, onScrubberPositionChange, legend, @@ -435,6 +463,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', layout, series: series ?? [], getSeries, @@ -478,6 +507,25 @@ 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; + }, [enableHighlighting, enableScrubbing]); + + // Wrap onHighlightChange to also call legacy onScrubberPositionChange. + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + if (onScrubberPositionChange) { + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onScrubberPositionChange], + ); + const legendElement = useMemo(() => { if (!legend) return; @@ -526,13 +574,15 @@ export const CartesianChart = memo( } } }} - accessibilityLabel={accessibilityLabel} + accessibilityLabel={ + typeof accessibilityLabel === 'string' ? accessibilityLabel : undefined + } aria-live="polite" as="svg" - className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} + className={cx(resolvedEnableHighlighting && focusStylesCss, classNames?.chart)} height="100%" style={styles?.chart} - tabIndex={enableScrubbing ? 0 : undefined} + tabIndex={resolvedEnableHighlighting ? 0 : undefined} width="100%" > {children} @@ -542,9 +592,12 @@ export const CartesianChart = memo( return ( - {legend ? ( @@ -561,7 +614,7 @@ export const CartesianChart = memo( ) : ( {chartContent} )} - + ); }, diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx index 192421a5d0..512d1ead27 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 0000000000..79c9e5cf00 --- /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 0000000000..ecb19edb48 --- /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 6898638742..3cda8d1deb 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -7,11 +7,11 @@ import { type CartesianChartProps, } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; -import { type CartesianAxisConfigProps, defaultStackId, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type CartesianSeries, defaultStackId } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -122,10 +122,10 @@ export const AreaChart = memo( }, ref, ) => { - // 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 a9907d565a..f1f1c18a7f 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -44,7 +44,7 @@ export type BarBaseProps = { */ origin?: number; /** - * The x-axis data value for this bar. + * The x-axis data index for this bar. */ dataX?: number | string; /** @@ -75,6 +75,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 & { @@ -155,6 +160,7 @@ export const Bar = memo( roundBottom = true, transitions, transition, + fadeOnHighlight, }) => { const { layout } = useCartesianChartContext(); @@ -176,6 +182,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 36ef4b537b..fb9dc37080 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -6,7 +6,7 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type CartesianAxisConfigProps, defaultStackId, type Series } from '../utils'; +import { type CartesianAxisConfigProps, defaultStackId } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; import type { BarSeries } from './BarStack'; @@ -37,6 +37,7 @@ export type BarChartBaseProps = Omit< | 'stackMinSize' | 'transitions' | 'transition' + | 'fadeOnHighlight' > & { /** * Configuration objects that define how to visualize the data. @@ -112,6 +113,7 @@ export const BarChart = memo( stackMinSize, transitions, transition, + fadeOnHighlight, ...chartProps }, ref, @@ -221,6 +223,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 2d3cc09a84..0d619f222d 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. @@ -53,6 +54,7 @@ export const BarPlot = memo( stackMinSize, transitions, 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/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 54836a392c..c23036f916 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -23,7 +23,7 @@ export type BarSeries = Series & { export type BarStackBaseProps = Pick< BarBaseProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -172,6 +172,7 @@ export const BarStack = memo( roundBaseline, transitions, transition, + fadeOnHighlight, }) => { const { layout, getSeriesData, getXAxis, getSeries } = useCartesianChartContext(); @@ -548,6 +549,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={barsGrowVertically ? dataX : (bar.dataValue as any)} // This is a bit loose, depends on Bar implementation dataY={barsGrowVertically ? (bar.dataValue as any) : dataX} + fadeOnHighlight={fadeOnHighlight} fill={bar.fill} fillOpacity={bar.fillOpacity ?? defaultFillOpacity} height={barsGrowVertically ? bar.length : thickness} diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 4179f0c35f..aa1940332a 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -21,6 +21,7 @@ export type BarStackGroupProps = Pick< | 'BarStackComponent' | 'transitions' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index 491b13e5f8..977db38227 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,6 +1,9 @@ import React, { memo, useMemo } from 'react'; +import { useCallback } from 'react'; +import { css } from '@linaria/core'; import { useCartesianChartContext } from '../ChartProvider'; +import { useHighlightContext } from '../HighlightProvider'; import { Path } from '../Path'; import { defaultBarEnterTransition, @@ -12,6 +15,12 @@ import { 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. @@ -25,6 +34,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( ({ @@ -44,9 +55,71 @@ export const DefaultBar = memo( seriesId, transitions, transition, + fadeOnHighlight, + className, + style, ...props }) => { const { animate, drawingArea, layout } = useCartesianChartContext(); + const highlightContext = useHighlightContext(); + const { enabled: highlightEnabled, highlight, scope } = highlightContext; + + const dataIndex = useMemo(() => { + if (typeof dataX === 'number') return dataX; + if (typeof dataY === 'number') return dataY; + return null; + }, [dataX, dataY]); + + const highlightByDataIndex = scope.dataIndex ?? false; + const highlightBySeries = scope.series ?? false; + + const effectiveOpacity = useMemo(() => { + if (!fadeOnHighlight || !highlightEnabled || highlight.length === 0) { + return fillOpacity; + } + + const isHighlighted = highlight.some((item) => { + const indexMatch = !highlightByDataIndex || 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 = + !highlightBySeries || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + + return isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + }, [ + fadeOnHighlight, + highlightEnabled, + highlight, + fillOpacity, + highlightByDataIndex, + highlightBySeries, + dataIndex, + seriesId, + ]); + + const handlePointerEnter = useCallback( + (event: React.PointerEvent) => { + if (!highlightEnabled || !highlightBySeries) return; + highlightContext.updatePointerHighlight(event.pointerId, { seriesId: seriesId ?? null }); + }, + [highlightContext, highlightEnabled, highlightBySeries, seriesId], + ); + + const handlePointerLeave = useCallback( + (event: React.PointerEvent) => { + if (!highlightEnabled || !highlightBySeries) return; + highlightContext.updatePointerHighlight(event.pointerId, { seriesId: null }); + }, + [highlightContext, highlightEnabled, highlightBySeries], + ); + + const resolvedStyle = + highlightEnabled && highlightBySeries ? { ...style, cursor: 'pointer' } : style; + const resolvedClassName = fadeOnHighlight + ? [className, fadeTransitionCss].filter(Boolean).join(' ') + : className; // For vertical layout, stagger by x (category axis). For horizontal, stagger by y (category axis). const normalizedStagger = useMemo(() => { @@ -105,11 +178,15 @@ export const DefaultBar = memo( { '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 ab531b5e4c..dc27dbad77 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -7,11 +7,11 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type CartesianAxisConfigProps, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type CartesianSeries } from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -111,10 +111,10 @@ export const LineChart = memo( }, ref, ) => { - // 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/scrubber/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx index fbb3fb37f6..df99a23981 100644 --- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx @@ -10,12 +10,12 @@ import { } from '../line'; import type { ChartTextChildren, ChartTextProps } from '../text'; import { + type CartesianSeries, type ChartInset, type ChartScaleFunction, defaultAccessoryEnterTransition, getPointOnScale, getTransition, - type Series, useScrubberContext, } from '../utils'; @@ -45,7 +45,7 @@ export type ScrubberBeaconBaseProps = { /** * Id of the series. */ - seriesId: Series['id']; + seriesId: CartesianSeries['id']; /** * Color of the series. */ @@ -132,7 +132,7 @@ export type ScrubberBeaconComponent = React.FC< ScrubberBeaconProps & { ref?: React.Ref } >; -export type ScrubberBeaconLabelProps = Pick & +export type ScrubberBeaconLabelProps = Pick & Pick< ChartTextProps, | 'x' @@ -152,7 +152,7 @@ export type ScrubberBeaconLabelProps = Pick & /** * Id of the series. */ - seriesId: Series['id']; + seriesId: CartesianSeries['id']; /** * Transition configuration for position animations. * When provided, the label component should animate its y position using this transition. diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx index 92b8698ba0..c5972c5fbe 100644 --- a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx +++ b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx @@ -7,7 +7,6 @@ import { type ChartScaleFunction, evaluateGradientAtValue, getGradientConfig, - type Series, useScrubberContext, } from '../utils'; diff --git a/packages/web-visualization/src/chart/utils/axis.ts b/packages/web-visualization/src/chart/utils/axis.ts index 0c2f9405fc..000f74e2cf 100644 --- a/packages/web-visualization/src/chart/utils/axis.ts +++ b/packages/web-visualization/src/chart/utils/axis.ts @@ -4,10 +4,10 @@ import type { Rect } from '@coinbase/cds-common/types'; import { type AxisBounds, + type CartesianSeries, getChartDomain, getChartRange, isValidBounds, - type Series, } from './chart'; import type { CartesianChartLayout } from './context'; import { getPointOnScale } from './point'; @@ -283,7 +283,7 @@ export const getAxisConfig = ( */ export const getCartesianAxisDomain = ( axisParam: CartesianAxisConfigProps, - series: Series[], + series: CartesianSeries[], axisType: 'x' | 'y', layout: CartesianChartLayout = 'vertical', ): AxisBounds => { diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index 889633513f..a69cebdb26 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 { GradientDefinition } from './gradient'; +import type { HighlightScope } from './highlight'; 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,7 +42,11 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; -export type Series = { +/** + * Series model for Cartesian charts. + * Designed to remain cartesian-specific as the codebase expands to other chart families (e.g. polar). + */ +export type CartesianSeries = { /** * Id of the series. */ @@ -87,12 +101,18 @@ export type Series = { legendShape?: LegendShape; }; +/** + * Backwards-compatible alias for legacy imports. + * @deprecated Prefer `CartesianSeries` in cartesian chart APIs. + */ +export type Series = CartesianSeries; + /** * Calculates the domain of a chart from series data. * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -121,7 +141,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes stack ID and axis IDs. * This ensures series with different 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 axis IDs to prevent cross-scale stacking @@ -138,7 +158,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>(); @@ -248,7 +268,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 => { @@ -349,6 +369,11 @@ export const defaultHorizontalLayoutChartInset: ChartInset = { right: 48, }; +/** + * Backwards-compatible alias used by legacy tests/docs. + */ +export const defaultCartesianChartInset: ChartInset = defaultVerticalLayoutChartInset; + /** * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts or * `defaultHorizontalLayoutChartInset` for horizontal layout charts. diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 64ef74b060..9d066288b4 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -2,7 +2,7 @@ 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 } from './chart'; import type { ChartScaleFunction } from './scale'; /** @@ -13,11 +13,17 @@ import type { ChartScaleFunction } from './scale'; */ export type CartesianChartLayout = 'horizontal' | 'vertical'; +export type ChartType = 'cartesian'; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ export type CartesianChartContextValue = { + /** + * The chart type. + */ + type: ChartType; /** * Chart layout - describes the direction bars/areas grow. * @default 'vertical' @@ -28,12 +34,12 @@ export type CartesianChartContextValue = { /** * 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 @@ -101,6 +107,8 @@ export type CartesianChartContextValue = { getAxisBounds: (id: string) => Rect | undefined; }; +export type ChartContextValue = CartesianChartContextValue; + export type ScrubberContextValue = { /** * Enables scrubbing interactions. diff --git a/packages/web-visualization/src/chart/utils/highlight.ts b/packages/web-visualization/src/chart/utils/highlight.ts new file mode 100644 index 0000000000..2f7e3e723e --- /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 a02719070a..28d4decf50 100644 --- a/packages/web-visualization/src/chart/utils/index.ts +++ b/packages/web-visualization/src/chart/utils/index.ts @@ -4,6 +4,7 @@ export * from './bar'; export * from './chart'; export * from './context'; export * from './gradient'; +export * from './highlight'; export * from './interpolate'; export * from './path'; export * from './point';