diff --git a/CHANGELOG.md b/CHANGELOG.md index 259d447..a099ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ +## [0.3.0](https://github.com/codeherence/react-native-graph/compare/v0.2.0...v0.3.0) (2024-08-08) + + +### Features + +* introduce multiline chart ([ac12f01](https://github.com/codeherence/react-native-graph/commit/ac12f0129799b55955b9ec0b25ad605eb35397c8)) + ## [0.2.0](https://github.com/codeherence/react-native-graph/compare/v0.1.1-rc.2...v0.2.0) (2024-05-12) diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 1da27fa..9d872c8 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -10,6 +10,7 @@ export default () => ( + ); diff --git a/example/app/index.tsx b/example/app/index.tsx index 6fc80e8..5b7851c 100644 --- a/example/app/index.tsx +++ b/example/app/index.tsx @@ -16,6 +16,9 @@ export default () => { navigate("/static_chart")}> Go to static chart + navigate("/multi_line_chart")}> + Go to multi line chart + ); }; diff --git a/example/app/multi_line_chart.tsx b/example/app/multi_line_chart.tsx new file mode 100644 index 0000000..4236697 --- /dev/null +++ b/example/app/multi_line_chart.tsx @@ -0,0 +1,99 @@ +import { Line, MultiLineChart } from "@codeherence/react-native-graph"; +import { Circle, Group } from "@shopify/react-native-skia"; +import * as Haptics from "expo-haptics"; +import { ScrollView, StyleSheet } from "react-native"; +import { runOnJS, useDerivedValue, useSharedValue, withTiming } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { priceMap } from "../src/store/prices"; + +const gestureStartImpact = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); +}; + +export default () => { + const cursorShown = useSharedValue(false); + const x = useSharedValue(0); + const y = useSharedValue(0); + const { bottom, left, right } = useSafeAreaInsets(); + + const opacity = useDerivedValue(() => { + // return cursorShown.value ? 1 : 0; + // Use a timing function to animate the opacity + return withTiming(cursorShown.value ? 1 : 0, { duration: 200 }); + }); + + return ( + + + // + // + // + // + // } + onPanGestureBegin={(payload) => { + "worklet"; + cursorShown.value = true; + x.value = payload.event.x; + y.value = payload.event.y; + runOnJS(gestureStartImpact)(); + }} + onPanGestureEnd={() => { + "worklet"; + cursorShown.value = false; + }} + onPanGestureChange={(payload) => { + "worklet"; + x.value = payload.event.x; + y.value = payload.event.y; + }} + > + {(args) => ( + <> + + + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + contentContainer: { flexGrow: 1 }, + chart: { flex: 1, maxHeight: 200 }, + price: { fontSize: 32 }, + buttonContainer: { + flexDirection: "row", + justifyContent: "center", + paddingVertical: 12, + }, + toggleBtn: { + padding: 12, + backgroundColor: "blue", + borderRadius: 12, + }, + buttonText: { + color: "white", + fontSize: 16, + }, +}); diff --git a/example/assets/circle-pic.png b/example/assets/circle-pic.png deleted file mode 100644 index c49171f..0000000 Binary files a/example/assets/circle-pic.png and /dev/null differ diff --git a/example/assets/planets.jpeg b/example/assets/planets.jpeg deleted file mode 100644 index 9c0fb5c..0000000 Binary files a/example/assets/planets.jpeg and /dev/null differ diff --git a/example/assets/twitter-verified.svg b/example/assets/twitter-verified.svg deleted file mode 100644 index 79319da..0000000 --- a/example/assets/twitter-verified.svg +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index c99ed61..79578ff 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -83,6 +83,8 @@ PODS: - ExpoModulesCore - ExpoFileSystem (16.0.7): - ExpoModulesCore + - ExpoHaptics (12.8.1): + - ExpoModulesCore - ExpoHead (3.4.8): - ExpoModulesCore - ExpoImage (1.10.6): @@ -1231,6 +1233,7 @@ DEPENDENCIES: - ExpoBlur (from `../node_modules/expo-blur/ios`) - ExpoCrypto (from `../node_modules/expo-crypto/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) + - ExpoHaptics (from `../node_modules/expo-haptics/ios`) - ExpoHead (from `../node_modules/expo-router/ios`) - ExpoImage (from `../node_modules/expo-image/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) @@ -1336,6 +1339,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-crypto/ios" ExpoFileSystem: :path: "../node_modules/expo-file-system/ios" + ExpoHaptics: + :path: "../node_modules/expo-haptics/ios" ExpoHead: :path: "../node_modules/expo-router/ios" ExpoImage: @@ -1469,6 +1474,7 @@ SPEC CHECKSUMS: ExpoBlur: e832d874bd94afc0645daddbd3162ec1ce172080 ExpoCrypto: b6428f48599c007676dc81a9b5f72c07e62fdccc ExpoFileSystem: c7488590959bf85ebc114909eb8186cbd62e3a25 + ExpoHaptics: 28a771b630353cd6e8dcf1b1e3e693e38ad7c3c3 ExpoHead: 8224345e80abcf4c97b31c99805dd5a3c8d3404d ExpoImage: 8cf2d51de3d03b7e984e9b0ba8f19c0c22057001 ExpoKeepAwake: 0f5cad99603a3268e50af9a6eb8b76d0d9ac956c diff --git a/example/package.json b/example/package.json index b8fe0c5..abd8a6b 100644 --- a/example/package.json +++ b/example/package.json @@ -17,6 +17,7 @@ "expo-constants": "~15.4.5", "expo-crypto": "~12.8.1", "expo-dev-client": "~3.3.9", + "expo-haptics": "~12.8.1", "expo-image": "~1.10.6", "expo-linking": "~6.2.2", "expo-router": "~3.4.8", diff --git a/example/yarn.lock b/example/yarn.lock index b020305..3a98a89 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -5189,6 +5189,11 @@ expo-font@~11.10.3: dependencies: fontfaceobserver "^2.1.0" +expo-haptics@~12.8.1: + version "12.8.1" + resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-12.8.1.tgz#42b996763be33d661bd33bbc3b3958c3f2734b9d" + integrity sha512-ntLsHkfle8K8w9MW8pZEw92ZN3sguaGUSSIxv30fPKNeQFu7Cq/h47Qv3tONv2MO3wU48N9FbKnant6XlfptpA== + expo-image@~1.10.6: version "1.10.6" resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.10.6.tgz#b0e54d31d97742505296c076a5f18d094ba9a8cc" diff --git a/package.json b/package.json index fc714fb..869d03f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codeherence/react-native-graph", - "version": "0.2.0", + "version": "0.3.0", "description": "A graphing library for React Native built with Shopify's react-native-skia.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/components/LineChart/InteractiveLineChart.tsx b/src/components/LineChart/InteractiveLineChart.tsx index 94118a7..b0211c9 100644 --- a/src/components/LineChart/InteractiveLineChart.tsx +++ b/src/components/LineChart/InteractiveLineChart.tsx @@ -9,7 +9,6 @@ import { Cursor } from "./Cursor"; import { LineChartProps } from "./LineChart"; import { computePath, computeGraphData } from "./computations"; import { useGestures } from "./useGestures"; -import { batchedUpdates } from "../../libs/batchedUpdates"; export const InteractiveLineChart: React.FC> = ({ points = [], @@ -33,8 +32,7 @@ export const InteractiveLineChart: React.FC> = ({ onHoverGestureEnd = null, ...viewProps }) => { - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); + const [{ width, height }, setSize] = useState({ width: 0, height: 0 }); // Initially -cursorRadius so that the cursor is hidden const x = useSharedValue(-cursorRadius); @@ -67,10 +65,7 @@ export const InteractiveLineChart: React.FC> = ({ const onLayout = useCallback((e: LayoutChangeEvent) => { // Batch the updates to avoid unnecessary re-renders - batchedUpdates(() => { - setWidth(e.nativeEvent.layout.width); - setHeight(e.nativeEvent.layout.height); - }); + setSize(e.nativeEvent.layout); }, []); return ( diff --git a/src/components/MultiLineChart/InteractiveMultiLineChart.tsx b/src/components/MultiLineChart/InteractiveMultiLineChart.tsx new file mode 100644 index 0000000..22f3ce2 --- /dev/null +++ b/src/components/MultiLineChart/InteractiveMultiLineChart.tsx @@ -0,0 +1,101 @@ +import { Canvas } from "@shopify/react-native-skia"; +import React, { useCallback, useState } from "react"; +import { LayoutChangeEvent, StyleSheet, View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; + +import type { MultiLineChartProps } from "./MultiLineChart"; +import { useMultiLineChartContext } from "./context"; +import { UseGestureProps, useGestures } from "./useGestures"; +import { batchedUpdates } from "../../libs/batchedUpdates"; + +export interface InteractiveLineChartProps> { + gestureLongPressDelay?: number; + /** + * Extra elements to render on the canvas. This prop is separated from the children prop to allow + * for clear separation between line chart elements and extra elements. + */ + ExtraCanvasElements?: JSX.Element; + onPanGestureBegin?: UseGestureProps["onPanGestureBegin"]; + onPanGestureChange?: UseGestureProps["onPanGestureChange"]; + onPanGestureEnd?: UseGestureProps["onPanGestureEnd"]; +} + +export const InteractiveMultiLineChart = >({ + points, + children, + gestureLongPressDelay = 200, + ExtraCanvasElements, + onCanvasResize, + onPanGestureBegin, + onPanGestureChange, + onPanGestureEnd, + ...viewProps +}: MultiLineChartProps) => { + const [layoutComputed, setLayoutComputed] = useState(false); + const { height, width, setCanvasSize } = useMultiLineChartContext(); + + const gestures = useGestures({ + points, + height, + precision: 2, + gestureLongPressDelay, + onPanGestureBegin, + onPanGestureChange, + onPanGestureEnd, + }); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + // Batch the updates to avoid unnecessary re-renders + batchedUpdates(() => { + setLayoutComputed(true); + onCanvasResize?.(e.nativeEvent.layout.width, e.nativeEvent.layout.height); + setCanvasSize(e.nativeEvent.layout); + }); + }, []); + + return ( + + + + + {!layoutComputed + ? null + : // Since the children need to be invoked, we invoke the children then inject the width and height manually. + (() => { + const invokedChildren = children({ height, width, points }); + if (!React.isValidElement(invokedChildren)) return null; + + if (invokedChildren.type === React.Fragment) { + // If the child is a fragment, iteratively clone all children + return React.Children.map(invokedChildren.props.children, (c) => { + if (!React.isValidElement(c)) return null; + return React.cloneElement(c, { + // @ts-ignore + ...c.props, + width, + height, + }); + }); + } + + return React.cloneElement(invokedChildren, { + ...invokedChildren.props, + width, + height, + }); + })()} + + {ExtraCanvasElements} + + + + + ); +}; +InteractiveMultiLineChart.displayName = "StaticMultiLineChart"; + +const styles = StyleSheet.create({ + root: { position: "relative", overflow: "hidden" }, + container: { flex: 1 }, + canvas: { flex: 1 }, +}); diff --git a/src/components/MultiLineChart/Line.tsx b/src/components/MultiLineChart/Line.tsx new file mode 100644 index 0000000..035760f --- /dev/null +++ b/src/components/MultiLineChart/Line.tsx @@ -0,0 +1,38 @@ +import { Path, PathProps } from "@shopify/react-native-skia"; +import { useMemo } from "react"; + +import { computeGraphData, computePath, ComputePathProps } from "../../utils/math"; + +interface LineProps extends Pick { + /** An array of tuples representing [time, value] pairs. */ + points?: [number, number][]; + /** + * The width of the canvas. This value is not modifiable and will be injected by the parent component. + */ + width?: number; + /** + * The height of the canvas. This value is not modifiable and will be injected by the parent component. + */ + height?: number; + strokeWidth?: number; + curveType?: ComputePathProps["curveType"]; +} + +export const Line: React.FC = ({ + points = [], + width = 0, + height = 0, + strokeWidth = 2, + curveType = "linear", + ...pathProps +}) => { + const data = useMemo(() => { + return computeGraphData(points); + }, [points]); + + const path = useMemo(() => { + return computePath({ ...data, width, height, cursorRadius: 0, curveType }); + }, [data, width, height, curveType]); + + return ; +}; diff --git a/src/components/MultiLineChart/MultiLineChart.tsx b/src/components/MultiLineChart/MultiLineChart.tsx new file mode 100644 index 0000000..189f449 --- /dev/null +++ b/src/components/MultiLineChart/MultiLineChart.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import type { ViewProps } from "react-native"; + +import { InteractiveLineChartProps, InteractiveMultiLineChart } from "./InteractiveMultiLineChart"; +import { StaticMultiLineChart } from "./StaticMultiLineChart"; +import { MultiLineChartProvider } from "./context"; + +export type MultiLineChartProps< + Data extends Record, + Static extends boolean = false, +> = React.PropsWithChildren< + { + points: Data; + onCanvasResize?: (width: number, height: number) => void; + } & Exclude & { + children: (args: { points: Data; height: number; width: number }) => React.ReactNode; + } & (Static extends true + ? { isStatic: true } + : { isStatic: false } & InteractiveLineChartProps) +>; + +export const MultiLineChart = < + Data extends Record, + Static extends boolean = false, +>({ + isStatic = false, + ...props +}: MultiLineChartProps) => { + return ( + + {/* */} + {isStatic ? ( + + ) : ( + + )} + + ); +}; +MultiLineChart.displayName = "MultiLineChart"; diff --git a/src/components/MultiLineChart/StaticMultiLineChart.tsx b/src/components/MultiLineChart/StaticMultiLineChart.tsx new file mode 100644 index 0000000..bbe86cc --- /dev/null +++ b/src/components/MultiLineChart/StaticMultiLineChart.tsx @@ -0,0 +1,93 @@ +import { Canvas } from "@shopify/react-native-skia"; +import React, { useCallback, useState } from "react"; +import { LayoutChangeEvent, StyleSheet, View } from "react-native"; + +import type { MultiLineChartProps } from "./MultiLineChart"; +import { useMultiLineChartContext } from "./context"; +import { batchedUpdates } from "../../libs/batchedUpdates"; + +export const StaticMultiLineChart = >({ + points, + children, + onCanvasResize, + ...viewProps +}: MultiLineChartProps) => { + const [layoutComputed, setLayoutComputed] = useState(false); + const { height, width, setCanvasSize } = useMultiLineChartContext(); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + // Batch the updates to avoid unnecessary re-renders + batchedUpdates(() => { + setLayoutComputed(true); + onCanvasResize?.(e.nativeEvent.layout.width, e.nativeEvent.layout.height); + setCanvasSize(e.nativeEvent.layout); + }); + }, []); + + return ( + + + + {!layoutComputed + ? null + : // Since the children need to be invoked, we invoke the children then inject the width and height manually. + (() => { + const invokedChildren = children({ height, width, points }); + if (!React.isValidElement(invokedChildren)) return null; + + if (invokedChildren.type === React.Fragment) { + // If the child is a fragment, iteratively clone all children + return React.Children.map(invokedChildren.props.children, (c) => { + if (!React.isValidElement(c)) return null; + return React.cloneElement(c, { + // @ts-ignore + ...c.props, + width, + height, + }); + }); + } + + return React.cloneElement(invokedChildren, { + ...invokedChildren.props, + width, + height, + }); + })()} + + + + ); +}; +StaticMultiLineChart.displayName = "StaticMultiLineChart"; + +const styles = StyleSheet.create({ + root: { position: "relative", overflow: "hidden" }, + container: { flex: 1 }, + canvas: { flex: 1 }, +}); + +// : React.Children.map(children, (child) => { +// if (!React.isValidElement(child)) return null; +// if (child.type === React.Fragment) { +// // If the child is a fragment, iteratively clone all children +// return React.Children.map(child.props.children, (c) => { +// if (!React.isValidElement(c)) return null; +// return React.cloneElement(c, { +// // @ts-ignore +// ...c.props, +// width, +// height, +// // @ts-ignore +// points: points[c.props.dataKey] ?? [], +// }); +// }); +// } + +// return React.cloneElement(child, { +// ...child.props, +// width, +// height, +// points: points[child.props.dataKey] ?? [], +// }); +// }) diff --git a/src/components/MultiLineChart/context.tsx b/src/components/MultiLineChart/context.tsx new file mode 100644 index 0000000..937aa46 --- /dev/null +++ b/src/components/MultiLineChart/context.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useState } from "react"; +import { LayoutRectangle } from "react-native"; + +interface MultiLineChartContextState { + width: number; + height: number; + setCanvasSize: React.Dispatch>; +} + +export const MultiLineChartContext = createContext( + undefined +); + +interface MultiLineChartProviderProps { + width?: number; + height?: number; +} + +export const MultiLineChartProvider: React.FC< + React.PropsWithChildren +> = ({ children }) => { + const [canvasSize, setCanvasSize] = useState({ + height: 0, + width: 0, + x: 0, + y: 0, + }); + + return ( + + {children} + + ); +}; + +export const useMultiLineChartContext = () => { + const context = useContext(MultiLineChartContext); + if (!context) { + throw new Error("useMultiLineChartContext must be used within a MultiLineChartProvider"); + } + return context; +}; diff --git a/src/components/MultiLineChart/helpers.ts b/src/components/MultiLineChart/helpers.ts new file mode 100644 index 0000000..dc17e5b --- /dev/null +++ b/src/components/MultiLineChart/helpers.ts @@ -0,0 +1,32 @@ +import React from "react"; + +import { Line } from "./Line"; + +const isAllowedChild = (child: React.ReactElement): boolean => { + // Check if child is a fragment. If so, recursively check all children + if (child.type === React.Fragment) { + return React.Children.toArray(child.props.children).every( + (c) => React.isValidElement(c) && isAllowedChild(c) + ); + } + return [Line].some((allowedChild) => child.type === allowedChild); +}; + +/** + * Throws an error if the children of the line chart component are not valid. + * @param children The children of the line chart component. + */ +export const validateLineChartChildren = (children?: React.ReactNode | undefined) => { + // Only perform this validation if the environment is development + if (process.env.NODE_ENV !== "development") return; + + const validChildren = React.Children.toArray(children).every((child) => { + return ( + typeof child !== "string" && + (child === React.Fragment || (React.isValidElement(child) && isAllowedChild(child))) + ); + }); + if (!validChildren) { + throw new Error("MultiLineChart only accepts Line components as children."); + } +}; diff --git a/src/components/MultiLineChart/types.ts b/src/components/MultiLineChart/types.ts new file mode 100644 index 0000000..5ff8e9c --- /dev/null +++ b/src/components/MultiLineChart/types.ts @@ -0,0 +1,4 @@ +export interface BaseCanvasComponentProps { + width: number; + height: number; +} diff --git a/src/components/MultiLineChart/useGestures.tsx b/src/components/MultiLineChart/useGestures.tsx new file mode 100644 index 0000000..d201c3c --- /dev/null +++ b/src/components/MultiLineChart/useGestures.tsx @@ -0,0 +1,124 @@ +import { SkPath } from "@shopify/react-native-skia"; +import { useEffect, useMemo } from "react"; +import { + Gesture, + type PanGestureHandlerEventPayload as ReanimatedPanGestureHandlerEventPayload, + type PanGestureChangeEventPayload, +} from "react-native-gesture-handler"; +import { interpolate, useSharedValue } from "react-native-reanimated"; + +import { useMultiLineChartContext } from "./context"; +import { computeGraphData, computePath, getYForX } from "../../utils/math"; + +export type PanGestureHandlerEventPayload = ReanimatedPanGestureHandlerEventPayload; +export type PanGestureHandlerOnBeginEventPayload> = + { + points: Record; + event: PanGestureHandlerEventPayload; + }; +export type PanGestureHandlerOnChangeEventPayload> = + { + points: Record; + event: PanGestureHandlerEventPayload & PanGestureChangeEventPayload; + }; + +// Extract Hover Gesture onBegin args since it isn't exported by rngh +export type HoverGestureOnBegin = ReturnType["onBegin"]; +export type HoverGestureOnBeginCallBack = Parameters[0]; +export type HoverGestureHandlerOnBeginEventPayload = { + point: number; + event: Parameters[0]; +}; + +export type UseGestureProps> = { + /** The path of the chart. */ + points: Data; + /** The height of the chart. */ + height: number; + /** The precision of the y value. */ + precision: number; + curveType?: "linear"; + gestureLongPressDelay?: number; + onPanGestureBegin?: ((payload: PanGestureHandlerOnBeginEventPayload) => void) | null; + onPanGestureChange?: ((payload: PanGestureHandlerOnChangeEventPayload) => void) | null; + onPanGestureEnd?: ((payload: PanGestureHandlerEventPayload) => void) | null; +}; + +/** + * Returns the gesture handlers for the LineChart component. + * @param param0 - The props to allow the gesture handlers to interact with the + * LineChart component. + * @returns The gesture handlers for the LineChart component. + */ +export const useGestures = >({ + points, + height, + precision, + curveType = "linear", + gestureLongPressDelay = 100, + onPanGestureBegin, + onPanGestureChange, + onPanGestureEnd, +}: UseGestureProps) => { + const { width } = useMultiLineChartContext(); + + const graphData = useMemo(() => { + return Object.entries(points).reduce( + (acc, [key, value]) => { + return { ...acc, [key]: computeGraphData(value) }; + }, + {} as Record> + ); + }, [points]); + + const pathsJS = useMemo(() => { + const results = {} as Record; + + const pathKeys = Object.keys(graphData) as (keyof Data)[]; + for (const key of pathKeys) { + const value = graphData[key]; + results[key] = computePath({ ...value, height, width, curveType }); + } + + return results; + }, [graphData, height, width, curveType]); + + const paths = useSharedValue>(pathsJS); + useEffect(() => { + paths.value = pathsJS; + }, [pathsJS]); + + const panGesture = Gesture.Pan() + .activateAfterLongPress(gestureLongPressDelay) + .onStart((event) => { + const pathKeys = Object.keys(paths.value) as (keyof Data)[]; + const yValues = {} as Record; + for (const key of pathKeys) { + const path = paths.value[key]!; + const rawYValue = getYForX({ path, x: event.x, precision }); + const { minValue, maxValue } = graphData[key]; + const yValue = interpolate(rawYValue, [0, height], [maxValue, minValue]); + yValues[key] = yValue; + } + + if (onPanGestureBegin) onPanGestureBegin({ event, points: yValues }); + }) + .onChange((event) => { + const pathKeys = Object.keys(paths.value) as (keyof Data)[]; + const yValues = {} as Record; + for (const key of pathKeys) { + const path = paths.value[key]!; + const rawYValue = getYForX({ path, x: event.x, precision }); + const { minValue, maxValue } = graphData[key]; + const yValue = interpolate(rawYValue, [0, height], [maxValue, minValue]); + yValues[key] = yValue; + } + + if (onPanGestureChange) onPanGestureChange({ event, points: yValues }); + }) + .onEnd((event) => { + if (onPanGestureEnd) onPanGestureEnd(event); + }); + + return panGesture; +}; diff --git a/src/components/index.ts b/src/components/index.ts index 195cbf7..a6413cc 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,8 @@ export { type InteractiveLineChartProps, type PathFillProps, } from "./LineChart/LineChart"; +export { MultiLineChart, type MultiLineChartProps } from "./MultiLineChart/MultiLineChart"; +export { Line } from "./MultiLineChart/Line"; export type { AxisLabelProps } from "./LineChart/AxisLabel"; export type { PanGestureHandlerOnBeginEventPayload, diff --git a/src/index.ts b/src/index.ts index 1ef527d..eb08343 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ export { LineChart, + Line, + MultiLineChart, type LineChartProps, + type MultiLineChartProps, type AxisLabelProps, type PanGestureHandlerOnBeginEventPayload, type PanGestureHandlerOnChangeEventPayload, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..d13d2c1 --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,188 @@ +import type { Vector, SkPath } from "@shopify/react-native-skia"; +import { PathVerb, Skia, vec } from "@shopify/react-native-skia"; +import { scaleSqrt, scaleTime } from "d3"; +import { CurveFactory, curveLinear, line } from "d3-shape"; + +interface RoundProps { + value: number; + precision?: number; +} + +const round = ({ value, precision = 0 }: RoundProps): number => { + "worklet"; + const p = Math.pow(10, precision); + return Math.round(value * p) / p; +}; + +interface LinearYForXProps { + path: SkPath; + x: number; + precision?: number; +} + +const linearYForX = ({ path, x, precision = 2 }: LinearYForXProps): number => { + "worklet"; + const cmds = path.toCmds(); + let from: Vector = vec(0, 0); + let found = false; + let yValue = 0; + + for (let i = 0; i < cmds.length; i++) { + const cmd = cmds[i]; + if (cmd == null) break; + if (cmd[0] === PathVerb.Move) { + // If the path starts with a move command, set the from point + from = vec(cmd[1], cmd[2]); + } else if (cmd[0] === PathVerb.Line) { + // If the path contains a line command, check if the x value is within the bounds of the line + const to = vec(cmd[1], cmd[2]); + if ((x >= from.x && x <= to.x) || (x <= from.x && x >= to.x)) { + const t = (x - from.x) / (to.x - from.x); + yValue = from.y + t * (to.y - from.y); + found = true; + break; + } + from = to; + } + } + + return found ? round({ value: yValue, precision }) : 0; +}; + +export interface GetYForXProps { + path: SkPath; + x: number; + precision?: number; +} + +export const getYForX = ({ path, x, precision = 2 }: GetYForXProps): number => { + "worklet"; + return linearYForX({ path, x, precision }); +}; + +export interface ComputePathProps { + width: number; + height: number; + points: [number, number][]; + cursorRadius?: number; + minValue: number; + maxValue: number; + minTimestamp: number; + maxTimestamp: number; + curveType: "linear"; +} + +export const computePath = ({ + width, + height, + points, + cursorRadius = 0, + minTimestamp, + maxTimestamp, + minValue, + maxValue, + curveType, +}: ComputePathProps): SkPath => { + "worklet"; + + const straightLine = Skia.Path.Make() + .moveTo(0, height / 2) + .lineTo(width, height / 2); + + // If the dates array is empty, return a Path as a horizontal straight line + // in the center of the chart + if (points.length === 0) return straightLine; + + const scaleX = scaleTime().domain([minTimestamp, maxTimestamp]).range([0, width]); + const scaleY = scaleSqrt() + .domain([minValue, maxValue]) + .range([height - cursorRadius, cursorRadius]); + const curve: CurveFactory = curveType === "linear" ? curveLinear : curveLinear; + const rawPath = line() + .x(([x]) => scaleX(x)) + .y(([, y]) => scaleY(y)) + .curve(curve)(points); + + if (rawPath === null) return straightLine; + return Skia.Path.MakeFromSVGString(rawPath) ?? straightLine; +}; + +interface GetMinValueProps { + points: [number, number][]; +} + +/** + * Get the index and value of the minimum value in the points array + * @returns The index and value of the minimum value in the points array + */ +const getMinValue = ({ points }: GetMinValueProps): [number, number] => { + if (points.length === 0) return [0, 0]; + + return points.reduce<[number, number]>( + (acc, [_, value], index) => { + if (value < acc[1]) return [index, value]; + return acc; + }, + [0, Number.MAX_SAFE_INTEGER] + ); +}; + +interface GetMaxValueProps { + points: [number, number][]; +} + +/** + * Get the index and value of the maximum value in the points array + * @returns The index and value of the maximum value in the points array + */ +const getMaxValue = ({ points }: GetMaxValueProps): [number, number] => { + if (points.length === 0) return [0, 0]; + + return points.reduce<[number, number]>( + (acc, [_, value], index) => { + if (value > acc[1]) return [index, value]; + return acc; + }, + [0, Number.MIN_SAFE_INTEGER] + ); +}; + +export const computeGraphData = (points: [number, number][]) => { + "worklet"; + + if (points.length === 0) { + return { + points: [], + minTimestamp: 0, + maxTimestamp: 0, + minValue: 0, + minValueIndex: 0, + minValueXProportion: 0, + maxValue: 0, + maxValueIndex: 0, + maxValueXProportion: 0, + }; + } + + const timestamps = points.map(([timestamp]) => timestamp); + const minTimestamp = Math.min(...timestamps); + const maxTimestamp = Math.max(...timestamps); + const [minValueIndex, minValue] = getMinValue({ points }); + const [maxValueIndex, maxValue] = getMaxValue({ points }); + + // We subtract 1 since the index is 0-based + const minValueXProportion = minValueIndex / (points.length - 1); + const maxValueXProportion = maxValueIndex / (points.length - 1); + + return { + points, + minTimestamp, + maxTimestamp, + minValue, + minValueIndex, + minValueXProportion, + maxValue, + maxValueIndex, + maxValueXProportion, + }; +};