Chart.js 소개 및 코드 분석 - 차트는 어떻게 만들어지는가

기운찬곰·2023년 8월 21일
2

프론트개발이모저모

목록 보기
17/20
post-thumbnail

Overview

프론트엔드에서 전문적인 영역이라고 하면 뭐가 있을까요? 뷰어도 있을 수 있겠고, 비디오, 에디터도 있을 수 있겠죠. 대시보드(차트)를 개발하는 것도 전문적이라고 볼 수 있을 거 같습니다. 보통은 이를 위해 라이브러리를 찾아보고 괜찮은 라이브러리를 가져다가 사용하게 되죠. 근데 만약에 회사에서 요구하는 차트 모형이 너무 특별해서 라이브러리에 없는 경우는 어떻게 해야할까요? 조율을 하거나 직접 만들거나 해야겠죠?

그런데 커스텀 차트는 어떻게 만들 수 있을까요? 그래서 이번 사이드 프로젝트는 커스텀 차트를 만들어보는 것을 목표로 합니다. 이를 위해 먼저 차트 라이브러리를 찾아서 코드를 분석해보고 그에 대한 결과를 작성해보려고 합니다.


차트 라이브러리

인기 있는 차트 라이브러리

참고 : https://npmtrends.com/@tremor/react-vs-chart.js-vs-d3-vs-echarts-vs-plotly.js-vs-recharts

차트 라이브러리하면 전통적으로 D3가 있겠죠. 그리고 chart.js라는게 있는데 어느새 D3를 역전해버렸네요. 그 외에 recharts, echarts 라는게 있고. 그 밑으로는 plotly.js, @tremor/react 라는게 있습니다. 현 시점에서는 chart.js가 가장 좋은 선택지인거 같네요.

대시보드 예시

예시 : https://vercel.com/templates/next.js/admin-dashboard-tailwind-planetscale-react-nextjs

vercel에는 여러가지 templates가 있는데 그 중에서 Admin Dashboard Template를 참고하였습니다. 여기서는 tremor 라는 차트 라이브러리를 사용하였습니다. 완전 차트 라이브러리는 아니고 리액트 컴포넌트 라이브러리인 거 같습니다.

예시 2 : https://ui.shadcn.com/

shadcn/ui에는 차트가 없는지 확인해봤더니 메인 페이지는 차트가 있긴 한데 이게 코드를 찾아보니까 Recharts라는 라이브러리를 사용했더군요.

예시 3 : https://github.com/PanJiaChen/vue-element-admin

보다 전문적인 대시보드 형태 느낌입니다. 코드를 살펴보니 echarts를 사용한 것으로 보입니다.

✍️ 나중에 대시보드 만들기를 사이드 프로젝트로 진행해보는 것도 재미있을 거 같군요.


Chart.js 알아보기

차트가 어떻게 만들어지는지 파악하기 위해서는 먼저 차트 라이브러리를 분석해볼 필요가 있겠습니다. 저는 그 중에서 Chart.js 를 알아보도록 하겠습니다.

Introduction

참고 : https://www.chartjs.org/docs/latest/

자바스크립트 애플리케이션 개발자들을 위한 많은 차트 라이브러리 중에서 Chart.js는 현재 깃허브 스타(~60,000)와 npm 다운로드(매주 2,400,000)에 따라 가장 인기 있는 라이브러리입니다. Chart.js는 2013년에 생성되고 발표되었고 그 이후로 많은 발전을 이루었습니다. 그것은 오픈 소스이며, 매우 허용적인 MIT 라이선스에 따라 라이센스가 부여되며, 활성화된 커뮤니티에 의해 유지됩니다.

Features

Chart.js는 자주 사용하는 차트 유형, 플러그인 및 customization options 을 제공합니다. 합리적인 기본 제공 차트 유형 집합 외에 추가적으로 커뮤니티에 의해 유지 관리되는 차트 유형을 사용할 수 있습니다.

참고 : https://github.com/chartjs/awesome#charts (아. 별도로 여러 차트 유형이 존재하네요 )

