안녕하세요, 똑똑한개발자에서 프론트엔드 개발을 하고 있는 Anne입니다.

이번에 프로젝트를 하면서 차트를 그리기 위한 용도로 Chart.js를 사용하게 되었습니다. 워낙에 기능도 많고 괜히 복잡해보여서(구현하려는 기능에 따라 뒤에 복잡한 부분이 분명 있기도 합니다만…😌) 선뜻 시작하기가 어려웠습니다. 뭐든 시작이 제일 어려운 법인데, 이번 시간에는 저처럼 차트를 ‘구현해야만’ 하는 상황에서 Chart.js를 “덜 어렵게” 시작할 수 있도록 Chart.js의 기본 사용 방법 및 커스텀을 위해 제공되는 옵션들에 대해 간단히 소개해드리고자 합니다.😺

설치하기

  • 리액트에서 Chart.js를 사용하려면 chart.js 뿐만 아니라 리액트에서 Chart.js를 렌더링하기 위해 필요한 react-chartjs-2도 설치를 해야 합니다. 사용하시는 패키지 매니저에 따라 아래와 같이 설치를 해주세요.

    <yarn을 사용하는 경우>
    yarn add react-chartjs-2 chart.js
    
    <npm을 사용하는 경우>
    npm install react-chartjs-2 chart.js
    

컴포넌트로 불러오기

  • 설치를 완료하셨으면, 사용하시려는 컴포넌트에 바로 차트 컴포넌트를 불러와주시면 됩니다. dataoptions는 밑에서 마저 설정해보도록 하겠습니다.
import styled from 'styled-components';
import { Line } from 'react-chartjs-2';

const Chart = () => {
  return (
    <Container>
      <Line type="line" data={data} options={options} />
    </Container>
  );
};

export default Chart;

const Container = styled.div`
  width: 90vw;
  max-width: 900px;
`;
  • div로 감싸지 않아도 작동은 되지만, 저는 뷰포트의 가로 길이에 따라 차트 가로 길이도 같은 비율로 늘어났다가 줄어드는 반응형으로 구현하기 위해 위와 같이 작성했습니다. (관련 내용은 여기를 참고해주세요!)

스마트락 방범창 광고가 생각나는 건 왜일까요..?

  • 위 스크린샷에서 보시는 것과 같이 저는 막대형(Bar 타입) 그래프와 꺾은선(Line 타입) 그래프를 함께 사용했는데요, 이처럼 여러 타입의 그래프를 함께 사용하는 걸 Chart.js에선 multi type chart 또는 mixed chart type이라고 하더라구요. react-chartjs-2의 데모 페이지 소스 코드에도 나와있듯이, Line만 불러와주셔도 mixed chart를 구현할 수 있습니다! 소스 코드의 해당 부분은 아래와 같습니다. datasets를 전달할 때 각각의 data에 각각의 차트 타입을 지정해주시면 됩니다.
datasets: [
  {
    type: 'line', // 💗
    label: 'Dataset 1',
    borderColor: 'rgb(54, 162, 235)',
    borderWidth: 2,
    fill: false,
    data: [rand(), rand(), rand(), rand(), rand(), rand()],
  },
  {
    type: 'bar', // 💗
    label: 'Dataset 2',
    backgroundColor: 'rgb(255, 99, 132)',
    data: [rand(), rand(), rand(), rand(), rand(), rand(), rand()],
    borderColor: 'white',
    borderWidth: 2,
  },
  {
    type: 'bar', // 💗
    label: 'Dataset 3',
    backgroundColor: 'rgb(75, 192, 192)',
    data: [rand(), rand(), rand(), rand(), rand(), rand(), rand()],
  },
],

데이터 전달하기

  • 이렇게 불러온 컴포넌트에 우선은 데이터(data)를 전달해보도록 하겠습니다!

  • 데이터는 말 그대로 차트에 그릴 데이터, 자료들인데요, 자료를 전달하는 것뿐만 아니라 각 데이터가 어떤 식으로 그려질지, 예를 들어 어떤 색깔로 보여질지 등을 정하는 각 데이터의 개별 옵션도 함께 전달할 수 있습니다. 위 소스 코드를 살짝 수정해서 적용해보도록 하겠습니다.

import styled from 'styled-components';
import { Line } from 'react-chartjs-2';

