우여곡절 겪은 Chart.js 일기
아직 Typescript도 제대로 못 다루는 Vue린이… (애니야 사랑해,, 영어 이름 ANY로 바꿀까 헤헷,,)
이런 내가 새로운 프로젝트에 투입되어 맡은 파트 중 좌담회 참석자들을 대상으로 한 설문조사의 결과 화면을 Chart.js를 사용하여 만드는 게 있었다.
Chart… js…?
그개.. 몮대요…?
그냥 그래프 그리는 걸로만 알고 있었지 직접 써 본 적은 없었기 때문에 이 기회에 써보고 내 것으로 만들어야겠다고 생각했다!
제로 베이스에서 이것저것 찾아가야 했기 때문에 그 순탄하지 않은 삽질을 한번 기록해보려고 한다.
Chart.js는 차트를 사용하기 좋고 활용성이 높은 오픈소스 라이브러리이다!
그리고 공식 문서도 굉장히 잘 되어있다.
https://www.chartjs.org/docs/latest/
<canvas id="myChart" width="400" height="400"></canvas>
<script>
const ctx = document.getElementById('myChart').getContext('2d');
const myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
datasets: [{
.
.
.
차트 그리는 법은 구글링하면 널리고 널려있다!
보통 Chart.js를 다운받고 캔버스를 삽입하여 스크립트를 작성하는 방식을 많이 쓰고 있다.
우리는 vue를 사용하기 때문에 vue-chartjs를 npm으로 설치해준다. 우리 프로젝트에서는 차트 컴포넌트를 따로 분리하여 data와 options 프로퍼티를 prop값으로 넘겨주고 있었다.
처음 내가 접하게 된 차트 소스는 도넛 차트였는데
//**DoughnutComponent.vue**
<script lang="ts">
import {Component, Mixins, Prop } from "vue-property-decorator";
import { Doughnut, mixins } from 'vue-chartjs';
@Component({
extends: Doughnut,
mixins: [mixins.reactiveProp],
})
export default class DoughnutComponent extends Mixins(mixins.reactiveProp, Doughnut) {
@Prop() chartData;
@Prop() options;
mounted () {
this.renderChart(this.chartData, this.options);
}
}
</script>
스크립트만 있었다.
구글링 해서 보던 캔버스를 만드는 방식과는 다른 소스를 보며 조금 많이 멘붕이 왔다… 소스를 이리 저리 뜯어본 결과 컴포넌트로 data와 options 값을 전달하는 방식은 같은 흐름으로 차트가 그려진다는 것을 알게 되었다.
**//InfoComponent.vue**
<b-col cols="6">
<DoughnutComponent
:key="genderData.datasets[0].data[0]"
:chartData="genderData"
:options="{
responsive: true,
maintainAspectRatio: false,
title: {
text: '성별',
position: 'top',
display: true,
fontSize: 15,
},
}"
/>
</b-col>
성별 차트를 불러오는 부분.
차트 컴포넌트를 불러오는 InfoComponent.vue에서 도넛 차트 컴포넌트에 chartData와 options 프로퍼티를 전달하여 차트를 그린다. key값은 DB에서 불러오는 데이터 값이 바뀔 때마다 차트를 다시 그려준다. 넘겨주는 프로퍼티 값들은 차트 타입에 따라 조금씩 상이하기 때문에 공식 문서를 확인하는 것이 좋다.
바보갓은 나는 도넛 차트의 options 값을 상수값으로 만들어 그냥 태그 안 에다가 다 때려 넣는 방식으로 했다… 다시 하게 되면 저렇게는 안 할 거 같은데,,,😓
genderData: any = {
labels: ['남자', '여자'],
datasets: [
{
backgroundColor: [
'rgba(232,69,88,1)',
'rgba(236,108,69,1)',],
data: [60,30] ,
},
],
};
성별 데이터는 따로 인터페이스를 만들지 않아 any 타입으로 지정했다.
labels와 backgroundColor 값은 변하지 않고 data의 배열 안의 값들은 DB에서 읽어온 데이터들로 매핑되는 메서드를 만들었다.
도넛 차트에서
labels는 차트 데이터를 구성하는 각각의 이름이 들어가고
backgroundColor는 차트에서 보여지는 값의 색이다.
data는 말 그대로 차트에서 보여지는 데이터 수치이다.
각각 배열값을 갖는데 배열의 길이는 셋 다 같아야 한다.
async loadGroupReportData() {
const { data } = await this.axios.get(
`/report/${this.groupObjectId}`
);
const { result, groupInfo, projectInfo, participantInfo, groupParticipantInfo } = data;
if (result === true) {
this.moderatorName = groupParticipantInfo.filter((p)=> p.role === 'moderator')[0].name;
this.project = projectInfo;
this.group = groupInfo;
this.title = `[ ${this.project.title} ] ${this.group.title}`;
this.$emit('setTitle',this.title);
this.$emit('setGroupInfo', this.group);
this.participants = groupParticipantInfo.filter(
(p) => p.status === 'completed'
).map((p)=>{
return{
...p,
_id: p.participantObjectId
}
});
let part: IParticipant[] = [];
this.participants.forEach((p) =>{
participantInfo.forEach((parti) =>{
if(parti.email === p.email){
part.push(parti);
}
});
});
this.participants = part;
this.genderData.datasets[0].data = this.calculateGender();
this.ageData.datasets[0].data = this.calculateAge();
}
}
calculateGender(){
let manCnt = 0;
this.participants.forEach((part)=>{
if(part.gender === 'man'){
manCnt++;
}
});
const womenCnt = this.participants.length - manCnt;
return [manCnt, womenCnt];
}
그룹 참여자 정보를 조회해 와서 상태값으로 참여자를 걸러주고 성별 비율을 계산하는 메서드의 리턴값을 dataset의 data에 넣어주기~~
그럼 그 데이터로 도넛 컴포넌트가 그려진다.
진짜 끝!
여기까진 좀 쉽게 들어갔다..
도넛차트가 이미 거의 다 만들어진 상태였고 요청사항도 복잡하지 않았고 데이터만 넣어주면 끝나는 부분이어서…
그리고 백지에 그려야 하는 세가지 챠트가 있었는데 (사실 네가지를 그렸다.)
Scatter , Line, Pie, Bar Chart…,,,
본격 나의 삽질이 시작되었다.
InfoComponent.vue 의 차트와는 다르게 이 다음으로 내가 해야 할 것은 db에서 설문리스트를 조회해 온 뒤 설문 타입에 따라 다양한 차트로 결과를 나타내주어야 하는 것이었다.
async setChartData(){
this.chartData = [];
const { data } = await this.axios.get(`/question/${this.group._id}`);
const { questions } = data;
this.questionData = questions.filter((q)=>
q.questionType !== 'text'
);
const questionsNotText = questions.filter((q)=>
q.questionType !== 'text'
);
questionsNotText?.forEach((q, idx)=>{
const {questionType, questionNumber} = q;
if(questionType === 'radioSet'){
this.chartData[idx] = this.calculateLine(q, questionNumber, idx);
}else if(questionType === 'check') {
this.chartData[idx] = this.calculateBar(q, questionNumber, idx);
}else if(questionType === 'radio'){
this.chartData[idx] = this.calculatePie(q, questionNumber, idx);
}else{
console.log(questionType, idx, questionNumber)
}
});
}
DB에서 조회해 온 설문 데이터 리스트를 반복문을 돌려서 각 설문 타입에 따라 line, bar, pie 데이터를 리턴 하는 메서드를 실행시켜 chartData 리스트에 차곡차곡 넣어준다. (text 타입은 차트를 보여주지 않는다)
radioSet (척도문항) : scatter (line으로 바뀜)
check (멀티문항) : bar (가로)
radio (싱글문항) : bubble (pie로 바뀜)
일단 해보자라는 마음으로 Scatter 차트부터 차근차근 해나가기 시작했다.
https://www.chartjs.org/docs/latest/charts/scatter.html
const data = {
datasets: [{
label: 'Scatter Dataset',
data: [{
x: -10,
y: 0
}, {
x: 0,
y: 10
}, {
x: 10,
y: 5
}, {
x: 0.5,
y: 5.5
}],
backgroundColor: 'rgb(255, 99, 132)'
}],
};
스캐터 차트 컴포넌트로 넘겨줘야 할 데이터 값은 이렇게 된다. 스캐터 차트의 pointer 위치가 (x,y)좌표로 요구가 되기 때문에 각각의 x, y 좌표값이 data 값으로 필요하다.
label은 그래프 전체에 대한 범례(legend)로써 그래프 상단에 이름으로 나타난다.
DB에서 데이터를 읽어와 이런 데이터 타입으로 가공해 준 뒤 prop으로 스캐터차트 컴포넌트로 편하게 넘기기 위해 x,y 좌표를 인터페이스로 만들어 놓고 좌표 데이터 배열 타입과 그래프를 그리는 프로퍼티를 포함한 인터페이스를 만들어주었다.
**//chart.ts**
export interface IScatterData{
datasets: IScatterDatasets[];
}
export interface IScatterDatasets{
borderColor: string;
data: IScatterDataXY[];
label: string;
showLine: boolean;
fill: boolean;
tension: number;
}
export interface IScatterDataXY{
x: number;
y: number;
}
값들을 초기화하는 함수도 생성해줌.
**//chart.ts**
export function getInitIScatterData(){
return {
datasets: []
}
}
export function getInitIScatterDataSets(){
return {
borderColor: 'rgb(255, 99, 132)',
data: [],
label: ''
}
}
암튼 이렇게 인터페이스로 만들어 준 뒤 다시 차트 데이터를 만들러 가야 한다…
**//ResultChartComponent.vue**
calculateScatter(question:IQuestion, qNum: string, idx: number){
let scatter: IScatterDatasets = getInitIScatterData();
let sDataSets: IScatterDatasets = getInitIScatterDataSets();
let sData: IScatterDataXY[] = [];
const qData = this.answerData.filter( d => d.module===qNum );
if(!qData.length){
this.questionData[idx].use = false;
}
const counts = question.examples.length;
let counting: number[] = [];
for(let i=0; i<counts; i++){
counting.push(0);
}
qData.forEach((q)=>{
counting[Number(q.value)-1]++;
});
for( let i=0; i<counts; i++){
let data = {
x: i+1,
y: counting[i]
}
sData.push(data);
}
sDataSets.data = sData;
scatter.datasets = [sDataSets];
return scatter;
}
Scatter Chart Data를 리턴하는 메서드인데 위에서 만든 인터페이스를 좌표부터 차곡차곡 채우는 형식으로 만들었다.
scatter Chart는 척도 문항의 결과를 나타내준다.
참여자의 설문 응답 데이터를 모두 조회한 후 해당 설문 번호와 맞는 응답 데이터를 거른다.
scatter Chart는 x,y 좌표값을 number로 가지기 때문에 척도 문항의 문항 번호를 x축, 해당 응답 수를 y축으로 넣는다. (근데 x축을 문항 번호가 아닌 문항 보기 내용으로 할 수도 있다!!)
최종 리턴 값인 scatter의 형식은 인터페이스로 만든 IScatterData,
scatter의 datasets 값으로 들어갈 sDataSets의 형식은 인터페이스로 만든 IScatterDatasets,
sDataSets의 data값으로 들어갈 sData의 형식은 인터페이스로 만든 IScatterDataXY이다.
**//ResultChartComponent.ts**
const counts = question.examples.length;
let counting: number[] = [];
for(let i=0; i<counts; i++){
counting.push(0);
}
qData.forEach((q)=>{
counting[Number(q.value)-1]++;
});
for( let i=0; i<counts; i++){
let data = {
x: i+1,
y: counting[i]
}
sData.push(data);
}
설문 선택지 갯수를 길이로 하고 모든 요소는 0의 값을 갖는 배열 counting을 만들어준다.
응답 value는 선택지 번호 숫자로 나타나기 때문에 value-1을 인덱스로 갖는 counting배열의 요소를 +1해주면 각 선택지의 응답 수를 측정할 수 있다! 이 counting 배열의 각 요소 값(응답 수)이 y축 값으로 들어간다. x,y축 데이터 값이 만들어지면 sData 배열 안에 넣고, 배열이 완성되면 sData배열을 sDataSets의 data값으로 넣어준다. 그리고 최종 리턴 값의 scatter의 datasets 값에 sDataSets를 배열 형식으로 넣어 리턴해준다.
**//ResultChartComponent.ts**
<ScatterComponent
v-if="survey.questionType==='radioSet' && survey.use"
:chartData="chartData[idx]"
:key="chartData.length"
:options="{
legend: {
display: false,
},
scales: {
xAxes: [{
gridLines: {
display: false,
},
ticks: {
stepSize: 1,
min: 0,
max: answer.length,
callback: function(val, index, ticks){
return index? getLabelForValue(index, idx) : '';
}
}
}],
yAxes: [{
ticks: {
stepSize: 1,
min: 0,
max: answer.length
}
}]
},
elements: {
point: {
radius: 5
}
}
}"
/>
**//ScatterComponent.vue**
<script lang="ts">
import {Component, Mixins, Prop } from "vue-property-decorator";
import { Scatter, mixins } from 'vue-chartjs';
@Component({
extends: Scatter,
mixins: [mixins.reactiveProp],
})
export default class ScatterComponent extends Mixins(mixins.reactiveProp, Scatter) {
@Prop() chartData;
@Prop() options;
mounted () {
this.renderChart(this.chartData, this.options);
}
}
</script>
리턴된 값은 chartData 배열 안에 들어가며 template에서 설문 리스트로 반복문을 돌려서 해당 인덱스의 설문 타입에 따라 각각 차트 컴포넌트의 data 프로퍼티로 전달이 되는 구조로 코딩을 했다.
지금 다시 코드를 보니 chartData 배열에 따로 넣지 말고 설문 리스트의 해당 설문 값에 넣어서 반복문을 돌렸어도 될 것 같다.. (어차피 같은 인덱스를 쓰니까…)
이제 data값은 넘겨주었고 options를 제대로 설정해주어야 그래프에서 x축, y축이 예쁘게… 정상적으로 나온다…
legend: {
display: false,
},
우선 legend는 범례에 관한 옵션이다.
scatter chart의 범례는
이렇게 상단에 나타나는데 저 범례를 숨기는 요청 사항이 있었기 때문에 display: false 로 설정을 한다. 기본 값이 true이기 때문에 legend 옵션을 주지 않으면 자동으로 생성이 된다.
scales: {
xAxes: [{
gridLines: {
display: false,
},
ticks: {
stepSize: 1,
min: 0,
max: answer.length,
callback: function(val, index, ticks){
return index? getLabelForValue(index, idx) : '';
}
}
}],
yAxes: [{
ticks: {
stepSize: 1,
min: 0,
max: answer.length
}
}]
},
scales는 x축, y축을 설정할 수 있다.
따로 건들지 않으면 data값을 기준으로 chartjs가 멋대로 x축 y축을 설정하는데 그렇게 되면 소수점이 나올 수도 있다,,, 값이 작으면 소수점이 나오거나 -값도 나오기 때문에 이 부분을 제대로 설정해주어야 한다.
xAxes는 x축을 설정하고 yAxes는 y축을 설정한다.
나는 x축의 세로선은 숨기려고 했기 때문에 xAxes의 gridLines값을 display: fasle로 설정해주었다. 이것도 기본값이 true라서 건드리지 않으면 무조건 보이게 됨. gridLines는 범위 값들의 선들을 나타내기 때문에 y축에서도 이 부분을 설정해주면 가로선을 숨길 수 있다.
ticks는 축의 눈금을 나타내는데 이 옵션을 이용해서 숫자 대신 텍스트 값을 리턴할 수있음.
stepSize는 축 위의 값의 간격을 설정해준다.
min은 가장 작은 값, max는 가장 큰 값이다.
나는 max값은 응답데이터 리스트의 길이로 설정했다.
좌표의 숫자로 나타나는 축의 눈금 값은 callback 함수를 사용해서 원하는 대로 바꿀 수 있다.
https://www.chartjs.org/docs/latest/axes/labelling.html
이렇게 Scatter 차트를 끝냈는데 기획이 Line 차트로 바뀌었다…!!
우여곡절을 겪은 내 코드 빠빠룽,,,
다행히 Line 차트는 Scatter 차트와 거의 흡사했다.
https://www.chartjs.org/docs/latest/charts/line.html
const data = {
labels: labels,
datasets: [{
label: 'My First Dataset',
data: [65, 59, 80, 81, 56, 55, 40],
fill: false,
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
};
scatter chart는 data 값이 x,y 좌표의 배열이지만 line chart는 단순 number 배열이라 더 간단하다.
마찬가지로 인터페이스를 만들어준다.
**//chart.ts**
export interface ILineData{
labels: string[];
datasets: ILineDataSets[];
}
export interface ILineDataSets{
label: string;
data: number[];
fill: boolean;
borderColor: string;
tension: number;
}
값을 초기화 해주는 함수도 만들어줬다.
**//chart.ts**
export function getInitILineData(){
return {
labels: [],
datasets: []
}
}
export function getInitILineDatasets(){
return{
label: '',
data: [],
fill: false,
borderColor: '',
tension: 0
}
}
fill 속성은 line 아래 부분 바탕색을 채울지 말지 여부를 나타낸다. true로 설정하면 선 그래프의 아래쪽 부분이 색칠 되어 나타난다.
tension은 line의 굴곡인데 0으로 하면 꺾이는 부분이 깔끔하게 딱 꺾인다.
scatter chart와 마찬가지로 labels는 범례 위치에 나타나게 되는데 options에서 legend display 옵션을 false로 설정하면 안 보인다.
label과 labels가 다르다. labels는 legend로서 차트 당 하나씩 나타나는데 반해 label은 축의 눈금이 된다.
//ResultChartComponent.vue
calculateLine(question:IQuestion, qNum: string, idx: number){
let line: ILineData = getInitILineData();
let lDataSets: ILineDataSets = getInitILineDatasets();
lDataSets.borderColor = 'rgb(255, 99, 132)';
line.labels = question.examples.map( e =>{
return e.text;
})
const counts = question.examples.length;
let counting: number[] = [];
for(let i=0; i<counts; i++){
counting.push(0);
}
const lData = this.answerData.filter( d => d.module===qNum );
if(!lData.length){
this.questionData[idx].use = false;
}
lData.forEach((q)=>{
counting[Number(q.value)-1]++;
});
lDataSets.data = counting;
line.datasets = [lDataSets];
return line;
}
data가 단순 number 배열이라 scatter 데이터 생성 로직보다 더 간단하다.
counting 배열이 data에 그대로 들어간다.
<LineComponent
v-if="survey.questionType==='radioSet' && survey.use"
:chartData="chartData[idx]"
:key="chartData.length"
:options="{
legend: {
display: false,
},
scales: {
xAxes: [{
gridLines: {
display: false,
},
ticks: {
stepSize: 1,
min: 0,
max: answer.length
}
}],
yAxes: [{
ticks: {
stepSize: 1,
min: 0,
max: answer.length
}
}]
},
}"
></LineComponent>
**//LineComponent.vue**
<script lang="ts">
import {Component, Mixins, Prop } from "vue-property-decorator";
import { Line, mixins } from 'vue-chartjs';
@Component({
extends: Line,
mixins: [mixins.reactiveProp],
})
export default class LineComponent extends Mixins(mixins.reactiveProp, Line) {
@Prop() chartData;
@Prop() options;
mounted () {
this.renderChart(this.chartData, this.options);
}
created(){
console.log(this.chartData,this.options, 'dddd');
}
}
</script>
line chart는 x축 눈금이 기본적으로 숫자가 아니기 때문에 문항 항목 텍스트가 그대로 들어가게 된다. 따라서 눈금을 변경하기 위한 콜백 함수를 만들어 줄 필요도 없더ㅏ…
scatter 차트를 먼저 만들어 놓아서 이걸 최대한 활용해보려고 콜백 함수까지 파고들었지만
그냥 라인차트 하나면 된다…ㅎ
겨우 하나 끝냈는데 글이 너무 길어졌다….;;
… 아직 두개나 더 남았는데,,,, 어떡하지…
일단은 가본다.
다음은 Pie 차트인데 이것은 그냥 도넛차트랑 234134퍼센트 같은 방식으로 그려지기 때문에 공식 사이트에서도 이 두 차트를 한번에 묶어서 다루고 있다.
**//ResultChartComponent.vue**
<PieComponent
v-if="survey.questionType==='radio' && survey.use"
:chart-data="chartData[idx]"
:key="chartData.length"
:options="{
maintainAspectRatio: false,
}"
/>
그렇기 때문에 이번에는 options에서 다루는 maintainAspectRatio에 대해서만 언급해보도록 하겠다.
저 속성을 false로 해줘야만 차트의 크기를 직접 설정할 수 있다. 기본 값이 true이기 때문에 chartjs로 그리는 차트들은 각각의 화면 크기에 자동으로 맞춰지고 대부분 엄청나게 큰 사이즈로 화면에 그려지게 된다.
<b-row>
<b-col
cols="6"
v-for="(survey,idx) in questionData"
:key="idx"
>
<h6 class="pl-3 mt-3"
v-if="survey.questionType!=='text'">
.
</h6>
<b-row>
<b-col
cols="3"
/>
<b-col
cols="6"
>
maintainAspectRatio속성을 false로 설정해주어야 내가 적용하려고 했던
이제 Bar 차트 하나 남았다아아…
Bar차트는 이름 그대로 막대그래프다.
https://www.chartjs.org/docs/latest/charts/bar.html
막대그래프를 가로로 그려야 했기 때문에 options의 값을 활용해야 한다.
공식 문서에서는
options:{
indexAxis: 'y'
}
이렇게 값을 주면 축의 기준을 바꿀 수 있다고 했는데…
안 돼요… 아니 안 돼요… 아뇨 그냥 안 돼요….
도대체 왜(keep your head down)…?
는 chartjs 버전 문제였다…
우리 프로젝트에서는 chartjs 2.9.4 버전을 쓰고 있었고 문서에서는 가장 최신 버전인 3.9.0을 다루고 있었는데 막대 그래프의 옵션을 살짝 다르게 사용하고 있었다.
var myBarChart = new Chart(ctx, {
type: 'horizontalBar',
data: data,
options: options
});
chartjs 2.9.4 버전에서는 이렇게 타입으로 아예 가로 막대 그래프를 지정할 수 있었다!
이게 더 쉬웠넹…
//chart.ts
export interface IBarData{
labels: string[];
datasets: IBarDatasets[];
}
export interface IBarDatasets{
axis: string;
label: string;
data: number[];
fill: boolean;
backgroundColor: string[];
borderColor: string[];
borderWidth: number;
}
labels는 문자열의 배열로써 축에 나타나는 값의 이름으로 나타나며 label은 도넛 차트처럼 그래프 상단에 전체 그래프에 대한 이름으로 나타난다.
January, February,… = labels
My First Dataset, My Second Dataset = label
January, February… 이런 눈금 값에 대한 범례를 설정해주는 부분이 따로 없어서 커스텀으로 만들어주어야 했다. 구글링 해보니 <table> 태그 이용해서 많이 만드는 것 같다…
이런 기능이 옵션으로 따로 탑재가 되어있지 않다니 아쉽구만… 다음 버전에는 이 부분이 보완됐으면 좋겠다…
모든 차트를 다루어보지는 않았지만 처음 chartjs를 접해보면서 여러 삽질과 우여곡절을 겪으며 오픈소스 활용에 대한 두려움을 조금은 극복해낸 것 같다. 다양한 사유의 에러와 오류를 겪고나니 머리가 어질어질하지만 그만큼 좋은 경험이 되었다. 단순한 프로젝트 참여로만 남겨두지 않고 이렇게 잘 정리해두면 다음 새로운 오픈소스를 접할 때 마음가짐이 이전보다 훨씬 가벼울 것 같다.
차트를 그리는 것은 여러 프로젝트에서 활용할 수 있기 때문에 다양한 차트 오픈소스를 접해보고싶다. 더불어 프론트 작업은 역시 눈으로 바로 바로 확인할 수 있어서 삽질을 하더라도 그~나~마~ 얕게 팔 수 있는 것 같다…ㅎㅎ… 여담으로 알록달록 동글동글 귀여운 chartjs 디자인 덕분에 개발로 머리가 지끈거리는 와중에 눈은 나름 재미있었다 걀걀걀걀…,,,,,
부족한 점이 많지만 나의 첫 피앰아이 개발블로그 포스팅은 여기서 끝!!!