d3 + react tooltip 만들기

마우스를 호버했을 때 뜨는 툴팁을 만들기 위한 두 가지 방법이 있다.

overlay를 사용하기

첫 번째 방법은 svg 내부에 많은 요소들이 있어서 요소를 어디에 등록할지 애매할 경우에 유용하다.

필수적인 조건은 요소들이 Scale로 위치를 계산하여 svg 내부에 위치해야 한다.

첫 번째 방법은 요소 최상단에 보이지 않는 얇은 층을 쌓는 것이다.

나는 임의로 overlay라고 이름지었다..!

opacity를 0으로 주어 보이지 않지만 분명히 존재한다.

1
2
3
4
5
6
7
const overlay = svg
.selectAll(".overlay")
.attr("opacity", 0)
.attr("width", width - marginLeft)
.attr("height", height - marginBottom)
.attr("fill", overlayColor)
.style("pointer-events", "all");

이제 이 overlay에 이벤트를 등록하면 된다.

1
2
3
4
5
6
7
8
9
10
11
overlay
.on("mouseover", focus.style("display", null))
.on("mouseout", focus.style("display", "none"));
.on("mousemove", function(e){
const focus = select(".focus");
const bisectDate = bisector((data) => data.date).left;
const curPosition = xScale.invert(pointer(e, e.target)[0]);
// 현재 포인터를 기반으로 xScale 내부에서의 위치정보를 구한다.
const i = bisectDate(data, curPosition, 1);
// 현재 마우스 위치를 기반으로 데이터 배열에서 해당 위치의 index를 구한다.
})

마우스 위치에 가까운 요소에 직접 접근할 수 있다.

요소에 직접 이벤트 등록하기

body에 relative속성을 주고 focus에 position:absolute를 주어 left와 right로 위치를 정해주는 방법이다.

어떤 요소를 기준으로 하냐에 따라 eventX를 사용할지 offsetX를 사용할지가 달라진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var tooltip = d3
.select("#my_dataviz")
.append("div")
.style("position", "absolute")
.style("visibility", "hidden")
.text("I'm a circle!");

d3.select("#circleBasicTooltip")
.on("mouseover", function () {
return tooltip.style("visibility", "visible");
})
.on("mousemove", function (event) {
return tooltip
.style("top", event.pageY - 800 + "px")
.style("left", event.pageX - 800 + "px");
})
.on("mouseout", function () {
return tooltip.style("visibility", "hidden");
});

d3 + react 유용한 함수

getTextWidth

text의 width를 반환하는 함수이다. d3로 차트를 조작할 때 텍스트의 너비보다 텍스트를 포함하는 요소의 너비가 작을경우의 예외처리를 할 수 있다.

1
2
3
4
5
6
export default function getTextWidth(text, fontSize, fontFace) {
var canvas = document.createElement("canvas");
var canvasContext = canvas.getContext("2d");
canvasContext.font = fontSize + "px " + fontFace;
return canvasContext.measureText(text).width;
}

isBright

16진수 색상코드를 받아서 밝은 색상인지 어두운 색상인지를 반환하는 함수이다.

배경색에 따른 글자 색상 처리를 해줄 때에 유용하게 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
export default function isBright(rgbCode) {
let c = rgbCode.substring(1);
let rgb = parseInt(c, 16);
let r = (rgb >> 16) & 0xff;
let g = (rgb >> 8) & 0xff;
let b = (rgb >> 0) & 0xff;

let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;

return luma > 127 ? true : false;
// true일경우 밝음, 아닐 경우 어두움
}

d3 + react 유용한 hooks

d3와 react를 사용할 때에 유용하게 쓰일 수 있는 몇 가지 custom hooks이 있다.

useResizeObserver

d3로 만든 차트가 반응형으로 작동하도록 하는 hooks이다.

해당 html요소를 관찰하다 크기변화가 발생하면 내부의 callback을 실행한다.

svg의 width가 100%여야 정상적으로 작동한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useEffect, useState } from "react";
import ResizeObserver from "resize-observer-polyfill";

export const useResizeObserver = (ref) => {
const [dimensions, setDimensions] = useState(null);
useEffect(() => {
const observeTarget = ref.current;
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setDimensions(entry.contentRect);
});
});
resizeObserver.observe(observeTarget);
return () => {
resizeObserver.unobserve(observeTarget);
};
}, [ref]);
return dimensions;
};

export default useResizeObserver;

usePrevValue

이전 렌더링의 값을 저장하는 hooks이다.

useEffect 내부에서 조건부 작업을 할 경우에 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect, useRef } from "react";

function usePrevValue(value) {
const prevRef = useRef(null);

useEffect(() => {
prevRef.current = value;
});

return prevRef.current;
}

export default usePrevValue;

useDebounce

d3에는 수많은 이벤트가 발생한다!

zoom 이벤트나 위에서 언급한 useResizeObserver의 resize이벤트등의 요청을 줄일 수 있는 hook이다.

delay 안에 새로운 이벤트가 발생하면 기존의 값을 리턴한다.

마지막 요청 이후 delay만큼의 시간이 지나면 새롭게 세팅된 값을 리턴한다.

즉 디바운스를 hook으로 만들었다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useEffect, useState } from "react";

export const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};

export default useDebounce;

적용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const App = ({ data }) => {
const containerRef = useRef();
const wrapperRef = useRef();
const dimensions = useResizeObserver(wrapperRef);
const resize = useDebounce(dimensions, 200);

useEffect(() => {
const svg = select(svgRef.curent);
const container = select(containerRef.current);

if (!resize) return;

const { width, height } = resize;
svg.attr("width", width).attr("height", height);
}, [data, resize]);

return (
<div ref={containerRef}>
<svg ref={svgRef} />
</div>
);
};