const data = {
  labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
  datasets: [
    {
      type: 'line',
      label: 'Dataset 1',
      borderColor: 'rgb(54, 162, 235)',
      borderWidth: 2,
      data: [1, 2, 3, 4, 5],
    },
    {
      type: 'bar',
      label: 'Dataset 2',
      backgroundColor: 'rgb(255, 99, 132)',
      data: [1, 2, 3, 4, 5, 6],
      borderColor: 'red',
      borderWidth: 2,
    },
    {
      type: 'bar',
      label: 'Dataset 3',
      backgroundColor: 'rgb(75, 192, 192)',
      data: [1, 2, 3, 4, 5, 6],
    },
  ],
};

const Chart = () => {
  return (
    <Container>
      <Line type="line" data={data} />
    </Container>
  );
};

export default Chart;

const Container = styled.div`
  width: 90vw;
  max-width: 900px;
`;
  • options는 뒤에서 전달해주기로 하고, 우선 data만 전달을 해보면 차트가 아래와 같이 그려지게 됩니다.

data만 전달한 상태

  • 위와 같이 가로축에 표시되는 값이 일정하고 미리 정해져있는 경우에는 위 예시 코드에 나와있듯이 가로축 값들을 나열한 labels 배열을 전달하는 방법이 가능합니다. 이 경우 labels 배열datasets 배열의 각 자료들의 data 배열에서 서로 인덱스가 동일한 것들이 각각 (x, y)쌍이 되어 그래프에 그려지게 됩니다.

  • 하지만 저희 프로젝트와 같이 가로축이 시간, 날짜를 나타내며 어떤 데이터는 월 단위로 존재하는데 어떤 데이터는 연 단위로 존재하는 그런 경우에는 위 방법을 사용할 수 없겠죠!? 이처럼 가로축이 시간, 날짜을 나타내는 Time Cartesian Axis의 경우에는 아래와 같은 방식으로 데이터를 정의하는 방법이 편리합니다.

const data = {
  // labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
  // labels 대신 아래와 같이 각각의 데이터의 x값을 개별적으로 전달해줍니다.
  datasets: [
    {
      type: 'line',
      label: 'Dataset 1',
      borderColor: 'rgb(54, 162, 235)',
      borderWidth: 2,
      data: [
        { x: 'January', y: 1 },
        { x: 'February', y: 2 },
        { x: 'March', y: 3 },
        { x: 'April', y: 4 },
        { x: 'May', y: 5 }
      ],
    },
    {
      type: 'bar',
      label: 'Dataset 2',
      backgroundColor: 'rgb(255, 99, 132)',
      data: [
        { x: 'January', y: 1 },
        { x: 'February', y: 2 },
        { x: 'March', y: 3 },
        { x: 'April', y: 4 },
        { x: 'May', y: 5 },
        { x: 'June', y: 6 }
      ],
      borderColor: 'red',
      borderWidth: 2,
    },
    {
      type: 'bar',
      label: 'Dataset 3',
      backgroundColor: 'rgb(75, 192, 192)',
      data: [
        { x: 'January', y: 1 },
        { x: 'February', y: 2 },
        { x: 'March', y: 3 },
        { x: 'April', y: 4 },
        { x: 'May', y: 5 },
        { x: 'June', y: 6 }
      ],
    },
  ],
};
  • 예제 코드는 가로축이 Time Cartesian Axis도 아니고 값들이 일정하므로 이 방법이 오히려 더 비효율적이긴 하지만, (x, y)쌍을 각각 전달한다는 점에서 좀 더 직관적인 것 같긴 하네요! (Time Cartesian Axis를 구현하기 위해선 추가적인 설정이 필요합니다. 설정 방법 등 관련 내용은 공식 문서를 참고해주세요!)

  • 차트 자체의 옵션(options)을 전달하기 전, 데이터 개별 옵션으로 어떤 것들을 지정할 수 있는지 살펴보겠습니다. 우선 예시 코드에 나와있듯이 각 데이터의 차트 타입은 type으로, 범례는 label로 지정할 수 있습니다. 색상도 당연히 개별적으로 지정할 수 있습니다! 이 외에도 가로축과 세로축이 둘 이상 존재하는 경우 xAxisIDyAxisID를 통해 축을 지정해줄 수도 있습니다. (이 경우 각 축들의 id 등의 설정이 미리 존재하는 상태여야겠죠!?) 개별 속성 목록과 자세한 설명은 아래 링크에서 확인해보실 수 있습니다!

