Skip to content

SVG로 차트만들기

김민섭 edited this page Dec 17, 2020 · 4 revisions

📌 SVG로 차트만들기

📝 목적

가계부 서비스 중 해당하는 년/월에 대한 카테고리별 통계를 보여주기 위해 Pie Chart와 Table Chart를 만들게 되었다.
별도의 라이브러리를 사용하지 않고 직접 Chart 구현에 도전해 보았기 때문에 정리하게 되었다.

📝 절차

  1. 차트에서 필요한 데이터 정리
  2. 데이터 관리 범위 결정
  3. 데이터 가공
  4. Chart 컴포넌트에서 데이터를 가지고 차트 그리기

1️⃣ 차트에서 필요한 데이터

차트를 그리기 위해서는 먼저, 차트에서 어떠한 데이터를 나타낼지를 결정해야 했다.
구현한 Pie Chart와 Table Chart는 현재 가르키고 있는 년/월에 대한 내역 정보를 바탕으로
카테고리별 통계를 시각화 하는 것이 목표였다.
따라서 chart를 그리는데 사용할 데이터는 아래의 예시와 같았다.

{
 category: '쇼핑', // 카테고리 이름 
 cost : 10000000, // 해당 년,월에 '쇼핑' 카테고리로 지출된 금액
 percent : 30, // 해당 년,월에 '쇼핑' 카테고리의 금액이 전체 카테고리 금액의 합에서 차지하는 퍼센티지
 startPoint : 108 // Pie Chart가 그려지는 시작 각도
}

2️⃣ 데이터 관리 범위 결정

위에서 결정한 데이터를 어느 layer에서 관리할지 결정해야 했다.
Chart 컴포넌트는 다른 컴포넌트와 독립된 성격의 컴포넌트 이기 때문에, 자체적으로 상태값을 가지게 할지
컴포넌트에서 사용하는 데이터가 결국 account book이라는 공통된 데이터 안의 값이기 때문에 전역으로 분리할지 고민이 되었다.
아직 구현을 다 하지않았지만, account book이라는 데이터는 전역으로 존재해야 한다는 생각이 들었다.
안에 있는 내역정보가 바뀌는 상황 또는 결제수단 관리 모달에 들어갈 payment 데이터가 바뀌는 상황에는
그 데이터 변화에 여러 컴포넌트가 반응해야 했기 때문이다.
Chart에서 사용하려는 값은 이 accoount book안의 내역 데이터들을 가공한 값이기 때문에, 1️⃣에서 정의한 데이터 format을 전역에서 만들어 관리하기로 결정하였다.

3️⃣ 데이터 가공

1️⃣에서 정의한 데이터 format을 만들기 위해 복잡한 로직이 사용되었다.(로직을 분리하고, 리팩토링하는 시간을 가져야 할 것 같다.)
먼저, 가공의 대상인 데이터는 store의 account book안에 transaction이다.
API가 완성되지 않은 상태여서, DB모델링에 따라 가상 데이터를 만들어 사용하였다.
accountBook은 아래와 같이 모델링 되어있다.

accountBook: { // 가계부 선택창에서 선택한 특정 account book의 전체 데이터가 이곳에 저장된다.
_id : string, // 가계부 고유 아이디
name : string, // 가계부 이름
description: string, // 가계부 설명
category : Object Array, // 해당 가계부에서 사용하는 카테고리
payment : Object Array, // 해당 가계부에서 사용되는 결제수단
user : String Array, // 해당 가계부의 유저목록
transaction: Object Array // 해당 가계부의 내역들
}

위 데이터중 transaction으로부터 특정 년,월의 데이터들을 뽑아서 1️⃣의 형태로 가공하는 것이 필요했다. 그에 따라 정의한 메소드는 다음과 같다.

getAllTransactions(); // 카테고리별 차트에는 사용되지 않았지만, 전체 내역데이터가 사용될 곳이 있을것 같아서 만들어 두었다.
getSpendingTotal(year,month); // 년,월에 해당하는 전체 지출금액을 계산해주는 메소드이다. 1️⃣의 percent와 startPoint를 구하기 위해 필요하였다.
getTransactionsForPieChart(year,month); // 1️⃣의 형태로 가공된 데이터를 return해주는 메소드이다. 해당 데이터가 사용될 컴포넌트에서 호출할 메소드이다.
                                       //만들고 나니 Pie Chart와 Table Chart에서 모두 사용하는 데이터이므로 추후 이름을 변경해야 할 것 같다.

구체적으로 가공은 getTransactionsForPieChart에서 이루어진다.

const chartInfo = {}; //각 카테고리별 데이터가 객체형태로 들어갈 틀
let accumDeg = 0; // 누적 각도를 계산하여 startPoint를 계산하기 위한 값

const datas = this.accountBook.transaction.filter( // 입력받은 년,월에 일치하는 내역들을 뽑아낸다.
        item =>
          year === Number(item.date.split('-')[0]) &&
          month === Number(item.date.split('-')[1]),
      );

