import React from 'react'; import Animated, { useSharedValue } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { GestureDetector, LongPressGestureEvent, useLongPressGesture, } from 'react-native-gesture-handler'; import { LineChartDimensionsContext } from './Chart'; import { StyleSheet } from 'react-native'; import { bisectCenter } from 'd3-array'; import { scaleLinear } from 'd3-scale'; import { useLineChart } from './useLineChart'; import { useEffect } from 'react'; export type LineChartCursorProps = { children: React.ReactNode; type: 'line' | 'crosshair'; snapToPoint?: boolean; at?: number; shouldCancelWhenOutside?: boolean; minDurationMs?: number; onActivated?: () => void; onEnded?: () => void; orientation?: 'horizontal' | 'vertical'; }; export const CursorContext = React.createContext({ type: '' }); const hapticFeedback = () => { ReactNativeHapticFeedback.trigger("impactLight"); }; LineChartCursor.displayName = 'LineChartCursor'; export function LineChartCursor({ children, snapToPoint, type, at, shouldCancelWhenOutside = false, minDurationMs = 0, onActivated, onEnded, }: LineChartCursorProps) { const { pathWidth: width, parsedPath } = React.useContext( LineChartDimensionsContext ); const { currentX, currentIndex, isActive, data, xDomain } = useLineChart(); const lastHapticAt = useSharedValue(0); const xValues = React.useMemo( () => (data ?? []).map(({ timestamp }, i) => (xDomain ? timestamp : i)), [data, xDomain] ); // Same scale as in /src/charts/line/utils/getPath.ts const scaleX = React.useMemo(() => { const domainArray = xDomain ?? [0, xValues.length]; return scaleLinear().domain(domainArray).range([0, width]); }, [width, xDomain, xValues.length]); const linearScalePositionAndIndex = ({ xPosition, }: { xPosition: number; }) => { if (!parsedPath) { return; } // Calculate a scaled timestamp for the current touch position const xRelative = scaleX.invert(xPosition); const closestIndex = bisectCenter(xValues, xRelative); const pathDataDelta = Math.abs(parsedPath.curves.length - xValues.length); // sometimes there is a difference between data length and number of path curves. const closestPathCurve = Math.max( Math.min(closestIndex, parsedPath.curves.length + 1) - pathDataDelta, 0 ); const newXPosition = ( closestIndex > 0 ? parsedPath.curves[closestPathCurve]!.to : parsedPath.move ).x; // Update values currentIndex.value = closestIndex; currentX.value = newXPosition; }; useEffect(() => { if (at !== undefined) { const xPosition = scaleX(at); scheduleOnRN(linearScalePositionAndIndex, { xPosition }); isActive.value = true; } }, [at, scaleX]); const updatePosition = (xPosition: number) => { 'worklet'; if (parsedPath) { // on Web, we could drag the cursor to be negative, breaking it // so we clamp the index at 0 to fix it // https://github.com/coinjar/react-native-wagmi-charts/issues/24 const minIndex = 0; const boundedIndex = Math.max( minIndex, Math.round(xPosition / width / (1 / (data ? data.length - 1 : 1))) ); // Haptic only when the cursor crosses onto a new data point, at most // every 50ms. Firing per touch-move event floods the JS thread and the // haptic engine (~60+ calls/sec at 120Hz). if (boundedIndex !== currentIndex.value) { const now = Date.now(); if (now - lastHapticAt.value >= 50) { lastHapticAt.value = now; scheduleOnRN(hapticFeedback); } } if (snapToPoint) { // We have to run this on the JS thread unfortunately as the scaleLinear functions won't work on UI thread scheduleOnRN(linearScalePositionAndIndex, { xPosition }); } else if (!snapToPoint) { currentX.value = xPosition; currentIndex.value = boundedIndex; } } }; const longPressGesture = useLongPressGesture({ minDuration: minDurationMs ?? 0, maxDistance: 999999, shouldCancelWhenOutside, onActivate: (event: LongPressGestureEvent) => { 'worklet'; if (parsedPath) { const xPosition = Math.max(0, event.x <= width ? event.x : width); isActive.value = true; updatePosition(xPosition); if (onActivated) { scheduleOnRN(onActivated); } } }, onTouchesMove: (event) => { 'worklet'; if (parsedPath && isActive.value && event.allTouches.length > 0) { const xPosition = Math.max( 0, event.allTouches[0]!.x <= width ? event.allTouches[0]!.x : width ); updatePosition(xPosition); } }, onDeactivate: () => { 'worklet'; isActive.value = false; currentIndex.value = -1; if (onEnded) { scheduleOnRN(onEnded); } }, }); return ( {children} ); }