D3.js 차트 개발 Hands on lab


직방 기술지원팀 기술공유 세미나 중 D3.js를 소개한 내용입니다

Introduce

D3 is not a monolithic framework that seeks to provide every conceivable feature. Instead, D3 solves the crux of the problem: efficient manipulation of documents based on data

D3는 생각할 수 있는 모든 기능을 제공하려는 모놀리식 프레임워크가 아닙니다. 대신 D3는 문제의 핵심인 데이터 기반 문서의 효율적인 조작을 해결합니다.

- D3 문서 중에서 -

chart.js, billboard.js 등의 데이터와 옵션을 조절하여 그래프를 그리는 라이브러리와 달리 svg, canvas를 data와 함께 다루기 쉽게 도와주는 저수준 라이브러리.


왜 쓸까? 차트 라이브러리 vs 직접 개발(d3)

  • billboard.js (d3 base)를 사용할 경우

      <!-- Markup -->
      <div id="lineChart"></div>
        
      <!-- JS -->
      var chart = bb.generate({
        data: {
          columns: [
      			["data1", 30, 200, 100, 400, 150, 250],
          ],
          type: "line",
        },
        bindto: "#lineChart"
      });
    
  • d3.js 를 사용할 경우

      function LineChart(data, {
        x = ([x]) => x, // given d in data, returns the (temporal) x-value
        y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
        defined, // for gaps in data
        curve = d3.curveLinear, // method of interpolation between points
        marginTop = 20, // top margin, in pixels
        marginRight = 30, // right margin, in pixels
        marginBottom = 30, // bottom margin, in pixels
        marginLeft = 40, // left margin, in pixels
        width = 640, // outer width, in pixels
        height = 400, // outer height, in pixels
        xType = d3.scaleUtc, // the x-scale type
        xDomain, // [xmin, xmax]
        xRange = [marginLeft, width - marginRight], // [left, right]
        yType = d3.scaleLinear, // the y-scale type
        yDomain, // [ymin, ymax]
        yRange = [height - marginBottom, marginTop], // [bottom, top]
        yFormat, // a format specifier string for the y-axis
        yLabel, // a label for the y-axis
        color = "currentColor", // stroke color of line
        strokeLinecap = "round", // stroke line cap of the line
        strokeLinejoin = "round", // stroke line join of the line
        strokeWidth = 1.5, // stroke width of line, in pixels
        strokeOpacity = 1, // stroke opacity of line
      } = {}) {
        // Compute values.
        const X = d3.map(data, x);
        const Y = d3.map(data, y);
        const I = d3.range(X.length);
        if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
        const D = d3.map(data, defined);
        
        // Compute default domains.
        if (xDomain === undefined) xDomain = d3.extent(X);
        if (yDomain === undefined) yDomain = [0, d3.max(Y)];
        
        // Construct scales and axes.
        const xScale = xType(xDomain, xRange);
        const yScale = yType(yDomain, yRange);
        const xAxis = d3.axisBottom(xScale).ticks(width / 80).tickSizeOuter(0);
        const yAxis = d3.axisLeft(yScale).ticks(height / 40, yFormat);
        
        // Construct a line generator.
        const line = d3.line()
            .defined(i => D[i])
            .curve(curve)
            .x(i => xScale(X[i]))
            .y(i => yScale(Y[i]));
        
        const svg = d3.create("svg")
            .attr("width", width)
            .attr("height", height)
            .attr("viewBox", [0, 0, width, height])
            .attr("style", "max-width: 100%; height: auto; height: intrinsic;");
        
        svg.append("g")
            .attr("transform", `translate(0,${height - marginBottom})`)
            .call(xAxis);
        
        svg.append("g")
            .attr("transform", `translate(${marginLeft},0)`)
            .call(yAxis)
            .call(g => g.select(".domain").remove())
            .call(g => g.selectAll(".tick line").clone()
                .attr("x2", width - marginLeft - marginRight)
                .attr("stroke-opacity", 0.1))
            .call(g => g.append("text")
                .attr("x", -marginLeft)
                .attr("y", 10)
                .attr("fill", "currentColor")
                .attr("text-anchor", "start")
                .text(yLabel));
        
        svg.append("path")
            .attr("fill", "none")
            .attr("stroke", color)
            .attr("stroke-width", strokeWidth)
            .attr("stroke-linecap", strokeLinecap)
            .attr("stroke-linejoin", strokeLinejoin)
            .attr("stroke-opacity", strokeOpacity)
            .attr("d", line(I));
        
        return svg.node();
      }
    

생산성이 중요. 유지보수 비용 줄여야 한다 → 차트 라이브러리

데이터를 차별화해서 보여줘야한다. (커스텀이 중요) → d3로 직접 만들어야 함.