datas.forEach(item => { // 특정 카테고리 이름의 key값이 없으면 객체를 생성하고 cost를 지정하며, key값이 있다면 해당 cost값에 더해준다.
        if (item.category.name in chartInfo) {
          chartInfo[item.category.name].cost += item.cost;
        } else {
          chartInfo[item.category.name] = {};
          chartInfo[item.category.name].cost = item.cost;
        }
      });

 for (const key in chartInfo) { // getSpendingTotal함수를 통해 해당 년,월의 총액을 가져와서 percent값 지정
        chartInfo[key].percent =
          (100 * chartInfo[key].cost) / this.getSpendingTotal(year, month);
      }

for (const key in chartInfo) { // Pie Chart에서 카테고리별로 그려질 시작점을 계산하여 startPoint 지정
        if (accumDeg === 0) {
          chartInfo[key].startPoint = 0;
          accumDeg +=
            360 * (chartInfo[key].cost / this.getSpendingTotal(year, month));
        } else {
          chartInfo[key].startPoint = accumDeg;
          accumDeg +=
            360 * (chartInfo[key].cost / this.getSpendingTotal(year, month));
        }
      }

const chartInfoArray = []; // 카테고리별 정보 담을 배열

for (const key in chartInfo) { // 카테고리 이름을 category로 지정하고 return할 배열에 푸쉬
     chartInfo[key].category = key;
     chartInfoArray.push(chartInfo[key]);
  }

return chartInfoArray; // 카테고리별로 1️⃣의 데이터 형식을 가진 배열을 리턴해준다.

4️⃣ 차트그리기

Chart 컴포넌트는 3️⃣으로부터 각 카테고리별로 1️⃣ 형식의 데이터 배열을 받게 된다.
이제, category, percent, startPoint, cost값을 가지고 Pie Chart와 Table Chart를 그려주기만 하면 된다.

☝🏻 Pie Chart

  • SVG의 circle을 이용하여 그렸다.
  • circle을 이용하였기 때문에, 원이 여러개 겹쳐지는 모양새이다. 따라서, startPoint값을 이용하여 그려지는 시작점을 정해주어야지 겹쳐보이지 않는다.
  • 가운데를 비우기 위해 fill값을 투명하게 주고, stroke의 굵기를 변경하여 윈 테두리가 그려지는 느낌으로 구현하였다.(storke-width값은 원의 테두리를 width의 중앙으로하는 값!)
  • 애니메이션은 자바스크립트를 통해 stroke-dasharray값과 stroke-dashoffset값을 조절하였다.(전체 반지름에서 stroke값을 뺀 값을 이용하여 원의 둘레 값을 만들어 사용)
  • opacity를 0에서 1로만드는 애니메이션을 통해, 기존에 있는 stroke가 그려지는 것처럼 나타내었다.
  • 가운데 글자가 그냥 나타나는게 심심해 보여서, rotate애니메이션을 주었다. (제거 가능성 농후)

✌🏻 Table Chart

  • 단순한 opacity, width값을 변경하는 애니메이션을 통해 구현하였다.
  • Pie Chart와 매칭되어, 색별로 카테고리를 보여주고, 정확한 수치를 보여주도록 구현하였다.

위키용파이차트

‼️ 주의할점

차트 애니메이션시에 주의할 점은 바로 key값이다.(사실 key값은 언제나 중요한 것 같지만...)
리액트에서는 리스트를 돌면서 react element를 가공하는 경우가 많은데, 이때 key값을 부여하라고 권장되어있다.
key는 React가 어떤 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕는다.
즉, 리렌더링시에 실제 브라우저에 반영할 요소를 식별해 주는 것이다.

만약 기존에 있던 key값 이라면 실제 브라우저에 반영하는 비용을 줄일 수 있게 된다.
이때, 실수 할 수 있는 것이 key값을 배열의 인덱스로 지정하는 것인데, 문제가 발생하는 케이스는 아래의 참고자료를 보면 이해가 될 것이다.
React - 리스트와 Key
배열의 index를 key로 쓰면 안되는 이유

배열의 인덱스 값을 쓰면 안되고... 고유의 값을 사용해야 한다는 것을 알았으므로 처음에는 uuid 모듈의 v4 함수값을 이용해 key값을 주었다.
이렇게되면 리렌더링시에 매번 새로운 해쉬값이 부여되므로, 애니메이션이 계속해서 발생하게 된다.
사실 이렇게 처리해도 애니메이션 효과가 없는 dom요소라면, 최적화를 고려하지 않는다고 가정할 때 문제가 되지 않을 수도 있다.

하지만 차트의 경우 리렌더링시마다 애니메이션이 매번 발생하면 UX를 깨뜨릴 수 있다는 생각이 들었다.
아래는 key값을 v4함수값을 이용하여 매번 렌더링마다 애니메이션이 발생하게 되는 상황이다
payment 모달 뒤로도 애니메이션이 계속 수행되고, 모달을 닫을때도 계속해서 애니메이션이 발생하는 것을 볼 수 있다.



key-v4-value


위와 같이 불필요한 애니메이션이 계속 반복되는 것을 막기위해 년+월+카테고리 조합의 값을 key값으로 사용하였다.
이 값은 고유하면서도 리렌더링시에 기억될 수 있기 때문에 리렌더링시에는 애니메이션이 다시 발생하는 것을 막을 수 있었다.
아래와 같이 모달을 열고 닫을때는 리렌더링 되지 않고, 아에 탭이동이 일어났을 때에만 리렌더링이 되도록 하여 좀 더 UX를 고려한 디자인이 되었다.



key-unique-memo

Clone this wiki locally