5️⃣

05장 D3.js를 사용한 데이터 시각화

 

5.1 D3.js 개요

5.1.1 D3.js는 무엇인가?

  • D3.js(이하 D3)는 데이터를 시각화하기 위한 무료 오픈 소스 자바스크립트 라이브러리이다. D3는 웹 표준에 기반한 저수준 접근 방식을 택하고 있으며, 이는 동적이고 데이터 기반의 그래픽을 작성하는 데 있어 유연함을 제공한다.
 

5.1.2 D3 is a low level toolbox

  • D3는 전통적인 차트 라이브러리가 아니다. 차트라는 개념이라기보다는 다음과 같은 구성 요소들로 데이터를 시각화한다.
 

5.1.3 D3 구성요소

stacked area chart를 만드는 예제를 기준으로 필요한 구성요소는 다음과 같다.
  • axes for documenting the position encodings, and
이것만 보면 고려 사항이 많은 것처럼 보인다. 하지만 각 요소는 독립적으로 사용할 수 있으므로 모든 것을 한꺼번에 배울 필요는 없고 개별 요소를 배운 다음 조합해서 사용하면 된다.
(D3는 거대한 단일 구조가 아니라 30개의 개별 모듈 모음이다!)
 

5.1.4 D3의 특징

  • D3는 유연하다
    • D3의 모든 요소는 완전히 제어가 가능하며 원하는 대로 시각화를 할 수 있다. (D3에는 데이터를 나타내는 기본적인 표현이 없으며 오직 사용자가 직접 작성한 코드만이 존재한다.)
  • D3는 웹과 함께 동작한다
    • D3는 새로운 그래픽 표현을 도입하지 않고 SVG 및 Canvas와 같은 웹 표준을 직접 사용하여 D3를 활용한다.
  • D3는 맞춤형 시각화를 위한 도구이다
  • D3는 동적 시각화를 위한 도구이다
    • D3는 동적 렌더링을 지원한다. 따라서 D3는 데이터 배열을 기반으로 하여 조건부로 HTML 요소를 생성하고 렌더링할 수 있다.
 

5.1.5 D3와 React는 악어와 악어새 관계이다.

  • D3와 React는 악어와 악어새처럼 서로에게 있어 이익을 주고받는 상리공생 관계에 놓여 있다.
  • 컴포넌트 기반 아키텍처와 효율적인 가상 DOM 렌더링이라는 특징을 지니는 React는 사용자 인터페이스를 구축하는 반면, 데이터 기반 문서(Data Driven Documents)인 D3는 데이터 기반의 직접적인 DOM 조작을 통해 정교하고 유연한 시각화를 구축한다.
  • 이러한 리액트와 D3는 통합되면 서로의 강점을 살려서 interactive한 데이터 시각화에 크게 기여할 수 있다. 왜냐하면 리액트 컴포넌트는 애플리케이션의 상태와 UI 로직을 관리하고, D3는 시각적 요소의 정확한 렌더링과 애니메이션을 처리하기 때문이다. 즉, 명확한 책임 구분을 통해 개발자는 반응성이 뛰어나며 유지 관리 용이한 데이터 기반 애플리케이션을 구축할 수 있게 되는 것이다.
  • 실제로, 데이터를 가져오고 나서 React 컴포넌트 내에서 이 데이터를 처리하게 된다. 준비가 완료되면 함수형 컴포넌트의 useEffect와 같은 React의 생명 주기 메서드 내에서 D3 메서드가 호출되어 SVG 요소를 생성하고 업데이트한다. 이러한 과정을 통해 필요한 경우에만 D3 조작이 수행되므로 React의 가상 DOM의 성능상 이점을 보존할 수 있다.
 

5.2 D3 시작하기

5.2.1 설치 방법

Node를 이용한 웹 어플리케이션을 개발하는 경우 yarn, npm, pnpm과 같은 패키지 매니저를 통해 D3를 설치할 수 있다.
# npm npm install d3 # yarn yarn add d3 # pnpm pnpm add d3
다음 구문을 통해 D3를 로드할 수 있다
import * as d3 from 'd3';
본 프로젝트는 npm을 기반으로 설명하겠다.
 

5.2.2 D3 이용에 필요한 요소 및 주의할 점

  • svg
브라우저상에서 데이터를 시각화하는 경우, 우리는 보통 SVG 요소들로 작업한다. (SVG는 표현력이 뛰어나고 위치가 확실하게 잡혀있기 때문이다)
💡
SVG (Scalable Vector Graphics)란, 벡터 그래픽을 서술하는 XML 기반의 마크업 언어이다. 텍스트 기반의 열린 웹 표준 중 하나이며 모든 사이즈에서 깔끔하게 렌더링 되는 이미지를 서술한다. (확대 및 축소를 하거나 회전하여도 이미지 손상이 일어나지 않는다.) 즉, SVG는 HTML과 텍스트의 관계를 그래픽 상에 적용한 것이다.
 
  • useRef