옵션 전달하기

  • 이제 옵션(options)을 전달해볼까요? 옵션에는 굉장히 많은 설정이 들어갈 수 있는데요, 제가 사용한 옵션들 몇 가지만 주석을 통해 간단히 소개해드리도록 하겠습니다! 네임스페이스에 신경써주세요~! 네임스페이스에 따라 적용되는 범위 및 작동 여부가 달라질 수 있습니다. 네임스페이스가 맞지 않으면 “어… 왜 안되지?”하는 상황이 생길 수 있어요~(는 제 경험담입니다. ㅎㅎ)
const options = {
  spanGaps: true,
  // line 타입의 경우 중간에 누락된 데이터가 있을 경우 이어그릴지 여부를 정합니다!
  maxBarThickness: 10,
  // bar 타입의 경우 막대의 최대 굵기를 정합니다!
  grouped: true,
  // x축 값이 같은 애들끼리 그룹화할지를 정하는데요,
  // true 설정 시 해당 x축 값내에서 서로 공간을 나누면서 나란히 보여지게 되고,
  // false 설정 시 각 포인트가 해당 x축 정중앙에 그려지게 되어서 x축 값이 같은 애들끼리 서로 겹쳐지게 됩니다.
  interaction: {
    mode: 'index',
  },
  // 호버 동작과 관련된 설정인데요, 호버를 하게 되면 툴팁이 뜨게 되는데
  // 그 툴팁이 뜨는 기준을 설정할 수 있습니다.
  // 위와 같이 index를 기준으로 설정하게 되면 동일한 index에 놓인 값들이 모두 떠요!
  plugins: {
    legend: { // 범례 스타일링
      labels: {
        usePointStyle: true,
        // 범례 도형 모양과 관련된 속성으로, false일 경우엔 기본 직사각형 도형으로 표시됩니다.
        padding: 10,
        // 범례 간 가로 간격을 조정할 수 있습니다. 범례의 상하 padding을 지정하는 기능은 따로 지원되지 않아요. ㅠㅠ
        font: { // 범례의 폰트 스타일도 지정할 수 있습니다.
          family: "'Noto Sans KR', 'serif'",
          lineHeight: 1,
        },
      }
    },
    tooltip: { // 툴팁 스타일링
      backgroundColor: 'rgba(124, 35, 35, 0.4)',
      // 툴팁 색상을 지정할 수 있습니다.
      padding: 10,
      // 툴팁 패딩을 지정할 수 있습니다.
      bodySpacing: 5,
      // 툴팁 내부의 항목들 간 간격을 조정할 수 있습니다.
      bodyFont: {
        font: { // 툴팁 내용의 폰트 스타일을 지정할 수 있습니다.
          family: "'Noto Sans KR', sans-serif",
        }
      },
      usePointStyle: true,
      // 범례 도형 모양과 마찬가지로 툴팁 내부에서도 도형의 모양을 지정할 수 있어요.
      filter: (item) => item.parsed.y !== null,
      // 툴팁에 표시될 항목을 필터링할 수 있는데요,
      // 예를 들어 값이 null인 항목은 툴팁에 나타나지 않게 하려면
      // 위와 같이 설정해주시면 됩니다.
      callbacks: {
        // 툴팁에 표시되는 내용은 이와 같이 콜백 함수를 통해
        // 조건에 맞게 수정할 수 있습니다!
        title: (context) => { // 툴팁에서 x축 값이 어떻게 표시될지 설정할 수 있어요.
          let title = '';

          // (context를 콘솔에 찍어보시면 차트에 전달되는 dataset과
          // 그 값들을 확인할 수 있는데요, 이를 바탕으로 조건을 구성하고
          // 그 조건에 따라 title을 재설정해주시면 됩니다.)

          return title; // 재설정한 title은 꼭 반환해주세요!
        },
        label: (context) => { // 툴팁에서 y축 값이 어떻게 표시될지 설정할 수 있어요.
          let label = context.dataset.label + '' || '';

          const isPrice = label === '주가';
          const isEV = label === 'EV';

          if (label) {
            label = isPrice
              ? ' 주가 : '
              : (' ' + label + ' : ');
          }
          if (context.parsed.y !== null) { // y축 값이 null이 아니라면,
            // 조건에 따라 label 재할당
          } else { // y축 값이 null이라면
            return null; // null 반환
          }

          return label; // 마찬가지로 재설정한 label도 꼭 반환해주세요!
        },
      },
    },
  },
  scales: { // x축과 y축에 대한 설정을 할 수 있습니다.
    x: { // 여기서 x는 이 축의 id인데요, 이 안에서 axis 속성만 x로 지정해놓으시면 id를 x가 아니라 다른 이름으로 설정해도 무관합니다.

      // afterTickToLabelConversion을 이용하여
      // x축 값이 어떻게 표시될지 설정할 수 있어요!
      afterTickToLabelConversion: function (scaleInstance) {
        const ticks = scaleInstance.ticks;

        const newTicks = ticks.map((tick) => {
          return {
            // 원본 x축 값을 이용하여 각 x축 값들이 어떻게 표시될지 수정할 수 있습니다.
          };
        });

        scaleInstance.ticks = newTicks;
        // scaleInstance.ticks에 새로운 ticks를 재할당해줘야 적용이 됩니다!
      },
      grid: { // x축을 기준으로 그려지는 선(세로선)에 대한 설정입니다.
        display: false, // 선이 아예 안 그려지게 됩니다.
        drawTicks: true, // 눈금 표시 여부를 지정합니다.
        tickLength: 4, // 눈금 길이를 지정합니다.
        color: '#E2E2E230' // 눈금 및 선의 색상을 지정합니다.
      },
      axis: 'x', // x축(가로축)인지 y축(세로축)인지 표시합니다.
      max: Date.parse(xMax) + 1296000000, // 축의 최대값을 강제합니다.
      min: Date.parse(xMin), // 축의 최소값을 강제합니다.
      position: 'bottom',
      // top으로 설정하면 가로축이 차트 상단에 그려지게 됩니다!
      ticks: {
        minRotation: 45, // x축 값의 회전 각도를 설정할 수 있어요.
        padding: 5, // x축 값의 상하 패딩을 설정할 수 있어요.
      },
    },
    y: { // 'y'라는 id를 가진 y축에 대한 설정
      type: isLinear ? 'linear' : 'logarithmic',
      // 리니어 스케일뿐만 아니라 로그 스케일로도 표시할 수 있습니다.
      grid: { // 가로선 설정
        color: '#E2E2E230',
      },
      afterDataLimits: (scale) => {
        // y축의 최대값은 데이터의 최대값에 딱 맞춰져서 그려지므로
        // y축 위쪽 여유공간이 없어 좀 답답한 느낌이 들 수 있는데요,
        // 이와 같이 afterDataLimits 콜백을 사용하여 y축의 최대값을 좀 더 여유있게 지정할 수 있습니다!
        scale.max = scale.max * 1.2;
      },
      axis: 'y', // 이 축이 y축임을 명시해줍니다.
      display: true, // 축의 가시성 여부도 설정할 수 있습니다.
      position: 'left', // 축이 왼쪽에 표시될지, 오른쪽에 표시될지 정할 수 있습니다.
      title: { // 이 축의 단위 또는 이름도 title 속성을 이용하여 표시할 수 있습니다.
        display: true,
        align: 'end',
        color: '#808080',
        font: {
          size: 12,
          family: "'Noto Sans KR', sans-serif",
          weight: 300,
        },
        text: '단위: 배'
      }
    },
    // y축을 여러 개 두고 싶다면 아래와 같이 또 만들어 주세요.
    y_sub: {
      position: 'right',
      title: {
        display: true,
        align: 'end',
        color: '#808080',
        font: {
          size: 12,
          family: "'Noto Sans KR', sans-serif",
          weight: 300,
        },
        text: '단위: 배'
      }
    },
  }
};
  • 이제 이러한 속성들을 적용한 옵션을 만들어보고 Line 컴포넌트에 전달해보도록 하겠습니다!
