Skip to content

Commit 6e077a5

Browse files
committed
feat: graph events
Support for graph events added.
1 parent dcabf21 commit 6e077a5

9 files changed

Lines changed: 333 additions & 68 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,34 @@ Example:
184184
```
185185

186186
See this [example `<SelectionDot />` component](./example/src/components/CustomSelectionDot.tsx).
187+
### `events`
188+
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.
187189

190+
### `EventComponent`
191+
A component that is used to render an event.
192+
193+
### `EventTooltipComponent`
194+
An additional event component that is rendered if the `SelectionDot` overlaps an `Event`.
195+
196+
### `onEventHover`
197+
Callback called when an `Event` is hovered on.
198+
199+
> Events related props require `animated` and `enablePanGesture` to be `true`.
200+
201+
Example:
202+
203+
<img src="./img/events.png" align="right" height="200" />
204+
205+
```jsx
206+
<LineGraph
207+
points={priceHistory}
208+
color="#4484B2"
209+
animated={true}
210+
enablePanGesture={true}
211+
events={transactionEvents}
212+
EventComponent={DefaultEventComponent}
213+
/>
214+
```
188215
## Sponsor
189216

190217
<img src="./img/pinkpanda.png" align="right" height="50">

example/src/data/GraphData.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GraphPoint } from '../../../src/LineGraphProps'
1+
import type { GraphEvent, GraphPoint } from '../../../src/LineGraphProps'
22
import gaussian from 'gaussian'
33

44
function weightedRandom(mean: number, variance: number): number {
@@ -7,7 +7,7 @@ function weightedRandom(mean: number, variance: number): number {
77
return distribution.ppf(Math.random())
88
}
99

10-
export function generateRandomGraphData(length: number): GraphPoint[] {
10+
export function generateRandomGraphPoints(length: number): GraphPoint[] {
1111
return Array<number>(length)
1212
.fill(0)
1313
.map((_, index) => ({
@@ -18,6 +18,28 @@ export function generateRandomGraphData(length: number): GraphPoint[] {
1818
}))
1919
}
2020

21+
export function generateRandomGraphEvents(
22+
length: number,
23+
points: GraphPoint[]
24+
): GraphEvent[] {
25+
const firstPointTimestamp = points[0]?.date.getTime()
26+
const lastPointTimestamp = points[points.length - 1]?.date.getTime()
27+
28+
if (!firstPointTimestamp || !lastPointTimestamp) {
29+
return []
30+
}
31+
return Array<number>(length)
32+
.fill(0)
33+
.map((_) => ({
34+
date: new Date( // Get a random date between the two defined timestamps.
35+
Math.floor(
36+
Math.random() * (lastPointTimestamp - firstPointTimestamp + 1)
37+
) + firstPointTimestamp
38+
),
39+
payload: {},
40+
}))
41+
}
42+
2143
export function generateSinusGraphData(length: number): GraphPoint[] {
2244
return Array<number>(length)
2345
.fill(0)

example/src/screens/GraphPage.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import type { GraphRange } from '../../../src/LineGraphProps'
66
import { SelectionDot } from '../components/CustomSelectionDot'
77
import { Toggle } from '../components/Toggle'
88
import {
9-
generateRandomGraphData,
9+
generateRandomGraphEvents,
10+
generateRandomGraphPoints,
1011
generateSinusGraphData,
1112
} from '../data/GraphData'
1213
import { useColors } from '../hooks/useColors'
1314
import { hapticFeedback } from '../utils/HapticFeedback'
1415

1516
const POINT_COUNT = 70
16-
const POINTS = generateRandomGraphData(POINT_COUNT)
17+
const POINTS = generateRandomGraphPoints(POINT_COUNT)
18+
const EVENT_COUNT = 10
19+
const EVENTS = generateRandomGraphEvents(EVENT_COUNT, POINTS)
1720
const COLOR = '#6a7ee7'
1821
const GRADIENT_FILL_COLORS = ['#7476df5D', '#7476df4D', '#7476df00']
1922
const SMALL_POINTS = generateSinusGraphData(9)
@@ -30,11 +33,16 @@ export function GraphPage() {
3033
const [enableRange, setEnableRange] = useState(false)
3134
const [enableIndicator, setEnableIndicator] = useState(false)
3235
const [indicatorPulsating, setIndicatorPulsating] = useState(false)
36+
const [enableEvents, setEnableEvents] = useState(false)
3337

3438
const [points, setPoints] = useState(POINTS)
39+
const [events, setEvents] = useState(EVENTS)
3540

3641
const refreshData = useCallback(() => {
37-
setPoints(generateRandomGraphData(POINT_COUNT))
42+
const freshPoints = generateRandomGraphPoints(POINT_COUNT)
43+
const freshEvents = generateRandomGraphEvents(EVENT_COUNT, freshPoints)
44+
setPoints(freshPoints)
45+
setEvents(freshEvents)
3846
hapticFeedback('impactLight')
3947
}, [])
4048

@@ -100,6 +108,7 @@ export function GraphPage() {
100108
enableIndicator={enableIndicator}
101109
horizontalPadding={enableIndicator ? 15 : 0}
102110
indicatorPulsating={indicatorPulsating}
111+
events={enableEvents ? events : []}
103112
/>
104113

105114
<Button title="Refresh" onPress={refreshData} />
@@ -148,6 +157,11 @@ export function GraphPage() {
148157
isEnabled={indicatorPulsating}
149158
setIsEnabled={setIndicatorPulsating}
150159
/>
160+
<Toggle
161+
title="Enable events:"
162+
isEnabled={enableEvents}
163+
setIsEnabled={setEnableEvents}
164+
/>
151165
</ScrollView>
152166

153167
<View style={styles.spacer} />

img/events.png

71.1 KB
Loading

src/AnimatedLineGraph.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ import {
3131
Shadow,
3232
} from '@shopify/react-native-skia'
3333

34-
import type { AnimatedLineGraphProps } from './LineGraphProps'
34+
import type {
35+
AnimatedLineGraphProps,
36+
GraphEventWithCords,
37+
} from './LineGraphProps'
3538
import { SelectionDot as DefaultSelectionDot } from './SelectionDot'
3639
import {
3740
createGraphPath,
@@ -45,6 +48,8 @@ import { getSixDigitHex } from './utils/getSixDigitHex'
4548
import { usePanGesture } from './hooks/usePanGesture'
4649
import { getYForX } from './GetYForX'
4750
import { hexToRgba } from './utils/hexToRgba'
51+
import { DefaultGraphEvent } from './DefaultGraphEvent'
52+
import { useEventTooltipProps } from './hooks/useEventTooltipProps'
4853

4954
const INDICATOR_RADIUS = 7
5055
const INDICATOR_BORDER_MULTIPLIER = 1.3
@@ -53,7 +58,7 @@ const INDICATOR_PULSE_BLUR_RADIUS_SMALL =
5358
const INDICATOR_PULSE_BLUR_RADIUS_BIG =
5459
INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER + 20
5560

56-
export function AnimatedLineGraph({
61+
export function AnimatedLineGraph<TEventPayload extends object>({
5762
points: allPoints,
5863
color,
5964
gradientFillColors,
@@ -74,12 +79,24 @@ export function AnimatedLineGraph({
7479
verticalPadding = lineThickness,
7580
TopAxisLabel,
7681
BottomAxisLabel,
82+
events,
83+
EventComponent = DefaultGraphEvent,
84+
EventTooltipComponent,
85+
onEventHover,
7786
...props
78-
}: AnimatedLineGraphProps): React.ReactElement {
87+
}: AnimatedLineGraphProps<TEventPayload>): React.ReactElement {
7988
const [width, setWidth] = useState(0)
8089
const [height, setHeight] = useState(0)
8190
const interpolateProgress = useValue(0)
8291

92+
const [eventsWithCords, setEventsWithCords] = useState<
93+
GraphEventWithCords<TEventPayload>[] | null
94+
>(null)
95+
const { eventTooltipProps, handleDisplayEventTooltip } = useEventTooltipProps(
96+
eventsWithCords,
97+
onEventHover
98+
)
99+
83100
const { gesture, isActive, x } = usePanGesture({
84101
enabled: enablePanGesture,
85102
holdDuration: panGestureDelay,
@@ -252,6 +269,7 @@ export function AnimatedLineGraph({
252269
}
253270

254271
setCommandsChanged(commandsChanged + 1)
272+
setEventsWithCords(null)
255273

256274
runSpring(
257275
interpolateProgress,
@@ -261,6 +279,19 @@ export function AnimatedLineGraph({
261279
stiffness: 500,
262280
damping: 400,
263281
velocity: 0,
282+
},
283+
() => {
284+
// Calculate graph event coordinates when the interpolation ends.
285+
if (events) {
286+
const extendedEvents: GraphEventWithCords<TEventPayload>[] = []
287+
events.forEach((e) => {
288+
const eventX =
289+
getXInRange(drawingWidth, e.date, pathRange.x) + horizontalPadding
290+
const eventY = getYForX(commands.value, eventX) ?? 0
291+
extendedEvents.push({ ...e, x: eventX, y: eventY })
292+
})
293+
setEventsWithCords(extendedEvents)
294+
}
264295
}
265296
)
266297
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -277,6 +308,7 @@ export function AnimatedLineGraph({
277308
straightLine,
278309
verticalPadding,
279310
width,
311+
events,
280312
])
281313

282314
const gradientColors = useMemo(() => {
@@ -515,6 +547,25 @@ export function AnimatedLineGraph({
515547
/>
516548
)}
517549

550+
{/* Render Event Component for every event. */}
551+
{EventComponent != null && eventsWithCords && (
552+
<Group>
553+
{eventsWithCords?.map((event, index) => (
554+
<EventComponent
555+
key={event.date.getTime()}
556+
index={index}
557+
isGraphActive={isActive}
558+
fingerX={circleX}
559+
eventX={event.x}
560+
eventY={event.y}
561+
color={color}
562+
onEventHover={handleDisplayEventTooltip}
563+
{...event.payload}
564+
/>
565+
))}
566+
</Group>
567+
)}
568+
518569
{indicatorVisible && (
519570
<Group>
520571
{indicatorPulsating && (
@@ -555,6 +606,11 @@ export function AnimatedLineGraph({
555606
)}
556607
</Reanimated.View>
557608
</GestureDetector>
609+
610+
{/* Tooltip displayed on hover on EventComponent. */}
611+
{EventTooltipComponent && eventTooltipProps && (
612+
<EventTooltipComponent {...eventTooltipProps} />
613+
)}
558614
</View>
559615
)
560616
}

src/DefaultGraphEvent.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { useEffect } from 'react'
2+
import {
3+
useDerivedValue,
4+
useSharedValue,
5+
withSpring,
6+
withTiming,
7+
} from 'react-native-reanimated'
8+
9+
import { Circle, Group } from '@shopify/react-native-skia'
10+
11+
import { EventComponentProps } from './LineGraphProps'
12+
13+
const EVENT_SIZE = 6
14+
const ACTIVE_EVENT_SIZE = 8
15+
const ENTERING_ANIMATION_DURATION = 750
16+
17+
export function DefaultGraphEvent({
18+
isGraphActive,
19+
fingerX,
20+
eventX,
21+
eventY,
22+
color,
23+
}: EventComponentProps) {
24+
const isEventActive = useDerivedValue(
25+
() =>
26+
isGraphActive.value &&
27+
Math.abs(fingerX.value - eventX) < ACTIVE_EVENT_SIZE
28+
)
29+
30+
const dotRadius = useDerivedValue(() =>
31+
withSpring(isEventActive.value ? ACTIVE_EVENT_SIZE : EVENT_SIZE)
32+
)
33+
34+
const animatedOpacity = useSharedValue(0)
35+
36+
useEffect(() => {
37+
// Entering opacity animation triggered on the first render.
38+
animatedOpacity.value = withTiming(1, {
39+
duration: ENTERING_ANIMATION_DURATION,
40+
})
41+
}, [animatedOpacity])
42+
43+
return (
44+
<Group opacity={animatedOpacity}>
45+
<Circle cx={eventX} cy={eventY} r={dotRadius} color={color} />
46+
</Group>
47+
)
48+
}

src/LineGraph.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import React from 'react'
2+
23
import { AnimatedLineGraph } from './AnimatedLineGraph'
34
import type { LineGraphProps } from './LineGraphProps'
45
import { StaticLineGraph } from './StaticLineGraph'
56

6-
function LineGraphImpl(props: LineGraphProps): React.ReactElement {
7-
if (props.animated) return <AnimatedLineGraph {...props} />
8-
else return <StaticLineGraph {...props} />
7+
export function LineGraphImpl<TEventPayload extends object>(
8+
props: LineGraphProps<TEventPayload>
9+
): React.ReactElement {
10+
if (props.animated) return <AnimatedLineGraph<TEventPayload> {...props} />
11+
return <StaticLineGraph {...props} />
912
}
1013

11-
export const LineGraph = React.memo(LineGraphImpl)
14+
export const LineGraph = React.memo(LineGraphImpl) as typeof LineGraphImpl

0 commit comments

Comments
 (0)