파이널 프로젝트를 진행하면서 칵테일 구현을 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
를 통해서 엘리먼트를 가져올 수 있다고 생각했기 때문이다.
강의에서 차트는 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 활용방법에 대해 익힐 수 있었다. 익숙하지는 않지만 이렇게 활용될 수 있다는 것을 아는 것만으로도 추후에 생각의 폭이 한층 넓어질 것이라 믿는다.
현재로써는 이외의 방법이 떠오르지 않으나 추후 실력이 향상되어 생각의 폭이 더 넓어졌을 때는 조금 더 효율적인 코드로 리팩토링 해볼 수 있지 않을까!