import styled from 'styled-components';
import { Line } from 'react-chartjs-2';

const data = {
  datasets: [
    {
      type: 'line',
      label: 'Dataset 1',
      borderColor: 'rgb(54, 162, 235)',
      borderWidth: 2,
      data: [
        { x: 'January', y: 1 },
        { x: 'February', y: 2 },
        { x: 'March', y: 3 },
        { x: 'April', y: null },
        { x: 'May', y: 5 }
      ],
      yAxisID: 'y_sub',
    },
    {
      type: 'bar',
      label: 'Dataset 2',
      backgroundColor: 'rgb(255, 99, 132)',
      data: [
        { x: 'January', y: 14 },
        { x: 'February', y: 20 },
        { x: 'March', y: 32 },
        { x: 'April', y: 41 },
        { x: 'May', y: 15 },
        { x: 'June', y: 26 }
      ],
      borderColor: 'red',
      borderWidth: 2,
    },
    {
      type: 'bar',
      label: 'Dataset 3',
      backgroundColor: 'rgb(75, 192, 192)',
      data: [
        { x: 'January', y: 1 },
        { x: 'February', y: 2 },
        { x: 'March', y: 3 },
        { x: 'April', y: 4 },
        { x: 'May', y: 5 },
        { x: 'June', y: 6 }
      ],
      yAxisID: 'y_sub',
    },
  ],
};

