d3 기본도형 path

path는 경로라는 의미로 종이 위에 펜을 두고, 펜을 움직여 그 경로를 모양으로 한다고 생각하면 된다.

핵심적인 속성은 d가 있다.

이 d속성을 이용하여 경로를 설정하게 된다.

d

d의 속성은 다음과 같다.

  1. Move To: M, m
  2. Line To: L, l, H, h, V, v
  3. Cubic Bézier Curve: C, c, S, s
  4. Quadratic Bézier Curve: Q, q, T, t
  5. Elliptical Arc Curve: A, a
  6. ClosePath: Z, z

사실 d3를 다루며 다른 도형에 대해선 이해할 수 있어도, 이 path 도형을 이해하는데에는 좀 오랜 시간이 걸렸다. 그래서 다시 하나씩 정리해보았다.

1. Move To

펜을 들어 지정된 곳에 놓는 것으로 생각할 수 있다. 즉 현재 지점을 이동하는 거을 의미한다.

CommandParametersNotes
M(x,y)+현재 지점을 x,y로 옮긴다.
m(dx,dy)+현재 경로를 (curX,curY)라고 할 때 현재 지점을 (curX+dx,curY+dy)로 옮긴다.

2. Line To

선을 그리는 정의이다.

CommandParametersNotes
L(x,y)+현재 지점에서 x,y까지 선을 그린다.
l(dx,dy)+현재 경로를 (curX,curY)라고 할 때 현재 지점에서 (curX+dx,curY+dy)까지 선을 그린다.
Hx+현재 지점에서 x,y까지 수평선을 그린다.
h(dx,dy)+현재 경로를 (curX,curY)라고 할 때 현재 지점에서 (curX+dx,curY+dy)까지 수평선을 그린다.
V(x,y)+현재 지점에서 x,y까지 수직선을 그린다.
v(dx,dy)+현재 경로를 (curX,curY)라고 할 때 현재 지점에서 (curX+dx,curY+dy)까지 수직선을 그린다.

3. Cubic Bézier Curve

4개의 점을 사용하는 부드러운 곡선 정의이다.

시작점 (current point)
(Po = {xo, yo})

끝점
(Pn = {xn, yn})

시작 쪽 제어 점
(Pcs = {xcs, ycs}) (controls curvature near the start of the curve)

끝 쪽 제어 점
(Pce = {xce, yce}) (controls curvature near the end of the curve)

같은 시간동안 시작 점에서 시작 쪽 제어 점으로 움직이는 점, 시작 쪽 제어 점에서 끝 쪽 제어 점으로 움직이는 점, 끝 쪽 제어 점에서 끝 점으로 움직이는 점을 각각 Pstarttocontrol, Pcontroltocontrol, Pcontroltoend으로 정의할 수 있다.

또한 PstarttoControl과 Pcontroltocontrol, Pcontrol과 Pcontroltoend을 연결하고 같은 시간동안 각각의 선의 시작점에서부터 끝 점까지 움직이는 점을 정의할 수 있다.

이러한 점을 각각 Pbezierstart, Pbezierend라고 하면 같은 시간동안 이 점의 움직임을 연결한 곡선이 Bezier 곡선이다.

말로 표현하면 좀 어려운데, Ref를 참조하면 이해가 더 쉽다.

CommandParametersNotes
C(x1,y1,x2,y2,x,y)+현재 지점에서 x,y까지 3차 베지어 곡선을 그린다. 끝 점은 x,y 시작 제어 점은 x1,y1, 끝 제어 점은 x2,y2로 정의된다.
c(dx1,dy1,dx2,dy2,dx,dy)+현재 지점에서 끝점까지 3차 베지어 곡선을 그린다. 현재 지점을 (curX,curY) 라고 할 때 끝 점은 (curX+dx,curY+dy), 시작 제어 점은 (curX+dx1,curY+y1), 끝 제어 점은 (curX+dx2,curY+dy2)로 정의된다.
S( x2, y2, x, y)+C와 똑같이 동작한다. 다만 시작점을 이전 베지어곡선의 끝점으로 한다.
s( dx2, dy2, dx, dy)+c와 똑같이 동작한다. 다만 시작점을 이전 베지어곡선의 끝점으로 한다.

4. Quadratic Bézier Curve

세 점을 사용하는 2차 베지어 곡선이다.

시작 점
Po = {xo, yo}

끝 점
Pn = {xn, yn}

