[D3.js] D3 with React: Line Chart Tutorial

Frontend Dev Diary·2020년 12월 2일
9
post-thumbnail

이 글에서는 D3.js와 React를 이용해서 Line chart를 그리기 위한 D3 선행 지식들(selection, shape, scale methods)과 각 단계들을 설명합니다. React와 D3.js를 같이 다루기 위해서는 좀 더 생각해야 할 부분들이 많지만, 첫 번째 글에서는 D3 위주로 간단하게만 다루도록 하겠습니다.
D3.js를 다루는 것은 저도 처음입니다. 설명이 잘못된 부분이 있으면 댓글로 알려주세요.

0. D3.js를 시작하기 전

svg를 다룬적이 없는 경우, svg 튜토리얼을 읽는 것을 추천한다.

또한 D3.js는 기본적으로 Builder Pattern을 사용해서 svg를 다룬다. D3.js에서 다루게 되는 Selection 메서드들은 현재(혹은 새로 만들어진) selection을 반환해서, 이를 이용하여 method chaining을 통해 여러 operation을 적용하게 된다.

d3.select("body")
  .append("svg")
    .attr("width", 960)
    .attr("height", 500)
  .append("g")
    .attr("transform", "translate(20,20)")
  .append("rect")
    .attr("width", 920)
    .attr("height", 460);

convention상 현재 selection을 반환하는 메서드는 네 칸을 띄우고, 새로운 selection을 반환하는 메서드는 두 칸을 띄운다.

1. D3 library 사용하기

npm install d3

d3를 다루기 위해서 d3 라이브러리를 설치한다.

import * as d3 from 'd3';

이제 React에서 다음과 같이 사용할 수 있다.

2. Element를 선택하기 - select, selectAll

d3.select(selector)

selector string에 해당하는 첫 번째 element를 선택한다. 해당하는 element가 없으면 빈 selection을 반환한다.

const anchor = d3.select("a");

d3.selectAll(selector)

selector string에 해당하는 모든 element를 선택한다. element는 document상에서 top-to-bottom 순서로 선택되며, 해당하는 element가 없거나, selector가 null/undefined인 경우 빈 selection을 반환한다.

const anchor = d3.select("p");

React에서는?

React에서는 type, class, ID를 사용하지 않아도 ref를 사용하여 element를 참조할 수 있다.

const ref = useRef();

useEffect(() => {
    const currentElement = ref.current;
    const svg = d3.select(currentElement)
}, [data])

D3에서는 .select, .selectAll을 많이 사용하는데, element가 렌더링 되기 전에 .select, .selectAll을 사용한다면 문제가 생길 수 있다. 컴포넌트가 렌더링 된 이후에 ref에 접근하기 위해 useEffect 사용한다. 이제부터 d3를 다루는 모든 코드는 useEffect 안에 작성한다.

step #1

div 안에 svg를 추가해서 차트를 그리게 되는데, 이를 위해 ref가 div를 참조하게 한다. 이 ref를 select 함수에서 사용해서 div element를 포함하는 selection을 만든다.

