Canvas를 이용한 차트 그리기

노태경·2021년 9월 27일
1
post-thumbnail

파이널 프로젝트를 진행하면서 칵테일 구현을 canvas를 이용해서 해보는게 어떨까라고 생각만 해보고, 일정에 쫓겨 canvas를 다뤄보지 못했었는데

최근 지원한 기업에서 canvas를 이용한 차트를 그려보는 강의가 유튜브 채널에 있길래 따라해보았다!
https://www.youtube.com/watch?v=RG41Hgna4hE - 차트
https://www.youtube.com/watch?v=jvOtAjqx3xI - 그래프!

강의를 진행하신 분은 class와 html을 이용해서 진행하셨는데, 본인은 react-hooks를 주로 사용해왔고 함수형 컴포넌트로 변형해서 코드를 작성해보는 것도 재밌을 것 같아서 시도해보았다!

그 과정에서 겪을 어려움들을 블로깅 해보려한다. 정보 전달의 목적보다는 이렇더라~ 는 내용이므로 틀린 내용을 알려주신다면 감사히 배우겠습니당

엘리먼트를 언제 가져와야 할까...!

처음에 해맸던 부분은 getElementById를 통해서 엘리먼트를 가져와야 했는데, 함수형 컴포넌트로 바꿔보면서 이 부분이 헷갈렸다. canvas태그가 존재해야 엘리먼트를 가져올 수 있을텐데 어느 부분에서 canvas를 반환해야 하며, getElementById는 어디서 해야 적절할지가 뒤죽박죽이였다.

결론부터 보자면

function LineChart({ id }){
  useEffect(() => {
  	...
    const canvas = document.getElementById(id);
    ...
  },[]);
  
  return <canvas id={id} width="500px" height="300px"></canvas>;
}

위와 같이 useEffect를 사용했다.