현재 사용되고 있는 Chart.js 버전은 크게 세 가지가 있습니다. 라이브러리에서 Chart.js 버전을 지원하는지 확인하려면 아래 버전 배지를 참조하십시오. (! 는 지원되지 않음을 나타낸다)

  • Chart.js v. 4️⃣ — released in November 2022
  • Chart.js v. 3️⃣ — released in April 2021
  • Chart.js v. 2️⃣ — released in April 2016

또한 여러 차트 유형을 혼합 차트(본질적으로 동일한 캔버스에서 여러 차트 유형을 하나로 혼합)로 결합할 수 있습니다. 예를 들어, Bar 차트와 더불어 Line 차트도 함께 사용할 수 있게 결합이 가능함을 의미합니다.

Chart.js는 사용자 지정 플러그인을 사용하여 annotations, 줌 또는 드래그 앤 드롭 기능을 생성하여 몇 가지를 지정할 수 있습니다. 오호. 그렇군요. 필수 기능 외 나머지는 플러그인으로 제공하는 모양이네요.

Defaults

Chart.js는 기본 구성과 함께 제공되므로 시작과 개발 준비가 완료된 앱을 쉽게 얻을 수 있습니다. 옵션을 전혀 지정하지 않더라도 매력적인 차트를 얻을 수 있습니다. 예를 들어, Chart.js는 기본적으로 애니메이션을 설정되어있으므로 데이터를 사용하여 보여주는 내용에 대해 사용자의 관심을 끌 수 있습니다.

또한, Chart.js에는 TypeScript 타이핑이 내장되어 있고, 여러 인기있는 자바스크립트 프레임워크와 호환됩니다. 그 외에도 문서가 잘 정리되어있습니다.

Canvas rendering

Chart.js는 “주로 D3.js 기반이면서 SVG로 렌더링되는 차트 라이브러리인 여러 다른 것들”과 달리 HTML5 캔버스에 차트 요소를 렌더링합니다. 캔버스 렌더링은 특히 DOM 트리에 수천 개의 SVG 노드가 필요한 대규모 데이터 세트 및 복잡한 시각화에 대해 Chart.js를 매우 성능 있게 만듭니다. 동시에 캔버스 렌더링은 CSS 스타일링을 허용하지 않으므로 내장 옵션을 사용하거나 사용자 지정 플러그인 또는 차트 유형을 생성하여 모든 것을 마음에 들게 만들어야 합니다.

💡 아하. 차트를 만들기 위해서는 D3.js 처럼 SVG로 렌더링하는 방식과 Chart.js 처럼 Canvas로 렌더링하는 방식이 있군요. 개인적으로는 Canvas가 더 끌리긴 하네요.

Performance

Chart.js는 대규모 데이터 세트에 매우 적합합니다. 이러한 데이터 세트는 내부 형식을 사용하여 효율적으로 수집할 수 있으므로 데이터 구문 분석 및 정규화를 생략할 수 있습니다. 대안적으로, 데이터 decimation(다운샘플링)은 데이터세트를 샘플링하고 렌더링하기 전에 그 크기를 감소시키도록 구성될 수 있습니다. ??

세부 사항 참고 : https://www.chartjs.org/docs/latest/general/performance.html

또한, Chart.js가 사용하는 캔버스 렌더링은 SVG 렌더링에 비해 DOM 트리의 부담을 줄여줍니다. 그리고 Tree Shaking 지원을 통해 번들에 Chart.js 코드의 최소 부분을 포함할 수 있으므로 번들 크기와 페이지 로드 시간을 줄일 수 있습니다. 오. 이런 부분에 있어서 최신화가 잘 되어있는 모양이네요.


Chart.js 사용해보기

간단 사용 예시 Getting Started - Create a Chart

설치 : https://www.chartjs.org/docs/latest/getting-started/installation.html
참고 : https://www.chartjs.org/docs/latest/getting-started/

먼저 라이브러리를 설치해줍니다.

pnpm add chart.js

그리고 예시에 나와있는 것처럼 코드를 작성해줍니다.

import { Chart } from 'chart.js'

