diff --git a/README.md b/README.md
index 0f702f7..1ff4843 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,34 @@ Example:
```
See this [example `` component](./example/src/components/CustomSelectionDot.tsx).
+### `events`
+ An array of events to be marked in the graph. The position is calculated based on the `date` property of each event relatively to `points` of the graph.
+
+### `EventComponent`
+ A component that is used to render an event.
+
+ See this [example `` component](./example/src/components/GraphEvent.tsx).
+
+### `EventTooltipComponent`
+ An additional event component that is rendered if the `SelectionDot` overlaps an `Event`.
+ See this [example `` component](./example/src/components/GraphEventTooltip.tsx).
+### `onEventHover`
+ Callback called when an `Event` is hovered on.
+
+Example:
+```jsx
+
+```
+> Events related props require `animated` and `enablePanGesture` to be `true`.
+
+
## Sponsor
diff --git a/example/src/components/GraphEvent.tsx b/example/src/components/GraphEvent.tsx
new file mode 100644
index 0000000..bcfa2e4
--- /dev/null
+++ b/example/src/components/GraphEvent.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect } from 'react'
+import {
+ runOnJS,
+ useDerivedValue,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated'
+
+import {
+ Circle,
+ Group,
+ TwoPointConicalGradient,
+ vec,
+} from '@shopify/react-native-skia'
+import { EventComponentProps } from '../../../src/LineGraphProps'
+
+const EVENT_SIZE = 6
+const ACTIVE_EVENT_SIZE = 8
+const ENTERING_ANIMATION_DURATION = 750
+
+export function GraphEvent({
+ isGraphActive,
+ fingerX,
+ eventX,
+ eventY,
+ color,
+ index,
+ onEventHover,
+}: EventComponentProps) {
+ const isEventActive = useDerivedValue(() => {
+ // If the finger is on X position of the event.
+ if (
+ isGraphActive.value &&
+ Math.abs(fingerX.value - eventX) < ACTIVE_EVENT_SIZE
+ ) {
+ if (onEventHover) runOnJS(onEventHover)(index, true)
+
+ return true
+ }
+
+ if (onEventHover) runOnJS(onEventHover)(index, false)
+ return false
+ })
+
+ const dotRadius = useDerivedValue(() =>
+ withSpring(isEventActive.value ? ACTIVE_EVENT_SIZE : EVENT_SIZE)
+ )
+ const gradientEndRadius = useDerivedValue(() =>
+ withSpring(dotRadius.value / 2)
+ )
+ const animatedOpacity = useSharedValue(0)
+
+ useEffect(() => {
+ // Entering opacity animation triggered on the first render.
+ animatedOpacity.value = withTiming(1, {
+ duration: ENTERING_ANIMATION_DURATION,
+ })
+ }, [animatedOpacity])
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/example/src/components/GraphEventTooltip.tsx b/example/src/components/GraphEventTooltip.tsx
new file mode 100644
index 0000000..33e3712
--- /dev/null
+++ b/example/src/components/GraphEventTooltip.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
+import { Dimensions, Platform, StyleSheet, Text, View } from 'react-native'
+import { EventTooltipComponentProps } from '../../../src/LineGraphProps'
+
+export type TransactionEventTooltipProps = EventTooltipComponentProps<{}>
+
+const SCREEN_WIDTH = Dimensions.get('screen').width
+const ANIMATION_DURATION = 200
+const TOOLTIP_LEFT_OFFSET = 25
+const TOOLTIP_RIGHT_OFFSET = 145
+
+export const GraphEventTooltip = ({
+ eventX,
+ eventY,
+}: TransactionEventTooltipProps) => {
+ const tooltipPositionStyle = {
+ left:
+ eventX > SCREEN_WIDTH / 2
+ ? eventX - TOOLTIP_RIGHT_OFFSET
+ : eventX + TOOLTIP_LEFT_OFFSET,
+ top: eventY,
+ }
+ return (
+
+
+
+ Here you can display {'\n'}
+ any information you {'\n'}
+ want about the event.
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ tooltip: {
+ position: 'absolute',
+ backgroundColor: 'white',
+ paddingHorizontal: 10,
+
+ borderRadius: 20,
+ // add shadow based on platform
+ ...Platform.select({
+ ios: {
+ shadowColor: 'black',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.5,
+ shadowRadius: 3,
+ },
+ android: {
+ elevation: 3,
+ },
+ }),
+ },
+ content: {
+ paddingVertical: 12,
+ },
+ textNote: {
+ color: 'gray',
+ fontSize: 10,
+ },
+})
diff --git a/example/src/data/GraphData.ts b/example/src/data/GraphData.ts
index f0c3fd8..7ac11c1 100644
--- a/example/src/data/GraphData.ts
+++ b/example/src/data/GraphData.ts
@@ -1,4 +1,4 @@
-import type { GraphPoint } from '../../../src/LineGraphProps'
+import type { GraphEvent, GraphPoint } from '../../../src/LineGraphProps'
import gaussian from 'gaussian'
function weightedRandom(mean: number, variance: number): number {
@@ -7,7 +7,7 @@ function weightedRandom(mean: number, variance: number): number {
return distribution.ppf(Math.random())
}
-export function generateRandomGraphData(length: number): GraphPoint[] {
+export function generateRandomGraphPoints(length: number): GraphPoint[] {
return Array(length)
.fill(0)
.map((_, index) => ({
@@ -18,6 +18,28 @@ export function generateRandomGraphData(length: number): GraphPoint[] {
}))
}
+export function generateRandomGraphEvents(
+ length: number,
+ points: GraphPoint[]
+): GraphEvent[] {
+ const firstPointTimestamp = points[0]?.date.getTime()
+ const lastPointTimestamp = points[points.length - 1]?.date.getTime()
+
+ if (!firstPointTimestamp || !lastPointTimestamp) {
+ return []
+ }
+ return Array(length)
+ .fill(0)
+ .map((_) => ({
+ date: new Date( // Get a random date between the two defined timestamps.
+ Math.floor(
+ Math.random() * (lastPointTimestamp - firstPointTimestamp + 1)
+ ) + firstPointTimestamp
+ ),
+ payload: {},
+ }))
+}
+
export function generateSinusGraphData(length: number): GraphPoint[] {
return Array(length)
.fill(0)
diff --git a/example/src/screens/GraphPage.tsx b/example/src/screens/GraphPage.tsx
index 8dbfd9b..f1b1bf2 100644
--- a/example/src/screens/GraphPage.tsx
+++ b/example/src/screens/GraphPage.tsx
@@ -4,16 +4,21 @@ import { LineGraph } from 'react-native-graph'
import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'
import type { GraphRange } from '../../../src/LineGraphProps'
import { SelectionDot } from '../components/CustomSelectionDot'
+import { GraphEvent } from '../components/GraphEvent'
+import { GraphEventTooltip } from '../components/GraphEventTooltip'
import { Toggle } from '../components/Toggle'
import {
- generateRandomGraphData,
+ generateRandomGraphEvents,
+ generateRandomGraphPoints,
generateSinusGraphData,
} from '../data/GraphData'
import { useColors } from '../hooks/useColors'
import { hapticFeedback } from '../utils/HapticFeedback'
const POINT_COUNT = 70
-const POINTS = generateRandomGraphData(POINT_COUNT)
+const POINTS = generateRandomGraphPoints(POINT_COUNT)
+const EVENT_COUNT = 10
+const EVENTS = generateRandomGraphEvents(EVENT_COUNT, POINTS)
const COLOR = '#6a7ee7'
const GRADIENT_FILL_COLORS = ['#7476df5D', '#7476df4D', '#7476df00']
const SMALL_POINTS = generateSinusGraphData(9)
@@ -30,11 +35,17 @@ export function GraphPage() {
const [enableRange, setEnableRange] = useState(false)
const [enableIndicator, setEnableIndicator] = useState(false)
const [indicatorPulsating, setIndicatorPulsating] = useState(false)
+ const [enableEvents, setEnableEvents] = useState(false)
+ const [enableEventTooltip, setEnableEventTooltip] = useState(false)
const [points, setPoints] = useState(POINTS)
+ const [events, setEvents] = useState(EVENTS)
const refreshData = useCallback(() => {
- setPoints(generateRandomGraphData(POINT_COUNT))
+ const freshPoints = generateRandomGraphPoints(POINT_COUNT)
+ const freshEvents = generateRandomGraphEvents(EVENT_COUNT, freshPoints)
+ setPoints(freshPoints)
+ setEvents(freshEvents)
hapticFeedback('impactLight')
}, [])
@@ -100,6 +111,9 @@ export function GraphPage() {
enableIndicator={enableIndicator}
horizontalPadding={enableIndicator ? 15 : 0}
indicatorPulsating={indicatorPulsating}
+ events={enableEvents ? events : []}
+ EventComponent={enableEvents ? GraphEvent : null}
+ EventTooltipComponent={enableEventTooltip ? GraphEventTooltip : null}
/>
@@ -148,6 +162,16 @@ export function GraphPage() {
isEnabled={indicatorPulsating}
setIsEnabled={setIndicatorPulsating}
/>
+
+
diff --git a/img/events.gif b/img/events.gif
new file mode 100644
index 0000000..b19d9d5
Binary files /dev/null and b/img/events.gif differ
diff --git a/src/AnimatedLineGraph.tsx b/src/AnimatedLineGraph.tsx
index 0b318ed..7a0dbfd 100644
--- a/src/AnimatedLineGraph.tsx
+++ b/src/AnimatedLineGraph.tsx
@@ -31,7 +31,10 @@ import {
Shadow,
} from '@shopify/react-native-skia'
-import type { AnimatedLineGraphProps } from './LineGraphProps'
+import type {
+ AnimatedLineGraphProps,
+ GraphEventWithCords,
+} from './LineGraphProps'
import { SelectionDot as DefaultSelectionDot } from './SelectionDot'
import {
createGraphPath,
@@ -45,6 +48,7 @@ import { getSixDigitHex } from './utils/getSixDigitHex'
import { usePanGesture } from './hooks/usePanGesture'
import { getYForX } from './GetYForX'
import { hexToRgba } from './utils/hexToRgba'
+import { useEventTooltipProps } from './hooks/useEventTooltipProps'
const INDICATOR_RADIUS = 7
const INDICATOR_BORDER_MULTIPLIER = 1.3
@@ -53,7 +57,7 @@ const INDICATOR_PULSE_BLUR_RADIUS_SMALL =
const INDICATOR_PULSE_BLUR_RADIUS_BIG =
INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER + 20
-export function AnimatedLineGraph({
+export function AnimatedLineGraph({
points: allPoints,
color,
gradientFillColors,
@@ -74,12 +78,24 @@ export function AnimatedLineGraph({
verticalPadding = lineThickness,
TopAxisLabel,
BottomAxisLabel,
+ events,
+ EventComponent = null,
+ EventTooltipComponent = null,
+ onEventHover,
...props
-}: AnimatedLineGraphProps): React.ReactElement {
+}: AnimatedLineGraphProps): React.ReactElement {
const [width, setWidth] = useState(0)
const [height, setHeight] = useState(0)
const interpolateProgress = useValue(0)
+ const [eventsWithCords, setEventsWithCords] = useState<
+ GraphEventWithCords[] | null
+ >(null)
+ const { eventTooltipProps, handleDisplayEventTooltip } = useEventTooltipProps(
+ eventsWithCords,
+ onEventHover
+ )
+
const { gesture, isActive, x } = usePanGesture({
enabled: enablePanGesture,
holdDuration: panGestureDelay,
@@ -252,6 +268,7 @@ export function AnimatedLineGraph({
}
setCommandsChanged(commandsChanged + 1)
+ setEventsWithCords(null)
runSpring(
interpolateProgress,
@@ -261,6 +278,19 @@ export function AnimatedLineGraph({
stiffness: 500,
damping: 400,
velocity: 0,
+ },
+ () => {
+ // Calculate graph event coordinates when the interpolation ends.
+ if (events) {
+ const extendedEvents: GraphEventWithCords[] = []
+ events.forEach((e) => {
+ const eventX =
+ getXInRange(drawingWidth, e.date, pathRange.x) + horizontalPadding
+ const eventY = getYForX(commands.value, eventX) ?? 0
+ extendedEvents.push({ ...e, x: eventX, y: eventY })
+ })
+ setEventsWithCords(extendedEvents)
+ }
}
)
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -277,6 +307,7 @@ export function AnimatedLineGraph({
straightLine,
verticalPadding,
width,
+ events,
])
const gradientColors = useMemo(() => {
@@ -515,6 +546,25 @@ export function AnimatedLineGraph({
/>
)}
+ {/* Render Event Component for every event. */}
+ {EventComponent != null && eventsWithCords && (
+
+ {eventsWithCords?.map((event, index) => (
+
+ ))}
+
+ )}
+
{indicatorVisible && (
{indicatorPulsating && (
@@ -555,6 +605,11 @@ export function AnimatedLineGraph({
)}
+
+ {/* Tooltip displayed on hover on EventComponent. */}
+ {EventTooltipComponent && eventTooltipProps && (
+
+ )}
)
}
diff --git a/src/LineGraph.tsx b/src/LineGraph.tsx
index 9b04087..1dd46de 100644
--- a/src/LineGraph.tsx
+++ b/src/LineGraph.tsx
@@ -1,11 +1,14 @@
import React from 'react'
+
import { AnimatedLineGraph } from './AnimatedLineGraph'
import type { LineGraphProps } from './LineGraphProps'
import { StaticLineGraph } from './StaticLineGraph'
-function LineGraphImpl(props: LineGraphProps): React.ReactElement {
- if (props.animated) return
- else return
+export function LineGraphImpl(
+ props: LineGraphProps
+): React.ReactElement {
+ if (props.animated) return {...props} />
+ return
}
-export const LineGraph = React.memo(LineGraphImpl)
+export const LineGraph = React.memo(LineGraphImpl) as typeof LineGraphImpl
diff --git a/src/LineGraphProps.ts b/src/LineGraphProps.ts
index 21e147f..1387f9e 100644
--- a/src/LineGraphProps.ts
+++ b/src/LineGraphProps.ts
@@ -9,6 +9,34 @@ export interface GraphPoint {
date: Date
}
+export type GraphEvent = {
+ payload: TEventPayload
+ date: Date
+}
+
+export type GraphEventWithCords =
+ GraphEvent & {
+ x: number
+ y: number
+ }
+
+export type EventComponentProps = {
+ isGraphActive: SharedValue
+ fingerX: SharedValue
+ index: number
+ eventX: number
+ eventY: number
+ color: string
+ onEventHover?: (index: number, willBeTooltipDisplayed: boolean) => void
+} & TEventPayload
+
+export type EventTooltipComponentProps =
+ {
+ eventX: number
+ eventY: number
+ eventPayload: TEventPayload
+ }
+
export type GraphRange = Partial
export interface SelectionDotProps {
@@ -52,65 +80,90 @@ interface BaseLineGraphProps extends ViewProps {
export type StaticLineGraphProps = BaseLineGraphProps & {
/* any static-only line graph props? */
}
-export type AnimatedLineGraphProps = BaseLineGraphProps & {
- /**
- * Whether to enable Graph scrubbing/pan gesture.
- */
- enablePanGesture?: boolean
- /**
- * The color of the selection dot when the user is panning the graph.
- */
- selectionDotShadowColor?: string
- /**
- * Horizontal padding applied to graph, so the pan gesture dot doesn't get cut off horizontally
- */
- horizontalPadding?: number
- /**
- * Vertical padding applied to graph, so the pan gesture dot doesn't get cut off vertically
- */
- verticalPadding?: number
- /**
- * Enables an indicator which is displayed at the end of the graph
- */
- enableIndicator?: boolean
- /**
- * Let's the indicator pulsate
- */
- indicatorPulsating?: boolean
- /**
- * Delay after which the pan gesture starts
- */
- panGestureDelay?: number
- /**
- * Called for each point while the user is scrubbing/panning through the graph
- */
- onPointSelected?: (point: GraphPoint) => void
- /**
- * Called once the user starts scrubbing/panning through the graph
- */
- onGestureStart?: () => void
- /**
- * Called once the user stopped scrubbing/panning through the graph
- */
- onGestureEnd?: () => void
+export type AnimatedLineGraphProps =
+ BaseLineGraphProps & {
+ /**
+ * Whether to enable Graph scrubbing/pan gesture.
+ */
+ enablePanGesture?: boolean
+ /**
+ * The color of the selection dot when the user is panning the graph.
+ */
+ selectionDotShadowColor?: string
+ /**
+ * Horizontal padding applied to graph, so the pan gesture dot doesn't get cut off horizontally
+ */
+ horizontalPadding?: number
+ /**
+ * Vertical padding applied to graph, so the pan gesture dot doesn't get cut off vertically
+ */
+ verticalPadding?: number
+ /**
+ * Enables an indicator which is displayed at the end of the graph
+ */
+ enableIndicator?: boolean
+ /**
+ * Let's the indicator pulsate
+ */
+ indicatorPulsating?: boolean
+ /**
+ * Delay after which the pan gesture starts
+ */
+ panGestureDelay?: number
- /**
- * The element that renders the selection dot
- */
- SelectionDot?: React.ComponentType | null
+ /**
+ * Called for each point while the user is scrubbing/panning through the graph
+ */
+ onPointSelected?: (point: GraphPoint) => void
+ /**
+ * Called once the user starts scrubbing/panning through the graph
+ */
+ onGestureStart?: () => void
+ /**
+ * Called once the user stopped scrubbing/panning through the graph
+ */
+ onGestureEnd?: () => void
- /**
- * The element that gets rendered above the Graph (usually the "max" point/value of the Graph)
- */
- TopAxisLabel?: () => React.ReactElement | null
+ /**
+ * The element that renders the selection dot
+ */
+ SelectionDot?: React.ComponentType | null
- /**
- * The element that gets rendered below the Graph (usually the "min" point/value of the Graph)
- */
- BottomAxisLabel?: () => React.ReactElement | null
-}
+ /**
+ * The element that gets rendered above the Graph (usually the "max" point/value of the Graph)
+ */
+ TopAxisLabel?: () => React.ReactElement | null
+
+ /**
+ * The element that gets rendered below the Graph (usually the "min" point/value of the Graph)
+ */
+ BottomAxisLabel?: () => React.ReactElement | null
+
+ /**
+ * All events to be marked in the graph. The position will be calculated based on the `date` property according to points of the graph.
+ */
+ events?: GraphEvent[]
+
+ /**
+ * The element that renders each event of the graph.
+ */
+ EventComponent?: React.ComponentType<
+ EventComponentProps
+ > | null
+ /**
+ * The element that gets rendered on hover on an EventComponent element.
+ */
+ EventTooltipComponent?: React.ComponentType<
+ EventTooltipComponentProps
+ > | null
+
+ /**
+ * Called once the user hover on EventComponent element.
+ */
+ onEventHover?: () => void
+ }
-export type LineGraphProps =
- | ({ animated: true } & AnimatedLineGraphProps)
+export type LineGraphProps =
+ | ({ animated: true } & AnimatedLineGraphProps)
| ({ animated: false } & StaticLineGraphProps)
diff --git a/src/hooks/useEventTooltipProps.ts b/src/hooks/useEventTooltipProps.ts
new file mode 100644
index 0000000..7c4c183
--- /dev/null
+++ b/src/hooks/useEventTooltipProps.ts
@@ -0,0 +1,42 @@
+import { useCallback, useState } from 'react'
+
+import {
+ EventTooltipComponentProps,
+ GraphEventWithCords,
+} from '../LineGraphProps'
+
+/**
+ * Returns props for tooltip of active graph event.
+ */
+export const useEventTooltipProps = (
+ eventsWithCords: GraphEventWithCords[] | null,
+ onEventHover?: () => void
+) => {
+ const [activeEventIndex, setActiveEventIndex] = useState(null)
+ const handleDisplayEventTooltip = useCallback(
+ (eventIndex: number, isDisplayed: boolean) => {
+ if (activeEventIndex === eventIndex && !isDisplayed)
+ setActiveEventIndex(null)
+
+ if (activeEventIndex === null && isDisplayed) {
+ onEventHover?.()
+ setActiveEventIndex(eventIndex)
+ }
+ },
+ [activeEventIndex, onEventHover]
+ )
+ const activeEvent =
+ eventsWithCords && typeof activeEventIndex === 'number'
+ ? eventsWithCords[activeEventIndex]
+ : null
+ const eventTooltipProps: EventTooltipComponentProps | null =
+ activeEvent
+ ? {
+ eventX: activeEvent.x,
+ eventY: activeEvent.y,
+ eventPayload: activeEvent.payload,
+ }
+ : null
+
+ return { handleDisplayEventTooltip, eventTooltipProps }
+}