svg 요소(DOM 요소)에 대한 참조를 저장하기 위해 사용
 
  • useEffect
D3.js 코드를 React 렌더링 주기에 맞춰 실행하고, 데이터 변경에 따라 시각화를 업데이트하기 위해서 useEffect를 사용한다. useEffect는 컴포넌트가 렌더링 된 후에 실행되므로, DOM 요소가 존재하는 시점에 D3 코드를 실행할 수 있다. useEffect 밖에 d3.select 관련 코드를 두면, 아직 DOM 요소가 존재하지 않는 시점에 D3 코드가 실행되어 아무것도 렌더링 되지 않는다. (React의 렌더링 사이클과 맞지 않기 때문에 결과가 렌더링 되지 않음)
 
  • 프로젝트 기본 구성
import { useEffect, useRef } from 'react'; import * as d3 from 'd3'; const ComponentName = () => { const ref = useRef; useEffect(() => { const svg = d3.select(ref.current); /* 데이터를 사용하여 시각화하기 위한 D3 코드 작성 */ },[]) } export default ComponentName;
 

5.3 D3로 시각화 구성하기

5.3.1 시각화 구성 시작하기

  • SVG 요소 만들기
D3를 시작하기 위해 간단한 <svg> 요소를 렌더링 해보자. width, height 및 backgroundColor를 추가하여 우리는 <svg>요소를 생성한 것을 확인할 수 있다.
const Svg1 = () => { return ( <> <svg style={{ width: "100px", height: "100px", backgroundColor: "red" }} /> </> ); }; export default Svg1;
notion imagenotion image
 