useEffect(() => {
    const currentElement = ref.current;
    const documentElement = d3.select(currentElement)
return (
    <>
      <h2> Line Chart </h2>
      <div ref={ref} style={{
        width: '100%',
        height: graphHeight
      }}/>
    </>
  );

3. svg를 추가하고 viewBox를 지정하기 - call, append, attr

selection.call(function[, arguments…])

argument(optional)를 selection에게 전달해주면서 function 을 정확히 한 번 호출한 뒤, selection을 반환한다.

selection.append(type)

type 이 string이라면 해당하는 tag의 새로운 element를 selection 의 가장 마지막 child로 추가한다. data가 element보다 많은 경우, enter selection에서 append를 사용하여 data를 binding하는 새로운 element들을 생성할 수 있다.

d3.selectAll("div").append("p");

type 에는 추가될 element를 return하는 함수를 넣을 수도 있다.

d3.selectAll("div").append(() => document.createElement("p"));

두 경우 모두 추가된 element가 포함된 새로운 selection을 반환한다.

selection.attr(name[, value])

value가 주어진 경우 namevalue 로 attribute를 수정하고 이 selection 을 반환한다. value가 주어지지 않은 경우, selection 에서 첫 번째 element에 있는 주어진 attribute의 값을 반환한다. (selection에 element가 하나만 있을 때 주로 사용)

step #2

const documentElement = d3.select(currentElement)
      .call(g => g.select("svg").remove())
      .append('svg')
      .attr('viewBox', `0,0,${width},${height}`);

remove svg

const documentElement = d3.select(currentElement)
      .call(g => g.select("svg").remove())

여기서는 useEffect 안에 차트를 그리는데 사용될 데이터가 dependency로 추가되어 있기 때문에, 현재는 데이터가 변경되어 기존의 차트(svg)를 지우고 새로 그려야 하는 상황이라고 가정하자.
#1 step에서 만들었던 documentElement는 div를 가지고 있는 selection이다. call을 부르게 되면 documentElement을 넘겨서 함수를 실행하게 된다. 여기서는 selection 안에 있는 svg를 없애는 함수를 실행한다.

append svg

      .append('svg')

selection에 svg element를 추가한다.

set attribute

      .attr('viewBox', `0,0,${width},${height}`);

viewBox attribute를 0, 0, width, height로 설정한다.

4. parseData

d3.timeParse(string)

string으로부터 Date Object를 만들어내는 string parser를 만들 수 있다. 만들어진 parser을 이용하면 string을 Date Object로 변환할 수 있다.

const formatTime = d3.timeFormat("%B %d, %Y");
formatTime(new Date); // "June 30, 2015"

step #3

현재는 다음과 같은 형식으로 날짜가 저장되어 있다.

export const lineData = [
    {d: '2007-04-23', v: 93.24},
    {d: '2007-04-24', v: 95.35},
    {d: '2007-04-25', v: 98.84},
    {d: '2007-04-26', v: 99.92},
  	...

이같은 형식을 Date Object로 변환할 수 있는 parse를 만들었다.

const parseDate = d3.timeParse("%Y-%m-%d");

5. Visualization - Lines

D3에서는 Arcs, Symbols(원, 마름모, 사각형 등등), line 같은 모양을 만들 수 있는 shape generators를 제공한다. 이러한 shapes들은 data에 의해서 그려지는데, 각각은 input data가 visual representation으로 어떻게 맵핑되는지를 제어하는 accessor(접근자) 를 가지고 있다. 현재는 Line Chart를 그리는 것이 목적이기 때문에 이 중에서도 line generator을 사용한다.

d3.line([x][, y])

디폴트 설정으로 line generator을 만든다. x, y를 지정해서 접근자를 만들 수 있다.

line.x([x])

x를 지정해서 x 접근자를 만들고, 이 line generator를 반환한다. x가 지정되지 않았다면 현재 x 접근자를 반환한다. x 접근자의 디폴트 설정은 d[0]이다.

line.y([y])

y를 지정해서 y 접근자를 만들고, 이 line generator를 반환한다. y가 지정되지 않았다면 현재 y 접근자를 반환한다. y 접근자의 디폴트 설정은 d[1]이다.

line.defined([defined])

defined를 지정해서 defined 접근자를 만들고, 이 line generator를 반환한다. defined가 지정되지 않았다면 현재 defined 접근자를 반환한다. defined 접근자의 디폴트 설정은 true이다. 따라서 디폴트 accessor는 input data가 항상 정의(defined) 되었다고 간주한다.
line이 만들어질 때 defined 접근자는 input data 배열의 각각의 element에 의해 호출되는데, 세 개의 argument를 갖는다. (각각의 element인 d, index i, 배열 전체 array data)

d, i, data를 실제로 출력해보면 다음과 같은 결과를 확인해볼 수 있다.
만약 주어진 element가 정의되었다면 (즉, 이 element의 defined accessor이 true 값을 가진다면) x, y 접근자는 실행되어 현재 line segment에 점이 찍힐 것이다. false인 경우 이 element는 스킵되고, 현재 line segment 또한 끝나서 다음 점을 위해 새로운 line segment가 만들어진다. 결과적으로 만들어진 Line Chart은 뚝뚝 끊어진 모양을 할 수 있다.
참고: Line with Missing Data

step #4

const d3Type = d3.line()
      .defined(d => !isNaN(d.v))
      .x(d => x(parseDate(d.d)))
      .y(d => y(d.v));

이 단계에서는 line generator를 만든다.

export const lineData = [
    {d: '2007-04-23', v: 93.24},
    {d: '2007-04-24', v: 95.35},
    {d: '2007-04-25', v: 98.84},
    {d: '2007-04-26', v: 99.92},
  	...

현재 데이터는 다음과 같이 정의되어 있으며, d가 x축(step #3에서 만든 string parser 사용), v가 y축을 그리게 된다. 그리기 전 v가 숫자인지 확안히기 위해서 defined를 사용한다. v가 숫자가 아니라면 이 element의 defined accessor 값이 false가 되고, x, y 접근자 또한 실행되지 않아 점이 그려지지 않는다.

6. Scale - scaleTime, scaleLinear

데이터 시각화를 위해서 쓰이는 추상화 기법이다. 주로 양적인 데이터를 위치로 변환시키기 위해서 사용된다. (ex: meter 단위로 측정한 것을 pixel로 바꾸기) Scales은 색 바꾸기, 선 굵기, symbol 사이즈 등등 어떤 비주얼 형태로든 나타낼 수 있다.
연속적인 데이터인 경우 주로 linear scale을 사용하지만, 연속적인 데이터를 quantize scale, quantile scale, threshold scale을 이용해서 이산적인 값들로 만들 수도 있다.
categorical data나 discrete data 같은 데이터는 ordinal scale을 사용한다.

d3.scaleTime([[domain, ]range])

domain과 range를 가지고 새로운 time scale을 만든다. range의 default 값은 [0, 1]이다.

d3.scaleLinear([[domain, ]range])

domain으로부터 값을 받아서, range에서 상응하는 값을 반환하는 continuous scale을 만든다. clamp 값이 true인 경우, 반환된 값은 항상 scale의 범위 안에 있다.

const x = d3.scaleLinear([10, 130], [0, 960]);
const color = d3.scaleLinear([10, 100], ["brown", "steelblue"]);

continuous.domain([domain])

domain을 받아서 scale의 domain을 지정된 숫자의 배열로 설정한다. 배열은 두 개 이상의 element를 포함하고 있어야 하며, 주어진 배열의 element가 숫자가 아닌 경우 강제로 숫자로 바꾼다.
domain을 지정하지 않은 경우에는 scale의 현재 domain을 반환한다.

continuous.range([range])

range을 받아서 scale의 range를 지정된 값의 배열로 설정한다. 두 개 이상의 element를 포함하고 있어야 한다. domain과 달리 배열이 꼭 숫자일 필요는 없으며, 어떤 값이든 보간이 되는 것은 동작한다.
range을 지정하지 않은 경우에는 scale의 현재 range를 반환한다.

continuous.nice([count])

domain의 시작과 끝을 가장 가까운 반올림 값으로 확장시킨다.
ex) [0.201479…, 0.996679…] -> [0.2, 1.0]

step #5

  const x = d3.scaleUtc()
      .domain(d3.extent(jsonData, d => parseDate(d.d)))
      .range([margin.left, width - margin.right]);

scaleUtc는 time scale이 local time이 아니라 Coordinated Universal Time에서 이용해서 동작한다는 점을 빼고는 scaleTime과 동일하다.
scaleUtc 메서드를 사용해서 새로운 time scale을 만든다.
extent를 사용하여 배열의 date값의 min, max를 domain으로 설정한다. range는 margin.left부터 width - margin.right로 설정하는데, 여기서 width는 div element의 offsetWidth 값이다. 즉, 양 옆 margin을 제외하고 div의 width를 x축의 range로 잡아준다.

  const y = d3.scaleLinear()
      .domain([0, d3.max(jsonData, d => d.v)]).nice()
      .range([height - margin.bottom, margin.top]);

연속적인 값을 가지는 v 값은 scaleLinear을 이용해서 continuous scale을 만든다. 0부터 v 값 중 최대값을 domain으로 설정하여 반올림한다.
range는 width와 마찬가지로 top, bottom margin을 제외하고 div의 height로 지정한다.

7. Axis - x, y축 그리기

x, y 방향에 상관없이 축은 항상 원점에서 렌더링된다. 차트상에서 이 위치를 바꾸기 위해서는 transform attribute를 사용해야 한다. D3를 통해서 축을 만들면 다음과 같이 생성된다.

<g fill="none" font-size="10" font-family="sans-serif" text-anchor="middle">
  <path class="domain" stroke="currentColor" d="M0.5,6V0.5H880.5V6"></path>
  <g class="tick" opacity="1" transform="translate(0.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">0.0</text>
  </g>
  <g class="tick" opacity="1" transform="translate(176.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">0.2</text>
  </g>
  <g class="tick" opacity="1" transform="translate(352.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">0.4</text>
  </g>
  <g class="tick" opacity="1" transform="translate(528.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">0.6</text>
  </g>
  <g class="tick" opacity="1" transform="translate(704.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">0.8</text>
  </g>
  <g class="tick" opacity="1" transform="translate(880.5,0)">
    <line stroke="currentColor" y2="6"></line>
    <text fill="currentColor" y="9" dy="0.71em">1.0</text>
  </g>
</g>

축은 domain의 범위를 나타내는 domain class를 가지고 있는 path element으로 구성된다. 그 뒤에는 각각의 tick을 나타내는 tick class를 가지고 있는 g element가 있다. 각각의 tick은 line element가 있어서 tick line을 그리며, text element가 tick label을 담당한다.
축의 방향은 고정되어 있기 때문에 방향을 바꾸고 싶다면 기존의 축을 지우고 새로 만들어야 한다.

d3.axisBottom(scale)

scale을 가지고 bottom 방향의 axis generator를 만든다. 이 방향에서는 tick이 수평 방향의 domain 아래에 그려진다.

d3.axisLeft(scale)

scale을 가지고 left 방향의 axis generator을 만든다. 이 방향에서는 tick이 수직 방향의 domain 왼쪽에 그려진다.

axisBottom, axisLeft 등의 메서드는 axis generator을 만드는 것이기 때문에 실제로 axis를 만들기 위해서는 axis를 추가하고자 하는 곳에 call로 호출해야 한다.

d3.select("body").append("svg")
    .attr("width", 1440)
    .attr("height", 30)
  .append("g")
    .attr("transform", "translate(0,30)")
    .call(axis);

axis.ticks(arguments…)

axis가 렌더링 됐을 때 scale.ticks와 scale.tickFormat으로 전달되는 arguments를 설정하고, axis generator을 반환한다. argument로는 주로 tick의 개수인 count 혹은 time scales에서는 시간 간격 등이 있다.

axis.tickSizeInner([size])

inner tick의 크기를 size로 지정하고, axis를 반환한다. size가 지정되지 않았다면 현재 inner tick의 크기를 반환한다. inner tick의 크기는 tick line의 길이를 조절한다.

axis.tickSizeOuter([size])

outer tick의 크기를 size로 지정하고, axis를 반환한다. size가 지정되지 않았다면 현재 outer tick의 크기를 반환한다. outer tick의 크기는 domain path 끝에 있는 사각형의 길이를 조절한다.
outer tick은 실제 tick은 아니지만 domain path 상에서 존재하며, scale의 domain 범위에 따라서 위치가 지정된다. 따라서 첫 번째나 마지막 inner tick과 겹쳐질 수 있다.

outer tick 크기가 6인 경우 (디폴트 값), 양 끝에 outer tick이 표시된다.

outer tick 크기가 0인 경우, 직선이 그려지지 않는다.

step #6

const xAxis = g => g
      .attr("transform", `translate(0,${height - margin.bottom})`)
      .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));

documentElement.append('g').call(xAxis);

먼저 x(time scale)를 가지고 x축을 위한 axis generator을 만든다. Chart 상에서 축이 바닥에 위치하도록 만들기 위해 transform 속성을 사용해서 축을 Chart의 세로 길이만큼 이동시켰다.
ticks의 길이를 80으로 맞추기 위해 tick의 개수를 width / 80으로 맞춰주었다. x축이 time scale이라면 count 값을 넣었을 때 시간 간격을 자동으로 계산해준다.

const yAxis = g => g
      .attr("transform", `translate(${margin.left},0)`)
      .call(d3.axisLeft(y));

documentElement.append('g')
  .call(yAxis)
  .call(g => g.select(".domain").remove());

y(continuous scale)을 가지고 y축을 위한 axis generator을 만든다. 왼쪽 margin을 두고 축을 그리기 위해 transform 속성을 사용하였다.
axis generator을 호출한 뒤에는 domain class을 없애주어서 path element(세로 선)을 없앴다.

8. Path 그리기

d3.path()

path 메서드는 CanvasPathMethods를 직렬화해서 만들어졌다. 이를 이용해서 새로운 path를 만들 수 있다.

selection.data([data])

data가 인자로 주어졌을 때, data 배열의 element들과 selection 사이에서 data-join을 수행함으로써 데이터가 바운드된 새로운 selection(update selection)을 반환하고, 이 selection에는 enter, exit selection도 정의되어 있다. 그 결과 selection에 있는 각각의 element는 data 배열의 하나의 element(datum)을 가지게 된다. 이후에 selection.join을 사용해서 element들을 data와 매치시키기 위해서 enter, update, exit를 수행할 수 있다.
data가 인자로 주어지지 않은 경우 selection.data()를 호출하면 selection에 있는 각각의 DOM element에게 바운드된 data element(datum)를 합쳐서 배열로 만들어서 리턴한다.
예를 들어서 data = [1,2,3]의 요소와 selection에 있는 3개의 DOM element가 각각 서로 바인드 되어 있을 때, selection.data()를 호출하면 ["a", "b", "c"]가 리턴된다. DOM element가 하나만 있는 경우에는 "a"가 바인드 되어서 selection.data()를 호출했을 때는 ["a"]이 리턴된다.

selection.datum([data])

data가 인자로 주어졌을 때, data 전체를 selection에 있는 모든 DOM element 각각에게 할당한다. join을 수행하지 않으므로 enter, exit selection도 생성되지 않는다. 따라서 selection에 있는 element가 하나일 때, 정적인 모습을 보여줄 때 더 적절하다.
data가 인자로 주어지지 않은 경우, selection.datum()을 수행하면 selection의 첫 번째 element에 바운드된 datum이 반환된다. 따라서 위의 예시에서 selection의 DOM element 각각에게 바운드된 datum으로"a", "b", "c"이 있을 때, selection.datum()을 호출하면 "a"가 리턴된다.

참고: What is the difference D3 datum vs data?
datum vs data Fiddle
selection-join

step #7

documentElement.append('path')
  .datum(jsonData)
  .attr('fill', 'none')
  .attr('stroke', 'steelblue')
  .attr('stroke-width', 1.5)
  .attr('stroke-linejoin', 'round')  
  .attr('stroke-linecap', 'round')
  .attr('d', d3Type);

마지막으로 데이터를 가지고 Chart에 선을 그려준다. datum을 이용해서 데이터를 바운드한다. step #4에서 만들었던 line generator인 d3Type을 이용해서 path의 d attribute를 계산한다.

구현 결과

소스코드(타입 스크립트 버전)

x축은 시간, y축은 값을 나타내는 Line Chart가 만들어졌다!

참고 자료

D3 repo의 README
Using D3 with React and TypeScript
enter selections
Working with Time

profile
성장하는 프론트엔드 개발자

0개의 댓글