만들며 코드 들여다보기

샘플 codepen 링크

이 데이터로

const data = [
  {name: "🍊", count: 21},
  {name: "🍇", count: 13},
  {name: "🍏", count: 8},
  {name: "🍌", count: 5},
  {name: "🍐", count: 3},
  {name: "🍋", count: 2},
  {name: "🍎", count: 1},
  {name: "🍉", count: 1}
]

bar 차트를 만들어 보자

1. Element Select

d3의 selection 문법은 jquery 문법과 비슷. 가장 기본이 되는 svg 태그를 추가.

<!-- HTML -->
<div id="chart"></div>

<!-- JS -->
const svg = d3
  .select('#chart')
  .append('svg')
  .attr('viewBox', `0 0 ${width + (margin.left + margin.right)} ${height + (margin.top + margin.bottom)}`)
  .attr('width', width)
  .attr('height', height);

g 태그는 주로 transform 으로 children elements를 그룹핑하여 한꺼번에 사용할 때 많이씀. (transform 속성)

const group = svg
  .append('g')
  .attr('transform', `translate(${margin.left} ${margin.top})`);

x축, y축 을 viewBox 안으로 밀어 넣기 위해 margin 값 설정

2. Scale 설정 (x축, y축)

  • scale : 추상적 차원(abstract dimension) 설정
    • d3.scaleBand : 카테고리형 (name : 🍊, 🍇, 🍏, 🍌, 🍐, 🍋, 🍎, 🍉)

        const yScale = d3
          .scaleBand()
          .domain(data.map(({ name }) => name)) // 🍊, 🍇, 🍏, 🍌, 🍐, 🍋, 🍎, 🍉
          .range([0, height])
          .padding(0.2);
              
        const yAxis = d3
          .axisLeft(yScale); // d3가 제공하는 쉽게 축을 그려주는 함수
              
        // 예시
        yScale(🍊) :  12.439024390243873
        yScale(🍇) :  74.63414634146339
        yScale(🍏) :  136.8292682926829
        yScale(🍌) :  199.02439024390245
        yScale(🍐) :  261.219512195122
        yScale(🍋) :  323.4146341463414
        yScale(🍎) :  385.609756097561
        yScale(🍉) :  447.80487804878055
      
    • d3.scaleLinear : 선형 (count : 21, 13, 8, 5, 3, 2, 1, 1)

        const xScale = d3
          .scaleLinear()
          .domain([0, d3.max(data, ({ count }) => count)]) // 21, 13, 8, 5, 3, 2, 1, 1
          .range([0, width]);
              
        const xAxis = d3
          .axisBottom(xScale); // d3가 제공하는 쉽게 축을 그려주는 함수
              
        // 예시
        xScale(21) :  680
        xScale(13) :  420.95238095238096
        xScale(8) :  259.04761904761904
        xScale(5) :  161.9047619047619
        xScale(3) :  97.14285714285714
        xScale(2) :  64.76190476190476
        xScale(1) :  32.38095238095238
      
  • domain : 값의 범위(min/max)
  • range : domain 안에서 값(value)이 차트에서 실제 위치해야할 위치 영역 설정(position encoding)

3. 데이터 바인딩

const groups = group
  .selectAll('g.group') // group 클래스를 가진 g 태그들을 전부 선택.
  .data(data) // data 매서드로 selection 객체에 데이터를 바인딩.
  .enter() // 만약 g.group 엘리먼트가 없을 경우 새로운 g.group을 반환하도록 설정.
  .append('g') // 없는 엘리먼트를 채워 넣음.
  .attr('class', 'group')
  .attr('transform', ({ name }) => `translate(0 ${yScale(name)})`) // data로 맵핑한 요소들을 iterating 하며 값을 설정.

groups
  .append('rect')
  .attr('x', 0)
  .attr('y', 0)
  .attr('width', ({ count }) => xScale(count))
  .attr('height', yScale.bandwidth())
  .style('fill', '#6994C0');

d3는 존재하지 않는 요소들을 선택(.selectAll())하고 바인드(.data(), .enter())하는 경우가 많음.

이는 데이터를 기반으로 엘리먼트를 동적으로 만들기 때문에 가상으로 selection 객체를 만들고 그 객체로 dom 요소를 조작(생성/수정/삭제)하기 때문.

👉 selection 객체 동작을 잘 설명해주는 블로그 링크

4. 그 외의 유용한 것들

  • 보간(interpolate)이라는 개념을 사용해서 색깔로 값의 크기를 가늠하게도 만들 수 있음. (d3.interpolator, color scheme)
  • 데이터의 통계 값 등을 추출해낼 때 유용한 연산 매서드들을 제공

      d3.extent([3, 2, 1, 1, 6, 2, 4])
      // [1, 6]
    
  • 이 밖에도 지도 데이터를 표현하기 위한 polygon 데이터들도 다루기 편하게 제공하는 함수들도 많음. d3-geo, d3-geo-projection, d3-polygon