export default function () {
  const ctx = document.getElementById("myChart")! as HTMLCanvasElement;

  new Chart(ctx, {
    type: "bar",
    data: {
      labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
      datasets: [
        {
          label: "# of Votes",
          data: [12, 19, 3, 5, 2, 3],
          borderWidth: 1,
        },
      ],
    },
    options: {
      scales: {
        y: {
          beginAtZero: true,
        },
      },
    },
  });
}

그랬더니 에러가 발생하네요... 이는 공식문서에서 빼먹고 읽지 않은 부분이 있어서 그렇습니다.

참고 : https://www.chartjs.org/docs/next/getting-started/integration.html
참고 : https://github.com/sgratzl/chartjs-chart-wordcloud/issues/4

Chart.js는 tree-shakeable이므로, 사용할 controllers, elements, scales, plugins을 가져와 등록해야 합니다. 번들 크기에 관심이 없는 경우 자동 패키지를 사용하여 모든 기능을 사용할 수 있습니다:

import Chart from 'chart.js/auto'; // 196.31KB (67.53KB zipped) 

혹은

import { Chart, registerables } from 'chart.js';
Chart.register(...registerables); // register all the coponents

번들을 최적화할 때는 응용 프로그램에 필요한 구성 요소를 가져와 등록해야 합니다. 옵션은 controllers, elements, scales, plugins로 분류됩니다. 각 유형의 차트에는 고유한 최소 요구사항(일반적으로 유형의 컨트롤러, 해당 컨트롤러에서 사용하는 elements 및 scale)이 있습니다:

따라서 아래와 같이 필요한 구성 요소를 가져와 등록해야 제대로 된 차트 모형이 나오게 됩니다. 근데 좀 번거롭긴 하네요.

import {
  Chart,
  CategoryScale,
  LinearScale,
  BarController,
  BarElement,
  Tooltip,
  Legend,
  Colors,
} from "chart.js"; // 163.36KB (56.49KB zipped)

Chart.register(
    BarController,
    BarElement,
    LinearScale,
    CategoryScale,
    Legend,
    Tooltip,
    Colors
  );

이러한 차트는 결국 canvas를 통해 만들어진다는 것을 알 수 있습니다.

Step-by-Step guide

참고 : https://www.chartjs.org/docs/latest/getting-started/usage.html

이 가이드에 따라 차트 유형 및 요소, 데이터 세트, 사용자 지정, 플러그인, 구성 요소 및 트리 쉐이킹 등 Chart.js의 모든 주요 개념에 대해 숙지하십시오. 본문의 링크를 따라가는 것을 주저하지 마세요.

몇 개의 차트를 처음부터 사용하여 Chart.js 데이터 시각화를 구축합니다:

import Chart from 'chart.js/auto'

(async function() {
  const data = [
    { year: 2010, count: 10 },
    { year: 2011, count: 20 },
    { year: 2012, count: 15 },
    { year: 2013, count: 25 },
    { year: 2014, count: 22 },
    { year: 2015, count: 30 },
    { year: 2016, count: 28 },
  ];

  new Chart(
    document.getElementById('acquisitions'),
    {
      type: 'bar',
      data: {
        labels: data.map(row => row.year),
        datasets: [
          {
            label: 'Acquisitions by year',
            data: data.map(row => row.count)
          }
        ]
      }
    }
  );
})();
  • 우리는 새로운 차트 인스턴스를 인스턴스화하고 차트가 렌더링될 캔버스 요소와 옵션 객체의 두 가지 인수를 제공합니다.
  • 우리는 단지 차트 유형(bar)를 제공하고, label(종종 데이터 포인트의 숫자 또는 텍스트 설명)과 데이터셋 배열로 구성된 데이터를 제공하면 됩니다. (Chart.js는 대부분의 차트 유형에 대해 여러 데이터셋을 지원함). 각 dataset는 레이블로 지정되며 데이터 포인트의 배열을 포함합니다.
  • 현재, 우리는 더미 데이터의 몇 가지 항목만 가지고 있습니다. 따라서, 우리는 유일한 데이터 세트 내에서 레이블과 데이터 포인트의 배열을 생성하기 위해 연도 및 카운트 속성을 추출합니다.

