Skip to content

Commit

Permalink
feat: introduce multiline chart
Browse files Browse the repository at this point in the history
Key Highlights:
- Major API refactor to support multiline charts
  • Loading branch information
e-younan committed Aug 8, 2024
1 parent 6d9c871 commit ac12f01
Show file tree
Hide file tree
Showing 22 changed files with 790 additions and 13 deletions.
1 change: 1 addition & 0 deletions example/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default () => (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen name="chart" />
<Stack.Screen name="multi_line_chart" />
</Stack>
</GestureHandlerRootView>
);
Expand Down
3 changes: 3 additions & 0 deletions example/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default () => {
<Pressable onPress={() => navigate("/static_chart")}>
<Text style={styles.link}>Go to static chart</Text>
</Pressable>
<Pressable onPress={() => navigate("/multi_line_chart")}>
<Text style={styles.link}>Go to multi line chart</Text>
</Pressable>
</ScrollView>
);
};
Expand Down
99 changes: 99 additions & 0 deletions example/app/multi_line_chart.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
{
paddingBottom: bottom,
paddingLeft: left,
paddingRight: right,
},
]}
showsVerticalScrollIndicator={false}
>
<MultiLineChart
isStatic={false}
points={priceMap}
style={styles.chart}
// ExtraCanvasElements={
// <>
// <Group color="blue" opacity={opacity}>
// <Circle cx={x} cy={y} r={10} />
// </Group>
// </>
// }
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) => (
<>
<Line points={args.points.aapl} strokeWidth={1} color="green" />
<Line points={args.points.msft} strokeWidth={1} color="purple" />
<Line points={args.points.nvda} strokeWidth={1} color="black" />
<Line points={args.points.unity} strokeWidth={1} color="orange" />
</>
)}
</MultiLineChart>
</ScrollView>
);
};

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,
},
});
Binary file removed example/assets/circle-pic.png
Binary file not shown.
Binary file removed example/assets/planets.jpeg
Binary file not shown.
6 changes: 0 additions & 6 deletions example/assets/twitter-verified.svg

This file was deleted.

6 changes: 6 additions & 0 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1469,6 +1474,7 @@ SPEC CHECKSUMS:
ExpoBlur: e832d874bd94afc0645daddbd3162ec1ce172080
ExpoCrypto: b6428f48599c007676dc81a9b5f72c07e62fdccc
ExpoFileSystem: c7488590959bf85ebc114909eb8186cbd62e3a25
ExpoHaptics: 28a771b630353cd6e8dcf1b1e3e693e38ad7c3c3
ExpoHead: 8224345e80abcf4c97b31c99805dd5a3c8d3404d
ExpoImage: 8cf2d51de3d03b7e984e9b0ba8f19c0c22057001
ExpoKeepAwake: 0f5cad99603a3268e50af9a6eb8b76d0d9ac956c
Expand Down
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 2 additions & 7 deletions src/components/LineChart/InteractiveLineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LineChartProps<false>> = ({
points = [],
Expand All @@ -33,8 +32,7 @@ export const InteractiveLineChart: React.FC<LineChartProps<false>> = ({
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);
Expand Down Expand Up @@ -67,10 +65,7 @@ export const InteractiveLineChart: React.FC<LineChartProps<false>> = ({

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 (
Expand Down
101 changes: 101 additions & 0 deletions src/components/MultiLineChart/InteractiveMultiLineChart.tsx
Original file line number Diff line number Diff line change
@@ -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<Data extends Record<string, [number, number][]>> {
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<Data>["onPanGestureBegin"];
onPanGestureChange?: UseGestureProps<Data>["onPanGestureChange"];
onPanGestureEnd?: UseGestureProps<Data>["onPanGestureEnd"];
}

export const InteractiveMultiLineChart = <Data extends Record<string, [number, number][]>>({
points,
children,
gestureLongPressDelay = 200,
ExtraCanvasElements,
onCanvasResize,
onPanGestureBegin,
onPanGestureChange,
onPanGestureEnd,
...viewProps
}: MultiLineChartProps<Data, false>) => {
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 (
<View style={[styles.root, viewProps.style]} {...viewProps}>
<GestureDetector gesture={gestures}>
<View style={styles.container} onLayout={onLayout}>
<Canvas style={{ height, width }}>
{!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}
</Canvas>
</View>
</GestureDetector>
</View>
);
};
InteractiveMultiLineChart.displayName = "StaticMultiLineChart";

const styles = StyleSheet.create({
root: { position: "relative", overflow: "hidden" },
container: { flex: 1 },
canvas: { flex: 1 },
});
38 changes: 38 additions & 0 deletions src/components/MultiLineChart/Line.tsx
Original file line number Diff line number Diff line change
@@ -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<PathProps, "children" | "color" | "strokeWidth" | "stroke"> {
/** 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<LineProps> = ({
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 <Path style="stroke" strokeWidth={strokeWidth} color="gray" {...pathProps} path={path} />;
};
40 changes: 40 additions & 0 deletions src/components/MultiLineChart/MultiLineChart.tsx
Original file line number Diff line number Diff line change
@@ -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<string, [number, number][]>,
Static extends boolean = false,
> = React.PropsWithChildren<
{
points: Data;
onCanvasResize?: (width: number, height: number) => void;
} & Exclude<ViewProps, "children"> & {
children: (args: { points: Data; height: number; width: number }) => React.ReactNode;
} & (Static extends true
? { isStatic: true }
: { isStatic: false } & InteractiveLineChartProps<Data>)
>;

export const MultiLineChart = <
Data extends Record<string, [number, number][]>,
Static extends boolean = false,
>({
isStatic = false,
...props
}: MultiLineChartProps<Data, Static>) => {
return (
<MultiLineChartProvider>
{/* <StaticMultiLineChart {...props} /> */}
{isStatic ? (
<StaticMultiLineChart isStatic {...props} />
) : (
<InteractiveMultiLineChart isStatic={false} {...props} />
)}
</MultiLineChartProvider>
);
};
MultiLineChart.displayName = "MultiLineChart";
Loading

0 comments on commit ac12f01

Please sign in to comment.