const options = {
  spanGaps: true,
  maxBarThickness: 30,
  grouped: true,
  interaction: {
    mode: 'index',
  },
  plugins: {
    legend: {
      labels: {
        usePointStyle: true,
        padding: 10,
        font: {
          family: "'Noto Sans KR', 'serif'",
          lineHeight: 1,
        },
      }
    },
    tooltip: {
      backgroundColor: 'rgba(124, 35, 35, 0.4)',
      padding: 10,
      bodySpacing: 5,
      bodyFont: {
        font: {
          family: "'Noto Sans KR', sans-serif",
        }
      },
      usePointStyle: true,
      filter: (item) => item.parsed.y !== null,
      callbacks: {
        title: (context) => context[0].label + '💙',
        label: (context) => {
          let label = context.dataset.label + '' || '';

          return context.parsed.y !== null
            ? label + ': ' + context.parsed.y + ''
            : null;
        },
      },
    },
  },
  scales: {
    x: {
      afterTickToLabelConversion: function (scaleInstance) {
        const ticks = scaleInstance.ticks;

        const newTicks = ticks.map((tick) => {
          return {
            ...tick,
            label: tick.label + '🎵'
          };
        });

        scaleInstance.ticks = newTicks;
      },
      grid: {
        display: false,
        drawTicks: true,
        tickLength: 4,
        color: '#E2E2E230'
      },
      axis: 'x',
      position: 'bottom',
      ticks: {
        minRotation: 45,
        padding: 5,
      },
    },
    y: {
      type: 'linear',
      grid: {
        color: '#E2E2E230',
      },
      afterDataLimits: (scale) => {
        scale.max = scale.max * 1.2;
      },
      axis: 'y',
      display: true,
      position: 'left',
      title: {
        display: true,
        align: 'end',
        color: '#808080',
        font: {
          size: 12,
          family: "'Noto Sans KR', sans-serif",
          weight: 300,
        },
        text: '단위: 배'
      }
    },
    y_sub: {
      position: 'right',
      title: {
        display: true,
        align: 'end',
        color: '#808080',
        font: {
          size: 12,
          family: "'Noto Sans KR', sans-serif",
          weight: 300,
        },
        text: '단위: 배'
      },
      afterDataLimits: (scale) => {
        scale.max = scale.max * 1.2;
      },
    },
  }
};

const Chart = () => {
  return (
    <Container>
      <Line type="line" data={data} options={options} />
    </Container>
  );
};

export default Chart;

const Container = styled.div`
  width: 90vw;
  max-width: 900px;
`;

완성된 예제

마치며

본문에서 소개해드린 내용들 외에도 Chart.js에는 다양한 커스텀 기능들이 있습니다. 기본적인 커스텀 속성 외에도, 복잡하긴 하지만 위 예제에서 사용한 것과 같이 콜백을 사용하여 커스텀을 할 수도 있습니다. 저의 경우 Chart.js 공식 문서를 주로 참고하면서, 기본적으로 지원이 되지 않는 부분들은 구글링을 통해 Stack Overflow 등에서 구현 방법을 모색하고 아이디어를 얻곤 했습니다. Chart.js를 써보기 전엔 차트 라이브러리라는 단어만 들어도 살짝 무서웠었는데(?) 차트 라이브러리는 크게 두 부분으로 나누어 시작하면 그나마 덜 복잡해지는 것 같습니다.

  1. 데이터셋
  2. 그래프 설정(옵션)

참고 사이트

anne's profile image

anne

2021-07-20 17:00

Read more posts by this author