UNPKG

react-native-graph-plus

Version:

📈 Beautiful, high-performance Graphs and Charts for React Native +

390 lines (362 loc) • 14.9 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View, StyleSheet } from 'react-native'; import Reanimated, { runOnJS, useAnimatedReaction, useSharedValue, useDerivedValue, cancelAnimation, withRepeat, withSequence, withTiming, withDelay, withSpring } from 'react-native-reanimated'; import { GestureDetector } from 'react-native-gesture-handler'; import { Canvas, runSpring, LinearGradient, Path, Skia, useValue, useComputedValue, vec, Group, mix, Circle, Shadow } from '@shopify/react-native-skia'; import { SelectionDot as DefaultSelectionDot } from './SelectionDot'; import { createGraphPath, createGraphPathWithGradient, getGraphPathRange, getXInRange, getPointsInRange } from './CreateGraphPath'; import { getSixDigitHex } from './utils/getSixDigitHex'; import { usePanGesture } from './hooks/usePanGesture'; import { getYForX } from './GetYForX'; import { hexToRgba } from './utils/hexToRgba'; const INDICATOR_RADIUS = 7; const INDICATOR_BORDER_MULTIPLIER = 1.3; const INDICATOR_PULSE_BLUR_RADIUS_SMALL = INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER; const INDICATOR_PULSE_BLUR_RADIUS_BIG = INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER + 20; export function AnimatedLineGraph(_ref) { let { points: allPoints, color, gradientFillColors, lineThickness = 3, range, enableFadeInMask, enablePanGesture = false, onPointSelected, onGestureStart, onGestureEnd, panGestureDelay = 300, SelectionDot = DefaultSelectionDot, enableIndicator = false, incrementPanBy, indicatorPulsating = false, horizontalPadding = enableIndicator ? Math.ceil(INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER) : 0, verticalPadding = lineThickness, TopAxisLabel, BottomAxisLabel, ...props } = _ref; const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const interpolateProgress = useValue(0); const { gesture, isActive, x } = usePanGesture({ enabled: enablePanGesture, holdDuration: panGestureDelay }); const circleX = useSharedValue(0); const circleY = useSharedValue(0); const pathEnd = useSharedValue(0); const indicatorRadius = useSharedValue(enableIndicator ? INDICATOR_RADIUS : 0); const indicatorBorderRadius = useDerivedValue(() => indicatorRadius.value * INDICATOR_BORDER_MULTIPLIER); const pulseTrigger = useDerivedValue(() => isActive.value ? 1 : 0); const indicatorPulseAnimation = useSharedValue(0); const indicatorPulseRadius = useDerivedValue(() => { if (pulseTrigger.value === 0) { return mix(indicatorPulseAnimation.value, INDICATOR_PULSE_BLUR_RADIUS_SMALL, INDICATOR_PULSE_BLUR_RADIUS_BIG); } return 0; }); const indicatorPulseOpacity = useDerivedValue(() => { if (pulseTrigger.value === 0) { return mix(indicatorPulseAnimation.value, 1, 0); } return 0; }); const positions = useDerivedValue(() => [0, Math.min(0.15, pathEnd.value), pathEnd.value, pathEnd.value, 1]); const onLayout = useCallback(_ref2 => { let { nativeEvent: { layout } } = _ref2; setWidth(Math.round(layout.width)); setHeight(Math.round(layout.height)); }, []); const straightLine = useMemo(() => { const path = Skia.Path.Make(); path.moveTo(0, height / 2); for (let i = 0; i < width - 1; i += 2) { const y = height / 2; path.cubicTo(i, y, i, y, i, y); } return path; }, [height, width]); const paths = useValue({}); const gradientPaths = useValue({}); const commands = useSharedValue([]); const [commandsChanged, setCommandsChanged] = useState(0); const pointSelectedIndex = useRef(); const pathRange = useMemo(() => getGraphPathRange(allPoints, range), [allPoints, range]); const pointsInRange = useMemo(() => getPointsInRange(allPoints, pathRange), [allPoints, pathRange]); const drawingWidth = useMemo(() => width - 2 * horizontalPadding, [horizontalPadding, width]); const lineWidth = useMemo(() => { const lastPoint = pointsInRange[pointsInRange.length - 1]; if (lastPoint == null) return drawingWidth; return Math.max(getXInRange(drawingWidth, lastPoint.date, pathRange.x), 0); }, [drawingWidth, pathRange.x, pointsInRange]); const indicatorX = useDerivedValue(() => Math.floor(lineWidth) + horizontalPadding); const indicatorY = useDerivedValue(() => getYForX(commands.value, indicatorX.value) || 0); const indicatorPulseColor = useMemo(() => hexToRgba(color, 0.4), [color]); const shouldFillGradient = gradientFillColors != null; useEffect(() => { var _previous$to2, _from$interpolate2; if (height < 1 || width < 1) { // view is not yet measured! return; } if (pointsInRange.length < 1) { // points are still empty! return; } let path; let gradientPath; const createGraphPathProps = { pointsInRange, range: pathRange, horizontalPadding, verticalPadding, canvasHeight: height, canvasWidth: width }; if (shouldFillGradient) { const { path: pathNew, gradientPath: gradientPathNew } = createGraphPathWithGradient(createGraphPathProps); path = pathNew; gradientPath = gradientPathNew; } else { path = createGraphPath(createGraphPathProps); } commands.value = path.toCmds(); if (gradientPath != null) { var _previous$to, _from$interpolate; const previous = gradientPaths.current; let from = (_previous$to = previous.to) !== null && _previous$to !== void 0 ? _previous$to : straightLine; if (previous.from != null && interpolateProgress.current < 1) from = (_from$interpolate = from.interpolate(previous.from, interpolateProgress.current)) !== null && _from$interpolate !== void 0 ? _from$interpolate : from; if (gradientPath.isInterpolatable(from)) { gradientPaths.current = { from, to: gradientPath }; } else { gradientPaths.current = { from: gradientPath, to: gradientPath }; } } const previous = paths.current; let from = (_previous$to2 = previous.to) !== null && _previous$to2 !== void 0 ? _previous$to2 : straightLine; if (previous.from != null && interpolateProgress.current < 1) from = (_from$interpolate2 = from.interpolate(previous.from, interpolateProgress.current)) !== null && _from$interpolate2 !== void 0 ? _from$interpolate2 : from; if (path.isInterpolatable(from)) { paths.current = { from, to: path }; } else { paths.current = { from: path, to: path }; } setCommandsChanged(commandsChanged + 1); runSpring(interpolateProgress, { from: 0, to: 1 }, { mass: 1, stiffness: 500, damping: 400, velocity: 0 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [height, horizontalPadding, interpolateProgress, pathRange, paths, shouldFillGradient, gradientPaths, pointsInRange, range, straightLine, verticalPadding, width]); const gradientColors = useMemo(() => { if (enableFadeInMask) { return [`${getSixDigitHex(color)}00`, `${getSixDigitHex(color)}ff`, `${getSixDigitHex(color)}ff`, `${getSixDigitHex(color)}33`, `${getSixDigitHex(color)}33`]; } return [color, color, color, `${getSixDigitHex(color)}33`, `${getSixDigitHex(color)}33`]; }, [color, enableFadeInMask]); const path = useComputedValue(() => { var _paths$current$from, _paths$current$to; const from = (_paths$current$from = paths.current.from) !== null && _paths$current$from !== void 0 ? _paths$current$from : straightLine; const to = (_paths$current$to = paths.current.to) !== null && _paths$current$to !== void 0 ? _paths$current$to : straightLine; return to.interpolate(from, interpolateProgress.current); }, // RN Skia deals with deps differently. They are actually the required SkiaValues that the derived value listens to, not react values. [interpolateProgress]); const gradientPath = useComputedValue(() => { var _gradientPaths$curren, _gradientPaths$curren2; const from = (_gradientPaths$curren = gradientPaths.current.from) !== null && _gradientPaths$curren !== void 0 ? _gradientPaths$curren : straightLine; const to = (_gradientPaths$curren2 = gradientPaths.current.to) !== null && _gradientPaths$curren2 !== void 0 ? _gradientPaths$curren2 : straightLine; return to.interpolate(from, interpolateProgress.current); }, // RN Skia deals with deps differently. They are actually the required SkiaValues that the derived value listens to, not react values. [interpolateProgress]); const stopPulsating = useCallback(() => { cancelAnimation(indicatorPulseAnimation); indicatorPulseAnimation.value = 0; }, [indicatorPulseAnimation]); const startPulsating = useCallback(() => { indicatorPulseAnimation.value = withRepeat(withDelay(1000, withSequence(withTiming(1, { duration: 1100 }), withTiming(0, { duration: 0 }), // revert to 0 withTiming(0, { duration: 1200 }), // delay between pulses withTiming(1, { duration: 1100 }), withTiming(1, { duration: 2000 }) // delay after both pulses )), -1); }, [indicatorPulseAnimation]); const setFingerPoint = useCallback(fingerX => { const fingerXInRange = Math.max(fingerX - horizontalPadding, 0); const index = Math.round(fingerXInRange / getXInRange(drawingWidth, pointsInRange[pointsInRange.length - 1].date, pathRange.x) * (pointsInRange.length - 1)); const pointIndex = Math.min(Math.max(index, 0), pointsInRange.length - 1); if (pointSelectedIndex.current !== pointIndex) { const dataPoint = pointsInRange[pointIndex]; pointSelectedIndex.current = pointIndex; if (dataPoint != null) { onPointSelected === null || onPointSelected === void 0 ? void 0 : onPointSelected(dataPoint); } } }, [drawingWidth, horizontalPadding, onPointSelected, pathRange.x, pointsInRange]); const setFingerX = useCallback(fingerX => { 'worklet'; const newFingerX = incrementPanBy ? Math.round(fingerX / incrementPanBy) * incrementPanBy : fingerX; const y = getYForX(commands.value, newFingerX); if (y != null) { circleX.value = newFingerX; circleY.value = y; } if (isActive.value) pathEnd.value = fingerX / width; }, // pathRange.x must be extra included in deps otherwise onPointSelected doesn't work, IDK why // eslint-disable-next-line react-hooks/exhaustive-deps [circleX, circleY, isActive, pathEnd, pathRange.x, width, commands]); const setIsActive = useCallback(active => { indicatorRadius.value = withSpring(!active ? INDICATOR_RADIUS : 0, { mass: 1, stiffness: 1000, damping: 50, velocity: 0 }); if (active) { onGestureStart === null || onGestureStart === void 0 ? void 0 : onGestureStart(); stopPulsating(); } else { onGestureEnd === null || onGestureEnd === void 0 ? void 0 : onGestureEnd(); if (pointsInRange) { const dataPoint = pointsInRange[pointsInRange.length - 1]; pointSelectedIndex.current = pointsInRange.length - 1; if (dataPoint) { onPointSelected === null || onPointSelected === void 0 ? void 0 : onPointSelected(dataPoint); } } else { pointSelectedIndex.current = undefined; } pathEnd.value = 1; startPulsating(); } }, [indicatorRadius, onGestureEnd, onGestureStart, onPointSelected, pathEnd, pointsInRange, startPulsating, stopPulsating]); useAnimatedReaction(() => x.value, fingerX => { if (isActive.value || fingerX) { setFingerX(fingerX); runOnJS(setFingerPoint)(fingerX); } }, [isActive, setFingerX, width, x]); useAnimatedReaction(() => isActive.value, active => { runOnJS(setIsActive)(active); }, [isActive, setIsActive]); useEffect(() => { if (pointsInRange.length !== 0 && commands.value.length !== 0) pathEnd.value = 1; }, [commands, pathEnd, pointsInRange.length]); useEffect(() => { if (indicatorPulsating) { startPulsating(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [indicatorPulsating]); const axisLabelContainerStyle = { paddingTop: TopAxisLabel != null ? 20 : 0, paddingBottom: BottomAxisLabel != null ? 20 : 0 }; const indicatorVisible = enableIndicator && commandsChanged > 0; return /*#__PURE__*/React.createElement(View, props, /*#__PURE__*/React.createElement(GestureDetector, { gesture: gesture }, /*#__PURE__*/React.createElement(Reanimated.View, { style: [styles.container, axisLabelContainerStyle] }, TopAxisLabel != null && /*#__PURE__*/React.createElement(View, { style: styles.axisRow }, /*#__PURE__*/React.createElement(TopAxisLabel, null)), /*#__PURE__*/React.createElement(View, { style: styles.container, onLayout: onLayout }, /*#__PURE__*/React.createElement(Canvas, { style: styles.svg }, /*#__PURE__*/React.createElement(Group, null, /*#__PURE__*/React.createElement(Path // @ts-expect-error , { path: path, strokeWidth: lineThickness, style: "stroke", strokeJoin: "round", strokeCap: "round" }, /*#__PURE__*/React.createElement(LinearGradient, { start: vec(0, 0), end: vec(width, 0), colors: gradientColors, positions: positions })), shouldFillGradient && /*#__PURE__*/React.createElement(Path // @ts-expect-error , { path: gradientPath }, /*#__PURE__*/React.createElement(LinearGradient, { start: vec(0, 0), end: vec(0, height), colors: gradientFillColors }))), SelectionDot != null && /*#__PURE__*/React.createElement(SelectionDot, { isActive: isActive, color: color, lineThickness: lineThickness, circleX: circleX, circleY: circleY }), indicatorVisible && /*#__PURE__*/React.createElement(Group, null, indicatorPulsating && /*#__PURE__*/React.createElement(Circle, { cx: indicatorX, cy: indicatorY, r: indicatorPulseRadius, opacity: indicatorPulseOpacity, color: indicatorPulseColor, style: "fill" }), /*#__PURE__*/React.createElement(Circle, { cx: indicatorX, cy: indicatorY, r: indicatorBorderRadius, color: "#ffffff" }, /*#__PURE__*/React.createElement(Shadow, { dx: 2, dy: 2, color: "rgba(0,0,0,0.2)", blur: 4 })), /*#__PURE__*/React.createElement(Circle, { cx: indicatorX, cy: indicatorY, r: indicatorRadius, color: color })))), BottomAxisLabel != null && /*#__PURE__*/React.createElement(View, { style: styles.axisRow }, /*#__PURE__*/React.createElement(BottomAxisLabel, null))))); } const styles = StyleSheet.create({ svg: { flex: 1 }, container: { flex: 1 }, axisRow: { height: 17 } }); //# sourceMappingURL=AnimatedLineGraph.js.map