react hooks component에서 setInterval 사용의 문제점

useInterval이라는 커스텀 훅이 있다.

이 훅은 interval을 세팅하고, 컴포넌트가 언마운트 될 때 interval을 clera한다.
이렇게 함으로서 setInterval과 clearInterval 모두 컴포넌트 라이프 사이클에 종속된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { useState, useEffect, useRef } from "react";

function useInterval(callback, delay) {
const savedCallback = useRef();

// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);

// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}

setInterval + clearInerval의 단점

임피던스 불일치라는 용어를 쓴다.
리액트 프로그래밍 모델은 state를 기반으로 렌더링되고 기타 변수들을 관리한다.

하지만 setInterval은 일단 설정하고 나면 없애는 것 외에는 변경할 수 없다. 또한 react의 state 기반으로 동작하지도 않는다.

state나 props가 변하더라도 교체하기 전까지는 내부의 props와 state를 참조할 것이다.

ref의 사용

위에서 알아본 것 처럼 setInterval은 react의 동작과 다르게 동작한다.
이로 인해 발생하는 문제점은 다음과 같다.

  1. 첫 렌더에서 callback1을 가진 setInterval(callback1,delay)를 수행한다.
  2. 다음 렌더에서 새로운 props와 state를 거쳐서 만들어지는 callback2가 있다. 하지만 시간을 재설정하지 않고서는 callback을 대체할 수 없다.

이를 해결하려면 useRef를 사용하면 된다.

useRef는 hooks에서 렌더링과 관련없는 변수를 담는데에 이용되는 하나의 상자이다.

솔루션은 다음과 같다.

  1. setInterval(fn,delay)에서 함수가 savedCallback을 호출하게 만든다.
  2. 첫 렌더링에서 savedCallback을 callback1로 설정한다.
  3. 두 번째 렌더링에서 savedCallback을 callback2로 설정한다.

결과본은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState, useEffect, useRef } from "react";

function useInterval(callback, delay) {
const savedCallback = useRef();

// callback에 변경사항이 있을 경우 가장 최근의 함수를 저장한다
// 최근의 state나 props의 변경사항을 반영한 callback이다.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);

// 타이머를 교체하거나 하는 작업 없이 제일 최근에 들어온 함수를 실행한다.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}

Interval 일시정지하기

위의 useInterval hook에선 null을 delay에 전달하는 것으로 interval을 일시 정지할 수 있다.
delay가 null이 아닐경우 타이머를 설정하기 떄문이다..!

1
2
3
4
5
6
7
8
9
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);

useInterval(
() => {
setCount(count + 1);
},
isRunning ? delay : null
);

ref

훅스 컴포넌트에서 setInterval 사용시의 문제점
Making setInterval Declarative with React Hooks

typescript useRef의 타입

antd+typescript+react를 사용하던 중 ref의 세 가지 타입에 대해 알게되어 포스팅한다.
react의 useRef는 세 가지 타입이 오버로딩 되어있다.

useRef

useRef는 initialValue를 useRef 객체의 current에 저장한다.
이 useRef의 current가 변경되어도 컴포넌트는 리렌더링 되지 않는다.
때문에 DOM 내부 객체를 직접 가리키거나 렌더링과 상관없는 변수를 저장하는데에 사용된다.

useRef의 타입

  1. MutableRefObject<T>
1
function useRef<T>(initialValue: T): MutableRefObject<T>;

initialValue의 타입과 제네릭의 타입이 T인경우 MutableRefObject<T>를 반환한다.
렌더링과 관련없는 값을 저장하는데에 사용하자.

  1. RefObject<T>
1
function useRef<T>(initialValue: T | null): RefObject<T>;

제네릭의 타입이 T이고 initialValue의 타입이 T 혹은 null인 경우 RefObject<T>를 반환한다.
RefObject<T>는 readonly로 current를 직접 수정할 수 없다.
하지만 current의 하위 프로퍼티는 조작 가능하다.

그렇기에 DOM 을 조작하기 위해 사용하자.

  1. MutableRefObject<T|undefined>
1
function useRef<T = undefined>(): MutableRefObject<T | undefined>;
ext.js를 사용하던 중 next/link의 Link 에서 forwarding Ref를 사용하라는 에러를 받았다.

그래서 알아보기로 하였다.

DOM 에 ref 전달하기

아래 코드는 button DOM 요소를 렌더링하는 FancyButton 컴포넌트이다.

1
2
3
function FancyButton(props) {
return <button className="FancyButton">{props.children}</button>;
}

FancyButton 컴포넌트의 경우 button이나 input같은 DOM요소와 같이 어플리케이션 전체에 걸쳐서 재사용될 것이다.

그리고 포커스, 선택, 애니메이션을 관리하기 위해서는 이런 DOM 노드에 접근하는 것이 불가피하다.

React.forwardRef

React.forwardRef를 이용하면 ref를 button DOM 요소로 직접 전달할 수 있다.

1
2
3
4
5
6
7
8
9
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));

const ref = React.createRef();
// 또는 const ref= useRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

요약

ref를 전달하기 위해선 ref라는 속성명 외의 다른 속성명을 사용해서 전달해야 한다.
하지만 React.forwardRef를 사용하면 ref를 props로 전달할 수 있다!

주의사항

컴포넌트 라이브러리에서 fowardRef를 사용하기 시작할 때 변경사항으로 간주하고 새로운 중요 버전을 릴리즈해야한다.

또한 forwardRef가 존재할 때 조건부로 적용하는 것도 권장되지 않는다.