기본적으로 legend, grid lines, ticks 및 hover에 표시된 tooltip과 같은 많은 기능이 있는 차트를 얻을 수 있습니다. 애니메이션도 자동으로 적용되어있습니다. 다양한 속성 참고 : https://www.chartjs.org/docs/latest/configuration/

옵션 커스터마이징을 해봅시다. 일단 차트가 바로 나오도록 애니메이션을 끄도록 하겠습니다. 또한, 우리는 데이터 세트가 하나밖에 없고 사소한 데이터가 있기 때문에 legend과 tooltip을 숨기도록 하겠습니다.

new Chart(
    document.getElementById('acquisitions'),
    {
      type: 'bar',
      options: {
        animation: false,
        plugins: {
          legend: {
            display: false
          },
          tooltip: {
            enabled: false
          }
        }
      },
      data: {
        labels: data.map(row => row.year),
        datasets: [
          {
            label: 'Acquisitions by year',
            data: data.map(row => row.count)
          }
        ]
      }
    }
  );

legend와 tooltip은 플러그인의 각 섹션 아래에 제공된 boolean 플래그로 숨겨집니다. 일부 Chart.js 기능은 플러그인(자체 포함, 별도의 코드 조각)으로 추출됩니다. 일부 플러그인은 Chart.js 배포판의 일부로 사용할 수 있으며, 다른 플러그인은 독립적으로 유지되며 플러그인, 프레임워크 통합 및 추가 차트 유형의 멋진 목록에 위치할 수 있습니다.


Chart.js 코드 분석

디버깅

이제 본격적으로 Chart.js 코드가 내부적으로 어떻게 동작하는지 살펴보도록 하겠습니다. 그러기 위해서는 디버깅을 해봐야 될 거 같습니다. 음... 근데 막상 디버깅을 잘 해본적이 없어서 약간의 시행착오가 있었습니다.

vscode에서 디버깅을 하려면 먼저 launch.json 이라는 파일을 만들어야 합니다. debug 탭에서 creat a launch.json file을 선택하면 기본적인 파일이 생성됩니다.

참고 : https://github.com/vitejs/vite/discussions/4065
참고 : https://stackoverflow.com/questions/66147328/is-there-a-way-to-debug-code-in-vscode-initiated-with-vite

그리고 나서 위에 나와있는 예시를 참고해서 다음과 같이 설정 해주었습니다.

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "chrome",
      "request": "launch",
      "name": "Launch Chrome against localhost",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}/src",
      "sourceMaps": true,
      "resolveSourceMapLocations": [
        "${workspaceFolder}/**",
        "!**/node_modules/**"
      ]
    }
  ]
}

Chart 실행 부분에다가 break point를 찍은 다음에 디버깅을 해보면 Chart.js 내부로 들어온 것을 알 수 있습니다.

근데 막상 코드를 보니 한번 번들링 된 거 같네요. 제가 원한건 원본 코드 상에서 어떻게 실행되는지를 보고 싶었는데요... 그래서 어떻게 할지 고민하다가 크롬 개발자 도구에서도 볼 수 있더군요. 둘이 아마 연동되는거 같습니다.

그리고 크롬 개발자 도구에서 보면 원본 파일에서 디버깅이 가능하더군요. 보기도 훨씬 편한거 같습니다.

Chart.js 내부 로직

소스 코드 : https://github.com/chartjs/Chart.js

전체적인 프로젝트 구조를 보면 src 폴더 안에 주요 폴더가 있는 것을 알 수 있었습니다. core가 가장 핵심인거 같고, 위에서 Chart.register로 개별 등록이 가능했듯이 controllers, elements, plugins, scales가 부가적인 요소인 듯보입니다. 그 외 helpers 폴더 등이 있고요.

  • controllers
  • core
  • elements
  • helpers
  • plugins
  • scales

⭐️ 아. 디버깅 하기 전에 animation을 false로 설정해두는 것을 추천합니다. animation을 true로 해놓으면 코드가 실행될 때마다 canvas에 그리는 것이 아닌 모든 코드가 끝난 다음에 실행되는 거 같더군요.