제어 점
Pc = {xc, yc} (controls curvature)

CommandParametersNotes
Q(x1,y1,x,y)+현재 지점에서 x,y까지 2차 베지어 곡선을 그린다.
q(dx1,dy1,dx,dy)+현재 지점에서 끝점까지 3차 베지어 곡선을 그린다. 현재 지점을 (curX,curY) 라고 할 때 끝 점은 (curX+dx,curY+dy)으로 정의된다.
T(x, y)+Q와 똑같이 동작한다. 다만 시작점을 이전 베지어곡선의 끝점으로 한다.
t(dx, dy)+q와 똑같이 동작한다. 다만 시작점을 이전 베지어곡선의 끝점으로 한다.

5. Elliptical Arc Curve

타원의 일부로 정의된 곡선이다. 즉 타원의 호이다.

CommandParametersNotes
A(rx, ry, angle, large-arc-flag, sweep-flag, x, y)+현재 지점에서 x,y까지 호를 그린다.
rx,ry는 타원의 두 반지름이다.
angle은 x축에 대한 타원의 회전 각도이다.
large-arc-flag, sweep-flag는 4개의 호가 가능하므로 어떤 호를 그릴지 선택한다.
large-arc-flog는 큰 호(1) 작은호(0)을 선택할 수 있다.
sweep-flag는 시계 방향 호(1) 반시계 방향 호(0)을 선택할 수 있다.
좌표 x,y는 다음 명령의 새 현재 점이 된다.
a(rx, ry, angle, large-arc-flag, sweep-flag, dx, dy)+현재 지점에서 끝점까지 호를 그린다. 현재 지점을 (curX,curY) 라고 할 때 끝 점은 (curX+dx,curY+dy)으로 정의된다.

6. Close Path

현재 위치에서 경로의 첫 번째 점까지 직선을 그린다.

CommandParametersNotes
Z,z경로의 마지막 지점을 시작 지점과 연결하여 현재 경로를 닫는다. 두 점이 다른 좌표에 있으면 두 점 사이에 직선이 그려진다.

interpolate

d3.interpolate를 사용하여 선을 보간할 수 있다.

아래의 링크를 참조하면 된다.(혹은 공식문서)

https://web.archive.org/web/20201029202235/https://www.dashingd3js.com/svg-paths-and-d3js

Ref

https://ko.javascript.info/bezier-curve
https://blog.coderifleman.com/2016/12/30/bezier-curves/

d3 데이터를 dom에 바인딩하기

d3의 가장 기초가 되는 메소드에 대해 알아보자

select

조작하고자 하는 DOM 요소를 선택할 수 있다.

반환되는 값은 selection 객체이다.

해당하는 결과가 없을 경우 빈 객체를 반환한다.

부모의 데이터와 인덱스등의 상태를 보존한 상태로 선택한다.

selectAll

선택자와 함께 DOM 요소를 여러 개 선택한다.

반환되는 값은 selection 객체 배열이다.

해당하는 결과가 없을경우 빈 객체 배열을 반환한다.

부모의 데이터와 인덱스등의 상태를 보존하지 않은 상태로 새 그룹을 만든다.

즉 새로운 데이터를 바인딩하기 위해선 select가 아닌 selectAll을 사용해야한다.

datum

요소에 단일 데이터를 바인딩 할 때 사용한다.

실제 DOM요소와의 연결을 계산하지 않는다.(enter,update,exit)

단순히 모든 요소에 할당한다.

1
2
3
4
5
6
const data = 1;
d3.select("body").datam(data).enter().append("div");

const datas = [1, 2, 3];
d3.select("body").datam(datas).enter().append("div");
// 모든 요소에 1,2,3을 할당

parameter를 제공하지 않을경우, 선택된 첫번째 요소의 데이터만을 반환한다.

위의 예시에서는 1을 반환한다.

data

데이터 배열을 selection 객체와 연결한다.

실제 DOM 요소와 연결하기 위해 이를 계산한다.

때문에 data를 수행할 경우 selection 객체는 enter,exit,update의 메소드를 갖게된다.

즉 데이터 각각을 실제 DOM 요소와 연결할 수 있다.

data를 수행할 경우 index를 key로 갖게 되는데, data함수 내부의 두 번째 parameter에 key function을 제공하여 고유한 키를 갖도록 할 수 있다.