직방시세차트 작업

다른 프로젝트의 차트를 마이그레이션 & 커스텀. 다수의 리팩토링 작업 진행.

  • js -> typescript 변환
  • 클라이언트에서의 차트 데이터 표준화 작업을 백엔드로 이전
  • 각 지표들(ex - x축, y축 기준 등)을 커스텀

필터에 따라 여러 차트들을 인터렉티브하게 조합해서 표현해야 했음.

chart_category1.png chart_category2.png chart_category3.png chart_category4.png

d3 쓰면서 느낀점:

  • 커스텀은 a-z 다 가능.
  • d3가 제공하는 유틸들이 정말 유용하다.


공부하기 좋은 사이트

d3 개발자 Mike Bostock는 다양한 예시들의 코드를 보며 감을 잡고 문서를 찾아보는 방식의 공부를 추천


추가적인 질문

webgl??

  • openGL ES 2.0을 기반으로 브라우저 canvas 에 렌더링하여 3D 웹 콘텐츠 제작
  • 대표적으로 three.js

canvas vs svg

  • canvas
    • 고속 애니메이션이 필요한 경우 적합. 그래픽 하드웨어를 사용하기 때문이라고 함.
    • dom으로 보면 canvas 태그 하나만 있음. 인터렉티브한 것을 구현하려면 좌표값 기반으로 canvas 객체들의 위치를 비교해가며 만들어야 함.
  • svg
    • 엘리먼트 인터렉티브한 표현을 구현하기 쉬움. svg 태그 안에 rect, circle, path 등의 여러 요소들이 나뉘어져 있음.




2023

Flutter 맛보기 1

3 분 소요

직방 기술지원팀 기술공유 세미나 중 플러터를 소개한 내용입니다

Back to Top ↑

2022

Back to Top ↑

2019

[Tutorial] Storybook과 Bit을 활용한 UI 컴포넌트 관리(Workflow)

1 분 소요

회사에서 프론트엔드 개발원칙을 SFC(Single File Component)에서 UI 컴포넌트를 기준으로 CDD(Component Driven Development)를 진행하려고 한다. 그래서 체계적으로 관리하기위해 Storybook과 Bit을 도입해보고자 한다. 각각의 역할은 ...

[Workflow] 프론트엔드 개발조직을 위한 워크플로 설계

2 분 소요

작성 배경 회사의 작업구조를 페이지 중심 개발에서 UI 컴포넌트 중심 개발로 변경하면서 Workflow를 개선할만한 환경을 구성해야했다. 폐쇄망 기반에서 개발자간 UI 명세서 역할을 할 수 있는 Storybook과 그것을 공유할 Verdaccio라는 구축형 NPM Pri...

[ESlint & Prettier] 개발 관습 설정 in Visual Studio

1 분 소요

회사 프로젝트를 작업하기 전 프론트엔드 개발자들 간의 코드 규칙을 Eslint와 Prettier 설정을 맞춰 관리해가는 방향을 정했다. 아직 협업을 할 경우는 없지만 미래에 인수인계 받거나 협업을 진행할 경우 코드관습이 달라 고생할 경우를 대비하기로 했다. 설정은 작업을 진행하며 ...

[CodingTest] 2019-10-26 SQL문제

최대 1 분 소요

공부겸 코딩테스트 사이트에서 토요일 오전 10시에 백엔드 포지션 테스트를 해준다기에 참여해봤다. SQL문제가 나왔는데 더 좋은 답이 있는것 같아 나중에 기록해두고 수정해보기로 한다.

[CodingTest] 피보나치 수열

1 분 소요

문제 : 피보나치 수열 제 1항부터 입력한 자연수(N)까지의 피보나치 수열 항들의 합을 구하여라.

[Syntax] 새로 알게된 파이썬 문법 정리

최대 1 분 소요

chaning comparison 파이썬은 chaning comparison이라는 신기한 문법이 있다. 참고 if a < b and b < c : (...) 라는 구문이 if a < b < c : (...) 으로 연산된다. 직관적인 문법이 인상적. ...

[DataType] Map & Set

최대 1 분 소요

Set vs Array - 관련기사 Set 유일값들의 배열이 필요할때(distinct) 집합의 개념이 필요할때(차집합, 교집합 등등 자체 메서드들이 많음.) index가 필요 없을때 Array에서 중복값을 없앨때 => ...

Back to Top ↑

2018

SGIS-shpToGeojson

1 분 소요

SGIS에서 받은 지도데이터(.shp)를 geojson으로 변경하는 작업 내용

Back to Top ↑