그중에서 가장 처음 시작점은 core 폴더에 있는 core.controller.js 안에 class Chart 인 것을 디버깅을 통해 확인해볼 수 있었습니다. 그리고 실행되는게 core.config.js에 있는 new Config(..) 입니다. 아무래도 initOptions를 통해 옵션을 초기화해주는 모양입니다.

{
    "type": "bar",
    "data": {
        "labels": [
            2010,
            2011,
            2012,
            2013,
            2014,
            2015,
            2016
        ],
        "datasets": [
            {
                "label": "Acquisitions by year",
                "data": [
                    10,
                    20,
                    15,
                    25,
                    22,
                    30,
                    28
                ]
            }
        ]
    },
    "options": {
        "plugins": {},
        "scales": {
            "x": {
                ...
            },
            "y": {
                ...
            }
        }
    }
}

getCanvas를 통해 canvas 요소도 얻고. 이건 core.animator.js를 통해 애니메이션 이벤트 설정도 하는거 같고요.

animator.listen(this, 'complete', onAnimationsComplete);
animator.listen(this, 'progress', onAnimationProgress);

그리고 constructor 마지막 부분에 보면 뭔가 중요해 보이는 함수가 있습니다.

this._initialize();
if (this.attached) {
  this.update();
}

this.update 함수 마지막 부분을 보면 this.render()를 실행하고 있습니다.

  render() {
    if (this.notifyPlugins('beforeRender', {cancelable: true}) === false) {
      return;
    }

    if (animator.has(this)) {
      if (this.attached && !animator.running(this)) {
        animator.start(this);
      }
    } else {
      this.draw();
      onAnimationsComplete({chart: this});
    }
  }

그리고 애니메이션 설정이 false이면 바로 this.draw()를 실행하게 됩니다.

// core.controller.js

draw() {
    let i;
    if (this._resizeBeforeDraw) {
      const {width, height} = this._resizeBeforeDraw;
      this._resize(width, height);
      this._resizeBeforeDraw = null;
    }
    this.clear();

    if (this.width <= 0 || this.height <= 0) {
      return;
    }

    if (this.notifyPlugins('beforeDraw', {cancelable: true}) === false) {
      return;
    }

    // Because of plugin hooks (before/afterDatasetsDraw), datasets can't
    // currently be part of layers. Instead, we draw
    // layers <= 0 before(default, backward compat), and the rest after
    const layers = this._layers;
    for (i = 0; i < layers.length && layers[i].z <= 0; ++i) {
      layers[i].draw(this.chartArea);
    }

    this._drawDatasets();

    // Rest of layers
    for (; i < layers.length; ++i) {
      layers[i].draw(this.chartArea);
    }

    this.notifyPlugins('afterDraw');
  }

그 중에서 layers[i].draw(this.chartArea);를 보면 core.scale.js에 있는 draw 함수가 실행됩니다. 여기서 보면 background, grid, title을 그리는 것을 알 수 있습니다.

// core.scale.js

this.drawBackground();
this.drawGrid(chartArea);
this.drawTitle();

보면 알 수 있듯이 canvas 안에 뭔가를 그리는 걸 알 수 있죠?

drawBackground() {
    const {ctx, options: {backgroundColor}, left, top, width, height} = this;
    if (backgroundColor) {
      ctx.save();
      ctx.fillStyle = backgroundColor;
      ctx.fillRect(left, top, width, height);
      ctx.restore();
    }
  }
drawGrid(chartArea) {
    const grid = this.options.grid;
    const ctx = this.ctx;
    const items = this._gridLineItems || (this._gridLineItems = this._computeGridLineItems(chartArea));
    let i, ilen;

    const drawLine = (p1, p2, style) => {
      if (!style.width || !style.color) {
        return;
      }
      ctx.save();
      ctx.lineWidth = style.width;
      ctx.strokeStyle = style.color;
      ctx.setLineDash(style.borderDash || []);
      ctx.lineDashOffset = style.borderDashOffset;

      ctx.beginPath();
      ctx.moveTo(p1.x, p1.y);
      ctx.lineTo(p2.x, p2.y);
      ctx.stroke();
      ctx.restore();
    };

    if (grid.display) {
      for (i = 0, ilen = items.length; i < ilen; ++i) {
        const item = items[i];

        if (grid.drawOnChartArea) {
          drawLine(
            {x: item.x1, y: item.y1},
            {x: item.x2, y: item.y2},
            item
          );
        }

        if (grid.drawTicks) {
          drawLine(
            {x: item.tx1, y: item.ty1},
            {x: item.tx2, y: item.ty2},
            {
              color: item.tickColor,
              width: item.tickWidth,
              borderDash: item.tickBorderDash,
              borderDashOffset: item.tickBorderDashOffset
            }
          );
        }
      }
    }
  }