1
2
3
4
5
6
7
const data = 1;
d3.select("body").data([data]).enter().append("div");
// 배열로 제공하였다.

const datas = [1, 2, 3];
d3.select("body").data(datas).enter().append("div");
// 실제 DOM 요소와의 연결이 존재하지 않는 2,3번째 요소에 2,3을 할당하여 enter

이중 enter()는 selection 객체에 바인드된 데이터 중에 실제 DOM을 가지지 못하는 요소를 찾아내 가상의 객체로 만들어 반환한다.

즉 데이터의 수가 DOM요소보다 많을 경우 하는 작업이다.

1
2
const data = [1, 2, 3];
d3.select("body").data(data).enter().append("div");

update는 업데이트된 데이터에 따라 어떻게 작업할지를 명시한다.

data 자체가 update이다.

1
2
const data = [1, 2, 3];
d3.select("body").data(data).attr("update");

exit은 업데이트된 데이터에 따라 요소를 제거할 때에 사용된다.

즉 데이터의 수가 DOM요소보다 적을 경우 하는 작업이다.

1
2
const data = [1, 2, 3];
d3.select("body").data(data).exit().remove();

parameter를 제공하지 않을경우, 선택된 요소에 대해 바인딩 된 데이터를 배열로 반환한다.

위의 예시에서는 [1,2,3] 을 반환한다.

join

enter,exit,update를 함수 하나에 정의할 수 있다.

즉 예전 general update pattern에 대한 대안이다.

일일히 정의해줄 수도 있다.

1
2
3
4
5
6
7
8
const data = [1, 2, 3];
d3.select("body")
.data(data)
.join(
(enter) => enter.append("div"),
(update) => update.attr("class", "update"),
(exit) => exit.remove()
);

예시

1
2
3
<div>hi</div>
<div>hi</div>
<div>hi</div>
1
2
3
4
const data = [1, 2, 3];
let dom = d3.select("body").selectAll("div").data(data).enter();

dom.append("div").text("hello");

즉 위의 코드의 결과는 다음과 같다.(enter)

1
2
3
4
5
<div>hi</div>
<div>hi</div>
<div>hi</div>
<div>hello</div>
<div>hello</div>
1
2
3
<div>hi</div>
<div>hi</div>
<div>hi</div>
1
2
const data = [1, 2, 3];
d3.select("body").selectAll("div").data(data).text("hello");

다음과 같이 수정하면 hello 5개로 구성된 HTML을 얻을 수 있다.(update)

1
2
3
4
5
<div>hello</div>
<div>hello</div>
<div>hello</div>
<div>hello</div>
<div>hello</div>

Ref

https://github.com/d3/d3-selection#joining-data
https://bost.ocks.org/mike/join/
https://bost.ocks.org/mike/nest/
https://stackoverflow.com/questions/13728402/what-is-the-difference-d3-datum-vs-data
https://www.intothevoid.io/data-visualization/understanding-d3-data-vs-datum/

데이터 시각화의 기본적인 프로세스

오렐리를 통해 데이터 시각화 책을 쓴 Ben Fry는 그의 책 첫 번째 장에서 데이터 시각화 프로세스의 일련의 단계를 정의하였다.

d3를 다루다보면 약간 막히는 부분이 생기는데 어느 프로세스에 있는지 항상 생각하면 더 수월하게 개발할 수 있을 것 같아서 메모한다.

  1. 데이터 수집

데이터를 수집한다.

  1. 데이터 구문분석

데이터를 이루고 있는 구성 성분으로 분해하고 “데이터” 로서 정의한다.

d3의 경우 이름, 형식, 태그등의 데이터 속성을 key,value 형태의 객체로 정의하는 과정이다.

  1. 데이터 필터링

데이터 시각화에 필요하지 않은 데이터를 필터링 해야한다.

  1. 데이터 마이닝

데이터 안에서 통계적 규칙이나 패턴을 분석하여 가치있는 정보로 만든다.

데이터를 표현하기 전 데이터에 대한 기본적인 이해를 돕는 과정이다.

  1. 데이터 표현방법 정의

막대 그래프, 트리등의 시각적 모델을 선택한다.

  1. 데이터의 표현을 정제

css3,html5,svg등의 마크업을 개선한다. 또한 여러 색상이론과 그래픽 디자인 이론에 따라 데이터의 표현을 개선한다.

  1. 상호작용 추가