이번에는 원을 그려보자.
notion imagenotion image
리액트를 이용해서 원은 다음과 같이 나타낼 수 있다.
import { useEffect, useRef } from "react"; import * as d3 from "d3"; const Svg2 = () => { const ref = useRef(); // 1) useEffect(() => { // 2) const svgElement = d3.select(ref.current); // 3) svgElement .append("circle") // 4) .attr("cx", 100) .attr("cy", 80) .attr("r", 50); }, []); return ( <> {/* 1 */} <svg ref={ref} /> </> ); }; export default Svg2;
  1. useRef를 사용해서 렌더링된 <svg> 요소에 대한 참조를 저장한다.
  1. 컴포넌트가 마운트될 때 d3 코드를 실행시킨다.
  1. d3.select()를 이용하여 refd3 selection object로 바꾼다.
  1. d3 selection object를 사용해서 <circle> 요소를 추가한다.
 
위 코드는 하나의 원을 그리기 위해 많은 양의 코드를 사용하고 있다. 실제로는 아래와 같이 간단하게 작성하더라도 똑같은 결과물을 도출해 낼 수 있다.
notion imagenotion image
const Svg3 = () => { return ( <> <svg> <circle cx="100" cy="80" r="50"></circle> </svg> </> ); }; export default Svg3;
하지만 우리는 학습의 단계에 있기 때문에 2번의 방법을 주로 사용해서 코드를 구성하기로 한다.
 

5.3.2 D3 Architecture flow

D3는 다음과 같은 흐름대로 동작한다.
  1. 외부 파일(JSON, CSV 등)로부터 데이터를 불러오거나 JS array 및 object 형태로 데이터를 제공한다.
  1. CSS selector 혹은 useRef등을 사용해서 HTML, SVG 요소를 선택한다.
  1. 선택된 DOM들을 각 데이터에 맞추어서 시각화될 수 있도록 조작을 가한다.
notion imagenotion image
 

5.3.3 시각화 구성요소

1. selection

  • D3는 메서드 체이닝을 이용하여 svg를 다룬다.
    • D3의 selection은 selection 객체를 반환하는데, 메서드 체이닝을 통해 선택된 DOM 요소에 attribute와 style 등을 적용할 수 있게 도와준다. → 이를 통해 데이터 조작과 시각화를 효율적으로 진행할 수 있게 된다.
 
예시를 통해 selection이 어떻게 동작하는지 살펴보자.
import { useEffect, useRef } from "react"; import * as d3 from "d3"; const Selection = () => { const ref = useRef(); useEffect(() => { const svg = d3.select(ref.current); svg.append("circle") .data() // option .attr("cx", 100) .attr("cy", 80) .attr("r", 50); }, []); return <svg ref={ref} width={200} height={200} />; }; export default Selection;
  1. select를 이용해서 Element를 탐색한 다음 selection 객체를 생성한다.
  1. data가 있다면 데이터를 넣는 경우도 있다
  1. append를 통해 circle요소를 추가한다
  1. 생성된 요소의 cx, cy, r 값에 데이터를 넣는다.
 
이를 통해 select는 가장 선행되어야 하는 행위임을 짐작할 수 있다.
selection에 대해 더 자세히 알고싶다면 아래 내용을 참고하면 좋다.
 

2. axis

축(axis) 컴포넌트는 SVG 컨테이너(일반적으로 단일 g 요소)의 선택 후 호출하면 축을 채울 수 있다.
notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; const Axis = () => { const ref = useRef(); useEffect(() => { const svg = d3.select(ref.current) .attr("width", 1000) .attr("height", 200); const x = d3.scaleLinear() .domain([1, 10]) .range([1,500]); svg .append("g") .attr("transform", "translate(0,100)") .call(d3.axisBottom(x)); }, []); return <svg ref={ref} />; }; export default Axis;
 
  • 축 생성 및 추가
svg.append("g") .attr("transform", "translate(0,100)") .call(d3.axisBottom(x));
  1. SVG 요소에 그룹 요소(g)를 추가한다.
  1. transform 속성을 사용하여 y축 방향으로 100 픽셀 이동시킨다.
  1. d3.axisBottom(x)을 호출하여 수평 축을 생성한다.
 

3. scale

scale을 사용하면 range와 domain을 지정함으로써 형태와 크기를 원하는 대로 만들 수 있다.
notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; const Scale = () => { const ref = useRef(); useEffect(() => { // SVG 크기 설정 const width = 2000; const height = 200; // 데이터 (0 ~ 10,000) const data = Array.from({ length: 10000 }, (_, i) => i + 1); // SVG 요소 선택 const svg = d3 .select(ref.current) .attr("width", width) .attr("height", height); // x 스케일 정의 (선형 스케일) const xScale = d3 .scaleLinear() .domain([0, d3.max(data)]) // 입력 도메인: 데이터의 최소값과 최대값 .range([0,1000]); // 출력 범위: SVG의 너비 // x축 추가 const xAxis = d3.axisBottom(xScale); svg .append("g") .attr("transform", `translate(0, ${height - 100})`) .call(xAxis); }, []); return ( <> <svg ref={ref}></svg> </> ); }; export default Scale;
  • d3.scaleLinear
    • (위 코드상) xScaleLinear 라는 변수에 d3.scaleLinear() 함수를 사용하여 입력 범위(domain)를 출력 범위(range)에 매핑한다. 그리고 이 둘간의 선형 관계를 가지는 scale을 생성한다.
    • notion imagenotion image
    • 기본꼴
    • scaleLinear(domain, range)
 
  • linear.domain([최솟값, 최댓값])
    • domain은 입력 데이터의 범위를 지정하는 입력 데이터의 집합이다. (시각화하려는 데이터의 최솟값과 최댓값)
    • 기본꼴
    • const xScale = d3 .scaleLinear() .domain([최솟값, 최댓값])
 
  • linear.range([최솟값, 최댓값])
    • range는 출력 범위를 지정하는 출력 값의 집합이다.
    • 기본 꼴
      • const xScale = d3 .scaleLinear() .domain([최솟값, 최댓값]) .range([최솟값, 최댓값]
    • range 비교
      • 앞서 살펴본 range를 1000 → 2000으로 바꾸면 다음과 같은 결과가 나온다. 매핑된 값이 이전과 달라진 것을 확인할 수 있다. (실제로 화면상 길이도 2배정도 늘어난 것을 볼 수 있다.)
        notion imagenotion image
 

4. color

앞서 살펴보았던 원 그리기 예제에서 color를 채우기 위해서는 fill 속성을 이용하고 색상을 지정해 주면 된다
notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; const Color = () => { const ref = useRef(); useEffect(() => { const svg= d3.select(ref.current); const color = d3.color('steelblue'); svg .append("circle") .attr("cx", 100) .attr("cy", 80) .attr("r", 50) .attr("fill", color); // 색상 지정 }, []); return ( <> <svg ref={ref} /> </> ); }; export default Color;
 

5. transition

이번에는 transition을 이용해서 화면상의 원이 왔다갔다하는 것을 구현해보자.
D3에서 트랜지션은 요소의 속성이나 스타일을 부드럽게 변화시키는 방법이다.
아래의 코드에서는 원(circle)의 반지름(r)과 색상(fill)에 트랜지션을 적용하고 있다.
notion imagenotion image
notion imagenotion image
import React, { useEffect, useRef, useState } from "react"; import * as d3 from "d3"; const generateCircles = () => { return Array.from({ length: 10 }, () => Math.floor(Math.random() * 10)); }; const Transition = () => { const [visibleCircles, setVisibleCircles] = useState(generateCircles()); const ref = useRef(); useEffect(() => { const timer = setInterval(() => { setVisibleCircles(generateCircles()); }, 2000); return () => clearInterval(timer); }, []); useEffect(() => { const svg = d3.select(ref.current); svg .selectAll("circle") .data(visibleCircles, (d) => d) .join( (enter) => enter .append("circle") .attr("cx", (d) => d * 10 + 5) .attr("cy", 10) .attr("r", 0) .attr("fill", "cornflowerblue") .transition() .duration(1000) .attr("r", 5), (update) => update.attr("fill", "lightgrey"), (exit) => exit.transition().duration(1000).attr("r", 0).remove() ); }, [visibleCircles]); return <svg viewBox="0 0 100 20" ref={ref} />; }; export default Transition;
체크 포인트
  • enter tarnsition
enter.append("circle") .attr("cx", d => d * 10 + 5) .attr("cy", 10) .attr("r", 0) .attr("fill", "cornflowerblue") .transition() .duration(1000) .attr("r", 5)
새로 생성되는 원은 반지름 0에서 시작해 1초(1000ms) 동안 반지름 5로 커지는 애니메이션을 적용하고있다.
 
  • update transition
update.attr("fill", "lightgrey")
기존 원의 색상을 즉시 변경한다.
이 코드가 없다면 lightgrey로 원이 변하지 않는다.
 
  • exit transition
exit.transition().duration(1000) .attr("r", 0) .remove()
삭제될 원은 1초 동안 반지름이 0으로 줄어드는 애니메이션을 적용한 후 제거된다.
 
useEffect(() => { const timer = setInterval(() => { setVisibleCircles(generateCircles()); }, 2000); return () => clearInterval(timer); }, []);
useEffect는 컴포넌트가 마운트될 때 한 번만 실행되며, 2초마다 visibleCircles 상태를 업데이트한다. (컴포넌트가 언마운트될 때 인터벌을 정리하는 클린업 함수도 포함시켜 위험성을 제거하였다.)
 

5.3.3 D3 shape 종류

1. Lines

notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; import data from './data.js' const LineShape = () => { const ref = useRef(); useEffect(() => { const width = 1000; const height = 400; const margin = { top:60, right: 40, bottom: 60, left: 60 }; const svg = d3.select(ref.current) .attr("width", width) .attr("height", height) const x = d3.scaleTime() // scaleTime: x축 시간 나타냄 .domain(d3.extent(data, d => d.date)) .range([margin.left, width - margin.right]); const y = d3.scaleLinear() .domain([0, d3.max(data, d => d.value)]) .range([height - margin.bottom, margin.top]); // 라인 그리기 - line 메서드, path 사용 const line = d3.line() .x(d => x(d.date)) .y(d => y(d.value)); svg.append("path") .datum(data) .attr("fill", "none") .attr("stroke", "steelblue") .attr("stroke-width", 2) .attr("d", line); const xAxis = d3.axisBottom(x) .ticks(d3.timeMonth.every(1)) .tickSizeOuter(0) .tickFormat(d3.timeFormat("%m월")); // 한국어 변환 svg.append("g") .attr("transform", `translate(0,${height - margin.bottom})`) .call(xAxis) .append("text") // x축 레이블 텍스트 (범례) .attr("fill", "red") .attr("x", width/2) .attr("y", margin.bottom - 10) .style("font-size", "12px") .text("월"); svg.append("g") .attr("transform", `translate(${margin.left},0)`) .call(d3.axisLeft(y)) // y축 레이블 텍스트 (범례) .append("text") .attr("fill", "green") .attr("x", -margin.left-1) .attr("y", height/2) .attr("text-anchor", "start") .style("font-size", "12px") .text("인원 수"); // 차트 제목 svg.append("text") .attr("x", width / 2) .attr("y", margin.top / 2 +10) .attr("text-anchor", "middle") .style("font-size", "20px") .style("font-weight", "bold") .text("월별 도서 대여 신청자 수"); }, []); return <svg ref={ref}/> }; export default LineShape;
 
  • scale 정의
const x = d3.scaleTime() // scaleTime: x축 시간 나타냄 .domain(d3.extent(data, d => d.date)) .range([margin.left, width - margin.right]); const y = d3.scaleLinear() .domain([0, d3.max(data, d => d.value)]) .range([height - margin.bottom, margin.top]);
 
  • 선 그리기
const line = d3.line() .x(d => x(d.date)) .y(d => y(d.value)); svg.append("path") .datum(data) .attr("fill", "none") .attr("stroke", "steelblue") .attr("stroke-width", 2) .attr("d", line);
라인 차트를 만들기 위해 선 그리기 메서드인 line을 사용하였다. 또한 x 속성은 d.date 값을 x축 scale에 따라 매핑하였고, y 속성은 d.value값을 y축 scale에 따라 매핑하였다.
라인 차트를 만들기 위해 중요한 path element를 svg에 추가하기도 하였다. data 배열을 기준으로 데이터를 채워 넣었고, 순서대로 채우기 없음 / 선 색상 / 선 두께 설정을 하였다. 마지막으로 line 메서드를 이용해서 데이터 point들을 연결 하였다.
  • 라인 스타일
    • stroke, stroke-width, stroke-dasharray 등을 사용하여 라인 스타일을 변경할 수 있다.
 

2. Bar

notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; import data from "./data.js"; const BarShape = () => { const ref = useRef(); useEffect(() => { const width = 1000; const height = 400; const margin = { top: 50, right: 30, bottom: 40, left: 40 }; const svg = d3 .select(ref.current) .attr("width", width) .attr("height", height); // x, y 축 스케일 정의 const xScale = d3 .scaleBand() .domain(data.map((d) => d.name)) .range([margin.left, width - margin.right]) .padding(0.25); const yScale = d3 .scaleLinear() .domain([0, d3.max(data, (d) => d.value)]) .nice() .range([height - margin.bottom, margin.top]); svg.selectAll("*").remove(); // Bar 생성 svg .append("g") .attr("fill", "#5ED3F3") .selectAll("rect") .data(data) .join("rect") .attr("x", (d) => xScale(d.name)) .attr("y", (d) => yScale(d.value)) .attr("height", (d) => yScale(0) - yScale(d.value)) .attr("width", xScale.bandwidth()); // 축 생성 svg .append("g") .call(d3.axisLeft(yScale)) .attr("transform", `translate(${margin.left},0)`); svg .append("g") .call(d3.axisBottom(xScale).tickSizeOuter(0)) .attr("transform", `translate(0,${height - margin.bottom})`); // 차트 제목 svg .append("text") .attr("x", width / 2) .attr("y", margin.top / 2 - 3) .attr("text-anchor", "middle") .style("font-size", "20px") .style("font-weight", "bold") .text("부문별 인원 현황"); }, [data]); return <svg ref={ref} />; }; export default BarShape;
 
  • x,y축 스케일 정의
const xScale = d3 .scaleBand() // x축의 스케일을 생성하는 함수 .domain(data.map((d) => d.name)) // x축에 표시할 데이터. map()을 사용하여 name을 추출한 배열을 반환 .range([margin.left, width - margin.right]) .padding(0.25); // 막대의 간격을 조절 const yScale = d3 .scaleLinear() .domain([0, d3.max(data, (d) => d.value)]) .nice() .range([height - margin.bottom, margin.top]);
 
  • 데이터 업데이트 반영
svg.selectAll("*").remove();
이전 데이터를 제거하는 역할을 해서 데이터 업데이트를 반영한다. (이것이 없다면 데이터를 업데이트 했을 때 블럭이 겹쳐져 보이는 것을 확인할 수 있다. → data의 맨 마지막 요소를 지웠다가 차트를 업데이트 하면서 확인해보기 바란다.)
 
  • Bar 생성
svg .append("g") .attr("fill", "#5ED3F3") .selectAll("rect") .data(data) .join("rect") .attr("x", (d) => xScale(d.name)) .attr("y", (d) => yScale(d.value)) .attr("height", (d) => yScale(0) - yScale(d.value)) .attr("width", xScale.bandwidth());
  1. SVG에 그룹 요소 <g> 추가
  1. 막대 색상 채우기 설정
  1. 그룹내의 모든 <rect> 요소를 선택
  1. 데이터 배열 data를 가져와서 사각형에 바인딩
  1. 각 막대의 x 속성 지정
  1. 각 막대의 y 속성 지정
  1. 각 막대의 높이 설정
  1. 각 막대의 너비 설정
 

3. Areas

notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; import data from "./data.js"; const AreaShape = () => { const ref = useRef(); useEffect(() => { const width = 1000; const height = 500; const margin = { top: 20, right: 30, bottom: 30, left: 50 }; const svg = d3 .select(ref.current) .attr("width", width) .attr("height", height); const xScale = d3 .scaleTime() .domain(d3.extent(data, (d) => d.date)) .range([margin.left + 5, width - margin.right]); // +5는 .attr("transform", `translate(${margin.left+5},0)`)의 +5와 맞춘것임 (그래야 일직선으로 수직, 수평의 경계가 겹치지 않게됨) const yScale = d3 .scaleLinear() .domain([0, d3.max(data, (d) => d.close)]) .nice() .range([height - margin.bottom, margin.top]); const area = d3 .area() .x((d) => xScale(d.date)) .y0(yScale(0)) .y1((d) => yScale(d.close)); // 영역 차트 그리기 svg.append("path").attr("fill", "#5ED3F3").attr("d", area(data)); // X축 svg .append("g") .attr("transform", `translate(0,${height - margin.bottom})`) .call( d3 .axisBottom(xScale) .ticks(d3.timeYear.every(1)) .tickSizeOuter(0) .tickFormat(d3.timeFormat("%Y")) // 연도 단위로 표시 ); // Y축 svg .append("g") .attr("transform", `translate(${margin.left + 5},0)`) .call(d3.axisLeft(yScale).ticks(6)) // Y축 레이블 추가 .call((g) => g.select(".domain").remove()) // Y축 라인 제거 .call((g) => g .selectAll(".tick line") .clone() .attr("x2", width - margin.left - margin.right) .attr("stroke-opacity", 0.05) ) // 보조선 추가 .call((g) => g .append("text") .attr("x", -margin.left) .attr("y", 10) .attr("fill", "currentColor") .attr("text-anchor", "start") .text("d3 다운로드 횟수") ); // Y축 제목 설정 }, []); return <svg ref={ref} />; }; export default AreaShape;
 
  • 영역 generator를 만든다
const area = d3.area() .x((d) => xScale(d.date)) .y0(yScale(0)) .y1((d) => yScale(d.close));
d3.area()를 통해 영역 generator를 만들고
데이터의 date 값을 x축 스케일 함수 x에 매핑하여 x 좌표를 설정한다.
그리고 영역의 하단 경계를 y축 스케일 함수 y에 0을 입력하여 설정한다. (양수일 경우 축을 기준으로 위쪽으로 뜨게되고, 음수일 경우 축을 기준으로 아래쪽까지 파고든다.)
마지막으로 데이터의 close 값을 y축 스케일 함수 y에 매핑하여 영역의 상단 경계를 설정한다.
 

4. Pies

notion imagenotion image
import { useEffect, useRef } from "react"; import * as d3 from "d3"; import data from "./data.js"; const PieShape = () => { const ref = useRef(); useEffect(() => { // 차트 면적 구성 const width = 1000; const height = 500; // SVG container 생성 const svg = d3 .select(ref.current) .attr("width", width) .attr("height", height) .attr("viewBox", [-width / 2, -height / 2, width, height]); // 색상 척도 생성 const color = d3 .scaleOrdinal() .domain(data.map((d) => d.name)) .range(["red", "orange", "green", "blue", "yellow"]); // 사용자 정의 색상 배열 // pie layout과 arc generator 생성 const pie = d3.pie().value((d) => d.value); const arc = d3 .arc() .innerRadius(0) .outerRadius(height / 2); const labelRadius = arc.outerRadius()() * 0.6; // label별 arc generator const arcLabel = d3.arc().innerRadius(labelRadius).outerRadius(labelRadius); const arcs = pie(data); // 총합 계산 const total = d3.sum(data, (d) => d.value); // 타이틀 추가 svg .append("text") .attr("x", -width / 2 + 20) // x 속성을 조정하여 왼쪽에 배치 .attr("y", -height / 2 + 20) // y 속성을 조정하여 상단에 배치 .attr("text-anchor", "start") // 왼쪽 정렬 .attr("font-size", "24px") .attr("font-weight", "bold") .text("음식 선호도"); // 파이 조각 추가 svg .append("g") .attr("stroke", "white") .selectAll("path") .data(arcs) .join("path") .attr("fill", (d) => color(d.data.name)) .attr("d", arc) // 라벨 추가 svg .append("g") .attr("text-anchor", "middle") .selectAll("text") .data(arcs) .join("text") .attr("transform", (d) => `translate(${arcLabel.centroid(d)})`) .call((text) => text .append("tspan") .attr("y", "-0.4em") .attr("font-weight", "bold") .text((d) => d.data.name) ) .call((text) => text .append("tspan") .attr("x", 0) .attr("y", "0.7em") .text((d) => `${((d.data.value / total) * 100).toFixed(2)}%`) ); }, []); return <svg ref={ref} />; }; export default PieShape;
차트 뜯어보기
  • 차트 면적 구성
const width = 1000; const height = 500;
 
  • SVG 컨테이너 생성
const svg = d3.create("svg") .attr("width", width) .attr("height", height) .attr("viewBox", [-width / 2, -height / 2, width, height]) .attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
 
  • 색상 척도 생성
const color = d3 .scaleOrdinal() .domain(data.map((d) => d.name)) .range(["red", "orange", "green", "blue", "yellow"]); // 사용자 정의 색상 배열
 
  • pie layout과 arc generator 생성
const pie = d3.pie() .sort(null) // 파이 조각을 정렬하지 않음 -> 없으면 정렬됨 .value(d => d.value); // value 속성을 사용해 파이 조각의 크기 결정 const arc = d3.arc() .innerRadius(0) .outerRadius(height/2); const labelRadius = arc.outerRadius()() * 0.6; // 라벨을 파이 조각 중심에서 약간 안쪽에 위치시키기 위해 외부 반지름의 60%로 설정
 
  • label별 arc generator
const arcLabel = d3.arc() .innerRadius(labelRadius) // 라벨의 내부 반지름 설정 .outerRadius(labelRadius); // 라벨 외부 반지름 설정 const arcs = pie(data); // 데이터를 바탕으로 파이 생성
 
  • 총합 계산
const total = d3.sum(data, (d) => d.value);
 
  • 타이틀 추가
svg .append("text") .attr("x", -width / 2 + 20) // x 속성을 조정하여 왼쪽에 배치 .attr("y", -height / 2 + 20) // y 속성을 조정하여 상단에 배치 .attr("text-anchor", "start") // 왼쪽 정렬 .attr("font-size", "24px") .attr("font-weight", "bold") .text("음식 선호도");
 
  • 파이 조각 추가
svg .append("g") .attr("stroke", "white") .selectAll("path") .data(arcs) .join("path") .attr("fill", (d) => color(d.data.name)) .attr("d", arc)
  1. svg.append("g").attr("stroke", "white") → 새로운 그룹 요소를 추가하고, 그룹의 경계선을 흰색으로 설정.
  1. .selectAll("path").data(arcs).join("path") → arcs 데이터를 사용하여 path 요소를 생성.
  1. .attr("fill", (d) => color(d.data.name)) → 각 파이 조각의 색상을 설정.
  1. .attr("d", arc) → 각 파이 조각의 경로 데이터를 설정.
 
  • 라벨 추가
svg .append("g") .attr("text-anchor", "middle") .selectAll("text") .data(arcs) .join("text") .attr("transform", (d) => `translate(${arcLabel.centroid(d)})`) .call((text) => text .append("tspan") .attr("y", "-0.4em") .attr("font-weight", "bold") .text((d) => d.data.name) ) .call((text) => text .append("tspan") .attr("x", 0) .attr("y", "0.7em") .text((d) => `${((d.data.value / total) * 100).toFixed(2)}%`) );
  1. svg.append("g").attr("text-anchor", "middle") → 새로운 그룹 요소를 추가하고, 텍스트 정렬을 가운데로 설정.
  1. .selectAll("text").data(arcs).join("text") → arcs 데이터를 사용하여 text 요소를 생성.
  1. .attr("transform", (d) =>translate(${arcLabel.centroid(d)})) → 각 라벨을 파이 조각의 중심으로 이동.
  1. .call((text) => text.append("tspan").attr("y", "-0.4em").attr("font-weight", "bold").text((d) => d.data.name)) → 각 라벨에 이름을 추가.
  1. .call((text) => text.append("tspan").attr("x", 0).attr("y", "0.7em").text((d) =>${((d.data.value / total) * 100).toFixed(2)}%)) → 각 라벨에 백분율을 추가
 

5.4 실전 예제

notion imagenotion image
import React, { useEffect, useRef } from "react"; import * as d3 from "d3"; import data from "./data.js"; const colors = [ "#FF0000", "#800000", "#FF69B4", "#FFA500", "#32CD32", "#00BFFF", "#8A2BE2", "#0000FF", "#8B4513", "#006400", ]; const ActualEx1 = () => { const ref = useRef(null); useEffect(() => { const svg = d3.select(ref.current).attr("width", 1000).attr("height", 800); svg.selectAll("*").remove(); const margin = { top: 50, right: 50, bottom: 50, left: 50 }; const width = 1000 - margin.left - margin.right; const height = 600 - margin.top - margin.bottom; svg .append("text") .attr("x", width / 2 + margin.left) .attr("y", margin.top / 2) .attr("text-anchor", "middle") .style("font-size", "24px") .text("주요 종합몰 앱 TOP 10 사용자 추이 (2019년 ~ 2024년)"); const chart = svg .append("g") .attr("transform", `translate(${margin.left}, ${margin.top})`); const x = d3 .scaleBand() .domain(data.map((d) => d.date)) .range([0, width]) .padding(0.1); chart .append("g") .attr("transform", `translate(0, ${height})`) .call(d3.axisBottom(x)) .selectAll("text") .attr("transform", "rotate(-45)") .style("text-anchor", "end"); const y = d3.scaleLinear().domain([0, 3500]).range([height, 0]); chart.append("g").call(d3.axisLeft(y).tickValues(d3.range(0, 3600, 200))); chart .append("g") .attr("class", "grid") .call(d3.axisLeft(y).tickSize(-width).tickFormat("")) .selectAll("line") .style("opacity", 0.1); chart.selectAll(".grid .domain").remove(); const line = d3 .line() .x((d) => x(d.date) + x.bandwidth() / 2) .y((d) => y(d.value)); Object.keys(data[0]) .filter((key) => key !== "date") .forEach((key, i) => { const appData = data.map((d) => ({ date: d.date, value: d[key] })); chart .append("path") .datum(appData) .attr("fill", "none") .attr("stroke", colors[i % colors.length]) .attr("stroke-width", 2) .attr("d", line); }); const legend = svg .append("g") .attr( "transform", `translate(${width + margin.left + 10}, ${margin.top})` ); Object.keys(data[0]) .filter((key) => key !== "date") .forEach((key, i) => { const legendRow = legend .append("g") .attr("transform", `translate(-50, ${i * 20})`); legendRow .append("rect") .attr("width", 10) .attr("height", 10) .attr("fill", colors[i % colors.length]); legendRow.append("text").attr("x", 12).attr("y", 10).text(key); }); const tooltip = d3 .select("body") .append("div") .attr("class", "tooltip") .style("opacity", 0) .style("position", "absolute") .style("background-color", "white") .style("border", "solid") .style("border-width", "1px") .style("border-radius", "5px") .style("padding", "10px"); Object.keys(data[0]) .filter((key) => key !== "date") .forEach((key, i) => { chart .selectAll(`.dot-${key}`) .data(data) .enter() .append("circle") .attr("class", `dot-${key}`) .attr("cx", (d) => x(d.date) + x.bandwidth() / 2) .attr("cy", (d) => y(d[key])) .attr("r", 5) .attr("fill", colors[i % colors.length]) .on("mouseover", (evt, d) => { const date = d.date; const values = Object.keys(d) .filter((key) => key !== "date") .map( (key, index) => `<div style="display: flex; align-items: center;"><div style="width: 10px; height: 10px; background-color: ${ colors[index % colors.length] }; margin-right: 5px;"></div>${key}: ${d[key]}</div>` ) .join(""); tooltip.transition().duration(200).style("opacity", 0.9); tooltip .html(`<strong>${date}</strong><br/><br/>${values}`) .style("left", evt.pageX + 5 + "px") .style("top", evt.pageY - 28 + "px"); }) .on("mouseout", () => { tooltip.transition().duration(500).style("opacity", 0); }); }); }, [data]); return <svg ref={ref} />; }; export default ActualEx1;
  • 차트 제목 추가
svg.append("text") .attr("x", width / 2 + margin.left) .attr("y", margin.top / 2) .attr("text-anchor", "middle") .style("font-size", "24px") .text("주요 종합몰 앱 TOP 10 사용자 추이 (2019년 ~ 2024년)");
 
  • X,Y축 설정
    • X축 → 각 연도를 구분하는 Band Scale을 사용하여 설정하고, 텍스트를 45도 회전시켜 연도를 표시한다.
    • Y축 → 앱 사용자를 나타내는 Linear Scale을 사용합니다. 0에서 3500까지 범위를 설정하고, 눈금을 200 단위로 표시한다.
const x = d3.scaleBand() .domain(data.map((d) => d.date)) // 연도 범위 설정 .range([0, width]) .padding(0.1); const y = d3.scaleLinear() .domain([0, 3500]) // 사용자 수 범위 설정 .range([height, 0]); chart.append("g") .attr("transform", `translate(0, ${height})`) .call(d3.axisBottom(x)); chart.append("g") .call(d3.axisLeft(y).tickValues(d3.range(0, 3600, 200)));
 
  • Gridlines
    • Y축을 기준으로 가로선(gridlines)을 추가하여 데이터 판독을 용이하게 만든다.
    • chart.append("g") .attr("class", "grid") .call(d3.axisLeft(y).tickSize(-width).tickFormat("")) // 눈금선 설정 .selectAll("line").style("opacity", 0.1); // 눈금선 투명도 설정 chart.selectAll(".grid .domain").remove(); // 축의 기본 경계선 제거
 
  • 범례 및 툴팁 추가
    • 각 앱의 선 색상이 무엇을 나타내는지 범례를 추가한다.
    • rect로 색상을 나타내고 text로 앱 이름을 표시한다.
    • 마우스를 각 데이터점에 올리면 툴팁이 나타나서 앱의 사용자 데이터를 표시한다.
    • 툴팁은 div로 구현되고, mouseover 이벤트에서 나타난다.
    • const tooltip = d3 .select("body") .append("div") .attr("class", "tooltip") .style("opacity", 0) .style("position", "absolute") .style("background-color", "white") .style("border", "solid") .style("border-width", "1px") .style("border-radius", "5px") .style("padding", "10px"); Object.keys(data[0]) .filter((key) => key !== "date") .forEach((key, i) => { chart .selectAll(`.dot-${key}`) .data(data) .enter() .append("circle") .attr("class", `dot-${key}`) .attr("cx", (d) => x(d.date) + x.bandwidth() / 2) .attr("cy", (d) => y(d[key])) .attr("r", 5) .attr("fill", colors[i % colors.length]) .on("mouseover", (evt, d) => { const date = d.date; const values = Object.keys(d) .filter((key) => key !== "date") .map( (key, index) => `<div style="display: flex; align-items: center;"><div style="width: 10px; height: 10px; background-color: ${ colors[index % colors.length] }; margin-right: 5px;"></div>${key}: ${d[key]}</div>` ) .join(""); tooltip.transition().duration(200).style("opacity", 0.9); tooltip .html(`<strong>${date}</strong><br/><br/>${values}`) .style("left", evt.pageX + 5 + "px") .style("top", evt.pageY - 28 + "px"); }) .on("mouseout", () => { tooltip.transition().duration(500).style("opacity", 0); }); });
 

끝 마치며

앞서 React를 사용하는 것을 명시적으로 보이기 위해 ref를 사용한 예제가 일부 존재한다.
하지만 이 과정은 하나의 도형을 그리기 위해 많은 양의 코드를 필요로한다.
리액트 공식 문서에는 다음과 같은 문구가 있다.
💡
Avoid using refs for anything that can be done declaratively.
따라서 우리는 ref 사용을 자제할 필요가 있다.
 
React 15부터는 JSX 파일 내에서 svg 요소에 대한 지원이 이루어지므로 다음과 같이 원을 간단하게 표현할 수 있음을 보여준 바 있다.
const Svg3 = () => { return ( <> <svg> <circle cx="100" cy="80" r="50"></circle> </svg> </> ); }; export default Svg3;
이렇게 명령형 대신 선언형을 사용함으로써 코드를 그리는 ‘방법’에 집중하기보다 그려진 것에 대해 묘사하는 것에 집중할 수 있고, 코드양 또한 감소시킬 수 있다