draw 함수 실행이 끝나면 아래와 같이 canvas가 그려지게 됩니다. 오호... 막대바를 제외한 나머지 부분이 그려졌군요.

막대바를 그리는 부분은 this._drawDatasets(); 입니다. 결국에는 _drawDataset 함수가 실행됩니다.

/**
	 * Draws dataset at index unless a plugin returns `false` to the `beforeDatasetDraw`
	 * hook, in which case, plugins will not be called on `afterDatasetDraw`.
	 * @private
	 */
  _drawDataset(meta) {
    const ctx = this.ctx;
    const clip = meta._clip;
    const useClip = !clip.disabled;
    const area = getDatasetArea(meta) || this.chartArea;
    const args = {
      meta,
      index: meta.index,
      cancelable: true
    };

    if (this.notifyPlugins('beforeDatasetDraw', args) === false) {
      return;
    }

    if (useClip) {
      clipArea(ctx, {
        left: clip.left === false ? 0 : area.left - clip.left,
        right: clip.right === false ? this.width : area.right + clip.right,
        top: clip.top === false ? 0 : area.top - clip.top,
        bottom: clip.bottom === false ? this.height : area.bottom + clip.bottom
      });
    }

    meta.controller.draw();

    if (useClip) {
      unclipArea(ctx);
    }

    args.cancelable = false;
    this.notifyPlugins('afterDatasetDraw', args);
  }

그리고 나서는 meta.controller.draw(); 가 실행되고 rects[i].draw(this._ctx);가 실행되면서 막대 바를 만들 수 있었습니다.

meta.controller.draw();
// controller.bar.js
draw() {
    const meta = this._cachedMeta;
    const vScale = meta.vScale;
    const rects = meta.data;
    const ilen = rects.length;
    let i = 0;

    for (; i < ilen; ++i) {
      if (this.getParsed(i)[vScale.axis] !== null) {
        rects[i].draw(this._ctx);
      }
    }
  }

// element.bar.js
draw(ctx) {
    const {inflateAmount, options: {borderColor, backgroundColor}} = this;
    const {inner, outer} = boundingRects(this);
    const addRectPath = hasRadius(outer.radius) ? addRoundedRectPath : addNormalRectPath;

    ctx.save();

    if (outer.w !== inner.w || outer.h !== inner.h) {
      ctx.beginPath();
      addRectPath(ctx, inflateRect(outer, inflateAmount, inner));
      ctx.clip();
      addRectPath(ctx, inflateRect(inner, -inflateAmount, outer));
      ctx.fillStyle = borderColor;
      ctx.fill('evenodd');
    }

    ctx.beginPath();
    addRectPath(ctx, inflateRect(inner, inflateAmount));
    ctx.fillStyle = backgroundColor;
    ctx.fill();

    ctx.restore();
  }

드디어 막대 바 한개가 그려졌습니다. 흠... 결국에는 이런 과정을 거쳐서 차트가 그려지는군요.


마치면서

이번 시간에는 차트 라이브러리에 대해 알아보고, 그 중에서 Chart.js 를 집중적으로 알아봤습니다.

그리고 마지막으로 디버깅을 통해 Chart.js는 차트를 어떻게 생성하는지 살펴보면서 결국에는 Canvas랑 데이터를 만들어내는 과정이 상당히 중요하다는 것을 알 수 있었습니다. 아무튼 이렇게 라이브러리를 디버깅까지 하면서 분석해본 건 오랜만이군요. 재밌었습니다.

나만의 커스텀 차트를 만들기 위해서는 결국 Canvas를 공부해야겠군요. 허허... Canvas 중요하죠...


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글