왜냐! 공식 문서에서 useEffect컴포넌트가 렌더링 이후에 어떤 일을 수행해야하는 지를 말합니다.라고 설명한다. (출처 : https://ko.reactjs.org/docs/hooks-effect.html)
즉, canvas가 렌더링 된 이후에 useEffect에 정의한 함수가 실행되므로, getElementById를 통해서 엘리먼트를 가져올 수 있다고 생각했기 때문이다.

setInterval이 왜 이럴까..?

강의에서 차트는 1초마다 data가 업데이트되어 차트에 나타나도록 구현한다.

강의에서는 update 메소드와 setInterval을 이용해서 차트에 나타낼 data를 추가한다.
본인은 update 메소드와 관련된 부분은 useState를 통해 진행할 수 있겠다고 판단하여, useState를 사용했고, setInterval은 그대로 사용했었...다!

그런데 문제가 생겼다. 그래프의 값이 계속 바뀌며 이상하게 나타나는 것이었다..
그래서 console.log를 통해 현재 data에 무슨 일이 벌어지고 있는지 확인했으나, 1초마다 data가 업데이트 되지 않고, 계속해서 값이 변하고 있었다.

추측컨데 리렌더링이 여러번 발생하면서, setInterval도 여러번 돌아가버린게 아닌가 싶었다...

문제를 보아하니

  const [data, setData] = useState([]);
  useEffect(() => {
  	...
    setInterval(() => {
      let count = 0;
      setData([...data, count]);
      count++;
      console.log(data);
    }, 1000);
    ...
  }, []);

위와 같이 작성한 것이 문제였던 듯 하다. console.log 결과를 보면 새로이 업데이트된 data가 나타나는 것이 아닌, useEffect가 실행되던 당시 렉시컬 환경의 data를 계속 나타내고 있었다. 즉 렌더링 될 당시의 data값인 빈배열만 나오고 있었다.

어디서 많이본 말인듯 한데, closure를 공부할 때 봤던 것 같다..

그럼 그렇다고 data가 바뀔 때마다 useEffect하면 되느냐?

그러면 setInterval이 다중(?)으로 여러개 실행되는 것을 볼 수 있었다.

브라우저의 backend에서 setInterval이 작동되는 것으로 안다. 그렇다면 data의 값이 바뀔때마다 새로운 setInterval이 backend에 등록되어 문제가 생기는 것으로 보인다.

구글링을 하다보니 setInterval과 React의 궁합이 좋지않다는 점(?)을 많이들 얘기하는 것 같다. 대신 setTimeOut을 쓰라는 블로그를 발견하고 적용하였다.
(출처: https://velog.io/@dongdong98/React-Hook%EC%97%90%EC%84%9C-setInterval-setTimeout%EC%9D%84-%ED%98%84%EB%AA%85%ED%95%98%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95)

useEffect(()=>{
	...
    const tick = () => {
      return setTimeout(() => {
        const before = data.length >= 30 ? data.slice(1) : data.slice();
        // 차트에 나타나는 데이터는 총 30초 분량으로 배열의 길이가 30을 유지할 수 있도록 필요없는 이전 데이터들을 제외하고 업데이트
        setData([...before, [Date.now(), Math.random() * 100]]);
        console.log(data);
      }, 1000);
    };
    tick();

    return () => clearTimeout(tick);
},[data]);

생애주기를 이용해 마운트되면 setTimeOut을 실행시켜 데이터를 변화시킨다.
useEffect의 Dependency에 data를 넣어 data가 변화될 때마다 useEffect가 실행되도록 했고, clearTimeOut을 반환해주며 언마운트될 때 timeout을 Clean-up을 해주었다.

그러면 1초마다 차트에 그래프가 그려지게 되더라~

전체코드

import React from "react";
import { useEffect, useState } from "react";
import "./App.css";

const PADDING = 20;
const MAX_VALUE = 100;
const Y_TICK = 4;
const DURATION = 1000 * 30; // 30s
const EX_TIME = "00:00";

function LineChart({ id }) {
  const [data, setData] = useState([[Date.now(), Math.random() * 100]]);

  useEffect(() => {
    const canvas = document.getElementById(id);
    const ctx = canvas.getContext("2d");
    const canvasWidth = canvas.clientWidth;
    const canvasHeight = canvas.clientHeight;
    const chartWidth = canvasWidth - PADDING;
    const chartHeight = canvasHeight - PADDING;
    const xFormatWidth = ctx.measureText(EX_TIME).width;
    let endTime, startTime, xTimeInterval;

    const setXInterval = () => {
      let xPoint = 0;
      let timeInterval = 1000;
      while (true) {
        xPoint = (timeInterval / DURATION) * chartWidth;
        if (xPoint > xFormatWidth) break;
        timeInterval *= 2;
      }

      xTimeInterval = timeInterval;
    };

    const setTime = () => {
      endTime = Date.now();
      startTime = endTime - DURATION;
      setXInterval();
    };

    const drawChart = () => {
      setTime();
      ctx.clearRect(0, 0, canvasWidth, canvasHeight);
      ctx.beginPath();
      ctx.moveTo(PADDING, PADDING);
      // draw Y axis
      ctx.lineTo(PADDING, chartHeight);
      const yInterval = MAX_VALUE / Y_TICK;
      ctx.textAlign = "right";
      ctx.textBaseLine = "middle";
      for (let i = 0; i <= Y_TICK; i++) {
        const value = yInterval * i;
        const YPoint =
          chartHeight - (value / MAX_VALUE) * (chartHeight - PADDING);
        ctx.fillText(value, PADDING - 3, YPoint); // 간격 3px
      }

      // draw X axis
      ctx.lineTo(chartWidth, chartHeight);
      ctx.stroke();

      ctx.save();
      ctx.beginPath();
      ctx.rect(PADDING, 0, chartWidth, canvasHeight);
      ctx.clip();

      let currentTime = startTime - (startTime % xTimeInterval);
      ctx.textBaseLine = "top";
      ctx.textAlign = "center";
      while (currentTime < endTime + xTimeInterval) {
        const xPoint = ((currentTime - startTime) / DURATION) * chartWidth;
        const date = new Date(currentTime);
        const text = date.getMinutes() + ":" + date.getSeconds();

        ctx.fillText(text, xPoint, chartHeight + PADDING);
        currentTime += xTimeInterval;
      }

      // draw data
      ctx.beginPath();
      data.forEach((datum, index) => {
        const [time, value] = datum;
        const xPoint = ((time - startTime) / DURATION) * chartWidth;
        const yPoint =
          chartHeight - (value / MAX_VALUE) * (chartHeight - PADDING);

        if (!index) {
          ctx.moveTo(xPoint, yPoint);
        } else {
          ctx.lineTo(xPoint, yPoint);
        }
      });
      ctx.stroke();
      ctx.restore();
      window.requestAnimationFrame(drawChart);
    };

    const tick = () => {
      return setTimeout(() => {
        const before = data.length >= 30 ? data.slice(1) : data.slice();
        setData([...before, [Date.now(), Math.random() * 100]]);
        console.log(data);
      }, 1000);
    };

    drawChart();
    tick();

    return () => clearTimeout(tick);
  }, [data]);

  return <canvas id={id} width="500px" height="300px"></canvas>;
}

function App() {
  return <div className="App">{<LineChart id="lineChart" />}</div>;
}

export default App;

강의에 나오지 않은 삭제 로직도 나름 잘 작동되는 듯 하다

강의를 따라해보면서 canvas 활용방법에 대해 익힐 수 있었다. 익숙하지는 않지만 이렇게 활용될 수 있다는 것을 아는 것만으로도 추후에 생각의 폭이 한층 넓어질 것이라 믿는다.

현재로써는 이외의 방법이 떠오르지 않으나 추후 실력이 향상되어 생각의 폭이 더 넓어졌을 때는 조금 더 효율적인 코드로 리팩토링 해볼 수 있지 않을까!

profile
개발자 공부 일기😉

0개의 댓글

관련 채용 정보