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 (
+
+
+
+
+
+
+
+ );
+};
+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 (
+
+
+
+
+
+ );
+};
+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,
+ };
+};