Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite <HoverLayer> using Hooks #10

Merged
merged 5 commits into from
Mar 4, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Extract the animation frame controls as a custom effect hook. (#10)
- Add `tslint-react-hooks` rules to lint React Hooks. (#8)
- Add `ThemeProvider` and color / xy axis themes config for customize theme. (#6)
- Create an animation package, and add a simple SVG clipping animation: `<AnimatedClipRect>`. (#4)
Expand All @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Makes simple components such as `<Foo>` and `<ResponsiveLayer>` as an experiment to see if the project settings go well. (#1)

# Changed
- Rewrite `<HoverLayer>` using Hooks. (#10)
- Use Hooks to rewrite functionalities of `<ResponsiveLayer>`. (#8)
- Refactor the way getting width and height in `<LineChart>`. (#8)
- Fix `yarn lint` command. (#7)
Expand Down
43 changes: 43 additions & 0 deletions packages/graph/src/hooks/useAnimationFrame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
useRef,
useEffect,
useCallback,
} from 'react';

export interface AnimationFrameControl {
/** The current animation frame ID */
animationFrame: number | null;

/** Function that takes `rafCallback` and put it into `window.requestAnimationFrame()` */
requestWindowAnimationFrame: (rafCallback: () => void) => void;
}

export function useAnimationFrame(): AnimationFrameControl {
/** stores the animation frame ID */
const animaFrameIdRef = useRef<number | null>(null);

const requestWindowAnimationFrame = useCallback(
(rafCallback) => {
animaFrameIdRef.current = window.requestAnimationFrame(rafCallback);
},
[],
);

useEffect(
() => {
// the functional component unmounting
return () => {
// cancel the scheduled update on the animation frame
if (animaFrameIdRef.current) {
window.cancelAnimationFrame(animaFrameIdRef.current);
}
};
},
[],
);

return {
requestWindowAnimationFrame,
animationFrame: animaFrameIdRef.current,
};
}
12 changes: 5 additions & 7 deletions packages/graph/src/hooks/useContainerDimension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import resizeObserverPolyfill from 'resize-observer-polyfill';

import { GraphDimension } from '../common/types';

import { useAnimationFrame } from './useAnimationFrame';

interface ResizeObserverEntry {
readonly target: Element;
readonly contentRect: DOMRectReadOnly;
Expand All @@ -26,13 +28,13 @@ export function useContainerDimension(

/** resizeObsrRef.current stores the ResizeObserver */
const resizeObsrRef = useRef<ResizeObserver | null>(null);
/** animaFrameIDRef.current stores the current animation frame ID */
const animaFrameIDRef = useRef<number | null>(null);
/** use requestAnimationFrame to update the dimension */
const { requestWindowAnimationFrame } = useAnimationFrame();

/** Function to set the updated dimension */
const updateDimension = useCallback(
(width: number, height: number) => {
animaFrameIDRef.current = window.requestAnimationFrame(() => {
requestWindowAnimationFrame(() => {
setDimension({
width,
height,
Expand Down Expand Up @@ -69,10 +71,6 @@ export function useContainerDimension(
resizeObsrRef.current.observe(containerRef.current!);

return () => {
// cancel the scheduled update of the container's dimension
if (animaFrameIDRef.current) {
window.cancelAnimationFrame(animaFrameIDRef.current);
}
// disconnect the resize observer on unmounted
resizeObsrRef.current!.disconnect();
};
Expand Down
1 change: 1 addition & 0 deletions packages/graph/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './components/Foo';
export * from './common/types';
export * from './hooks/useContainerDimension';
export * from './hooks/useAnimationFrame';
export * from './layers/ResponsiveLayer';
export * from './layers/DataLayer';
export * from './layers/AxisLayer';
Expand Down
145 changes: 81 additions & 64 deletions packages/graph/src/layers/HoverLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import * as React from 'react';
import React, {
FunctionComponent,
useRef,
useEffect,
useCallback,
} from 'react';
import { throttle } from 'lodash-es';
import { localPoint } from '@vx/event';

import { useAnimationFrame } from '../hooks/useAnimationFrame';

import { DataLayerRenderParams } from './DataLayer';

export interface HoverLayerProps {
Expand All @@ -14,72 +21,82 @@ export interface HoverLayerProps {
/** Function to hide the tooltip */
clearHovering: () => void;

/** Hidden components to detect the mouse or touch interactions */
/**
* Hidden components to detect the mouse or touch interactions.
* **Note:** The order of the components should correspond to the order of the data.
*/
collisionComponents: JSX.Element[];

/** The debounce time for the mouse and touch events */
/** The throttle time for the mouse and touch events */
throttleTime: number;
}

export class HoverLayer extends React.PureComponent<HoverLayerProps, {}> {
public static defaultProps = {
throttleTime: 180,
handleHover: () => null,
};

public animaFrameID: number;

/** Updates the position of the tooltip and sets the currently active data index */
private updatePosition = (dataIndex: number, event: React.SyntheticEvent) => {
const { setHoveredPosAndIndex, handleHover } = this.props;

// custom action which executes before the position is updated
handleHover();

// convert the position of the event to the coordinate system of the SVG
const { x, y } = localPoint(event);
this.animaFrameID = window.requestAnimationFrame(() => {
setHoveredPosAndIndex(
dataIndex,
x,
y,
);
export const HoverLayer: FunctionComponent<HoverLayerProps> = ({
setHoveredPosAndIndex,
handleHover= () => null,
clearHovering,
collisionComponents,
throttleTime = 180,
}) => {
/** use requestAnimationFrame to schedule updates of hovered position and data index */
const { requestWindowAnimationFrame } = useAnimationFrame();

/** Function to update the position of the tooltip and sets the currently active data index */
const updatePosition = useCallback(
throttle(
(dataIndex: number, event: React.SyntheticEvent) => {
// custom action which executes before the position is updated
handleHover();

// convert the position of the event to the coordinate system of the SVG
const { x, y } = localPoint(event);
requestWindowAnimationFrame(() => {
setHoveredPosAndIndex(
dataIndex,
x,
y,
);
});
},
throttleTime,
),
[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the second args should be [handleHover, setHoveredPosAndIndex, throttleTime]. The throttled function should change if one of these props changes. See the note part of useCallback doc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch. Thanks for finding this out 😃

);

/** Function to keep the event data and perform throttled updates of the position */
const handleTooltip = useCallback(
(dataIndex: number) => (event: React.SyntheticEvent) => {
// removes it from the event pool and allows references to the event
event.persist();
updatePosition(dataIndex, event);
},
[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the comment above (L63).

);

/** Function to cancel the update of position and disable the hovering state */
const hideTooltip = useCallback(
() => {
// cancel the previously thorttled event to prevent the tooltip from reappearing
updatePosition.cancel();
clearHovering();
},
[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the comment above with one more clearHovering argument in input arrays.

);

const detectionAreas = collisionComponents.map((area: JSX.Element, dataIndex: number) => {
const handleCurrentTooltip = handleTooltip(dataIndex);
return React.cloneElement(area, {
onTouchStart: handleCurrentTooltip,
onTouchMove: handleCurrentTooltip,
onMouseMove: handleCurrentTooltip,
onMouseLeave: hideTooltip,
});
};

private throttledUpdatePosition = throttle(this.updatePosition, this.props.throttleTime);

private handleTooltip = (dataIndex: number) => (event: React.SyntheticEvent) => {
// removes it from the event pool and allows references to the event
event.persist();
this.throttledUpdatePosition(dataIndex, event);
};

private hideTooltip = () => {
// cancel the previously thorttled event to prevent the tooltip from reappearing
this.throttledUpdatePosition.cancel();
this.props.clearHovering();
};

public componentWillUnmount() {
window.cancelAnimationFrame(this.animaFrameID);
}

public render() {
const { collisionComponents } = this.props;
const detectionAreas = collisionComponents.map((area: JSX.Element, dataIndex: number) => {
const handleCurrentTooltip = this.handleTooltip(dataIndex);
return React.cloneElement(area, {
onTouchStart: handleCurrentTooltip,
onTouchMove: handleCurrentTooltip,
onMouseMove: handleCurrentTooltip,
onMouseLeave: this.hideTooltip,
});
});

return (
// Render areas to detect collisions of mouse pointers or touches with data points
detectionAreas
);
}
}
});

// Render areas to detect collisions of mouse pointers or touches with data points
return (
<>
{detectionAreas}
</>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need the Fragment, just return detectionAreas as previous code?

Copy link
Contributor Author

@hsunpei hsunpei Mar 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary for Typescript because if detectionAreas were not wrapped by Fragment, <HoverLayer> would not be a legit FunctionComponent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting 🤕 I just tried directly return detectionAreas and the typescript complier / tslint didn't have any error / warning for this.

);
};