From 93ca570d720864e6abc525106230aff5086b53dc Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 15 Jan 2026 08:59:40 -0500 Subject: [PATCH 01/16] Build out interaction provider --- .../src/chart/CartesianChart.tsx | 155 ++++- .../mobile-visualization/src/chart/index.ts | 1 + .../chart/interaction/InteractionProvider.tsx | 547 +++++++++++++++++ .../src/chart/interaction/index.ts | 3 + .../src/chart/utils/context.ts | 112 ++++ .../src/chart/CartesianChart.tsx | 144 ++++- .../chart/__stories__/Interaction.stories.tsx | 506 ++++++++++++++++ packages/web-visualization/src/chart/index.ts | 1 + .../chart/interaction/InteractionProvider.tsx | 567 ++++++++++++++++++ .../src/chart/interaction/index.ts | 3 + .../src/chart/utils/context.ts | 104 ++++ 11 files changed, 2080 insertions(+), 63 deletions(-) create mode 100644 packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx create mode 100644 packages/mobile-visualization/src/chart/interaction/index.ts create mode 100644 packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx create mode 100644 packages/web-visualization/src/chart/interaction/InteractionProvider.tsx create mode 100644 packages/web-visualization/src/chart/interaction/index.ts diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 1cce17197..87b2aba83 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -6,11 +6,13 @@ import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; -import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; +import { InteractionProvider } from './interaction/InteractionProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; import { + type ActiveItem, + type ActiveItems, type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, @@ -24,6 +26,9 @@ import { getAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type InteractionMode, + type InteractionScope, + type InteractionState, type Series, useTotalAxisPadding, } from './utils'; @@ -40,34 +45,95 @@ const ChartCanvas = memo( }, ); -export type CartesianChartBaseProps = Omit & - Pick & { - /** - * Configuration objects that define how to visualize the data. - * Each series contains its own data array. - */ - series?: Array; - /** - * Whether to animate the chart. - * @default true - */ - animate?: boolean; - /** - * Configuration for x-axis. - */ - xAxis?: Partial>; - /** - * Configuration for y-axis(es). Can be a single config or array of configs. - */ - yAxis?: Partial | Partial[]; - /** - * Inset around the entire chart (outside the axes). - */ - inset?: number | Partial; - }; +export type CartesianChartBaseProps = Omit & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data array. + */ + series?: Array; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for x-axis. + */ + xAxis?: Partial>; + /** + * Configuration for y-axis(es). Can be a single config or array of configs. + */ + yAxis?: Partial | Partial[]; + /** + * Inset around the entire chart (outside the axes). + */ + inset?: number | Partial; + + // New Interaction API + /** + * The interaction mode. + * - 'none': Interaction disabled + * - 'single': Single touch interaction (default) + * - 'multi': Multi-touch interaction + * @default 'single' + */ + interaction?: InteractionMode; + /** + * Controls what aspects of the data can be interacted with. + * @default { dataIndex: true, series: false } + */ + interactionScope?: InteractionScope; + /** + * Controlled active item (for single mode). + * - undefined: Uncontrolled mode + * - null: Controlled mode with no active item (ignores user gestures) + * - ActiveItem: Controlled mode with specific active item + */ + activeItem?: ActiveItem | null; + /** + * Controlled active items (for multi mode). + * - undefined: Uncontrolled mode + * - []: Controlled mode with no active items (ignores user gestures) + * - ActiveItems: Controlled mode with specific active items + */ + activeItems?: ActiveItems; + /** + * Callback fired when the active item changes during interaction. + * For single mode: receives `ActiveItem | undefined` + * For multi mode: receives `ActiveItems` + */ + onInteractionChange?: (state: InteractionState) => void; + /** + * Accessibility label for the chart. + * - When a string: Used as a static label for the chart element + * - When a function: Called with the active item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((activeItem: ActiveItem) => 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; + + // Legacy props for backwards compatibility + /** + * @deprecated Use `interaction="single"` instead. Will be removed in next major version. + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onInteractionChange` instead. Will be removed in next major version. + */ + onScrubberPositionChange?: (index: number | undefined) => void; +}; export type CartesianChartProps = CartesianChartBaseProps & - Pick & Omit & { /** * Default font families to use within ChartText. @@ -98,6 +164,10 @@ export type CartesianChartProps = CartesianChartBaseProps & */ chart?: StyleProp; }; + /** + * Allows continuous gestures on the chart to continue outside the bounds of the chart element. + */ + allowOverflowGestures?: boolean; }; export const CartesianChart = memo( @@ -107,10 +177,20 @@ export const CartesianChart = memo( series, children, animate = true, - enableScrubbing, xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, + // New interaction props + interaction, + interactionScope, + activeItem, + activeItems, + onInteractionChange, + accessibilityLabel, + accessibilityMode, + accessibilityChunkCount, + // Legacy props + enableScrubbing, onScrubberPositionChange, width = '100%', height = '100%', @@ -429,11 +509,26 @@ export const CartesianChart = memo( return [style, styles?.root]; }, [style, styles?.root]); + // Resolve interaction mode (backwards compatibility with enableScrubbing) + const resolvedInteraction: InteractionMode = useMemo(() => { + if (interaction !== undefined) return interaction; + if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; + return 'single'; // Default to single + }, [interaction, enableScrubbing]); + return ( - {children} - + ); }, diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 7042f6699..6d3e66e58 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 './interaction'; export * from './line'; export * from './Path'; export * from './PeriodSelector'; diff --git a/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx b/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx new file mode 100644 index 000000000..e4d35053f --- /dev/null +++ b/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx @@ -0,0 +1,547 @@ +import React, { useCallback, useMemo } 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 { useCartesianChartContext } from '../ChartProvider'; +import { + type ActiveItem, + type ActiveItems, + InteractionContext, + type InteractionContextValue, + type InteractionMode, + type InteractionScope, + type InteractionState, + invertSerializableScale, + ScrubberContext, + type ScrubberContextValue, +} from '../utils'; +import { getPointOnSerializableScale } from '../utils/point'; + +const defaultInteractionScope: InteractionScope = { + dataIndex: true, + series: false, +}; + +export type InteractionProviderProps = { + children: React.ReactNode; + /** + * Allows continuous gestures on the chart to continue outside the bounds of the chart element. + */ + allowOverflowGestures?: boolean; + /** + * The interaction mode. + * - 'none': Interaction disabled + * - 'single': Single touch interaction (default) + * - 'multi': Multi-touch interaction + * @default 'single' + */ + interaction?: InteractionMode; + /** + * Controls what aspects of the data can be interacted with. + * @default { dataIndex: true, series: false } + */ + interactionScope?: InteractionScope; + /** + * Controlled active item (for single mode). + * - undefined: Uncontrolled mode + * - null: Controlled mode with no active item (ignores user gestures) + * - ActiveItem: Controlled mode with specific active item + */ + activeItem?: ActiveItem | null; + /** + * Controlled active items (for multi mode). + * - undefined: Uncontrolled mode + * - []: Controlled mode with no active items (ignores user gestures) + * - ActiveItems: Controlled mode with specific active items + */ + activeItems?: ActiveItems; + /** + * Callback fired when the active item changes during interaction. + * For single mode: receives `ActiveItem | undefined` + * For multi mode: receives `ActiveItems` + */ + onInteractionChange?: (state: InteractionState) => void; + /** + * Accessibility label for the chart. + * - When a string: Used as a static label for the chart element + * - When a function: Called with the active item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((activeItem: ActiveItem) => 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) + * - 'series': Each series is an accessible region + * @default 'chunked' + */ + accessibilityMode?: 'chunked' | 'item' | 'series'; + /** + * Number of accessible chunks when accessibilityMode is 'chunked'. + * @default 10 + */ + accessibilityChunkCount?: number; + + // Legacy props for backwards compatibility + /** + * @deprecated Use `interaction="single"` instead + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onInteractionChange` instead + */ + onScrubberPositionChange?: (index: number | undefined) => void; +}; + +/** + * InteractionProvider manages chart interaction state and gesture handling for mobile. + * It supports single and multi-touch interactions with configurable scope. + */ +export const InteractionProvider: React.FC = ({ + children, + allowOverflowGestures, + interaction: interactionProp, + interactionScope: scopeProp, + activeItem: controlledActiveItem, + activeItems: controlledActiveItems, + onInteractionChange, + accessibilityLabel, + accessibilityMode = 'chunked', + accessibilityChunkCount = 10, + // Legacy props + enableScrubbing, + onScrubberPositionChange, +}) => { + const chartContext = useCartesianChartContext(); + + if (!chartContext) { + throw new Error('InteractionProvider must be used within a ChartContext'); + } + + const { getXSerializableScale, getXAxis, dataLength } = chartContext; + + // Resolve interaction mode (with backwards compatibility) + const interaction: InteractionMode = useMemo(() => { + if (interactionProp !== undefined) return interactionProp; + if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; + return 'single'; // Default to single + }, [interactionProp, enableScrubbing]); + + const scope: InteractionScope = useMemo( + () => ({ ...defaultInteractionScope, ...scopeProp }), + [scopeProp], + ); + + // Determine if we're in controlled mode + // null/[] means "controlled with no active item" - distinct from undefined (uncontrolled) + const isControlled = controlledActiveItem !== undefined || controlledActiveItems !== undefined; + + // Use SharedValue for UI thread performance + const internalActiveItem = useSharedValue( + interaction === 'multi' ? [] : undefined, + ); + + // The exposed activeItem SharedValue - returns controlled value or internal value + const activeItem: SharedValue = useMemo(() => { + if (isControlled) { + // Create a proxy that returns the controlled value but doesn't update internal state + return { + get value() { + return interaction === 'multi' ? controlledActiveItems : controlledActiveItem; + }, + set value(_newValue: InteractionState) { + // In controlled mode, don't update - the gesture handlers will call onInteractionChange directly + }, + addListener: internalActiveItem.addListener.bind(internalActiveItem), + removeListener: internalActiveItem.removeListener.bind(internalActiveItem), + modify: internalActiveItem.modify.bind(internalActiveItem), + } as SharedValue; + } + return internalActiveItem; + }, [isControlled, interaction, controlledActiveItem, controlledActiveItems, internalActiveItem]); + + 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 { + // For numeric scales with axis data, find the nearest data point + 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 handlers + const handleStartEndHaptics = useCallback(() => { + void Haptics.lightImpact(); + }, []); + + // Handle JS thread callback when active item changes + const handleInteractionChangeJS = useCallback( + (state: InteractionState) => { + onInteractionChange?.(state); + + // Legacy callback support + if (onScrubberPositionChange && interaction === 'single') { + const singleState = state as ActiveItem | undefined; + onScrubberPositionChange(singleState?.dataIndex ?? undefined); + } + }, + [onInteractionChange, onScrubberPositionChange, interaction], + ); + + // React to active item changes and call JS callback + useAnimatedReaction( + () => activeItem.value, + (currentValue, previousValue) => { + if (currentValue !== previousValue) { + runOnJS(handleInteractionChangeJS)(currentValue); + } + }, + [handleInteractionChangeJS], + ); + + // Setter function for context - always fires callback, only updates internal state when uncontrolled + const setActiveItem = useCallback( + (newState: InteractionState) => { + if (!isControlled) { + internalActiveItem.value = newState; + } + onInteractionChange?.(newState); + }, + [isControlled, internalActiveItem, onInteractionChange], + ); + + // Create the long press pan gesture for single mode + const singleTouchGesture = useMemo( + () => + Gesture.Pan() + .activateAfterLongPress(110) + .shouldCancelWhenOutside(!allowOverflowGestures) + .onStart(function onStart(event) { + runOnJS(handleStartEndHaptics)(); + + // Android does not trigger onUpdate when the gesture starts + if (Platform.OS === 'android') { + const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; + const newActiveItem: ActiveItem = { dataIndex, seriesId: null }; + const currentItem = internalActiveItem.value as ActiveItem | undefined; + if (newActiveItem.dataIndex !== currentItem?.dataIndex) { + if (!isControlled) { + internalActiveItem.value = newActiveItem; + } + runOnJS(onInteractionChange ?? (() => {}))(newActiveItem); + } + } + }) + .onUpdate(function onUpdate(event) { + const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; + const newActiveItem: ActiveItem = { dataIndex, seriesId: null }; + const currentItem = internalActiveItem.value as ActiveItem | undefined; + if (newActiveItem.dataIndex !== currentItem?.dataIndex) { + if (!isControlled) { + internalActiveItem.value = newActiveItem; + } + runOnJS(onInteractionChange ?? (() => {}))(newActiveItem); + } + }) + .onEnd(function onEnd() { + if (interaction !== 'none') { + runOnJS(handleStartEndHaptics)(); + if (!isControlled) { + internalActiveItem.value = undefined; + } + runOnJS(onInteractionChange ?? (() => {}))(undefined); + } + }) + .onTouchesCancelled(function onTouchesCancelled() { + if (interaction !== 'none') { + if (!isControlled) { + internalActiveItem.value = undefined; + } + runOnJS(onInteractionChange ?? (() => {}))(undefined); + } + }), + [ + allowOverflowGestures, + handleStartEndHaptics, + getDataIndexFromX, + scope.dataIndex, + internalActiveItem, + interaction, + isControlled, + onInteractionChange, + ], + ); + + // Create multi-touch gesture + const multiTouchGesture = useMemo( + () => + Gesture.Manual() + .shouldCancelWhenOutside(!allowOverflowGestures) + .onTouchesDown(function onTouchesDown(event) { + runOnJS(handleStartEndHaptics)(); + + const items: ActiveItems = event.allTouches.map((touch) => { + const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + return { dataIndex, seriesId: null }; + }); + if (!isControlled) { + internalActiveItem.value = items; + } + runOnJS(onInteractionChange ?? (() => {}))(items); + }) + .onTouchesMove(function onTouchesMove(event) { + const items: ActiveItems = event.allTouches.map((touch) => { + const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + return { dataIndex, seriesId: null }; + }); + if (!isControlled) { + internalActiveItem.value = items; + } + runOnJS(onInteractionChange ?? (() => {}))(items); + }) + .onTouchesUp(function onTouchesUp(event) { + if (event.allTouches.length === 0) { + runOnJS(handleStartEndHaptics)(); + if (!isControlled) { + internalActiveItem.value = []; + } + runOnJS(onInteractionChange ?? (() => {}))([]); + } else { + const items: ActiveItems = event.allTouches.map((touch) => { + const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + return { dataIndex, seriesId: null }; + }); + if (!isControlled) { + internalActiveItem.value = items; + } + runOnJS(onInteractionChange ?? (() => {}))(items); + } + }) + .onTouchesCancelled(function onTouchesCancelled() { + if (!isControlled) { + internalActiveItem.value = []; + } + runOnJS(onInteractionChange ?? (() => {}))([]); + }), + [ + allowOverflowGestures, + handleStartEndHaptics, + getDataIndexFromX, + scope.dataIndex, + internalActiveItem, + isControlled, + onInteractionChange, + ], + ); + + const gesture = interaction === 'multi' ? multiTouchGesture : singleTouchGesture; + + const contextValue: InteractionContextValue = useMemo( + () => ({ + mode: interaction, + scope, + activeItem, + setActiveItem, + }), + [interaction, scope, activeItem, setActiveItem], + ); + + // Derive scrubberPosition from internal active item for backwards compatibility + const scrubberPosition = useDerivedValue(() => { + const state = internalActiveItem.value; + if (state === null || state === undefined) return undefined; + if (Array.isArray(state)) { + // For multi mode, use first item's dataIndex + return state[0]?.dataIndex ?? undefined; + } + return state.dataIndex ?? undefined; + }, [internalActiveItem]); + + // Provide ScrubberContext for backwards compatibility + const scrubberContextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing: interaction !== 'none', + scrubberPosition, + }), + [interaction, scrubberPosition], + ); + + // Helper to get label from accessibilityLabel (string or function) + const getAccessibilityLabelForItem = useCallback( + (item: ActiveItem): string => { + if (typeof accessibilityLabel === 'string') { + return accessibilityLabel; + } + if (typeof accessibilityLabel === 'function') { + return accessibilityLabel(item); + } + return ''; + }, + [accessibilityLabel], + ); + + // Generate accessibility regions based on mode + const accessibilityRegions = useMemo(() => { + // Only generate regions if we have a function label (for dynamic per-item labels) + // Static string labels don't need regions + if (interaction === 'none' || !accessibilityLabel || typeof accessibilityLabel === 'string') { + return null; + } + + const regions: Array<{ + key: string; + flex: number; + label: string; + activeItem: ActiveItem; + }> = []; + + if (accessibilityMode === 'chunked') { + // Divide into chunks + 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: ActiveItem = { dataIndex: startIndex, seriesId: null }; + + regions.push({ + key: `chunk-${i}`, + flex: chunkLength, + label: getAccessibilityLabelForItem(item), + activeItem: item, + }); + } + } else if (accessibilityMode === 'item') { + // Each data point is a region + for (let i = 0; i < dataLength; i++) { + const item: ActiveItem = { dataIndex: i, seriesId: null }; + regions.push({ + key: `item-${i}`, + flex: 1, + label: getAccessibilityLabelForItem(item), + activeItem: item, + }); + } + } + // Note: 'series' mode would require series info from context + + return regions; + }, [ + interaction, + accessibilityLabel, + accessibilityMode, + accessibilityChunkCount, + dataLength, + getAccessibilityLabelForItem, + ]); + + const content = ( + + + {children} + {accessibilityRegions && ( + + {accessibilityRegions.map((region) => ( + { + // Always fire callback, only update internal state when not controlled + if (!isControlled) { + internalActiveItem.value = region.activeItem; + } + onInteractionChange?.(region.activeItem); + // Clear after a short delay + setTimeout(() => { + if (!isControlled) { + internalActiveItem.value = undefined; + } + onInteractionChange?.(undefined); + }, 100); + }} + style={{ flex: region.flex }} + /> + ))} + + )} + + + ); + + // Wrap with gesture handler only if interaction is enabled + if (interaction !== 'none') { + 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/interaction/index.ts b/packages/mobile-visualization/src/chart/interaction/index.ts new file mode 100644 index 000000000..a2fe71804 --- /dev/null +++ b/packages/mobile-visualization/src/chart/interaction/index.ts @@ -0,0 +1,3 @@ +// codegen:start {preset: barrel, include: ./*.tsx} +export * from './InteractionProvider'; +// codegen:end diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index d6372574b..a12e9575c 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -125,3 +125,115 @@ export const useScrubberContext = (): ScrubberContextValue => { } return context; }; + +// ============================================================================ +// Interaction Types (New API) +// ============================================================================ + +/** + * Interaction mode - controls how many simultaneous interactions to track. + * - 'none': Interaction disabled + * - 'single': Single touch interaction (default) + * - 'multi': Multi-touch interaction + */ +export type InteractionMode = 'none' | 'single' | 'multi'; + +/** + * Controls what aspects of the data can be interacted with. + */ +export type InteractionScope = { + /** + * Whether interaction tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether interaction tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * Represents a single active item during interaction. + * - `undefined` means the user is not interacting with the chart + * - `null` values mean the user is interacting but not over a specific item/series + */ +export type ActiveItem = { + /** + * The data index (x-axis position) being interacted with. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being interacted with. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; + +/** + * Active items for multi-touch interaction. + */ +export type ActiveItems = Array; + +/** + * Unified interaction state. + * - For 'single' mode: `ActiveItem | undefined` + * - For 'multi' mode: `ActiveItems` (empty array when not interacting) + */ +/** + * The state of the interaction. + * - `undefined`: No active interaction (uncontrolled) + * - `null`: Controlled mode with no active item (gestures ignored) + * - `ActiveItem`: Single active item + * - `ActiveItems`: Multiple active items (multi-touch) + */ +export type InteractionState = ActiveItem | ActiveItems | undefined | null; + +/** + * Context value for chart interaction state (mobile). + * Uses SharedValue for UI thread performance. + */ +export type InteractionContextValue = { + /** + * The current interaction mode. + */ + mode: InteractionMode; + /** + * The interaction scope configuration. + */ + scope: InteractionScope; + /** + * The current active item(s) during interaction. + * For 'single' mode: SharedValue + * For 'multi' mode: SharedValue + */ + activeItem: SharedValue; + /** + * Function to programmatically set the active item. + */ + setActiveItem: (state: InteractionState) => void; +}; + +export const InteractionContext = createContext(undefined); + +/** + * Hook to access the interaction context. + * @throws Error if used outside of an InteractionProvider + */ +export const useInteractionContext = (): InteractionContextValue => { + const context = useContext(InteractionContext); + if (!context) { + throw new Error('useInteractionContext must be used within an InteractionProvider'); + } + return context; +}; + +/** + * Hook to optionally access the interaction context. + * Returns undefined if not within an InteractionProvider. + */ +export const useOptionalInteractionContext = (): InteractionContextValue | undefined => { + return useContext(InteractionContext); +}; diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 2b541d2a3..578321291 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,9 +5,11 @@ 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 { InteractionProvider } from './interaction/InteractionProvider'; import { CartesianChartProvider } from './ChartProvider'; import { + type ActiveItem, + type ActiveItems, type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, @@ -21,6 +23,9 @@ import { getAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type InteractionMode, + type InteractionScope, + type InteractionState, type Series, useTotalAxisPadding, } from './utils'; @@ -35,33 +40,83 @@ const focusStylesCss = css` } `; -export type CartesianChartBaseProps = BoxBaseProps & - Pick & { - /** - * Configuration objects that define how to visualize the data. - * Each series contains its own data array. - */ - series?: Array; - /** - * Whether to animate the chart. - * @default true - */ - animate?: boolean; - /** - * Configuration for x-axis. - */ - xAxis?: Partial>; - /** - * Configuration for y-axis(es). Can be a single config or array of configs. - */ - yAxis?: Partial> | Partial>[]; - /** - * Inset around the entire chart (outside the axes). - */ - inset?: number | Partial; - }; - -export type CartesianChartProps = Omit, 'title'> & +export type CartesianChartBaseProps = Omit & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data array. + */ + series?: Array; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for x-axis. + */ + xAxis?: Partial>; + /** + * Configuration for y-axis(es). Can be a single config or array of configs. + */ + yAxis?: Partial> | Partial>[]; + /** + * Inset around the entire chart (outside the axes). + */ + inset?: number | Partial; + + // New Interaction API + /** + * The interaction mode. + * - 'none': Interaction disabled + * - 'single': Single pointer/touch interaction (default) + * - 'multi': Multi-touch/multi-pointer interaction + * @default 'single' + */ + interaction?: InteractionMode; + /** + * Controls what aspects of the data can be interacted with. + * @default { dataIndex: true, series: false } + */ + interactionScope?: InteractionScope; + /** + * Controlled active item state (for single mode). + * - `undefined` or not passed: uncontrolled mode, listens to user input + * - `null`: controlled mode with no active item, ignores user input + * - `ActiveItem`: controlled mode with specific active item + */ + activeItem?: ActiveItem | null; + /** + * Controlled active items state (for multi mode). + * - `undefined` or not passed: uncontrolled mode, listens to user input + * - Empty array `[]`: controlled mode with no active items, ignores user input + * - `ActiveItems`: controlled mode with specific active items + */ + activeItems?: ActiveItems; + /** + * Callback fired when the active item changes during interaction. + * For single mode: receives `ActiveItem | undefined` + * For multi mode: receives `ActiveItems` + */ + onInteractionChange?: (state: InteractionState) => void; + /** + * Accessibility label for the chart. + * - When a string: Used as a static label for the chart element + * - When a function: Called with the active item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((activeItem: ActiveItem) => string); + + // Legacy props for backwards compatibility + /** + * @deprecated Use `interaction="single"` instead. Will be removed in next major version. + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onInteractionChange` instead. Will be removed in next major version. + */ + onScrubberPositionChange?: (index: number | undefined) => void; +}; + +export type CartesianChartProps = Omit, 'title' | 'accessibilityLabel'> & CartesianChartBaseProps & { /** * Custom class name for the root element. @@ -109,6 +164,14 @@ export const CartesianChart = memo( xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, + // New interaction props + interaction, + interactionScope, + activeItem, + activeItems, + onInteractionChange, + accessibilityLabel, + // Legacy props enableScrubbing, onScrubberPositionChange, width = '100%', @@ -386,10 +449,25 @@ export const CartesianChart = memo( ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); + // Resolve interaction mode (backwards compatibility with enableScrubbing) + const resolvedInteraction: InteractionMode = useMemo(() => { + if (interaction !== undefined) return interaction; + if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; + return 'single'; // Default to single + }, [interaction, enableScrubbing]); + + const isInteractionEnabled = resolvedInteraction !== 'none'; + return ( - @@ -418,16 +496,16 @@ export const CartesianChart = memo( }} aria-live="polite" as="svg" - className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} + className={cx(isInteractionEnabled && focusStylesCss, classNames?.chart)} height="100%" style={styles?.chart} - tabIndex={enableScrubbing ? 0 : undefined} + tabIndex={isInteractionEnabled ? 0 : undefined} width="100%" > {children} - + ); }, diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx new file mode 100644 index 000000000..cba15b310 --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -0,0 +1,506 @@ +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 type { ActiveItem, ActiveItems, InteractionState } from '../utils'; +import { useInteractionContext, 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 interaction with the new API + */ +export function BasicInteraction() { + const [activeItem, setActiveItem] = useState(undefined); + + const accessibilityLabel = useCallback((item: ActiveItem) => { + if (item.dataIndex === null) return 'Interacting with chart'; + return `Day ${item.dataIndex + 1}: ${formatPrice(samplePrices[item.dataIndex])}`; + }, []); + + return ( + + + Basic Interaction (Single Mode) + + + Hover or touch the chart to see interaction state. + + + + + Active: {activeItem ? `dataIndex: ${activeItem.dataIndex}` : 'Not interacting'} + + + + setActiveItem(state as ActiveItem | undefined)} + series={[{ id: 'price', data: samplePrices }]} + > + + + + ); +} + +/** + * Controlled state - programmatically set the active item + */ +export function ControlledState() { + // null = controlled mode with no active item (ignores user input) + // ActiveItem = controlled mode with specific active item + const [activeItem, setActiveItem] = useState(null); + + return ( + + + Controlled State + + + Use buttons to programmatically select data points. Pass null to clear without listening to + user input. + + + + + + + + + + + + Index: {activeItem?.dataIndex ?? 'none'} + {activeItem?.dataIndex !== undefined && + activeItem.dataIndex !== null && + ` (${formatPrice(samplePrices[activeItem.dataIndex])})`} + + + + + + + + ); +} + +/** + * Interaction disabled + */ +export function InteractionDisabled() { + return ( + + + Interaction Disabled + + + Set interaction="none" to disable all interaction. + + + + + ); +} + +/** + * 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} + interaction="single" + series={[{ id: 'price', data: samplePrices }]} + > + + + + + ); +} + +/** + * Multi-series chart with interaction + */ +export function MultiSeriesInteraction() { + const [activeItem, setActiveItem] = useState(undefined); + + const series1Data = useMemo(() => samplePrices, []); + const series2Data = useMemo(() => samplePrices.map((p) => p * 0.8 + Math.random() * 1000), []); + + return ( + + + Multi-Series Interaction + + + + + Index: {activeItem?.dataIndex ?? 'none'} + {activeItem?.dataIndex !== undefined && activeItem.dataIndex !== null && ( + <> + {' '} + | BTC: {formatPrice(series1Data[activeItem.dataIndex])} | ETH:{' '} + {formatPrice(series2Data[activeItem.dataIndex])} + + )} + + + + setActiveItem(state as ActiveItem | undefined)} + series={[ + { id: 'btc', data: series1Data, color: 'var(--color-fgPrimary)', label: 'BTC' }, + { id: 'eth', data: series2Data, color: 'var(--color-fgPositive)', label: 'ETH' }, + ]} + > + + + + `Day ${dataIndex + 1}`} /> + + + ); +} + +/** + * Interaction callback details + */ +export function InteractionCallbackDetails() { + const [events, setEvents] = useState([]); + + const handleInteractionChange = useCallback((state: InteractionState) => { + const item = state as ActiveItem | undefined; + const event = item + ? `{ dataIndex: ${item.dataIndex}, seriesId: ${item.seriesId ?? 'null'} }` + : 'undefined'; + setEvents((prev) => [...prev.slice(-9), event]); + }, []); + + return ( + + + Interaction Callback Details + + + + + Recent events: + + {events.length === 0 ? ( + + Interact with the chart... + + ) : ( + events.map((event, i) => ( + + {event} + + )) + )} + + + + + + + ); +} + +/** + * Multi-touch interaction with reference lines + */ +export function MultiTouchInteraction() { + const [activeItems, setActiveItems] = useState([]); + + // Custom component that renders a ReferenceLine for each active touch point + const MultiTouchReferenceLines = memo(() => { + const { activeItem } = useInteractionContext(); + const items = (activeItem as ActiveItems) ?? []; + + // 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 Interaction + + + Use multiple fingers on a touch device to see multiple reference lines. Each touch point + gets a different color. + + + + + Active touches: {activeItems.length} + {activeItems.length > 0 && + ` (${activeItems.map((item) => `Day ${(item.dataIndex ?? 0) + 1}`).join(', ')})`} + + + + setActiveItems((state as ActiveItems) ?? [])} + series={[{ id: 'price', data: samplePrices }]} + > + + + + ); +} + +// 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 interaction across multiple charts + */ +export function SynchronizedCharts() { + const [activeItem, setActiveItem] = useState(null); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem((state as ActiveItem) ?? null); + }, []); + + 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: {activeItem?.dataIndex ?? 'none'} + {activeItem?.dataIndex !== null && + activeItem?.dataIndex !== undefined && + ` (A: ${seriesA[activeItem.dataIndex]}, B: ${seriesB[activeItem.dataIndex]})`} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 6b18c5af7..30d89d003 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -5,6 +5,7 @@ export * from './bar/index'; export * from './CartesianChart'; export * from './ChartProvider'; export * from './gradient/index'; +export * from './interaction/index'; export * from './line/index'; export * from './Path'; export * from './PeriodSelector'; diff --git a/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx b/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx new file mode 100644 index 000000000..20e20e3d7 --- /dev/null +++ b/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx @@ -0,0 +1,567 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useCartesianChartContext } from '../ChartProvider'; +import { + type ActiveItem, + type ActiveItems, + InteractionContext, + type InteractionContextValue, + type InteractionMode, + type InteractionScope, + type InteractionState, + isCategoricalScale, + ScrubberContext, + type ScrubberContextValue, +} from '../utils'; + +const defaultInteractionScope: InteractionScope = { + dataIndex: true, + series: false, +}; + +export type InteractionProviderProps = { + children: React.ReactNode; + /** + * A reference to the root SVG element, where interaction event handlers will be attached. + */ + svgRef: React.RefObject | null; + /** + * The interaction mode. + * - 'none': Interaction disabled + * - 'single': Single pointer/touch interaction (default) + * - 'multi': Multi-touch/multi-pointer interaction + * @default 'single' + */ + interaction?: InteractionMode; + /** + * Controls what aspects of the data can be interacted with. + * @default { dataIndex: true, series: false } + */ + interactionScope?: InteractionScope; + /** + * Controlled active item state (for single mode). + * - `undefined` or not passed: uncontrolled mode, listens to user input + * - `null`: controlled mode with no active item, ignores user input + * - `ActiveItem`: controlled mode with specific active item + */ + activeItem?: ActiveItem | null; + /** + * Controlled active items state (for multi mode). + * - `undefined` or not passed: uncontrolled mode, listens to user input + * - Empty array `[]`: controlled mode with no active items, ignores user input + * - `ActiveItems`: controlled mode with specific active items + */ + activeItems?: ActiveItems; + /** + * Callback fired when the active item changes. + * For single mode: receives `ActiveItem | undefined` + * For multi mode: receives `ActiveItems` + */ + onInteractionChange?: (state: InteractionState) => void; + /** + * Accessibility label for the chart. + * - When a string: Used as a static label for the chart element + * - When a function: Called with the active item to generate dynamic labels during interaction + */ + accessibilityLabel?: string | ((activeItem: ActiveItem) => string); + + // Legacy props for backwards compatibility + /** + * @deprecated Use `interaction="single"` instead + */ + enableScrubbing?: boolean; + /** + * @deprecated Use `onInteractionChange` instead + */ + onScrubberPositionChange?: (index: number | undefined) => void; +}; + +/** + * InteractionProvider manages chart interaction state and input handling. + * It supports single and multi-pointer/touch interactions with configurable scope. + */ +export const InteractionProvider: React.FC = ({ + children, + svgRef, + interaction: interactionProp, + interactionScope: scopeProp, + activeItem: controlledActiveItem, + activeItems: controlledActiveItems, + onInteractionChange, + accessibilityLabel, + // Legacy props + enableScrubbing, + onScrubberPositionChange, +}) => { + const chartContext = useCartesianChartContext(); + + if (!chartContext) { + throw new Error('InteractionProvider must be used within a ChartContext'); + } + + const { getXScale, getXAxis, series } = chartContext; + + // Resolve interaction mode (with backwards compatibility) + const interaction: InteractionMode = useMemo(() => { + if (interactionProp !== undefined) return interactionProp; + if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; + return 'single'; // Default to single + }, [interactionProp, enableScrubbing]); + + const scope: InteractionScope = useMemo( + () => ({ ...defaultInteractionScope, ...scopeProp }), + [scopeProp], + ); + + // Determine if we're in controlled mode + // null means "controlled with no active item" - distinct from undefined (uncontrolled) + const isControlled = controlledActiveItem !== undefined || controlledActiveItems !== undefined; + + // Internal state for uncontrolled mode + const [internalActiveItem, setInternalActiveItem] = useState( + interaction === 'multi' ? [] : undefined, + ); + + // Get the current active state (controlled or uncontrolled) + // For controlled mode: null means "no active item" (different from undefined) + const activeState: InteractionState = useMemo(() => { + if (isControlled) { + if (interaction === 'multi') { + return controlledActiveItems ?? []; + } + // For single mode: null → undefined (no active item), otherwise use the value + return controlledActiveItem ?? undefined; + } + return internalActiveItem; + }, [isControlled, interaction, controlledActiveItem, controlledActiveItems, internalActiveItem]); + + // Update active state + const setActiveState = useCallback( + (newState: InteractionState) => { + if (!isControlled) { + setInternalActiveItem(newState); + } + onInteractionChange?.(newState); + + // Legacy callback support + if (onScrubberPositionChange && interaction === 'single') { + const singleState = newState as ActiveItem | undefined; + onScrubberPositionChange(singleState?.dataIndex ?? undefined); + } + }, + [isControlled, onInteractionChange, onScrubberPositionChange, interaction], + ); + + // 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 { + // For numeric scales with axis data, find the nearest data point + 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], + ); + + // Find series at a given point (for series scope) + const getSeriesIdFromPoint = useCallback( + (_mouseX: number, _mouseY: number): string | null => { + // TODO: Implement series detection based on proximity to data points + // For now, return null (series scope not fully implemented) + if (!scope.series) return null; + return null; + }, + [scope.series], + ); + + // Convert pointer position to ActiveItem + const getActiveItemFromPointer = useCallback( + (clientX: number, clientY: number, target: SVGSVGElement): ActiveItem => { + const rect = target.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + const dataIndex = scope.dataIndex ? getDataIndexFromX(x) : null; + const seriesId = scope.series ? getSeriesIdFromPoint(x, y) : null; + + return { dataIndex, seriesId }; + }, + [scope.dataIndex, scope.series, getDataIndexFromX, getSeriesIdFromPoint], + ); + + // Track active pointers for multi-touch + const activePointersRef = React.useRef>( + new Map(), + ); + + // Handle pointer move + const handlePointerMove = useCallback( + (clientX: number, clientY: number, target: SVGSVGElement) => { + if (interaction === 'none' || !series || series.length === 0) return; + + const newActiveItem = getActiveItemFromPointer(clientX, clientY, target); + + if (interaction === 'single') { + const currentItem = activeState as ActiveItem | undefined; + if ( + newActiveItem.dataIndex !== currentItem?.dataIndex || + newActiveItem.seriesId !== currentItem?.seriesId + ) { + setActiveState(newActiveItem); + } + } else if (interaction === 'multi') { + // For mouse in multi mode, treat as a single pointer with id -1 + const currentItems = (activeState as ActiveItems) ?? []; + // Check if data changed to avoid unnecessary updates + if ( + currentItems.length !== 1 || + currentItems[0]?.dataIndex !== newActiveItem.dataIndex || + currentItems[0]?.seriesId !== newActiveItem.seriesId + ) { + setActiveState([newActiveItem]); + } + } + }, + [interaction, series, getActiveItemFromPointer, activeState, setActiveState], + ); + + // Handle multi-pointer update + const updateMultiPointerState = useCallback( + (target: SVGSVGElement) => { + if (interaction !== 'multi') return; + + const activeItems: ActiveItems = Array.from(activePointersRef.current.values()).map( + (pointer) => getActiveItemFromPointer(pointer.clientX, pointer.clientY, target), + ); + + setActiveState(activeItems); + }, + [interaction, getActiveItemFromPointer, setActiveState], + ); + + // Mouse event handlers + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const target = event.currentTarget as SVGSVGElement; + handlePointerMove(event.clientX, event.clientY, target); + }, + [handlePointerMove], + ); + + const handleMouseLeave = useCallback(() => { + if (interaction === 'none') return; + setActiveState(interaction === 'multi' ? [] : undefined); + }, [interaction, setActiveState]); + + // Touch event handlers + const handleTouchStart = useCallback( + (event: TouchEvent) => { + if (interaction === 'none' || !event.touches.length) return; + + const target = event.currentTarget as SVGSVGElement; + + if (interaction === 'multi') { + // Track all touches + for (let i = 0; i < event.touches.length; i++) { + const touch = event.touches[i]; + activePointersRef.current.set(touch.identifier, { + clientX: touch.clientX, + clientY: touch.clientY, + }); + } + updateMultiPointerState(target); + } else { + // Single touch + const touch = event.touches[0]; + handlePointerMove(touch.clientX, touch.clientY, target); + } + }, + [interaction, handlePointerMove, updateMultiPointerState], + ); + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + if (interaction === 'none' || !event.touches.length) return; + event.preventDefault(); // Prevent scrolling while interacting + + const target = event.currentTarget as SVGSVGElement; + + if (interaction === 'multi') { + // Update all touches + for (let i = 0; i < event.touches.length; i++) { + const touch = event.touches[i]; + activePointersRef.current.set(touch.identifier, { + clientX: touch.clientX, + clientY: touch.clientY, + }); + } + updateMultiPointerState(target); + } else { + // Single touch + const touch = event.touches[0]; + handlePointerMove(touch.clientX, touch.clientY, target); + } + }, + [interaction, handlePointerMove, updateMultiPointerState], + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + if (interaction === 'none') return; + + if (interaction === 'multi') { + // Remove ended touches + for (let i = 0; i < event.changedTouches.length; i++) { + const touch = event.changedTouches[i]; + activePointersRef.current.delete(touch.identifier); + } + + if (activePointersRef.current.size === 0) { + setActiveState([]); + } else { + const target = event.currentTarget as SVGSVGElement; + updateMultiPointerState(target); + } + } else { + setActiveState(undefined); + } + }, + [interaction, setActiveState, updateMultiPointerState], + ); + + // Keyboard navigation handler + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (interaction === 'none') return; + + const xScale = getXScale(); + const xAxis = getXAxis(); + + if (!xScale || !xAxis) return; + + const isBand = isCategoricalScale(xScale); + + // Determine navigation bounds + 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 = (activeState as ActiveItem | undefined) ?? { + dataIndex: null, + seriesId: null, + }; + const currentIndex = currentItem.dataIndex ?? minIndex; + const dataRange = maxIndex - minIndex; + + // Multi-step jump when shift is held (10% of data range, minimum 1, maximum 10) + 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(); + setActiveState(interaction === 'multi' ? [] : undefined); + return; + default: + return; + } + + if (newIndex !== currentItem.dataIndex) { + const newActiveItem: ActiveItem = { + dataIndex: newIndex, + seriesId: currentItem.seriesId, + }; + setActiveState(newActiveItem); + } + }, + [interaction, getXScale, getXAxis, activeState, setActiveState], + ); + + const handleBlur = useCallback(() => { + if (interaction === 'none' || activeState === undefined) return; + setActiveState(interaction === 'multi' ? [] : undefined); + }, [interaction, activeState, setActiveState]); + + // Attach event listeners to SVG element + useEffect(() => { + if (!svgRef?.current || interaction === 'none') return; + + const svg = svgRef.current; + + svg.addEventListener('mousemove', handleMouseMove); + svg.addEventListener('mouseleave', handleMouseLeave); + svg.addEventListener('touchstart', handleTouchStart, { passive: false }); + svg.addEventListener('touchmove', handleTouchMove, { passive: false }); + svg.addEventListener('touchend', handleTouchEnd); + svg.addEventListener('touchcancel', handleTouchEnd); + svg.addEventListener('keydown', handleKeyDown); + svg.addEventListener('blur', handleBlur); + + return () => { + svg.removeEventListener('mousemove', handleMouseMove); + svg.removeEventListener('mouseleave', handleMouseLeave); + svg.removeEventListener('touchstart', handleTouchStart); + svg.removeEventListener('touchmove', handleTouchMove); + svg.removeEventListener('touchend', handleTouchEnd); + svg.removeEventListener('touchcancel', handleTouchEnd); + svg.removeEventListener('keydown', handleKeyDown); + svg.removeEventListener('blur', handleBlur); + }; + }, [ + svgRef, + interaction, + handleMouseMove, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + handleKeyDown, + handleBlur, + ]); + + // Update accessibility label when active item changes + useEffect(() => { + if (!svgRef?.current || !accessibilityLabel) return; + + const svg = svgRef.current; + + // If it's a static string, always use it + if (typeof accessibilityLabel === 'string') { + svg.setAttribute('aria-label', accessibilityLabel); + return; + } + + // If it's a function, use it for dynamic labels during interaction + if (interaction === 'none') return; + + const currentItem = interaction === 'single' ? (activeState as ActiveItem | undefined) : null; + + if (currentItem && currentItem.dataIndex !== null) { + svg.setAttribute('aria-label', accessibilityLabel(currentItem)); + } else { + svg.removeAttribute('aria-label'); + } + }, [svgRef, interaction, activeState, accessibilityLabel]); + + const contextValue: InteractionContextValue = useMemo( + () => ({ + mode: interaction, + scope, + activeItem: activeState, + setActiveItem: setActiveState, + }), + [interaction, scope, activeState, setActiveState], + ); + + // Provide ScrubberContext for backwards compatibility with Scrubber component + // Derive scrubberPosition from activeItem.dataIndex + const scrubberPosition = useMemo(() => { + if (interaction === 'none') return undefined; + if (interaction === 'single') { + const item = activeState as ActiveItem | undefined; + return item?.dataIndex ?? undefined; + } + // For multi mode, use the first item's dataIndex + const items = activeState as ActiveItems | undefined; + return items?.[0]?.dataIndex ?? undefined; + }, [interaction, activeState]); + + const scrubberContextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing: interaction !== 'none', + scrubberPosition, + onScrubberPositionChange: (index: number | undefined) => { + if (interaction === 'none') return; + if (index === undefined) { + setActiveState(undefined); + } else { + setActiveState({ dataIndex: index, seriesId: null }); + } + }, + }), + [interaction, scrubberPosition, setActiveState], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/web-visualization/src/chart/interaction/index.ts b/packages/web-visualization/src/chart/interaction/index.ts new file mode 100644 index 000000000..a2fe71804 --- /dev/null +++ b/packages/web-visualization/src/chart/interaction/index.ts @@ -0,0 +1,3 @@ +// codegen:start {preset: barrel, include: ./*.tsx} +export * from './InteractionProvider'; +// codegen:end diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index c976ac02b..6f58f0b77 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -110,3 +110,107 @@ export const useScrubberContext = (): ScrubberContextValue => { } return context; }; + +// ============================================================================ +// Interaction Types (New API) +// ============================================================================ + +/** + * Interaction mode - controls how many simultaneous interactions to track. + * - 'none': Interaction disabled + * - 'single': Single pointer/touch interaction (default) + * - 'multi': Multi-touch/multi-pointer interaction + */ +export type InteractionMode = 'none' | 'single' | 'multi'; + +/** + * Controls what aspects of the data can be interacted with. + */ +export type InteractionScope = { + /** + * Whether interaction tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether interaction tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * Represents a single active item during interaction. + * - `undefined` means the user is not interacting with the chart + * - `null` values mean the user is interacting but not over a specific item/series + */ +export type ActiveItem = { + /** + * The data index (x-axis position) being interacted with. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being interacted with. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; + +/** + * Active items for multi-touch/multi-pointer interaction. + */ +export type ActiveItems = Array; + +/** + * Unified interaction state. + * - For 'single' mode: `ActiveItem | undefined` + * - For 'multi' mode: `ActiveItems` (empty array when not interacting) + */ +export type InteractionState = ActiveItem | ActiveItems | undefined; + +/** + * Context value for chart interaction state. + */ +export type InteractionContextValue = { + /** + * The current interaction mode. + */ + mode: InteractionMode; + /** + * The interaction scope configuration. + */ + scope: InteractionScope; + /** + * The current active item(s) during interaction. + * For 'single' mode: `ActiveItem | undefined` + * For 'multi' mode: `ActiveItems` + */ + activeItem: InteractionState; + /** + * Callback to update the active item state. + */ + setActiveItem: (item: InteractionState) => void; +}; + +export const InteractionContext = createContext(undefined); + +/** + * Hook to access the interaction context. + * @throws Error if used outside of an InteractionProvider + */ +export const useInteractionContext = (): InteractionContextValue => { + const context = useContext(InteractionContext); + if (!context) { + throw new Error('useInteractionContext must be used within an InteractionProvider'); + } + return context; +}; + +/** + * Hook to optionally access the interaction context. + * Returns undefined if not within an InteractionProvider. + */ +export const useOptionalInteractionContext = (): InteractionContextValue | undefined => { + return useContext(InteractionContext); +}; From 5aef1c3d1e0b5afe2bff30224dfd24e923545866 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 15 Jan 2026 10:58:57 -0500 Subject: [PATCH 02/16] Support highlighting bars --- .../src/chart/bar/Bar.tsx | 7 + .../src/chart/bar/BarStack.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 8 + .../chart/__stories__/Interaction.stories.tsx | 218 ++++++++++++++++++ .../web-visualization/src/chart/bar/Bar.tsx | 9 +- .../src/chart/bar/BarStack.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 47 +++- .../chart/interaction/InteractionProvider.tsx | 32 ++- 8 files changed, 310 insertions(+), 13 deletions(-) diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index c6e8ae052..3ffaade95 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -47,6 +47,11 @@ export type BarBaseProps = { * The y-axis data value for this bar. */ dataY?: number | [number, number] | null; + /** + * The series ID this bar belongs to. + * Used for interaction tracking when `interactionScope.series` is true. + */ + seriesId?: string; /** * Fill color for the bar. */ @@ -106,6 +111,7 @@ export const Bar = memo( originY, dataX, dataY, + seriesId, BarComponent = DefaultBar, fill, fillOpacity = 1, @@ -146,6 +152,7 @@ export const Bar = memo( originY={effectiveOriginY} roundBottom={roundBottom} roundTop={roundTop} + seriesId={seriesId} stroke={stroke} strokeWidth={strokeWidth} transition={transition} diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index 53aa12100..458a6c141 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -676,6 +676,7 @@ export const BarStack = memo( originY={baseline} roundBottom={bar.roundBottom} roundTop={bar.roundTop} + seriesId={bar.seriesId} stroke={defaultStroke} strokeWidth={defaultStrokeWidth} transition={transition} diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index ff9bd8014..bd7fa7edb 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -11,6 +11,10 @@ export type DefaultBarProps = BarComponentProps; /** * Default bar component that renders a solid bar with animation support. + * + * Note: Series-level interaction tracking on mobile requires coordinate-based hit testing + * in the gesture handler, as Skia paths don't support touch events directly. + * The `seriesId` prop is available for future series interaction implementations. */ export const DefaultBar = memo( ({ @@ -27,6 +31,10 @@ export const DefaultBar = memo( stroke, strokeWidth, originY, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dataX, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + seriesId, transition, }) => { const { animate } = useCartesianChartContext(); diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx index cba15b310..017462438 100644 --- a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -504,3 +504,221 @@ export function SynchronizedCharts() { ); } + +/** + * Series interaction - track which specific bar/series is being hovered + */ +export function SeriesInteraction() { + const [activeItem, setActiveItem] = useState(undefined); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem(state as ActiveItem | undefined); + }, []); + + const seriesColors: Record = { + A: 'var(--color-fgPrimary)', + B: 'var(--color-fgPositive)', + C: 'var(--color-fgWarning)', + }; + + return ( + + + Series Interaction + + + Hover over individual bars to see both dataIndex and seriesId tracked. Uses InteractiveBar + component. + + + + + {activeItem ? ( + <> + Index: {activeItem.dataIndex ?? 'none'} + {activeItem.seriesId && ( + <> + {' '} + | Series:{' '} + + {activeItem.seriesId} + + + )} + + ) : ( + 'Hover over a bar...' + )} + + + + + + ); +} + +/** + * Test overlapping bars with separate BarPlots to verify z-order behavior + */ +export function OverlappingBarsZOrder() { + const [activeItem, setActiveItem] = useState(undefined); + const [eventLog, setEventLog] = useState([]); + + const handleInteractionChange = useCallback((state: InteractionState) => { + const item = state as ActiveItem | undefined; + setActiveItem(item); + + // 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. + + + + + {activeItem ? ( + <> + Index: {activeItem.dataIndex ?? 'none'} + {activeItem.seriesId && ( + <> + {' '} + | Series:{' '} + + {activeItem.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/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index e7f3f7b8b..cd04644d3 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -42,13 +42,18 @@ export type BarBaseProps = { */ originY?: number; /** - * The x-axis data value for this bar. + * The x-axis data index for this bar. */ dataX?: number | string; /** * The y-axis data value for this bar. */ dataY?: number | [number, number] | null; + /** + * The series ID this bar belongs to. + * Used for interaction tracking when `interactionScope.series` is true. + */ + seriesId?: string; /** * Fill color for the bar. */ @@ -108,6 +113,7 @@ export const Bar = memo( originY, dataX, dataY, + seriesId, BarComponent = DefaultBar, fill = 'var(--color-fgPrimary)', fillOpacity = 1, @@ -140,6 +146,7 @@ export const Bar = memo( originY={effectiveOriginY} roundBottom={roundBottom} roundTop={roundTop} + seriesId={seriesId} stroke={stroke} strokeWidth={strokeWidth} transition={transition} diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 37fb64c3c..15b396645 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -687,6 +687,7 @@ export const BarStack = memo( originY={baseline} roundBottom={bar.roundBottom} roundTop={bar.roundTop} + seriesId={bar.seriesId} stroke={bar.stroke ?? defaultStroke} strokeWidth={bar.strokeWidth ?? defaultStrokeWidth} transition={transition} diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index c5cdf6339..db46f73fc 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,8 +1,8 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import { getBarPath } from '../utils'; +import { getBarPath, useOptionalInteractionContext } from '../utils'; import type { BarComponentProps } from './Bar'; @@ -19,6 +19,7 @@ export type DefaultBarProps = BarComponentProps & { /** * Default bar component that renders a solid bar with animation. + * Automatically tracks series interaction when `interactionScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -33,10 +34,12 @@ export const DefaultBar = memo( fillOpacity = 1, dataX, dataY, + seriesId, transition, ...props }) => { const { animate } = useCartesianChartContext(); + const interactionContext = useOptionalInteractionContext(); const initialPath = useMemo(() => { if (!animate) return undefined; @@ -46,10 +49,48 @@ export const DefaultBar = memo( return getBarPath(x, initialY, width, minHeight, borderRadius, !!roundTop, !!roundBottom); }, [animate, x, originY, width, borderRadius, roundTop, roundBottom]); + // Get the data index as a number for interaction + const dataIndex = typeof dataX === 'number' ? dataX : null; + + const handleMouseEnter = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + interactionContext.setActiveItem({ + dataIndex, + seriesId: seriesId ?? null, + }); + }, [interactionContext, dataIndex, seriesId]); + + const handleMouseLeave = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + // Reset to just dataIndex (keep dataIndex tracking, clear series) + if (interactionContext.scope.dataIndex) { + interactionContext.setActiveItem({ + dataIndex, + seriesId: null, + }); + } else { + interactionContext.setActiveItem(undefined); + } + }, [interactionContext, dataIndex]); + + // Only add event handlers when series scope is enabled + const eventHandlers = interactionContext?.scope.series + ? { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + style: { cursor: 'pointer' }, + } + : {}; + if (animate && initialPath) { return ( ( ); } - return ; + return ; }, ); diff --git a/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx b/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx index 20e20e3d7..a81cce5a2 100644 --- a/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx +++ b/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx @@ -247,26 +247,40 @@ export const InteractionProvider: React.FC = ({ if (interaction === 'single') { const currentItem = activeState as ActiveItem | undefined; + + // When series scope is enabled, preserve the existing seriesId + // (let bar components handle setting/clearing seriesId via their own handlers) + const effectiveSeriesId = scope.series + ? (currentItem?.seriesId ?? newActiveItem.seriesId) + : newActiveItem.seriesId; + + const effectiveItem = { ...newActiveItem, seriesId: effectiveSeriesId }; + if ( - newActiveItem.dataIndex !== currentItem?.dataIndex || - newActiveItem.seriesId !== currentItem?.seriesId + effectiveItem.dataIndex !== currentItem?.dataIndex || + effectiveItem.seriesId !== currentItem?.seriesId ) { - setActiveState(newActiveItem); + setActiveState(effectiveItem); } } else if (interaction === 'multi') { - // For mouse in multi mode, treat as a single pointer with id -1 + // For mouse in multi mode, treat as a single pointer const currentItems = (activeState as ActiveItems) ?? []; - // Check if data changed to avoid unnecessary updates + const currentSeriesId = scope.series ? currentItems[0]?.seriesId : null; + const effectiveItem = { + ...newActiveItem, + seriesId: currentSeriesId ?? newActiveItem.seriesId, + }; + if ( currentItems.length !== 1 || - currentItems[0]?.dataIndex !== newActiveItem.dataIndex || - currentItems[0]?.seriesId !== newActiveItem.seriesId + currentItems[0]?.dataIndex !== effectiveItem.dataIndex || + currentItems[0]?.seriesId !== effectiveItem.seriesId ) { - setActiveState([newActiveItem]); + setActiveState([effectiveItem]); } } }, - [interaction, series, getActiveItemFromPointer, activeState, setActiveState], + [interaction, series, scope.series, getActiveItemFromPointer, activeState, setActiveState], ); // Handle multi-pointer update From a154505e3bf39bba52ff8549855e5d9537701fad Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Fri, 16 Jan 2026 10:48:21 -0500 Subject: [PATCH 03/16] wip lines --- ...chart-interaction-mobile-implementation.md | 634 +++++++++++++++++ docs/research/chart-interaction-revamp.md | 666 ++++++++++++++++++ .../research/pointer-events-simplification.md | 395 +++++++++++ .../chart/__stories__/Interaction.stories.tsx | 421 +++++++++++ .../src/chart/bar/DefaultBar.tsx | 33 +- .../chart/interaction/InteractionProvider.tsx | 252 +++++-- .../src/chart/utils/context.ts | 70 ++ .../chart/__stories__/Interaction.stories.tsx | 161 +++++ .../src/chart/bar/DefaultBar.tsx | 2 +- .../src/chart/line/DottedLine.tsx | 78 +- .../web-visualization/src/chart/line/Line.tsx | 38 +- .../src/chart/line/SolidLine.tsx | 78 +- .../line/__stories__/LineChart.stories.tsx | 500 ++++++++++++- 13 files changed, 3270 insertions(+), 58 deletions(-) create mode 100644 docs/research/chart-interaction-mobile-implementation.md create mode 100644 docs/research/chart-interaction-revamp.md create mode 100644 docs/research/pointer-events-simplification.md create mode 100644 packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx diff --git a/docs/research/chart-interaction-mobile-implementation.md b/docs/research/chart-interaction-mobile-implementation.md new file mode 100644 index 000000000..91db5d55a --- /dev/null +++ b/docs/research/chart-interaction-mobile-implementation.md @@ -0,0 +1,634 @@ +# Chart Interaction System - Mobile Implementation Guide + +> **Purpose**: This document provides all context needed to implement series-level interaction on mobile, mirroring the web implementation. + +## Table of Contents + +1. [Background & Context](#background--context) +2. [Web Implementation Summary](#web-implementation-summary) +3. [Mobile Implementation Plan](#mobile-implementation-plan) +4. [API Reference](#api-reference) +5. [Key Learnings & Gotchas](#key-learnings--gotchas) +6. [Testing Requirements](#testing-requirements) + +--- + +## Background & Context + +### Problem Statement + +The CDS chart interaction system needed to be revamped to support: + +- **Series-level interaction**: Knowing which specific series (line, bar) the user is interacting with, not just which data index +- **Controlled state**: External control of the active item via props +- **Multi-touch support**: Multiple simultaneous touch points (mobile) +- **Backward compatibility**: Existing `enableScrubbing` and `onScrubberPositionChange` props must continue working +- **Future polar chart support**: Architecture should be chart-type agnostic + +### New Interaction API + +```tsx +// New API + console.log(item)} +/> + +// Legacy API (still supported via mapping) + console.log(index)} +/> +``` + +### Key Types + +```typescript +// packages/*/src/chart/utils/context.ts + +type InteractionMode = 'none' | 'single' | 'multi'; + +type InteractionScope = { + dataIndex?: boolean; // Track which data point (x-axis position) + series?: boolean; // Track which series (line, bar, etc.) +}; + +type ActiveItem = { + dataIndex: number | null; + seriesId: string | null; +}; + +type ActiveItems = ActiveItem[]; // For multi-touch + +// For controlled state: +// - undefined = uncontrolled (internal state management) +// - null = controlled with no active item (gestures still fire onInteractionChange but don't update internal state) +// - ActiveItem = controlled with specific active item +type InteractionState = ActiveItem | ActiveItems | null | undefined; +``` + +--- + +## Web Implementation Summary + +### Architecture + +``` +CartesianChart + └── InteractionProvider (new) + ├── Handles mouse/touch events at SVG level + ├── Calculates dataIndex from x position + ├── Provides InteractionContext + └── ScrubberProvider (wrapped for backward compat) + └── Chart content (Line, Bar, Scrubber, etc.) +``` + +### How Series Interaction Works on Web + +#### For Bars (`DefaultBar.tsx`) + +Bars are individual `` elements with native mouse events: + +```tsx +// packages/web-visualization/src/chart/bar/DefaultBar.tsx +const DefaultBar = ({ seriesId, dataIndex, ...props }) => { + const interactionContext = useOptionalInteractionContext(); + + const handleMouseEnter = useCallback(() => { + if (!interactionContext?.scope.series) return; + + const currentItem = interactionContext.activeItem; + const currentDataIndex = currentItem?.dataIndex ?? null; + + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: seriesId ?? null, + }); + }, [interactionContext, seriesId]); + + const handleMouseLeave = useCallback(() => { + if (!interactionContext?.scope.series) return; + + const currentDataIndex = interactionContext.activeItem?.dataIndex ?? null; + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: null, // Clear series, keep dataIndex + }); + }, [interactionContext]); + + return ; +}; +``` + +#### For Lines (`SolidLine.tsx`, `DottedLine.tsx`) + +**CRITICAL**: framer-motion's `motion.path` does NOT reliably forward mouse events. We discovered this through debugging and had to use raw `` elements for event handling. + +```tsx +// packages/web-visualization/src/chart/line/SolidLine.tsx +const SolidLine = ({ seriesId, interactionOffset, strokeWidth, d, ...props }) => { + const interactionContext = useOptionalInteractionContext(); + + // Determine if we need event handling + const needsEventHandling = interactionContext?.scope.series && seriesId; + + // Calculate event path stroke width (includes interactionOffset for larger hit area) + const eventPathStrokeWidth = + interactionOffset && interactionOffset > 0 ? strokeWidth + interactionOffset * 2 : strokeWidth; + + const handleMouseEnter = useCallback(() => { + // Similar to DefaultBar - preserve dataIndex, set seriesId + }, [interactionContext, seriesId]); + + const handleMouseLeave = useCallback(() => { + // Similar to DefaultBar - preserve dataIndex, clear seriesId + }, [interactionContext, seriesId]); + + return ( + <> + {/* Visible line - MUST have pointerEvents: 'none' when event handling is needed */} + + + {/* + Event handling layer - RAW , NOT framer-motion Path! + This is critical - motion.path doesn't forward mouse events reliably. + */} + {needsEventHandling && ( + + )} + + ); +}; +``` + +### Z-Order Behavior for Overlapping Elements + +When multiple elements overlap (e.g., two bar series at the same x position), the element rendered **last** in the DOM receives mouse events first. This is standard SVG/DOM behavior. + +**Example**: If `BarPlot A` is rendered before `BarPlot B`, hovering over an overlapping area will detect `B`. + +```tsx + {/* Rendered first = underneath */} + {/* Rendered second = on top, receives events */} +``` + +This is documented behavior and matches user expectations (what you see on top is what you interact with). + +--- + +## Mobile Implementation Plan + +### Key Differences from Web + +| Aspect | Web (SVG) | Mobile (Skia) | +| ---------------- | ------------------------------------ | --------------------------------------------------- | +| Rendering | SVG elements in DOM | Skia canvas drawing | +| Element events | Native `onMouseEnter`/`onMouseLeave` | ❌ Not available | +| Gesture handling | Per-element + chart-level | Chart-level only via `react-native-gesture-handler` | +| Hit detection | Browser handles automatically | **Must implement manually** | + +### Implementation Strategy + +Since Skia paths don't have touch events, we need **coordinate-based hit testing** in the gesture handler. + +#### Phase 1: Bar Interaction (Recommended Starting Point) + +Bars have known, simple bounding boxes. This is the easiest to implement. + +**Step 1**: Create a registry for bar bounds + +```typescript +// packages/mobile-visualization/src/chart/utils/context.ts + +type ElementBounds = { + x: number; + y: number; + width: number; + height: number; + dataIndex: number; + seriesId: string; +}; + +type InteractionRegistry = { + bars: ElementBounds[]; + // Future: points, lines +}; + +// Add to InteractionContextValue +type InteractionContextValue = { + // ... existing fields + registerBar: (bounds: ElementBounds) => void; + unregisterBar: (seriesId: string, dataIndex: number) => void; + registry: InteractionRegistry; +}; +``` + +**Step 2**: Register bounds when rendering bars + +```typescript +// packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +const DefaultBar = ({ x, y, width, height, dataIndex, seriesId, ...props }) => { + const interactionContext = useOptionalInteractionContext(); + + useEffect(() => { + if (!interactionContext?.scope.series || !seriesId) return; + + interactionContext.registerBar({ + x, y, width, height, + dataIndex, + seriesId, + }); + + return () => { + interactionContext.unregisterBar(seriesId, dataIndex); + }; + }, [x, y, width, height, dataIndex, seriesId, interactionContext]); + + return ; +}; +``` + +**Step 3**: Hit test in gesture handler + +```typescript +// packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx + +const findBarAtPoint = ( + x: number, + y: number, + registry: InteractionRegistry, +): ElementBounds | null => { + // Iterate in reverse order (last rendered = on top = checked first) + for (let i = registry.bars.length - 1; i >= 0; i--) { + const bar = registry.bars[i]; + if (x >= bar.x && x <= bar.x + bar.width && y >= bar.y && y <= bar.y + bar.height) { + return bar; + } + } + return null; +}; + +// In gesture handler: +const onGestureEvent = useCallback( + (event: GestureEvent) => { + const { x, y } = event; + + // Calculate dataIndex from x position (existing logic) + const dataIndex = calculateDataIndex(x); + + // Check for series interaction + let seriesId: string | null = null; + if (scope.series) { + const hitBar = findBarAtPoint(x, y, registry); + if (hitBar) { + seriesId = hitBar.seriesId; + } + } + + setActiveItem({ dataIndex, seriesId }); + }, + [registry, scope], +); +``` + +#### Phase 2: Point/Scatter Interaction + +Points are circles with known centers and radii. + +```typescript +type PointBounds = { + cx: number; + cy: number; + radius: number; + dataIndex: number; + seriesId: string; +}; + +const findPointAtTouch = ( + touchX: number, + touchY: number, + points: PointBounds[], + touchTolerance: number = 10, // Extra pixels for easier touch +): PointBounds | null => { + for (let i = points.length - 1; i >= 0; i--) { + const point = points[i]; + const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); + if (distance <= point.radius + touchTolerance) { + return point; + } + } + return null; +}; +``` + +#### Phase 3: Line Interaction (Most Complex) + +Two approaches: + +**Option A: Skia Path Hit Testing (Recommended)** + +```typescript +import { Skia, PathOp } from '@shopify/react-native-skia'; + +type LinePath = { + pathString: string; // SVG path d attribute + seriesId: string; +}; + +const findLineAtTouch = ( + touchX: number, + touchY: number, + lines: LinePath[], + hitAreaWidth: number = 20, // Pixel tolerance +): LinePath | null => { + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + const path = Skia.Path.MakeFromSVGString(line.pathString); + + if (!path) continue; + + // Create a stroked version of the path for hit testing + const strokePath = path.copy().stroke({ + width: hitAreaWidth, + cap: Skia.CapStyle.Round, + join: Skia.JoinStyle.Round, + }); + + if (strokePath?.contains(touchX, touchY)) { + return line; + } + } + return null; +}; +``` + +**Option B: Distance-Based (Fallback)** + +Calculate perpendicular distance from touch point to line segments: + +```typescript +const distanceToLineSegment = ( + px: number, + py: number, // Touch point + x1: number, + y1: number, // Line segment start + x2: number, + y2: number, // Line segment end +): number => { + const A = px - x1; + const B = py - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) param = dot / lenSq; + + let xx, yy; + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + return Math.sqrt(Math.pow(px - xx, 2) + Math.pow(py - yy, 2)); +}; + +const findNearestLine = ( + touchX: number, + touchY: number, + lines: LineData[], + maxDistance: number = 20, +): LineData | null => { + let nearest: LineData | null = null; + let minDistance = maxDistance; + + for (const line of lines) { + for (let i = 0; i < line.points.length - 1; i++) { + const dist = distanceToLineSegment( + touchX, + touchY, + line.points[i].x, + line.points[i].y, + line.points[i + 1].x, + line.points[i + 1].y, + ); + if (dist < minDistance) { + minDistance = dist; + nearest = line; + } + } + } + + return nearest; +}; +``` + +### Performance Considerations + +1. **Registry updates**: Use refs or shared values to avoid re-renders when registering bounds +2. **Hit testing frequency**: Consider throttling hit tests during rapid pan gestures +3. **Path parsing**: Cache parsed Skia paths, don't recreate on every touch +4. **Z-order**: Process elements in reverse render order for correct "topmost wins" behavior + +### Mobile Files to Modify + +``` +packages/mobile-visualization/src/chart/ +├── utils/context.ts # Add InteractionRegistry types +├── interaction/ +│ └── InteractionProvider.tsx # Add hit testing logic, registry management +├── bar/ +│ ├── DefaultBar.tsx # Register bounds on mount, unregister on unmount +│ └── BarStack.tsx # Pass seriesId to DefaultBar (already done) +├── line/ +│ ├── SolidLine.tsx # Register path on mount (Phase 3) +│ └── DottedLine.tsx # Register path on mount (Phase 3) +└── scatter/ + └── Point.tsx # Register center/radius (Phase 2) +``` + +--- + +## API Reference + +### InteractionProvider Props + +| Prop | Type | Default | Description | +| --------------------- | ------------------------------------------- | ------------------------------------ | ------------------------------------ | +| `interaction` | `'none' \| 'single' \| 'multi'` | `'single'` | Interaction mode | +| `interactionScope` | `{ dataIndex?: boolean; series?: boolean }` | `{ dataIndex: true, series: false }` | What to track | +| `activeItem` | `ActiveItem \| null` | `undefined` | Controlled active item (single mode) | +| `activeItems` | `ActiveItems \| []` | `undefined` | Controlled active items (multi mode) | +| `onInteractionChange` | `(state: InteractionState) => void` | - | Callback when interaction changes | +| `accessibilityLabel` | `string \| ((item: ActiveItem) => string)` | - | Static or dynamic a11y label | + +### InteractionContext Value + +```typescript +type InteractionContextValue = { + mode: InteractionMode; + scope: InteractionScope; + activeItem: InteractionState; + setActiveItem: (item: InteractionState) => void; + + // Mobile-specific (to be added): + registry: InteractionRegistry; + registerBar: (bounds: ElementBounds) => void; + unregisterBar: (seriesId: string, dataIndex: number) => void; + // Future: registerLine, registerPoint, etc. +}; +``` + +### Controlled State Behavior + +| Value | Meaning | Gesture Behavior | +| ------------------------------- | -------------------------- | -------------------------------------------------- | +| `undefined` | Uncontrolled | Updates internal state, fires callback | +| `null` (single) or `[]` (multi) | Controlled, no active item | Does NOT update internal state, DOES fire callback | +| `ActiveItem` / `ActiveItems` | Controlled with value | Uses provided value, fires callback | + +--- + +## Key Learnings & Gotchas + +### 1. framer-motion Doesn't Forward Mouse Events Reliably + +**Problem**: When using framer-motion's `motion.path` for SVG lines, `onMouseEnter`/`onMouseLeave` events don't fire reliably. + +**Solution**: Use raw `` elements for event handling, with `motion.path` only for animated visuals. + +```tsx +// ❌ WRONG - events don't fire reliably + + +// ✅ CORRECT - separate visual and event layers + + +``` + +### 2. Preserve dataIndex When Updating seriesId + +When a user hovers over a series element, we should preserve the current `dataIndex` (from mouse x position) while updating `seriesId`: + +```typescript +// ✅ CORRECT +const handleMouseEnter = () => { + const currentDataIndex = interactionContext.activeItem?.dataIndex ?? null; + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, // Preserve! + seriesId: seriesId, + }); +}; +``` + +### 3. InteractionProvider Must Preserve seriesId on Pointer Move + +The `InteractionProvider.handlePointerMove` fires on every pixel of mouse movement. It must NOT overwrite `seriesId` set by child components: + +```typescript +// In InteractionProvider.handlePointerMove: +const newActiveItem = { dataIndex, seriesId: null }; + +// Preserve existing seriesId if series scope is enabled +if (scope.series) { + const currentItem = activeItemRef.current; + const effectiveItem = { + ...newActiveItem, + seriesId: currentItem?.seriesId ?? null, // Preserve! + }; + // Use effectiveItem for state update +} +``` + +### 4. Z-Order Follows Render Order + +Last rendered element is on top and receives events first. This matches visual expectations. + +### 5. Mobile Needs Coordinate-Based Hit Testing + +Unlike web where the browser handles hit detection, mobile requires manual implementation because Skia canvas elements don't have touch events. + +--- + +## Testing Requirements + +### Web Stories (Already Implemented) + +Location: `packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx` + +- `BasicInteraction` - Single mode interaction +- `ControlledState` - Programmatic control with null handling +- `InteractionDisabled` - `interaction="none"` +- `BackwardsCompatibility` - Legacy props still work +- `AccessibilityLabels` - Static and dynamic labels +- `MultiSeriesInteraction` - Multiple series, single mode +- `InteractionCallbackDetails` - Event logging +- `MultiTouchInteraction` - Multi-touch with reference lines +- `SynchronizedCharts` - Two charts sharing state +- `SeriesInteraction` - Bar hover with seriesId tracking +- `OverlappingBarsZOrder` - Z-order verification +- `LineSeriesInteraction` - Line hover with interactionOffset + +### Mobile Tests Needed + +1. **Bar interaction**: Touch a bar, verify `seriesId` is set +2. **Overlapping elements**: Touch overlap area, verify topmost element wins +3. **Pan across bars**: Verify `seriesId` updates as finger moves between bars +4. **Controlled state**: Verify `null` prevents internal state update but fires callback +5. **Performance**: Verify smooth interaction with many bars/lines + +### Debug Logging + +During development, add logging to verify hit detection: + +```typescript +console.log('[InteractionProvider] hit test', { + touchX, + touchY, + hitBar: hitBar?.seriesId ?? 'none', + registeredBars: registry.bars.length, +}); +``` + +Remove all `console.log` statements before merging. + +--- + +## Summary: What Needs to Be Done for Mobile + +1. **Add `InteractionRegistry` types** to `context.ts` +2. **Add registry management** to `InteractionProvider` (register/unregister functions, ref storage) +3. **Add hit testing** to gesture handler in `InteractionProvider` +4. **Update `DefaultBar`** to register bounds on mount +5. **Add mobile stories** to test series interaction +6. **Future**: Extend to points and lines + +Start with bars - they're the simplest and establish the pattern. Lines are the most complex due to path hit testing requirements. diff --git a/docs/research/chart-interaction-revamp.md b/docs/research/chart-interaction-revamp.md new file mode 100644 index 000000000..065ca5c17 --- /dev/null +++ b/docs/research/chart-interaction-revamp.md @@ -0,0 +1,666 @@ +# Chart Interaction Experience Revamp - Research Document + +## Overview + +This document outlines research and recommendations for revamping the chart interaction experience in CDS visualization packages (`@coinbase/cds-web-visualization` and `@coinbase/cds-mobile-visualization`). + +### Goals + +1. Support future **polar charts** (radar, pie, donut, etc.) +2. Improve **mobile accessibility** (currently lacking in charts, but present in deprecated Sparkline) +3. Support **controlled state** for interactions +4. Add **series-level and data-index-level interaction scope** (similar to MUI X Charts' tooltip triggers) +5. Support **multi-touch** interactions +6. Maintain **backwards compatibility** with existing `enableScrubbing` and `onScrubberPositionChange` props +7. Reserve **"highlight" terminology** for future programmatic visual emphasis features + +--- + +## Industry Terminology Comparison + +| Concept | MUI X Charts | Recharts | CDS (Proposed) | +| ----------------------------------- | ------------------------------------------------ | ------------------------------------------- | --------------------------------------- | +| **Enable/disable user interaction** | Tooltip `trigger: 'none'`, `disableAxisListener` | ` null} />` | `interaction="none"` | +| **Single-point interaction** | Tooltip `trigger: 'item'` | Tooltip `trigger: 'hover'` | `interaction="single"` | +| **Axis-based interaction** | Tooltip `trigger: 'axis'` | Tooltip `shared={true}` | `interactionScope: { dataIndex: true }` | +| **Currently active item** | `highlightedItem` | `activeIndex` (deprecated), `activePayload` | `activeItem` | +| **Callback on change** | `onHighlightChange` | N/A (managed internally) | `onInteractionChange` | +| **Scope of what's affected** | `highlighting.highlight`, `highlighting.fade` | N/A | `interactionScope` | +| **Programmatic visual emphasis** | `highlighting` prop | N/A | Future: `highlightedItems` | + +**Key Insight**: MUI conflates "highlighting" (visual emphasis) with "interaction" (user engagement). Recharts uses "active" terminology. We recommend separating these concepts. + +--- + +## Current Implementation Analysis + +### Web (`ScrubberProvider.tsx`) + +- Manages scrubber position via local `useState` +- Attaches mouse/touch/keyboard event listeners to SVG element +- Converts pointer X position to data index +- Handles keyboard navigation (Arrow keys, Home, End, Escape) +- Tightly coupled to Cartesian chart coordinate system + +### Mobile (`ScrubberProvider.tsx`) + +- Uses `react-native-reanimated` `SharedValue` for scrubber position +- Uses `react-native-gesture-handler` for long-press pan gesture +- Converts touch X position to data index via worklet-compatible functions +- Provides haptic feedback +- **Not accessible** - no VoiceOver/TalkBack support + +### Mobile Sparkline Accessibility (`SparklineAccessibleView.tsx`) + +- Chunks data into ~10 accessible regions +- Each region has `accessibilityLabel` with date and value +- Provides basic screen reader support + +--- + +## Industry Research + +### MUI X Charts + +MUI X Charts provides a comprehensive highlighting system with several key concepts: + +#### Highlighting API + +```tsx + { + // { seriesId: string | null, dataIndex: number | null } + }} +/> +``` + +#### Key Concepts + +| Concept | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | +| `highlight` | What gets emphasized: `'none'`, `'item'` (single data point), `'series'` (entire series) | +| `fade` | What gets de-emphasized: `'none'`, `'series'` (same series stays visible), `'global'` (everything else fades) | +| `highlightScope` | Granular control: `{ highlight: string, fade: string }` | + +#### Tooltip Trigger Types + +| Trigger | Behavior | +| -------- | --------------------------------------------------------- | +| `'item'` | Shows tooltip when hovering over a specific data point | +| `'axis'` | Shows tooltip for all series at the current axis position | +| `'none'` | Tooltip disabled | + +#### Controlled State + +```tsx +const [highlightedItem, setHighlightedItem] = useState<{ + seriesId: string | null; + dataIndex: number | null; +} | null>(null); + +; +``` + +### Recharts + +Recharts takes a different approach with more implicit highlighting: + +#### Active Index System (Pre-v3.0) + +```tsx +// Deprecated in v3.0 - internal state conflicts + +``` + +#### Tooltip-Driven Highlighting (v3.0+) + +```tsx + +``` + +#### Event Types + +| Event Type | Behavior | +| ---------- | ------------------------------------------- | +| `'axis'` | Triggers tooltip for all data at X position | +| `'item'` | Triggers tooltip for specific data point | + +#### Accessibility Layer + +```tsx +{/* Adds ARIA labels, roles, keyboard controls */} +``` + +Keyboard navigation: + +- `←` / `→`: Navigate data points +- `Home` / `End`: Jump to first/last +- Announces data via ARIA live regions + +### Victory Charts (React Native) + +Victory provides accessibility through: + +- `aria-label` and `tabIndex` props on data components +- Custom `VictoryVoronoiContainer` for nearest-point detection +- No built-in chunked accessibility regions + +### SciChart (Polar Charts Reference) + +SciChart demonstrates polar chart interactions: + +- Radial highlighting based on angle and radius +- Different interaction patterns than Cartesian (no left/right navigation) +- Touch interactions map to polar coordinates + +--- + +## Proposed Architecture + +### Naming Recommendations + +**Important Distinction**: "Highlight" should be reserved for **programmatic visual emphasis** (a future feature), while what we're building is **user interaction** handling. + +| Concept | Description | Future Use | +| --------------- | -------------------------------------------------------- | --------------- | +| **Interaction** | How users engage with the chart (touch, hover, keyboard) | Current feature | +| **Active Item** | Which item the user is currently interacting with | Current feature | +| **Highlight** | Visual emphasis on specific data (could be programmatic) | Future feature | +| **Selection** | User explicitly chose items (persistent) | Future feature | + +#### Provider Naming Options + +| Name | Pros | Cons | +| ------------------------- | -------------------------------------------------------- | ------------------------------------- | +| **`InteractionProvider`** | Clear intent, leaves room for future `HighlightProvider` | Slightly generic | +| **`ActiveItemProvider`** | Specific about what it manages | Doesn't convey multi-touch | +| **`FocusProvider`** | Accessibility-oriented | Conflicts with browser focus concepts | + +**Recommendation**: Use `InteractionProvider` internally, expose as `interaction*` props on charts. Reserve `highlight*` for future programmatic highlighting features. + +### Core Types + +```typescript +// Interaction mode - how many simultaneous interactions to track +type InteractionMode = 'none' | 'single' | 'multi'; + +// What aspects of the data can be interacted with +type InteractionScope = { + dataIndex?: boolean; // Default: true for Cartesian + series?: boolean; // Default: false for Cartesian +}; + +// Single active item state +type ActiveItem = { + dataIndex: number | null; // null = interacting but not on a point + seriesId: string | null; // null = no specific series (or series scope disabled) +}; + +// Multi-touch active items state +type ActiveItems = Array; + +// Unified interaction state +type InteractionState = ActiveItem | ActiveItems | undefined; + +// undefined = not interacting +// null values = interacting but not over specific item/series +// defined values = interacting with specific item/series +``` + +### Null vs Undefined Convention + +| Value | Meaning | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| `undefined` | User is not interacting with the chart | +| `{ dataIndex: null, seriesId: null }` | User is interacting but not over a data point | +| `{ dataIndex: 5, seriesId: null }` | User is over data index 5 (series scope disabled or not over specific series) | +| `{ dataIndex: 5, seriesId: 'revenue' }` | User is over data index 5 on the 'revenue' series | + +### API Design + +#### Chart-Level Props + +```tsx +// Basic usage (backwards compatible) + console.log(index)} +/> + +// New interaction API + { + // { dataIndex: number | null, seriesId: string | null } | undefined + }} +/> + +// Multi-touch / multi-pointer + { + // Array<{ dataIndex: number | null, seriesId: string | null }> + }} +/> + +// Disabled (default - opt-in interaction) + + +// Controlled state + +``` + +#### Accessibility Props + +```tsx + { + // activeItem: { dataIndex: number, seriesId: string | null } + // context: { series: Series[], xAxis: AxisConfig, ... } + const value = context.getSeriesValue(activeItem.seriesId, activeItem.dataIndex); + const label = context.getXAxisLabel(activeItem.dataIndex); + return `${label}: ${formatCurrency(value)}`; + }} +/> +``` + +#### Future Programmatic Highlighting (Separate Feature) + +```tsx +// This would be a SEPARATE feature from interaction + +``` + +### Provider Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ CartesianChart / PolarChart │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ ChartProvider (series, axes, scales, dimensions) │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ InteractionProvider │ │ │ +│ │ │ - interaction mode (none/single/multi) │ │ │ +│ │ │ - interaction scope │ │ │ +│ │ │ - activeItem state │ │ │ +│ │ │ - controlled/uncontrolled support │ │ │ +│ │ │ ┌───────────────────────────────────────┐ │ │ │ +│ │ │ │ InputHandler (Web/Mobile) │ │ │ │ +│ │ │ │ - Pointer/Touch/Gesture handling │ │ │ │ +│ │ │ │ - Coordinate → activeItem conversion │ │ │ │ +│ │ │ └───────────────────────────────────────┘ │ │ │ +│ │ │ ┌───────────────────────────────────────┐ │ │ │ +│ │ │ │ KeyboardHandler (Web only) │ │ │ │ +│ │ │ │ - Arrow/Home/End/Escape navigation │ │ │ │ +│ │ │ │ - Chart-type agnostic interface │ │ │ │ +│ │ │ └───────────────────────────────────────┘ │ │ │ +│ │ │ ┌───────────────────────────────────────┐ │ │ │ +│ │ │ │ AccessibilityHandler (Mobile) │ │ │ │ +│ │ │ │ - Chunked regions for VoiceOver │ │ │ │ +│ │ │ │ - Per-item/per-series navigation │ │ │ │ +│ │ │ └───────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Keyboard Navigation Strategy + +Current issue: Keyboard navigation in `ScrubberProvider` assumes Cartesian charts (left/right = data index). + +**Proposed Solution**: Abstract navigation into chart-type-specific handlers: + +```typescript +interface NavigationHandler { + // Returns the next active item for a given navigation action + getNextActiveItem( + action: 'previous' | 'next' | 'first' | 'last' | 'clear', + currentItem: ActiveItem | undefined, + context: ChartContext, + ): ActiveItem | undefined; +} + +// Cartesian implementation +const cartesianNavigation: NavigationHandler = { + getNextActiveItem(action, current, context) { + switch (action) { + case 'previous': + return { ...current, dataIndex: Math.max(0, (current?.dataIndex ?? 0) - 1) }; + case 'next': + return { + ...current, + dataIndex: Math.min(context.dataLength - 1, (current?.dataIndex ?? -1) + 1), + }; + // ... + } + }, +}; + +// Future polar implementation +const polarNavigation: NavigationHandler = { + getNextActiveItem(action, current, context) { + // Navigate by angle/segment instead of data index + // ... + }, +}; +``` + +### Mobile Accessibility Strategy + +Current issue: Mobile charts have no accessibility support. + +**Proposed Solution**: Configurable accessibility regions based on chart type and highlight scope: + +#### Option 1: Chunked Regions (Default for Line Charts) + +```tsx +// Similar to SparklineAccessibleView + +``` + +- Divides chart into N accessible regions +- Each region announces first data point in chunk +- Good for continuous data (line charts, area charts) + +#### Option 2: Per-Item Regions (Default for Bar Charts) + +```tsx + +``` + +- Each data point is an accessible region +- User can swipe through each bar/point +- Good for discrete data (bar charts, scatter plots) + +#### Option 3: Per-Series Regions + +```tsx + +``` + +- Each series is an accessible region +- Good for comparing series values at a glance +- Can combine with item mode for drill-down + +#### Mobile Accessibility Implementation + +```tsx +// Internal: AccessibilityOverlay component +const AccessibilityOverlay: React.FC<{ + mode: 'chunked' | 'item' | 'series'; + chunkCount?: number; + getAccessibilityLabel: (activeItem: ActiveItem) => string; +}> = ({ mode, chunkCount = 10, getAccessibilityLabel }) => { + const chartContext = useChartContext(); + const { onInteractionChange } = useInteractionContext(); + + const regions = useMemo(() => { + switch (mode) { + case 'chunked': + return createChunkedRegions(chartContext.dataLength, chunkCount); + case 'item': + return createItemRegions(chartContext.dataLength); + case 'series': + return createSeriesRegions(chartContext.series); + } + }, [mode, chartContext, chunkCount]); + + return ( + + {regions.map((region) => ( + onInteractionChange(region.activeItem)} + style={region.style} + /> + ))} + + ); +}; +``` + +### Multi-Touch Support + +#### Web Implementation + +```typescript +// Track multiple active pointers +const [activePointers, setActivePointers] = useState>(new Map()); + +const handlePointerDown = (e: PointerEvent) => { + setActivePointers((prev) => new Map(prev).set(e.pointerId, e)); +}; + +const handlePointerMove = (e: PointerEvent) => { + if (!activePointers.has(e.pointerId)) return; + + // Convert each pointer to an active item + const activeItems = Array.from(activePointers.values()).map((pointer) => + getActiveItemFromPointer(pointer), + ); + + onInteractionChange(activeItems); +}; + +const handlePointerUp = (e: PointerEvent) => { + setActivePointers((prev) => { + const next = new Map(prev); + next.delete(e.pointerId); + return next; + }); +}; +``` + +#### Mobile Implementation + +```typescript +// Use react-native-gesture-handler's multi-touch support +const gesture = Gesture.Manual() + .onTouchesDown((event) => { + const activeItems = event.allTouches.map((touch) => getActiveItemFromTouch(touch)); + onInteractionChange(activeItems); + }) + .onTouchesMove((event) => { + const activeItems = event.allTouches.map((touch) => getActiveItemFromTouch(touch)); + onInteractionChange(activeItems); + }) + .onTouchesUp((event) => { + // Update with remaining touches + const activeItems = event.allTouches + .filter((t) => t.state !== State.END) + .map((touch) => getActiveItemFromTouch(touch)); + onInteractionChange(activeItems.length > 0 ? activeItems : undefined); + }); +``` + +--- + +## Backwards Compatibility + +### Migration Path + +The new API should be fully backwards compatible: + +```tsx +// OLD API (continues to work) + console.log(index)} +/> + +// Internally translates to: + { + onScrubberPositionChange?.(activeItem?.dataIndex); + }} +/> +``` + +### Deprecation Strategy + +1. **Phase 1**: Add new `interaction*` props alongside existing `enableScrubbing` / `onScrubberPositionChange` +2. **Phase 2**: Add deprecation warnings to old props +3. **Phase 3**: Remove old props in next major version + +--- + +## Open Questions + +### 1. Default Interaction Mode + +Should interaction be opt-in (current) or opt-out? + +| Option | Pros | Cons | +| -------------------------------------------- | -------------------------------- | ------------------------------------- | +| **Opt-in** (`interaction="none"` default) | Explicit, no unexpected behavior | More boilerplate for common use cases | +| **Opt-out** (`interaction="single"` default) | Better DX for interactive charts | May cause unexpected behavior | + +**Recommendation**: Keep opt-in for backwards compatibility, but provide `` etc. preset components. + +### 2. Interaction Mode Naming + +Alternative names for `interaction` prop values: + +| Current | Alternative 1 | Alternative 2 | +| ---------- | ------------- | --------------- | +| `'none'` | `'disabled'` | `false` | +| `'single'` | `'item'` | `'point'` | +| `'multi'` | `'multiple'` | `'multi-touch'` | + +### 3. Series Interaction Default + +For multi-series charts, should series interaction scope be: + +| Option | Behavior | +| -------------------------- | ------------------------------------------- | +| **All series at index** | All bars/points at x=5 become active | +| **Only interacted series** | Only the specific bar/point touched/hovered | + +**Recommendation**: Make this configurable via `interactionScope.series`. + +### 4. Z-Order Behavior for Overlapping Elements + +When multiple chart elements (bars, lines, areas) overlap at the same position, the **topmost element in the DOM (last rendered)** receives mouse/touch events. This is standard SVG/DOM behavior. + +#### Key Points for Documentation + +1. **SVG rendering order**: Elements rendered later in JSX appear visually on top +2. **Event propagation**: The topmost element receives mouse events first +3. **No automatic "nearest" detection**: Unlike some libraries with Voronoi-based detection, we rely on direct element hit testing + +#### Example: Overlapping Bars + +```tsx + + {/* Revenue bars rendered first - underneath */} + + {/* Profit margin bars rendered second - on top */} + + +``` + +In this example: +- Where bars overlap, hovering will **always detect `profitMargin`** (the topmost element) +- The `revenue` bars are only detectable in areas where they extend beyond the `profitMargin` bars + +#### Documentation Guidance + +Users should be aware that: +1. **Render order matters** - the last-rendered series will capture interactions in overlapping regions +2. **Use separate y-axes carefully** - when series have different scales, bars may overlap unexpectedly +3. **Consider visual clarity** - if users need to interact with both overlapping series, consider alternative visualizations (grouped bars, separate charts, or tooltips that show all series at an index) + +This behavior is consistent with how other charting libraries handle overlapping elements (Chart.js, SciChart, Syncfusion, etc.). + +### 4. Polar Chart Navigation + +How should keyboard navigation work for polar charts? + +| Option | Behavior | +| -------------- | ------------------------------------------------- | +| **By segment** | Arrow keys move between pie slices / radar points | +| **By angle** | Arrow keys rotate around the chart | +| **By radius** | Arrow keys move in/out (for nested polar charts) | + +--- + +## Implementation Plan + +### Phase 1: Core Infrastructure + +1. Create `InteractionContext` and `InteractionProvider` types +2. Implement uncontrolled `activeItem` state management +3. Add backwards-compatible prop handling (`enableScrubbing` → `interaction`) + +### Phase 2: Input Handlers + +1. Refactor web pointer/touch handlers +2. Refactor mobile gesture handlers +3. Add multi-touch support + +### Phase 3: Keyboard Navigation + +1. Abstract navigation into `NavigationHandler` interface +2. Implement Cartesian navigation handler +3. Move keyboard handling to dedicated component + +### Phase 4: Mobile Accessibility + +1. Create `AccessibilityOverlay` component +2. Implement chunked regions (like Sparkline) +3. Implement per-item regions +4. Add accessibility label customization + +### Phase 5: Advanced Features + +1. Add series-level interaction scope +2. Add controlled state support (`activeItem` prop) +3. Prepare polar chart navigation interface + +### Phase 6: Future - Programmatic Highlighting (Separate Feature) + +1. Add `highlightedItems` prop for visual emphasis +2. Add highlight/fade visual effects +3. Support programmatic highlighting independent of interaction + +--- + +## References + +- [MUI X Charts - Highlighting](https://mui.com/x/react-charts/highlighting/) +- [MUI X Charts - Tooltip](https://mui.com/x/react-charts/tooltip/) +- [Recharts - Accessibility](https://github.com/recharts/recharts/wiki/Recharts-and-accessibility) +- [Recharts - Tooltip](https://github.com/recharts/recharts/wiki/Tooltip-event-type-and-shared-prop) +- [WAI-ARIA Authoring Practices - Data Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) +- [React Native Accessibility](https://reactnative.dev/docs/accessibility) diff --git a/docs/research/pointer-events-simplification.md b/docs/research/pointer-events-simplification.md new file mode 100644 index 000000000..851c19b23 --- /dev/null +++ b/docs/research/pointer-events-simplification.md @@ -0,0 +1,395 @@ +# Web InteractionProvider: Pointer Events Simplification + +> **Purpose**: Document the proposed simplification of the web `InteractionProvider` from separate mouse/touch events to unified Pointer Events. + +## Table of Contents + +1. [Current Implementation](#current-implementation) +2. [Pointer Events Overview](#pointer-events-overview) +3. [Proposed Changes](#proposed-changes) +4. [Implementation Plan](#implementation-plan) +5. [Browser Compatibility](#browser-compatibility) +6. [Migration Guide](#migration-guide) + +--- + +## Current Implementation + +### Event Handlers + +The current `InteractionProvider.tsx` uses separate event handlers for mouse and touch: + +```typescript +// Mouse event handlers (lines 300-312) +const handleMouseMove = useCallback((event: MouseEvent) => { + const target = event.currentTarget as SVGSVGElement; + handlePointerMove(event.clientX, event.clientY, target); +}, [handlePointerMove]); + +const handleMouseLeave = useCallback(() => { + if (interaction === 'none') return; + setActiveState(interaction === 'multi' ? [] : undefined); +}, [interaction, setActiveState]); + +// Touch event handlers (lines 314-388) +const handleTouchStart = useCallback((event: TouchEvent) => { + // Track touches, update state +}, [...]); + +const handleTouchMove = useCallback((event: TouchEvent) => { + event.preventDefault(); // Prevent scrolling + // Update state based on touch positions +}, [...]); + +const handleTouchEnd = useCallback((event: TouchEvent) => { + // Remove ended touches, clear state if empty +}, [...]); +``` + +### Event Listener Registration + +```typescript +// Lines 476-511 +useEffect( + () => { + svg.addEventListener('mousemove', handleMouseMove); + svg.addEventListener('mouseleave', handleMouseLeave); + svg.addEventListener('touchstart', handleTouchStart, { passive: false }); + svg.addEventListener('touchmove', handleTouchMove, { passive: false }); + svg.addEventListener('touchend', handleTouchEnd); + svg.addEventListener('touchcancel', handleTouchEnd); + svg.addEventListener('keydown', handleKeyDown); + svg.addEventListener('blur', handleBlur); + // ... cleanup + }, + [ + /* 10 dependencies */ + ], +); +``` + +### Issues with Current Approach + +1. **Code duplication**: Separate handlers for mouse and touch doing essentially the same thing +2. **Maintenance burden**: Changes need to be applied to both mouse and touch handlers +3. **Complex dependency arrays**: 10 dependencies in the useEffect +4. **Two tracking mechanisms**: `activePointersRef` for touches, implicit single-pointer for mouse + +--- + +## Pointer Events Overview + +### What are Pointer Events? + +Pointer Events is a W3C standard that unifies mouse, touch, and stylus input into a single event model. Instead of handling `MouseEvent` and `TouchEvent` separately, you handle `PointerEvent` which works for all input types. + +### Key Properties + +| Property | Description | +| -------------- | ----------------------------------------------------------------- | +| `pointerId` | Unique identifier for the pointer (like `touch.identifier`) | +| `pointerType` | `'mouse'`, `'touch'`, or `'pen'` | +| `isPrimary` | `true` if this is the primary pointer (first touch, or the mouse) | +| `clientX/Y` | Coordinates (same as mouse/touch) | +| `pressure` | Pressure level (0 to 1) | +| `width/height` | Contact geometry (for touch) | + +### Event Types + +| Pointer Event | Replaces | +| --------------- | ------------------------- | +| `pointerdown` | `mousedown`, `touchstart` | +| `pointermove` | `mousemove`, `touchmove` | +| `pointerup` | `mouseup`, `touchend` | +| `pointerleave` | `mouseleave` | +| `pointercancel` | `touchcancel` | +| `pointerenter` | `mouseenter` | + +### Multi-Pointer Handling + +```typescript +// Each pointer has a unique ID +const activePointers = new Map(); + +function onPointerDown(event: PointerEvent) { + activePointers.set(event.pointerId, event); +} + +function onPointerMove(event: PointerEvent) { + if (activePointers.has(event.pointerId)) { + activePointers.set(event.pointerId, event); + } +} + +function onPointerUp(event: PointerEvent) { + activePointers.delete(event.pointerId); +} +``` + +--- + +## Proposed Changes + +### Before vs After + +| Aspect | Before | After | +| ------------------------- | ---------------------- | ---------- | +| Event handlers | 6 (2 mouse + 4 touch) | 4 pointer | +| Event listeners | 8 total | 6 total | +| Code lines | ~90 lines | ~50 lines | +| Dependencies in useEffect | 10 | 6 | +| Tracking mechanism | Mixed (implicit + Map) | Single Map | + +### New Event Handlers + +```typescript +// Single handler for all pointer movement +const handlePointerMoveEvent = useCallback( + (event: PointerEvent) => { + if (interaction === 'none') return; + + const target = event.currentTarget as SVGSVGElement; + + if (interaction === 'single') { + // Only respond to primary pointer (first touch or mouse) + if (!event.isPrimary) return; + handlePointerMove(event.clientX, event.clientY, target); + } else { + // Multi mode: update the specific pointer + activePointersRef.current.set(event.pointerId, { + clientX: event.clientX, + clientY: event.clientY, + }); + updateMultiPointerState(target); + } + }, + [interaction, handlePointerMove, updateMultiPointerState], +); + +// Handle pointer down (needed for multi-touch tracking) +const handlePointerDown = useCallback( + (event: PointerEvent) => { + if (interaction !== 'multi') return; + + activePointersRef.current.set(event.pointerId, { + clientX: event.clientX, + clientY: event.clientY, + }); + + const target = event.currentTarget as SVGSVGElement; + updateMultiPointerState(target); + }, + [interaction, updateMultiPointerState], +); + +// Handle pointer up/cancel +const handlePointerUp = useCallback( + (event: PointerEvent) => { + if (interaction === 'none') return; + + if (interaction === 'multi') { + activePointersRef.current.delete(event.pointerId); + + if (activePointersRef.current.size === 0) { + setActiveState([]); + } else { + const target = event.currentTarget as SVGSVGElement; + updateMultiPointerState(target); + } + } else { + // Single mode: only clear on primary pointer + if (event.isPrimary) { + setActiveState(undefined); + } + } + }, + [interaction, setActiveState, updateMultiPointerState], +); + +// Handle pointer leaving the element +const handlePointerLeave = useCallback( + (event: PointerEvent) => { + if (interaction === 'none') return; + + // For touch, pointerleave fires when finger lifts + // For mouse, fires when cursor leaves element + if (event.pointerType === 'mouse' && event.isPrimary) { + setActiveState(interaction === 'multi' ? [] : undefined); + } + }, + [interaction, setActiveState], +); +``` + +### New Event Registration + +```typescript +useEffect(() => { + if (!svgRef?.current || interaction === 'none') return; + + const svg = svgRef.current; + + // Prevent touch scrolling on the SVG + svg.style.touchAction = 'none'; + + // Pointer events (unified mouse + touch) + svg.addEventListener('pointerdown', handlePointerDown); + svg.addEventListener('pointermove', handlePointerMoveEvent); + svg.addEventListener('pointerup', handlePointerUp); + svg.addEventListener('pointercancel', handlePointerUp); + svg.addEventListener('pointerleave', handlePointerLeave); + + // Keyboard (unchanged) + svg.addEventListener('keydown', handleKeyDown); + svg.addEventListener('blur', handleBlur); + + return () => { + svg.style.touchAction = ''; + svg.removeEventListener('pointerdown', handlePointerDown); + svg.removeEventListener('pointermove', handlePointerMoveEvent); + svg.removeEventListener('pointerup', handlePointerUp); + svg.removeEventListener('pointercancel', handlePointerUp); + svg.removeEventListener('pointerleave', handlePointerLeave); + svg.removeEventListener('keydown', handleKeyDown); + svg.removeEventListener('blur', handleBlur); + }; +}, [ + svgRef, + interaction, + handlePointerDown, + handlePointerMoveEvent, + handlePointerUp, + handlePointerLeave, + handleKeyDown, + handleBlur, +]); +``` + +--- + +## Implementation Plan + +### Phase 1: Refactor Event Handlers + +1. **Remove separate mouse/touch handlers**: + - Delete `handleMouseMove`, `handleMouseLeave` + - Delete `handleTouchStart`, `handleTouchMove`, `handleTouchEnd` + +2. **Add unified pointer handlers**: + - Add `handlePointerDown` (for multi-touch tracking start) + - Add `handlePointerMoveEvent` (replaces both mouse/touch move) + - Add `handlePointerUp` (replaces touch end, handles multi) + - Add `handlePointerLeave` (replaces mouse leave) + +3. **Keep existing logic**: + - `handlePointerMove` internal function (coordinate → ActiveItem) stays the same + - `updateMultiPointerState` stays the same + - `activePointersRef` stays the same (just uses `pointerId` instead of touch `identifier`) + +### Phase 2: Update Event Registration + +1. **Replace event listeners**: + + ```diff + - svg.addEventListener('mousemove', handleMouseMove); + - svg.addEventListener('mouseleave', handleMouseLeave); + - svg.addEventListener('touchstart', handleTouchStart, { passive: false }); + - svg.addEventListener('touchmove', handleTouchMove, { passive: false }); + - svg.addEventListener('touchend', handleTouchEnd); + - svg.addEventListener('touchcancel', handleTouchEnd); + + svg.addEventListener('pointerdown', handlePointerDown); + + svg.addEventListener('pointermove', handlePointerMoveEvent); + + svg.addEventListener('pointerup', handlePointerUp); + + svg.addEventListener('pointercancel', handlePointerUp); + + svg.addEventListener('pointerleave', handlePointerLeave); + ``` + +2. **Add touch-action CSS**: + ```typescript + svg.style.touchAction = 'none'; // Prevent scrolling + ``` + +### Phase 3: Test & Verify + +1. **Single mode mouse**: Hover tracking works +2. **Single mode touch**: Touch tracking works +3. **Multi mode mouse**: Single pointer tracked +4. **Multi mode touch**: Multiple pointers tracked +5. **Leave behavior**: State clears on mouse leave +6. **Cancel behavior**: State clears on touch cancel +7. **Controlled state**: Still works with pointer events + +--- + +## Browser Compatibility + +### Support Matrix + +| Browser | Pointer Events Support | +| -------------- | ---------------------- | +| Chrome | ✅ 55+ (Dec 2016) | +| Firefox | ✅ 59+ (Mar 2018) | +| Safari | ✅ 13+ (Sep 2019) | +| Edge | ✅ All versions | +| IE | ✅ 11+ (with prefix) | +| Mobile Safari | ✅ 13+ | +| Chrome Android | ✅ 55+ | + +### Polyfill + +Not needed for modern browsers. If legacy support required: + +```bash +npm install pepjs +``` + +```typescript +import 'pepjs'; // Polyfill for IE10 +``` + +--- + +## Migration Guide + +### No Breaking Changes + +This refactor is internal to `InteractionProvider`. The public API remains unchanged: + +```tsx +// Still works exactly the same + console.log(state)} +> + {/* ... */} + +``` + +### Testing Checklist + +- [ ] Mouse hover updates `activeItem.dataIndex` +- [ ] Mouse leave clears `activeItem` +- [ ] Touch drag updates `activeItem.dataIndex` +- [ ] Touch end clears `activeItem` +- [ ] Multi-touch creates multiple `activeItems` +- [ ] Multi-touch end removes individual items +- [ ] Controlled state (`activeItem` prop) ignores input +- [ ] `null` controlled state prevents internal updates +- [ ] Series interaction (`interactionScope.series`) still works +- [ ] Keyboard navigation still works +- [ ] Legacy `enableScrubbing` / `onScrubberPositionChange` still work + +--- + +## Summary + +| Metric | Before | After | Improvement | +| ----------------------- | ------ | ----- | ----------- | +| Event handler functions | 6 | 4 | -33% | +| Event listeners | 8 | 7 | -12% | +| Lines of code | ~90 | ~50 | -44% | +| useEffect dependencies | 10 | 8 | -20% | +| Code paths for input | 2 | 1 | -50% | + +The Pointer Events API provides a cleaner, more maintainable implementation with no functionality loss and excellent browser support. diff --git a/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx new file mode 100644 index 000000000..61f7ba25b --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -0,0 +1,421 @@ +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 { ActiveItem, InteractionState } 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 interaction with single mode + */ +const BasicInteraction = () => { + const [activeItem, setActiveItem] = useState(undefined); + const theme = useTheme(); + + return ( + + + + Active: {activeItem ? `dataIndex: ${activeItem.dataIndex}` : 'Not interacting'} + + + + setActiveItem(state as ActiveItem | undefined)} + series={[{ id: 'price', data: samplePrices, color: theme.color.fgPrimary }]} + > + + + + ); +}; + +/** + * Controlled state - programmatically set the active item + */ +const ControlledState = () => { + const theme = useTheme(); + // null = controlled mode with no active item + // ActiveItem = controlled mode with specific active item + const [activeItem, setActiveItem] = useState(null); + + return ( + + + Use buttons to programmatically select data points. Pass null to clear without listening to + user input. + + + + + + + + + + + + Index: {activeItem?.dataIndex ?? 'none'} + {activeItem?.dataIndex !== undefined && + activeItem.dataIndex !== null && + ` (${formatPrice(samplePrices[activeItem.dataIndex])})`} + + + + + + + + ); +}; + +/** + * Series interaction - track which specific bar is being touched + */ +const SeriesInteraction = () => { + const theme = useTheme(); + const [activeItem, setActiveItem] = useState(undefined); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem(state as ActiveItem | undefined); + }, []); + + 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. + + + + + {activeItem + ? `Index: ${activeItem.dataIndex ?? 'none'}${activeItem.seriesId ? ` | Series: ${activeItem.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 [activeItem, setActiveItem] = useState(undefined); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem(state as ActiveItem | undefined); + }, []); + + 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. + + + + + {activeItem + ? `Index: ${activeItem.dataIndex ?? 'none'}${activeItem.seriesId ? ` | Series: ${activeItem.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 interaction across multiple charts + */ +const SynchronizedCharts = () => { + const theme = useTheme(); + const [activeItem, setActiveItem] = useState(null); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem((state as ActiveItem) ?? null); + }, []); + + return ( + + + Interact with either chart and both will highlight the same data point. + + + + {xAxisData.map((label, index) => ( + + ))} + + + + + + Highlighted index: {activeItem?.dataIndex ?? 'none'} + {activeItem?.dataIndex !== null && + activeItem?.dataIndex !== undefined && + ` (A: ${seriesA[activeItem.dataIndex]}, B: ${seriesB[activeItem.dataIndex]})`} + + + + + + + + + + + + + + + + + + ); +}; + +/** + * 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/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index bd7fa7edb..5b7ad85e8 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -1,9 +1,9 @@ -import { memo, useMemo } from 'react'; +import { memo, useEffect, useMemo } from 'react'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Path } from '../Path'; -import { getBarPath } from '../utils'; +import { getBarPath, useOptionalInteractionContext } from '../utils'; import type { BarComponentProps } from './Bar'; @@ -12,9 +12,8 @@ export type DefaultBarProps = BarComponentProps; /** * Default bar component that renders a solid bar with animation support. * - * Note: Series-level interaction tracking on mobile requires coordinate-based hit testing - * in the gesture handler, as Skia paths don't support touch events directly. - * The `seriesId` prop is available for future series interaction implementations. + * Automatically registers bounds for series interaction hit testing when + * `interactionScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -31,13 +30,33 @@ export const DefaultBar = memo( stroke, strokeWidth, originY, - // eslint-disable-next-line @typescript-eslint/no-unused-vars dataX, - // eslint-disable-next-line @typescript-eslint/no-unused-vars seriesId, transition, }) => { const { animate } = useCartesianChartContext(); + const interactionContext = useOptionalInteractionContext(); + + // Register bar bounds for hit testing when series interaction is enabled + useEffect(() => { + if (!interactionContext?.scope.series || !seriesId) return; + + // Get the data index as a number + const dataIndex = typeof dataX === 'number' ? dataX : 0; + + interactionContext.registerBar({ + x, + y, + width, + height, + dataIndex, + seriesId, + }); + + return () => { + interactionContext.unregisterBar(seriesId, dataIndex); + }; + }, [x, y, width, height, dataX, seriesId, interactionContext]); const theme = useTheme(); const defaultFill = fill || theme.color.fgPrimary; diff --git a/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx b/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx index e4d35053f..65d716f7f 100644 --- a/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx +++ b/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { @@ -14,12 +14,16 @@ import { useCartesianChartContext } from '../ChartProvider'; import { type ActiveItem, type ActiveItems, + type ElementBounds, InteractionContext, type InteractionContextValue, type InteractionMode, + type InteractionRegistry, type InteractionScope, type InteractionState, invertSerializableScale, + type LinePath, + type PointBounds, ScrubberContext, type ScrubberContextValue, } from '../utils'; @@ -139,6 +143,113 @@ export const InteractionProvider: React.FC = ({ [scopeProp], ); + // ============================================================================ + // Interaction Registry (for coordinate-based hit testing) + // ============================================================================ + + // Use ref to avoid re-renders when registering elements + const registryRef = useRef({ + bars: [], + points: [], + lines: [], + }); + + // Register a bar element for hit testing + const registerBar = useCallback((bounds: ElementBounds) => { + // Add to registry (elements are stored in render order) + registryRef.current.bars.push(bounds); + }, []); + + // Unregister a bar element + const unregisterBar = useCallback((seriesId: string, dataIndex: number) => { + registryRef.current.bars = registryRef.current.bars.filter( + (bar) => !(bar.seriesId === seriesId && bar.dataIndex === dataIndex), + ); + }, []); + + // Register a point element for hit testing + const registerPoint = useCallback((bounds: PointBounds) => { + registryRef.current.points.push(bounds); + }, []); + + // Unregister a point element + const unregisterPoint = useCallback((seriesId: string, dataIndex: number) => { + registryRef.current.points = registryRef.current.points.filter( + (point) => !(point.seriesId === seriesId && point.dataIndex === dataIndex), + ); + }, []); + + // Register a line path for hit testing + const registerLine = useCallback((path: LinePath) => { + // Replace existing line with same seriesId (path may update) + registryRef.current.lines = registryRef.current.lines.filter( + (line) => line.seriesId !== path.seriesId, + ); + registryRef.current.lines.push(path); + }, []); + + // Unregister a line path + const unregisterLine = useCallback((seriesId: string) => { + registryRef.current.lines = registryRef.current.lines.filter( + (line) => line.seriesId !== seriesId, + ); + }, []); + + // Find bar at touch point (iterates in reverse for correct z-order) + const findBarAtPoint = useCallback((touchX: number, touchY: number): ElementBounds | null => { + const bars = registryRef.current.bars; + // Iterate in reverse order (last rendered = on top = checked first) + 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; + }, []); + + // Find point at touch point + const findPointAtTouch = useCallback( + (touchX: number, touchY: number, touchTolerance: number = 10): PointBounds | null => { + const points = registryRef.current.points; + for (let i = points.length - 1; i >= 0; i--) { + const point = points[i]; + const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); + if (distance <= point.radius + touchTolerance) { + return point; + } + } + return null; + }, + [], + ); + + // Find series at touch point (checks bars first, then points) + // Note: Line hit testing would require Skia path parsing - not implemented yet + const findSeriesAtPoint = useCallback( + (touchX: number, touchY: number): string | null => { + // Check bars first + const hitBar = findBarAtPoint(touchX, touchY); + if (hitBar) return hitBar.seriesId; + + // Check points + const hitPoint = findPointAtTouch(touchX, touchY); + if (hitPoint) return hitPoint.seriesId; + + // TODO: Add line hit testing using Skia path.contains() + + return null; + }, + [findBarAtPoint, findPointAtTouch], + ); + + // ============================================================================ + // Determine if we're in controlled mode // null/[] means "controlled with no active item" - distinct from undefined (uncontrolled) const isControlled = controlledActiveItem !== undefined || controlledActiveItems !== undefined; @@ -266,6 +377,18 @@ export const InteractionProvider: React.FC = ({ [isControlled, internalActiveItem, onInteractionChange], ); + // Helper to create active item with optional series hit testing (runs on JS thread) + const createActiveItemWithSeries = useCallback( + (x: number, y: number, dataIndex: number | null): ActiveItem => { + let seriesId: string | null = null; + if (scope.series) { + seriesId = findSeriesAtPoint(x, y); + } + return { dataIndex, seriesId }; + }, + [scope.series, findSeriesAtPoint], + ); + // Create the long press pan gesture for single mode const singleTouchGesture = useMemo( () => @@ -278,26 +401,38 @@ export const InteractionProvider: React.FC = ({ // Android does not trigger onUpdate when the gesture starts if (Platform.OS === 'android') { const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - const newActiveItem: ActiveItem = { dataIndex, seriesId: null }; - const currentItem = internalActiveItem.value as ActiveItem | undefined; - if (newActiveItem.dataIndex !== currentItem?.dataIndex) { - if (!isControlled) { - internalActiveItem.value = newActiveItem; + // Series hit testing runs on JS thread + runOnJS((x: number, y: number, di: number | null) => { + const newActiveItem = createActiveItemWithSeries(x, y, di); + const currentItem = internalActiveItem.value as ActiveItem | undefined; + if ( + newActiveItem.dataIndex !== currentItem?.dataIndex || + newActiveItem.seriesId !== currentItem?.seriesId + ) { + if (!isControlled) { + internalActiveItem.value = newActiveItem; + } + onInteractionChange?.(newActiveItem); } - runOnJS(onInteractionChange ?? (() => {}))(newActiveItem); - } + })(event.x, event.y, dataIndex); } }) .onUpdate(function onUpdate(event) { const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - const newActiveItem: ActiveItem = { dataIndex, seriesId: null }; - const currentItem = internalActiveItem.value as ActiveItem | undefined; - if (newActiveItem.dataIndex !== currentItem?.dataIndex) { - if (!isControlled) { - internalActiveItem.value = newActiveItem; + // Series hit testing runs on JS thread + runOnJS((x: number, y: number, di: number | null) => { + const newActiveItem = createActiveItemWithSeries(x, y, di); + const currentItem = internalActiveItem.value as ActiveItem | undefined; + if ( + newActiveItem.dataIndex !== currentItem?.dataIndex || + newActiveItem.seriesId !== currentItem?.seriesId + ) { + if (!isControlled) { + internalActiveItem.value = newActiveItem; + } + onInteractionChange?.(newActiveItem); } - runOnJS(onInteractionChange ?? (() => {}))(newActiveItem); - } + })(event.x, event.y, dataIndex); }) .onEnd(function onEnd() { if (interaction !== 'none') { @@ -321,6 +456,7 @@ export const InteractionProvider: React.FC = ({ handleStartEndHaptics, getDataIndexFromX, scope.dataIndex, + createActiveItemWithSeries, internalActiveItem, interaction, isControlled, @@ -328,6 +464,21 @@ export const InteractionProvider: React.FC = ({ ], ); + // Helper to process touches and create active items (runs on JS thread) + const processMultiTouches = useCallback( + (touches: Array<{ x: number; y: number }>): ActiveItems => { + return touches.map((touch) => { + const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + let seriesId: string | null = null; + if (scope.series) { + seriesId = findSeriesAtPoint(touch.x, touch.y); + } + return { dataIndex, seriesId }; + }); + }, + [scope.dataIndex, scope.series, getDataIndexFromX, findSeriesAtPoint], + ); + // Create multi-touch gesture const multiTouchGesture = useMemo( () => @@ -336,24 +487,25 @@ export const InteractionProvider: React.FC = ({ .onTouchesDown(function onTouchesDown(event) { runOnJS(handleStartEndHaptics)(); - const items: ActiveItems = event.allTouches.map((touch) => { - const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; - return { dataIndex, seriesId: null }; - }); - if (!isControlled) { - internalActiveItem.value = items; - } - runOnJS(onInteractionChange ?? (() => {}))(items); + // Extract touch coordinates for JS thread processing + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalActiveItem.value = items; + } + onInteractionChange?.(items); + })(touches); }) .onTouchesMove(function onTouchesMove(event) { - const items: ActiveItems = event.allTouches.map((touch) => { - const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; - return { dataIndex, seriesId: null }; - }); - if (!isControlled) { - internalActiveItem.value = items; - } - runOnJS(onInteractionChange ?? (() => {}))(items); + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalActiveItem.value = items; + } + onInteractionChange?.(items); + })(touches); }) .onTouchesUp(function onTouchesUp(event) { if (event.allTouches.length === 0) { @@ -363,14 +515,14 @@ export const InteractionProvider: React.FC = ({ } runOnJS(onInteractionChange ?? (() => {}))([]); } else { - const items: ActiveItems = event.allTouches.map((touch) => { - const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; - return { dataIndex, seriesId: null }; - }); - if (!isControlled) { - internalActiveItem.value = items; - } - runOnJS(onInteractionChange ?? (() => {}))(items); + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalActiveItem.value = items; + } + onInteractionChange?.(items); + })(touches); } }) .onTouchesCancelled(function onTouchesCancelled() { @@ -382,8 +534,7 @@ export const InteractionProvider: React.FC = ({ [ allowOverflowGestures, handleStartEndHaptics, - getDataIndexFromX, - scope.dataIndex, + processMultiTouches, internalActiveItem, isControlled, onInteractionChange, @@ -398,8 +549,25 @@ export const InteractionProvider: React.FC = ({ scope, activeItem, setActiveItem, + registerBar, + unregisterBar, + registerPoint, + unregisterPoint, + registerLine, + unregisterLine, }), - [interaction, scope, activeItem, setActiveItem], + [ + interaction, + scope, + activeItem, + setActiveItem, + registerBar, + unregisterBar, + registerPoint, + unregisterPoint, + registerLine, + unregisterLine, + ], ); // Derive scrubberPosition from internal active item for backwards compatibility diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index a12e9575c..4a04eae3d 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -191,6 +191,52 @@ export type ActiveItems = Array; */ export type InteractionState = ActiveItem | ActiveItems | undefined | null; +// ============================================================================ +// Interaction Registry Types (for coordinate-based hit testing on mobile) +// ============================================================================ + +/** + * Bounds of an interactive element (bar, point, etc.) + * Used for coordinate-based hit testing since Skia doesn't have native touch events. + */ +export type ElementBounds = { + x: number; + y: number; + width: number; + height: number; + dataIndex: number; + seriesId: string; +}; + +/** + * Bounds of a point (circle) element. + */ +export type PointBounds = { + cx: number; + cy: number; + radius: number; + dataIndex: number; + seriesId: string; +}; + +/** + * Line path for hit testing. + */ +export type LinePath = { + pathString: string; + seriesId: string; +}; + +/** + * Registry of interactive elements for coordinate-based hit testing. + * Elements are stored in render order (last = on top). + */ +export type InteractionRegistry = { + bars: ElementBounds[]; + points: PointBounds[]; + lines: LinePath[]; +}; + /** * Context value for chart interaction state (mobile). * Uses SharedValue for UI thread performance. @@ -214,6 +260,30 @@ export type InteractionContextValue = { * Function to programmatically set the active item. */ setActiveItem: (state: InteractionState) => void; + /** + * Register a bar element for hit testing. + */ + registerBar: (bounds: ElementBounds) => void; + /** + * Unregister a bar element. + */ + unregisterBar: (seriesId: string, dataIndex: number) => void; + /** + * Register a point element for hit testing. + */ + registerPoint: (bounds: PointBounds) => void; + /** + * Unregister a point element. + */ + unregisterPoint: (seriesId: string, dataIndex: number) => void; + /** + * Register a line path for hit testing. + */ + registerLine: (path: LinePath) => void; + /** + * Unregister a line path. + */ + unregisterLine: (seriesId: string) => void; }; export const InteractionContext = createContext(undefined); diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx index 017462438..08935210e 100644 --- a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -722,3 +722,164 @@ export function OverlappingBarsZOrder() { ); } + +// Custom line component that fades when another series is active, and grows when it's the active one +type FadeableLineProps = React.ComponentProps & { + /** Stroke width when this line is the active series. Defaults to strokeWidth * 2 */ + activeStrokeWidth?: number; +}; + +const FadeableLine = memo( + ({ seriesId, strokeWidth = 2, activeStrokeWidth, ...props }) => { + const interactionContext = useInteractionContext(); + const activeSeriesId = + interactionContext.activeItem && !Array.isArray(interactionContext.activeItem) + ? interactionContext.activeItem.seriesId + : null; + + // Determine if this line is active, faded, or neutral + const isActive = activeSeriesId === seriesId; + const isFaded = activeSeriesId !== null && !isActive; + + // Active: larger stroke (default 2x), Faded: reduced opacity, Neutral: normal + const effectiveActiveStrokeWidth = activeStrokeWidth ?? strokeWidth * 2; + const effectiveStrokeWidth = isActive ? effectiveActiveStrokeWidth : strokeWidth; + const effectiveOpacity = isFaded ? 0.2 : 1; + + return ( + + ); + }, +); + +/** + * Line series interaction with interactionOffset for larger hit area + */ +export function LineSeriesInteraction() { + const [activeItem, setActiveItem] = useState(undefined); + const [interactionOffset, setInteractionOffset] = useState(8); + const [enableFade, setEnableFade] = useState(true); + + const handleInteractionChange = useCallback((state: InteractionState) => { + setActiveItem(state as ActiveItem | undefined); + }, []); + + const seriesColors: Record = { + btc: 'var(--color-fgPrimary)', + eth: 'var(--color-fgPositive)', + sol: 'var(--color-fgWarning)', + }; + + // Generate some sample data + const btcData = useMemo(() => samplePrices.slice(0, 20), []); + const ethData = useMemo(() => btcData.map((p) => p * 0.7 + Math.random() * 500), [btcData]); + const solData = useMemo(() => btcData.map((p) => p * 0.4 + Math.random() * 300), [btcData]); + + return ( + + + Line Series Interaction + + + Hover over the lines to highlight a specific series. Other lines fade out when one is + active. + + + + interactionOffset: + {[0, 4, 8, 16].map((offset) => ( + + ))} + + + + + + + + + {activeItem ? ( + <> + Index: {activeItem.dataIndex ?? 'none'} + {activeItem.seriesId && ( + <> + {' '} + | Series:{' '} + + {activeItem.seriesId} + + + )} + + ) : ( + 'Hover over a line...' + )} + + + Hit area = strokeWidth (2) + interactionOffset ({interactionOffset}) × 2 ={' '} + {2 + interactionOffset * 2}px + + + + + + + + + {/* Wrap Scrubber with pointer-events: none so it doesn't block line interactions */} + + + + + + + {Object.entries(seriesColors).map(([id, color]) => ( + + + {id.toUpperCase()} + + ))} + + + ); +} diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index db46f73fc..194f1a255 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -75,7 +75,7 @@ export const DefaultBar = memo( } else { interactionContext.setActiveItem(undefined); } - }, [interactionContext, dataIndex]); + }, [interactionContext, dataIndex, seriesId]); // Only add event handlers when series scope is enabled const eventHandlers = interactionContext?.scope.series diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 404720344..0883177c0 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -1,8 +1,9 @@ -import { memo, type SVGProps, useId } from 'react'; +import { memo, type SVGProps, useCallback, useId } from 'react'; import type { SharedProps } from '@coinbase/cds-common/types'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; +import { useOptionalInteractionContext } from '../utils'; import type { LineComponentProps } from './Line'; @@ -26,6 +27,7 @@ export type DottedLineProps = SharedProps & /** * A customizable dotted line component. * Supports gradient for gradient effects on the dots. + * Automatically tracks series interaction when `interactionScope.series` is enabled. */ export const DottedLine = memo( ({ @@ -36,15 +38,63 @@ export const DottedLine = memo( strokeLinejoin = 'round', strokeOpacity = 1, strokeWidth = 2, + interactionOffset, vectorEffect = 'non-scaling-stroke', gradient, yAxisId, + seriesId, animate, transition, d, ...props }) => { const gradientId = useId(); + const interactionContext = useOptionalInteractionContext(); + + // Series interaction handlers + const handleMouseEnter = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + // Get current dataIndex from active item (preserve it) + const currentItem = interactionContext.activeItem; + const currentDataIndex = + currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: seriesId ?? null, + }); + }, [interactionContext, seriesId]); + + const handleMouseLeave = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + // Get current dataIndex from active item (preserve it) + const currentItem = interactionContext.activeItem; + const currentDataIndex = + currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + + // Reset seriesId but keep dataIndex tracking + if (interactionContext.scope.dataIndex) { + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: null, + }); + } else { + interactionContext.setActiveItem(undefined); + } + }, [interactionContext, seriesId]); + + // Determine if we need event handling (series interaction enabled with a seriesId) + const needsEventHandling = interactionContext?.scope.series && seriesId; + + // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) + const eventPathStrokeWidth = + interactionOffset && interactionOffset > 0 + ? strokeWidth + interactionOffset * 2 + : strokeWidth; return ( <> @@ -59,7 +109,9 @@ export const DottedLine = memo( /> )} + {/* Visible dotted line - pointerEvents disabled when we have event handling layer */} ( strokeLinejoin={strokeLinejoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} + style={{ + ...props.style, + pointerEvents: needsEventHandling ? 'none' : undefined, + cursor: needsEventHandling ? 'pointer' : undefined, + }} transition={transition} vectorEffect={vectorEffect} - {...props} /> + {/* + Event handling layer - use raw instead of framer-motion Path component + because motion.path doesn't reliably forward mouse events. + Uses eventPathStrokeWidth which includes interactionOffset when specified. + */} + {needsEventHandling && ( + + )} ); }, diff --git a/packages/web-visualization/src/chart/line/Line.tsx b/packages/web-visualization/src/chart/line/Line.tsx index a58c5b835..678f15d56 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -97,6 +97,16 @@ export type LineBaseProps = SharedProps & { * @default 2 */ strokeWidth?: number; + /** + * Additional pixels to add to each side of the stroke for interaction hit area. + * When set, renders an invisible path with a larger stroke width to make the line + * easier to interact with. Only active when `interactionScope.series` is enabled. + * + * @example + * // A 2px visible line with a 10px hit area (2 + 4*2 = 10px) + * + */ + interactionOffset?: number; /** * Gradient configuration. * When provided, creates gradient or threshold-based coloring. @@ -134,6 +144,7 @@ export type LineComponentProps = Pick< | 'stroke' | 'strokeOpacity' | 'strokeWidth' + | 'interactionOffset' | 'gradient' | 'animate' | 'transition' @@ -150,6 +161,11 @@ export type LineComponentProps = Pick< * If not provided, defaults to the default y-axis. */ yAxisId?: string; + /** + * The series ID this line belongs to. + * Used for interaction tracking when `interactionScope.series` is true. + */ + seriesId?: string; }; export type LineComponent = React.FC; @@ -182,7 +198,11 @@ export const Line = memo( () => gradientProp ?? matchedSeries?.gradient, [gradientProp, matchedSeries?.gradient], ); - const sourceData = useMemo(() => getSeriesData(seriesId), [getSeriesData, seriesId]); + const sourceData = useMemo(() => { + const data = getSeriesData(seriesId); + console.log('[Line] sourceData for seriesId:', seriesId, 'length:', data?.length); + return data; + }, [getSeriesData, seriesId]); const xAxis = useMemo(() => getXAxis(), [getXAxis]); const xScale = useMemo(() => getXScale(), [getXScale]); @@ -192,7 +212,11 @@ export const Line = memo( ); // Convert sourceData to number array (line only supports numbers, not tuples) - const chartData = useMemo(() => getLineData(sourceData), [sourceData]); + const chartData = useMemo(() => { + const data = getLineData(sourceData); + console.log('[Line] chartData length:', data.length); + return data; + }, [sourceData]); const path = useMemo(() => { if (!xScale || !yScale || chartData.length === 0) return ''; @@ -203,7 +227,7 @@ export const Line = memo( ? (xAxis.data as number[]) : undefined; - return getLinePath({ + const result = getLinePath({ data: chartData, xScale, yScale, @@ -211,6 +235,13 @@ export const Line = memo( xData, connectNulls, }); + console.log( + '[Line] path computed, chartData.length:', + chartData.length, + 'path length:', + result.length, + ); + return result; }, [chartData, xScale, yScale, curve, xAxis?.data, connectNulls]); const LineComponent = useMemo((): LineComponent => { @@ -270,6 +301,7 @@ export const Line = memo( ( ({ @@ -34,14 +36,62 @@ export const SolidLine = memo( strokeLinejoin = 'round', strokeOpacity = 1, strokeWidth = 2, + interactionOffset, gradient, yAxisId, + seriesId, animate, transition, d, ...props }) => { const gradientId = useId(); + const interactionContext = useOptionalInteractionContext(); + + // Series interaction handlers + const handleMouseEnter = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + // Get current dataIndex from active item (preserve it) + const currentItem = interactionContext.activeItem; + const currentDataIndex = + currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: seriesId ?? null, + }); + }, [interactionContext, seriesId]); + + const handleMouseLeave = useCallback(() => { + if (!interactionContext || interactionContext.mode === 'none') return; + if (!interactionContext.scope.series) return; + + // Get current dataIndex from active item (preserve it) + const currentItem = interactionContext.activeItem; + const currentDataIndex = + currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + + // Reset seriesId but keep dataIndex tracking + if (interactionContext.scope.dataIndex) { + interactionContext.setActiveItem({ + dataIndex: currentDataIndex, + seriesId: null, + }); + } else { + interactionContext.setActiveItem(undefined); + } + }, [interactionContext, seriesId]); + + // Determine if we need event handling (series interaction enabled with a seriesId) + const needsEventHandling = interactionContext?.scope.series && seriesId; + + // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) + const eventPathStrokeWidth = + interactionOffset && interactionOffset > 0 + ? strokeWidth + interactionOffset * 2 + : strokeWidth; return ( <> @@ -56,7 +106,9 @@ export const SolidLine = memo( /> )} + {/* Visible line - pointerEvents disabled when we have event handling layer */} ( strokeLinejoin={strokeLinejoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} + style={{ + ...props.style, + pointerEvents: needsEventHandling ? 'none' : undefined, + cursor: needsEventHandling ? 'pointer' : undefined, + }} transition={transition} - {...props} /> + {/* + Event handling layer - use raw instead of framer-motion Path component + because motion.path doesn't reliably forward mouse events. + Uses eventPathStrokeWidth which includes interactionOffset when specified. + */} + {needsEventHandling && ( + + )} ); }, diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 903cf1ea2..94d90fdf7 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -7,6 +7,7 @@ import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { ListCell } from '@coinbase/cds-web/cells'; import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'; +import { Icon } from '@coinbase/cds-web/icons'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Avatar, RemoteImage } from '@coinbase/cds-web/media'; import { SectionHeader } from '@coinbase/cds-web/section-header/SectionHeader'; @@ -17,14 +18,16 @@ import { type TabComponent, type TabsActiveIndicatorProps, } from '@coinbase/cds-web/tabs'; -import { Text } from '@coinbase/cds-web/typography'; +import { Text, TextLabel1 } from '@coinbase/cds-web/typography'; import { m } from 'framer-motion'; import { + type ActiveItem, type AxisBounds, DefaultScrubberBeacon, DefaultScrubberLabel, defaultTransition, + type InteractionState, PeriodSelector, PeriodSelectorActiveIndicator, Point, @@ -1599,6 +1602,495 @@ function CustomLabelComponent() { ); } +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleInteractionChange = useCallback((state: InteractionState) => { + const item = state as ActiveItem | undefined; + setScrubberPosition(item?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x' as const, + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x' as const, stops }; + }, [monthStart, monthEnd, prices.length]); + + return ( + + + + ); +} + +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef( + ({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }, + ), + ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + // Sample data using a moving average for smoother results + const sampleData = useCallback((data: number[], targetPoints: number) => { + if (data.length <= targetPoints) return data; + + // First, apply a moving average to smooth the data + const windowSize = Math.max(3, Math.floor(data.length / targetPoints)); + const smoothed: number[] = []; + + for (let i = 0; i < data.length; i++) { + const halfWindow = Math.floor(windowSize / 2); + const start = Math.max(0, i - halfWindow); + const end = Math.min(data.length, i + halfWindow + 1); + const window = data.slice(start, end); + const avg = window.reduce((sum, val) => sum + val, 0) / window.length; + smoothed.push(avg); + } + + // Then sample from the smoothed data + const step = smoothed.length / targetPoints; + const sampled: number[] = []; + + for (let i = 0; i < targetPoints; i++) { + const idx = Math.floor(i * step); + sampled.push(smoothed[idx]); + } + + // Always include the last point for accuracy + sampled[sampled.length - 1] = data[data.length - 1]; + + return sampled; + }, []); + + // Memoized chart component - only re-renders when data or isScrubbing changes + type MemoizedChartProps = { + activeItem: ActiveItem | undefined; + data: number[]; + isScrubbing: boolean; + onInteractionChange: (state: InteractionState) => void; + scrubberLabel: (index: number) => string; + }; + + const MemoizedChart = memo( + ({ activeItem, data, isScrubbing, onInteractionChange, scrubberLabel }: MemoizedChartProps) => { + console.log('[MemoizedChart] Rendering with:', { + activeItem, + dataLength: data.length, + isScrubbing, + strokeWidth: isScrubbing ? 2 : 4, + }); + return ( + ({ min: min + 8, max: max - 8 }) }} + > + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + console.log('[AdaptiveDetailChart] Rendering'); + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + // Store selected timestamp instead of dataIndex - this persists across dataset changes + const [selectedTimestamp, setSelectedTimestamp] = useState(null); + // Track if we're actively scrubbing (separate from selectedTimestamp to handle exit delay) + const [isInteracting, setIsInteracting] = useState(false); + // Timeout ref for delayed exit - prevents race condition when data switches while mouse is still over chart + const exitTimeoutRef = useRef | null>(null); + const isScrubbing = isInteracting; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (exitTimeoutRef.current) { + clearTimeout(exitTimeoutRef.current); + } + }; + }, []); + + // Debug: log state changes + useEffect(() => { + console.log('[AdaptiveDetail] State changed:', { + isInteracting, + isScrubbing, + selectedTimestamp: selectedTimestamp?.toISOString() ?? null, + }); + }, [isInteracting, isScrubbing, selectedTimestamp]); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const sparklineTimePeriodDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const sparklineTimePeriodDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + // Sample more points for larger time frames + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + // Create sampled data with corresponding timestamps for index mapping + const sampledDataWithTimestamps = useMemo(() => { + const values = sparklineTimePeriodDataValues; + const timestamps = sparklineTimePeriodDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues: number[] = []; + const sampledTimestamps: Date[] = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + // Always include the last point for accuracy + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [sparklineTimePeriodDataValues, sparklineTimePeriodDataTimestamps, samplePointCount]); + + // Use sampled data for display when idle, full data when scrubbing + const displayData = useMemo(() => { + const data = isScrubbing ? sparklineTimePeriodDataValues : sampledDataWithTimestamps.values; + console.log('[AdaptiveDetail] displayData computed:', { + isScrubbing, + dataLength: data.length, + fullDataLength: sparklineTimePeriodDataValues.length, + sampledDataLength: sampledDataWithTimestamps.values.length, + }); + return data; + }, [isScrubbing, sparklineTimePeriodDataValues, sampledDataWithTimestamps.values]); + + // Get timestamps for current display data + const displayTimestamps = useMemo(() => { + return isScrubbing ? sparklineTimePeriodDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, sparklineTimePeriodDataTimestamps, sampledDataWithTimestamps.timestamps]); + + // Use ref to avoid stale closure in handleInteractionChange + // This ensures we always access the latest timestamps when the callback fires + const displayTimestampsRef = useRef(displayTimestamps); + displayTimestampsRef.current = displayTimestamps; + + // Find the closest index in the current display data for the selected timestamp + const findClosestIndex = useCallback((timestamp: Date, timestamps: Date[]) => { + const targetTime = timestamp.getTime(); + let closestIdx = 0; + let closestDiff = Math.abs(timestamps[0].getTime() - targetTime); + + for (let i = 1; i < timestamps.length; i++) { + const diff = Math.abs(timestamps[i].getTime() - targetTime); + if (diff < closestDiff) { + closestDiff = diff; + closestIdx = i; + } + } + + return closestIdx; + }, []); + + // Compute controlled activeItem based on selected timestamp and current display data + // Return undefined (not null) when not interacting to allow uncontrolled user input + // Return ActiveItem when interacting to control position across dataset changes + const activeItem = useMemo(() => { + if (selectedTimestamp === null) { + console.log('[AdaptiveDetail] activeItem: undefined (no timestamp)'); + return undefined; + } + + const dataIndex = findClosestIndex(selectedTimestamp, displayTimestamps); + console.log('[AdaptiveDetail] activeItem computed:', { + selectedTimestamp: selectedTimestamp.toISOString(), + dataIndex, + displayTimestampsLength: displayTimestamps.length, + }); + return { dataIndex, seriesId: null }; + }, [selectedTimestamp, displayTimestamps, findClosestIndex]); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + }, + [tabs], + ); + + // Store the timestamp when interaction changes, not the dataIndex + // Uses ref to always get latest displayTimestamps, avoiding stale closure issues + const handleInteractionChange = useCallback((state: InteractionState) => { + const item = state as ActiveItem | undefined; + console.log('[AdaptiveDetail] handleInteractionChange called:', { + item, + displayTimestampsRefLength: displayTimestampsRef.current.length, + }); + + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + // User is interacting - cancel any pending exit timeout + if (exitTimeoutRef.current) { + console.log('[AdaptiveDetail] Cancelling exit timeout'); + clearTimeout(exitTimeoutRef.current); + exitTimeoutRef.current = null; + } + const timestamp = displayTimestampsRef.current[item.dataIndex]; + console.log('[AdaptiveDetail] Setting interaction:', { + dataIndex: item.dataIndex, + timestamp: timestamp?.toISOString(), + }); + setIsInteracting(true); + // Use ref to get the current displayTimestamps (avoids stale closure) + setSelectedTimestamp(timestamp ?? null); + } else { + // User stopped interacting - delay before switching back to sampled data + // This prevents the race condition where switching data while mouse is still + // over the chart causes an immediate re-interaction + console.log('[AdaptiveDetail] Starting exit timeout (50ms)'); + exitTimeoutRef.current = setTimeout(() => { + console.log('[AdaptiveDetail] Exit timeout fired - clearing interaction'); + setIsInteracting(false); + setSelectedTimestamp(null); + exitTimeoutRef.current = null; + }, 50); + } + }, []); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date, periodId: string) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + // Scrubber label now uses displayTimestamps which matches displayData + const scrubberLabel = useCallback( + (index: number) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + // Calculate price change - use selected timestamp to find price in FULL data + const startPrice = sparklineTimePeriodDataValues[0]; + const displayPrice = useMemo(() => { + if (selectedTimestamp === null) { + return sparklineTimePeriodDataValues[sparklineTimePeriodDataValues.length - 1]; + } + // Find the index in full data for the selected timestamp + const fullDataIndex = findClosestIndex(selectedTimestamp, sparklineTimePeriodDataTimestamps); + return sparklineTimePeriodDataValues[fullDataIndex]; + }, [ + selectedTimestamp, + sparklineTimePeriodDataValues, + sparklineTimePeriodDataTimestamps, + findClosestIndex, + ]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + = 0 ? 'rotate(0deg)' : 'rotate(90deg)' }} + /> + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + + ); + }); + + return ; +} + export const All = () => { return ( @@ -1797,6 +2289,12 @@ export const All = () => { + + + + + + ); }; From c6910e3cb5e385b6a5f268d8059d07d533d81104 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 28 Jan 2026 11:03:42 -0500 Subject: [PATCH 04/16] Continue highlight support --- .../graphs/Highlighting/_mobileContent.mdx | 319 +++++++++ .../graphs/Highlighting/_webContent.mdx | 511 +++++++++++++ .../components/graphs/Highlighting/index.mdx | 31 + .../graphs/Highlighting/mobileMetadata.json | 3 + .../graphs/Highlighting/webMetadata.json | 3 + apps/docs/sidebars.ts | 5 + .../src/chart/CartesianChart.tsx | 276 +++---- .../chart/__stories__/Interaction.stories.tsx | 145 ++-- .../src/chart/bar/DefaultBar.tsx | 18 +- .../chart/interaction/HighlightProvider.tsx | 671 ++++++++++++++++++ .../src/chart/interaction/index.ts | 4 +- .../src/chart/utils/context.ts | 133 ++-- .../src/chart/CartesianChart.tsx | 213 +++--- .../chart/__stories__/Interaction.stories.tsx | 247 +++---- .../src/chart/bar/DefaultBar.tsx | 46 +- .../chart/interaction/HighlightProvider.tsx | 495 +++++++++++++ .../chart/interaction/InteractionProvider.tsx | 581 --------------- .../src/chart/interaction/index.ts | 4 +- .../src/chart/line/DottedLine.tsx | 60 +- .../src/chart/line/SolidLine.tsx | 60 +- .../line/__stories__/LineChart.stories.tsx | 56 +- .../src/chart/utils/context.ts | 132 ++-- 22 files changed, 2765 insertions(+), 1248 deletions(-) create mode 100644 apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx create mode 100644 apps/docs/docs/components/graphs/Highlighting/_webContent.mdx create mode 100644 apps/docs/docs/components/graphs/Highlighting/index.mdx create mode 100644 apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json create mode 100644 apps/docs/docs/components/graphs/Highlighting/webMetadata.json create mode 100644 packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx create mode 100644 packages/web-visualization/src/chart/interaction/HighlightProvider.tsx delete mode 100644 packages/web-visualization/src/chart/interaction/InteractionProvider.tsx diff --git a/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx new file mode 100644 index 000000000..92b1d9db2 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx @@ -0,0 +1,319 @@ +import { MDXSection } from '@site/src/components/page/MDXSection'; +import { MDXArticle } from '@site/src/components/page/MDXArticle'; + + + + +## Overview + +Chart highlighting on mobile enables users to interact with data points through touch gestures. When a user long-presses and drags on the chart, the highlighted data point is tracked with haptic feedback. + +Key features: + +- **Touch Gestures**: Long-press to activate, drag to explore data points +- **Haptic Feedback**: Light impact feedback on start and end of interaction +- **Multi-touch**: Track multiple touch points simultaneously +- **Controlled & Uncontrolled Modes**: Manage state internally or externally +- **Series Highlighting**: Optionally track which specific series is being touched +- **Accessibility**: VoiceOver support with configurable region modes + + + + + + + +## Basic Usage + +Highlighting is enabled by default on all Cartesian charts. Use the `onHighlightChange` callback to respond to user interactions. + +```tsx +import { LineChart, Scrubber } from '@coinbase/cds-mobile-visualization'; +import type { HighlightedItem } from '@coinbase/cds-mobile-visualization'; + +function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const displayIndex = highlight[0]?.dataIndex; + const displayValue = + displayIndex !== undefined && displayIndex !== null + ? `$${data[displayIndex].toFixed(2)}` + : 'Long-press to see value'; + + return ( + + {displayValue} + + + + + ); +} +``` + +The `onHighlightChange` callback receives an array of `HighlightedItem` objects: + +```ts +type HighlightedItem = { + dataIndex: number | null; // Index of the highlighted data point + seriesId: string | null; // ID of the highlighted series (when using highlightScope.series) +}; +``` + + + + + + + +## Controlled State + +For full control over the highlighted state, use the `highlight` prop along with `onHighlightChange`. + +```tsx +function ControlledHighlighting() { + const [highlight, setHighlight] = useState(undefined); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + + + + + + + + + ); +} +``` + +### Controlled Mode Behavior + +| `highlight` value | Behavior | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `undefined` | **Uncontrolled mode** - Chart manages its own state | +| `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | +| `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | + + + + + + + +## Disabling Highlighting + +Set `enableHighlighting={false}` to disable all highlighting functionality. + +```tsx + +``` + + + + + + + +## Series Highlighting + +Enable series highlighting to track which specific bar or series the user is touching: + +```tsx +function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + return ( + + + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex} | Series: ${highlight[0]?.seriesId ?? 'none'}` + : 'Long-press over a bar...'} + + + + + ); +} +``` + +### highlightScope Options + +```ts +type HighlightScope = { + dataIndex?: boolean; // Track which data index is highlighted (default: true) + series?: boolean; // Track which series is highlighted (default: false) +}; +``` + + + + + + + +## Accessibility + +Mobile charts support VoiceOver with configurable accessibility modes. + +### accessibilityMode Options + +| Mode | Description | +| ----------- | ------------------------------------------------- | +| `'chunked'` | Divides chart into N accessible regions (default) | +| `'item'` | Each data point is an accessible region | + +```tsx + + item.dataIndex !== null + ? `Day ${item.dataIndex + 1}: $${data[item.dataIndex].toFixed(2)}` + : 'Interacting with price chart' + } + accessibilityMode="chunked" + accessibilityChunkCount={10} + height={200} + series={[{ id: 'prices', data }]} +> + + +``` + + + + + + + +## Using the Highlight Context + +Access the highlight state from child components using the `useHighlightContext` hook: + +```tsx +import { useHighlightContext } from '@coinbase/cds-mobile-visualization'; + +function CustomHighlightIndicator() { + const { highlight, enabled, scope, setHighlight } = useHighlightContext(); + + // highlight is a SharedValue for UI thread performance + // Use useAnimatedReaction or useDerivedValue to react to changes + + return ( + // Custom indicator implementation + ); +} +``` + +### Context Value + +```ts +type HighlightContextValue = { + enabled: boolean; // Whether highlighting is enabled + scope: HighlightScope; // What aspects are being tracked + highlight: SharedValue; // Current highlighted items (Reanimated SharedValue) + setHighlight: (items: HighlightedItem[]) => void; // Update highlight state + // ... element registration methods for hit testing +}; +``` + +:::note +On mobile, `highlight` is a Reanimated `SharedValue` for UI thread performance. Use `useAnimatedReaction` or `useDerivedValue` to react to changes efficiently. +::: + + + + + + + +## Migration from Legacy Props + +The following legacy props are still supported for backwards compatibility but are deprecated: + +| Legacy Prop | New Prop | +| -------------------------- | -------------------- | +| `enableScrubbing` | `enableHighlighting` | +| `onScrubberPositionChange` | `onHighlightChange` | +| `interaction` | `enableHighlighting` | +| `interactionScope` | `highlightScope` | +| `activeItem` | `highlight` | +| `activeItems` | `highlight` | +| `onInteractionChange` | `onHighlightChange` | + +```tsx +// Legacy (still works) + console.log(index)} + {...props} +/> + +// Recommended + console.log(items[0]?.dataIndex)} + {...props} +/> +``` + + + + + + + +## API Reference + +### Chart Props + +| Prop | Type | Default | Description | +| ------------------------- | ----------------------------------------------- | --------------------- | ----------------------------------- | +| `enableHighlighting` | `boolean` | `true` | Enable/disable highlighting | +| `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | +| `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | +| `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | +| `accessibilityLabel` | `string \| ((item: HighlightedItem) => string)` | - | Accessibility label | +| `accessibilityMode` | `'chunked' \| 'item'` | `'chunked'` | VoiceOver region mode | +| `accessibilityChunkCount` | `number` | `10` | Number of chunks for 'chunked' mode | + +### Types + +```ts +type HighlightedItem = { + dataIndex: number | null; + seriesId: string | null; +}; + +type HighlightScope = { + dataIndex?: boolean; + series?: boolean; +}; +``` + + + diff --git a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx new file mode 100644 index 000000000..86cd89e1b --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx @@ -0,0 +1,511 @@ +import { MDXSection } from '@site/src/components/page/MDXSection'; +import { MDXArticle } from '@site/src/components/page/MDXArticle'; + + + + +## Overview + +Chart highlighting enables users to interact with data points in Cartesian charts through mouse, touch, and keyboard input. When a user hovers over or touches the chart, the highlighted data point is tracked and can be used to display additional information like tooltips, data labels, or synchronized views across multiple charts. + +Key features: + +- **Mouse & Touch Support**: Highlight data points on hover or touch +- **Multi-touch**: Track multiple touch points simultaneously on touch devices +- **Keyboard Navigation**: Navigate between data points using arrow keys +- **Controlled & Uncontrolled Modes**: Manage state internally or externally +- **Series Highlighting**: Optionally track which specific series is being interacted with +- **Accessibility**: Dynamic `aria-label` support for screen readers + + + + + + + +## Basic Usage + +Highlighting is enabled by default on all Cartesian charts. Use the `onHighlightChange` callback to respond to user interactions. + +```jsx live +function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const formatPrice = useCallback((value) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const displayIndex = highlight[0]?.dataIndex; + const displayValue = + displayIndex !== undefined && displayIndex !== null + ? formatPrice(data[displayIndex]) + : 'Hover to see value'; + + return ( + + + {displayValue} + + + + + + ); +} +``` + +The `onHighlightChange` callback receives an array of `HighlightedItem` objects: + +```ts +type HighlightedItem = { + dataIndex: number | null; // Index of the highlighted data point + seriesId: string | null; // ID of the highlighted series (when using highlightScope.series) +}; +``` + + + + + + + +## Controlled State + +For full control over the highlighted state, use the `highlight` prop along with `onHighlightChange`. This is useful for: + +- Programmatically selecting data points +- Synchronizing highlights across multiple charts +- Persisting highlight state + +```jsx live +function ControlledHighlighting() { + const [highlight, setHighlight] = useState(undefined); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + + + + + + + Index: {highlight?.[0]?.dataIndex ?? 'none'} + + + + + + + ); +} +``` + +### Controlled Mode Behavior + +| `highlight` value | Behavior | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `undefined` | **Uncontrolled mode** - Chart manages its own state | +| `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | +| `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | + +:::tip +Even in controlled mode, `onHighlightChange` still fires when the user interacts with the chart. This allows you to respond to user gestures without necessarily updating the controlled state. +::: + + + + + + + +## Disabling Highlighting + +Set `enableHighlighting={false}` to disable all highlighting functionality. The chart will be display-only. + +```jsx live + +``` + + + + + + + +## Series Highlighting + +By default, only the data index is tracked during interactions. To also track which specific series the user is interacting with (useful for grouped bar charts or multi-line charts), enable series highlighting with `highlightScope`: + +```jsx live +function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); + + const seriesColors = { + A: 'var(--color-fgPrimary)', + B: 'var(--color-fgPositive)', + C: 'var(--color-fgWarning)', + }; + + return ( + + + + {highlight.length > 0 ? ( + <> + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( + <> + {' | Series: '} + + {highlight[0].seriesId} + + + )} + + ) : ( + 'Hover over a bar...' + )} + + + + + + ); +} +``` + +### highlightScope Options + +```ts +type HighlightScope = { + dataIndex?: boolean; // Track which data index is highlighted (default: true) + series?: boolean; // Track which series is highlighted (default: false) +}; +``` + + + + + + + +## Synchronizing with UI Elements + +Use controlled state to synchronize chart highlighting with other UI elements like lists. This is useful for creating interactive experiences where users can explore data from either the chart or a list view. + +```jsx live +function SynchronizedWithList() { + const [highlight, setHighlight] = useState(undefined); + + const data = [ + { name: 'Bitcoin', symbol: 'BTC', price: 42150, change: 2.4 }, + { name: 'Ethereum', symbol: 'ETH', price: 2280, change: -1.2 }, + { name: 'Solana', symbol: 'SOL', price: 98, change: 5.8 }, + { name: 'Cardano', symbol: 'ADA', price: 0.52, change: -0.3 }, + { name: 'Polygon', symbol: 'MATIC', price: 0.89, change: 1.1 }, + ]; + + const chartData = data.map((item) => item.price); + const highlightedIndex = highlight?.[0]?.dataIndex; + + const formatPrice = (price) => + price >= 1 + ? `$${price.toLocaleString('en-US', { minimumFractionDigits: 2 })}` + : `$${price.toFixed(2)}`; + + return ( + + + Hover over the chart or the list items to see synchronized highlighting. + + + d.symbol) }} + > + + + + + + {data.map((item, index) => ( + 0 ? '+' : ''}${item.change}%`} + variant={item.change >= 0 ? 'positive' : 'negative'} + selected={highlightedIndex === index} + onMouseEnter={() => setHighlight([{ dataIndex: index, seriesId: null }])} + onMouseLeave={() => setHighlight(undefined)} + /> + ))} + + + ); +} +``` + + + + + + + +## Multi-Touch Support + +On touch devices, the highlight array can contain multiple items - one for each touch point. This enables features like comparing two data points simultaneously. + +```jsx live +function MultiTouchHighlighting() { + const [highlight, setHighlight] = useState([]); + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + // Custom component that renders a ReferenceLine for each touch point + const MultiTouchReferenceLines = memo(() => { + const { highlight: items } = useHighlightContext(); + const colors = ['var(--color-fgPrimary)', 'var(--color-fgPositive)', 'var(--color-fgNegative)']; + + return ( + <> + {items.map((item, index) => + item.dataIndex !== null ? ( + + ) : null, + )} + + ); + }); + + return ( + + + Use multiple fingers on a touch device to see multiple reference lines. + + + + + Active touches: {highlight.length} + {highlight.length > 0 && ` (${highlight.map((item) => `#${item.dataIndex}`).join(', ')})`} + + + + + + + + ); +} +``` + + + + + + + +## Accessibility + +Provide an `accessibilityLabel` to make the chart accessible to screen readers. This can be a static string or a function that receives the current highlighted item for dynamic labels. + +### Static Label + +```jsx + + + +``` + +### Dynamic Label + +```jsx live +function DynamicAccessibilityLabel() { + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const accessibilityLabel = useCallback((item) => { + if (item.dataIndex === null) return 'Interacting with price chart'; + return `Day ${item.dataIndex + 1}: $${data[item.dataIndex].toFixed(2)}`; + }, []); + + return ( + + + + ); +} +``` + + + + + + + +## Using the Highlight Context + +For advanced use cases, access the highlight state from child components using the `useHighlightContext` hook: + +```jsx +import { useHighlightContext } from '@coinbase/cds-web-visualization'; + +function CustomHighlightIndicator() { + const { highlight, enabled, scope, setHighlight } = useHighlightContext(); + + if (!enabled || highlight.length === 0) return null; + + return ( +
+ Current index: {highlight[0]?.dataIndex} + {scope.series && ` | Series: ${highlight[0]?.seriesId}`} +
+ ); +} +``` + +### Context Value + +```ts +type HighlightContextValue = { + enabled: boolean; // Whether highlighting is enabled + scope: HighlightScope; // What aspects are being tracked + highlight: HighlightedItem[]; // Current highlighted items + setHighlight: (items: HighlightedItem[]) => void; // Update highlight state +}; +``` + +:::note +Use `useOptionalHighlightContext` if your component might be rendered outside of a chart context. It returns `undefined` instead of throwing an error. +::: + +
+
+ + + + +## Migration from Legacy Props + +The following legacy props are still supported for backwards compatibility but are deprecated: + +| Legacy Prop | New Prop | +| -------------------------- | -------------------- | +| `enableScrubbing` | `enableHighlighting` | +| `onScrubberPositionChange` | `onHighlightChange` | + +```jsx +// Legacy (still works) + console.log(index)} + {...props} +/> + +// Recommended + console.log(items[0]?.dataIndex)} + {...props} +/> +``` + + + + + + + +## API Reference + +### Chart Props + +| Prop | Type | Default | Description | +| -------------------- | ----------------------------------------------- | --------------------- | ------------------------------- | +| `enableHighlighting` | `boolean` | `true` | Enable/disable highlighting | +| `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | +| `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | +| `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | +| `accessibilityLabel` | `string \| ((item: HighlightedItem) => string)` | - | Accessibility label | + +### Types + +```ts +type HighlightedItem = { + dataIndex: number | null; + seriesId: string | null; +}; + +type HighlightScope = { + dataIndex?: boolean; + series?: boolean; +}; +``` + + + diff --git a/apps/docs/docs/components/graphs/Highlighting/index.mdx b/apps/docs/docs/components/graphs/Highlighting/index.mdx new file mode 100644 index 000000000..162601507 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/index.mdx @@ -0,0 +1,31 @@ +--- +id: highlighting +title: Chart Highlighting +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ContentHeader } from '@site/src/components/page/ContentHeader'; +import { ContentPageContainer } from '@site/src/components/page/ContentPageContainer'; + +import WebContent, { toc as webContentToc } from './_webContent.mdx'; +import MobileContent, { toc as mobileContentToc } from './_mobileContent.mdx'; + +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + mobileContent={} + webContentToc={webContentToc} + mobileContentToc={mobileContentToc} + /> + diff --git a/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json b/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json new file mode 100644 index 000000000..8154823d3 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/mobileMetadata.json @@ -0,0 +1,3 @@ +{ + "description": "Interactive highlighting for Cartesian charts on mobile. Enable users to explore data points through touch gestures with haptic feedback." +} diff --git a/apps/docs/docs/components/graphs/Highlighting/webMetadata.json b/apps/docs/docs/components/graphs/Highlighting/webMetadata.json new file mode 100644 index 000000000..c7edab9b3 --- /dev/null +++ b/apps/docs/docs/components/graphs/Highlighting/webMetadata.json @@ -0,0 +1,3 @@ +{ + "description": "Interactive highlighting for Cartesian charts. Enable users to explore data points through mouse, touch, and keyboard interaction." +} diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 2561f5655..84708519b 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -609,6 +609,11 @@ const sidebars: SidebarsConfig = { id: 'components/graphs/CartesianChart/cartesianChart', label: 'CartesianChart', }, + { + type: 'doc', + id: 'components/graphs/Highlighting/highlighting', + label: 'Highlighting', + }, { type: 'doc', id: 'components/graphs/Legend/legend', diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 5063ff1ac..effe639cd 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -6,6 +6,7 @@ import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; +import { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; import { InteractionProvider } from './interaction/InteractionProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; @@ -27,6 +28,7 @@ import { getAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type HighlightedItem, type InteractionMode, type InteractionScope, type InteractionState, @@ -47,109 +49,96 @@ const ChartCanvas = memo( }, ); -export type CartesianChartBaseProps = Omit & { - /** - * Configuration objects that define how to visualize the data. - * Each series contains its own data array. - */ - series?: Array; - /** - * Whether to animate the chart. - * @default true - */ - animate?: boolean; - /** - * Configuration for x-axis. - */ - xAxis?: Partial>; - /** - * Configuration for y-axis(es). Can be a single config or array of configs. - */ - yAxis?: Partial | Partial[]; - /** - * Inset around the entire chart (outside the axes). - */ - inset?: number | Partial; - /** - * Whether to show the legend or a custom legend element. - * - `true` renders the default Legend component - * - A React element renders that element as the legend - * - `false` or omitted hides the legend - */ - legend?: boolean | React.ReactNode; - /** - * Position of the legend relative to the chart. - * @default 'bottom' - */ - legendPosition?: LegendPosition; - /** - * Accessibility label for the legend group. - * @default 'Legend' - */ - legendAccessibilityLabel?: string; - // New Interaction API - /** - * The interaction mode. - * - 'none': Interaction disabled - * - 'single': Single touch interaction (default) - * - 'multi': Multi-touch interaction - * @default 'single' - */ - interaction?: InteractionMode; - /** - * Controls what aspects of the data can be interacted with. - * @default { dataIndex: true, series: false } - */ - interactionScope?: InteractionScope; - /** - * Controlled active item (for single mode). - * - undefined: Uncontrolled mode - * - null: Controlled mode with no active item (ignores user gestures) - * - ActiveItem: Controlled mode with specific active item - */ - activeItem?: ActiveItem | null; - /** - * Controlled active items (for multi mode). - * - undefined: Uncontrolled mode - * - []: Controlled mode with no active items (ignores user gestures) - * - ActiveItems: Controlled mode with specific active items - */ - activeItems?: ActiveItems; - /** - * Callback fired when the active item changes during interaction. - * For single mode: receives `ActiveItem | undefined` - * For multi mode: receives `ActiveItems` - */ - onInteractionChange?: (state: InteractionState) => void; - /** - * Accessibility label for the chart. - * - When a string: Used as a static label for the chart element - * - When a function: Called with the active item to generate dynamic labels during interaction - */ - accessibilityLabel?: string | ((activeItem: ActiveItem) => 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; - - // Legacy props for backwards compatibility - /** - * @deprecated Use `interaction="single"` instead. Will be removed in next major version. - */ - enableScrubbing?: boolean; - /** - * @deprecated Use `onInteractionChange` instead. Will be removed in next major version. - */ - onScrubberPositionChange?: (index: number | undefined) => void; -}; +export type CartesianChartBaseProps = Omit & + HighlightProps & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data array. + */ + series?: Array; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for x-axis. + */ + xAxis?: Partial>; + /** + * Configuration for y-axis(es). Can be a single config or array of configs. + */ + yAxis?: Partial | Partial[]; + /** + * Inset around the entire chart (outside the axes). + */ + inset?: number | Partial; + /** + * Whether to show the legend or a custom legend element. + * - `true` renders the default Legend component + * - A React element renders that element as the legend + * - `false` or omitted hides the legend + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; + /** + * Accessibility label for the legend group. + * @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; + + // Legacy props for backwards compatibility + /** + * @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; + /** + * @deprecated Use `enableHighlighting` instead. + */ + interaction?: InteractionMode; + /** + * @deprecated Use `highlightScope` instead. + */ + interactionScope?: InteractionScope; + /** + * @deprecated Use `highlight` instead. + */ + activeItem?: ActiveItem | null; + /** + * @deprecated Use `highlight` instead. + */ + activeItems?: ActiveItems; + /** + * @deprecated Use `onHighlightChange` instead. + */ + onInteractionChange?: (state: InteractionState) => void; + }; export type CartesianChartProps = CartesianChartBaseProps & Omit & { @@ -198,18 +187,22 @@ export const CartesianChart = memo( xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, - // New interaction props - interaction, - interactionScope, - activeItem, - activeItems, - onInteractionChange, + // New highlighting props + enableHighlighting, + highlightScope, + highlight, + onHighlightChange, accessibilityLabel, accessibilityMode, accessibilityChunkCount, // Legacy props enableScrubbing, onScrubberPositionChange, + interaction, + interactionScope, + activeItem, + activeItems, + onInteractionChange, legend, legendPosition = 'bottom', legendAccessibilityLabel, @@ -530,12 +523,50 @@ export const CartesianChart = memo( return [style, styles?.root]; }, [style, styles?.root]); - // Resolve interaction mode (backwards compatibility with enableScrubbing) - const resolvedInteraction: InteractionMode = useMemo(() => { - if (interaction !== undefined) return interaction; - if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; - return 'single'; // Default to single - }, [interaction, enableScrubbing]); + // Resolve highlighting enabled (backwards compatibility with enableScrubbing and interaction) + const isHighlightingEnabled: boolean = useMemo(() => { + if (enableHighlighting !== undefined) return enableHighlighting; + if (interaction !== undefined) return interaction !== 'none'; + if (enableScrubbing !== undefined) return enableScrubbing; + return true; // Default to enabled + }, [enableHighlighting, interaction, enableScrubbing]); + + // Resolve highlight scope (backwards compatibility with interactionScope) + const resolvedHighlightScope = useMemo(() => { + if (highlightScope !== undefined) return highlightScope; + if (interactionScope !== undefined) return interactionScope; + return undefined; + }, [highlightScope, interactionScope]); + + // Resolve highlight state (backwards compatibility with activeItem/activeItems) + const resolvedHighlight = useMemo((): HighlightedItem[] | undefined => { + if (highlight !== undefined) return highlight; + if (activeItems !== undefined) return activeItems; + if (activeItem !== undefined) { + return activeItem === null ? [] : [activeItem]; + } + return undefined; + }, [highlight, activeItem, activeItems]); + + // Wrap onHighlightChange to also call legacy callbacks + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + // Legacy callback support + if (onInteractionChange) { + // Convert to old InteractionState format + const singleItem = items[0]; + onInteractionChange(singleItem); + } + + if (onScrubberPositionChange) { + const singleItem = items[0]; + onScrubberPositionChange(singleItem?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onInteractionChange, onScrubberPositionChange], + ); const legendElement = useMemo(() => { if (!legend) return; @@ -566,18 +597,15 @@ export const CartesianChart = memo( return ( - {legend ? ( {children} )} - + ); }, diff --git a/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx index 61f7ba25b..f7e9aa620 100644 --- a/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx +++ b/packages/mobile-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -10,7 +10,7 @@ import { BarChart, BarPlot } from '../bar'; import { CartesianChart } from '../CartesianChart'; import { Line, LineChart } from '../line'; import { Scrubber } from '../scrubber'; -import type { ActiveItem, InteractionState } from '../utils'; +import type { HighlightedItem } from '../utils'; const formatPrice = (value: number) => new Intl.NumberFormat('en-US', { @@ -30,17 +30,18 @@ const seriesB = [4, 3, 1, 5, 8]; const xAxisData = ['0', '2', '5', '10', '20']; /** - * Basic interaction with single mode + * Basic highlighting */ -const BasicInteraction = () => { - const [activeItem, setActiveItem] = useState(undefined); +const BasicHighlighting = () => { + const [highlight, setHighlight] = useState([]); const theme = useTheme(); return ( - Active: {activeItem ? `dataIndex: ${activeItem.dataIndex}` : 'Not interacting'} + Active:{' '} + {highlight.length > 0 ? `dataIndex: ${highlight[0]?.dataIndex}` : 'Not interacting'} @@ -48,8 +49,7 @@ const BasicInteraction = () => { showArea showYAxis height={250} - interaction="single" - onInteractionChange={(state) => setActiveItem(state as ActiveItem | undefined)} + onHighlightChange={setHighlight} series={[{ id: 'price', data: samplePrices, color: theme.color.fgPrimary }]} > @@ -59,51 +59,50 @@ const BasicInteraction = () => { }; /** - * Controlled state - programmatically set the active item + * Controlled state - programmatically set the highlighted item */ const ControlledState = () => { const theme = useTheme(); - // null = controlled mode with no active item - // ActiveItem = controlled mode with specific active item - const [activeItem, setActiveItem] = useState(null); + // undefined = uncontrolled mode + // HighlightedItem[] = controlled mode with specific highlighted items + const [highlight, setHighlight] = useState(undefined); return ( - Use buttons to programmatically select data points. Pass null to clear without listening to - user input. + Use buttons to programmatically select data points. Pass undefined to go back to + uncontrolled mode. - - - - - Index: {activeItem?.dataIndex ?? 'none'} - {activeItem?.dataIndex !== undefined && - activeItem.dataIndex !== null && - ` (${formatPrice(samplePrices[activeItem.dataIndex])})`} + Index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== undefined && + highlight[0].dataIndex !== null && + ` (${formatPrice(samplePrices[highlight[0].dataIndex])})`} @@ -113,15 +112,11 @@ const ControlledState = () => { }; /** - * Series interaction - track which specific bar is being touched + * Series highlighting - track which specific bar is being touched */ -const SeriesInteraction = () => { +const SeriesHighlighting = () => { const theme = useTheme(); - const [activeItem, setActiveItem] = useState(undefined); - - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem(state as ActiveItem | undefined); - }, []); + const [highlight, setHighlight] = useState([]); const seriesColors: Record = { A: theme.color.fgPrimary, @@ -137,17 +132,16 @@ const SeriesInteraction = () => { - {activeItem - ? `Index: ${activeItem.dataIndex ?? 'none'}${activeItem.seriesId ? ` | Series: ${activeItem.seriesId}` : ''}` + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex ?? 'none'}${highlight[0]?.seriesId ? ` | Series: ${highlight[0].seriesId}` : ''}` : 'Long-press over a bar...'} { */ const OverlappingBarsZOrder = () => { const theme = useTheme(); - const [activeItem, setActiveItem] = useState(undefined); - - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem(state as ActiveItem | undefined); - }, []); + const [highlight, setHighlight] = useState([]); const seriesColors: Record = { revenue: theme.color.fgWarning, @@ -193,18 +183,17 @@ const OverlappingBarsZOrder = () => { - {activeItem - ? `Index: ${activeItem.dataIndex ?? 'none'}${activeItem.seriesId ? ` | Series: ${activeItem.seriesId}` : ''}` + {highlight.length > 0 + ? `Index: ${highlight[0]?.dataIndex ?? 'none'}${highlight[0]?.seriesId ? ` | Series: ${highlight[0].seriesId}` : ''}` : 'Long-press over a bar...'} { }; /** - * Synchronized interaction across multiple charts + * Synchronized highlighting across multiple charts */ const SynchronizedCharts = () => { const theme = useTheme(); - const [activeItem, setActiveItem] = useState(null); - - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem((state as ActiveItem) ?? null); - }, []); + const [highlight, setHighlight] = useState(undefined); return ( @@ -305,31 +290,30 @@ const SynchronizedCharts = () => { ))} - - Highlighted index: {activeItem?.dataIndex ?? 'none'} - {activeItem?.dataIndex !== null && - activeItem?.dataIndex !== undefined && - ` (A: ${seriesA[activeItem.dataIndex]}, B: ${seriesB[activeItem.dataIndex]})`} + 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 */ @@ -396,14 +402,14 @@ const BackwardsCompatibility = () => { const InteractionStories = () => { return ( - - + + - - + + @@ -411,6 +417,9 @@ const InteractionStories = () => { + + + diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index 5b7ad85e8..5f07d1ee1 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -3,7 +3,7 @@ import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Path } from '../Path'; -import { getBarPath, useOptionalInteractionContext } from '../utils'; +import { getBarPath, useOptionalHighlightContext } from '../utils'; import type { BarComponentProps } from './Bar'; @@ -12,8 +12,8 @@ export type DefaultBarProps = BarComponentProps; /** * Default bar component that renders a solid bar with animation support. * - * Automatically registers bounds for series interaction hit testing when - * `interactionScope.series` is enabled. + * Automatically registers bounds for series highlighting hit testing when + * `highlightScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -35,16 +35,16 @@ export const DefaultBar = memo( transition, }) => { const { animate } = useCartesianChartContext(); - const interactionContext = useOptionalInteractionContext(); + const highlightContext = useOptionalHighlightContext(); - // Register bar bounds for hit testing when series interaction is enabled + // Register bar bounds for hit testing when series highlighting is enabled useEffect(() => { - if (!interactionContext?.scope.series || !seriesId) return; + if (!highlightContext?.scope.series || !seriesId) return; // Get the data index as a number const dataIndex = typeof dataX === 'number' ? dataX : 0; - interactionContext.registerBar({ + highlightContext.registerBar({ x, y, width, @@ -54,9 +54,9 @@ export const DefaultBar = memo( }); return () => { - interactionContext.unregisterBar(seriesId, dataIndex); + highlightContext.unregisterBar(seriesId, dataIndex); }; - }, [x, y, width, height, dataX, seriesId, interactionContext]); + }, [x, y, width, height, dataX, seriesId, highlightContext]); const theme = useTheme(); const defaultFill = fill || theme.color.fgPrimary; diff --git a/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx new file mode 100644 index 000000000..a87c7381f --- /dev/null +++ b/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx @@ -0,0 +1,671 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { + runOnJS, + type SharedValue, + useAnimatedReaction, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated'; +import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; + +import { useCartesianChartContext } from '../ChartProvider'; +import { + type ElementBounds, + HighlightContext, + type HighlightContextValue, + type HighlightedItem, + type HighlightScope, + type InteractionRegistry, + invertSerializableScale, + type LinePath, + type PointBounds, + ScrubberContext, + type ScrubberContextValue, +} from '../utils'; +import { getPointOnSerializableScale } from '../utils/point'; + +const defaultHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + +export type HighlightProps = { + /** + * Whether highlighting is enabled. + * @default true + */ + enableHighlighting?: boolean; + /** + * Controls what aspects of the data can be highlighted. + * @default { dataIndex: true, series: false } + */ + highlightScope?: HighlightScope; + /** + * Controlled highlight state. + * - undefined: Uncontrolled mode - chart manages its own state + * - HighlightedItem[]: Controlled mode with specific highlighted items + * + * In controlled mode, user interactions still fire onHighlightChange but don't update the UI. + * This allows the parent to decide whether to apply the change. + */ + highlight?: HighlightedItem[]; + /** + * Callback fired when highlighting changes during interaction. + * Always fires in both controlled and uncontrolled modes. + */ + 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) + * - 'series': Each series is an accessible region + * @default 'chunked' + */ + accessibilityMode?: 'chunked' | 'item' | 'series'; + /** + * Number of accessible chunks when accessibilityMode is 'chunked'. + * @default 10 + */ + accessibilityChunkCount?: number; +}; + +/** + * HighlightProvider manages chart highlighting state and gesture handling for mobile. + * It supports single and multi-touch interactions with configurable scope. + */ +export const HighlightProvider: React.FC = ({ + children, + allowOverflowGestures, + enableHighlighting = true, + 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( + () => ({ ...defaultHighlightScope, ...scopeProp }), + [scopeProp], + ); + + // ============================================================================ + // Interaction Registry (for coordinate-based hit testing) + // ============================================================================ + + // Use ref to avoid re-renders when registering elements + const registryRef = useRef({ + bars: [], + points: [], + lines: [], + }); + + // Register a bar element for hit testing + const registerBar = useCallback((bounds: ElementBounds) => { + // Add to registry (elements are stored in render order) + registryRef.current.bars.push(bounds); + }, []); + + // Unregister a bar element + const unregisterBar = useCallback((seriesId: string, dataIndex: number) => { + registryRef.current.bars = registryRef.current.bars.filter( + (bar) => !(bar.seriesId === seriesId && bar.dataIndex === dataIndex), + ); + }, []); + + // Register a point element for hit testing + const registerPoint = useCallback((bounds: PointBounds) => { + registryRef.current.points.push(bounds); + }, []); + + // Unregister a point element + const unregisterPoint = useCallback((seriesId: string, dataIndex: number) => { + registryRef.current.points = registryRef.current.points.filter( + (point) => !(point.seriesId === seriesId && point.dataIndex === dataIndex), + ); + }, []); + + // Register a line path for hit testing + const registerLine = useCallback((path: LinePath) => { + // Replace existing line with same seriesId (path may update) + registryRef.current.lines = registryRef.current.lines.filter( + (line) => line.seriesId !== path.seriesId, + ); + registryRef.current.lines.push(path); + }, []); + + // Unregister a line path + const unregisterLine = useCallback((seriesId: string) => { + registryRef.current.lines = registryRef.current.lines.filter( + (line) => line.seriesId !== seriesId, + ); + }, []); + + // Find bar at touch point (iterates in reverse for correct z-order) + const findBarAtPoint = useCallback((touchX: number, touchY: number): ElementBounds | null => { + const bars = registryRef.current.bars; + // Iterate in reverse order (last rendered = on top = checked first) + 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; + }, []); + + // Find point at touch point + const findPointAtTouch = useCallback( + (touchX: number, touchY: number, touchTolerance: number = 10): PointBounds | null => { + const points = registryRef.current.points; + for (let i = points.length - 1; i >= 0; i--) { + const point = points[i]; + const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); + if (distance <= point.radius + touchTolerance) { + return point; + } + } + return null; + }, + [], + ); + + // Find series at touch point (checks bars first, then points) + const findSeriesAtPoint = useCallback( + (touchX: number, touchY: number): string | null => { + // Check bars first + const hitBar = findBarAtPoint(touchX, touchY); + if (hitBar) return hitBar.seriesId; + + // Check points + const hitPoint = findPointAtTouch(touchX, touchY); + if (hitPoint) return hitPoint.seriesId; + + return null; + }, + [findBarAtPoint, findPointAtTouch], + ); + + // ============================================================================ + + // Determine if we're in controlled mode + const isControlled = controlledHighlight !== undefined; + + // Use SharedValue for UI thread performance + const internalHighlight = useSharedValue([]); + + // The exposed highlight SharedValue - returns controlled value or internal value + const highlight: SharedValue = useMemo(() => { + if (isControlled) { + // Create a proxy that returns the controlled value but doesn't update internal state + return { + get value() { + return controlledHighlight ?? []; + }, + set value(_newValue: HighlightedItem[]) { + // In controlled mode, don't update - the gesture handlers will call onHighlightChange directly + }, + 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 { + // For numeric scales with axis data, find the nearest data point + 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 handlers + const handleStartEndHaptics = useCallback(() => { + void Haptics.lightImpact(); + }, []); + + // Handle JS thread callback when highlight changes + const handleHighlightChangeJS = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + }, + [onHighlightChange], + ); + + // React to highlight changes and call JS callback + useAnimatedReaction( + () => highlight.value, + (currentValue, previousValue) => { + if (currentValue !== previousValue) { + runOnJS(handleHighlightChangeJS)(currentValue); + } + }, + [handleHighlightChangeJS], + ); + + // Setter function for context - always fires callback, only updates internal state when uncontrolled + const setHighlight = useCallback( + (newItems: HighlightedItem[]) => { + if (!isControlled) { + internalHighlight.value = newItems; + } + onHighlightChange?.(newItems); + }, + [isControlled, internalHighlight, onHighlightChange], + ); + + // Helper to create highlighted item with optional series hit testing (runs on JS thread) + const createHighlightedItemWithSeries = useCallback( + (x: number, y: number, dataIndex: number | null): HighlightedItem => { + let seriesId: string | null = null; + if (scope.series) { + seriesId = findSeriesAtPoint(x, y); + } + return { dataIndex, seriesId }; + }, + [scope.series, findSeriesAtPoint], + ); + + // Create the long press pan gesture for single touch + const singleTouchGesture = useMemo( + () => + Gesture.Pan() + .activateAfterLongPress(110) + .shouldCancelWhenOutside(!allowOverflowGestures) + .onStart(function onStart(event) { + runOnJS(handleStartEndHaptics)(); + + // Android does not trigger onUpdate when the gesture starts + if (Platform.OS === 'android') { + const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; + // Series hit testing runs on JS thread + runOnJS((x: number, y: number, di: number | null) => { + const newItem = createHighlightedItemWithSeries(x, y, di); + const currentItems = internalHighlight.value; + const currentItem = currentItems[0]; + if ( + newItem.dataIndex !== currentItem?.dataIndex || + newItem.seriesId !== currentItem?.seriesId + ) { + if (!isControlled) { + internalHighlight.value = [newItem]; + } + onHighlightChange?.([newItem]); + } + })(event.x, event.y, dataIndex); + } + }) + .onUpdate(function onUpdate(event) { + const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; + // Series hit testing runs on JS thread + runOnJS((x: number, y: number, di: number | null) => { + const newItem = createHighlightedItemWithSeries(x, y, di); + const currentItems = internalHighlight.value; + const currentItem = currentItems[0]; + if ( + newItem.dataIndex !== currentItem?.dataIndex || + newItem.seriesId !== currentItem?.seriesId + ) { + if (!isControlled) { + internalHighlight.value = [newItem]; + } + onHighlightChange?.([newItem]); + } + })(event.x, event.y, dataIndex); + }) + .onEnd(function onEnd() { + if (enableHighlighting) { + runOnJS(handleStartEndHaptics)(); + if (!isControlled) { + internalHighlight.value = []; + } + runOnJS(onHighlightChange ?? (() => {}))([]); + } + }) + .onTouchesCancelled(function onTouchesCancelled() { + if (enableHighlighting) { + if (!isControlled) { + internalHighlight.value = []; + } + runOnJS(onHighlightChange ?? (() => {}))([]); + } + }), + [ + allowOverflowGestures, + handleStartEndHaptics, + getDataIndexFromX, + scope.dataIndex, + createHighlightedItemWithSeries, + internalHighlight, + enableHighlighting, + isControlled, + onHighlightChange, + ], + ); + + // Helper to process touches and create highlighted items (runs on JS thread) + const processMultiTouches = useCallback( + (touches: Array<{ x: number; y: number }>): HighlightedItem[] => { + return touches.map((touch) => { + const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; + let seriesId: string | null = null; + if (scope.series) { + seriesId = findSeriesAtPoint(touch.x, touch.y); + } + return { dataIndex, seriesId }; + }); + }, + [scope.dataIndex, scope.series, getDataIndexFromX, findSeriesAtPoint], + ); + + // Create multi-touch gesture + const multiTouchGesture = useMemo( + () => + Gesture.Manual() + .shouldCancelWhenOutside(!allowOverflowGestures) + .onTouchesDown(function onTouchesDown(event) { + runOnJS(handleStartEndHaptics)(); + + // Extract touch coordinates for JS thread processing + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalHighlight.value = items; + } + onHighlightChange?.(items); + })(touches); + }) + .onTouchesMove(function onTouchesMove(event) { + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalHighlight.value = items; + } + onHighlightChange?.(items); + })(touches); + }) + .onTouchesUp(function onTouchesUp(event) { + if (event.allTouches.length === 0) { + runOnJS(handleStartEndHaptics)(); + if (!isControlled) { + internalHighlight.value = []; + } + runOnJS(onHighlightChange ?? (() => {}))([]); + } else { + const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); + runOnJS((touchData: Array<{ x: number; y: number }>) => { + const items = processMultiTouches(touchData); + if (!isControlled) { + internalHighlight.value = items; + } + onHighlightChange?.(items); + })(touches); + } + }) + .onTouchesCancelled(function onTouchesCancelled() { + if (!isControlled) { + internalHighlight.value = []; + } + runOnJS(onHighlightChange ?? (() => {}))([]); + }), + [ + allowOverflowGestures, + handleStartEndHaptics, + processMultiTouches, + internalHighlight, + isControlled, + onHighlightChange, + ], + ); + + // Use single touch gesture by default (multi-touch can be enabled via context if needed) + const gesture = singleTouchGesture; + + const contextValue: HighlightContextValue = useMemo( + () => ({ + enabled: enableHighlighting, + scope, + highlight, + setHighlight, + registerBar, + unregisterBar, + registerPoint, + unregisterPoint, + registerLine, + unregisterLine, + }), + [ + enableHighlighting, + scope, + highlight, + setHighlight, + registerBar, + unregisterBar, + registerPoint, + unregisterPoint, + registerLine, + unregisterLine, + ], + ); + + // Derive scrubberPosition from internal highlight for backwards compatibility + const scrubberPosition = useDerivedValue(() => { + const items = internalHighlight.value; + if (!items || items.length === 0) return undefined; + return items[0]?.dataIndex ?? undefined; + }, [internalHighlight]); + + // Provide ScrubberContext for backwards compatibility + const scrubberContextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing: enableHighlighting, + scrubberPosition, + }), + [enableHighlighting, scrubberPosition], + ); + + // Helper to get label from accessibilityLabel (string or function) + const getAccessibilityLabelForItem = useCallback( + (item: HighlightedItem): string => { + if (typeof accessibilityLabel === 'string') { + return accessibilityLabel; + } + if (typeof accessibilityLabel === 'function') { + return accessibilityLabel(item); + } + return ''; + }, + [accessibilityLabel], + ); + + // Generate accessibility regions based on mode + const accessibilityRegions = useMemo(() => { + // Only generate regions if we have a function label (for dynamic per-item labels) + // Static string labels don't need regions + if (!enableHighlighting || !accessibilityLabel || typeof accessibilityLabel === 'string') { + return null; + } + + const regions: Array<{ + key: string; + flex: number; + label: string; + highlightedItem: HighlightedItem; + }> = []; + + if (accessibilityMode === 'chunked') { + // Divide into chunks + 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') { + // Each data point is a region + 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) => ( + { + // Always fire callback, only update internal state when not controlled + if (!isControlled) { + internalHighlight.value = [region.highlightedItem]; + } + onHighlightChange?.([region.highlightedItem]); + // Clear after a short delay + setTimeout(() => { + if (!isControlled) { + internalHighlight.value = []; + } + onHighlightChange?.([]); + }, 100); + }} + style={{ flex: region.flex }} + /> + ))} + + )} + + + ); + + // Wrap with gesture handler only if highlighting is enabled + 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/interaction/index.ts b/packages/mobile-visualization/src/chart/interaction/index.ts index a2fe71804..a06940b19 100644 --- a/packages/mobile-visualization/src/chart/interaction/index.ts +++ b/packages/mobile-visualization/src/chart/interaction/index.ts @@ -1,3 +1 @@ -// codegen:start {preset: barrel, include: ./*.tsx} -export * from './InteractionProvider'; -// codegen:end +export * from './HighlightProvider'; diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index 4a04eae3d..39824d296 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -127,69 +127,70 @@ export const useScrubberContext = (): ScrubberContextValue => { }; // ============================================================================ -// Interaction Types (New API) +// Highlighting Types (New API) // ============================================================================ /** - * Interaction mode - controls how many simultaneous interactions to track. - * - 'none': Interaction disabled - * - 'single': Single touch interaction (default) - * - 'multi': Multi-touch interaction + * Controls what aspects of the data can be highlighted. */ -export type InteractionMode = 'none' | 'single' | 'multi'; - -/** - * Controls what aspects of the data can be interacted with. - */ -export type InteractionScope = { +export type HighlightScope = { /** - * Whether interaction tracks data index (x-axis position). + * Whether highlighting tracks data index (x-axis position). * @default true */ dataIndex?: boolean; /** - * Whether interaction tracks specific series. + * Whether highlighting tracks specific series. * @default false */ series?: boolean; }; /** - * Represents a single active item during interaction. - * - `undefined` means the user is not interacting with the chart + * Represents a single highlighted item during interaction. * - `null` values mean the user is interacting but not over a specific item/series */ -export type ActiveItem = { +export type HighlightedItem = { /** - * The data index (x-axis position) being interacted with. + * The data index (x-axis position) being highlighted. * `null` when interacting but not over a data point. */ dataIndex: number | null; /** - * The series ID being interacted with. + * The series ID being highlighted. * `null` when series scope is disabled or not over a specific series. */ seriesId: string | null; }; +// ============================================================================ +// Backwards Compatibility Aliases (Deprecated) +// ============================================================================ + +/** + * @deprecated Use `enableHighlighting` instead. + */ +export type InteractionMode = 'none' | 'single' | 'multi'; + +/** + * @deprecated Use `HighlightScope` instead. + */ +export type InteractionScope = HighlightScope; + /** - * Active items for multi-touch interaction. + * @deprecated Use `HighlightedItem` instead. */ -export type ActiveItems = Array; +export type ActiveItem = HighlightedItem; /** - * Unified interaction state. - * - For 'single' mode: `ActiveItem | undefined` - * - For 'multi' mode: `ActiveItems` (empty array when not interacting) + * @deprecated Use `HighlightedItem[]` instead. */ +export type ActiveItems = Array; + /** - * The state of the interaction. - * - `undefined`: No active interaction (uncontrolled) - * - `null`: Controlled mode with no active item (gestures ignored) - * - `ActiveItem`: Single active item - * - `ActiveItems`: Multiple active items (multi-touch) + * @deprecated Use `HighlightedItem[]` instead. */ -export type InteractionState = ActiveItem | ActiveItems | undefined | null; +export type InteractionState = HighlightedItem | HighlightedItem[] | undefined | null; // ============================================================================ // Interaction Registry Types (for coordinate-based hit testing on mobile) @@ -238,28 +239,27 @@ export type InteractionRegistry = { }; /** - * Context value for chart interaction state (mobile). + * Context value for chart highlighting state (mobile). * Uses SharedValue for UI thread performance. */ -export type InteractionContextValue = { +export type HighlightContextValue = { /** - * The current interaction mode. + * Whether highlighting is enabled. */ - mode: InteractionMode; + enabled: boolean; /** - * The interaction scope configuration. + * The highlight scope configuration. */ - scope: InteractionScope; + scope: HighlightScope; /** - * The current active item(s) during interaction. - * For 'single' mode: SharedValue - * For 'multi' mode: SharedValue + * The current highlighted item(s) during interaction. + * SharedValue */ - activeItem: SharedValue; + highlight: SharedValue; /** - * Function to programmatically set the active item. + * Function to programmatically set the highlighted items. */ - setActiveItem: (state: InteractionState) => void; + setHighlight: (items: HighlightedItem[]) => void; /** * Register a bar element for hit testing. */ @@ -286,11 +286,55 @@ export type InteractionContextValue = { unregisterLine: (seriesId: string) => 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; +}; + +/** + * Hook to optionally access the highlight context. + * Returns undefined if not within a HighlightProvider. + */ +export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { + return useContext(HighlightContext); +}; + +// ============================================================================ +// Backwards Compatibility (Deprecated) +// ============================================================================ + +/** + * @deprecated Use `HighlightContextValue` instead. + */ +export type InteractionContextValue = { + mode: InteractionMode; + scope: InteractionScope; + activeItem: SharedValue; + setActiveItem: (state: InteractionState) => void; + registerBar: (bounds: ElementBounds) => void; + unregisterBar: (seriesId: string, dataIndex: number) => void; + registerPoint: (bounds: PointBounds) => void; + unregisterPoint: (seriesId: string, dataIndex: number) => void; + registerLine: (path: LinePath) => void; + unregisterLine: (seriesId: string) => void; +}; + +/** + * @deprecated Use `HighlightContext` instead. + */ export const InteractionContext = createContext(undefined); /** - * Hook to access the interaction context. - * @throws Error if used outside of an InteractionProvider + * @deprecated Use `useHighlightContext` instead. */ export const useInteractionContext = (): InteractionContextValue => { const context = useContext(InteractionContext); @@ -301,8 +345,7 @@ export const useInteractionContext = (): InteractionContextValue => { }; /** - * Hook to optionally access the interaction context. - * Returns undefined if not within an InteractionProvider. + * @deprecated Use `useOptionalHighlightContext` instead. */ export const useOptionalInteractionContext = (): InteractionContextValue | undefined => { return useContext(InteractionContext); diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 595065923..874d4df9b 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,12 +5,10 @@ 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 { InteractionProvider } from './interaction/InteractionProvider'; +import { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; import { CartesianChartProvider } from './ChartProvider'; import { Legend, type LegendProps } from './legend'; import { - type ActiveItem, - type ActiveItems, type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, @@ -24,9 +22,7 @@ import { getAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, - type InteractionMode, - type InteractionScope, - type InteractionState, + type HighlightedItem, type LegendPosition, type Series, useTotalAxisPadding, @@ -42,96 +38,64 @@ const focusStylesCss = css` } `; -export type CartesianChartBaseProps = Omit & { - /** - * Configuration objects that define how to visualize the data. - * Each series contains its own data array. - */ - series?: Array; - /** - * Whether to animate the chart. - * @default true - */ - animate?: boolean; - /** - * Configuration for x-axis. - */ - xAxis?: Partial>; - /** - * Configuration for y-axis(es). Can be a single config or array of configs. - */ - yAxis?: Partial> | Partial>[]; - /** - * Inset around the entire chart (outside the axes). - */ - inset?: number | Partial; - /** - * Whether to show the legend or a custom legend element. - * - `true` renders the default Legend component - * - A React element renders that element as the legend - * - `false` or omitted hides the legend - */ - legend?: boolean | React.ReactNode; - /** - * Position of the legend relative to the chart. - * @default 'bottom' - */ - legendPosition?: LegendPosition; - /** - * Accessibility label for the legend group. - * @default 'Legend' - */ - legendAccessibilityLabel?: string; - /** - * The interaction mode. - * - 'none': Interaction disabled - * - 'single': Single pointer/touch interaction (default) - * - 'multi': Multi-touch/multi-pointer interaction - * @default 'single' - */ - interaction?: InteractionMode; - /** - * Controls what aspects of the data can be interacted with. - * @default { dataIndex: true, series: false } - */ - interactionScope?: InteractionScope; - /** - * Controlled active item state (for single mode). - * - `undefined` or not passed: uncontrolled mode, listens to user input - * - `null`: controlled mode with no active item, ignores user input - * - `ActiveItem`: controlled mode with specific active item - */ - activeItem?: ActiveItem | null; - /** - * Controlled active items state (for multi mode). - * - `undefined` or not passed: uncontrolled mode, listens to user input - * - Empty array `[]`: controlled mode with no active items, ignores user input - * - `ActiveItems`: controlled mode with specific active items - */ - activeItems?: ActiveItems; - /** - * Callback fired when the active item changes during interaction. - * For single mode: receives `ActiveItem | undefined` - * For multi mode: receives `ActiveItems` - */ - onInteractionChange?: (state: InteractionState) => void; - /** - * Accessibility label for the chart. - * - When a string: Used as a static label for the chart element - * - When a function: Called with the active item to generate dynamic labels during interaction - */ - accessibilityLabel?: string | ((activeItem: ActiveItem) => string); - - // Legacy props for backwards compatibility - /** - * @deprecated Use `interaction="single"` instead. Will be removed in next major version. - */ - enableScrubbing?: boolean; - /** - * @deprecated Use `onInteractionChange` instead. Will be removed in next major version. - */ - onScrubberPositionChange?: (index: number | undefined) => void; -}; +export type CartesianChartBaseProps = Omit & + HighlightProps & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data array. + */ + series?: Array; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for x-axis. + */ + xAxis?: Partial>; + /** + * Configuration for y-axis(es). Can be a single config or array of configs. + */ + yAxis?: Partial> | Partial>[]; + /** + * Inset around the entire chart (outside the axes). + */ + inset?: number | Partial; + /** + * Whether to show the legend or a custom legend element. + * - `true` renders the default Legend component + * - A React element renders that element as the legend + * - `false` or omitted hides the legend + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; + /** + * Accessibility label for the legend group. + * @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); + + // Legacy props for backwards compatibility + /** + * @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' | 'accessibilityLabel'> & CartesianChartBaseProps & { @@ -181,12 +145,11 @@ export const CartesianChart = memo( xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, - // New interaction props - interaction, - interactionScope, - activeItem, - activeItems, - onInteractionChange, + // Highlight props + enableHighlighting, + highlightScope, + highlight, + onHighlightChange, // Legacy props enableScrubbing, onScrubberPositionChange, @@ -470,14 +433,27 @@ export const CartesianChart = memo( ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); - // Resolve interaction mode (backwards compatibility with enableScrubbing) - const resolvedInteraction: InteractionMode = useMemo(() => { - if (interaction !== undefined) return interaction; - if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; - return 'single'; // Default to single - }, [interaction, enableScrubbing]); + // Resolve enableHighlighting (backwards compatibility with enableScrubbing) + const resolvedEnableHighlighting = useMemo(() => { + if (enableHighlighting !== undefined) return enableHighlighting; + if (enableScrubbing !== undefined) return enableScrubbing; + return true; // Default to enabled + }, [enableHighlighting, enableScrubbing]); + + // Wrap onHighlightChange to also call legacy onScrubberPositionChange + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + onHighlightChange?.(items); + + // Legacy callback support + if (onScrubberPositionChange) { + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); + } + }, + [onHighlightChange, onScrubberPositionChange], + ); - const isInteractionEnabled = resolvedInteraction !== 'none'; + const isHighlightingEnabled = resolvedEnableHighlighting; const legendElement = useMemo(() => { if (!legend) return; @@ -541,15 +517,12 @@ export const CartesianChart = memo( return ( - {legend ? ( @@ -568,10 +541,10 @@ export const CartesianChart = memo( }} aria-live="polite" as="svg" - className={cx(isInteractionEnabled && focusStylesCss, classNames?.chart)} + className={cx(isHighlightingEnabled && focusStylesCss, classNames?.chart)} height="100%" style={styles?.chart} - tabIndex={isInteractionEnabled ? 0 : undefined} + tabIndex={isHighlightingEnabled ? 0 : undefined} width="100%" > {(legendPosition === 'top' || legendPosition === 'left') && legendElement} @@ -581,7 +554,7 @@ export const CartesianChart = memo( ) : ( {chartContent} )} - + ); }, diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx index 08935210e..9c8bc634f 100644 --- a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -10,8 +10,8 @@ import { CartesianChart } from '../CartesianChart'; import { useCartesianChartContext } from '../ChartProvider'; import { Line, LineChart, ReferenceLine, SolidLine } from '../line'; import { Scrubber } from '../scrubber'; -import type { ActiveItem, ActiveItems, InteractionState } from '../utils'; -import { useInteractionContext, useScrubberContext } from '../utils'; +import type { HighlightedItem } from '../utils'; +import { useHighlightContext, useScrubberContext } from '../utils'; export default { title: 'Components/Chart/Interaction', @@ -28,12 +28,12 @@ const formatPrice = (value: number) => }).format(value); /** - * Basic interaction with the new API + * Basic highlighting with the new API */ -export function BasicInteraction() { - const [activeItem, setActiveItem] = useState(undefined); +export function BasicHighlighting() { + const [highlight, setHighlight] = useState([]); - const accessibilityLabel = useCallback((item: ActiveItem) => { + const accessibilityLabel = useCallback((item: HighlightedItem) => { if (item.dataIndex === null) return 'Interacting with chart'; return `Day ${item.dataIndex + 1}: ${formatPrice(samplePrices[item.dataIndex])}`; }, []); @@ -41,15 +41,16 @@ export function BasicInteraction() { return ( - Basic Interaction (Single Mode) + Basic Highlighting - Hover or touch the chart to see interaction state. + Hover or touch the chart to see highlight state. - Active: {activeItem ? `dataIndex: ${activeItem.dataIndex}` : 'Not interacting'} + Active:{' '} + {highlight.length > 0 ? `dataIndex: ${highlight[0]?.dataIndex}` : 'Not interacting'} @@ -58,8 +59,7 @@ export function BasicInteraction() { showYAxis accessibilityLabel={accessibilityLabel} height={250} - interaction="single" - onInteractionChange={(state) => setActiveItem(state as ActiveItem | undefined)} + onHighlightChange={setHighlight} series={[{ id: 'price', data: samplePrices }]} > @@ -69,12 +69,12 @@ export function BasicInteraction() { } /** - * Controlled state - programmatically set the active item + * Controlled state - programmatically set the highlighted item */ export function ControlledState() { - // null = controlled mode with no active item (ignores user input) - // ActiveItem = controlled mode with specific active item - const [activeItem, setActiveItem] = useState(null); + // null = controlled mode with no highlights + // HighlightedItem[] = controlled mode with specific highlights + const [highlight, setHighlight] = useState(undefined); return ( @@ -87,35 +87,34 @@ export function ControlledState() { - - - - - Index: {activeItem?.dataIndex ?? 'none'} - {activeItem?.dataIndex !== undefined && - activeItem.dataIndex !== null && - ` (${formatPrice(samplePrices[activeItem.dataIndex])})`} + Index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== undefined && + highlight[0].dataIndex !== null && + ` (${formatPrice(samplePrices[highlight[0].dataIndex])})`} @@ -125,24 +124,24 @@ export function ControlledState() { } /** - * Interaction disabled + * Highlighting disabled */ -export function InteractionDisabled() { +export function HighlightingDisabled() { return ( - Interaction Disabled + Highlighting Disabled - Set interaction="none" to disable all interaction. + Set enableHighlighting=false to disable all highlighting. @@ -196,7 +195,6 @@ export function AccessibilityLabels() { showArea accessibilityLabel="Bitcoin price chart showing 30 days" height={200} - interaction="single" series={[{ id: 'price', data: samplePrices }]} > @@ -209,13 +207,12 @@ export function AccessibilityLabels() { + accessibilityLabel={(item: HighlightedItem) => item.dataIndex !== null ? `Day ${item.dataIndex + 1}: ${formatPrice(samplePrices[item.dataIndex])}` : 'Interacting with chart' } height={200} - interaction="single" series={[{ id: 'price', data: samplePrices }]} > @@ -226,10 +223,10 @@ export function AccessibilityLabels() { } /** - * Multi-series chart with interaction + * Multi-series chart with highlighting */ -export function MultiSeriesInteraction() { - const [activeItem, setActiveItem] = useState(undefined); +export function MultiSeriesHighlighting() { + const [highlight, setHighlight] = useState([]); const series1Data = useMemo(() => samplePrices, []); const series2Data = useMemo(() => samplePrices.map((p) => p * 0.8 + Math.random() * 1000), []); @@ -237,17 +234,17 @@ export function MultiSeriesInteraction() { return ( - Multi-Series Interaction + Multi-Series Highlighting - Index: {activeItem?.dataIndex ?? 'none'} - {activeItem?.dataIndex !== undefined && activeItem.dataIndex !== null && ( + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.dataIndex !== undefined && highlight[0].dataIndex !== null && ( <> {' '} - | BTC: {formatPrice(series1Data[activeItem.dataIndex])} | ETH:{' '} - {formatPrice(series2Data[activeItem.dataIndex])} + | BTC: {formatPrice(series1Data[highlight[0].dataIndex])} | ETH:{' '} + {formatPrice(series2Data[highlight[0].dataIndex])} )} @@ -255,8 +252,7 @@ export function MultiSeriesInteraction() { setActiveItem(state as ActiveItem | undefined)} + onHighlightChange={setHighlight} series={[ { id: 'btc', data: series1Data, color: 'var(--color-fgPrimary)', label: 'BTC' }, { id: 'eth', data: series2Data, color: 'var(--color-fgPositive)', label: 'ETH' }, @@ -272,23 +268,23 @@ export function MultiSeriesInteraction() { } /** - * Interaction callback details + * Highlight callback details */ -export function InteractionCallbackDetails() { +export function HighlightCallbackDetails() { const [events, setEvents] = useState([]); - const handleInteractionChange = useCallback((state: InteractionState) => { - const item = state as ActiveItem | undefined; + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; const event = item ? `{ dataIndex: ${item.dataIndex}, seriesId: ${item.seriesId ?? 'null'} }` - : 'undefined'; + : '[]'; setEvents((prev) => [...prev.slice(-9), event]); }, []); return ( - Interaction Callback Details + Highlight Callback Details @@ -311,8 +307,7 @@ export function InteractionCallbackDetails() { @@ -322,15 +317,14 @@ export function InteractionCallbackDetails() { } /** - * Multi-touch interaction with reference lines + * Multi-touch highlighting with reference lines */ -export function MultiTouchInteraction() { - const [activeItems, setActiveItems] = useState([]); +export function MultiTouchHighlighting() { + const [highlight, setHighlight] = useState([]); - // Custom component that renders a ReferenceLine for each active touch point + // Custom component that renders a ReferenceLine for each highlighted touch point const MultiTouchReferenceLines = memo(() => { - const { activeItem } = useInteractionContext(); - const items = (activeItem as ActiveItems) ?? []; + const { highlight: items } = useHighlightContext(); // Different colors for each touch point const colors = [ @@ -361,7 +355,7 @@ export function MultiTouchInteraction() { return ( - Multi-Touch Interaction + Multi-Touch Highlighting Use multiple fingers on a touch device to see multiple reference lines. Each touch point @@ -370,9 +364,9 @@ export function MultiTouchInteraction() { - Active touches: {activeItems.length} - {activeItems.length > 0 && - ` (${activeItems.map((item) => `Day ${(item.dataIndex ?? 0) + 1}`).join(', ')})`} + Active touches: {highlight.length} + {highlight.length > 0 && + ` (${highlight.map((item) => `Day ${(item.dataIndex ?? 0) + 1}`).join(', ')})`} @@ -381,8 +375,7 @@ export function MultiTouchInteraction() { showYAxis height={300} inset={{ top: 40 }} - interaction="multi" - onInteractionChange={(state) => setActiveItems((state as ActiveItems) ?? [])} + onHighlightChange={setHighlight} series={[{ id: 'price', data: samplePrices }]} > @@ -423,14 +416,10 @@ const BandwidthHighlight = memo(() => { }); /** - * Synchronized interaction across multiple charts + * Synchronized highlighting across multiple charts */ export function SynchronizedCharts() { - const [activeItem, setActiveItem] = useState(null); - - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem((state as ActiveItem) ?? null); - }, []); + const [highlight, setHighlight] = useState(undefined); return ( @@ -447,31 +436,30 @@ export function SynchronizedCharts() { ))} - - Highlighted index: {activeItem?.dataIndex ?? 'none'} - {activeItem?.dataIndex !== null && - activeItem?.dataIndex !== undefined && - ` (A: ${seriesA[activeItem.dataIndex]}, B: ${seriesB[activeItem.dataIndex]})`} + Highlighted index: {highlight?.[0]?.dataIndex ?? 'none'} + {highlight?.[0]?.dataIndex !== null && + highlight?.[0]?.dataIndex !== undefined && + ` (A: ${seriesA[highlight[0].dataIndex]}, B: ${seriesB[highlight[0].dataIndex]})`} (undefined); - - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem(state as ActiveItem | undefined); - }, []); +export function SeriesHighlighting() { + const [highlight, setHighlight] = useState([]); const seriesColors: Record = { A: 'var(--color-fgPrimary)', @@ -524,7 +507,7 @@ export function SeriesInteraction() { return ( - Series Interaction + Series Highlighting Hover over individual bars to see both dataIndex and seriesId tracked. Uses InteractiveBar @@ -533,15 +516,19 @@ export function SeriesInteraction() { - {activeItem ? ( + {highlight.length > 0 ? ( <> - Index: {activeItem.dataIndex ?? 'none'} - {activeItem.seriesId && ( + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( <> {' '} | Series:{' '} - - {activeItem.seriesId} + + {highlight[0].seriesId} )} @@ -554,9 +541,8 @@ export function SeriesInteraction() { (undefined); + const [highlight, setHighlight] = useState([]); const [eventLog, setEventLog] = useState([]); - const handleInteractionChange = useCallback((state: InteractionState) => { - const item = state as ActiveItem | undefined; - setActiveItem(item); + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + setHighlight(items); // Log the event if (item) { @@ -603,15 +589,19 @@ export function OverlappingBarsZOrder() { - {activeItem ? ( + {highlight.length > 0 ? ( <> - Index: {activeItem.dataIndex ?? 'none'} - {activeItem.seriesId && ( + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( <> {' '} | Series:{' '} - - {activeItem.seriesId} + + {highlight[0].seriesId} )} @@ -624,10 +614,9 @@ export function OverlappingBarsZOrder() { & { const FadeableLine = memo( ({ seriesId, strokeWidth = 2, activeStrokeWidth, ...props }) => { - const interactionContext = useInteractionContext(); - const activeSeriesId = - interactionContext.activeItem && !Array.isArray(interactionContext.activeItem) - ? interactionContext.activeItem.seriesId - : null; + const highlightContext = useHighlightContext(); + const activeSeriesId = highlightContext.highlight[0]?.seriesId ?? null; // Determine if this line is active, faded, or neutral const isActive = activeSeriesId === seriesId; @@ -759,17 +745,13 @@ const FadeableLine = memo( ); /** - * Line series interaction with interactionOffset for larger hit area + * Line series highlighting with interactionOffset for larger hit area */ -export function LineSeriesInteraction() { - const [activeItem, setActiveItem] = useState(undefined); +export function LineSeriesHighlighting() { + const [highlight, setHighlight] = useState([]); const [interactionOffset, setInteractionOffset] = useState(8); const [enableFade, setEnableFade] = useState(true); - const handleInteractionChange = useCallback((state: InteractionState) => { - setActiveItem(state as ActiveItem | undefined); - }, []); - const seriesColors: Record = { btc: 'var(--color-fgPrimary)', eth: 'var(--color-fgPositive)', @@ -784,7 +766,7 @@ export function LineSeriesInteraction() { return ( - Line Series Interaction + Line Series Highlighting Hover over the lines to highlight a specific series. Other lines fade out when one is @@ -813,15 +795,19 @@ export function LineSeriesInteraction() { - {activeItem ? ( + {highlight.length > 0 ? ( <> - Index: {activeItem.dataIndex ?? 'none'} - {activeItem.seriesId && ( + Index: {highlight[0]?.dataIndex ?? 'none'} + {highlight[0]?.seriesId && ( <> {' '} | Series:{' '} - - {activeItem.seriesId} + + {highlight[0].seriesId} )} @@ -838,9 +824,8 @@ export function LineSeriesInteraction() { ( ({ @@ -39,7 +39,7 @@ export const DefaultBar = memo( ...props }) => { const { animate } = useCartesianChartContext(); - const interactionContext = useOptionalInteractionContext(); + const highlightContext = useOptionalHighlightContext(); const initialPath = useMemo(() => { if (!animate) return undefined; @@ -49,36 +49,40 @@ export const DefaultBar = memo( return getBarPath(x, initialY, width, minHeight, borderRadius, !!roundTop, !!roundBottom); }, [animate, x, originY, width, borderRadius, roundTop, roundBottom]); - // Get the data index as a number for interaction + // Get the data index as a number for highlighting const dataIndex = typeof dataX === 'number' ? dataX : null; const handleMouseEnter = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; - interactionContext.setActiveItem({ - dataIndex, - seriesId: seriesId ?? null, - }); - }, [interactionContext, dataIndex, seriesId]); + highlightContext.setHighlight([ + { + dataIndex, + seriesId: seriesId ?? null, + }, + ]); + }, [highlightContext, dataIndex, seriesId]); const handleMouseLeave = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; // Reset to just dataIndex (keep dataIndex tracking, clear series) - if (interactionContext.scope.dataIndex) { - interactionContext.setActiveItem({ - dataIndex, - seriesId: null, - }); + if (highlightContext.scope.dataIndex) { + highlightContext.setHighlight([ + { + dataIndex, + seriesId: null, + }, + ]); } else { - interactionContext.setActiveItem(undefined); + highlightContext.setHighlight([]); } - }, [interactionContext, dataIndex, seriesId]); + }, [highlightContext, dataIndex, seriesId]); // Only add event handlers when series scope is enabled - const eventHandlers = interactionContext?.scope.series + const eventHandlers = highlightContext?.scope.series ? { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, diff --git a/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx new file mode 100644 index 000000000..dce4c2b33 --- /dev/null +++ b/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx @@ -0,0 +1,495 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useCartesianChartContext } from '../ChartProvider'; +import { + HighlightContext, + type HighlightContextValue, + type HighlightedItem, + type HighlightScope, + isCategoricalScale, + ScrubberContext, + type ScrubberContextValue, +} from '../utils'; + +const defaultHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + +/** + * Props for configuring chart highlight behavior. + * Used by CartesianChart and other chart components. + */ +export type HighlightProps = { + /** + * Whether highlighting is enabled. + * @default true + */ + enableHighlighting?: boolean; + /** + * Controls what aspects of the data can be highlighted. + * @default { dataIndex: true, series: false } + */ + highlightScope?: HighlightScope; + /** + * Controlled highlight state. + * - `undefined`: Uncontrolled mode (internal state is managed) + * - `[]`: Controlled mode with no highlights (gestures still fire onHighlightChange) + * - `HighlightedItem[]`: Controlled mode with specific highlighted items + */ + highlight?: HighlightedItem[]; + /** + * Callback fired when the highlight changes during interaction. + * Always fires regardless of controlled/uncontrolled mode. + */ + 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); +}; + +/** + * HighlightProvider manages chart highlight state and input handling. + * It supports multi-touch interactions with configurable scope. + */ +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 ?? true; + + const scope: HighlightScope = useMemo( + () => ({ ...defaultHighlightScope, ...scopeProp }), + [scopeProp], + ); + + // Determine if we're in controlled mode + // [] means "controlled with no highlights" - distinct from undefined (uncontrolled) + const isControlled = controlledHighlight !== undefined; + + // Internal state for uncontrolled mode + const [internalHighlight, setInternalHighlight] = useState([]); + + // Get the current highlight state (controlled or uncontrolled) + const highlight: HighlightedItem[] = useMemo(() => { + if (isControlled) { + return controlledHighlight; + } + return internalHighlight; + }, [isControlled, controlledHighlight, internalHighlight]); + + // Update highlight state + const setHighlight = useCallback( + (newHighlight: HighlightedItem[]) => { + if (!isControlled) { + setInternalHighlight(newHighlight); + } + onHighlightChange?.(newHighlight); + }, + [isControlled, onHighlightChange], + ); + + // 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 { + // For numeric scales with axis data, find the nearest data point + 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], + ); + + // Find series at a given point (for series scope) + const getSeriesIdFromPoint = useCallback( + (_mouseX: number, _mouseY: number): string | null => { + // TODO: Implement series detection based on proximity to data points + // For now, return null (series scope not fully implemented) + if (!scope.series) return null; + return null; + }, + [scope.series], + ); + + // Convert pointer position to HighlightedItem + const getHighlightedItemFromPointer = useCallback( + (clientX: number, clientY: number, target: SVGSVGElement): HighlightedItem => { + const rect = target.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + const dataIndex = scope.dataIndex ? getDataIndexFromX(x) : null; + const seriesId = scope.series ? getSeriesIdFromPoint(x, y) : null; + + return { dataIndex, seriesId }; + }, + [scope.dataIndex, scope.series, getDataIndexFromX, getSeriesIdFromPoint], + ); + + // Track active pointers for multi-touch + const activePointersRef = React.useRef>( + new Map(), + ); + + // Handle pointer move (mouse - single pointer) + const handlePointerMove = useCallback( + (clientX: number, clientY: number, target: SVGSVGElement) => { + if (!enabled || !series || series.length === 0) return; + + const newItem = getHighlightedItemFromPointer(clientX, clientY, target); + + // When series scope is enabled, preserve the existing seriesId + const currentSeriesId = scope.series ? highlight[0]?.seriesId : null; + const effectiveItem = { + ...newItem, + seriesId: currentSeriesId ?? newItem.seriesId, + }; + + if ( + highlight.length !== 1 || + highlight[0]?.dataIndex !== effectiveItem.dataIndex || + highlight[0]?.seriesId !== effectiveItem.seriesId + ) { + setHighlight([effectiveItem]); + } + }, + [enabled, series, scope.series, getHighlightedItemFromPointer, highlight, setHighlight], + ); + + // Handle multi-pointer update (touch) + const updateMultiPointerState = useCallback( + (target: SVGSVGElement) => { + const items: HighlightedItem[] = Array.from(activePointersRef.current.values()).map( + (pointer) => getHighlightedItemFromPointer(pointer.clientX, pointer.clientY, target), + ); + + setHighlight(items); + }, + [getHighlightedItemFromPointer, setHighlight], + ); + + // Mouse event handlers + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const target = event.currentTarget as SVGSVGElement; + handlePointerMove(event.clientX, event.clientY, target); + }, + [handlePointerMove], + ); + + const handleMouseLeave = useCallback(() => { + if (!enabled) return; + setHighlight([]); + }, [enabled, setHighlight]); + + // Touch event handlers + const handleTouchStart = useCallback( + (event: TouchEvent) => { + if (!enabled || !event.touches.length) return; + + const target = event.currentTarget as SVGSVGElement; + + // Track all touches + for (let i = 0; i < event.touches.length; i++) { + const touch = event.touches[i]; + activePointersRef.current.set(touch.identifier, { + clientX: touch.clientX, + clientY: touch.clientY, + }); + } + updateMultiPointerState(target); + }, + [enabled, updateMultiPointerState], + ); + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + if (!enabled || !event.touches.length) return; + event.preventDefault(); // Prevent scrolling while interacting + + const target = event.currentTarget as SVGSVGElement; + + // Update all touches + for (let i = 0; i < event.touches.length; i++) { + const touch = event.touches[i]; + activePointersRef.current.set(touch.identifier, { + clientX: touch.clientX, + clientY: touch.clientY, + }); + } + updateMultiPointerState(target); + }, + [enabled, updateMultiPointerState], + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + if (!enabled) return; + + // Remove ended touches + for (let i = 0; i < event.changedTouches.length; i++) { + const touch = event.changedTouches[i]; + activePointersRef.current.delete(touch.identifier); + } + + if (activePointersRef.current.size === 0) { + setHighlight([]); + } else { + const target = event.currentTarget as SVGSVGElement; + updateMultiPointerState(target); + } + }, + [enabled, setHighlight, updateMultiPointerState], + ); + + // Keyboard navigation handler + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!enabled) return; + + const xScale = getXScale(); + const xAxis = getXAxis(); + + if (!xScale || !xAxis) return; + + const isBand = isCategoricalScale(xScale); + + // Determine navigation bounds + 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] ?? { dataIndex: null, seriesId: null }; + const currentIndex = currentItem.dataIndex ?? minIndex; + const dataRange = maxIndex - minIndex; + + // Multi-step jump when shift is held (10% of data range, minimum 1, maximum 10) + 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 to SVG element + useEffect(() => { + if (!svgRef?.current || !enabled) return; + + const svg = svgRef.current; + + svg.addEventListener('mousemove', handleMouseMove); + svg.addEventListener('mouseleave', handleMouseLeave); + svg.addEventListener('touchstart', handleTouchStart, { passive: false }); + svg.addEventListener('touchmove', handleTouchMove, { passive: false }); + svg.addEventListener('touchend', handleTouchEnd); + svg.addEventListener('touchcancel', handleTouchEnd); + svg.addEventListener('keydown', handleKeyDown); + svg.addEventListener('blur', handleBlur); + + return () => { + svg.removeEventListener('mousemove', handleMouseMove); + svg.removeEventListener('mouseleave', handleMouseLeave); + svg.removeEventListener('touchstart', handleTouchStart); + svg.removeEventListener('touchmove', handleTouchMove); + svg.removeEventListener('touchend', handleTouchEnd); + svg.removeEventListener('touchcancel', handleTouchEnd); + svg.removeEventListener('keydown', handleKeyDown); + svg.removeEventListener('blur', handleBlur); + }; + }, [ + svgRef, + enabled, + handleMouseMove, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + handleKeyDown, + handleBlur, + ]); + + // Update accessibility label when highlight changes + useEffect(() => { + if (!svgRef?.current || !accessibilityLabel) return; + + const svg = svgRef.current; + + // If it's a static string, always use it + if (typeof accessibilityLabel === 'string') { + svg.setAttribute('aria-label', accessibilityLabel); + return; + } + + // If it's a function, use it for dynamic labels during interaction + 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]); + + const contextValue: HighlightContextValue = useMemo( + () => ({ + enabled, + scope, + highlight, + setHighlight, + }), + [enabled, scope, highlight, setHighlight], + ); + + // Provide ScrubberContext for backwards compatibility with Scrubber component + // Derive scrubberPosition from first highlighted item's dataIndex + 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/interaction/InteractionProvider.tsx b/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx deleted file mode 100644 index a81cce5a2..000000000 --- a/packages/web-visualization/src/chart/interaction/InteractionProvider.tsx +++ /dev/null @@ -1,581 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -import { useCartesianChartContext } from '../ChartProvider'; -import { - type ActiveItem, - type ActiveItems, - InteractionContext, - type InteractionContextValue, - type InteractionMode, - type InteractionScope, - type InteractionState, - isCategoricalScale, - ScrubberContext, - type ScrubberContextValue, -} from '../utils'; - -const defaultInteractionScope: InteractionScope = { - dataIndex: true, - series: false, -}; - -export type InteractionProviderProps = { - children: React.ReactNode; - /** - * A reference to the root SVG element, where interaction event handlers will be attached. - */ - svgRef: React.RefObject | null; - /** - * The interaction mode. - * - 'none': Interaction disabled - * - 'single': Single pointer/touch interaction (default) - * - 'multi': Multi-touch/multi-pointer interaction - * @default 'single' - */ - interaction?: InteractionMode; - /** - * Controls what aspects of the data can be interacted with. - * @default { dataIndex: true, series: false } - */ - interactionScope?: InteractionScope; - /** - * Controlled active item state (for single mode). - * - `undefined` or not passed: uncontrolled mode, listens to user input - * - `null`: controlled mode with no active item, ignores user input - * - `ActiveItem`: controlled mode with specific active item - */ - activeItem?: ActiveItem | null; - /** - * Controlled active items state (for multi mode). - * - `undefined` or not passed: uncontrolled mode, listens to user input - * - Empty array `[]`: controlled mode with no active items, ignores user input - * - `ActiveItems`: controlled mode with specific active items - */ - activeItems?: ActiveItems; - /** - * Callback fired when the active item changes. - * For single mode: receives `ActiveItem | undefined` - * For multi mode: receives `ActiveItems` - */ - onInteractionChange?: (state: InteractionState) => void; - /** - * Accessibility label for the chart. - * - When a string: Used as a static label for the chart element - * - When a function: Called with the active item to generate dynamic labels during interaction - */ - accessibilityLabel?: string | ((activeItem: ActiveItem) => string); - - // Legacy props for backwards compatibility - /** - * @deprecated Use `interaction="single"` instead - */ - enableScrubbing?: boolean; - /** - * @deprecated Use `onInteractionChange` instead - */ - onScrubberPositionChange?: (index: number | undefined) => void; -}; - -/** - * InteractionProvider manages chart interaction state and input handling. - * It supports single and multi-pointer/touch interactions with configurable scope. - */ -export const InteractionProvider: React.FC = ({ - children, - svgRef, - interaction: interactionProp, - interactionScope: scopeProp, - activeItem: controlledActiveItem, - activeItems: controlledActiveItems, - onInteractionChange, - accessibilityLabel, - // Legacy props - enableScrubbing, - onScrubberPositionChange, -}) => { - const chartContext = useCartesianChartContext(); - - if (!chartContext) { - throw new Error('InteractionProvider must be used within a ChartContext'); - } - - const { getXScale, getXAxis, series } = chartContext; - - // Resolve interaction mode (with backwards compatibility) - const interaction: InteractionMode = useMemo(() => { - if (interactionProp !== undefined) return interactionProp; - if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; - return 'single'; // Default to single - }, [interactionProp, enableScrubbing]); - - const scope: InteractionScope = useMemo( - () => ({ ...defaultInteractionScope, ...scopeProp }), - [scopeProp], - ); - - // Determine if we're in controlled mode - // null means "controlled with no active item" - distinct from undefined (uncontrolled) - const isControlled = controlledActiveItem !== undefined || controlledActiveItems !== undefined; - - // Internal state for uncontrolled mode - const [internalActiveItem, setInternalActiveItem] = useState( - interaction === 'multi' ? [] : undefined, - ); - - // Get the current active state (controlled or uncontrolled) - // For controlled mode: null means "no active item" (different from undefined) - const activeState: InteractionState = useMemo(() => { - if (isControlled) { - if (interaction === 'multi') { - return controlledActiveItems ?? []; - } - // For single mode: null → undefined (no active item), otherwise use the value - return controlledActiveItem ?? undefined; - } - return internalActiveItem; - }, [isControlled, interaction, controlledActiveItem, controlledActiveItems, internalActiveItem]); - - // Update active state - const setActiveState = useCallback( - (newState: InteractionState) => { - if (!isControlled) { - setInternalActiveItem(newState); - } - onInteractionChange?.(newState); - - // Legacy callback support - if (onScrubberPositionChange && interaction === 'single') { - const singleState = newState as ActiveItem | undefined; - onScrubberPositionChange(singleState?.dataIndex ?? undefined); - } - }, - [isControlled, onInteractionChange, onScrubberPositionChange, interaction], - ); - - // 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 { - // For numeric scales with axis data, find the nearest data point - 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], - ); - - // Find series at a given point (for series scope) - const getSeriesIdFromPoint = useCallback( - (_mouseX: number, _mouseY: number): string | null => { - // TODO: Implement series detection based on proximity to data points - // For now, return null (series scope not fully implemented) - if (!scope.series) return null; - return null; - }, - [scope.series], - ); - - // Convert pointer position to ActiveItem - const getActiveItemFromPointer = useCallback( - (clientX: number, clientY: number, target: SVGSVGElement): ActiveItem => { - const rect = target.getBoundingClientRect(); - const x = clientX - rect.left; - const y = clientY - rect.top; - - const dataIndex = scope.dataIndex ? getDataIndexFromX(x) : null; - const seriesId = scope.series ? getSeriesIdFromPoint(x, y) : null; - - return { dataIndex, seriesId }; - }, - [scope.dataIndex, scope.series, getDataIndexFromX, getSeriesIdFromPoint], - ); - - // Track active pointers for multi-touch - const activePointersRef = React.useRef>( - new Map(), - ); - - // Handle pointer move - const handlePointerMove = useCallback( - (clientX: number, clientY: number, target: SVGSVGElement) => { - if (interaction === 'none' || !series || series.length === 0) return; - - const newActiveItem = getActiveItemFromPointer(clientX, clientY, target); - - if (interaction === 'single') { - const currentItem = activeState as ActiveItem | undefined; - - // When series scope is enabled, preserve the existing seriesId - // (let bar components handle setting/clearing seriesId via their own handlers) - const effectiveSeriesId = scope.series - ? (currentItem?.seriesId ?? newActiveItem.seriesId) - : newActiveItem.seriesId; - - const effectiveItem = { ...newActiveItem, seriesId: effectiveSeriesId }; - - if ( - effectiveItem.dataIndex !== currentItem?.dataIndex || - effectiveItem.seriesId !== currentItem?.seriesId - ) { - setActiveState(effectiveItem); - } - } else if (interaction === 'multi') { - // For mouse in multi mode, treat as a single pointer - const currentItems = (activeState as ActiveItems) ?? []; - const currentSeriesId = scope.series ? currentItems[0]?.seriesId : null; - const effectiveItem = { - ...newActiveItem, - seriesId: currentSeriesId ?? newActiveItem.seriesId, - }; - - if ( - currentItems.length !== 1 || - currentItems[0]?.dataIndex !== effectiveItem.dataIndex || - currentItems[0]?.seriesId !== effectiveItem.seriesId - ) { - setActiveState([effectiveItem]); - } - } - }, - [interaction, series, scope.series, getActiveItemFromPointer, activeState, setActiveState], - ); - - // Handle multi-pointer update - const updateMultiPointerState = useCallback( - (target: SVGSVGElement) => { - if (interaction !== 'multi') return; - - const activeItems: ActiveItems = Array.from(activePointersRef.current.values()).map( - (pointer) => getActiveItemFromPointer(pointer.clientX, pointer.clientY, target), - ); - - setActiveState(activeItems); - }, - [interaction, getActiveItemFromPointer, setActiveState], - ); - - // Mouse event handlers - const handleMouseMove = useCallback( - (event: MouseEvent) => { - const target = event.currentTarget as SVGSVGElement; - handlePointerMove(event.clientX, event.clientY, target); - }, - [handlePointerMove], - ); - - const handleMouseLeave = useCallback(() => { - if (interaction === 'none') return; - setActiveState(interaction === 'multi' ? [] : undefined); - }, [interaction, setActiveState]); - - // Touch event handlers - const handleTouchStart = useCallback( - (event: TouchEvent) => { - if (interaction === 'none' || !event.touches.length) return; - - const target = event.currentTarget as SVGSVGElement; - - if (interaction === 'multi') { - // Track all touches - for (let i = 0; i < event.touches.length; i++) { - const touch = event.touches[i]; - activePointersRef.current.set(touch.identifier, { - clientX: touch.clientX, - clientY: touch.clientY, - }); - } - updateMultiPointerState(target); - } else { - // Single touch - const touch = event.touches[0]; - handlePointerMove(touch.clientX, touch.clientY, target); - } - }, - [interaction, handlePointerMove, updateMultiPointerState], - ); - - const handleTouchMove = useCallback( - (event: TouchEvent) => { - if (interaction === 'none' || !event.touches.length) return; - event.preventDefault(); // Prevent scrolling while interacting - - const target = event.currentTarget as SVGSVGElement; - - if (interaction === 'multi') { - // Update all touches - for (let i = 0; i < event.touches.length; i++) { - const touch = event.touches[i]; - activePointersRef.current.set(touch.identifier, { - clientX: touch.clientX, - clientY: touch.clientY, - }); - } - updateMultiPointerState(target); - } else { - // Single touch - const touch = event.touches[0]; - handlePointerMove(touch.clientX, touch.clientY, target); - } - }, - [interaction, handlePointerMove, updateMultiPointerState], - ); - - const handleTouchEnd = useCallback( - (event: TouchEvent) => { - if (interaction === 'none') return; - - if (interaction === 'multi') { - // Remove ended touches - for (let i = 0; i < event.changedTouches.length; i++) { - const touch = event.changedTouches[i]; - activePointersRef.current.delete(touch.identifier); - } - - if (activePointersRef.current.size === 0) { - setActiveState([]); - } else { - const target = event.currentTarget as SVGSVGElement; - updateMultiPointerState(target); - } - } else { - setActiveState(undefined); - } - }, - [interaction, setActiveState, updateMultiPointerState], - ); - - // Keyboard navigation handler - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (interaction === 'none') return; - - const xScale = getXScale(); - const xAxis = getXAxis(); - - if (!xScale || !xAxis) return; - - const isBand = isCategoricalScale(xScale); - - // Determine navigation bounds - 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 = (activeState as ActiveItem | undefined) ?? { - dataIndex: null, - seriesId: null, - }; - const currentIndex = currentItem.dataIndex ?? minIndex; - const dataRange = maxIndex - minIndex; - - // Multi-step jump when shift is held (10% of data range, minimum 1, maximum 10) - 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(); - setActiveState(interaction === 'multi' ? [] : undefined); - return; - default: - return; - } - - if (newIndex !== currentItem.dataIndex) { - const newActiveItem: ActiveItem = { - dataIndex: newIndex, - seriesId: currentItem.seriesId, - }; - setActiveState(newActiveItem); - } - }, - [interaction, getXScale, getXAxis, activeState, setActiveState], - ); - - const handleBlur = useCallback(() => { - if (interaction === 'none' || activeState === undefined) return; - setActiveState(interaction === 'multi' ? [] : undefined); - }, [interaction, activeState, setActiveState]); - - // Attach event listeners to SVG element - useEffect(() => { - if (!svgRef?.current || interaction === 'none') return; - - const svg = svgRef.current; - - svg.addEventListener('mousemove', handleMouseMove); - svg.addEventListener('mouseleave', handleMouseLeave); - svg.addEventListener('touchstart', handleTouchStart, { passive: false }); - svg.addEventListener('touchmove', handleTouchMove, { passive: false }); - svg.addEventListener('touchend', handleTouchEnd); - svg.addEventListener('touchcancel', handleTouchEnd); - svg.addEventListener('keydown', handleKeyDown); - svg.addEventListener('blur', handleBlur); - - return () => { - svg.removeEventListener('mousemove', handleMouseMove); - svg.removeEventListener('mouseleave', handleMouseLeave); - svg.removeEventListener('touchstart', handleTouchStart); - svg.removeEventListener('touchmove', handleTouchMove); - svg.removeEventListener('touchend', handleTouchEnd); - svg.removeEventListener('touchcancel', handleTouchEnd); - svg.removeEventListener('keydown', handleKeyDown); - svg.removeEventListener('blur', handleBlur); - }; - }, [ - svgRef, - interaction, - handleMouseMove, - handleMouseLeave, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - handleKeyDown, - handleBlur, - ]); - - // Update accessibility label when active item changes - useEffect(() => { - if (!svgRef?.current || !accessibilityLabel) return; - - const svg = svgRef.current; - - // If it's a static string, always use it - if (typeof accessibilityLabel === 'string') { - svg.setAttribute('aria-label', accessibilityLabel); - return; - } - - // If it's a function, use it for dynamic labels during interaction - if (interaction === 'none') return; - - const currentItem = interaction === 'single' ? (activeState as ActiveItem | undefined) : null; - - if (currentItem && currentItem.dataIndex !== null) { - svg.setAttribute('aria-label', accessibilityLabel(currentItem)); - } else { - svg.removeAttribute('aria-label'); - } - }, [svgRef, interaction, activeState, accessibilityLabel]); - - const contextValue: InteractionContextValue = useMemo( - () => ({ - mode: interaction, - scope, - activeItem: activeState, - setActiveItem: setActiveState, - }), - [interaction, scope, activeState, setActiveState], - ); - - // Provide ScrubberContext for backwards compatibility with Scrubber component - // Derive scrubberPosition from activeItem.dataIndex - const scrubberPosition = useMemo(() => { - if (interaction === 'none') return undefined; - if (interaction === 'single') { - const item = activeState as ActiveItem | undefined; - return item?.dataIndex ?? undefined; - } - // For multi mode, use the first item's dataIndex - const items = activeState as ActiveItems | undefined; - return items?.[0]?.dataIndex ?? undefined; - }, [interaction, activeState]); - - const scrubberContextValue: ScrubberContextValue = useMemo( - () => ({ - enableScrubbing: interaction !== 'none', - scrubberPosition, - onScrubberPositionChange: (index: number | undefined) => { - if (interaction === 'none') return; - if (index === undefined) { - setActiveState(undefined); - } else { - setActiveState({ dataIndex: index, seriesId: null }); - } - }, - }), - [interaction, scrubberPosition, setActiveState], - ); - - return ( - - {children} - - ); -}; diff --git a/packages/web-visualization/src/chart/interaction/index.ts b/packages/web-visualization/src/chart/interaction/index.ts index a2fe71804..a06940b19 100644 --- a/packages/web-visualization/src/chart/interaction/index.ts +++ b/packages/web-visualization/src/chart/interaction/index.ts @@ -1,3 +1 @@ -// codegen:start {preset: barrel, include: ./*.tsx} -export * from './InteractionProvider'; -// codegen:end +export * from './HighlightProvider'; diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 0883177c0..8680ce0f1 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -3,7 +3,7 @@ import type { SharedProps } from '@coinbase/cds-common/types'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; -import { useOptionalInteractionContext } from '../utils'; +import { useOptionalHighlightContext } from '../utils'; import type { LineComponentProps } from './Line'; @@ -27,7 +27,7 @@ export type DottedLineProps = SharedProps & /** * A customizable dotted line component. * Supports gradient for gradient effects on the dots. - * Automatically tracks series interaction when `interactionScope.series` is enabled. + * Automatically tracks series highlighting when `highlightScope.series` is enabled. */ export const DottedLine = memo( ({ @@ -49,46 +49,46 @@ export const DottedLine = memo( ...props }) => { const gradientId = useId(); - const interactionContext = useOptionalInteractionContext(); + const highlightContext = useOptionalHighlightContext(); - // Series interaction handlers + // Series highlight handlers const handleMouseEnter = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; - // Get current dataIndex from active item (preserve it) - const currentItem = interactionContext.activeItem; - const currentDataIndex = - currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + // Get current dataIndex from highlight (preserve it) + const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: seriesId ?? null, - }); - }, [interactionContext, seriesId]); + highlightContext.setHighlight([ + { + dataIndex: currentDataIndex, + seriesId: seriesId ?? null, + }, + ]); + }, [highlightContext, seriesId]); const handleMouseLeave = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; - // Get current dataIndex from active item (preserve it) - const currentItem = interactionContext.activeItem; - const currentDataIndex = - currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + // Get current dataIndex from highlight (preserve it) + const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; // Reset seriesId but keep dataIndex tracking - if (interactionContext.scope.dataIndex) { - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: null, - }); + if (highlightContext.scope.dataIndex) { + highlightContext.setHighlight([ + { + dataIndex: currentDataIndex, + seriesId: null, + }, + ]); } else { - interactionContext.setActiveItem(undefined); + highlightContext.setHighlight([]); } - }, [interactionContext, seriesId]); + }, [highlightContext, seriesId]); - // Determine if we need event handling (series interaction enabled with a seriesId) - const needsEventHandling = interactionContext?.scope.series && seriesId; + // Determine if we need event handling (series highlighting enabled with a seriesId) + const needsEventHandling = highlightContext?.scope.series && seriesId; // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) const eventPathStrokeWidth = diff --git a/packages/web-visualization/src/chart/line/SolidLine.tsx b/packages/web-visualization/src/chart/line/SolidLine.tsx index e82e137ef..c283b1e0e 100644 --- a/packages/web-visualization/src/chart/line/SolidLine.tsx +++ b/packages/web-visualization/src/chart/line/SolidLine.tsx @@ -3,7 +3,7 @@ import type { SharedProps } from '@coinbase/cds-common/types'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; -import { useOptionalInteractionContext } from '../utils'; +import { useOptionalHighlightContext } from '../utils'; import type { LineComponentProps } from './Line'; @@ -26,7 +26,7 @@ export type SolidLineProps = SharedProps & /** * A customizable solid line component. * Supports gradient for gradient effects and smooth data transitions. - * Automatically tracks series interaction when `interactionScope.series` is enabled. + * Automatically tracks series highlighting when `highlightScope.series` is enabled. */ export const SolidLine = memo( ({ @@ -46,46 +46,46 @@ export const SolidLine = memo( ...props }) => { const gradientId = useId(); - const interactionContext = useOptionalInteractionContext(); + const highlightContext = useOptionalHighlightContext(); - // Series interaction handlers + // Series highlight handlers const handleMouseEnter = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; - // Get current dataIndex from active item (preserve it) - const currentItem = interactionContext.activeItem; - const currentDataIndex = - currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + // Get current dataIndex from highlight (preserve it) + const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: seriesId ?? null, - }); - }, [interactionContext, seriesId]); + highlightContext.setHighlight([ + { + dataIndex: currentDataIndex, + seriesId: seriesId ?? null, + }, + ]); + }, [highlightContext, seriesId]); const handleMouseLeave = useCallback(() => { - if (!interactionContext || interactionContext.mode === 'none') return; - if (!interactionContext.scope.series) return; + if (!highlightContext || !highlightContext.enabled) return; + if (!highlightContext.scope.series) return; - // Get current dataIndex from active item (preserve it) - const currentItem = interactionContext.activeItem; - const currentDataIndex = - currentItem && !Array.isArray(currentItem) ? currentItem.dataIndex : null; + // Get current dataIndex from highlight (preserve it) + const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; // Reset seriesId but keep dataIndex tracking - if (interactionContext.scope.dataIndex) { - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: null, - }); + if (highlightContext.scope.dataIndex) { + highlightContext.setHighlight([ + { + dataIndex: currentDataIndex, + seriesId: null, + }, + ]); } else { - interactionContext.setActiveItem(undefined); + highlightContext.setHighlight([]); } - }, [interactionContext, seriesId]); + }, [highlightContext, seriesId]); - // Determine if we need event handling (series interaction enabled with a seriesId) - const needsEventHandling = interactionContext?.scope.series && seriesId; + // Determine if we need event handling (series highlighting enabled with a seriesId) + const needsEventHandling = highlightContext?.scope.series && seriesId; // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) const eventPathStrokeWidth = diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index f32ca564e..9f362e509 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -22,12 +22,11 @@ import { Text, TextLabel1 } from '@coinbase/cds-web/typography'; import { m } from 'framer-motion'; import { - type ActiveItem, type AxisBounds, DefaultScrubberBeacon, DefaultScrubberLabel, defaultTransition, - type InteractionState, + type HighlightedItem, PeriodSelector, PeriodSelectorActiveIndicator, Point, @@ -1645,9 +1644,8 @@ function HighlightLineSegments() { const [scrubberPosition, setScrubberPosition] = useState(undefined); - const handleInteractionChange = useCallback((state: InteractionState) => { - const item = state as ActiveItem | undefined; - setScrubberPosition(item?.dataIndex ?? undefined); + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); }, []); // Calculate which month (~30-day segment) the scrubber is in @@ -1694,8 +1692,7 @@ function HighlightLineSegments() { @@ -1772,27 +1769,26 @@ function AdaptiveDetail() { // Memoized chart component - only re-renders when data or isScrubbing changes type MemoizedChartProps = { - activeItem: ActiveItem | undefined; + highlight: HighlightedItem[] | undefined; data: number[]; isScrubbing: boolean; - onInteractionChange: (state: InteractionState) => void; + onHighlightChange: (items: HighlightedItem[]) => void; scrubberLabel: (index: number) => string; }; const MemoizedChart = memo( - ({ activeItem, data, isScrubbing, onInteractionChange, scrubberLabel }: MemoizedChartProps) => { + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { console.log('[MemoizedChart] Rendering with:', { - activeItem, + highlight, dataLength: data.length, isScrubbing, strokeWidth: isScrubbing ? 2 : 4, }); return ( (() => { + // Compute controlled highlight based on selected timestamp and current display data + // Return undefined when not interacting to allow uncontrolled user input + // Return HighlightedItem[] when interacting to control position across dataset changes + const highlight = useMemo(() => { if (selectedTimestamp === null) { - console.log('[AdaptiveDetail] activeItem: undefined (no timestamp)'); + console.log('[AdaptiveDetail] highlight: undefined (no timestamp)'); return undefined; } const dataIndex = findClosestIndex(selectedTimestamp, displayTimestamps); - console.log('[AdaptiveDetail] activeItem computed:', { + console.log('[AdaptiveDetail] highlight computed:', { selectedTimestamp: selectedTimestamp.toISOString(), dataIndex, displayTimestampsLength: displayTimestamps.length, }); - return { dataIndex, seriesId: null }; + return [{ dataIndex, seriesId: null }]; }, [selectedTimestamp, displayTimestamps, findClosestIndex]); const onPeriodChange = useCallback( @@ -1970,11 +1966,11 @@ function AdaptiveDetail() { [tabs], ); - // Store the timestamp when interaction changes, not the dataIndex + // Store the timestamp when highlight changes, not the dataIndex // Uses ref to always get latest displayTimestamps, avoiding stale closure issues - const handleInteractionChange = useCallback((state: InteractionState) => { - const item = state as ActiveItem | undefined; - console.log('[AdaptiveDetail] handleInteractionChange called:', { + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + console.log('[AdaptiveDetail] handleHighlightChange called:', { item, displayTimestampsRefLength: displayTimestampsRef.current.length, }); @@ -1987,7 +1983,7 @@ function AdaptiveDetail() { exitTimeoutRef.current = null; } const timestamp = displayTimestampsRef.current[item.dataIndex]; - console.log('[AdaptiveDetail] Setting interaction:', { + console.log('[AdaptiveDetail] Setting highlight:', { dataIndex: item.dataIndex, timestamp: timestamp?.toISOString(), }); @@ -1997,10 +1993,10 @@ function AdaptiveDetail() { } else { // User stopped interacting - delay before switching back to sampled data // This prevents the race condition where switching data while mouse is still - // over the chart causes an immediate re-interaction + // over the chart causes an immediate re-highlight console.log('[AdaptiveDetail] Starting exit timeout (50ms)'); exitTimeoutRef.current = setTimeout(() => { - console.log('[AdaptiveDetail] Exit timeout fired - clearing interaction'); + console.log('[AdaptiveDetail] Exit timeout fired - clearing highlight'); setIsInteracting(false); setSelectedTimestamp(null); exitTimeoutRef.current = null; @@ -2106,10 +2102,10 @@ function AdaptiveDetail() { { }; // ============================================================================ -// Interaction Types (New API) +// Highlight Types // ============================================================================ /** - * Interaction mode - controls how many simultaneous interactions to track. - * - 'none': Interaction disabled - * - 'single': Single pointer/touch interaction (default) - * - 'multi': Multi-touch/multi-pointer interaction + * Controls what aspects of the data can be highlighted. */ -export type InteractionMode = 'none' | 'single' | 'multi'; - -/** - * Controls what aspects of the data can be interacted with. - */ -export type InteractionScope = { +export type HighlightScope = { /** - * Whether interaction tracks data index (x-axis position). + * Whether highlighting tracks data index (x-axis position). * @default true */ dataIndex?: boolean; /** - * Whether interaction tracks specific series. + * Whether highlighting tracks specific series. * @default false */ series?: boolean; }; /** - * Represents a single active item during interaction. - * - `undefined` means the user is not interacting with the chart - * - `null` values mean the user is interacting but not over a specific item/series + * Represents a single highlighted item. + * `null` values mean the user is interacting but not over a specific item/series. */ -export type ActiveItem = { +export type HighlightedItem = { /** - * The data index (x-axis position) being interacted with. + * The data index (x-axis position) being highlighted. * `null` when interacting but not over a data point. */ dataIndex: number | null; /** - * The series ID being interacted with. + * The series ID being highlighted. * `null` when series scope is disabled or not over a specific series. */ seriesId: string | null; }; /** - * Active items for multi-touch/multi-pointer interaction. + * Context value for chart highlight state. */ -export type ActiveItems = Array; - -/** - * Unified interaction state. - * - For 'single' mode: `ActiveItem | undefined` - * - For 'multi' mode: `ActiveItems` (empty array when not interacting) - */ -export type InteractionState = ActiveItem | ActiveItems | undefined; - -/** - * Context value for chart interaction state. - */ -export type InteractionContextValue = { +export type HighlightContextValue = { /** - * The current interaction mode. + * Whether highlighting is enabled. */ - mode: InteractionMode; + enabled: boolean; /** - * The interaction scope configuration. + * The highlight scope configuration. */ - scope: InteractionScope; + scope: HighlightScope; /** - * The current active item(s) during interaction. - * For 'single' mode: `ActiveItem | undefined` - * For 'multi' mode: `ActiveItems` + * The currently highlighted items. */ - activeItem: InteractionState; + highlight: HighlightedItem[]; /** - * Callback to update the active item state. + * Callback to update the highlight state. */ - setActiveItem: (item: InteractionState) => void; + setHighlight: (items: HighlightedItem[]) => void; }; -export const InteractionContext = createContext(undefined); +export const HighlightContext = createContext(undefined); /** - * Hook to access the interaction context. - * @throws Error if used outside of an InteractionProvider + * Hook to access the highlight context. + * @throws Error if used outside of a HighlightProvider */ -export const useInteractionContext = (): InteractionContextValue => { - const context = useContext(InteractionContext); +export const useHighlightContext = (): HighlightContextValue => { + const context = useContext(HighlightContext); if (!context) { - throw new Error('useInteractionContext must be used within an InteractionProvider'); + throw new Error('useHighlightContext must be used within a HighlightProvider'); } return context; }; /** - * Hook to optionally access the interaction context. - * Returns undefined if not within an InteractionProvider. + * Hook to optionally access the highlight context. + * Returns undefined if not within a HighlightProvider. */ -export const useOptionalInteractionContext = (): InteractionContextValue | undefined => { - return useContext(InteractionContext); +export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { + return useContext(HighlightContext); }; + +// ============================================================================ +// Backwards-compatible aliases (deprecated) +// ============================================================================ + +/** + * @deprecated Use `HighlightScope` instead. + */ +export type InteractionScope = HighlightScope; + +/** + * @deprecated Use `HighlightedItem` instead. + */ +export type ActiveItem = HighlightedItem; + +/** + * @deprecated Use `HighlightedItem[]` instead. + */ +export type ActiveItems = HighlightedItem[]; + +/** + * @deprecated Use `HighlightedItem[]` instead. + */ +export type InteractionState = HighlightedItem[]; + +/** + * @deprecated No longer used. Highlighting is always enabled via boolean. + */ +export type InteractionMode = 'none' | 'single' | 'multi'; + +/** + * @deprecated Use `HighlightContextValue` instead. + */ +export type InteractionContextValue = HighlightContextValue; + +/** + * @deprecated Use `HighlightContext` instead. + */ +export const InteractionContext = HighlightContext; + +/** + * @deprecated Use `useHighlightContext` instead. + */ +export const useInteractionContext = useHighlightContext; + +/** + * @deprecated Use `useOptionalHighlightContext` instead. + */ +export const useOptionalInteractionContext = useOptionalHighlightContext; From f1468b459e4c0613ce1c6f05f827007035e6a63a Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 28 Jan 2026 13:20:44 -0500 Subject: [PATCH 05/16] Update chart --- .../graphs/Highlighting/_mobileContent.mdx | 7 +- .../graphs/Highlighting/_webContent.mdx | 2 +- .../src/chart/CartesianChart.tsx | 76 +- .../chart/interaction/InteractionProvider.tsx | 715 ------------------ .../src/chart/CartesianChart.tsx | 2 +- 5 files changed, 13 insertions(+), 789 deletions(-) delete mode 100644 packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx diff --git a/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx index 92b1d9db2..0baf42017 100644 --- a/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx +++ b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx @@ -259,11 +259,6 @@ The following legacy props are still supported for backwards compatibility but a | -------------------------- | -------------------- | | `enableScrubbing` | `enableHighlighting` | | `onScrubberPositionChange` | `onHighlightChange` | -| `interaction` | `enableHighlighting` | -| `interactionScope` | `highlightScope` | -| `activeItem` | `highlight` | -| `activeItems` | `highlight` | -| `onInteractionChange` | `onHighlightChange` | ```tsx // Legacy (still works) @@ -293,7 +288,7 @@ The following legacy props are still supported for backwards compatibility but a | Prop | Type | Default | Description | | ------------------------- | ----------------------------------------------- | --------------------- | ----------------------------------- | -| `enableHighlighting` | `boolean` | `true` | Enable/disable highlighting | +| `enableHighlighting` | `boolean` | `false` | Enable/disable highlighting | | `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | | `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | | `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | diff --git a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx index 86cd89e1b..62812a931 100644 --- a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx +++ b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx @@ -487,7 +487,7 @@ The following legacy props are still supported for backwards compatibility but a | Prop | Type | Default | Description | | -------------------- | ----------------------------------------------- | --------------------- | ------------------------------- | -| `enableHighlighting` | `boolean` | `true` | Enable/disable highlighting | +| `enableHighlighting` | `boolean` | `false` | Enable/disable highlighting | | `highlight` | `HighlightedItem[]` | `undefined` | Controlled highlight state | | `onHighlightChange` | `(items: HighlightedItem[]) => void` | - | Callback when highlight changes | | `highlightScope` | `HighlightScope` | `{ dataIndex: true }` | What aspects to track | diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index effe639cd..ae6db74a5 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -7,14 +7,11 @@ import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; import { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; -import { InteractionProvider } from './interaction/InteractionProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; import { Legend, type LegendProps } from './legend'; import { - type ActiveItem, - type ActiveItems, type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, @@ -29,9 +26,6 @@ import { getChartInset, getStackedSeriesData as calculateStackedSeriesData, type HighlightedItem, - type InteractionMode, - type InteractionScope, - type InteractionState, type LegendPosition, type Series, useTotalAxisPadding, @@ -118,26 +112,6 @@ export type CartesianChartBaseProps = Omit void; - /** - * @deprecated Use `enableHighlighting` instead. - */ - interaction?: InteractionMode; - /** - * @deprecated Use `highlightScope` instead. - */ - interactionScope?: InteractionScope; - /** - * @deprecated Use `highlight` instead. - */ - activeItem?: ActiveItem | null; - /** - * @deprecated Use `highlight` instead. - */ - activeItems?: ActiveItems; - /** - * @deprecated Use `onHighlightChange` instead. - */ - onInteractionChange?: (state: InteractionState) => void; }; export type CartesianChartProps = CartesianChartBaseProps & @@ -198,11 +172,6 @@ export const CartesianChart = memo( // Legacy props enableScrubbing, onScrubberPositionChange, - interaction, - interactionScope, - activeItem, - activeItems, - onInteractionChange, legend, legendPosition = 'bottom', legendAccessibilityLabel, @@ -523,49 +492,24 @@ export const CartesianChart = memo( return [style, styles?.root]; }, [style, styles?.root]); - // Resolve highlighting enabled (backwards compatibility with enableScrubbing and interaction) - const isHighlightingEnabled: boolean = useMemo(() => { + // Resolve enableHighlighting (backwards compatibility with enableScrubbing) + const resolvedEnableHighlighting = useMemo(() => { if (enableHighlighting !== undefined) return enableHighlighting; - if (interaction !== undefined) return interaction !== 'none'; if (enableScrubbing !== undefined) return enableScrubbing; - return true; // Default to enabled - }, [enableHighlighting, interaction, enableScrubbing]); - - // Resolve highlight scope (backwards compatibility with interactionScope) - const resolvedHighlightScope = useMemo(() => { - if (highlightScope !== undefined) return highlightScope; - if (interactionScope !== undefined) return interactionScope; - return undefined; - }, [highlightScope, interactionScope]); - - // Resolve highlight state (backwards compatibility with activeItem/activeItems) - const resolvedHighlight = useMemo((): HighlightedItem[] | undefined => { - if (highlight !== undefined) return highlight; - if (activeItems !== undefined) return activeItems; - if (activeItem !== undefined) { - return activeItem === null ? [] : [activeItem]; - } - return undefined; - }, [highlight, activeItem, activeItems]); + return false; // Default to disabled + }, [enableHighlighting, enableScrubbing]); - // Wrap onHighlightChange to also call legacy callbacks + // Wrap onHighlightChange to also call legacy onScrubberPositionChange const handleHighlightChange = useCallback( (items: HighlightedItem[]) => { onHighlightChange?.(items); // Legacy callback support - if (onInteractionChange) { - // Convert to old InteractionState format - const singleItem = items[0]; - onInteractionChange(singleItem); - } - if (onScrubberPositionChange) { - const singleItem = items[0]; - onScrubberPositionChange(singleItem?.dataIndex ?? undefined); + onScrubberPositionChange(items[0]?.dataIndex ?? undefined); } }, - [onHighlightChange, onInteractionChange, onScrubberPositionChange], + [onHighlightChange, onScrubberPositionChange], ); const legendElement = useMemo(() => { if (!legend) return; @@ -602,9 +546,9 @@ export const CartesianChart = memo( accessibilityLabel={accessibilityLabel} accessibilityMode={accessibilityMode} allowOverflowGestures={allowOverflowGestures} - enableHighlighting={isHighlightingEnabled} - highlight={resolvedHighlight} - highlightScope={resolvedHighlightScope} + enableHighlighting={resolvedEnableHighlighting} + highlight={highlight} + highlightScope={highlightScope} onHighlightChange={handleHighlightChange} > {legend ? ( diff --git a/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx b/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx deleted file mode 100644 index 65d716f7f..000000000 --- a/packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx +++ /dev/null @@ -1,715 +0,0 @@ -import React, { useCallback, useMemo, useRef } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { - runOnJS, - type SharedValue, - useAnimatedReaction, - useDerivedValue, - useSharedValue, -} from 'react-native-reanimated'; -import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; - -import { useCartesianChartContext } from '../ChartProvider'; -import { - type ActiveItem, - type ActiveItems, - type ElementBounds, - InteractionContext, - type InteractionContextValue, - type InteractionMode, - type InteractionRegistry, - type InteractionScope, - type InteractionState, - invertSerializableScale, - type LinePath, - type PointBounds, - ScrubberContext, - type ScrubberContextValue, -} from '../utils'; -import { getPointOnSerializableScale } from '../utils/point'; - -const defaultInteractionScope: InteractionScope = { - dataIndex: true, - series: false, -}; - -export type InteractionProviderProps = { - children: React.ReactNode; - /** - * Allows continuous gestures on the chart to continue outside the bounds of the chart element. - */ - allowOverflowGestures?: boolean; - /** - * The interaction mode. - * - 'none': Interaction disabled - * - 'single': Single touch interaction (default) - * - 'multi': Multi-touch interaction - * @default 'single' - */ - interaction?: InteractionMode; - /** - * Controls what aspects of the data can be interacted with. - * @default { dataIndex: true, series: false } - */ - interactionScope?: InteractionScope; - /** - * Controlled active item (for single mode). - * - undefined: Uncontrolled mode - * - null: Controlled mode with no active item (ignores user gestures) - * - ActiveItem: Controlled mode with specific active item - */ - activeItem?: ActiveItem | null; - /** - * Controlled active items (for multi mode). - * - undefined: Uncontrolled mode - * - []: Controlled mode with no active items (ignores user gestures) - * - ActiveItems: Controlled mode with specific active items - */ - activeItems?: ActiveItems; - /** - * Callback fired when the active item changes during interaction. - * For single mode: receives `ActiveItem | undefined` - * For multi mode: receives `ActiveItems` - */ - onInteractionChange?: (state: InteractionState) => void; - /** - * Accessibility label for the chart. - * - When a string: Used as a static label for the chart element - * - When a function: Called with the active item to generate dynamic labels during interaction - */ - accessibilityLabel?: string | ((activeItem: ActiveItem) => 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) - * - 'series': Each series is an accessible region - * @default 'chunked' - */ - accessibilityMode?: 'chunked' | 'item' | 'series'; - /** - * Number of accessible chunks when accessibilityMode is 'chunked'. - * @default 10 - */ - accessibilityChunkCount?: number; - - // Legacy props for backwards compatibility - /** - * @deprecated Use `interaction="single"` instead - */ - enableScrubbing?: boolean; - /** - * @deprecated Use `onInteractionChange` instead - */ - onScrubberPositionChange?: (index: number | undefined) => void; -}; - -/** - * InteractionProvider manages chart interaction state and gesture handling for mobile. - * It supports single and multi-touch interactions with configurable scope. - */ -export const InteractionProvider: React.FC = ({ - children, - allowOverflowGestures, - interaction: interactionProp, - interactionScope: scopeProp, - activeItem: controlledActiveItem, - activeItems: controlledActiveItems, - onInteractionChange, - accessibilityLabel, - accessibilityMode = 'chunked', - accessibilityChunkCount = 10, - // Legacy props - enableScrubbing, - onScrubberPositionChange, -}) => { - const chartContext = useCartesianChartContext(); - - if (!chartContext) { - throw new Error('InteractionProvider must be used within a ChartContext'); - } - - const { getXSerializableScale, getXAxis, dataLength } = chartContext; - - // Resolve interaction mode (with backwards compatibility) - const interaction: InteractionMode = useMemo(() => { - if (interactionProp !== undefined) return interactionProp; - if (enableScrubbing !== undefined) return enableScrubbing ? 'single' : 'none'; - return 'single'; // Default to single - }, [interactionProp, enableScrubbing]); - - const scope: InteractionScope = useMemo( - () => ({ ...defaultInteractionScope, ...scopeProp }), - [scopeProp], - ); - - // ============================================================================ - // Interaction Registry (for coordinate-based hit testing) - // ============================================================================ - - // Use ref to avoid re-renders when registering elements - const registryRef = useRef({ - bars: [], - points: [], - lines: [], - }); - - // Register a bar element for hit testing - const registerBar = useCallback((bounds: ElementBounds) => { - // Add to registry (elements are stored in render order) - registryRef.current.bars.push(bounds); - }, []); - - // Unregister a bar element - const unregisterBar = useCallback((seriesId: string, dataIndex: number) => { - registryRef.current.bars = registryRef.current.bars.filter( - (bar) => !(bar.seriesId === seriesId && bar.dataIndex === dataIndex), - ); - }, []); - - // Register a point element for hit testing - const registerPoint = useCallback((bounds: PointBounds) => { - registryRef.current.points.push(bounds); - }, []); - - // Unregister a point element - const unregisterPoint = useCallback((seriesId: string, dataIndex: number) => { - registryRef.current.points = registryRef.current.points.filter( - (point) => !(point.seriesId === seriesId && point.dataIndex === dataIndex), - ); - }, []); - - // Register a line path for hit testing - const registerLine = useCallback((path: LinePath) => { - // Replace existing line with same seriesId (path may update) - registryRef.current.lines = registryRef.current.lines.filter( - (line) => line.seriesId !== path.seriesId, - ); - registryRef.current.lines.push(path); - }, []); - - // Unregister a line path - const unregisterLine = useCallback((seriesId: string) => { - registryRef.current.lines = registryRef.current.lines.filter( - (line) => line.seriesId !== seriesId, - ); - }, []); - - // Find bar at touch point (iterates in reverse for correct z-order) - const findBarAtPoint = useCallback((touchX: number, touchY: number): ElementBounds | null => { - const bars = registryRef.current.bars; - // Iterate in reverse order (last rendered = on top = checked first) - 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; - }, []); - - // Find point at touch point - const findPointAtTouch = useCallback( - (touchX: number, touchY: number, touchTolerance: number = 10): PointBounds | null => { - const points = registryRef.current.points; - for (let i = points.length - 1; i >= 0; i--) { - const point = points[i]; - const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); - if (distance <= point.radius + touchTolerance) { - return point; - } - } - return null; - }, - [], - ); - - // Find series at touch point (checks bars first, then points) - // Note: Line hit testing would require Skia path parsing - not implemented yet - const findSeriesAtPoint = useCallback( - (touchX: number, touchY: number): string | null => { - // Check bars first - const hitBar = findBarAtPoint(touchX, touchY); - if (hitBar) return hitBar.seriesId; - - // Check points - const hitPoint = findPointAtTouch(touchX, touchY); - if (hitPoint) return hitPoint.seriesId; - - // TODO: Add line hit testing using Skia path.contains() - - return null; - }, - [findBarAtPoint, findPointAtTouch], - ); - - // ============================================================================ - - // Determine if we're in controlled mode - // null/[] means "controlled with no active item" - distinct from undefined (uncontrolled) - const isControlled = controlledActiveItem !== undefined || controlledActiveItems !== undefined; - - // Use SharedValue for UI thread performance - const internalActiveItem = useSharedValue( - interaction === 'multi' ? [] : undefined, - ); - - // The exposed activeItem SharedValue - returns controlled value or internal value - const activeItem: SharedValue = useMemo(() => { - if (isControlled) { - // Create a proxy that returns the controlled value but doesn't update internal state - return { - get value() { - return interaction === 'multi' ? controlledActiveItems : controlledActiveItem; - }, - set value(_newValue: InteractionState) { - // In controlled mode, don't update - the gesture handlers will call onInteractionChange directly - }, - addListener: internalActiveItem.addListener.bind(internalActiveItem), - removeListener: internalActiveItem.removeListener.bind(internalActiveItem), - modify: internalActiveItem.modify.bind(internalActiveItem), - } as SharedValue; - } - return internalActiveItem; - }, [isControlled, interaction, controlledActiveItem, controlledActiveItems, internalActiveItem]); - - 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 { - // For numeric scales with axis data, find the nearest data point - 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 handlers - const handleStartEndHaptics = useCallback(() => { - void Haptics.lightImpact(); - }, []); - - // Handle JS thread callback when active item changes - const handleInteractionChangeJS = useCallback( - (state: InteractionState) => { - onInteractionChange?.(state); - - // Legacy callback support - if (onScrubberPositionChange && interaction === 'single') { - const singleState = state as ActiveItem | undefined; - onScrubberPositionChange(singleState?.dataIndex ?? undefined); - } - }, - [onInteractionChange, onScrubberPositionChange, interaction], - ); - - // React to active item changes and call JS callback - useAnimatedReaction( - () => activeItem.value, - (currentValue, previousValue) => { - if (currentValue !== previousValue) { - runOnJS(handleInteractionChangeJS)(currentValue); - } - }, - [handleInteractionChangeJS], - ); - - // Setter function for context - always fires callback, only updates internal state when uncontrolled - const setActiveItem = useCallback( - (newState: InteractionState) => { - if (!isControlled) { - internalActiveItem.value = newState; - } - onInteractionChange?.(newState); - }, - [isControlled, internalActiveItem, onInteractionChange], - ); - - // Helper to create active item with optional series hit testing (runs on JS thread) - const createActiveItemWithSeries = useCallback( - (x: number, y: number, dataIndex: number | null): ActiveItem => { - let seriesId: string | null = null; - if (scope.series) { - seriesId = findSeriesAtPoint(x, y); - } - return { dataIndex, seriesId }; - }, - [scope.series, findSeriesAtPoint], - ); - - // Create the long press pan gesture for single mode - const singleTouchGesture = useMemo( - () => - Gesture.Pan() - .activateAfterLongPress(110) - .shouldCancelWhenOutside(!allowOverflowGestures) - .onStart(function onStart(event) { - runOnJS(handleStartEndHaptics)(); - - // Android does not trigger onUpdate when the gesture starts - if (Platform.OS === 'android') { - const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - // Series hit testing runs on JS thread - runOnJS((x: number, y: number, di: number | null) => { - const newActiveItem = createActiveItemWithSeries(x, y, di); - const currentItem = internalActiveItem.value as ActiveItem | undefined; - if ( - newActiveItem.dataIndex !== currentItem?.dataIndex || - newActiveItem.seriesId !== currentItem?.seriesId - ) { - if (!isControlled) { - internalActiveItem.value = newActiveItem; - } - onInteractionChange?.(newActiveItem); - } - })(event.x, event.y, dataIndex); - } - }) - .onUpdate(function onUpdate(event) { - const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - // Series hit testing runs on JS thread - runOnJS((x: number, y: number, di: number | null) => { - const newActiveItem = createActiveItemWithSeries(x, y, di); - const currentItem = internalActiveItem.value as ActiveItem | undefined; - if ( - newActiveItem.dataIndex !== currentItem?.dataIndex || - newActiveItem.seriesId !== currentItem?.seriesId - ) { - if (!isControlled) { - internalActiveItem.value = newActiveItem; - } - onInteractionChange?.(newActiveItem); - } - })(event.x, event.y, dataIndex); - }) - .onEnd(function onEnd() { - if (interaction !== 'none') { - runOnJS(handleStartEndHaptics)(); - if (!isControlled) { - internalActiveItem.value = undefined; - } - runOnJS(onInteractionChange ?? (() => {}))(undefined); - } - }) - .onTouchesCancelled(function onTouchesCancelled() { - if (interaction !== 'none') { - if (!isControlled) { - internalActiveItem.value = undefined; - } - runOnJS(onInteractionChange ?? (() => {}))(undefined); - } - }), - [ - allowOverflowGestures, - handleStartEndHaptics, - getDataIndexFromX, - scope.dataIndex, - createActiveItemWithSeries, - internalActiveItem, - interaction, - isControlled, - onInteractionChange, - ], - ); - - // Helper to process touches and create active items (runs on JS thread) - const processMultiTouches = useCallback( - (touches: Array<{ x: number; y: number }>): ActiveItems => { - return touches.map((touch) => { - const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; - let seriesId: string | null = null; - if (scope.series) { - seriesId = findSeriesAtPoint(touch.x, touch.y); - } - return { dataIndex, seriesId }; - }); - }, - [scope.dataIndex, scope.series, getDataIndexFromX, findSeriesAtPoint], - ); - - // Create multi-touch gesture - const multiTouchGesture = useMemo( - () => - Gesture.Manual() - .shouldCancelWhenOutside(!allowOverflowGestures) - .onTouchesDown(function onTouchesDown(event) { - runOnJS(handleStartEndHaptics)(); - - // Extract touch coordinates for JS thread processing - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalActiveItem.value = items; - } - onInteractionChange?.(items); - })(touches); - }) - .onTouchesMove(function onTouchesMove(event) { - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalActiveItem.value = items; - } - onInteractionChange?.(items); - })(touches); - }) - .onTouchesUp(function onTouchesUp(event) { - if (event.allTouches.length === 0) { - runOnJS(handleStartEndHaptics)(); - if (!isControlled) { - internalActiveItem.value = []; - } - runOnJS(onInteractionChange ?? (() => {}))([]); - } else { - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalActiveItem.value = items; - } - onInteractionChange?.(items); - })(touches); - } - }) - .onTouchesCancelled(function onTouchesCancelled() { - if (!isControlled) { - internalActiveItem.value = []; - } - runOnJS(onInteractionChange ?? (() => {}))([]); - }), - [ - allowOverflowGestures, - handleStartEndHaptics, - processMultiTouches, - internalActiveItem, - isControlled, - onInteractionChange, - ], - ); - - const gesture = interaction === 'multi' ? multiTouchGesture : singleTouchGesture; - - const contextValue: InteractionContextValue = useMemo( - () => ({ - mode: interaction, - scope, - activeItem, - setActiveItem, - registerBar, - unregisterBar, - registerPoint, - unregisterPoint, - registerLine, - unregisterLine, - }), - [ - interaction, - scope, - activeItem, - setActiveItem, - registerBar, - unregisterBar, - registerPoint, - unregisterPoint, - registerLine, - unregisterLine, - ], - ); - - // Derive scrubberPosition from internal active item for backwards compatibility - const scrubberPosition = useDerivedValue(() => { - const state = internalActiveItem.value; - if (state === null || state === undefined) return undefined; - if (Array.isArray(state)) { - // For multi mode, use first item's dataIndex - return state[0]?.dataIndex ?? undefined; - } - return state.dataIndex ?? undefined; - }, [internalActiveItem]); - - // Provide ScrubberContext for backwards compatibility - const scrubberContextValue: ScrubberContextValue = useMemo( - () => ({ - enableScrubbing: interaction !== 'none', - scrubberPosition, - }), - [interaction, scrubberPosition], - ); - - // Helper to get label from accessibilityLabel (string or function) - const getAccessibilityLabelForItem = useCallback( - (item: ActiveItem): string => { - if (typeof accessibilityLabel === 'string') { - return accessibilityLabel; - } - if (typeof accessibilityLabel === 'function') { - return accessibilityLabel(item); - } - return ''; - }, - [accessibilityLabel], - ); - - // Generate accessibility regions based on mode - const accessibilityRegions = useMemo(() => { - // Only generate regions if we have a function label (for dynamic per-item labels) - // Static string labels don't need regions - if (interaction === 'none' || !accessibilityLabel || typeof accessibilityLabel === 'string') { - return null; - } - - const regions: Array<{ - key: string; - flex: number; - label: string; - activeItem: ActiveItem; - }> = []; - - if (accessibilityMode === 'chunked') { - // Divide into chunks - 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: ActiveItem = { dataIndex: startIndex, seriesId: null }; - - regions.push({ - key: `chunk-${i}`, - flex: chunkLength, - label: getAccessibilityLabelForItem(item), - activeItem: item, - }); - } - } else if (accessibilityMode === 'item') { - // Each data point is a region - for (let i = 0; i < dataLength; i++) { - const item: ActiveItem = { dataIndex: i, seriesId: null }; - regions.push({ - key: `item-${i}`, - flex: 1, - label: getAccessibilityLabelForItem(item), - activeItem: item, - }); - } - } - // Note: 'series' mode would require series info from context - - return regions; - }, [ - interaction, - accessibilityLabel, - accessibilityMode, - accessibilityChunkCount, - dataLength, - getAccessibilityLabelForItem, - ]); - - const content = ( - - - {children} - {accessibilityRegions && ( - - {accessibilityRegions.map((region) => ( - { - // Always fire callback, only update internal state when not controlled - if (!isControlled) { - internalActiveItem.value = region.activeItem; - } - onInteractionChange?.(region.activeItem); - // Clear after a short delay - setTimeout(() => { - if (!isControlled) { - internalActiveItem.value = undefined; - } - onInteractionChange?.(undefined); - }, 100); - }} - style={{ flex: region.flex }} - /> - ))} - - )} - - - ); - - // Wrap with gesture handler only if interaction is enabled - if (interaction !== 'none') { - 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/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 874d4df9b..f7dff15e3 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -437,7 +437,7 @@ export const CartesianChart = memo( const resolvedEnableHighlighting = useMemo(() => { if (enableHighlighting !== undefined) return enableHighlighting; if (enableScrubbing !== undefined) return enableScrubbing; - return true; // Default to enabled + return false; // Default to disabled }, [enableHighlighting, enableScrubbing]); // Wrap onHighlightChange to also call legacy onScrubberPositionChange From 39914a4de1f6f9d58cc00bf7a1f8fa98e6089503 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 28 Jan 2026 17:04:49 -0500 Subject: [PATCH 06/16] Fix docs --- .../graphs/AreaChart/_webExamples.mdx | 6 +- .../graphs/BarChart/_webExamples.mdx | 9 +- .../graphs/CartesianChart/_mobileExamples.mdx | 73 ++++++++------- .../graphs/CartesianChart/_webExamples.mdx | 51 ++++++----- .../graphs/Legend/_mobileExamples.mdx | 12 ++- .../components/graphs/Legend/_webExamples.mdx | 6 +- .../graphs/LineChart/_mobileExamples.mdx | 90 +++++++++++-------- .../graphs/LineChart/_webExamples.mdx | 56 ++++++------ .../graphs/PeriodSelector/_webExamples.mdx | 30 ++++--- .../graphs/Point/_mobileExamples.mdx | 2 +- .../components/graphs/Point/_webExamples.mdx | 2 +- .../graphs/Scrubber/_mobileExamples.mdx | 36 ++++---- .../graphs/Scrubber/_webExamples.mdx | 34 +++---- .../graphs/XAxis/_mobileExamples.mdx | 32 +++---- .../components/graphs/XAxis/_webExamples.mdx | 32 +++---- .../graphs/YAxis/_mobileExamples.mdx | 24 ++--- .../components/graphs/YAxis/_webExamples.mdx | 24 ++--- 17 files changed, 277 insertions(+), 242 deletions(-) diff --git a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx index 2eb5751cb..7470e1326 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx @@ -410,7 +410,7 @@ function ContinuousGradient() { ))} { + const handleHighlightChange = React.useCallback( + (items) => { if (!infoTextRef.current) return; + const index = items[0]?.dataIndex; const text = index !== null && index !== undefined ? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice( @@ -798,7 +799,7 @@ function Candlesticks() { {initialInfo} Custom inset Default inset { - // Do a light impact when the scrubber position changes - // An initial and final impact is already configured by the chart - if (scrubIndex !== undefined && index !== undefined) { - void Haptics.lightImpact(); - } - setScrubIndex(index); - }, [scrubIndex]); +function Highlighting() { + const [highlightedIndex, setHighlightedIndex] = useState(undefined); + + const handleHighlightChange = useCallback( + (items) => { + const index = items[0]?.dataIndex; + // Do a light impact when the highlighted position changes + // An initial and final impact is already configured by the chart + if (highlightedIndex !== undefined && index !== undefined) { + void Haptics.lightImpact(); + } + setHighlightedIndex(index ?? undefined); + }, + [highlightedIndex], + ); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {highlightedIndex ?? 'none'} @@ -416,7 +419,7 @@ By default, the scrubber will not allow overflow gestures. You can allow overflo ```jsx @@ -434,7 +437,7 @@ You can showcase the price and volume of an asset over time within one chart. function PriceWithVolume() { const theme = useTheme(); - const [scrubIndex, setScrubIndex] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); const btcData = btcCandles .slice(0, 180) .reverse() @@ -468,7 +471,11 @@ function PriceWithVolume() { }); }, []); - const displayIndex = scrubIndex ?? btcPrices.length - 1; + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? null); + }, []); + + const displayIndex = highlightedIndex ?? btcPrices.length - 1; const currentPrice = btcPrices[displayIndex]; const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; @@ -477,9 +484,9 @@ function PriceWithVolume() { : 0; const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + if (highlightedIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + }, [highlightedIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); const ThinSolidLine = memo((props: SolidLineProps) => ); @@ -505,8 +512,8 @@ function PriceWithVolume() { } /> { Custom inset { Default inset { } ``` -## Scrubbing +## Highlighting -CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. +CartesianChart has built-in highlighting functionality that can be enabled with the `enableHighlighting` prop. This will then enable the usage of `onHighlightChange` to get the current highlighted position as the user interacts with the chart. ```jsx live -function Scrubbing() { - const [scrubIndex, setScrubIndex] = useState(undefined); +function Highlighting() { + const [highlightedIndex, setHighlightedIndex] = useState(undefined); - const onScrubberPositionChange = useCallback((index: number | undefined) => { - setScrubIndex(index); + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); }, []); return ( - Scrubber index: {scrubIndex ?? 'none'} + Highlighted index: {highlightedIndex ?? 'none'} @@ -376,7 +375,7 @@ You can showcase the price and volume of an asset over time within one chart. ```jsx live function PriceWithVolume() { - const [scrubIndex, setScrubIndex] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); const btcData = btcCandles .slice(0, 180) .reverse() @@ -410,7 +409,11 @@ function PriceWithVolume() { }); }, []); - const displayIndex = scrubIndex ?? btcPrices.length - 1; + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? null); + }, []); + + const displayIndex = highlightedIndex ?? btcPrices.length - 1; const currentPrice = btcPrices[displayIndex]; const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; @@ -419,9 +422,9 @@ function PriceWithVolume() { : 0; const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + if (highlightedIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + }, [highlightedIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); const ThinSolidLine = memo((props: SolidLineProps) => ); @@ -447,8 +450,8 @@ function PriceWithVolume() { } /> { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); const timeLabels = [ 'Jan', @@ -447,7 +451,7 @@ function DynamicLabel() { ); const dataLength = seriesConfig[0].data?.length ?? 0; - const dataIndex = scrubberPosition ?? dataLength - 1; + const dataIndex = highlightedIndex ?? dataLength - 1; const ValueLegendEntry = useCallback( ({ seriesId, label, color, shape }) => { @@ -470,12 +474,12 @@ function DynamicLabel() { return ( } legendPosition="top" - onScrubberPositionChange={setScrubberPosition} + onHighlightChange={handleHighlightChange} series={seriesConfig} width="100%" xAxis={{ diff --git a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx index d84bb9eec..f5beb2214 100644 --- a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx @@ -29,7 +29,7 @@ function BasicLegend() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + {highlightedIndex !== undefined + ? `Highlighted index: ${highlightedIndex}` + : 'Not highlighting'} (tabs[0]); - const [scrubberPosition, setScrubberPosition] = useState(); + const [highlightedIndex, setHighlightedIndex] = useState(); const sparklineTimePeriodData = useMemo(() => { return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; @@ -379,13 +383,17 @@ function Performance() { [tabs], ); + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); + return ( - + ); @@ -393,10 +401,10 @@ function Performance() { const PerformanceHeader = memo( ({ - scrubberPosition, + highlightedIndex, sparklineTimePeriodDataValues, }: { - scrubberPosition: number | undefined; + highlightedIndex: number | undefined; sparklineTimePeriodDataValues: number[]; }) => { const theme = useTheme(); @@ -411,7 +419,7 @@ const PerformanceHeader = memo( }, []); const shownPosition = - scrubberPosition !== undefined ? scrubberPosition : sparklineTimePeriodDataValues.length - 1; + highlightedIndex !== undefined ? highlightedIndex : sparklineTimePeriodDataValues.length - 1; return ( @@ -438,10 +446,10 @@ const PerformanceHeader = memo( const PerformanceChart = memo( ({ timePeriod, - onScrubberPositionChange, + onHighlightChange, }: { timePeriod: TabValue; - onScrubberPositionChange: (position: number | undefined) => void; + onHighlightChange: (items: Array<{ dataIndex: number | null; seriesId: string | null }>) => void; }) => { const theme = useTheme(); @@ -490,13 +498,13 @@ const PerformanceChart = memo( return ( @@ -649,7 +657,7 @@ function Transitions() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); // Chart-level accessibility label provides overview @@ -697,8 +705,8 @@ function BasicAccessible() { return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; }, [data]); - // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( + // Dynamic accessibility label provides specific position info + const dynamicAccessibilityLabel = useCallback( (index: number) => { return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; }, @@ -706,20 +714,24 @@ function BasicAccessible() { ); const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); + if (highlightedIndex !== undefined) { + return dynamicAccessibilityLabel(highlightedIndex); } return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); + }, [highlightedIndex, chartAccessibilityLabel, dynamicAccessibilityLabel]); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( Bitcoin} /> diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx index 609090ed3..a6d725abf 100644 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx @@ -45,7 +45,7 @@ function MultipleLine() { return ( (); + const [highlightedIndex, setHighlightedIndex] = useState(); + + const handleHighlightChange = useCallback((items) => { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); return ( - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} + {highlightedIndex !== undefined + ? `Highlighted index: ${highlightedIndex}` + : 'Not highlighting'} Bitcoin} /> { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; + const { highlight } = useHighlightContext(); + const isHighlighting = highlight.length > 0 && highlight[0]?.dataIndex !== null; // We need a fade in animation for the Scrubber return ( - + - + @@ -1898,7 +1902,7 @@ function ForecastAssetPrice() { return ( { + setHighlightedIndex(items[0]?.dataIndex ?? undefined); + }, []); + const formatPrice = useCallback((price: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -381,11 +385,11 @@ function CustomizableAssetPriceExample() { const data = useMemo(() => sparklineInteractiveData[activeTab.id], [activeTab.id]); const currentPrice = useMemo(() => sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value, []); const currentTimePrice = useMemo(() => { - if (scrubIndex !== undefined) { - return data[scrubIndex].value; + if (highlightedIndex !== undefined) { + return data[highlightedIndex].value; } return currentPrice; - }, [data, scrubIndex, currentPrice]); + }, [data, highlightedIndex, currentPrice]); const formatDate = useCallback((date) => { const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); @@ -402,19 +406,19 @@ function CustomizableAssetPriceExample() { }, []); const scrubberLabel = useMemo(() => { - if (scrubIndex === undefined) return; - return formatDate(data[scrubIndex].date); - }, [scrubIndex, data, formatDate]); + if (highlightedIndex === undefined) return; + return formatDate(data[highlightedIndex].date); + }, [highlightedIndex, data, formatDate]); const accessibilityLabel = useMemo(() => { - if (scrubIndex === undefined) return; + if (highlightedIndex === undefined) return; const price = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, - }).format(data[scrubIndex].value); - const date = formatDate(data[scrubIndex].date); + }).format(data[highlightedIndex].value); + const date = formatDate(data[highlightedIndex].date); return `Asset price: ${price} USD on ${date}`; - }, [scrubIndex, data, formatDate]); + }, [highlightedIndex, data, formatDate]); const onClickSettings = useCallback(() => setShowSettings(!showSettings), [showSettings]); @@ -477,9 +481,9 @@ function CustomizableAssetPriceExample() { } /> diff --git a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx index 2b2668bcf..0a23dcde3 100644 --- a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx @@ -4,7 +4,7 @@ Scrubber can be used to provide horizontal interaction with a chart. As your mou ```jsx live Date: Wed, 28 Jan 2026 17:13:32 -0500 Subject: [PATCH 07/16] Remove research docs --- ...chart-interaction-mobile-implementation.md | 634 ----------------- docs/research/chart-interaction-revamp.md | 666 ------------------ .../research/pointer-events-simplification.md | 395 ----------- 3 files changed, 1695 deletions(-) delete mode 100644 docs/research/chart-interaction-mobile-implementation.md delete mode 100644 docs/research/chart-interaction-revamp.md delete mode 100644 docs/research/pointer-events-simplification.md diff --git a/docs/research/chart-interaction-mobile-implementation.md b/docs/research/chart-interaction-mobile-implementation.md deleted file mode 100644 index 91db5d55a..000000000 --- a/docs/research/chart-interaction-mobile-implementation.md +++ /dev/null @@ -1,634 +0,0 @@ -# Chart Interaction System - Mobile Implementation Guide - -> **Purpose**: This document provides all context needed to implement series-level interaction on mobile, mirroring the web implementation. - -## Table of Contents - -1. [Background & Context](#background--context) -2. [Web Implementation Summary](#web-implementation-summary) -3. [Mobile Implementation Plan](#mobile-implementation-plan) -4. [API Reference](#api-reference) -5. [Key Learnings & Gotchas](#key-learnings--gotchas) -6. [Testing Requirements](#testing-requirements) - ---- - -## Background & Context - -### Problem Statement - -The CDS chart interaction system needed to be revamped to support: - -- **Series-level interaction**: Knowing which specific series (line, bar) the user is interacting with, not just which data index -- **Controlled state**: External control of the active item via props -- **Multi-touch support**: Multiple simultaneous touch points (mobile) -- **Backward compatibility**: Existing `enableScrubbing` and `onScrubberPositionChange` props must continue working -- **Future polar chart support**: Architecture should be chart-type agnostic - -### New Interaction API - -```tsx -// New API - console.log(item)} -/> - -// Legacy API (still supported via mapping) - console.log(index)} -/> -``` - -### Key Types - -```typescript -// packages/*/src/chart/utils/context.ts - -type InteractionMode = 'none' | 'single' | 'multi'; - -type InteractionScope = { - dataIndex?: boolean; // Track which data point (x-axis position) - series?: boolean; // Track which series (line, bar, etc.) -}; - -type ActiveItem = { - dataIndex: number | null; - seriesId: string | null; -}; - -type ActiveItems = ActiveItem[]; // For multi-touch - -// For controlled state: -// - undefined = uncontrolled (internal state management) -// - null = controlled with no active item (gestures still fire onInteractionChange but don't update internal state) -// - ActiveItem = controlled with specific active item -type InteractionState = ActiveItem | ActiveItems | null | undefined; -``` - ---- - -## Web Implementation Summary - -### Architecture - -``` -CartesianChart - └── InteractionProvider (new) - ├── Handles mouse/touch events at SVG level - ├── Calculates dataIndex from x position - ├── Provides InteractionContext - └── ScrubberProvider (wrapped for backward compat) - └── Chart content (Line, Bar, Scrubber, etc.) -``` - -### How Series Interaction Works on Web - -#### For Bars (`DefaultBar.tsx`) - -Bars are individual `` elements with native mouse events: - -```tsx -// packages/web-visualization/src/chart/bar/DefaultBar.tsx -const DefaultBar = ({ seriesId, dataIndex, ...props }) => { - const interactionContext = useOptionalInteractionContext(); - - const handleMouseEnter = useCallback(() => { - if (!interactionContext?.scope.series) return; - - const currentItem = interactionContext.activeItem; - const currentDataIndex = currentItem?.dataIndex ?? null; - - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: seriesId ?? null, - }); - }, [interactionContext, seriesId]); - - const handleMouseLeave = useCallback(() => { - if (!interactionContext?.scope.series) return; - - const currentDataIndex = interactionContext.activeItem?.dataIndex ?? null; - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, - seriesId: null, // Clear series, keep dataIndex - }); - }, [interactionContext]); - - return ; -}; -``` - -#### For Lines (`SolidLine.tsx`, `DottedLine.tsx`) - -**CRITICAL**: framer-motion's `motion.path` does NOT reliably forward mouse events. We discovered this through debugging and had to use raw `` elements for event handling. - -```tsx -// packages/web-visualization/src/chart/line/SolidLine.tsx -const SolidLine = ({ seriesId, interactionOffset, strokeWidth, d, ...props }) => { - const interactionContext = useOptionalInteractionContext(); - - // Determine if we need event handling - const needsEventHandling = interactionContext?.scope.series && seriesId; - - // Calculate event path stroke width (includes interactionOffset for larger hit area) - const eventPathStrokeWidth = - interactionOffset && interactionOffset > 0 ? strokeWidth + interactionOffset * 2 : strokeWidth; - - const handleMouseEnter = useCallback(() => { - // Similar to DefaultBar - preserve dataIndex, set seriesId - }, [interactionContext, seriesId]); - - const handleMouseLeave = useCallback(() => { - // Similar to DefaultBar - preserve dataIndex, clear seriesId - }, [interactionContext, seriesId]); - - return ( - <> - {/* Visible line - MUST have pointerEvents: 'none' when event handling is needed */} - - - {/* - Event handling layer - RAW , NOT framer-motion Path! - This is critical - motion.path doesn't forward mouse events reliably. - */} - {needsEventHandling && ( - - )} - - ); -}; -``` - -### Z-Order Behavior for Overlapping Elements - -When multiple elements overlap (e.g., two bar series at the same x position), the element rendered **last** in the DOM receives mouse events first. This is standard SVG/DOM behavior. - -**Example**: If `BarPlot A` is rendered before `BarPlot B`, hovering over an overlapping area will detect `B`. - -```tsx - {/* Rendered first = underneath */} - {/* Rendered second = on top, receives events */} -``` - -This is documented behavior and matches user expectations (what you see on top is what you interact with). - ---- - -## Mobile Implementation Plan - -### Key Differences from Web - -| Aspect | Web (SVG) | Mobile (Skia) | -| ---------------- | ------------------------------------ | --------------------------------------------------- | -| Rendering | SVG elements in DOM | Skia canvas drawing | -| Element events | Native `onMouseEnter`/`onMouseLeave` | ❌ Not available | -| Gesture handling | Per-element + chart-level | Chart-level only via `react-native-gesture-handler` | -| Hit detection | Browser handles automatically | **Must implement manually** | - -### Implementation Strategy - -Since Skia paths don't have touch events, we need **coordinate-based hit testing** in the gesture handler. - -#### Phase 1: Bar Interaction (Recommended Starting Point) - -Bars have known, simple bounding boxes. This is the easiest to implement. - -**Step 1**: Create a registry for bar bounds - -```typescript -// packages/mobile-visualization/src/chart/utils/context.ts - -type ElementBounds = { - x: number; - y: number; - width: number; - height: number; - dataIndex: number; - seriesId: string; -}; - -type InteractionRegistry = { - bars: ElementBounds[]; - // Future: points, lines -}; - -// Add to InteractionContextValue -type InteractionContextValue = { - // ... existing fields - registerBar: (bounds: ElementBounds) => void; - unregisterBar: (seriesId: string, dataIndex: number) => void; - registry: InteractionRegistry; -}; -``` - -**Step 2**: Register bounds when rendering bars - -```typescript -// packages/mobile-visualization/src/chart/bar/DefaultBar.tsx -const DefaultBar = ({ x, y, width, height, dataIndex, seriesId, ...props }) => { - const interactionContext = useOptionalInteractionContext(); - - useEffect(() => { - if (!interactionContext?.scope.series || !seriesId) return; - - interactionContext.registerBar({ - x, y, width, height, - dataIndex, - seriesId, - }); - - return () => { - interactionContext.unregisterBar(seriesId, dataIndex); - }; - }, [x, y, width, height, dataIndex, seriesId, interactionContext]); - - return ; -}; -``` - -**Step 3**: Hit test in gesture handler - -```typescript -// packages/mobile-visualization/src/chart/interaction/InteractionProvider.tsx - -const findBarAtPoint = ( - x: number, - y: number, - registry: InteractionRegistry, -): ElementBounds | null => { - // Iterate in reverse order (last rendered = on top = checked first) - for (let i = registry.bars.length - 1; i >= 0; i--) { - const bar = registry.bars[i]; - if (x >= bar.x && x <= bar.x + bar.width && y >= bar.y && y <= bar.y + bar.height) { - return bar; - } - } - return null; -}; - -// In gesture handler: -const onGestureEvent = useCallback( - (event: GestureEvent) => { - const { x, y } = event; - - // Calculate dataIndex from x position (existing logic) - const dataIndex = calculateDataIndex(x); - - // Check for series interaction - let seriesId: string | null = null; - if (scope.series) { - const hitBar = findBarAtPoint(x, y, registry); - if (hitBar) { - seriesId = hitBar.seriesId; - } - } - - setActiveItem({ dataIndex, seriesId }); - }, - [registry, scope], -); -``` - -#### Phase 2: Point/Scatter Interaction - -Points are circles with known centers and radii. - -```typescript -type PointBounds = { - cx: number; - cy: number; - radius: number; - dataIndex: number; - seriesId: string; -}; - -const findPointAtTouch = ( - touchX: number, - touchY: number, - points: PointBounds[], - touchTolerance: number = 10, // Extra pixels for easier touch -): PointBounds | null => { - for (let i = points.length - 1; i >= 0; i--) { - const point = points[i]; - const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); - if (distance <= point.radius + touchTolerance) { - return point; - } - } - return null; -}; -``` - -#### Phase 3: Line Interaction (Most Complex) - -Two approaches: - -**Option A: Skia Path Hit Testing (Recommended)** - -```typescript -import { Skia, PathOp } from '@shopify/react-native-skia'; - -type LinePath = { - pathString: string; // SVG path d attribute - seriesId: string; -}; - -const findLineAtTouch = ( - touchX: number, - touchY: number, - lines: LinePath[], - hitAreaWidth: number = 20, // Pixel tolerance -): LinePath | null => { - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]; - const path = Skia.Path.MakeFromSVGString(line.pathString); - - if (!path) continue; - - // Create a stroked version of the path for hit testing - const strokePath = path.copy().stroke({ - width: hitAreaWidth, - cap: Skia.CapStyle.Round, - join: Skia.JoinStyle.Round, - }); - - if (strokePath?.contains(touchX, touchY)) { - return line; - } - } - return null; -}; -``` - -**Option B: Distance-Based (Fallback)** - -Calculate perpendicular distance from touch point to line segments: - -```typescript -const distanceToLineSegment = ( - px: number, - py: number, // Touch point - x1: number, - y1: number, // Line segment start - x2: number, - y2: number, // Line segment end -): number => { - const A = px - x1; - const B = py - y1; - const C = x2 - x1; - const D = y2 - y1; - - const dot = A * C + B * D; - const lenSq = C * C + D * D; - let param = -1; - - if (lenSq !== 0) param = dot / lenSq; - - let xx, yy; - if (param < 0) { - xx = x1; - yy = y1; - } else if (param > 1) { - xx = x2; - yy = y2; - } else { - xx = x1 + param * C; - yy = y1 + param * D; - } - - return Math.sqrt(Math.pow(px - xx, 2) + Math.pow(py - yy, 2)); -}; - -const findNearestLine = ( - touchX: number, - touchY: number, - lines: LineData[], - maxDistance: number = 20, -): LineData | null => { - let nearest: LineData | null = null; - let minDistance = maxDistance; - - for (const line of lines) { - for (let i = 0; i < line.points.length - 1; i++) { - const dist = distanceToLineSegment( - touchX, - touchY, - line.points[i].x, - line.points[i].y, - line.points[i + 1].x, - line.points[i + 1].y, - ); - if (dist < minDistance) { - minDistance = dist; - nearest = line; - } - } - } - - return nearest; -}; -``` - -### Performance Considerations - -1. **Registry updates**: Use refs or shared values to avoid re-renders when registering bounds -2. **Hit testing frequency**: Consider throttling hit tests during rapid pan gestures -3. **Path parsing**: Cache parsed Skia paths, don't recreate on every touch -4. **Z-order**: Process elements in reverse render order for correct "topmost wins" behavior - -### Mobile Files to Modify - -``` -packages/mobile-visualization/src/chart/ -├── utils/context.ts # Add InteractionRegistry types -├── interaction/ -│ └── InteractionProvider.tsx # Add hit testing logic, registry management -├── bar/ -│ ├── DefaultBar.tsx # Register bounds on mount, unregister on unmount -│ └── BarStack.tsx # Pass seriesId to DefaultBar (already done) -├── line/ -│ ├── SolidLine.tsx # Register path on mount (Phase 3) -│ └── DottedLine.tsx # Register path on mount (Phase 3) -└── scatter/ - └── Point.tsx # Register center/radius (Phase 2) -``` - ---- - -## API Reference - -### InteractionProvider Props - -| Prop | Type | Default | Description | -| --------------------- | ------------------------------------------- | ------------------------------------ | ------------------------------------ | -| `interaction` | `'none' \| 'single' \| 'multi'` | `'single'` | Interaction mode | -| `interactionScope` | `{ dataIndex?: boolean; series?: boolean }` | `{ dataIndex: true, series: false }` | What to track | -| `activeItem` | `ActiveItem \| null` | `undefined` | Controlled active item (single mode) | -| `activeItems` | `ActiveItems \| []` | `undefined` | Controlled active items (multi mode) | -| `onInteractionChange` | `(state: InteractionState) => void` | - | Callback when interaction changes | -| `accessibilityLabel` | `string \| ((item: ActiveItem) => string)` | - | Static or dynamic a11y label | - -### InteractionContext Value - -```typescript -type InteractionContextValue = { - mode: InteractionMode; - scope: InteractionScope; - activeItem: InteractionState; - setActiveItem: (item: InteractionState) => void; - - // Mobile-specific (to be added): - registry: InteractionRegistry; - registerBar: (bounds: ElementBounds) => void; - unregisterBar: (seriesId: string, dataIndex: number) => void; - // Future: registerLine, registerPoint, etc. -}; -``` - -### Controlled State Behavior - -| Value | Meaning | Gesture Behavior | -| ------------------------------- | -------------------------- | -------------------------------------------------- | -| `undefined` | Uncontrolled | Updates internal state, fires callback | -| `null` (single) or `[]` (multi) | Controlled, no active item | Does NOT update internal state, DOES fire callback | -| `ActiveItem` / `ActiveItems` | Controlled with value | Uses provided value, fires callback | - ---- - -## Key Learnings & Gotchas - -### 1. framer-motion Doesn't Forward Mouse Events Reliably - -**Problem**: When using framer-motion's `motion.path` for SVG lines, `onMouseEnter`/`onMouseLeave` events don't fire reliably. - -**Solution**: Use raw `` elements for event handling, with `motion.path` only for animated visuals. - -```tsx -// ❌ WRONG - events don't fire reliably - - -// ✅ CORRECT - separate visual and event layers - - -``` - -### 2. Preserve dataIndex When Updating seriesId - -When a user hovers over a series element, we should preserve the current `dataIndex` (from mouse x position) while updating `seriesId`: - -```typescript -// ✅ CORRECT -const handleMouseEnter = () => { - const currentDataIndex = interactionContext.activeItem?.dataIndex ?? null; - interactionContext.setActiveItem({ - dataIndex: currentDataIndex, // Preserve! - seriesId: seriesId, - }); -}; -``` - -### 3. InteractionProvider Must Preserve seriesId on Pointer Move - -The `InteractionProvider.handlePointerMove` fires on every pixel of mouse movement. It must NOT overwrite `seriesId` set by child components: - -```typescript -// In InteractionProvider.handlePointerMove: -const newActiveItem = { dataIndex, seriesId: null }; - -// Preserve existing seriesId if series scope is enabled -if (scope.series) { - const currentItem = activeItemRef.current; - const effectiveItem = { - ...newActiveItem, - seriesId: currentItem?.seriesId ?? null, // Preserve! - }; - // Use effectiveItem for state update -} -``` - -### 4. Z-Order Follows Render Order - -Last rendered element is on top and receives events first. This matches visual expectations. - -### 5. Mobile Needs Coordinate-Based Hit Testing - -Unlike web where the browser handles hit detection, mobile requires manual implementation because Skia canvas elements don't have touch events. - ---- - -## Testing Requirements - -### Web Stories (Already Implemented) - -Location: `packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx` - -- `BasicInteraction` - Single mode interaction -- `ControlledState` - Programmatic control with null handling -- `InteractionDisabled` - `interaction="none"` -- `BackwardsCompatibility` - Legacy props still work -- `AccessibilityLabels` - Static and dynamic labels -- `MultiSeriesInteraction` - Multiple series, single mode -- `InteractionCallbackDetails` - Event logging -- `MultiTouchInteraction` - Multi-touch with reference lines -- `SynchronizedCharts` - Two charts sharing state -- `SeriesInteraction` - Bar hover with seriesId tracking -- `OverlappingBarsZOrder` - Z-order verification -- `LineSeriesInteraction` - Line hover with interactionOffset - -### Mobile Tests Needed - -1. **Bar interaction**: Touch a bar, verify `seriesId` is set -2. **Overlapping elements**: Touch overlap area, verify topmost element wins -3. **Pan across bars**: Verify `seriesId` updates as finger moves between bars -4. **Controlled state**: Verify `null` prevents internal state update but fires callback -5. **Performance**: Verify smooth interaction with many bars/lines - -### Debug Logging - -During development, add logging to verify hit detection: - -```typescript -console.log('[InteractionProvider] hit test', { - touchX, - touchY, - hitBar: hitBar?.seriesId ?? 'none', - registeredBars: registry.bars.length, -}); -``` - -Remove all `console.log` statements before merging. - ---- - -## Summary: What Needs to Be Done for Mobile - -1. **Add `InteractionRegistry` types** to `context.ts` -2. **Add registry management** to `InteractionProvider` (register/unregister functions, ref storage) -3. **Add hit testing** to gesture handler in `InteractionProvider` -4. **Update `DefaultBar`** to register bounds on mount -5. **Add mobile stories** to test series interaction -6. **Future**: Extend to points and lines - -Start with bars - they're the simplest and establish the pattern. Lines are the most complex due to path hit testing requirements. diff --git a/docs/research/chart-interaction-revamp.md b/docs/research/chart-interaction-revamp.md deleted file mode 100644 index 065ca5c17..000000000 --- a/docs/research/chart-interaction-revamp.md +++ /dev/null @@ -1,666 +0,0 @@ -# Chart Interaction Experience Revamp - Research Document - -## Overview - -This document outlines research and recommendations for revamping the chart interaction experience in CDS visualization packages (`@coinbase/cds-web-visualization` and `@coinbase/cds-mobile-visualization`). - -### Goals - -1. Support future **polar charts** (radar, pie, donut, etc.) -2. Improve **mobile accessibility** (currently lacking in charts, but present in deprecated Sparkline) -3. Support **controlled state** for interactions -4. Add **series-level and data-index-level interaction scope** (similar to MUI X Charts' tooltip triggers) -5. Support **multi-touch** interactions -6. Maintain **backwards compatibility** with existing `enableScrubbing` and `onScrubberPositionChange` props -7. Reserve **"highlight" terminology** for future programmatic visual emphasis features - ---- - -## Industry Terminology Comparison - -| Concept | MUI X Charts | Recharts | CDS (Proposed) | -| ----------------------------------- | ------------------------------------------------ | ------------------------------------------- | --------------------------------------- | -| **Enable/disable user interaction** | Tooltip `trigger: 'none'`, `disableAxisListener` | ` null} />` | `interaction="none"` | -| **Single-point interaction** | Tooltip `trigger: 'item'` | Tooltip `trigger: 'hover'` | `interaction="single"` | -| **Axis-based interaction** | Tooltip `trigger: 'axis'` | Tooltip `shared={true}` | `interactionScope: { dataIndex: true }` | -| **Currently active item** | `highlightedItem` | `activeIndex` (deprecated), `activePayload` | `activeItem` | -| **Callback on change** | `onHighlightChange` | N/A (managed internally) | `onInteractionChange` | -| **Scope of what's affected** | `highlighting.highlight`, `highlighting.fade` | N/A | `interactionScope` | -| **Programmatic visual emphasis** | `highlighting` prop | N/A | Future: `highlightedItems` | - -**Key Insight**: MUI conflates "highlighting" (visual emphasis) with "interaction" (user engagement). Recharts uses "active" terminology. We recommend separating these concepts. - ---- - -## Current Implementation Analysis - -### Web (`ScrubberProvider.tsx`) - -- Manages scrubber position via local `useState` -- Attaches mouse/touch/keyboard event listeners to SVG element -- Converts pointer X position to data index -- Handles keyboard navigation (Arrow keys, Home, End, Escape) -- Tightly coupled to Cartesian chart coordinate system - -### Mobile (`ScrubberProvider.tsx`) - -- Uses `react-native-reanimated` `SharedValue` for scrubber position -- Uses `react-native-gesture-handler` for long-press pan gesture -- Converts touch X position to data index via worklet-compatible functions -- Provides haptic feedback -- **Not accessible** - no VoiceOver/TalkBack support - -### Mobile Sparkline Accessibility (`SparklineAccessibleView.tsx`) - -- Chunks data into ~10 accessible regions -- Each region has `accessibilityLabel` with date and value -- Provides basic screen reader support - ---- - -## Industry Research - -### MUI X Charts - -MUI X Charts provides a comprehensive highlighting system with several key concepts: - -#### Highlighting API - -```tsx - { - // { seriesId: string | null, dataIndex: number | null } - }} -/> -``` - -#### Key Concepts - -| Concept | Description | -| ---------------- | ------------------------------------------------------------------------------------------------------------- | -| `highlight` | What gets emphasized: `'none'`, `'item'` (single data point), `'series'` (entire series) | -| `fade` | What gets de-emphasized: `'none'`, `'series'` (same series stays visible), `'global'` (everything else fades) | -| `highlightScope` | Granular control: `{ highlight: string, fade: string }` | - -#### Tooltip Trigger Types - -| Trigger | Behavior | -| -------- | --------------------------------------------------------- | -| `'item'` | Shows tooltip when hovering over a specific data point | -| `'axis'` | Shows tooltip for all series at the current axis position | -| `'none'` | Tooltip disabled | - -#### Controlled State - -```tsx -const [highlightedItem, setHighlightedItem] = useState<{ - seriesId: string | null; - dataIndex: number | null; -} | null>(null); - -; -``` - -### Recharts - -Recharts takes a different approach with more implicit highlighting: - -#### Active Index System (Pre-v3.0) - -```tsx -// Deprecated in v3.0 - internal state conflicts - -``` - -#### Tooltip-Driven Highlighting (v3.0+) - -```tsx - -``` - -#### Event Types - -| Event Type | Behavior | -| ---------- | ------------------------------------------- | -| `'axis'` | Triggers tooltip for all data at X position | -| `'item'` | Triggers tooltip for specific data point | - -#### Accessibility Layer - -```tsx -{/* Adds ARIA labels, roles, keyboard controls */} -``` - -Keyboard navigation: - -- `←` / `→`: Navigate data points -- `Home` / `End`: Jump to first/last -- Announces data via ARIA live regions - -### Victory Charts (React Native) - -Victory provides accessibility through: - -- `aria-label` and `tabIndex` props on data components -- Custom `VictoryVoronoiContainer` for nearest-point detection -- No built-in chunked accessibility regions - -### SciChart (Polar Charts Reference) - -SciChart demonstrates polar chart interactions: - -- Radial highlighting based on angle and radius -- Different interaction patterns than Cartesian (no left/right navigation) -- Touch interactions map to polar coordinates - ---- - -## Proposed Architecture - -### Naming Recommendations - -**Important Distinction**: "Highlight" should be reserved for **programmatic visual emphasis** (a future feature), while what we're building is **user interaction** handling. - -| Concept | Description | Future Use | -| --------------- | -------------------------------------------------------- | --------------- | -| **Interaction** | How users engage with the chart (touch, hover, keyboard) | Current feature | -| **Active Item** | Which item the user is currently interacting with | Current feature | -| **Highlight** | Visual emphasis on specific data (could be programmatic) | Future feature | -| **Selection** | User explicitly chose items (persistent) | Future feature | - -#### Provider Naming Options - -| Name | Pros | Cons | -| ------------------------- | -------------------------------------------------------- | ------------------------------------- | -| **`InteractionProvider`** | Clear intent, leaves room for future `HighlightProvider` | Slightly generic | -| **`ActiveItemProvider`** | Specific about what it manages | Doesn't convey multi-touch | -| **`FocusProvider`** | Accessibility-oriented | Conflicts with browser focus concepts | - -**Recommendation**: Use `InteractionProvider` internally, expose as `interaction*` props on charts. Reserve `highlight*` for future programmatic highlighting features. - -### Core Types - -```typescript -// Interaction mode - how many simultaneous interactions to track -type InteractionMode = 'none' | 'single' | 'multi'; - -// What aspects of the data can be interacted with -type InteractionScope = { - dataIndex?: boolean; // Default: true for Cartesian - series?: boolean; // Default: false for Cartesian -}; - -// Single active item state -type ActiveItem = { - dataIndex: number | null; // null = interacting but not on a point - seriesId: string | null; // null = no specific series (or series scope disabled) -}; - -// Multi-touch active items state -type ActiveItems = Array; - -// Unified interaction state -type InteractionState = ActiveItem | ActiveItems | undefined; - -// undefined = not interacting -// null values = interacting but not over specific item/series -// defined values = interacting with specific item/series -``` - -### Null vs Undefined Convention - -| Value | Meaning | -| --------------------------------------- | ----------------------------------------------------------------------------- | -| `undefined` | User is not interacting with the chart | -| `{ dataIndex: null, seriesId: null }` | User is interacting but not over a data point | -| `{ dataIndex: 5, seriesId: null }` | User is over data index 5 (series scope disabled or not over specific series) | -| `{ dataIndex: 5, seriesId: 'revenue' }` | User is over data index 5 on the 'revenue' series | - -### API Design - -#### Chart-Level Props - -```tsx -// Basic usage (backwards compatible) - console.log(index)} -/> - -// New interaction API - { - // { dataIndex: number | null, seriesId: string | null } | undefined - }} -/> - -// Multi-touch / multi-pointer - { - // Array<{ dataIndex: number | null, seriesId: string | null }> - }} -/> - -// Disabled (default - opt-in interaction) - - -// Controlled state - -``` - -#### Accessibility Props - -```tsx - { - // activeItem: { dataIndex: number, seriesId: string | null } - // context: { series: Series[], xAxis: AxisConfig, ... } - const value = context.getSeriesValue(activeItem.seriesId, activeItem.dataIndex); - const label = context.getXAxisLabel(activeItem.dataIndex); - return `${label}: ${formatCurrency(value)}`; - }} -/> -``` - -#### Future Programmatic Highlighting (Separate Feature) - -```tsx -// This would be a SEPARATE feature from interaction - -``` - -### Provider Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ CartesianChart / PolarChart │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ ChartProvider (series, axes, scales, dimensions) │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ InteractionProvider │ │ │ -│ │ │ - interaction mode (none/single/multi) │ │ │ -│ │ │ - interaction scope │ │ │ -│ │ │ - activeItem state │ │ │ -│ │ │ - controlled/uncontrolled support │ │ │ -│ │ │ ┌───────────────────────────────────────┐ │ │ │ -│ │ │ │ InputHandler (Web/Mobile) │ │ │ │ -│ │ │ │ - Pointer/Touch/Gesture handling │ │ │ │ -│ │ │ │ - Coordinate → activeItem conversion │ │ │ │ -│ │ │ └───────────────────────────────────────┘ │ │ │ -│ │ │ ┌───────────────────────────────────────┐ │ │ │ -│ │ │ │ KeyboardHandler (Web only) │ │ │ │ -│ │ │ │ - Arrow/Home/End/Escape navigation │ │ │ │ -│ │ │ │ - Chart-type agnostic interface │ │ │ │ -│ │ │ └───────────────────────────────────────┘ │ │ │ -│ │ │ ┌───────────────────────────────────────┐ │ │ │ -│ │ │ │ AccessibilityHandler (Mobile) │ │ │ │ -│ │ │ │ - Chunked regions for VoiceOver │ │ │ │ -│ │ │ │ - Per-item/per-series navigation │ │ │ │ -│ │ │ └───────────────────────────────────────┘ │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ └───────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### Keyboard Navigation Strategy - -Current issue: Keyboard navigation in `ScrubberProvider` assumes Cartesian charts (left/right = data index). - -**Proposed Solution**: Abstract navigation into chart-type-specific handlers: - -```typescript -interface NavigationHandler { - // Returns the next active item for a given navigation action - getNextActiveItem( - action: 'previous' | 'next' | 'first' | 'last' | 'clear', - currentItem: ActiveItem | undefined, - context: ChartContext, - ): ActiveItem | undefined; -} - -// Cartesian implementation -const cartesianNavigation: NavigationHandler = { - getNextActiveItem(action, current, context) { - switch (action) { - case 'previous': - return { ...current, dataIndex: Math.max(0, (current?.dataIndex ?? 0) - 1) }; - case 'next': - return { - ...current, - dataIndex: Math.min(context.dataLength - 1, (current?.dataIndex ?? -1) + 1), - }; - // ... - } - }, -}; - -// Future polar implementation -const polarNavigation: NavigationHandler = { - getNextActiveItem(action, current, context) { - // Navigate by angle/segment instead of data index - // ... - }, -}; -``` - -### Mobile Accessibility Strategy - -Current issue: Mobile charts have no accessibility support. - -**Proposed Solution**: Configurable accessibility regions based on chart type and highlight scope: - -#### Option 1: Chunked Regions (Default for Line Charts) - -```tsx -// Similar to SparklineAccessibleView - -``` - -- Divides chart into N accessible regions -- Each region announces first data point in chunk -- Good for continuous data (line charts, area charts) - -#### Option 2: Per-Item Regions (Default for Bar Charts) - -```tsx - -``` - -- Each data point is an accessible region -- User can swipe through each bar/point -- Good for discrete data (bar charts, scatter plots) - -#### Option 3: Per-Series Regions - -```tsx - -``` - -- Each series is an accessible region -- Good for comparing series values at a glance -- Can combine with item mode for drill-down - -#### Mobile Accessibility Implementation - -```tsx -// Internal: AccessibilityOverlay component -const AccessibilityOverlay: React.FC<{ - mode: 'chunked' | 'item' | 'series'; - chunkCount?: number; - getAccessibilityLabel: (activeItem: ActiveItem) => string; -}> = ({ mode, chunkCount = 10, getAccessibilityLabel }) => { - const chartContext = useChartContext(); - const { onInteractionChange } = useInteractionContext(); - - const regions = useMemo(() => { - switch (mode) { - case 'chunked': - return createChunkedRegions(chartContext.dataLength, chunkCount); - case 'item': - return createItemRegions(chartContext.dataLength); - case 'series': - return createSeriesRegions(chartContext.series); - } - }, [mode, chartContext, chunkCount]); - - return ( - - {regions.map((region) => ( - onInteractionChange(region.activeItem)} - style={region.style} - /> - ))} - - ); -}; -``` - -### Multi-Touch Support - -#### Web Implementation - -```typescript -// Track multiple active pointers -const [activePointers, setActivePointers] = useState>(new Map()); - -const handlePointerDown = (e: PointerEvent) => { - setActivePointers((prev) => new Map(prev).set(e.pointerId, e)); -}; - -const handlePointerMove = (e: PointerEvent) => { - if (!activePointers.has(e.pointerId)) return; - - // Convert each pointer to an active item - const activeItems = Array.from(activePointers.values()).map((pointer) => - getActiveItemFromPointer(pointer), - ); - - onInteractionChange(activeItems); -}; - -const handlePointerUp = (e: PointerEvent) => { - setActivePointers((prev) => { - const next = new Map(prev); - next.delete(e.pointerId); - return next; - }); -}; -``` - -#### Mobile Implementation - -```typescript -// Use react-native-gesture-handler's multi-touch support -const gesture = Gesture.Manual() - .onTouchesDown((event) => { - const activeItems = event.allTouches.map((touch) => getActiveItemFromTouch(touch)); - onInteractionChange(activeItems); - }) - .onTouchesMove((event) => { - const activeItems = event.allTouches.map((touch) => getActiveItemFromTouch(touch)); - onInteractionChange(activeItems); - }) - .onTouchesUp((event) => { - // Update with remaining touches - const activeItems = event.allTouches - .filter((t) => t.state !== State.END) - .map((touch) => getActiveItemFromTouch(touch)); - onInteractionChange(activeItems.length > 0 ? activeItems : undefined); - }); -``` - ---- - -## Backwards Compatibility - -### Migration Path - -The new API should be fully backwards compatible: - -```tsx -// OLD API (continues to work) - console.log(index)} -/> - -// Internally translates to: - { - onScrubberPositionChange?.(activeItem?.dataIndex); - }} -/> -``` - -### Deprecation Strategy - -1. **Phase 1**: Add new `interaction*` props alongside existing `enableScrubbing` / `onScrubberPositionChange` -2. **Phase 2**: Add deprecation warnings to old props -3. **Phase 3**: Remove old props in next major version - ---- - -## Open Questions - -### 1. Default Interaction Mode - -Should interaction be opt-in (current) or opt-out? - -| Option | Pros | Cons | -| -------------------------------------------- | -------------------------------- | ------------------------------------- | -| **Opt-in** (`interaction="none"` default) | Explicit, no unexpected behavior | More boilerplate for common use cases | -| **Opt-out** (`interaction="single"` default) | Better DX for interactive charts | May cause unexpected behavior | - -**Recommendation**: Keep opt-in for backwards compatibility, but provide `` etc. preset components. - -### 2. Interaction Mode Naming - -Alternative names for `interaction` prop values: - -| Current | Alternative 1 | Alternative 2 | -| ---------- | ------------- | --------------- | -| `'none'` | `'disabled'` | `false` | -| `'single'` | `'item'` | `'point'` | -| `'multi'` | `'multiple'` | `'multi-touch'` | - -### 3. Series Interaction Default - -For multi-series charts, should series interaction scope be: - -| Option | Behavior | -| -------------------------- | ------------------------------------------- | -| **All series at index** | All bars/points at x=5 become active | -| **Only interacted series** | Only the specific bar/point touched/hovered | - -**Recommendation**: Make this configurable via `interactionScope.series`. - -### 4. Z-Order Behavior for Overlapping Elements - -When multiple chart elements (bars, lines, areas) overlap at the same position, the **topmost element in the DOM (last rendered)** receives mouse/touch events. This is standard SVG/DOM behavior. - -#### Key Points for Documentation - -1. **SVG rendering order**: Elements rendered later in JSX appear visually on top -2. **Event propagation**: The topmost element receives mouse events first -3. **No automatic "nearest" detection**: Unlike some libraries with Voronoi-based detection, we rely on direct element hit testing - -#### Example: Overlapping Bars - -```tsx - - {/* Revenue bars rendered first - underneath */} - - {/* Profit margin bars rendered second - on top */} - - -``` - -In this example: -- Where bars overlap, hovering will **always detect `profitMargin`** (the topmost element) -- The `revenue` bars are only detectable in areas where they extend beyond the `profitMargin` bars - -#### Documentation Guidance - -Users should be aware that: -1. **Render order matters** - the last-rendered series will capture interactions in overlapping regions -2. **Use separate y-axes carefully** - when series have different scales, bars may overlap unexpectedly -3. **Consider visual clarity** - if users need to interact with both overlapping series, consider alternative visualizations (grouped bars, separate charts, or tooltips that show all series at an index) - -This behavior is consistent with how other charting libraries handle overlapping elements (Chart.js, SciChart, Syncfusion, etc.). - -### 4. Polar Chart Navigation - -How should keyboard navigation work for polar charts? - -| Option | Behavior | -| -------------- | ------------------------------------------------- | -| **By segment** | Arrow keys move between pie slices / radar points | -| **By angle** | Arrow keys rotate around the chart | -| **By radius** | Arrow keys move in/out (for nested polar charts) | - ---- - -## Implementation Plan - -### Phase 1: Core Infrastructure - -1. Create `InteractionContext` and `InteractionProvider` types -2. Implement uncontrolled `activeItem` state management -3. Add backwards-compatible prop handling (`enableScrubbing` → `interaction`) - -### Phase 2: Input Handlers - -1. Refactor web pointer/touch handlers -2. Refactor mobile gesture handlers -3. Add multi-touch support - -### Phase 3: Keyboard Navigation - -1. Abstract navigation into `NavigationHandler` interface -2. Implement Cartesian navigation handler -3. Move keyboard handling to dedicated component - -### Phase 4: Mobile Accessibility - -1. Create `AccessibilityOverlay` component -2. Implement chunked regions (like Sparkline) -3. Implement per-item regions -4. Add accessibility label customization - -### Phase 5: Advanced Features - -1. Add series-level interaction scope -2. Add controlled state support (`activeItem` prop) -3. Prepare polar chart navigation interface - -### Phase 6: Future - Programmatic Highlighting (Separate Feature) - -1. Add `highlightedItems` prop for visual emphasis -2. Add highlight/fade visual effects -3. Support programmatic highlighting independent of interaction - ---- - -## References - -- [MUI X Charts - Highlighting](https://mui.com/x/react-charts/highlighting/) -- [MUI X Charts - Tooltip](https://mui.com/x/react-charts/tooltip/) -- [Recharts - Accessibility](https://github.com/recharts/recharts/wiki/Recharts-and-accessibility) -- [Recharts - Tooltip](https://github.com/recharts/recharts/wiki/Tooltip-event-type-and-shared-prop) -- [WAI-ARIA Authoring Practices - Data Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) -- [React Native Accessibility](https://reactnative.dev/docs/accessibility) diff --git a/docs/research/pointer-events-simplification.md b/docs/research/pointer-events-simplification.md deleted file mode 100644 index 851c19b23..000000000 --- a/docs/research/pointer-events-simplification.md +++ /dev/null @@ -1,395 +0,0 @@ -# Web InteractionProvider: Pointer Events Simplification - -> **Purpose**: Document the proposed simplification of the web `InteractionProvider` from separate mouse/touch events to unified Pointer Events. - -## Table of Contents - -1. [Current Implementation](#current-implementation) -2. [Pointer Events Overview](#pointer-events-overview) -3. [Proposed Changes](#proposed-changes) -4. [Implementation Plan](#implementation-plan) -5. [Browser Compatibility](#browser-compatibility) -6. [Migration Guide](#migration-guide) - ---- - -## Current Implementation - -### Event Handlers - -The current `InteractionProvider.tsx` uses separate event handlers for mouse and touch: - -```typescript -// Mouse event handlers (lines 300-312) -const handleMouseMove = useCallback((event: MouseEvent) => { - const target = event.currentTarget as SVGSVGElement; - handlePointerMove(event.clientX, event.clientY, target); -}, [handlePointerMove]); - -const handleMouseLeave = useCallback(() => { - if (interaction === 'none') return; - setActiveState(interaction === 'multi' ? [] : undefined); -}, [interaction, setActiveState]); - -// Touch event handlers (lines 314-388) -const handleTouchStart = useCallback((event: TouchEvent) => { - // Track touches, update state -}, [...]); - -const handleTouchMove = useCallback((event: TouchEvent) => { - event.preventDefault(); // Prevent scrolling - // Update state based on touch positions -}, [...]); - -const handleTouchEnd = useCallback((event: TouchEvent) => { - // Remove ended touches, clear state if empty -}, [...]); -``` - -### Event Listener Registration - -```typescript -// Lines 476-511 -useEffect( - () => { - svg.addEventListener('mousemove', handleMouseMove); - svg.addEventListener('mouseleave', handleMouseLeave); - svg.addEventListener('touchstart', handleTouchStart, { passive: false }); - svg.addEventListener('touchmove', handleTouchMove, { passive: false }); - svg.addEventListener('touchend', handleTouchEnd); - svg.addEventListener('touchcancel', handleTouchEnd); - svg.addEventListener('keydown', handleKeyDown); - svg.addEventListener('blur', handleBlur); - // ... cleanup - }, - [ - /* 10 dependencies */ - ], -); -``` - -### Issues with Current Approach - -1. **Code duplication**: Separate handlers for mouse and touch doing essentially the same thing -2. **Maintenance burden**: Changes need to be applied to both mouse and touch handlers -3. **Complex dependency arrays**: 10 dependencies in the useEffect -4. **Two tracking mechanisms**: `activePointersRef` for touches, implicit single-pointer for mouse - ---- - -## Pointer Events Overview - -### What are Pointer Events? - -Pointer Events is a W3C standard that unifies mouse, touch, and stylus input into a single event model. Instead of handling `MouseEvent` and `TouchEvent` separately, you handle `PointerEvent` which works for all input types. - -### Key Properties - -| Property | Description | -| -------------- | ----------------------------------------------------------------- | -| `pointerId` | Unique identifier for the pointer (like `touch.identifier`) | -| `pointerType` | `'mouse'`, `'touch'`, or `'pen'` | -| `isPrimary` | `true` if this is the primary pointer (first touch, or the mouse) | -| `clientX/Y` | Coordinates (same as mouse/touch) | -| `pressure` | Pressure level (0 to 1) | -| `width/height` | Contact geometry (for touch) | - -### Event Types - -| Pointer Event | Replaces | -| --------------- | ------------------------- | -| `pointerdown` | `mousedown`, `touchstart` | -| `pointermove` | `mousemove`, `touchmove` | -| `pointerup` | `mouseup`, `touchend` | -| `pointerleave` | `mouseleave` | -| `pointercancel` | `touchcancel` | -| `pointerenter` | `mouseenter` | - -### Multi-Pointer Handling - -```typescript -// Each pointer has a unique ID -const activePointers = new Map(); - -function onPointerDown(event: PointerEvent) { - activePointers.set(event.pointerId, event); -} - -function onPointerMove(event: PointerEvent) { - if (activePointers.has(event.pointerId)) { - activePointers.set(event.pointerId, event); - } -} - -function onPointerUp(event: PointerEvent) { - activePointers.delete(event.pointerId); -} -``` - ---- - -## Proposed Changes - -### Before vs After - -| Aspect | Before | After | -| ------------------------- | ---------------------- | ---------- | -| Event handlers | 6 (2 mouse + 4 touch) | 4 pointer | -| Event listeners | 8 total | 6 total | -| Code lines | ~90 lines | ~50 lines | -| Dependencies in useEffect | 10 | 6 | -| Tracking mechanism | Mixed (implicit + Map) | Single Map | - -### New Event Handlers - -```typescript -// Single handler for all pointer movement -const handlePointerMoveEvent = useCallback( - (event: PointerEvent) => { - if (interaction === 'none') return; - - const target = event.currentTarget as SVGSVGElement; - - if (interaction === 'single') { - // Only respond to primary pointer (first touch or mouse) - if (!event.isPrimary) return; - handlePointerMove(event.clientX, event.clientY, target); - } else { - // Multi mode: update the specific pointer - activePointersRef.current.set(event.pointerId, { - clientX: event.clientX, - clientY: event.clientY, - }); - updateMultiPointerState(target); - } - }, - [interaction, handlePointerMove, updateMultiPointerState], -); - -// Handle pointer down (needed for multi-touch tracking) -const handlePointerDown = useCallback( - (event: PointerEvent) => { - if (interaction !== 'multi') return; - - activePointersRef.current.set(event.pointerId, { - clientX: event.clientX, - clientY: event.clientY, - }); - - const target = event.currentTarget as SVGSVGElement; - updateMultiPointerState(target); - }, - [interaction, updateMultiPointerState], -); - -// Handle pointer up/cancel -const handlePointerUp = useCallback( - (event: PointerEvent) => { - if (interaction === 'none') return; - - if (interaction === 'multi') { - activePointersRef.current.delete(event.pointerId); - - if (activePointersRef.current.size === 0) { - setActiveState([]); - } else { - const target = event.currentTarget as SVGSVGElement; - updateMultiPointerState(target); - } - } else { - // Single mode: only clear on primary pointer - if (event.isPrimary) { - setActiveState(undefined); - } - } - }, - [interaction, setActiveState, updateMultiPointerState], -); - -// Handle pointer leaving the element -const handlePointerLeave = useCallback( - (event: PointerEvent) => { - if (interaction === 'none') return; - - // For touch, pointerleave fires when finger lifts - // For mouse, fires when cursor leaves element - if (event.pointerType === 'mouse' && event.isPrimary) { - setActiveState(interaction === 'multi' ? [] : undefined); - } - }, - [interaction, setActiveState], -); -``` - -### New Event Registration - -```typescript -useEffect(() => { - if (!svgRef?.current || interaction === 'none') return; - - const svg = svgRef.current; - - // Prevent touch scrolling on the SVG - svg.style.touchAction = 'none'; - - // Pointer events (unified mouse + touch) - svg.addEventListener('pointerdown', handlePointerDown); - svg.addEventListener('pointermove', handlePointerMoveEvent); - svg.addEventListener('pointerup', handlePointerUp); - svg.addEventListener('pointercancel', handlePointerUp); - svg.addEventListener('pointerleave', handlePointerLeave); - - // Keyboard (unchanged) - svg.addEventListener('keydown', handleKeyDown); - svg.addEventListener('blur', handleBlur); - - return () => { - svg.style.touchAction = ''; - svg.removeEventListener('pointerdown', handlePointerDown); - svg.removeEventListener('pointermove', handlePointerMoveEvent); - svg.removeEventListener('pointerup', handlePointerUp); - svg.removeEventListener('pointercancel', handlePointerUp); - svg.removeEventListener('pointerleave', handlePointerLeave); - svg.removeEventListener('keydown', handleKeyDown); - svg.removeEventListener('blur', handleBlur); - }; -}, [ - svgRef, - interaction, - handlePointerDown, - handlePointerMoveEvent, - handlePointerUp, - handlePointerLeave, - handleKeyDown, - handleBlur, -]); -``` - ---- - -## Implementation Plan - -### Phase 1: Refactor Event Handlers - -1. **Remove separate mouse/touch handlers**: - - Delete `handleMouseMove`, `handleMouseLeave` - - Delete `handleTouchStart`, `handleTouchMove`, `handleTouchEnd` - -2. **Add unified pointer handlers**: - - Add `handlePointerDown` (for multi-touch tracking start) - - Add `handlePointerMoveEvent` (replaces both mouse/touch move) - - Add `handlePointerUp` (replaces touch end, handles multi) - - Add `handlePointerLeave` (replaces mouse leave) - -3. **Keep existing logic**: - - `handlePointerMove` internal function (coordinate → ActiveItem) stays the same - - `updateMultiPointerState` stays the same - - `activePointersRef` stays the same (just uses `pointerId` instead of touch `identifier`) - -### Phase 2: Update Event Registration - -1. **Replace event listeners**: - - ```diff - - svg.addEventListener('mousemove', handleMouseMove); - - svg.addEventListener('mouseleave', handleMouseLeave); - - svg.addEventListener('touchstart', handleTouchStart, { passive: false }); - - svg.addEventListener('touchmove', handleTouchMove, { passive: false }); - - svg.addEventListener('touchend', handleTouchEnd); - - svg.addEventListener('touchcancel', handleTouchEnd); - + svg.addEventListener('pointerdown', handlePointerDown); - + svg.addEventListener('pointermove', handlePointerMoveEvent); - + svg.addEventListener('pointerup', handlePointerUp); - + svg.addEventListener('pointercancel', handlePointerUp); - + svg.addEventListener('pointerleave', handlePointerLeave); - ``` - -2. **Add touch-action CSS**: - ```typescript - svg.style.touchAction = 'none'; // Prevent scrolling - ``` - -### Phase 3: Test & Verify - -1. **Single mode mouse**: Hover tracking works -2. **Single mode touch**: Touch tracking works -3. **Multi mode mouse**: Single pointer tracked -4. **Multi mode touch**: Multiple pointers tracked -5. **Leave behavior**: State clears on mouse leave -6. **Cancel behavior**: State clears on touch cancel -7. **Controlled state**: Still works with pointer events - ---- - -## Browser Compatibility - -### Support Matrix - -| Browser | Pointer Events Support | -| -------------- | ---------------------- | -| Chrome | ✅ 55+ (Dec 2016) | -| Firefox | ✅ 59+ (Mar 2018) | -| Safari | ✅ 13+ (Sep 2019) | -| Edge | ✅ All versions | -| IE | ✅ 11+ (with prefix) | -| Mobile Safari | ✅ 13+ | -| Chrome Android | ✅ 55+ | - -### Polyfill - -Not needed for modern browsers. If legacy support required: - -```bash -npm install pepjs -``` - -```typescript -import 'pepjs'; // Polyfill for IE10 -``` - ---- - -## Migration Guide - -### No Breaking Changes - -This refactor is internal to `InteractionProvider`. The public API remains unchanged: - -```tsx -// Still works exactly the same - console.log(state)} -> - {/* ... */} - -``` - -### Testing Checklist - -- [ ] Mouse hover updates `activeItem.dataIndex` -- [ ] Mouse leave clears `activeItem` -- [ ] Touch drag updates `activeItem.dataIndex` -- [ ] Touch end clears `activeItem` -- [ ] Multi-touch creates multiple `activeItems` -- [ ] Multi-touch end removes individual items -- [ ] Controlled state (`activeItem` prop) ignores input -- [ ] `null` controlled state prevents internal updates -- [ ] Series interaction (`interactionScope.series`) still works -- [ ] Keyboard navigation still works -- [ ] Legacy `enableScrubbing` / `onScrubberPositionChange` still work - ---- - -## Summary - -| Metric | Before | After | Improvement | -| ----------------------- | ------ | ----- | ----------- | -| Event handler functions | 6 | 4 | -33% | -| Event listeners | 8 | 7 | -12% | -| Lines of code | ~90 | ~50 | -44% | -| useEffect dependencies | 10 | 8 | -20% | -| Code paths for input | 2 | 1 | -50% | - -The Pointer Events API provides a cleaner, more maintainable implementation with no functionality loss and excellent browser support. From f34a46cf4aa86d4b437cc072932a6100e120be6a Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 28 Jan 2026 17:30:02 -0500 Subject: [PATCH 08/16] Cleanup logic --- .../src/chart/CartesianChart.tsx | 2 - .../src/chart/utils/context.ts | 72 ------------------- .../src/chart/CartesianChart.tsx | 28 ++------ .../src/chart/utils/context.ts | 49 ------------- 4 files changed, 5 insertions(+), 146 deletions(-) diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index ae6db74a5..cc1e0e8f8 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -102,8 +102,6 @@ export type CartesianChartBaseProps = Omit; - -/** - * @deprecated Use `HighlightedItem[]` instead. - */ -export type InteractionState = HighlightedItem | HighlightedItem[] | undefined | null; - // ============================================================================ // Interaction Registry Types (for coordinate-based hit testing on mobile) // ============================================================================ @@ -307,46 +278,3 @@ export const useHighlightContext = (): HighlightContextValue => { export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { return useContext(HighlightContext); }; - -// ============================================================================ -// Backwards Compatibility (Deprecated) -// ============================================================================ - -/** - * @deprecated Use `HighlightContextValue` instead. - */ -export type InteractionContextValue = { - mode: InteractionMode; - scope: InteractionScope; - activeItem: SharedValue; - setActiveItem: (state: InteractionState) => void; - registerBar: (bounds: ElementBounds) => void; - unregisterBar: (seriesId: string, dataIndex: number) => void; - registerPoint: (bounds: PointBounds) => void; - unregisterPoint: (seriesId: string, dataIndex: number) => void; - registerLine: (path: LinePath) => void; - unregisterLine: (seriesId: string) => void; -}; - -/** - * @deprecated Use `HighlightContext` instead. - */ -export const InteractionContext = createContext(undefined); - -/** - * @deprecated Use `useHighlightContext` instead. - */ -export const useInteractionContext = (): InteractionContextValue => { - const context = useContext(InteractionContext); - if (!context) { - throw new Error('useInteractionContext must be used within an InteractionProvider'); - } - return context; -}; - -/** - * @deprecated Use `useOptionalHighlightContext` instead. - */ -export const useOptionalInteractionContext = (): InteractionContextValue | undefined => { - return useContext(InteractionContext); -}; diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index f7dff15e3..838c8b63a 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -85,8 +85,6 @@ export type CartesianChartBaseProps = Omit & * - When a function: Called with the highlighted item to generate dynamic labels during interaction */ accessibilityLabel?: string | ((item: HighlightedItem) => string); - - // Legacy props for backwards compatibility /** * @deprecated Use `enableHighlighting={false}` instead. Will be removed in next major version. */ @@ -515,6 +513,10 @@ export const CartesianChart = memo( ); + // Determine flex direction based on legend position + const isLegendVertical = legendPosition === 'left' || legendPosition === 'right'; + const legendFlexDirection = isLegendVertical ? 'row' : 'column'; + return ( {legend ? ( - { - const svgElement = node as unknown as SVGSVGElement; - svgRef.current = svgElement; - // Forward the ref to the user - if (ref) { - if (typeof ref === 'function') { - ref(svgElement); - } else { - (ref as React.MutableRefObject).current = svgElement; - } - } - }} - aria-live="polite" - as="svg" - className={cx(isHighlightingEnabled && focusStylesCss, classNames?.chart)} - height="100%" - style={styles?.chart} - tabIndex={isHighlightingEnabled ? 0 : undefined} - width="100%" - > + {(legendPosition === 'top' || legendPosition === 'left') && legendElement} {chartContent} {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 7066e82f5..8fc286331 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -191,52 +191,3 @@ export const useHighlightContext = (): HighlightContextValue => { export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { return useContext(HighlightContext); }; - -// ============================================================================ -// Backwards-compatible aliases (deprecated) -// ============================================================================ - -/** - * @deprecated Use `HighlightScope` instead. - */ -export type InteractionScope = HighlightScope; - -/** - * @deprecated Use `HighlightedItem` instead. - */ -export type ActiveItem = HighlightedItem; - -/** - * @deprecated Use `HighlightedItem[]` instead. - */ -export type ActiveItems = HighlightedItem[]; - -/** - * @deprecated Use `HighlightedItem[]` instead. - */ -export type InteractionState = HighlightedItem[]; - -/** - * @deprecated No longer used. Highlighting is always enabled via boolean. - */ -export type InteractionMode = 'none' | 'single' | 'multi'; - -/** - * @deprecated Use `HighlightContextValue` instead. - */ -export type InteractionContextValue = HighlightContextValue; - -/** - * @deprecated Use `HighlightContext` instead. - */ -export const InteractionContext = HighlightContext; - -/** - * @deprecated Use `useHighlightContext` instead. - */ -export const useInteractionContext = useHighlightContext; - -/** - * @deprecated Use `useOptionalHighlightContext` instead. - */ -export const useOptionalInteractionContext = useOptionalHighlightContext; From 7e921f17301784345cdfd9619c09f67e4702d7b0 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 28 Jan 2026 17:53:45 -0500 Subject: [PATCH 09/16] Add ref forwarding to doc examples --- .../graphs/LineChart/_mobileExamples.mdx | 20 ++++++++++-------- .../graphs/LineChart/_webExamples.mdx | 20 ++++++++++-------- .../graphs/Scrubber/_mobileExamples.mdx | 20 ++++++++++-------- .../graphs/Scrubber/_webExamples.mdx | 20 ++++++++++-------- .../line/__stories__/LineChart.stories.tsx | 21 +++++++++++-------- 5 files changed, 56 insertions(+), 45 deletions(-) diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx index 31d33ae7b..70b86716a 100644 --- a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx @@ -1642,15 +1642,17 @@ function MonotoneAssetPrice() { ); const InvertedBeacon = useMemo( - () => (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [theme.color.fg, theme.color.bg], ); diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx index a6d725abf..6a8fa0cc8 100644 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx @@ -1547,15 +1547,17 @@ function MonotoneAssetPrice() { ); const InvertedBeacon = useMemo( - () => (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [], ); diff --git a/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx index 07f7d6b95..8707abcc3 100644 --- a/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx @@ -209,15 +209,17 @@ function OutlineBeacon() { } const InvertedBeacon = useMemo( - () => (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [theme.color.fg, theme.color.bg], ); diff --git a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx index 0a23dcde3..2ef622d44 100644 --- a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx @@ -219,15 +219,17 @@ function OutlineBeacon() { } const InvertedBeacon = useMemo( - () => (props) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [], ); diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 9f362e509..3e613b1cd 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -33,6 +33,7 @@ import { projectPoint, Scrubber, type ScrubberBeaconProps, + type ScrubberBeaconRef, type ScrubberLabelProps, type ScrubberRef, useCartesianChartContext, @@ -2190,15 +2191,17 @@ function CustomBeaconSize() { } const InvertedBeacon = useMemo( - () => (props: ScrubberBeaconProps) => ( - - ), + () => + forwardRef((props, ref) => ( + + )), [], ); From efdfaab8546cf048774b197405a99f3d0b770360 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 29 Jan 2026 11:01:23 -0500 Subject: [PATCH 10/16] Update default insets --- .../src/chart/CartesianChart.tsx | 20 ++++++++++++----- .../src/chart/area/AreaChart.tsx | 7 ++++-- .../src/chart/bar/BarChart.tsx | 12 ++++++++-- .../chart/interaction/HighlightProvider.tsx | 22 +++++-------------- .../src/chart/line/LineChart.tsx | 12 ++++++++-- .../src/chart/utils/__tests__/chart.test.ts | 6 ++--- .../src/chart/utils/chart.ts | 12 +++++++++- .../src/chart/CartesianChart.tsx | 20 ++++++++++++----- .../src/chart/area/AreaChart.tsx | 7 ++++-- .../src/chart/bar/BarChart.tsx | 12 ++++++++-- .../chart/interaction/HighlightProvider.tsx | 20 +++++------------ .../src/chart/line/LineChart.tsx | 12 ++++++++-- .../src/chart/utils/__tests__/chart.test.ts | 6 ++--- .../src/chart/utils/chart.ts | 12 +++++++++- 14 files changed, 120 insertions(+), 60 deletions(-) diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index cc1e0e8f8..7707e357e 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -10,7 +10,7 @@ import { type HighlightProps, HighlightProvider } from './interaction/HighlightP import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; -import { Legend, type LegendProps } from './legend'; +import { Legend } from './legend'; import { type AxisConfig, type AxisConfigProps, @@ -18,7 +18,8 @@ import { type ChartInset, type ChartScaleFunction, defaultAxisId, - defaultChartInset, + defaultCartesianChartHighlightScope, + defaultCartesianChartInset, getAxisConfig, getAxisDomain, getAxisRange, @@ -26,6 +27,7 @@ import { getChartInset, getStackedSeriesData as calculateStackedSeriesData, type HighlightedItem, + type HighlightScope, type LegendPosition, type Series, useTotalAxisPadding, @@ -44,7 +46,7 @@ const ChartCanvas = memo( ); export type CartesianChartBaseProps = Omit & - HighlightProps & { + Omit & { /** * Configuration objects that define how to visualize the data. * Each series contains its own data array. @@ -102,6 +104,11 @@ export type CartesianChartBaseProps = Omit getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // there can only be one x axis but the helper function always returns an array const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index c79187070..6cdfc0515 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -10,7 +10,7 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartInset, type Series, @@ -115,7 +115,10 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index e19577fda..14cb3a617 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -7,7 +7,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, defaultStackId, getChartInset } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + defaultStackId, + getChartInset, +} from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; import type { BarSeries } from './BarStack'; @@ -95,7 +100,10 @@ export const BarChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); const transformedSeries = useMemo(() => { if (!stacked || !series) return series; diff --git a/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx index a87c7381f..ab5448bf7 100644 --- a/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx +++ b/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx @@ -26,34 +26,21 @@ import { } from '../utils'; import { getPointOnSerializableScale } from '../utils/point'; -const defaultHighlightScope: HighlightScope = { - dataIndex: true, - series: false, -}; - export type HighlightProps = { /** * Whether highlighting is enabled. - * @default true */ enableHighlighting?: boolean; /** * Controls what aspects of the data can be highlighted. - * @default { dataIndex: true, series: false } */ highlightScope?: HighlightScope; /** - * Controlled highlight state. - * - undefined: Uncontrolled mode - chart manages its own state - * - HighlightedItem[]: Controlled mode with specific highlighted items - * - * In controlled mode, user interactions still fire onHighlightChange but don't update the UI. - * This allows the parent to decide whether to apply the change. + * Pass a value to override the internal highlight state. */ highlight?: HighlightedItem[]; /** * Callback fired when highlighting changes during interaction. - * Always fires in both controlled and uncontrolled modes. */ onHighlightChange?: (items: HighlightedItem[]) => void; }; @@ -92,7 +79,7 @@ export type HighlightProviderProps = HighlightProps & { export const HighlightProvider: React.FC = ({ children, allowOverflowGestures, - enableHighlighting = true, + enableHighlighting = false, highlightScope: scopeProp, highlight: controlledHighlight, onHighlightChange, @@ -109,7 +96,10 @@ export const HighlightProvider: React.FC = ({ const { getXSerializableScale, getXAxis, dataLength } = chartContext; const scope: HighlightScope = useMemo( - () => ({ ...defaultHighlightScope, ...scopeProp }), + () => ({ + dataIndex: scopeProp?.dataIndex ?? false, + series: scopeProp?.series ?? false, + }), [scopeProp], ); diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index 2abdc2b9e..c8cecc1bf 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -8,7 +8,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + getChartInset, + type Series, +} from '../utils'; import { Line, type LineProps } from './Line'; @@ -106,7 +111,10 @@ export const LineChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..93b171a86 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,7 +1,7 @@ import { type AxisBounds, type ChartInset, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartDomain, getChartInset, @@ -370,9 +370,9 @@ describe('isValidBounds', () => { }); }); -describe('defaultChartInset', () => { +describe('defaultCartesianChartInset', () => { it('should have correct default values', () => { - expect(defaultChartInset).toEqual({ + expect(defaultCartesianChartInset).toEqual({ top: 32, left: 16, bottom: 16, diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 46ee06bc4..e66c37d4d 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -2,10 +2,20 @@ import { isSharedValue } from 'react-native-reanimated'; import type { AnimatedProp } from '@shopify/react-native-skia'; import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import type { HighlightScope } from './context'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Default highlight scope for cartesian charts. + * Highlights by data index (x-axis position), not by series. + */ +export const defaultCartesianChartHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + /** * Shape variants available for legend items. */ @@ -329,7 +339,7 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultCartesianChartInset: ChartInset = { top: 32, left: 16, bottom: 16, diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 838c8b63a..45d776ba4 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -7,7 +7,7 @@ import { css } from '@linaria/core'; import { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; import { CartesianChartProvider } from './ChartProvider'; -import { Legend, type LegendProps } from './legend'; +import { Legend } from './legend'; import { type AxisConfig, type AxisConfigProps, @@ -15,7 +15,8 @@ import { type ChartInset, type ChartScaleFunction, defaultAxisId, - defaultChartInset, + defaultCartesianChartHighlightScope, + defaultCartesianChartInset, getAxisConfig, getAxisDomain, getAxisRange, @@ -23,6 +24,7 @@ import { getChartInset, getStackedSeriesData as calculateStackedSeriesData, type HighlightedItem, + type HighlightScope, type LegendPosition, type Series, useTotalAxisPadding, @@ -39,7 +41,7 @@ const focusStylesCss = css` `; export type CartesianChartBaseProps = Omit & - HighlightProps & { + Omit & { /** * Configuration objects that define how to visualize the data. * Each series contains its own data array. @@ -85,6 +87,11 @@ export type CartesianChartBaseProps = Omit & * - 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. */ @@ -145,7 +152,7 @@ export const CartesianChart = memo( inset, // Highlight props enableHighlighting, - highlightScope, + highlightScope = defaultCartesianChartHighlightScope, highlight, onHighlightChange, // Legacy props @@ -169,7 +176,10 @@ export const CartesianChart = memo( const { observe, width: chartWidth, height: chartHeight } = useDimensions(); const svgRef = useRef(null); - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Axis configs store the properties of each axis, such as id, scale type, domain limit, etc. // We only support 1 x axis but allow for multiple y axes. diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 7d3f9c333..4aa537c0c 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -9,7 +9,7 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartInset, type Series, @@ -114,7 +114,10 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 6aa15d856..3fa68dd4d 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -6,7 +6,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, defaultStackId, getChartInset } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + defaultStackId, + getChartInset, +} from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; import type { BarSeries } from './BarStack'; @@ -94,7 +99,10 @@ export const BarChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); const transformedSeries = useMemo(() => { if (!stacked || !series) return series; diff --git a/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx index dce4c2b33..35597712f 100644 --- a/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx +++ b/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx @@ -11,11 +11,6 @@ import { type ScrubberContextValue, } from '../utils'; -const defaultHighlightScope: HighlightScope = { - dataIndex: true, - series: false, -}; - /** * Props for configuring chart highlight behavior. * Used by CartesianChart and other chart components. @@ -23,24 +18,18 @@ const defaultHighlightScope: HighlightScope = { export type HighlightProps = { /** * Whether highlighting is enabled. - * @default true */ enableHighlighting?: boolean; /** * Controls what aspects of the data can be highlighted. - * @default { dataIndex: true, series: false } */ highlightScope?: HighlightScope; /** - * Controlled highlight state. - * - `undefined`: Uncontrolled mode (internal state is managed) - * - `[]`: Controlled mode with no highlights (gestures still fire onHighlightChange) - * - `HighlightedItem[]`: Controlled mode with specific highlighted items + * Pass a value to override the internal highlight state. */ highlight?: HighlightedItem[]; /** * Callback fired when the highlight changes during interaction. - * Always fires regardless of controlled/uncontrolled mode. */ onHighlightChange?: (items: HighlightedItem[]) => void; }; @@ -80,10 +69,13 @@ export const HighlightProvider: React.FC = ({ const { getXScale, getXAxis, series } = chartContext; - const enabled = enableHighlightingProp ?? true; + const enabled = enableHighlightingProp ?? false; const scope: HighlightScope = useMemo( - () => ({ ...defaultHighlightScope, ...scopeProp }), + () => ({ + dataIndex: scopeProp?.dataIndex ?? false, + series: scopeProp?.series ?? false, + }), [scopeProp], ); diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index c14c98df4..c3a448272 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -7,7 +7,12 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type AxisConfigProps, + defaultCartesianChartInset, + getChartInset, + type Series, +} from '../utils'; import { Line, type LineProps } from './Line'; @@ -108,7 +113,10 @@ export const LineChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => getChartInset(inset, defaultCartesianChartInset), + [inset], + ); // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { diff --git a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..93b171a86 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,7 +1,7 @@ import { type AxisBounds, type ChartInset, - defaultChartInset, + defaultCartesianChartInset, defaultStackId, getChartDomain, getChartInset, @@ -370,9 +370,9 @@ describe('isValidBounds', () => { }); }); -describe('defaultChartInset', () => { +describe('defaultCartesianChartInset', () => { it('should have correct default values', () => { - expect(defaultChartInset).toEqual({ + expect(defaultCartesianChartInset).toEqual({ top: 32, left: 16, bottom: 16, diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index 7bfee5d87..6ac3c6e6b 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -1,9 +1,19 @@ import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import type { HighlightScope } from './context'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Default highlight scope for cartesian charts. + * Highlights by data index (x-axis position), not by series. + */ +export const defaultCartesianChartHighlightScope: HighlightScope = { + dataIndex: true, + series: false, +}; + /** * Shape variants available for legend items. */ @@ -327,7 +337,7 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultCartesianChartInset: ChartInset = { top: 32, left: 16, bottom: 16, From 9d1489c6a5f3c6b98639aef121c4f3125d013841 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 29 Jan 2026 11:23:25 -0500 Subject: [PATCH 11/16] Fix scrubber types --- .../mobile-visualization/src/chart/scrubber/Scrubber.tsx | 6 +++--- packages/web-visualization/src/chart/scrubber/Scrubber.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index 86bd52a52..93bc82ad9 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx @@ -122,9 +122,9 @@ export type ScrubberBeaconProps = { stroke?: string; }; -export type ScrubberBeaconComponent = React.FC< - ScrubberBeaconProps & { ref?: React.Ref } ->; +export type ScrubberBeaconComponent = ( + props: ScrubberBeaconProps & { ref?: React.Ref }, +) => React.ReactNode; export type ScrubberBeaconLabelProps = Pick & Pick< diff --git a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx index 815f50919..64b3a0c29 100644 --- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx @@ -115,9 +115,9 @@ export type ScrubberBeaconProps = SharedProps & { style?: React.CSSProperties; }; -export type ScrubberBeaconComponent = React.FC< - ScrubberBeaconProps & { ref?: React.Ref } ->; +export type ScrubberBeaconComponent = ( + props: ScrubberBeaconProps & { ref?: React.Ref }, +) => React.ReactNode; export type ScrubberBeaconLabelProps = Pick & Pick< From b229625d1686ab76fb7bdc09237644fcfed5a62b Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 29 Jan 2026 13:52:47 -0500 Subject: [PATCH 12/16] Separate cartesianseries from series --- .../graphs/Highlighting/_mobileContent.mdx | 24 ++++-- .../graphs/Highlighting/_webContent.mdx | 30 ++++++-- .../src/chart/CartesianChart.tsx | 5 +- .../src/chart/ChartProvider.tsx | 21 +++++- .../src/chart/area/AreaChart.tsx | 6 +- .../src/chart/bar/BarStack.tsx | 4 +- .../legend/__stories__/Legend.stories.tsx | 4 +- .../src/chart/line/LineChart.tsx | 6 +- .../src/chart/utils/__tests__/chart.test.ts | 52 ++++++------- .../src/chart/utils/chart.ts | 54 ++++++++------ .../src/chart/utils/context.ts | 74 ++++++++++++------- .../src/chart/CartesianChart.tsx | 5 +- .../src/chart/ChartProvider.tsx | 21 +++++- .../src/chart/area/AreaChart.tsx | 8 +- .../src/chart/bar/BarStack.tsx | 4 +- .../legend/__stories__/Legend.stories.tsx | 4 +- .../src/chart/line/LineChart.tsx | 8 +- .../src/chart/utils/__tests__/chart.test.ts | 52 ++++++------- .../src/chart/utils/chart.ts | 54 ++++++++------ .../src/chart/utils/context.ts | 74 ++++++++++++------- 20 files changed, 321 insertions(+), 189 deletions(-) diff --git a/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx index 0baf42017..e31aef9e1 100644 --- a/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx +++ b/apps/docs/docs/components/graphs/Highlighting/_mobileContent.mdx @@ -25,7 +25,7 @@ Key features: ## Basic Usage -Highlighting is enabled by default on all Cartesian charts. Use the `onHighlightChange` callback to respond to user interactions. +To enable highlighting, set `enableHighlighting={true}` on your chart. Use the `onHighlightChange` callback to respond to user interactions. ```tsx import { LineChart, Scrubber } from '@coinbase/cds-mobile-visualization'; @@ -45,6 +45,7 @@ function BasicHighlighting() { {displayValue} (undefined); + const [highlight, setHighlight] = useState([]); const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; return ( @@ -90,12 +91,19 @@ function ControlledHighlighting() { - - + @@ -107,10 +115,14 @@ function ControlledHighlighting() { | `highlight` value | Behavior | | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `undefined` | **Uncontrolled mode** - Chart manages its own state | +| `undefined` | **Uncontrolled mode** - Chart manages its own state internally | | `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | | `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | +:::tip +In controlled mode, pass an empty array `[]` (not `undefined`) to clear highlights while staying in controlled mode. Using `undefined` switches back to uncontrolled mode. +::: + @@ -148,6 +160,7 @@ function SeriesHighlighting() { item.dataIndex !== null ? `Day ${item.dataIndex + 1}: $${data[item.dataIndex].toFixed(2)}` diff --git a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx index 62812a931..42c7385af 100644 --- a/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx +++ b/apps/docs/docs/components/graphs/Highlighting/_webContent.mdx @@ -25,7 +25,7 @@ Key features: ## Basic Usage -Highlighting is enabled by default on all Cartesian charts. Use the `onHighlightChange` callback to respond to user interactions. +To enable highlighting, set `enableHighlighting={true}` on your chart. Use the `onHighlightChange` callback to respond to user interactions. ```jsx live function BasicHighlighting() { @@ -51,6 +51,7 @@ function BasicHighlighting() { {displayValue} setHighlight([{ dataIndex: 13, seriesId: null }])}> Last - - Index: {highlight?.[0]?.dataIndex ?? 'none'} + Index: {highlight[0]?.dataIndex ?? 'none'} - + @@ -124,10 +131,14 @@ function ControlledHighlighting() { | `highlight` value | Behavior | | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `undefined` | **Uncontrolled mode** - Chart manages its own state | +| `undefined` | **Uncontrolled mode** - Chart manages its own state internally | | `[]` (empty array) | **Controlled mode** - No items highlighted, user interactions don't change the UI but `onHighlightChange` still fires | | `[{ dataIndex, seriesId }]` | **Controlled mode** - Specified items are highlighted | +:::tip +In controlled mode, pass an empty array `[]` (not `undefined`) to clear highlights while staying in controlled mode. Using `undefined` switches back to uncontrolled mode. +::: + :::tip Even in controlled mode, `onHighlightChange` still fires when the user interacts with the chart. This allows you to respond to user gestures without necessarily updating the controlled state. ::: @@ -198,6 +209,7 @@ function SeriesHighlighting() { = 0 ? 'positive' : 'negative'} selected={highlightedIndex === index} onMouseEnter={() => setHighlight([{ dataIndex: index, seriesId: null }])} - onMouseLeave={() => setHighlight(undefined)} + onMouseLeave={() => setHighlight([])} /> ))} @@ -343,6 +356,7 @@ function MultiTouchHighlighting() { ; + series?: Array; /** * Whether to animate the chart. * @default true @@ -453,6 +453,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile-visualization/src/chart/ChartProvider.tsx index 4d255c885..6849fb2fd 100644 --- a/packages/mobile-visualization/src/chart/ChartProvider.tsx +++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx @@ -1,9 +1,28 @@ import { createContext, useContext } from 'react'; -import type { CartesianChartContextValue } from './utils'; +import type { CartesianChartContextValue, ChartContextValue } from './utils'; 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/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index 6cdfc0515..96e7ebcdd 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -10,15 +10,15 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, + type CartesianSeries, defaultCartesianChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -123,7 +123,7 @@ export const AreaChart = memo( // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index 6bc56b4a9..fab04f851 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series, Transition } from '../utils'; +import type { CartesianSeries, ChartScaleFunction, Transition } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; @@ -15,7 +15,7 @@ const EPSILON = 1e-4; /** * Extended series type that includes bar-specific properties. */ -export type BarSeries = Series & { +export type BarSeries = CartesianSeries & { /** * Custom component to render bars for this series. */ diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx index 5890ad80b..2c51e4890 100644 --- a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -14,7 +14,7 @@ import { BarChart, BarPlot, DefaultBar } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { LineChart } from '../../line'; import { Scrubber } from '../../scrubber'; -import type { LegendShapeVariant, Series } from '../../utils/chart'; +import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart'; import { getDottedAreaPath } from '../../utils/path'; import { DefaultLegendShape } from '../DefaultLegendShape'; import { Legend, type LegendEntryProps } from '../Legend'; @@ -231,7 +231,7 @@ const DynamicData = () => { [], ); - const seriesConfig: Series[] = useMemo( + const seriesConfig: CartesianSeries[] = useMemo( () => [ { id: 'candidate-a', diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index c8cecc1bf..6c7c13b21 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -10,14 +10,14 @@ import { } from '../CartesianChart'; import { type AxisConfigProps, + type CartesianSeries, defaultCartesianChartInset, getChartInset, - type Series, } from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -119,7 +119,7 @@ export const LineChart = memo( // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts index 93b171a86..eab93f7bf 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,5 +1,6 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, defaultCartesianChartInset, defaultStackId, @@ -8,12 +9,11 @@ import { getChartRange, getStackedSeriesData, isValidBounds, - type Series, } from '../chart'; describe('getChartDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; @@ -23,7 +23,7 @@ describe('getChartDomain', () => { }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; @@ -33,14 +33,14 @@ describe('getChartDomain', () => { }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; const result = getChartDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); @@ -52,14 +52,14 @@ describe('getChartDomain', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; @@ -69,7 +69,7 @@ describe('getChartDomain', () => { }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, @@ -82,7 +82,7 @@ describe('getChartDomain', () => { describe('getStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; @@ -103,7 +103,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -125,7 +125,7 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -144,7 +144,7 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -166,7 +166,7 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; const result = getStackedSeriesData(series); @@ -179,14 +179,14 @@ describe('getStackedSeriesData', () => { }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId @@ -205,14 +205,14 @@ describe('getStackedSeriesData', () => { describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; @@ -222,7 +222,7 @@ describe('getChartRange', () => { }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -245,7 +245,7 @@ describe('getChartRange', () => { }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -260,14 +260,14 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; @@ -286,35 +286,35 @@ describe('getChartRange', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index e66c37d4d..0ec0bdc2d 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -44,11 +44,38 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +/** + * Base series type with common properties shared across all chart types. + * Used by generic chart components like HighlightProvider. + */ export type Series = { /** - * Id of the series. + * Unique identifier for the series. */ id: string; + /** + * Label of the series. + * Used for scrubber beacon labels and legend items. + */ + label?: string; + /** + * Color of the series. + * If gradient is provided, that will be used for chart components. + * Color will still be used by scrubber beacon labels. + */ + color?: string; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; +}; + +/** + * Series type for cartesian (X/Y) charts with axis-specific properties. + */ +export type CartesianSeries = Series & { /** * Data array for this series. Use null values to create gaps in the visualization. * @@ -57,17 +84,6 @@ export type Series = { * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs */ data?: Array | Array<[number, number] | null>; - /** - * Label of the series. - * Used for scrubber beacon labels. - */ - label?: string; - /** - * Color for the series. - * If gradient is provided, that will be used for chart components - * Color will still be used by scrubber beacon labels - */ - color?: string; /** * Color gradient configuration. * Takes precedence over color except for scrubber beacon labels. @@ -84,12 +100,6 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; - /** - * Shape of the legend item for this series. - * Can be a preset shape variant or a custom ReactNode. - * @default 'circle' - */ - legendShape?: LegendShape; }; /** @@ -97,7 +107,7 @@ export type Series = { * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -126,7 +136,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -142,7 +152,7 @@ const createStackKey = (series: Series): string | undefined => { * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( - series: Series[], + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -252,7 +262,7 @@ export const getLineData = ( * Handles stacking by transforming data when series have stack properties. */ export const getChartRange = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index 4a7268d25..eb9508e75 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -4,41 +4,73 @@ import type { Rect } from '@coinbase/cds-common/types'; import type { SkTypefaceFontProvider } from '@shopify/react-native-skia'; import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { CartesianSeries, Series } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; +/** + * Supported chart types. + */ +export type ChartType = 'cartesian'; + +/** + * Base context value shared by all chart types. + * Contains common properties like series and dimensions. + */ +export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + */ + series: Series[]; + /** + * Whether to animate the chart. + */ + animate: boolean; + /** + * Width of the chart. + */ + width: number; + /** + * Height of the chart. + */ + height: number; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; + /** + * Length of the data domain. + */ + dataLength: number; +}; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = { +export type CartesianChartContextValue = Omit & { + /** + * The chart type (always 'cartesian' for this context). + */ + type: 'cartesian'; /** * The series data for the chart. */ - series: Series[]; + series: CartesianSeries[]; /** * Returns the series which matches the seriesId or undefined. * @param seriesId - A series' id */ - getSeries: (seriesId?: string) => Series | undefined; + getSeries: (seriesId?: string) => CartesianSeries | undefined; /** * Returns the data for a series * @param seriesId - A series' id * @returns data for series, if series exists */ getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined; - /** - * Whether to animate the chart. - */ - animate: boolean; - /** - * Width of the chart SVG. - */ - width: number; - /** - * Height of the chart SVG. - */ - height: number; /** * Default font families to use within ChartText. * When not set, should use the default for the system. @@ -75,16 +107,6 @@ export type CartesianChartContextValue = { * @param id - The axis ID. Defaults to defaultAxisId. */ getYSerializableScale: (id?: string) => SerializableScale | undefined; - /** - * Drawing area of the chart. - */ - drawingArea: Rect; - /** - * Length of the data domain. - * This is equal to the length of xAxis.data or the longest series data length - * This equals the number of possible scrubber positions - */ - dataLength: number; /** * Registers an axis. * Used by axis components to reserve space in the chart, preventing overlap with the drawing area. diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 45d776ba4..a16268441 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -12,6 +12,7 @@ import { type AxisConfig, type AxisConfigProps, type CartesianChartContextValue, + type CartesianSeries, type ChartInset, type ChartScaleFunction, defaultAxisId, @@ -26,7 +27,6 @@ import { type HighlightedItem, type HighlightScope, type LegendPosition, - type Series, useTotalAxisPadding, } from './utils'; @@ -46,7 +46,7 @@ export type CartesianChartBaseProps = Omit & * Configuration objects that define how to visualize the data. * Each series contains its own data array. */ - series?: Array; + series?: Array; /** * Whether to animate the chart. * @default true @@ -400,6 +400,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + type: 'cartesian', series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx index 34ac00c48..9ed241b2c 100644 --- a/packages/web-visualization/src/chart/ChartProvider.tsx +++ b/packages/web-visualization/src/chart/ChartProvider.tsx @@ -1,9 +1,28 @@ import { createContext, useContext } from 'react'; -import type { CartesianChartContextValue } from './utils/context'; +import type { CartesianChartContextValue, ChartContextValue } from './utils/context'; 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/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 4aa537c0c..8f80c5a09 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -9,15 +9,15 @@ import { import { Line, type LineProps } from '../line/Line'; import { type AxisConfigProps, + type CartesianSeries, defaultCartesianChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -119,10 +119,10 @@ export const AreaChart = memo( [inset], ); - // Convert AreaSeries to Series for Chart context + // Convert AreaSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 64453e980..e993186e3 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series } from '../utils'; +import type { CartesianSeries, ChartScaleFunction } from '../utils'; import { evaluateGradientAtValue, getGradientConfig } from '../utils/gradient'; import { Bar, type BarComponent, type BarProps } from './Bar'; @@ -14,7 +14,7 @@ const EPSILON = 1e-4; /** * Extended series type that includes bar-specific properties. */ -export type BarSeries = Series & { +export type BarSeries = CartesianSeries & { /** * Custom component to render bars for this series. */ diff --git a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx index c0fb924d5..c3a381830 100644 --- a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -10,7 +10,7 @@ import { useCartesianChartContext } from '../../ChartProvider'; import { LineChart } from '../../line'; import { Scrubber } from '../../scrubber'; import { useScrubberContext } from '../../utils'; -import type { LegendShapeVariant, Series } from '../../utils/chart'; +import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart'; import { DefaultLegendShape } from '../DefaultLegendShape'; import { Legend, type LegendEntryProps } from '../Legend'; @@ -345,7 +345,7 @@ const DynamicData = () => { 'Dec', ]; - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'candidate-a', label: 'Candidate A', diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index c3a448272..d53a151e5 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -9,14 +9,14 @@ import { } from '../CartesianChart'; import { type AxisConfigProps, + type CartesianSeries, defaultCartesianChartInset, getChartInset, - type Series, } from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -118,10 +118,10 @@ export const LineChart = memo( [inset], ); - // Convert LineSeries to Series for Chart context + // Convert LineSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, diff --git a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts index 93b171a86..eab93f7bf 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,5 +1,6 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, defaultCartesianChartInset, defaultStackId, @@ -8,12 +9,11 @@ import { getChartRange, getStackedSeriesData, isValidBounds, - type Series, } from '../chart'; describe('getChartDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; @@ -23,7 +23,7 @@ describe('getChartDomain', () => { }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; @@ -33,14 +33,14 @@ describe('getChartDomain', () => { }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; const result = getChartDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); @@ -52,14 +52,14 @@ describe('getChartDomain', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; @@ -69,7 +69,7 @@ describe('getChartDomain', () => { }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, @@ -82,7 +82,7 @@ describe('getChartDomain', () => { describe('getStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; @@ -103,7 +103,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -125,7 +125,7 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -144,7 +144,7 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; @@ -166,7 +166,7 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; const result = getStackedSeriesData(series); @@ -179,14 +179,14 @@ describe('getStackedSeriesData', () => { }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId @@ -205,14 +205,14 @@ describe('getStackedSeriesData', () => { describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; @@ -222,7 +222,7 @@ describe('getChartRange', () => { }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -245,7 +245,7 @@ describe('getChartRange', () => { }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; @@ -260,14 +260,14 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; @@ -286,35 +286,35 @@ describe('getChartRange', () => { }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; const result = getChartRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; const result = getChartRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; const result = getChartRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index 6ac3c6e6b..971d37f65 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -42,30 +42,46 @@ export type AxisBounds = { export const isValidBounds = (bounds: Partial): bounds is AxisBounds => bounds.min !== undefined && bounds.max !== undefined; +/** + * Base series type with common properties shared across all chart types. + * Used by generic chart components like HighlightProvider. + */ export type Series = { /** - * Id of the series. + * Unique identifier for the series. */ id: string; - /** - * Data array for this series. Use null values to create gaps in the visualization. - * - * Can be either: - * - Array of numbers: `[10, -5, 20]` - * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs - */ - data?: Array | Array<[number, number] | null>; /** * Label of the series. - * Used for scrubber beacon labels. + * Used for scrubber beacon labels and legend items. */ label?: string; /** * Color of the series. - * If gradient is provided, that will be used for chart components - * Color will still be used by scrubber beacon labels + * If gradient is provided, that will be used for chart components. + * Color will still be used by scrubber beacon labels. */ color?: string; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; +}; + +/** + * Series type for cartesian (X/Y) charts with axis-specific properties. + */ +export type CartesianSeries = Series & { + /** + * Data array for this series. Use null values to create gaps in the visualization. + * + * Can be either: + * - Array of numbers: `[10, -5, 20]` + * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs + */ + data?: Array | Array<[number, number] | null>; /** * Color gradient configuration. * Takes precedence over color except for scrubber beacon labels. @@ -82,12 +98,6 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; - /** - * Shape of the legend item for this series. - * Can be a preset shape variant or a custom ReactNode. - * @default 'circle' - */ - legendShape?: LegendShape; }; /** @@ -95,7 +105,7 @@ export type Series = { * Domain represents the range of x-values from the data. */ export const getChartDomain = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -124,7 +134,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -140,7 +150,7 @@ const createStackKey = (series: Series): string | undefined => { * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( - series: Series[], + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -250,7 +260,7 @@ export const getLineData = ( * Handles stacking by transforming data when series have stack properties. */ export const getChartRange = ( - series: Series[], + series: CartesianSeries[], min?: number, max?: number, ): Partial => { diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 8fc286331..77526c485 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -2,41 +2,73 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { CartesianSeries, Series } from './chart'; import type { ChartScaleFunction } from './scale'; +/** + * Supported chart types. + */ +export type ChartType = 'cartesian'; + +/** + * Base context value shared by all chart types. + * Contains common properties like series and dimensions. + */ +export type ChartContextValue = { + /** + * The type of chart. + */ + type: ChartType; + /** + * The series data for the chart. + */ + series: Series[]; + /** + * Whether to animate the chart. + */ + animate: boolean; + /** + * Width of the chart. + */ + width: number; + /** + * Height of the chart. + */ + height: number; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; + /** + * Length of the data domain. + */ + dataLength: number; +}; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = { +export type CartesianChartContextValue = Omit & { + /** + * The chart type (always 'cartesian' for this context). + */ + type: 'cartesian'; /** * The series data for the chart. */ - series: Series[]; + series: CartesianSeries[]; /** * Returns the series which matches the seriesId or undefined. * @param seriesId - A series' id */ - getSeries: (seriesId?: string) => Series | undefined; + getSeries: (seriesId?: string) => CartesianSeries | undefined; /** * Returns the data for a series * @param seriesId - A series' id * @returns data for series, if series exists */ getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined; - /** - * Whether to animate the chart. - */ - animate: boolean; - /** - * Width of the chart SVG. - */ - width: number; - /** - * Height of the chart SVG. - */ - height: number; /** * Get x-axis configuration. */ @@ -55,16 +87,6 @@ export type CartesianChartContextValue = { * @param id - The axis ID. Defaults to defaultAxisId. */ getYScale: (id?: string) => ChartScaleFunction | undefined; - /** - * Drawing area of the chart. - */ - drawingArea: Rect; - /** - * Length of the data domain. - * This is equal to the length of xAxis.data or the longest series data length - * This equals the number of possible scrubber positions - */ - dataLength: number; /** * Registers an axis. * Used by axis components to reserve space in the chart, preventing overlap with the drawing area. From e2024af07c7a74e133bf9e7f2db50df9ba291855 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 29 Jan 2026 19:02:10 -0500 Subject: [PATCH 13/16] Improve highlight --- .../src/chart/CartesianChart.tsx | 2 +- .../{interaction => }/HighlightProvider.tsx | 273 +++++------------- .../src/chart/bar/DefaultBar.tsx | 14 +- .../mobile-visualization/src/chart/index.ts | 2 +- .../src/chart/interaction/index.ts | 1 - .../src/chart/utils/chart.ts | 2 +- .../src/chart/utils/context.ts | 153 ---------- .../src/chart/utils/highlight.ts | 45 +++ .../src/chart/utils/index.ts | 1 + .../src/chart/CartesianChart.tsx | 2 +- .../{interaction => }/HighlightProvider.tsx | 53 +++- .../chart/__stories__/Interaction.stories.tsx | 160 +--------- .../src/chart/bar/DefaultBar.tsx | 13 +- packages/web-visualization/src/chart/index.ts | 2 +- .../src/chart/interaction/index.ts | 1 - .../src/chart/line/DottedLine.tsx | 75 +---- .../web-visualization/src/chart/line/Line.tsx | 11 - .../src/chart/line/SolidLine.tsx | 75 +---- .../src/chart/utils/chart.ts | 2 +- .../src/chart/utils/context.ts | 81 ------ .../src/chart/utils/highlight.ts | 32 ++ .../src/chart/utils/index.ts | 1 + 22 files changed, 211 insertions(+), 790 deletions(-) rename packages/mobile-visualization/src/chart/{interaction => }/HighlightProvider.tsx (67%) delete mode 100644 packages/mobile-visualization/src/chart/interaction/index.ts create mode 100644 packages/mobile-visualization/src/chart/utils/highlight.ts rename packages/web-visualization/src/chart/{interaction => }/HighlightProvider.tsx (92%) delete mode 100644 packages/web-visualization/src/chart/interaction/index.ts create mode 100644 packages/web-visualization/src/chart/utils/highlight.ts diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index e2d90d198..e54d19d01 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -6,10 +6,10 @@ import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; -import { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; +import { type HighlightProps, HighlightProvider } from './HighlightProvider'; import { Legend } from './legend'; import { type AxisConfig, diff --git a/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/mobile-visualization/src/chart/HighlightProvider.tsx similarity index 67% rename from packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx rename to packages/mobile-visualization/src/chart/HighlightProvider.tsx index ab5448bf7..e3c2f6402 100644 --- a/packages/mobile-visualization/src/chart/interaction/HighlightProvider.tsx +++ b/packages/mobile-visualization/src/chart/HighlightProvider.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +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 { @@ -10,21 +10,54 @@ import { } from 'react-native-reanimated'; import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; -import { useCartesianChartContext } from '../ChartProvider'; -import { - type ElementBounds, - HighlightContext, - type HighlightContextValue, - type HighlightedItem, - type HighlightScope, - type InteractionRegistry, - invertSerializableScale, - type LinePath, - type PointBounds, - ScrubberContext, - type ScrubberContextValue, -} from '../utils'; -import { getPointOnSerializableScale } from '../utils/point'; +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; + /** + * Register a bar element for hit testing. + */ + registerBar: (bounds: BarBounds) => void; + /** + * Unregister a bar element. + */ + unregisterBar: (seriesId: string, dataIndex: 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; +}; export type HighlightProps = { /** @@ -61,10 +94,9 @@ export type HighlightProviderProps = HighlightProps & { * 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) - * - 'series': Each series is an accessible region * @default 'chunked' */ - accessibilityMode?: 'chunked' | 'item' | 'series'; + accessibilityMode?: 'chunked' | 'item'; /** * Number of accessible chunks when accessibilityMode is 'chunked'. * @default 10 @@ -74,7 +106,6 @@ export type HighlightProviderProps = HighlightProps & { /** * HighlightProvider manages chart highlighting state and gesture handling for mobile. - * It supports single and multi-touch interactions with configurable scope. */ export const HighlightProvider: React.FC = ({ children, @@ -103,62 +134,22 @@ export const HighlightProvider: React.FC = ({ [scopeProp], ); - // ============================================================================ - // Interaction Registry (for coordinate-based hit testing) - // ============================================================================ - - // Use ref to avoid re-renders when registering elements - const registryRef = useRef({ - bars: [], - points: [], - lines: [], - }); - - // Register a bar element for hit testing - const registerBar = useCallback((bounds: ElementBounds) => { - // Add to registry (elements are stored in render order) - registryRef.current.bars.push(bounds); + // Bar registry for hit testing (use ref to avoid re-renders) + const barsRef = useRef([]); + + const registerBar = useCallback((bounds: BarBounds) => { + barsRef.current.push(bounds); }, []); - // Unregister a bar element const unregisterBar = useCallback((seriesId: string, dataIndex: number) => { - registryRef.current.bars = registryRef.current.bars.filter( + barsRef.current = barsRef.current.filter( (bar) => !(bar.seriesId === seriesId && bar.dataIndex === dataIndex), ); }, []); - // Register a point element for hit testing - const registerPoint = useCallback((bounds: PointBounds) => { - registryRef.current.points.push(bounds); - }, []); - - // Unregister a point element - const unregisterPoint = useCallback((seriesId: string, dataIndex: number) => { - registryRef.current.points = registryRef.current.points.filter( - (point) => !(point.seriesId === seriesId && point.dataIndex === dataIndex), - ); - }, []); - - // Register a line path for hit testing - const registerLine = useCallback((path: LinePath) => { - // Replace existing line with same seriesId (path may update) - registryRef.current.lines = registryRef.current.lines.filter( - (line) => line.seriesId !== path.seriesId, - ); - registryRef.current.lines.push(path); - }, []); - - // Unregister a line path - const unregisterLine = useCallback((seriesId: string) => { - registryRef.current.lines = registryRef.current.lines.filter( - (line) => line.seriesId !== seriesId, - ); - }, []); - // Find bar at touch point (iterates in reverse for correct z-order) - const findBarAtPoint = useCallback((touchX: number, touchY: number): ElementBounds | null => { - const bars = registryRef.current.bars; - // Iterate in reverse order (last rendered = on top = checked first) + 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 ( @@ -173,40 +164,6 @@ export const HighlightProvider: React.FC = ({ return null; }, []); - // Find point at touch point - const findPointAtTouch = useCallback( - (touchX: number, touchY: number, touchTolerance: number = 10): PointBounds | null => { - const points = registryRef.current.points; - for (let i = points.length - 1; i >= 0; i--) { - const point = points[i]; - const distance = Math.sqrt(Math.pow(touchX - point.cx, 2) + Math.pow(touchY - point.cy, 2)); - if (distance <= point.radius + touchTolerance) { - return point; - } - } - return null; - }, - [], - ); - - // Find series at touch point (checks bars first, then points) - const findSeriesAtPoint = useCallback( - (touchX: number, touchY: number): string | null => { - // Check bars first - const hitBar = findBarAtPoint(touchX, touchY); - if (hitBar) return hitBar.seriesId; - - // Check points - const hitPoint = findPointAtTouch(touchX, touchY); - if (hitPoint) return hitPoint.seriesId; - - return null; - }, - [findBarAtPoint, findPointAtTouch], - ); - - // ============================================================================ - // Determine if we're in controlled mode const isControlled = controlledHighlight !== undefined; @@ -325,16 +282,19 @@ export const HighlightProvider: React.FC = ({ [isControlled, internalHighlight, onHighlightChange], ); - // Helper to create highlighted item with optional series hit testing (runs on JS thread) - const createHighlightedItemWithSeries = useCallback( + // Helper to create highlighted item with optional series hit testing + const createHighlightedItem = useCallback( (x: number, y: number, dataIndex: number | null): HighlightedItem => { let seriesId: string | null = null; if (scope.series) { - seriesId = findSeriesAtPoint(x, y); + const hitBar = findBarAtPoint(x, y); + if (hitBar) { + seriesId = hitBar.seriesId; + } } return { dataIndex, seriesId }; }, - [scope.series, findSeriesAtPoint], + [scope.series, findBarAtPoint], ); // Create the long press pan gesture for single touch @@ -349,9 +309,8 @@ export const HighlightProvider: React.FC = ({ // Android does not trigger onUpdate when the gesture starts if (Platform.OS === 'android') { const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - // Series hit testing runs on JS thread runOnJS((x: number, y: number, di: number | null) => { - const newItem = createHighlightedItemWithSeries(x, y, di); + const newItem = createHighlightedItem(x, y, di); const currentItems = internalHighlight.value; const currentItem = currentItems[0]; if ( @@ -368,9 +327,8 @@ export const HighlightProvider: React.FC = ({ }) .onUpdate(function onUpdate(event) { const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - // Series hit testing runs on JS thread runOnJS((x: number, y: number, di: number | null) => { - const newItem = createHighlightedItemWithSeries(x, y, di); + const newItem = createHighlightedItem(x, y, di); const currentItems = internalHighlight.value; const currentItem = currentItems[0]; if ( @@ -406,7 +364,7 @@ export const HighlightProvider: React.FC = ({ handleStartEndHaptics, getDataIndexFromX, scope.dataIndex, - createHighlightedItemWithSeries, + createHighlightedItem, internalHighlight, enableHighlighting, isControlled, @@ -414,84 +372,6 @@ export const HighlightProvider: React.FC = ({ ], ); - // Helper to process touches and create highlighted items (runs on JS thread) - const processMultiTouches = useCallback( - (touches: Array<{ x: number; y: number }>): HighlightedItem[] => { - return touches.map((touch) => { - const dataIndex = scope.dataIndex ? getDataIndexFromX(touch.x) : null; - let seriesId: string | null = null; - if (scope.series) { - seriesId = findSeriesAtPoint(touch.x, touch.y); - } - return { dataIndex, seriesId }; - }); - }, - [scope.dataIndex, scope.series, getDataIndexFromX, findSeriesAtPoint], - ); - - // Create multi-touch gesture - const multiTouchGesture = useMemo( - () => - Gesture.Manual() - .shouldCancelWhenOutside(!allowOverflowGestures) - .onTouchesDown(function onTouchesDown(event) { - runOnJS(handleStartEndHaptics)(); - - // Extract touch coordinates for JS thread processing - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalHighlight.value = items; - } - onHighlightChange?.(items); - })(touches); - }) - .onTouchesMove(function onTouchesMove(event) { - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalHighlight.value = items; - } - onHighlightChange?.(items); - })(touches); - }) - .onTouchesUp(function onTouchesUp(event) { - if (event.allTouches.length === 0) { - runOnJS(handleStartEndHaptics)(); - if (!isControlled) { - internalHighlight.value = []; - } - runOnJS(onHighlightChange ?? (() => {}))([]); - } else { - const touches = event.allTouches.map((t) => ({ x: t.x, y: t.y })); - runOnJS((touchData: Array<{ x: number; y: number }>) => { - const items = processMultiTouches(touchData); - if (!isControlled) { - internalHighlight.value = items; - } - onHighlightChange?.(items); - })(touches); - } - }) - .onTouchesCancelled(function onTouchesCancelled() { - if (!isControlled) { - internalHighlight.value = []; - } - runOnJS(onHighlightChange ?? (() => {}))([]); - }), - [ - allowOverflowGestures, - handleStartEndHaptics, - processMultiTouches, - internalHighlight, - isControlled, - onHighlightChange, - ], - ); - - // Use single touch gesture by default (multi-touch can be enabled via context if needed) const gesture = singleTouchGesture; const contextValue: HighlightContextValue = useMemo( @@ -502,23 +382,8 @@ export const HighlightProvider: React.FC = ({ setHighlight, registerBar, unregisterBar, - registerPoint, - unregisterPoint, - registerLine, - unregisterLine, }), - [ - enableHighlighting, - scope, - highlight, - setHighlight, - registerBar, - unregisterBar, - registerPoint, - unregisterPoint, - registerLine, - unregisterLine, - ], + [enableHighlighting, scope, highlight, setHighlight, registerBar, unregisterBar], ); // Derive scrubberPosition from internal highlight for backwards compatibility diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index 5f07d1ee1..85c93301e 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -2,8 +2,9 @@ import { memo, useEffect, useMemo } from 'react'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; +import { useHighlightContext } from '../HighlightProvider'; import { Path } from '../Path'; -import { getBarPath, useOptionalHighlightContext } from '../utils'; +import { getBarPath } from '../utils'; import type { BarComponentProps } from './Bar'; @@ -11,9 +12,7 @@ export type DefaultBarProps = BarComponentProps; /** * Default bar component that renders a solid bar with animation support. - * - * Automatically registers bounds for series highlighting hit testing when - * `highlightScope.series` is enabled. + * Registers bounds for series highlighting hit testing when `highlightScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -35,13 +34,13 @@ export const DefaultBar = memo( transition, }) => { const { animate } = useCartesianChartContext(); - const highlightContext = useOptionalHighlightContext(); + const highlightContext = useHighlightContext(); + const theme = useTheme(); // Register bar bounds for hit testing when series highlighting is enabled useEffect(() => { - if (!highlightContext?.scope.series || !seriesId) return; + if (!highlightContext.scope.series || !seriesId) return; - // Get the data index as a number const dataIndex = typeof dataX === 'number' ? dataX : 0; highlightContext.registerBar({ @@ -57,7 +56,6 @@ export const DefaultBar = memo( highlightContext.unregisterBar(seriesId, dataIndex); }; }, [x, y, width, height, dataX, seriesId, highlightContext]); - const theme = useTheme(); const defaultFill = fill || theme.color.fgPrimary; diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 195641c52..403e8f7cd 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -6,7 +6,7 @@ export * from './CartesianChart'; export * from './ChartContextBridge'; export * from './ChartProvider'; export * from './gradient'; -export * from './interaction'; +export * from './HighlightProvider'; export * from './legend'; export * from './line'; export * from './Path'; diff --git a/packages/mobile-visualization/src/chart/interaction/index.ts b/packages/mobile-visualization/src/chart/interaction/index.ts deleted file mode 100644 index a06940b19..000000000 --- a/packages/mobile-visualization/src/chart/interaction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './HighlightProvider'; diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 0ec0bdc2d..08bb0eea2 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -2,7 +2,7 @@ import { isSharedValue } from 'react-native-reanimated'; import type { AnimatedProp } from '@shopify/react-native-skia'; import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; -import type { HighlightScope } from './context'; +import type { HighlightScope } from './highlight'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index eb9508e75..375699d5c 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -147,156 +147,3 @@ export const useScrubberContext = (): ScrubberContextValue => { } return context; }; - -// ============================================================================ -// Highlighting Types (New API) -// ============================================================================ - -/** - * 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; -}; - -// ============================================================================ -// Interaction Registry Types (for coordinate-based hit testing on mobile) -// ============================================================================ - -/** - * Bounds of an interactive element (bar, point, etc.) - * Used for coordinate-based hit testing since Skia doesn't have native touch events. - */ -export type ElementBounds = { - x: number; - y: number; - width: number; - height: number; - dataIndex: number; - seriesId: string; -}; - -/** - * Bounds of a point (circle) element. - */ -export type PointBounds = { - cx: number; - cy: number; - radius: number; - dataIndex: number; - seriesId: string; -}; - -/** - * Line path for hit testing. - */ -export type LinePath = { - pathString: string; - seriesId: string; -}; - -/** - * Registry of interactive elements for coordinate-based hit testing. - * Elements are stored in render order (last = on top). - */ -export type InteractionRegistry = { - bars: ElementBounds[]; - points: PointBounds[]; - lines: LinePath[]; -}; - -/** - * Context value for chart highlighting state (mobile). - * Uses SharedValue for UI thread performance. - */ -export type HighlightContextValue = { - /** - * Whether highlighting is enabled. - */ - enabled: boolean; - /** - * The highlight scope configuration. - */ - scope: HighlightScope; - /** - * The current highlighted item(s) during interaction. - * SharedValue - */ - highlight: SharedValue; - /** - * Function to programmatically set the highlighted items. - */ - setHighlight: (items: HighlightedItem[]) => void; - /** - * Register a bar element for hit testing. - */ - registerBar: (bounds: ElementBounds) => void; - /** - * Unregister a bar element. - */ - unregisterBar: (seriesId: string, dataIndex: number) => void; - /** - * Register a point element for hit testing. - */ - registerPoint: (bounds: PointBounds) => void; - /** - * Unregister a point element. - */ - unregisterPoint: (seriesId: string, dataIndex: number) => void; - /** - * Register a line path for hit testing. - */ - registerLine: (path: LinePath) => void; - /** - * Unregister a line path. - */ - unregisterLine: (seriesId: string) => 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; -}; - -/** - * Hook to optionally access the highlight context. - * Returns undefined if not within a HighlightProvider. - */ -export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { - return useContext(HighlightContext); -}; diff --git a/packages/mobile-visualization/src/chart/utils/highlight.ts b/packages/mobile-visualization/src/chart/utils/highlight.ts new file mode 100644 index 000000000..34277ce86 --- /dev/null +++ b/packages/mobile-visualization/src/chart/utils/highlight.ts @@ -0,0 +1,45 @@ +/** + * Controls what aspects of the data can be highlighted. + */ +export type HighlightScope = { + /** + * Whether highlighting tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether highlighting tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * Represents a single highlighted item during interaction. + * - `null` values mean the user is interacting but not over a specific item/series + */ +export type HighlightedItem = { + /** + * The data index (x-axis position) being highlighted. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being highlighted. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; + +/** + * Bounds of a bar element for hit testing. + * Used for coordinate-based hit testing since Skia doesn't have native touch events. + */ +export type BarBounds = { + x: number; + y: number; + width: number; + height: number; + dataIndex: number; + seriesId: string; +}; diff --git a/packages/mobile-visualization/src/chart/utils/index.ts b/packages/mobile-visualization/src/chart/utils/index.ts index 0bf7ad953..ff6f8088a 100644 --- a/packages/mobile-visualization/src/chart/utils/index.ts +++ b/packages/mobile-visualization/src/chart/utils/index.ts @@ -4,6 +4,7 @@ export * from './bar'; export * from './chart'; export * from './context'; export * from './gradient'; +export * from './highlight'; export * from './path'; export * from './point'; export * from './scale'; diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index a16268441..2e1d1c462 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -5,7 +5,7 @@ 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 { type HighlightProps, HighlightProvider } from './interaction/HighlightProvider'; +import { type HighlightProps, HighlightProvider } from './HighlightProvider'; import { CartesianChartProvider } from './ChartProvider'; import { Legend } from './legend'; import { diff --git a/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx b/packages/web-visualization/src/chart/HighlightProvider.tsx similarity index 92% rename from packages/web-visualization/src/chart/interaction/HighlightProvider.tsx rename to packages/web-visualization/src/chart/HighlightProvider.tsx index 35597712f..20c68fba1 100644 --- a/packages/web-visualization/src/chart/interaction/HighlightProvider.tsx +++ b/packages/web-visualization/src/chart/HighlightProvider.tsx @@ -1,15 +1,44 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -import { useCartesianChartContext } from '../ChartProvider'; -import { - HighlightContext, - type HighlightContextValue, - type HighlightedItem, - type HighlightScope, - isCategoricalScale, - ScrubberContext, - type ScrubberContextValue, -} from '../utils'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, 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 update the highlight state. + */ + setHighlight: (items: HighlightedItem[]) => 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. diff --git a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx index 9c8bc634f..ecb19edb4 100644 --- a/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/Interaction.stories.tsx @@ -10,8 +10,9 @@ 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 { useHighlightContext, useScrubberContext } from '../utils'; +import { useScrubberContext } from '../utils'; export default { title: 'Components/Chart/Interaction', @@ -711,160 +712,3 @@ export function OverlappingBarsZOrder() { ); } - -// Custom line component that fades when another series is active, and grows when it's the active one -type FadeableLineProps = React.ComponentProps & { - /** Stroke width when this line is the active series. Defaults to strokeWidth * 2 */ - activeStrokeWidth?: number; -}; - -const FadeableLine = memo( - ({ seriesId, strokeWidth = 2, activeStrokeWidth, ...props }) => { - const highlightContext = useHighlightContext(); - const activeSeriesId = highlightContext.highlight[0]?.seriesId ?? null; - - // Determine if this line is active, faded, or neutral - const isActive = activeSeriesId === seriesId; - const isFaded = activeSeriesId !== null && !isActive; - - // Active: larger stroke (default 2x), Faded: reduced opacity, Neutral: normal - const effectiveActiveStrokeWidth = activeStrokeWidth ?? strokeWidth * 2; - const effectiveStrokeWidth = isActive ? effectiveActiveStrokeWidth : strokeWidth; - const effectiveOpacity = isFaded ? 0.2 : 1; - - return ( - - ); - }, -); - -/** - * Line series highlighting with interactionOffset for larger hit area - */ -export function LineSeriesHighlighting() { - const [highlight, setHighlight] = useState([]); - const [interactionOffset, setInteractionOffset] = useState(8); - const [enableFade, setEnableFade] = useState(true); - - const seriesColors: Record = { - btc: 'var(--color-fgPrimary)', - eth: 'var(--color-fgPositive)', - sol: 'var(--color-fgWarning)', - }; - - // Generate some sample data - const btcData = useMemo(() => samplePrices.slice(0, 20), []); - const ethData = useMemo(() => btcData.map((p) => p * 0.7 + Math.random() * 500), [btcData]); - const solData = useMemo(() => btcData.map((p) => p * 0.4 + Math.random() * 300), [btcData]); - - return ( - - - Line Series Highlighting - - - Hover over the lines to highlight a specific series. Other lines fade out when one is - active. - - - - interactionOffset: - {[0, 4, 8, 16].map((offset) => ( - - ))} - - - - - - - - - {highlight.length > 0 ? ( - <> - Index: {highlight[0]?.dataIndex ?? 'none'} - {highlight[0]?.seriesId && ( - <> - {' '} - | Series:{' '} - - {highlight[0].seriesId} - - - )} - - ) : ( - 'Hover over a line...' - )} - - - Hit area = strokeWidth (2) + interactionOffset ({interactionOffset}) × 2 ={' '} - {2 + interactionOffset * 2}px - - - - - - - - - {/* Wrap Scrubber with pointer-events: none so it doesn't block line interactions */} - - - - - - - {Object.entries(seriesColors).map(([id, color]) => ( - - - {id.toUpperCase()} - - ))} - - - ); -} diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index 8f95cad26..ea1321877 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -2,7 +2,8 @@ import React, { memo, useCallback, useMemo } from 'react'; import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import { getBarPath, useOptionalHighlightContext } from '../utils'; +import { useHighlightContext } from '../HighlightProvider'; +import { getBarPath } from '../utils'; import type { BarComponentProps } from './Bar'; @@ -39,7 +40,7 @@ export const DefaultBar = memo( ...props }) => { const { animate } = useCartesianChartContext(); - const highlightContext = useOptionalHighlightContext(); + const highlightContext = useHighlightContext(); const initialPath = useMemo(() => { if (!animate) return undefined; @@ -53,8 +54,7 @@ export const DefaultBar = memo( const dataIndex = typeof dataX === 'number' ? dataX : null; const handleMouseEnter = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; + if (!highlightContext.enabled || !highlightContext.scope.series) return; highlightContext.setHighlight([ { @@ -65,8 +65,7 @@ export const DefaultBar = memo( }, [highlightContext, dataIndex, seriesId]); const handleMouseLeave = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; + if (!highlightContext.enabled || !highlightContext.scope.series) return; // Reset to just dataIndex (keep dataIndex tracking, clear series) if (highlightContext.scope.dataIndex) { @@ -82,7 +81,7 @@ export const DefaultBar = memo( }, [highlightContext, dataIndex, seriesId]); // Only add event handlers when series scope is enabled - const eventHandlers = highlightContext?.scope.series + const eventHandlers = highlightContext.scope.series ? { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 2ea34f256..55ca22b94 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -5,7 +5,7 @@ export * from './bar/index'; export * from './CartesianChart'; export * from './ChartProvider'; export * from './gradient/index'; -export * from './interaction/index'; +export * from './HighlightProvider'; export * from './legend/index'; export * from './line/index'; export * from './Path'; diff --git a/packages/web-visualization/src/chart/interaction/index.ts b/packages/web-visualization/src/chart/interaction/index.ts deleted file mode 100644 index a06940b19..000000000 --- a/packages/web-visualization/src/chart/interaction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './HighlightProvider'; diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 8680ce0f1..3a0acbb92 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -1,9 +1,8 @@ -import { memo, type SVGProps, useCallback, useId } from 'react'; +import { memo, type SVGProps, useId } from 'react'; import type { SharedProps } from '@coinbase/cds-common/types'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; -import { useOptionalHighlightContext } from '../utils'; import type { LineComponentProps } from './Line'; @@ -27,7 +26,6 @@ export type DottedLineProps = SharedProps & /** * A customizable dotted line component. * Supports gradient for gradient effects on the dots. - * Automatically tracks series highlighting when `highlightScope.series` is enabled. */ export const DottedLine = memo( ({ @@ -38,7 +36,6 @@ export const DottedLine = memo( strokeLinejoin = 'round', strokeOpacity = 1, strokeWidth = 2, - interactionOffset, vectorEffect = 'non-scaling-stroke', gradient, yAxisId, @@ -49,52 +46,6 @@ export const DottedLine = memo( ...props }) => { const gradientId = useId(); - const highlightContext = useOptionalHighlightContext(); - - // Series highlight handlers - const handleMouseEnter = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; - - // Get current dataIndex from highlight (preserve it) - const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - - highlightContext.setHighlight([ - { - dataIndex: currentDataIndex, - seriesId: seriesId ?? null, - }, - ]); - }, [highlightContext, seriesId]); - - const handleMouseLeave = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; - - // Get current dataIndex from highlight (preserve it) - const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - - // Reset seriesId but keep dataIndex tracking - if (highlightContext.scope.dataIndex) { - highlightContext.setHighlight([ - { - dataIndex: currentDataIndex, - seriesId: null, - }, - ]); - } else { - highlightContext.setHighlight([]); - } - }, [highlightContext, seriesId]); - - // Determine if we need event handling (series highlighting enabled with a seriesId) - const needsEventHandling = highlightContext?.scope.series && seriesId; - - // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) - const eventPathStrokeWidth = - interactionOffset && interactionOffset > 0 - ? strokeWidth + interactionOffset * 2 - : strokeWidth; return ( <> @@ -109,7 +60,6 @@ export const DottedLine = memo( /> )} - {/* Visible dotted line - pointerEvents disabled when we have event handling layer */} ( strokeLinejoin={strokeLinejoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} - style={{ - ...props.style, - pointerEvents: needsEventHandling ? 'none' : undefined, - cursor: needsEventHandling ? 'pointer' : undefined, - }} transition={transition} vectorEffect={vectorEffect} /> - {/* - Event handling layer - use raw instead of framer-motion Path component - because motion.path doesn't reliably forward mouse events. - Uses eventPathStrokeWidth which includes interactionOffset when specified. - */} - {needsEventHandling && ( - - )} ); }, diff --git a/packages/web-visualization/src/chart/line/Line.tsx b/packages/web-visualization/src/chart/line/Line.tsx index 678f15d56..72c5c24d1 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -97,16 +97,6 @@ export type LineBaseProps = SharedProps & { * @default 2 */ strokeWidth?: number; - /** - * Additional pixels to add to each side of the stroke for interaction hit area. - * When set, renders an invisible path with a larger stroke width to make the line - * easier to interact with. Only active when `interactionScope.series` is enabled. - * - * @example - * // A 2px visible line with a 10px hit area (2 + 4*2 = 10px) - * - */ - interactionOffset?: number; /** * Gradient configuration. * When provided, creates gradient or threshold-based coloring. @@ -144,7 +134,6 @@ export type LineComponentProps = Pick< | 'stroke' | 'strokeOpacity' | 'strokeWidth' - | 'interactionOffset' | 'gradient' | 'animate' | 'transition' diff --git a/packages/web-visualization/src/chart/line/SolidLine.tsx b/packages/web-visualization/src/chart/line/SolidLine.tsx index c283b1e0e..9d6b2dc01 100644 --- a/packages/web-visualization/src/chart/line/SolidLine.tsx +++ b/packages/web-visualization/src/chart/line/SolidLine.tsx @@ -1,9 +1,8 @@ -import { memo, type SVGProps, useCallback, useId } from 'react'; +import { memo, type SVGProps, useId } from 'react'; import type { SharedProps } from '@coinbase/cds-common/types'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; -import { useOptionalHighlightContext } from '../utils'; import type { LineComponentProps } from './Line'; @@ -26,7 +25,6 @@ export type SolidLineProps = SharedProps & /** * A customizable solid line component. * Supports gradient for gradient effects and smooth data transitions. - * Automatically tracks series highlighting when `highlightScope.series` is enabled. */ export const SolidLine = memo( ({ @@ -36,7 +34,6 @@ export const SolidLine = memo( strokeLinejoin = 'round', strokeOpacity = 1, strokeWidth = 2, - interactionOffset, gradient, yAxisId, seriesId, @@ -46,52 +43,6 @@ export const SolidLine = memo( ...props }) => { const gradientId = useId(); - const highlightContext = useOptionalHighlightContext(); - - // Series highlight handlers - const handleMouseEnter = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; - - // Get current dataIndex from highlight (preserve it) - const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - - highlightContext.setHighlight([ - { - dataIndex: currentDataIndex, - seriesId: seriesId ?? null, - }, - ]); - }, [highlightContext, seriesId]); - - const handleMouseLeave = useCallback(() => { - if (!highlightContext || !highlightContext.enabled) return; - if (!highlightContext.scope.series) return; - - // Get current dataIndex from highlight (preserve it) - const currentDataIndex = highlightContext.highlight[0]?.dataIndex ?? null; - - // Reset seriesId but keep dataIndex tracking - if (highlightContext.scope.dataIndex) { - highlightContext.setHighlight([ - { - dataIndex: currentDataIndex, - seriesId: null, - }, - ]); - } else { - highlightContext.setHighlight([]); - } - }, [highlightContext, seriesId]); - - // Determine if we need event handling (series highlighting enabled with a seriesId) - const needsEventHandling = highlightContext?.scope.series && seriesId; - - // Calculate event handler path stroke width (with optional interactionOffset for larger hit area) - const eventPathStrokeWidth = - interactionOffset && interactionOffset > 0 - ? strokeWidth + interactionOffset * 2 - : strokeWidth; return ( <> @@ -106,7 +57,6 @@ export const SolidLine = memo( /> )} - {/* Visible line - pointerEvents disabled when we have event handling layer */} ( strokeLinejoin={strokeLinejoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} - style={{ - ...props.style, - pointerEvents: needsEventHandling ? 'none' : undefined, - cursor: needsEventHandling ? 'pointer' : undefined, - }} transition={transition} /> - {/* - Event handling layer - use raw instead of framer-motion Path component - because motion.path doesn't reliably forward mouse events. - Uses eventPathStrokeWidth which includes interactionOffset when specified. - */} - {needsEventHandling && ( - - )} ); }, diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index 971d37f65..80b740e01 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -1,6 +1,6 @@ import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; -import type { HighlightScope } from './context'; +import type { HighlightScope } from './highlight'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 77526c485..c8ea1ee00 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -132,84 +132,3 @@ export const useScrubberContext = (): ScrubberContextValue => { } return context; }; - -// ============================================================================ -// Highlight Types -// ============================================================================ - -/** - * 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; -}; - -/** - * 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 update the highlight state. - */ - setHighlight: (items: HighlightedItem[]) => 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; -}; - -/** - * Hook to optionally access the highlight context. - * Returns undefined if not within a HighlightProvider. - */ -export const useOptionalHighlightContext = (): HighlightContextValue | undefined => { - return useContext(HighlightContext); -}; diff --git a/packages/web-visualization/src/chart/utils/highlight.ts b/packages/web-visualization/src/chart/utils/highlight.ts new file mode 100644 index 000000000..2f7e3e723 --- /dev/null +++ b/packages/web-visualization/src/chart/utils/highlight.ts @@ -0,0 +1,32 @@ +/** + * Controls what aspects of the data can be highlighted. + */ +export type HighlightScope = { + /** + * Whether highlighting tracks data index (x-axis position). + * @default true + */ + dataIndex?: boolean; + /** + * Whether highlighting tracks specific series. + * @default false + */ + series?: boolean; +}; + +/** + * Represents a single highlighted item. + * `null` values mean the user is interacting but not over a specific item/series. + */ +export type HighlightedItem = { + /** + * The data index (x-axis position) being highlighted. + * `null` when interacting but not over a data point. + */ + dataIndex: number | null; + /** + * The series ID being highlighted. + * `null` when series scope is disabled or not over a specific series. + */ + seriesId: string | null; +}; diff --git a/packages/web-visualization/src/chart/utils/index.ts b/packages/web-visualization/src/chart/utils/index.ts index a02719070..28d4decf5 100644 --- a/packages/web-visualization/src/chart/utils/index.ts +++ b/packages/web-visualization/src/chart/utils/index.ts @@ -4,6 +4,7 @@ export * from './bar'; export * from './chart'; export * from './context'; export * from './gradient'; +export * from './highlight'; export * from './interpolate'; export * from './path'; export * from './point'; From 3ff34e07044d748a0c7ef8c9c6db29d6a9379cdc Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 29 Jan 2026 19:08:18 -0500 Subject: [PATCH 14/16] Cleanup web line --- .../src/chart/line/DottedLine.tsx | 3 +-- .../web-visualization/src/chart/line/Line.tsx | 27 +++---------------- .../src/chart/line/SolidLine.tsx | 3 +-- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 3a0acbb92..404720344 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -39,7 +39,6 @@ export const DottedLine = memo( vectorEffect = 'non-scaling-stroke', gradient, yAxisId, - seriesId, animate, transition, d, @@ -61,7 +60,6 @@ export const DottedLine = memo( )} ( strokeWidth={strokeWidth} transition={transition} vectorEffect={vectorEffect} + {...props} /> ); diff --git a/packages/web-visualization/src/chart/line/Line.tsx b/packages/web-visualization/src/chart/line/Line.tsx index 72c5c24d1..a58c5b835 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -150,11 +150,6 @@ export type LineComponentProps = Pick< * If not provided, defaults to the default y-axis. */ yAxisId?: string; - /** - * The series ID this line belongs to. - * Used for interaction tracking when `interactionScope.series` is true. - */ - seriesId?: string; }; export type LineComponent = React.FC; @@ -187,11 +182,7 @@ export const Line = memo( () => gradientProp ?? matchedSeries?.gradient, [gradientProp, matchedSeries?.gradient], ); - const sourceData = useMemo(() => { - const data = getSeriesData(seriesId); - console.log('[Line] sourceData for seriesId:', seriesId, 'length:', data?.length); - return data; - }, [getSeriesData, seriesId]); + const sourceData = useMemo(() => getSeriesData(seriesId), [getSeriesData, seriesId]); const xAxis = useMemo(() => getXAxis(), [getXAxis]); const xScale = useMemo(() => getXScale(), [getXScale]); @@ -201,11 +192,7 @@ export const Line = memo( ); // Convert sourceData to number array (line only supports numbers, not tuples) - const chartData = useMemo(() => { - const data = getLineData(sourceData); - console.log('[Line] chartData length:', data.length); - return data; - }, [sourceData]); + const chartData = useMemo(() => getLineData(sourceData), [sourceData]); const path = useMemo(() => { if (!xScale || !yScale || chartData.length === 0) return ''; @@ -216,7 +203,7 @@ export const Line = memo( ? (xAxis.data as number[]) : undefined; - const result = getLinePath({ + return getLinePath({ data: chartData, xScale, yScale, @@ -224,13 +211,6 @@ export const Line = memo( xData, connectNulls, }); - console.log( - '[Line] path computed, chartData.length:', - chartData.length, - 'path length:', - result.length, - ); - return result; }, [chartData, xScale, yScale, curve, xAxis?.data, connectNulls]); const LineComponent = useMemo((): LineComponent => { @@ -290,7 +270,6 @@ export const Line = memo( ( strokeWidth = 2, gradient, yAxisId, - seriesId, animate, transition, d, @@ -58,7 +57,6 @@ export const SolidLine = memo( )} ( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + {...props} /> ); From 26eb18465de23ac53c385675de8f00b07d8e3ff0 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Thu, 26 Feb 2026 08:39:56 -0500 Subject: [PATCH 15/16] Continue work --- .../graphs/BarChart/_webExamples.mdx | 410 +++++++++++++++ .../graphs/LineChart/_mobileExamples.mdx | 415 ++++++++++++++-- .../graphs/LineChart/_webExamples.mdx | 428 +++++++++++++++- .../src/chart/ChartContextBridge.tsx | 2 + .../src/chart/HighlightProvider.tsx | 252 ++++++---- .../mobile-visualization/src/chart/Path.tsx | 4 +- .../src/chart/bar/Bar.tsx | 7 + .../src/chart/bar/BarChart.tsx | 3 + .../src/chart/bar/BarPlot.tsx | 3 + .../src/chart/bar/BarStack.tsx | 4 +- .../src/chart/bar/BarStackGroup.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 36 +- .../bar/__stories__/BarChart.stories.tsx | 186 ++++++- .../line/__stories__/LineChart.stories.tsx | 467 ++++++++++++++++-- packages/mobile/src/controls/TextInput.tsx | 2 +- .../src/chart/CartesianChart.tsx | 2 +- .../src/chart/HighlightProvider.tsx | 303 +++++------- .../web-visualization/src/chart/bar/Bar.tsx | 7 + .../src/chart/bar/BarChart.tsx | 3 + .../src/chart/bar/BarPlot.tsx | 3 + .../src/chart/bar/BarStack.tsx | 4 +- .../src/chart/bar/BarStackGroup.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 105 ++-- .../line/__stories__/LineChart.stories.tsx | 317 +++++------- .../chart/utils/__tests__/transition.test.ts | 51 +- .../src/chart/utils/transition.ts | 6 + 26 files changed, 2395 insertions(+), 627 deletions(-) diff --git a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx index 790d490f6..9de3ef5af 100644 --- a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx @@ -490,6 +490,416 @@ function MonthlyRewards() { }; ``` +## Interaction + +Bar charts support the same highlighting system as line charts. Enable highlighting with `enableHighlighting` and listen to changes with `onHighlightChange`. + +### Basic Highlighting + +Highlighting tracks the data index (x-axis position) as the user moves their pointer across the chart. + +```jsx live +function BasicHighlighting() { + const [highlightedIndex, setHighlightedIndex] = useState(null); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const data = [45, 80, 120, 95, 150, 110, 85]; + + const handleHighlightChange = useCallback((items) => { + const index = items[0]?.dataIndex ?? null; + setHighlightedIndex(index); + }, []); + + return ( + + + {highlightedIndex !== null + ? `${days[highlightedIndex]}: ${data[highlightedIndex]} visits` + : 'Hover or touch to explore'} + + + + ); +} +``` + +### With Scrubber + +Adding a `Scrubber` component displays a vertical line at the highlighted position, just like in line charts. + +```jsx live +function BarChartWithScrubber() { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const revenue = [345, 510, 280, 720, 655, 410, 580, 815, 740, 910, 975, 620]; + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount), + [], + ); + + const scrubberLabel = useCallback( + (index) => `${months[index]}: ${tickFormatter(revenue[index])}`, + [months, revenue, tickFormatter], + ); + + return ( + + + + ); +} +``` + +### Multi-Series with Scrubber + +Highlighting works with multiple series. The scrubber shows beacons for each series at the highlighted data index. + +```jsx live +function MultiSeriesHighlighting() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const revenueData = [455, 520, 380, 455, 285, 235]; + const costData = [270, 425, 190, 380, 210, 150]; + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount), + [], + ); + + const scrubberLabel = useCallback( + (index) => + `${months[index]}: Revenue ${tickFormatter(revenueData[index])}, Cost ${tickFormatter(costData[index])}`, + [months, revenueData, costData, tickFormatter], + ); + + return ( + + + + ); +} +``` + +### Controlled Highlighting + +You can control the highlight state externally using the `highlight` and `onHighlightChange` props. +Passing `highlight` puts the chart in controlled mode — useful for synchronizing multiple charts or driving highlight from external UI. + +```jsx live +function ControlledHighlighting() { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const data = [45, 80, 120, 95, 150, 110, 85]; + const [highlight, setHighlight] = useState([]); + + const handleHighlightChange = useCallback((items) => { + setHighlight(items); + }, []); + + const highlightedIndex = highlight[0]?.dataIndex ?? null; + + return ( + + + {days.map((day, i) => ( + + ))} + + + + + + ); +} +``` + +### Series Scope Highlighting + +When `highlightScope` includes `series: true`, hovering individual bars will identify which series is being interacted with. +The `seriesId` is included in the highlight callback. + +```jsx live +function SeriesScopeHighlighting() { + const [info, setInfo] = useState('Hover a bar to see series info'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + const handleHighlightChange = useCallback( + (items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const seriesLabel = item.seriesId ? `Series: ${item.seriesId}` : 'No series detected'; + setInfo(`${months[item.dataIndex]} — ${seriesLabel}`); + } else { + setInfo('Hover a bar to see series info'); + } + }, + [months], + ); + + return ( + + {info} + + + + + ); +} +``` + +### Fade on Highlight + +Set `fadeOnHighlight` to dim non-highlighted bars during interaction. This makes the currently highlighted data index stand out. + +```jsx live +function FadeOnHighlight() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +} +``` + +When `highlightScope` includes `series: true`, the fade logic also considers which series is highlighted. +Only the bars matching both the data index and series remain at full opacity. + +```jsx live +function FadeWithSeriesScope() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const [info, setInfo] = useState('Hover a bar to highlight it'); + + const handleHighlightChange = useCallback( + (items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const series = item.seriesId ?? 'none'; + setInfo(`${months[item.dataIndex]} — Series: ${series}`); + } else { + setInfo('Hover a bar to highlight it'); + } + }, + [months], + ); + + return ( + + {info} + + + ); +} +``` + ## Customization ### Bar Spacing diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx index 70b86716a..eee6b8213 100644 --- a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx @@ -1857,53 +1857,396 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], + const Example = memo(() => { + const defaultHighlight = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [currentIndex], ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], ); - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); - - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); - return ( - - - - - - - - + + + + + + ); }); + return ; +} +``` + +### Highlighted Line Segments + +You can use `gradient` with dynamic stops to highlight specific segments of a line based on scrubber position. + +```jsx live +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleHighlightChange = useCallback((items) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x', + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x', stops }; + }, [monthStart, monthEnd, prices.length]); + return ( - - - - - - + + + ); +} +``` + +### Adaptive Detail + +You can show sampled data at rest for performance and switch to full-resolution data when the user begins scrubbing, providing an adaptive level of detail. + +```jsx live +function AdaptiveDetail() { + const BTCTab = memo( + forwardRef(({ label, ...props }, ref) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + + const BTCActiveIndicator = memo(({ style, ...props }) => ( + + )); + + const chartTransition = useMemo(() => ({ duration: 150 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }) => { + return ( + + + + ); + }, ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues = []; + const sampledTimestamps = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date, periodId) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + ); + }); + + return ; } ``` diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx index 6a8fa0cc8..e1de3a94f 100644 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx @@ -1882,39 +1882,419 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { highlight } = useHighlightContext(); - const isHighlighting = highlight.length > 0 && highlight[0]?.dataIndex !== null; - // We need a fade in animation for the Scrubber + const Example = memo(() => { + const defaultHighlight = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [currentIndex], + ); + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], + ); + return ( - - - - - - - - + + + + +
); }); + return ; +} +``` + +### Highlighted Line Segments + +You can use `gradient` with dynamic stops to highlight specific segments of a line based on scrubber position. + +```jsx live +function HighlightLineSegments() { + const prices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const [scrubberPosition, setScrubberPosition] = useState(undefined); + + const handleHighlightChange = useCallback((items) => { + setScrubberPosition(items[0]?.dataIndex ?? undefined); + }, []); + + // Calculate which month (~30-day segment) the scrubber is in + const dataPointsPerMonth = 30; + const currentMonth = + scrubberPosition !== undefined ? Math.floor(scrubberPosition / dataPointsPerMonth) : undefined; + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, prices.length - 1) + : undefined; + + // Create gradient to highlight the current month + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x', + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: prices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < prices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: prices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x', stops }; + }, [monthStart, monthEnd, prices.length]); + return ( - - - - - - + +
+ ); +} +``` + +### Adaptive Detail + +You can show sampled data at rest for performance and switch to full-resolution data when the user begins scrubbing, providing an adaptive level of detail. + +```jsx live +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef( + ({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }, + ), ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + type MemoizedChartProps = { + highlight: HighlightedItem[]; + data: number[]; + isScrubbing: boolean; + onHighlightChange: (items: HighlightedItem[]) => void; + scrubberLabel: (index: number) => string; + }; + + const chartTransition = useMemo(() => ({ duration: 0.15 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues = []; + const sampledTimestamps = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date, periodId) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + = 0 ? 'rotate(0deg)' : 'rotate(90deg)' }} + /> + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + + ); + }); + + return ; } ``` diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx index 89db490bf..a0c66f9c0 100644 --- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx +++ b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx @@ -10,6 +10,7 @@ import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider'; import { ScrubberContext } from './utils/context'; import { CartesianChartContext } from './ChartProvider'; +import { HighlightContext } from './HighlightProvider'; /** * Whitelist of contexts that should be bridged to the Skia canvas. @@ -20,6 +21,7 @@ const BRIDGED_CONTEXTS: React.Context[] = [ ThemeContext, CartesianChartContext, ScrubberContext, + HighlightContext, ]; /** diff --git a/packages/mobile-visualization/src/chart/HighlightProvider.tsx b/packages/mobile-visualization/src/chart/HighlightProvider.tsx index e3c2f6402..6e41e9207 100644 --- a/packages/mobile-visualization/src/chart/HighlightProvider.tsx +++ b/packages/mobile-visualization/src/chart/HighlightProvider.tsx @@ -35,6 +35,15 @@ export type HighlightContextValue = { * 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. */ @@ -45,7 +54,7 @@ export type HighlightContextValue = { unregisterBar: (seriesId: string, dataIndex: number) => void; }; -const HighlightContext = createContext(undefined); +export const HighlightContext = createContext(undefined); /** * Hook to access the highlight context. @@ -104,8 +113,17 @@ export type HighlightProviderProps = HighlightProps & { 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, @@ -134,7 +152,7 @@ export const HighlightProvider: React.FC = ({ [scopeProp], ); - // Bar registry for hit testing (use ref to avoid re-renders) + // Bar registry for hit testing const barsRef = useRef([]); const registerBar = useCallback((bounds: BarBounds) => { @@ -147,7 +165,6 @@ export const HighlightProvider: React.FC = ({ ); }, []); - // Find bar at touch point (iterates in reverse for correct z-order) const findBarAtPoint = useCallback((touchX: number, touchY: number): BarBounds | null => { const bars = barsRef.current; for (let i = bars.length - 1; i >= 0; i--) { @@ -164,22 +181,26 @@ export const HighlightProvider: React.FC = ({ return null; }, []); - // Determine if we're in controlled mode const isControlled = controlledHighlight !== undefined; - // Use SharedValue for UI thread performance + // 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([]); - // The exposed highlight SharedValue - returns controlled value or internal value + const syncInternalHighlight = useCallback(() => { + internalHighlight.value = Object.values(pointerMapRef.current); + }, [internalHighlight]); + + // The exposed highlight SharedValue const highlight: SharedValue = useMemo(() => { if (isControlled) { - // Create a proxy that returns the controlled value but doesn't update internal state return { get value() { return controlledHighlight ?? []; }, set value(_newValue: HighlightedItem[]) { - // In controlled mode, don't update - the gesture handlers will call onHighlightChange directly + // In controlled mode, don't update internal state }, addListener: internalHighlight.addListener.bind(internalHighlight), removeListener: internalHighlight.removeListener.bind(internalHighlight), @@ -217,7 +238,6 @@ export const HighlightProvider: React.FC = ({ } return closestIndex; } else { - // For numeric scales with axis data, find the nearest data point const axisData = xAxis.data; if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') { const numericData = axisData as number[]; @@ -247,12 +267,12 @@ export const HighlightProvider: React.FC = ({ [xAxis, xScale], ); - // Haptic feedback handlers + // Haptic feedback const handleStartEndHaptics = useCallback(() => { void Haptics.lightImpact(); }, []); - // Handle JS thread callback when highlight changes + // Fire onHighlightChange when highlight SharedValue changes const handleHighlightChangeJS = useCallback( (items: HighlightedItem[]) => { onHighlightChange?.(items); @@ -260,7 +280,6 @@ export const HighlightProvider: React.FC = ({ [onHighlightChange], ); - // React to highlight changes and call JS callback useAnimatedReaction( () => highlight.value, (currentValue, previousValue) => { @@ -271,129 +290,174 @@ export const HighlightProvider: React.FC = ({ [handleHighlightChangeJS], ); - // Setter function for context - always fires callback, only updates internal state when uncontrolled + // 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) { - internalHighlight.value = newItems; + syncInternalHighlight(); } onHighlightChange?.(newItems); }, - [isControlled, internalHighlight, onHighlightChange], + [isControlled, syncInternalHighlight, onHighlightChange], ); - // Helper to create highlighted item with optional series hit testing - const createHighlightedItem = useCallback( - (x: number, y: number, dataIndex: number | null): HighlightedItem => { - let seriesId: string | null = null; - if (scope.series) { - const hitBar = findBarAtPoint(x, y); - if (hitBar) { - seriesId = hitBar.seriesId; - } + // 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(); } - return { dataIndex, seriesId }; }, - [scope.series, findBarAtPoint], + [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], ); - // Create the long press pan gesture for single touch - const singleTouchGesture = useMemo( + 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)(); - // Android does not trigger onUpdate when the gesture starts - if (Platform.OS === 'android') { - const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - runOnJS((x: number, y: number, di: number | null) => { - const newItem = createHighlightedItem(x, y, di); - const currentItems = internalHighlight.value; - const currentItem = currentItems[0]; - if ( - newItem.dataIndex !== currentItem?.dataIndex || - newItem.seriesId !== currentItem?.seriesId - ) { - if (!isControlled) { - internalHighlight.value = [newItem]; - } - onHighlightChange?.([newItem]); - } - })(event.x, event.y, dataIndex); + // 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); } }) - .onUpdate(function onUpdate(event) { - const dataIndex = scope.dataIndex ? getDataIndexFromX(event.x) : null; - runOnJS((x: number, y: number, di: number | null) => { - const newItem = createHighlightedItem(x, y, di); - const currentItems = internalHighlight.value; - const currentItem = currentItems[0]; - if ( - newItem.dataIndex !== currentItem?.dataIndex || - newItem.seriesId !== currentItem?.seriesId - ) { - if (!isControlled) { - internalHighlight.value = [newItem]; - } - onHighlightChange?.([newItem]); - } - })(event.x, event.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); + } }) - .onEnd(function onEnd() { - if (enableHighlighting) { - runOnJS(handleStartEndHaptics)(); - if (!isControlled) { - internalHighlight.value = []; - } - runOnJS(onHighlightChange ?? (() => {}))([]); + .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() { - if (enableHighlighting) { - if (!isControlled) { - internalHighlight.value = []; - } - runOnJS(onHighlightChange ?? (() => {}))([]); - } + isGestureActive.value = false; + runOnJS(handleGestureEnd)(); }), [ allowOverflowGestures, + isGestureActive, handleStartEndHaptics, getDataIndexFromX, scope.dataIndex, - createHighlightedItem, - internalHighlight, - enableHighlighting, - isControlled, - onHighlightChange, + handleTouchHighlight, + handleTouchRemove, + handleClearInitialTouch, + handleGestureEnd, ], ); - const gesture = singleTouchGesture; - const contextValue: HighlightContextValue = useMemo( () => ({ enabled: enableHighlighting, scope, highlight, setHighlight, + updatePointerHighlight, + removePointer, registerBar, unregisterBar, }), - [enableHighlighting, scope, highlight, setHighlight, registerBar, unregisterBar], + [ + enableHighlighting, + scope, + highlight, + setHighlight, + updatePointerHighlight, + removePointer, + registerBar, + unregisterBar, + ], ); - // Derive scrubberPosition from internal highlight for backwards compatibility + // 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]); - // Provide ScrubberContext for backwards compatibility const scrubberContextValue: ScrubberContextValue = useMemo( () => ({ enableScrubbing: enableHighlighting, @@ -402,7 +466,7 @@ export const HighlightProvider: React.FC = ({ [enableHighlighting, scrubberPosition], ); - // Helper to get label from accessibilityLabel (string or function) + // Accessibility const getAccessibilityLabelForItem = useCallback( (item: HighlightedItem): string => { if (typeof accessibilityLabel === 'string') { @@ -416,10 +480,7 @@ export const HighlightProvider: React.FC = ({ [accessibilityLabel], ); - // Generate accessibility regions based on mode const accessibilityRegions = useMemo(() => { - // Only generate regions if we have a function label (for dynamic per-item labels) - // Static string labels don't need regions if (!enableHighlighting || !accessibilityLabel || typeof accessibilityLabel === 'string') { return null; } @@ -432,7 +493,6 @@ export const HighlightProvider: React.FC = ({ }> = []; if (accessibilityMode === 'chunked') { - // Divide into chunks const chunkSize = Math.ceil(dataLength / accessibilityChunkCount); for (let i = 0; i < accessibilityChunkCount && i * chunkSize < dataLength; i++) { const startIndex = i * chunkSize; @@ -448,7 +508,6 @@ export const HighlightProvider: React.FC = ({ }); } } else if (accessibilityMode === 'item') { - // Each data point is a region for (let i = 0; i < dataLength; i++) { const item: HighlightedItem = { dataIndex: i, seriesId: null }; regions.push({ @@ -483,17 +542,9 @@ export const HighlightProvider: React.FC = ({ accessibilityLabel={region.label} accessibilityRole="button" onAccessibilityTap={() => { - // Always fire callback, only update internal state when not controlled - if (!isControlled) { - internalHighlight.value = [region.highlightedItem]; - } - onHighlightChange?.([region.highlightedItem]); - // Clear after a short delay + setHighlight([region.highlightedItem]); setTimeout(() => { - if (!isControlled) { - internalHighlight.value = []; - } - onHighlightChange?.([]); + setHighlight([]); }, 100); }} style={{ flex: region.flex }} @@ -505,7 +556,6 @@ export const HighlightProvider: React.FC = ({ ); - // Wrap with gesture handler only if highlighting is enabled if (enableHighlighting) { return {content}; } diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile-visualization/src/chart/Path.tsx index d0d9bfddd..6b5f00eed 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -38,9 +38,9 @@ export type PathBaseProps = { */ fill?: string; /** - * Opacity for the path fill. + * Opacity for the path fill. Accepts a static number or a SharedValue for animated opacity. */ - fillOpacity?: number; + fillOpacity?: AnimatedProp; /** * Stroke color for the path. * When provided, will render a fill with the given color. diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index 7a156cb9d..f38214c7e 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -71,6 +71,11 @@ export type BarBaseProps = { * Component to render the bar. */ BarComponent?: BarComponent; + /** + * Whether non-highlighted bars should fade when highlighting is active. + * @default false + */ + fadeOnHighlight?: boolean; }; export type BarProps = BarBaseProps & { @@ -120,6 +125,7 @@ export const Bar = memo( roundTop = true, roundBottom = true, transition, + fadeOnHighlight, }) => { const theme = useTheme(); @@ -145,6 +151,7 @@ export const Bar = memo( d={barPath} dataX={dataX} dataY={dataY} + fadeOnHighlight={fadeOnHighlight} fill={effectiveFill} fillOpacity={fillOpacity} height={height} diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index 14cb3a617..da967267b 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -32,6 +32,7 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. @@ -96,6 +97,7 @@ export const BarChart = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, ...chartProps }, ref, @@ -183,6 +185,7 @@ export const BarChart = memo( barMinSize={barMinSize} barPadding={barPadding} borderRadius={borderRadius} + fadeOnHighlight={fadeOnHighlight} fillOpacity={fillOpacity} roundBaseline={roundBaseline} seriesIds={seriesIds} diff --git a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx index 7d24fefd9..8a66b39f5 100644 --- a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx @@ -21,6 +21,7 @@ export type BarPlotBaseProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'fadeOnHighlight' > & { /** * Array of series IDs to render. @@ -52,6 +53,7 @@ export const BarPlot = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); @@ -125,6 +127,7 @@ export const BarPlot = memo( barMinSize={barMinSize} barPadding={barPadding} borderRadius={defaultBorderRadius} + fadeOnHighlight={fadeOnHighlight} fillOpacity={defaultFillOpacity} roundBaseline={roundBaseline} series={group.series} diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index fab04f851..be0f43744 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 = CartesianSeries & { export type BarStackBaseProps = Pick< BarProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -141,6 +141,7 @@ export const BarStack = memo( stackMinSize, roundBaseline, transition, + fadeOnHighlight, }) => { const theme = useTheme(); const { getSeriesData, getXAxis, getXScale } = useCartesianChartContext(); @@ -682,6 +683,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={dataX} dataY={bar.dataY} + fadeOnHighlight={fadeOnHighlight} fill={bar.fill} fillOpacity={defaultFillOpacity} height={bar.height} diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 2ce56d065..343236114 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -18,6 +18,7 @@ export type BarStackGroupProps = Pick< | 'stackMinSize' | 'BarStackComponent' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index 85c93301e..f752ac99b 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -1,4 +1,5 @@ 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'; @@ -10,9 +11,13 @@ import type { BarComponentProps } from './Bar'; export type DefaultBarProps = BarComponentProps; +const FADED_OPACITY_FACTOR = 0.3; +const FADE_ANIMATION_CONFIG = { duration: 250, easing: Easing.inOut(Easing.ease) }; + /** * Default bar component that renders a solid bar with animation support. * Registers bounds for series highlighting hit testing when `highlightScope.series` is enabled. + * Supports animated fade via `fadeOnHighlight` prop. */ export const DefaultBar = memo( ({ @@ -32,31 +37,54 @@ export const DefaultBar = memo( dataX, seriesId, transition, + fadeOnHighlight, }) => { const { animate } = useCartesianChartContext(); const highlightContext = useHighlightContext(); const theme = useTheme(); + const { scope } = highlightContext; + const dataIndex = typeof dataX === 'number' ? dataX : null; + // Register bar bounds for hit testing when series highlighting is enabled useEffect(() => { if (!highlightContext.scope.series || !seriesId) return; - const dataIndex = typeof dataX === 'number' ? dataX : 0; + const idx = typeof dataX === 'number' ? dataX : 0; highlightContext.registerBar({ x, y, width, height, - dataIndex, + dataIndex: idx, seriesId, }); return () => { - highlightContext.unregisterBar(seriesId, dataIndex); + highlightContext.unregisterBar(seriesId, idx); }; }, [x, y, width, height, dataX, seriesId, highlightContext]); + // Animated opacity based on highlight state + const effectiveOpacity = useDerivedValue(() => { + if (!fadeOnHighlight || !highlightContext.enabled) return fillOpacity; + + const items = highlightContext.highlight.value; + + let targetOpacity = fillOpacity; + if (items.length > 0) { + const isHighlighted = items.some((item) => { + const indexMatch = !scope.dataIndex || item.dataIndex === dataIndex; + const seriesMatch = !scope.series || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); + targetOpacity = isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + } + + return withTiming(targetOpacity, FADE_ANIMATION_CONFIG); + }, [fadeOnHighlight, highlightContext.enabled, fillOpacity, scope, dataIndex, seriesId]); + const defaultFill = fill || theme.color.fgPrimary; const targetPath = useMemo(() => { @@ -101,7 +129,7 @@ export const DefaultBar = memo( clipPath={null} d={targetPath} fill={stroke ? 'none' : defaultFill} - fillOpacity={fillOpacity} + fillOpacity={fadeOnHighlight ? effectiveOpacity : fillOpacity} initialPath={initialPath} stroke={stroke} strokeWidth={strokeWidth} diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx index ba10f6d9d..4512b2835 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { Text } from 'react-native'; import { Button } from '@coinbase/cds-mobile/buttons'; import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; @@ -6,6 +7,7 @@ import { VStack } from '@coinbase/cds-mobile/layout'; import { XAxis, YAxis } from '../../axis'; import { CartesianChart } from '../../CartesianChart'; +import type { HighlightedItem } from '../../utils/highlight'; import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line'; import { Bar } from '../Bar'; import { BarChart } from '../BarChart'; @@ -612,9 +614,191 @@ const BandGridPositionExample = ({
); +const BasicHighlighting = () => { + const theme = useTheme(); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const data = [45, 80, 120, 95, 150, 110, 85]; + const [info, setInfo] = useState('Long press and drag to highlight'); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + setInfo(`${days[item.dataIndex]}: ${data[item.dataIndex]} visits`); + } else { + setInfo('Long press and drag to highlight'); + } + }, + [days, data], + ); + + return ( + + {info} + + + ); +}; + +const FadeOnHighlight = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +}; + +const FadeWithSeriesScope = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + const [info, setInfo] = useState('Long press a bar to highlight'); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + const series = item.seriesId ?? 'all'; + setInfo(`${months[item.dataIndex]} — Series: ${series}`); + } else { + setInfo('Long press a bar to highlight'); + } + }, + [months], + ); + + return ( + + {info} + + + ); +}; + +const FadeOnHighlightStacked = () => { + const theme = useTheme(); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + return ( + + ); +}; + const BarChartStories = () => { return ( + + + + + + + + + + + + diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 96d6619a9..5ef1b7722 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { View } from 'react-native'; import { + runOnJS, useAnimatedReaction, useDerivedValue, useSharedValue, @@ -46,18 +47,14 @@ import { CartesianChart } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector'; import { Point } from '../../point'; -import { - DefaultScrubberBeacon, - Scrubber, - type ScrubberBeaconProps, - type ScrubberRef, -} from '../../scrubber'; +import { Scrubber, type ScrubberBeaconProps, type ScrubberRef } from '../../scrubber'; import { type AxisBounds, buildTransition, defaultTransition, getLineData, getPointOnSerializableScale, + type HighlightedItem, projectPointWithSerializableScale, type Transition, unwrapAnimatedValue, @@ -1891,56 +1888,434 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], + const Example = memo(() => { + const defaultHighlight: HighlightedItem[] = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [], ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], ); - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); - - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); - return ( - - - - - - - - + + + + + + ); }); + return ; +} + +// Watches scrubberPosition on the UI thread and only fires a JS callback +// when the active month segment changes, avoiding per-pixel re-renders. +const MonthTracker = memo( + ({ + dataPointsPerMonth, + onMonthChange, + }: { + dataPointsPerMonth: number; + onMonthChange: (month: number | undefined) => void; + }) => { + const { scrubberPosition } = useScrubberContext(); + + const currentSection = useDerivedValue(() => { + const pos = scrubberPosition.value; + if (pos === undefined) return -1; + return Math.floor(pos / dataPointsPerMonth); + }, [dataPointsPerMonth]); + + useAnimatedReaction( + () => currentSection.value, + (current, previous) => { + if (current !== previous) { + runOnJS(onMonthChange)(current === -1 ? undefined : current); + } + }, + ); + + return null; + }, +); + +function HighlightLineSegments() { + const chartPrices = useMemo( + () => [...btcCandles].reverse().map((candle) => parseFloat(candle.close)), + [], + ); + + const dataPointsPerMonth = 30; + const [currentMonth, setCurrentMonth] = useState(undefined); + + const handleMonthChange = useCallback((month: number | undefined) => { + setCurrentMonth(month); + }, []); + + const monthStart = currentMonth !== undefined ? currentMonth * dataPointsPerMonth : undefined; + const monthEnd = + currentMonth !== undefined + ? Math.min((currentMonth + 1) * dataPointsPerMonth - 1, chartPrices.length - 1) + : undefined; + + const gradient = useMemo(() => { + const color = assets.btc.color; + + if (monthStart === undefined || monthEnd === undefined) { + return { + axis: 'x' as const, + stops: [ + { offset: 0, color, opacity: 1 }, + { offset: chartPrices.length - 1, color, opacity: 1 }, + ], + }; + } + + const stops = []; + if (monthStart > 0) { + stops.push({ offset: 0, color, opacity: 0.25 }); + stops.push({ offset: monthStart, color, opacity: 0.25 }); + } + stops.push({ offset: monthStart, color, opacity: 1 }); + stops.push({ offset: monthEnd, color, opacity: 1 }); + if (monthEnd < chartPrices.length - 1) { + stops.push({ offset: monthEnd, color, opacity: 0.25 }); + stops.push({ offset: chartPrices.length - 1, color, opacity: 0.25 }); + } + + return { axis: 'x' as const, stops }; + }, [monthStart, monthEnd, chartPrices.length]); + return ( - - - - - - + + + ); } +function AdaptiveDetail() { + const BTCTab: TabComponent = memo( + forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + type MemoizedChartProps = { + highlight: HighlightedItem[]; + data: number[]; + isScrubbing: boolean; + onHighlightChange: (items: HighlightedItem[]) => void; + scrubberLabel: (index: number) => string; + }; + + const chartTransition = useMemo(() => ({ type: 'timing', duration: 150 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }: { min: number; max: number }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + + const MemoizedChart = memo( + ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { + return ( + + + + ); + }, + ); + + const AdaptiveDetailChart = memo(() => { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + // Controlled highlight: [] = nothing highlighted, [{dataIndex}] = highlight shown + const [highlight, setHighlight] = useState([]); + const [isInteracting, setIsInteracting] = useState(false); + const isScrubbing = isInteracting; + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const fullDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const fullDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const samplePointCount = useMemo(() => { + switch (timePeriod.id) { + case 'hour': + case 'day': + return 24; + case 'week': + return 32; + case 'month': + return 40; + case 'year': + case 'all': + default: + return 48; + } + }, [timePeriod.id]); + + const sampledDataWithTimestamps = useMemo(() => { + const values = fullDataValues; + const timestamps = fullDataTimestamps; + + if (values.length <= samplePointCount) { + return { values, timestamps }; + } + + const step = values.length / samplePointCount; + const sampledValues: number[] = []; + const sampledTimestamps: Date[] = []; + + for (let i = 0; i < samplePointCount; i++) { + const idx = Math.floor(i * step); + sampledValues.push(values[idx]); + sampledTimestamps.push(timestamps[idx]); + } + + sampledValues[sampledValues.length - 1] = values[values.length - 1]; + sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; + + return { values: sampledValues, timestamps: sampledTimestamps }; + }, [fullDataValues, fullDataTimestamps, samplePointCount]); + + // Show full data when scrubbing, sampled when idle + const displayData = useMemo(() => { + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); + + const displayTimestamps = useMemo(() => { + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); + + // Refs for stable callback to avoid stale closures + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; + + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + // Entering scrubbing: dataIndex is relative to sampled data. + // Proportionally map so the scrubber stays at the same visual + // position after switching from sampled to full data. + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + // Already scrubbing: index is relative to full data + setHighlight(items); + } + } else { + setIsInteracting(false); + setHighlight([]); + } + }, []); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); + }, + [tabs], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date, periodId: string) => { + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + switch (periodId) { + case 'hour': + case 'day': + return time; + case 'week': { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + return `${dayOfWeek} ${time}`; + } + case 'month': + case 'year': + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'all': + default: + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }, []); + + const scrubberLabel = useCallback( + (index: number) => { + return formatDate(displayTimestamps[index], timePeriod.id); + }, + [displayTimestamps, formatDate, timePeriod.id], + ); + + // Price display: when scrubbing, look up directly in full data by index + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; + const displayPrice = useMemo(() => { + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; + } + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); + + const difference = displayPrice - startPrice; + const percentChange = (difference / startPrice) * 100; + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + + + + + Bitcoin + + {formatPrice(displayPrice)} + + {formatPrice(Math.abs(difference))} ({Math.abs(percentChange).toFixed(2)}%) + + + + + + + + ); + }); + + return ; +} + function DataCardWithLineChart() { const { spectrum } = useTheme(); const exampleThumbnail = ( @@ -2308,6 +2683,14 @@ function ExampleNavigator() { title: 'In DataCard', component: , }, + { + title: 'Highlight Line Segments', + component: , + }, + { + title: 'Adaptive Detail', + component: , + }, ], [theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50], ); diff --git a/packages/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index 97aaa2007..9e8e6e487 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -346,7 +346,7 @@ export const TextInput = memo( importantForAccessibility={startIconA11yLabel ? 'auto' : 'no'} onPress={handleNodePress} > - + {compact && (labelNode ? labelNode : !!label && {label})} {!!start && ( diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 2e1d1c462..b90ef945c 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 { type HighlightProps, HighlightProvider } from './HighlightProvider'; import { CartesianChartProvider } from './ChartProvider'; +import { type HighlightProps, HighlightProvider } from './HighlightProvider'; import { Legend } from './legend'; import { type AxisConfig, diff --git a/packages/web-visualization/src/chart/HighlightProvider.tsx b/packages/web-visualization/src/chart/HighlightProvider.tsx index 20c68fba1..79c9e5cf0 100644 --- a/packages/web-visualization/src/chart/HighlightProvider.tsx +++ b/packages/web-visualization/src/chart/HighlightProvider.tsx @@ -1,4 +1,12 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useCartesianChartContext } from './ChartProvider'; import { isCategoricalScale, ScrubberContext, type ScrubberContextValue } from './utils'; @@ -21,9 +29,21 @@ export type HighlightContextValue = { */ highlight: HighlightedItem[]; /** - * Callback to update the highlight state. + * 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); @@ -77,9 +97,11 @@ export type HighlightProviderProps = HighlightProps & { accessibilityLabel?: string | ((item: HighlightedItem) => string); }; +const DEFAULT_ITEM: HighlightedItem = { dataIndex: null, seriesId: null }; + /** * HighlightProvider manages chart highlight state and input handling. - * It supports multi-touch interactions with configurable scope. + * Uses Pointer Events for unified mouse/touch interaction with per-pointer state tracking. */ export const HighlightProvider: React.FC = ({ children, @@ -108,14 +130,17 @@ export const HighlightProvider: React.FC = ({ [scopeProp], ); - // Determine if we're in controlled mode - // [] means "controlled with no highlights" - distinct from undefined (uncontrolled) const isControlled = controlledHighlight !== undefined; - // Internal state for uncontrolled mode - const [internalHighlight, setInternalHighlight] = useState([]); + // 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]); - // Get the current highlight state (controlled or uncontrolled) const highlight: HighlightedItem[] = useMemo(() => { if (isControlled) { return controlledHighlight; @@ -123,17 +148,51 @@ export const HighlightProvider: React.FC = ({ return internalHighlight; }, [isControlled, controlledHighlight, internalHighlight]); - // Update highlight state - const setHighlight = useCallback( - (newHighlight: HighlightedItem[]) => { - if (!isControlled) { - setInternalHighlight(newHighlight); - } - onHighlightChange?.(newHighlight); + // 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 }; + }); }, - [isControlled, onHighlightChange], + [], ); + // 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 => { @@ -159,7 +218,6 @@ export const HighlightProvider: React.FC = ({ } return closestIndex; } else { - // For numeric scales with axis data, find the nearest data point const axisData = xAxis.data; if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') { const numericData = axisData as number[]; @@ -189,149 +247,54 @@ export const HighlightProvider: React.FC = ({ [getXScale, getXAxis], ); - // Find series at a given point (for series scope) - const getSeriesIdFromPoint = useCallback( - (_mouseX: number, _mouseY: number): string | null => { - // TODO: Implement series detection based on proximity to data points - // For now, return null (series scope not fully implemented) - if (!scope.series) return null; - return null; - }, - [scope.series], - ); - - // Convert pointer position to HighlightedItem - const getHighlightedItemFromPointer = useCallback( - (clientX: number, clientY: number, target: SVGSVGElement): HighlightedItem => { - const rect = target.getBoundingClientRect(); - const x = clientX - rect.left; - const y = clientY - rect.top; + // --- Pointer Event handlers --- - const dataIndex = scope.dataIndex ? getDataIndexFromX(x) : null; - const seriesId = scope.series ? getSeriesIdFromPoint(x, y) : null; - - return { dataIndex, seriesId }; + 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 + } + } }, - [scope.dataIndex, scope.series, getDataIndexFromX, getSeriesIdFromPoint], - ); - - // Track active pointers for multi-touch - const activePointersRef = React.useRef>( - new Map(), + [enabled], ); - // Handle pointer move (mouse - single pointer) const handlePointerMove = useCallback( - (clientX: number, clientY: number, target: SVGSVGElement) => { + (event: PointerEvent) => { if (!enabled || !series || series.length === 0) return; - - const newItem = getHighlightedItemFromPointer(clientX, clientY, target); - - // When series scope is enabled, preserve the existing seriesId - const currentSeriesId = scope.series ? highlight[0]?.seriesId : null; - const effectiveItem = { - ...newItem, - seriesId: currentSeriesId ?? newItem.seriesId, - }; - - if ( - highlight.length !== 1 || - highlight[0]?.dataIndex !== effectiveItem.dataIndex || - highlight[0]?.seriesId !== effectiveItem.seriesId - ) { - setHighlight([effectiveItem]); - } - }, - [enabled, series, scope.series, getHighlightedItemFromPointer, highlight, setHighlight], - ); - - // Handle multi-pointer update (touch) - const updateMultiPointerState = useCallback( - (target: SVGSVGElement) => { - const items: HighlightedItem[] = Array.from(activePointersRef.current.values()).map( - (pointer) => getHighlightedItemFromPointer(pointer.clientX, pointer.clientY, target), - ); - - setHighlight(items); - }, - [getHighlightedItemFromPointer, setHighlight], - ); - - // Mouse event handlers - const handleMouseMove = useCallback( - (event: MouseEvent) => { - const target = event.currentTarget as SVGSVGElement; - handlePointerMove(event.clientX, event.clientY, target); - }, - [handlePointerMove], - ); - - const handleMouseLeave = useCallback(() => { - if (!enabled) return; - setHighlight([]); - }, [enabled, setHighlight]); - - // Touch event handlers - const handleTouchStart = useCallback( - (event: TouchEvent) => { - if (!enabled || !event.touches.length) return; - - const target = event.currentTarget as SVGSVGElement; - - // Track all touches - for (let i = 0; i < event.touches.length; i++) { - const touch = event.touches[i]; - activePointersRef.current.set(touch.identifier, { - clientX: touch.clientX, - clientY: touch.clientY, - }); - } - updateMultiPointerState(target); + 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, updateMultiPointerState], + [enabled, series, scope.dataIndex, getDataIndexFromX, updatePointerHighlight], ); - const handleTouchMove = useCallback( - (event: TouchEvent) => { - if (!enabled || !event.touches.length) return; - event.preventDefault(); // Prevent scrolling while interacting - - const target = event.currentTarget as SVGSVGElement; - - // Update all touches - for (let i = 0; i < event.touches.length; i++) { - const touch = event.touches[i]; - activePointersRef.current.set(touch.identifier, { - clientX: touch.clientX, - clientY: touch.clientY, - }); - } - updateMultiPointerState(target); + const handlePointerUp = useCallback( + (event: PointerEvent) => { + if (!enabled) return; + removePointer(event.pointerId); }, - [enabled, updateMultiPointerState], + [enabled, removePointer], ); - const handleTouchEnd = useCallback( - (event: TouchEvent) => { + const handlePointerLeave = useCallback( + (event: PointerEvent) => { if (!enabled) return; - - // Remove ended touches - for (let i = 0; i < event.changedTouches.length; i++) { - const touch = event.changedTouches[i]; - activePointersRef.current.delete(touch.identifier); - } - - if (activePointersRef.current.size === 0) { - setHighlight([]); - } else { - const target = event.currentTarget as SVGSVGElement; - updateMultiPointerState(target); - } + removePointer(event.pointerId); }, - [enabled, setHighlight, updateMultiPointerState], + [enabled, removePointer], ); - // Keyboard navigation handler + // --- Keyboard navigation --- + const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (!enabled) return; @@ -343,7 +306,6 @@ export const HighlightProvider: React.FC = ({ const isBand = isCategoricalScale(xScale); - // Determine navigation bounds let minIndex: number; let maxIndex: number; @@ -363,11 +325,10 @@ export const HighlightProvider: React.FC = ({ } } - const currentItem = highlight[0] ?? { dataIndex: null, seriesId: null }; + const currentItem = highlight[0] ?? DEFAULT_ITEM; const currentIndex = currentItem.dataIndex ?? minIndex; const dataRange = maxIndex - minIndex; - // Multi-step jump when shift is held (10% of data range, minimum 1, maximum 10) const multiSkip = event.shiftKey; const stepSize = multiSkip ? Math.min(10, Math.max(1, Math.floor(dataRange * 0.1))) : 1; @@ -414,56 +375,53 @@ export const HighlightProvider: React.FC = ({ setHighlight([]); }, [enabled, highlight, setHighlight]); - // Attach event listeners to SVG element + // --- Attach event listeners --- + useEffect(() => { if (!svgRef?.current || !enabled) return; const svg = svgRef.current; - svg.addEventListener('mousemove', handleMouseMove); - svg.addEventListener('mouseleave', handleMouseLeave); - svg.addEventListener('touchstart', handleTouchStart, { passive: false }); - svg.addEventListener('touchmove', handleTouchMove, { passive: false }); - svg.addEventListener('touchend', handleTouchEnd); - svg.addEventListener('touchcancel', handleTouchEnd); + 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('mousemove', handleMouseMove); - svg.removeEventListener('mouseleave', handleMouseLeave); - svg.removeEventListener('touchstart', handleTouchStart); - svg.removeEventListener('touchmove', handleTouchMove); - svg.removeEventListener('touchend', handleTouchEnd); - svg.removeEventListener('touchcancel', handleTouchEnd); + 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, - handleMouseMove, - handleMouseLeave, - handleTouchStart, - handleTouchMove, - handleTouchEnd, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerLeave, handleKeyDown, handleBlur, ]); - // Update accessibility label when highlight changes + // --- Accessibility --- + useEffect(() => { if (!svgRef?.current || !accessibilityLabel) return; const svg = svgRef.current; - // If it's a static string, always use it if (typeof accessibilityLabel === 'string') { svg.setAttribute('aria-label', accessibilityLabel); return; } - // If it's a function, use it for dynamic labels during interaction if (!enabled) return; const currentItem = highlight[0]; @@ -475,18 +433,21 @@ export const HighlightProvider: React.FC = ({ } }, [svgRef, enabled, highlight, accessibilityLabel]); + // --- Context values --- + const contextValue: HighlightContextValue = useMemo( () => ({ enabled, scope, highlight, setHighlight, + updatePointerHighlight, + removePointer, }), - [enabled, scope, highlight, setHighlight], + [enabled, scope, highlight, setHighlight, updatePointerHighlight, removePointer], ); - // Provide ScrubberContext for backwards compatibility with Scrubber component - // Derive scrubberPosition from first highlighted item's dataIndex + // ScrubberContext bridge for backwards compatibility const scrubberPosition = useMemo(() => { if (!enabled) return undefined; return highlight[0]?.dataIndex ?? undefined; diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index 8e4c27bef..0be2e188e 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -73,6 +73,11 @@ export type BarBaseProps = { * Component to render the bar. */ BarComponent?: BarComponent; + /** + * Whether non-highlighted bars should fade when highlighting is active. + * @default false + */ + fadeOnHighlight?: boolean; }; export type BarProps = BarBaseProps & { @@ -122,6 +127,7 @@ export const Bar = memo( roundTop = true, roundBottom = true, transition, + fadeOnHighlight, }) => { const barPath = useMemo(() => { return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); @@ -139,6 +145,7 @@ export const Bar = memo( d={barPath} dataX={dataX} dataY={dataY} + fadeOnHighlight={fadeOnHighlight} fill={fill} fillOpacity={fillOpacity} height={height} diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 3fa68dd4d..6a712d70b 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -31,6 +31,7 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. @@ -95,6 +96,7 @@ export const BarChart = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, ...chartProps }, ref, @@ -182,6 +184,7 @@ export const BarChart = memo( barMinSize={barMinSize} barPadding={barPadding} borderRadius={borderRadius} + fadeOnHighlight={fadeOnHighlight} fillOpacity={fillOpacity} roundBaseline={roundBaseline} seriesIds={seriesIds} diff --git a/packages/web-visualization/src/chart/bar/BarPlot.tsx b/packages/web-visualization/src/chart/bar/BarPlot.tsx index 08e1c21a6..3a9fcceb5 100644 --- a/packages/web-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/web-visualization/src/chart/bar/BarPlot.tsx @@ -20,6 +20,7 @@ export type BarPlotBaseProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'fadeOnHighlight' > & { /** * Array of series IDs to render. @@ -51,6 +52,7 @@ export const BarPlot = memo( barMinSize, stackMinSize, transition, + fadeOnHighlight, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); @@ -120,6 +122,7 @@ export const BarPlot = memo( barMinSize={barMinSize} barPadding={barPadding} borderRadius={defaultBorderRadius} + fadeOnHighlight={fadeOnHighlight} fillOpacity={defaultFillOpacity} roundBaseline={roundBaseline} series={group.series} diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index e993186e3..fed996e13 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 = CartesianSeries & { export type BarStackBaseProps = Pick< BarProps, - 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' + 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' | 'fadeOnHighlight' > & { /** * Array of series configurations that belong to this stack. @@ -140,6 +140,7 @@ export const BarStack = memo( stackMinSize, roundBaseline, transition, + fadeOnHighlight, }) => { const { getSeriesData, getXAxis, getXScale, getSeries } = useCartesianChartContext(); @@ -691,6 +692,7 @@ export const BarStack = memo( borderRadius={borderRadius} dataX={dataX} dataY={bar.dataY} + fadeOnHighlight={fadeOnHighlight} fill={bar.fill} fillOpacity={bar.fillOpacity ?? defaultFillOpacity} height={bar.height} diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 31ea2e64a..dd59a7314 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -20,6 +20,7 @@ export type BarStackGroupProps = Pick< | 'stackMinSize' | 'BarStackComponent' | 'transition' + | 'fadeOnHighlight' > & Pick & { /** diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index ea1321877..c59c4f9fd 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,5 +1,6 @@ import React, { memo, useCallback, useMemo } from 'react'; import { m as motion } from 'framer-motion'; +import { css } from '@linaria/core'; import { useCartesianChartContext } from '../ChartProvider'; import { useHighlightContext } from '../HighlightProvider'; @@ -7,6 +8,12 @@ import { getBarPath } from '../utils'; import type { BarComponentProps } from './Bar'; +const fadeTransitionCss = css` + transition: fill-opacity 250ms ease-in-out; +`; + +const FADED_OPACITY_FACTOR = 0.3; + export type DefaultBarProps = BarComponentProps & { /** * Custom class name for the bar. @@ -20,7 +27,8 @@ export type DefaultBarProps = BarComponentProps & { /** * Default bar component that renders a solid bar with animation. - * Automatically tracks series highlighting when `highlightScope.series` is enabled. + * Uses pointer events to report series identity to the highlight system + * when `highlightScope.series` is enabled. */ export const DefaultBar = memo( ({ @@ -37,72 +45,101 @@ export const DefaultBar = memo( dataY, seriesId, transition, + fadeOnHighlight, ...props }) => { const { animate } = useCartesianChartContext(); const highlightContext = useHighlightContext(); + const { highlight, scope } = highlightContext; const initialPath = useMemo(() => { if (!animate) return undefined; - // Need a minimum height to allow for animation const minHeight = 1; const initialY = (originY ?? 0) - minHeight; return getBarPath(x, initialY, width, minHeight, borderRadius, !!roundTop, !!roundBottom); }, [animate, x, originY, width, borderRadius, roundTop, roundBottom]); - // Get the data index as a number for highlighting const dataIndex = typeof dataX === 'number' ? dataX : null; - const handleMouseEnter = useCallback(() => { - if (!highlightContext.enabled || !highlightContext.scope.series) return; + // Determine effective opacity based on highlight state + const effectiveOpacity = useMemo(() => { + if (!fadeOnHighlight || !highlightContext.enabled || highlight.length === 0) { + return fillOpacity; + } + + const isHighlighted = highlight.some((item) => { + const indexMatch = !scope.dataIndex || item.dataIndex === dataIndex; + // When seriesId is null (pointer between bars), all series at this index match. + // Only narrow to a specific series when one is identified. + const seriesMatch = !scope.series || item.seriesId === null || item.seriesId === seriesId; + return indexMatch && seriesMatch; + }); - highlightContext.setHighlight([ - { - dataIndex, + return isHighlighted ? fillOpacity : fillOpacity * FADED_OPACITY_FACTOR; + }, [ + fadeOnHighlight, + highlightContext.enabled, + highlight, + scope, + dataIndex, + seriesId, + fillOpacity, + ]); + + const handlePointerEnter = useCallback( + (event: React.PointerEvent) => { + if (!highlightContext.enabled || !highlightContext.scope.series) return; + highlightContext.updatePointerHighlight(event.pointerId, { seriesId: seriesId ?? null, - }, - ]); - }, [highlightContext, dataIndex, seriesId]); - - const handleMouseLeave = useCallback(() => { - if (!highlightContext.enabled || !highlightContext.scope.series) return; - - // Reset to just dataIndex (keep dataIndex tracking, clear series) - if (highlightContext.scope.dataIndex) { - highlightContext.setHighlight([ - { - dataIndex, - seriesId: null, - }, - ]); - } else { - highlightContext.setHighlight([]); - } - }, [highlightContext, dataIndex, seriesId]); + }); + }, + [highlightContext, seriesId], + ); - // Only add event handlers when series scope is enabled - const eventHandlers = highlightContext.scope.series + const handlePointerLeave = useCallback( + (event: React.PointerEvent) => { + if (!highlightContext.enabled || !highlightContext.scope.series) return; + highlightContext.updatePointerHighlight(event.pointerId, { + seriesId: null, + }); + }, + [highlightContext], + ); + + const pointerHandlers = highlightContext.scope.series ? { - onMouseEnter: handleMouseEnter, - onMouseLeave: handleMouseLeave, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, style: { cursor: 'pointer' }, } : {}; + const className = fadeOnHighlight ? fadeTransitionCss : undefined; + if (animate && initialPath) { return ( ); } - return ; + return ( + + ); }, ); diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 7ebe65d37..717979d4f 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -24,7 +24,6 @@ import { m } from 'framer-motion'; import { type AxisBounds, - DefaultScrubberBeacon, defaultTransition, type HighlightedItem, PeriodSelector, @@ -35,7 +34,6 @@ import { type ScrubberBeaconProps, type ScrubberRef, useCartesianChartContext, - useScrubberContext, } from '../..'; import { Area, DottedArea, type DottedAreaProps, GradientArea } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; @@ -1368,40 +1366,47 @@ function ForecastAssetPrice() { ); }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - // We need a fade in animation for the Scrubber + const Example = memo(() => { + const defaultHighlight: HighlightedItem[] = useMemo( + () => [{ dataIndex: currentIndex, seriesId: null }], + [], + ); + const [highlight, setHighlight] = useState(defaultHighlight); + const [isScrubbing, setIsScrubbing] = useState(false); + + const handleHighlightChange = useCallback( + (items: HighlightedItem[]) => { + const isActive = items.length > 0; + setIsScrubbing(isActive); + setHighlight(isActive ? items : defaultHighlight); + }, + [defaultHighlight], + ); + return ( - - - - - - - - + + + + +
); }); - return ( - - - - - - - ); + return ; } function MonotoneAssetPrice() { @@ -1605,13 +1610,14 @@ function HighlightLineSegments() { return ( - + ); } @@ -1650,57 +1656,28 @@ function AdaptiveDetail() { /> )); - // Sample data using a moving average for smoother results - const sampleData = useCallback((data: number[], targetPoints: number) => { - if (data.length <= targetPoints) return data; - - // First, apply a moving average to smooth the data - const windowSize = Math.max(3, Math.floor(data.length / targetPoints)); - const smoothed: number[] = []; - - for (let i = 0; i < data.length; i++) { - const halfWindow = Math.floor(windowSize / 2); - const start = Math.max(0, i - halfWindow); - const end = Math.min(data.length, i + halfWindow + 1); - const window = data.slice(start, end); - const avg = window.reduce((sum, val) => sum + val, 0) / window.length; - smoothed.push(avg); - } - - // Then sample from the smoothed data - const step = smoothed.length / targetPoints; - const sampled: number[] = []; - - for (let i = 0; i < targetPoints; i++) { - const idx = Math.floor(i * step); - sampled.push(smoothed[idx]); - } - - // Always include the last point for accuracy - sampled[sampled.length - 1] = data[data.length - 1]; - - return sampled; - }, []); - // Memoized chart component - only re-renders when data or isScrubbing changes type MemoizedChartProps = { - highlight: HighlightedItem[] | undefined; + highlight: HighlightedItem[]; data: number[]; isScrubbing: boolean; onHighlightChange: (items: HighlightedItem[]) => void; scrubberLabel: (index: number) => string; }; + const chartTransition = useMemo(() => ({ duration: 0.15 }), []); + const chartYAxis = useMemo( + () => ({ + range: ({ min, max }: { min: number; max: number }) => ({ min: min + 8, max: max - 8 }), + }), + [], + ); + const MemoizedChart = memo( ({ highlight, data, isScrubbing, onHighlightChange, scrubberLabel }: MemoizedChartProps) => { - console.log('[MemoizedChart] Rendering with:', { - highlight, - dataLength: data.length, - isScrubbing, - strokeWidth: isScrubbing ? 2 : 4, - }); return ( ({ min: min + 8, max: max - 8 }) }} + transition={chartTransition} + yAxis={chartYAxis} > @@ -1723,7 +1700,6 @@ function AdaptiveDetail() { ); const AdaptiveDetailChart = memo(() => { - console.log('[AdaptiveDetailChart] Rendering'); const tabs = useMemo( () => [ { id: 'hour', label: '1H' }, @@ -1736,41 +1712,21 @@ function AdaptiveDetail() { [], ); const [timePeriod, setTimePeriod] = useState(tabs[0]); - // Store selected timestamp instead of dataIndex - this persists across dataset changes - const [selectedTimestamp, setSelectedTimestamp] = useState(null); - // Track if we're actively scrubbing (separate from selectedTimestamp to handle exit delay) + // Always controlled: [] = nothing highlighted, [{dataIndex}] = highlight shown + const [highlight, setHighlight] = useState([]); const [isInteracting, setIsInteracting] = useState(false); - // Timeout ref for delayed exit - prevents race condition when data switches while mouse is still over chart - const exitTimeoutRef = useRef | null>(null); const isScrubbing = isInteracting; - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (exitTimeoutRef.current) { - clearTimeout(exitTimeoutRef.current); - } - }; - }, []); - - // Debug: log state changes - useEffect(() => { - console.log('[AdaptiveDetail] State changed:', { - isInteracting, - isScrubbing, - selectedTimestamp: selectedTimestamp?.toISOString() ?? null, - }); - }, [isInteracting, isScrubbing, selectedTimestamp]); - + // Full data for current period const sparklineTimePeriodData = useMemo(() => { return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; }, [timePeriod]); - const sparklineTimePeriodDataValues = useMemo(() => { + const fullDataValues = useMemo(() => { return sparklineTimePeriodData.map((d) => d.value); }, [sparklineTimePeriodData]); - const sparklineTimePeriodDataTimestamps = useMemo(() => { + const fullDataTimestamps = useMemo(() => { return sparklineTimePeriodData.map((d) => d.date); }, [sparklineTimePeriodData]); @@ -1793,8 +1749,8 @@ function AdaptiveDetail() { // Create sampled data with corresponding timestamps for index mapping const sampledDataWithTimestamps = useMemo(() => { - const values = sparklineTimePeriodDataValues; - const timestamps = sparklineTimePeriodDataTimestamps; + const values = fullDataValues; + const timestamps = fullDataTimestamps; if (values.length <= samplePointCount) { return { values, timestamps }; @@ -1815,110 +1771,67 @@ function AdaptiveDetail() { sampledTimestamps[sampledTimestamps.length - 1] = timestamps[timestamps.length - 1]; return { values: sampledValues, timestamps: sampledTimestamps }; - }, [sparklineTimePeriodDataValues, sparklineTimePeriodDataTimestamps, samplePointCount]); + }, [fullDataValues, fullDataTimestamps, samplePointCount]); - // Use sampled data for display when idle, full data when scrubbing + // Show full data when scrubbing, sampled when idle const displayData = useMemo(() => { - const data = isScrubbing ? sparklineTimePeriodDataValues : sampledDataWithTimestamps.values; - console.log('[AdaptiveDetail] displayData computed:', { - isScrubbing, - dataLength: data.length, - fullDataLength: sparklineTimePeriodDataValues.length, - sampledDataLength: sampledDataWithTimestamps.values.length, - }); - return data; - }, [isScrubbing, sparklineTimePeriodDataValues, sampledDataWithTimestamps.values]); + return isScrubbing ? fullDataValues : sampledDataWithTimestamps.values; + }, [isScrubbing, fullDataValues, sampledDataWithTimestamps.values]); - // Get timestamps for current display data const displayTimestamps = useMemo(() => { - return isScrubbing ? sparklineTimePeriodDataTimestamps : sampledDataWithTimestamps.timestamps; - }, [isScrubbing, sparklineTimePeriodDataTimestamps, sampledDataWithTimestamps.timestamps]); - - // Use ref to avoid stale closure in handleInteractionChange - // This ensures we always access the latest timestamps when the callback fires - const displayTimestampsRef = useRef(displayTimestamps); - displayTimestampsRef.current = displayTimestamps; - - // Find the closest index in the current display data for the selected timestamp - const findClosestIndex = useCallback((timestamp: Date, timestamps: Date[]) => { - const targetTime = timestamp.getTime(); - let closestIdx = 0; - let closestDiff = Math.abs(timestamps[0].getTime() - targetTime); - - for (let i = 1; i < timestamps.length; i++) { - const diff = Math.abs(timestamps[i].getTime() - targetTime); - if (diff < closestDiff) { - closestDiff = diff; - closestIdx = i; - } - } + return isScrubbing ? fullDataTimestamps : sampledDataWithTimestamps.timestamps; + }, [isScrubbing, fullDataTimestamps, sampledDataWithTimestamps.timestamps]); - return closestIdx; - }, []); + // Refs for stable callback - avoids stale closures in handleHighlightChange + const isInteractingRef = useRef(isInteracting); + isInteractingRef.current = isInteracting; + const sampledCountRef = useRef(sampledDataWithTimestamps.values.length); + sampledCountRef.current = sampledDataWithTimestamps.values.length; + const fullCountRef = useRef(fullDataValues.length); + fullCountRef.current = fullDataValues.length; - // Compute controlled highlight based on selected timestamp and current display data - // Return undefined when not interacting to allow uncontrolled user input - // Return HighlightedItem[] when interacting to control position across dataset changes - const highlight = useMemo(() => { - if (selectedTimestamp === null) { - console.log('[AdaptiveDetail] highlight: undefined (no timestamp)'); - return undefined; - } + const handleHighlightChange = useCallback((items: HighlightedItem[]) => { + const item = items[0]; + if (item?.dataIndex !== null && item?.dataIndex !== undefined) { + if (!isInteractingRef.current) { + // Entering scrubbing: dataIndex is relative to sampled data. + // Use proportional mapping so the pixel position stays the same + // after switching from sampled to full data. + const sampledCount = sampledCountRef.current; + const fullCount = fullCountRef.current; + const proportion = item.dataIndex / (sampledCount - 1); + const fullIndex = Math.round(proportion * (fullCount - 1)); + + console.log('[AdaptiveDetail] Entering scrubbing:', { + sampledIndex: item.dataIndex, + sampledCount, + fullCount, + proportion: `${(proportion * 100).toFixed(1)}%`, + fullIndex, + }); - const dataIndex = findClosestIndex(selectedTimestamp, displayTimestamps); - console.log('[AdaptiveDetail] highlight computed:', { - selectedTimestamp: selectedTimestamp.toISOString(), - dataIndex, - displayTimestampsLength: displayTimestamps.length, - }); - return [{ dataIndex, seriesId: null }]; - }, [selectedTimestamp, displayTimestamps, findClosestIndex]); + setIsInteracting(true); + setHighlight([{ dataIndex: fullIndex, seriesId: null }]); + } else { + // Already scrubbing: index is relative to full data, use directly + setHighlight(items); + } + } else { + // User stopped interacting + setIsInteracting(false); + setHighlight([]); + } + }, []); const onPeriodChange = useCallback( (period: TabValue | null) => { setTimePeriod(period || tabs[0]); + setIsInteracting(false); + setHighlight([]); }, [tabs], ); - // Store the timestamp when highlight changes, not the dataIndex - // Uses ref to always get latest displayTimestamps, avoiding stale closure issues - const handleHighlightChange = useCallback((items: HighlightedItem[]) => { - const item = items[0]; - console.log('[AdaptiveDetail] handleHighlightChange called:', { - item, - displayTimestampsRefLength: displayTimestampsRef.current.length, - }); - - if (item?.dataIndex !== null && item?.dataIndex !== undefined) { - // User is interacting - cancel any pending exit timeout - if (exitTimeoutRef.current) { - console.log('[AdaptiveDetail] Cancelling exit timeout'); - clearTimeout(exitTimeoutRef.current); - exitTimeoutRef.current = null; - } - const timestamp = displayTimestampsRef.current[item.dataIndex]; - console.log('[AdaptiveDetail] Setting highlight:', { - dataIndex: item.dataIndex, - timestamp: timestamp?.toISOString(), - }); - setIsInteracting(true); - // Use ref to get the current displayTimestamps (avoids stale closure) - setSelectedTimestamp(timestamp ?? null); - } else { - // User stopped interacting - delay before switching back to sampled data - // This prevents the race condition where switching data while mouse is still - // over the chart causes an immediate re-highlight - console.log('[AdaptiveDetail] Starting exit timeout (50ms)'); - exitTimeoutRef.current = setTimeout(() => { - console.log('[AdaptiveDetail] Exit timeout fired - clearing highlight'); - setIsInteracting(false); - setSelectedTimestamp(null); - exitTimeoutRef.current = null; - }, 50); - } - }, []); - const priceFormatter = useMemo( () => new Intl.NumberFormat('en-US', { @@ -1966,7 +1879,6 @@ function AdaptiveDetail() { } }, []); - // Scrubber label now uses displayTimestamps which matches displayData const scrubberLabel = useCallback( (index: number) => { return formatDate(displayTimestamps[index], timePeriod.id); @@ -1974,21 +1886,15 @@ function AdaptiveDetail() { [displayTimestamps, formatDate, timePeriod.id], ); - // Calculate price change - use selected timestamp to find price in FULL data - const startPrice = sparklineTimePeriodDataValues[0]; + // Price display: when scrubbing, look up directly in full data by index + const highlightedIndex = highlight[0]?.dataIndex; + const startPrice = fullDataValues[0]; const displayPrice = useMemo(() => { - if (selectedTimestamp === null) { - return sparklineTimePeriodDataValues[sparklineTimePeriodDataValues.length - 1]; + if (isScrubbing && highlightedIndex !== null && highlightedIndex !== undefined) { + return fullDataValues[highlightedIndex]; } - // Find the index in full data for the selected timestamp - const fullDataIndex = findClosestIndex(selectedTimestamp, sparklineTimePeriodDataTimestamps); - return sparklineTimePeriodDataValues[fullDataIndex]; - }, [ - selectedTimestamp, - sparklineTimePeriodDataValues, - sparklineTimePeriodDataTimestamps, - findClosestIndex, - ]); + return fullDataValues[fullDataValues.length - 1]; + }, [isScrubbing, highlightedIndex, fullDataValues]); const difference = displayPrice - startPrice; const percentChange = (difference / startPrice) * 100; @@ -2245,6 +2151,9 @@ export const All = () => { ); }; +export const AdaptiveDetailStory = () => ; +AdaptiveDetailStory.storyName = 'Adaptive Detail'; + export const Transitions = () => { const dataCount = 20; const maxDataOffset = 15000; diff --git a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts index 8ea8ac990..3afe1c71c 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts @@ -4,6 +4,8 @@ import { defaultTransition, usePathTransition } from '../transition'; // Mock framer-motion jest.mock('framer-motion', () => { + const { useRef } = require('react'); + const mockMotionValue = (initial: any) => { let value = initial; const listeners: Array<(v: any) => void> = []; @@ -24,7 +26,14 @@ jest.mock('framer-motion', () => { }; return { - useMotionValue: jest.fn((initial) => mockMotionValue(initial)), + // Return a stable reference across re-renders (like real useMotionValue) + useMotionValue: jest.fn((initial: any) => { + const ref = useRef(null); + if (ref.current === null) { + ref.current = mockMotionValue(initial); + } + return ref.current; + }), useTransform: jest.fn((source, transformer) => { const result = mockMotionValue(transformer(source.get())); source.onChange((v: any) => { @@ -32,9 +41,8 @@ jest.mock('framer-motion', () => { }); return result; }), - animate: jest.fn((value, target, config) => { - // Immediately set to target for testing - value.set(target); + animate: jest.fn((_from, _to, config) => { + // Immediately complete animation for testing if (config?.onComplete) { config.onComplete(); } @@ -291,6 +299,41 @@ describe('usePathTransition', () => { expect(animate.mock.calls.length).toBeGreaterThan(animateCallCount); }); + it('should snap to target path when animation is cancelled by cleanup', () => { + const { animate } = require('framer-motion'); + const cancelMock = jest.fn(); + + // Override animate to NOT immediately complete - keeps animation "in progress" + animate.mockImplementation(() => ({ + cancel: cancelMock, + stop: jest.fn(), + })); + + const path1 = 'M0,0L10,10'; + const path2 = 'M0,0L20,20'; + + const { result, unmount, rerender } = renderHook( + ({ path }) => + usePathTransition({ + currentPath: path, + }), + { + initialProps: { path: path1 }, + }, + ); + + // Start animation by changing path (animation stays "in progress") + rerender({ path: path2 }); + + // Unmount triggers cleanup which cancels the animation + unmount(); + + expect(cancelMock).toHaveBeenCalled(); + // After cancellation, animatedPath should have snapped to the target (path2) + // rather than being stuck at an intermediate interpolated value + expect(result.current.get()).toBe(path2); + }); + it('should cleanup animation on unmount', () => { const { animate } = require('framer-motion'); const cancelMock = jest.fn(); diff --git a/packages/web-visualization/src/chart/utils/transition.ts b/packages/web-visualization/src/chart/utils/transition.ts index f4fa56008..e7eec1322 100644 --- a/packages/web-visualization/src/chart/utils/transition.ts +++ b/packages/web-visualization/src/chart/utils/transition.ts @@ -131,6 +131,12 @@ export const usePathTransition = ({ return () => { if (animationRef.current) { animationRef.current.cancel(); + // Snap to target so the visual state stays consistent. + // Without this, a cancelled mid-flight animation leaves + // the path stuck at an intermediate interpolated value. + progress.set(1); + previousPathRef.current = targetPathRef.current; + animationRef.current = null; } }; }, [currentPath, transition, progress, interpolatedPath]); From 9960ba17d55439fcbd1930b4fc8f449bfd043217 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Fri, 13 Mar 2026 11:34:45 -0400 Subject: [PATCH 16/16] Update series --- .../src/chart/CartesianChart.tsx | 4 ++-- .../src/chart/area/AreaChart.tsx | 8 ++++---- .../src/chart/bar/DefaultBar.tsx | 3 ++- .../src/chart/line/LineChart.tsx | 8 ++++---- .../src/chart/scrubber/Scrubber.tsx | 8 ++++---- .../src/chart/utils/__tests__/chart.test.ts | 2 +- .../src/chart/utils/axis.ts | 4 ++-- .../src/chart/utils/chart.ts | 20 ++++++++++++++----- .../src/chart/utils/context.ts | 6 +++--- .../src/chart/CartesianChart.tsx | 4 ++-- .../src/chart/area/AreaChart.tsx | 8 ++++---- .../src/chart/bar/BarChart.tsx | 2 +- .../src/chart/bar/DefaultBar.tsx | 3 ++- .../src/chart/line/LineChart.tsx | 8 ++++---- .../src/chart/scrubber/Scrubber.tsx | 8 ++++---- .../chart/scrubber/ScrubberBeaconGroup.tsx | 1 - .../web-visualization/src/chart/utils/axis.ts | 4 ++-- .../src/chart/utils/chart.ts | 20 ++++++++++++++----- .../src/chart/utils/context.ts | 6 +++--- 19 files changed, 74 insertions(+), 53 deletions(-) diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 5e3aac460..38b9411e4 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -39,7 +39,7 @@ import { type HighlightedItem, type HighlightScope, type LegendPosition, - type Series, + type CartesianSeries, useTotalAxisPadding, } from './utils'; @@ -79,7 +79,7 @@ export type CartesianChartBaseProps = Omit; + series?: Array; /** * Chart layout - describes the direction bars/areas grow. * - 'vertical' (default): Bars grow vertically. X is category axis, Y is value axis. diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index 8ba617521..7da4ca510 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/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index 7d7711d87..e4dda4cbe 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -98,7 +98,8 @@ export const DefaultBar = memo( if (items.length > 0) { const isHighlighted = items.some((item) => { const indexMatch = !highlightByDataIndex || item.dataIndex === dataIndex; - const seriesMatch = !highlightBySeries || item.seriesId === null || item.seriesId === seriesId; + const seriesMatch = + !highlightBySeries || item.seriesId === null || item.seriesId === seriesId; return indexMatch && seriesMatch; }); diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index bf578f7e4..da9979aca 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 75687a5ee..fa1f8f6f7 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 1cc47fdff..00bde3350 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -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' }, ]; diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index de3080239..ef7268bc8 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 27544dc0c..4b7cce5c5 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -44,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. */ @@ -99,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 => { @@ -133,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 @@ -150,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>(); @@ -260,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 => { diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index e74bf4229..d9697014d 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'; /** @@ -34,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 diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 000681ed5..e553d2bc1 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -28,7 +28,7 @@ import { type HighlightedItem, type HighlightScope, type LegendPosition, - type Series, + type CartesianSeries, useTotalAxisPadding, } from './utils'; @@ -48,7 +48,7 @@ export type CartesianChartBaseProps = 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. diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 689863874..3cda8d1de 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/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index e35867534..fb9dc3708 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'; diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index b3f207c5a..977db3822 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -82,7 +82,8 @@ export const DefaultBar = memo( 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; + const seriesMatch = + !highlightBySeries || item.seriesId === null || item.seriesId === seriesId; return indexMatch && seriesMatch; }); diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index ab531b5e4..dc27dbad7 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 fbb3fb37f..df99a2398 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 92b8698ba..c5972c5fb 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 0c2f9405f..000f74e2c 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 979791e35..a69cebdb2 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -42,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. */ @@ -97,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 => { @@ -131,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 @@ -148,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>(); @@ -258,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 => { diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index 59832001e..9d066288b 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'; /** @@ -34,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