데이터를 조작하거나 표시되는 기능을 제어하는 방법을 추가한다.

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>
);
};

d3 + react 기초

d3는 데이터 시각화 라이브러리다.

데이터와 html요소를 바인딩하여 시각화한다.

깃허브 스타수 10위 언저리에 있다..!

기본 세팅

d3는 dom을 직접 조작하기 때문에 react의 state기반 렌더링과는 잘 어울리지 않는다.

때문에 역할을 분리해주어야 한다.

나는 계산과 좌표계산등은 d3가 하고 기타 html세팅과 같은 작업들은 react를 이용해서 하도록 분리해주었다.

컴포넌트의 기본 세팅은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const App = ({ data }) => {
const containerRef = useRef();
const wrapperRef = useRef();

useEffect(() => {
const svg = select(svgRef.curent);
const container = select(containerRef.current);
// 계산하기
}, [data]);

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

useRef로 svg와 div를 참조하여 useEffect내부에서 d3를 통해 계산한다.

css설정은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
export const chartWrapper = styled.div`
width: 100%;
height: 700px;
padding: 0 32px;
box-sizing: border-box;
& > svg {
display: block;
margin: auto;
width: 100%;
}
`;

나중에 반응형을 적용해주기 위해 container와 svg의 width를 100%로 준다.

또한 container의 height를 직접 명시해주어야 한다.

또한 가운데 정렬을 위해 svg의 margin을 auto, display를 block으로 주었다.

container에 padding값을 주게되면 svg는 가운데 정렬 됨을 알 수 있다.

overflow

svg의 크기를 넘치를 요소를 보이지 않게 하려면 css의 clip-path를 이용해야한다.

clip-path를 넘친 부분은 보이지 않는다.

1
2
3
4
5
6
7
8
svg
.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width - marginLeft - marginRight)
.attr("height", height);

svg.select("target").attr("clip-path", "url(#clip)");

React+d3 에서 ResizeObserver로 반응형 svg 만들기

React에서 d3를 사용하게 되면 React는 state 기반으로 렌더링하지만 d3는 DOM을 직접 조작한다.

width와 height 또한 .attr 메소드를 이용하여 설정해주기 때문에 반응형으로 너비가 변하는 svg를 작성하기가 까다롭다.

유튜브 영상을 보고 ResizeObserver를 이용하여 이러한 설정을 해줄 수 있는 hook을 작성해보았다.

ResizeObserver

대상으로 하는 HTML 요소의 콘텐츠 또는 테두리의 크기를 관찰하다 변경사항이 생기면 내부의 콜백을 실행한다.

useResizeObserver

위의 ResizeObserver를 이용하여 hook을 작성하였다.
코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useEffect, useState } from "react";
import ResizeObserver from "resize-observer-polyfill";
// IE를 지원하기 위한 폴리필

const useResizeObserver = (ref) => {
const [dimensions, setDimensions] = useState(null);
// 현재 대상되는 요소의 정보를 state로 관리한다.
// 사용하는 컴포넌트에서 이 state가 바뀔 경우 리렌더링 하기 위함이다.
useEffect(() => {
// useEffect는 DOM 요소가 렌더링 되자마자 실행된다.
const observeTarget = ref.current;
// 관찰할 대상을 지정한다.
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setDimensions(entry.contentRect);
// 변화가 생겼을 경우 내부의 콜백이 실행된다.
// 이 콜백에서 state를 변화시키며 해당 컴포넌트를 리렌더링 한다.
});
});
resizeObserver.observe(observeTarget);
// 관찰을 시작한다.
return () => {
// 컴포넌트가 언마운트 될 경우 관찰을 종료한다.
resizeObserver.unobserve(observeTarget);
};
}, [ref]);
return dimensions;
};

export default useResizeObserver;

컴포넌트에서는 다음과 같이 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function BarChart({ data }) {
const svgRef = useRef();
const wrapperRef = useRef();
const dimensions = useResizeObserver(wrapperRef);

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

if (!dimensions) return;

const { width, height } = dimensions;

// width와 height를 사용하여 설정한다.
// ...
}, [data, dimensions]);
// 바뀔때마다 호출하기 위함이다.

return (
<div ref={wrapperRef} style={{ marginBottom: "2rem" }}>
<svg ref={svgRef}>
<g className="x-axis" />
<g className="y-axis" />
</svg>
</div>
);
}

export default BarChart;

Ref

useResizeObserver
ResizeObserver