d3.js 기반 도넛 차트 그리기
Vue.js component 기본 구조
<template>
<div id="svg">
<!-- html 영역 -->
</div>
</template>
<script>
// javascript 영역
export default {
}
</script>
<style>
/* css 영역 */
</style>
Inputed data
export default {
data() {
return {
// 1층(안쪽) 도넛을 그릴 데이터입니다.
gdp: [
{country: "USA", value: 20.5 },
{country: "China", value: 13.4 },
{country: "Germany", value: 4.0 },
{country: "Japan", value: 4.9 },
{country: "France", value: 2.8 }
],
// 2층(바깥쪽) 도넛을 그릴 데이터입니다.
childData: [
10, 20, 30, 40, 50
]
};
},
...
svg element 생성
// <svg> element 생성
const svg = d3
.select("#arc")
.append("svg")
.attr("width", svgWidth)
.attr("height", svgHeight);
d3는 작업의 대부분을 메소드 체인을 통하여 처리합니다. 즉, 메소드(함수)가 꼬리에 꼬리를 무는 형태로 작성됩니다.
물론 두 개 이상의 작업을 동시에 처리할때에는 체인을 끊고 변수에 저장하여 해당 변수에서 메소드 체인을 이어나가시면 됩니다.
위 코드는 id가 arc인 요소(element)를 선택하여 하위에 svg 요소를 생성하고, 그 속성으로 width와 height를 주고 있습니다. 그리고 그 반환값을 svg에 저장했습니다.
렌더링이 끝난 결과는
<div id="arc">
<svg width="**svgWidth의 값" height="**svgHeight의 값">
</svg>
</div>
가 될 것입니다.
svg는 웹에서 반응형 이미지를 그리기 위한 도화지라고 생각하시면 편합니다.
자세한 내용이 궁금하시다면
해당 링크 혹은 구글에 svg로 검색해서 찾아보시길 권장합니다.
d3.js(이하 d3라고 칭함)는 svg 기반의 프레임워크이므로 svg의 속성을 아는것이 중요합니다.
스케일 설정
// 스케일 설정 (실제 값 <-> 화면에 보여지기 위한 값)
const angleScale = d3
.scaleLinear()
.domain([0, sumGDP])
.range([0, 2 * Math.PI]);
실제 데이터 수치 그대로를 시각화하려면 많은 애로사항이 있습니다. 웹 브라우저의 기본 단위는 px(픽셀)이며 이 값은 대체로 480px ~ 1980px 해상도에서 표시되기 때문입니다.
d3에서는 이를 scale을 이용하여 실제 데이터의 범위(domain)과 자신이 시각화할 영역의 범위(range)를 입력하여 scale 함수를 생성할 수 있습니다. 만약
domain : 0 ~ 10000
range : 0 ~ 100
이라면 scale(9000)의 값은 90을 반환합니다.
도넛 그리기
const arc = d3
.arc()
.innerRadius(50)
.outerRadius(150)
.startAngle((d,i) => i != 0 ? angleScale(sGdpValues.slice(0,i).reduce((a,b)=> a+b)) : 0)
.endAngle((d,i) => angleScale(sGdpValues.slice(0,i+1).reduce((a,b)=> a+b)));
도넛 차트에서 도넛의 각 요소를 호(arc)의 형태로 그리는 코드입니다. inner,outer Radius는 이름에서 알 수 있듯 내부 반지름, 외부 반지름을 뜻합니다. 중심부 기준에서 얼마나 멀어지는지 입니다.
startAngle, endAngle은 호가 시작하는 위치, 끝나는 위치를 지정합니다.
그리기 코드에 데이터를 연결하여 마저 그리기
// 데이터 연결
const g = svg.append("g");
g.selectAll("path")
.data(sortedGDP)
.enter()
.append("path")
.attr("d", arc)
.attr("fill", (d, i) => color(i))
.attr("stroke", "#fff")
.attr("stroke-width", "2px")
// event
.on("mouseenter", function() {
d3.select(this)
.transition()
.duration(200)
.attr("opacity", 0.5);
})
.on("mouseout", function() {
d3.select(this)
.transition()
.duration(200)
.attr("opacity", 1);
})
.data()와 .enter()로 데이터를 연결(binding)하여 각종 속성(모양, 색상, 구분선) 등을 지정하는 코드입니다.
요소 위치 정렬하기
// *올바른 위치로 이동*
g.attr("transform", `translate(${svgWidth/2},${svgHeight/2})`);
전체 코드
<template>
<div id='arc'>
</div>
</template>
<script>
import * as d3 from "d3";
export default {
data() {
return {
gdp: [
{country: "USA", value: 20.5 },
{country: "China", value: 13.4 },
{country: "Germany", value: 4.0 },
{country: "Japan", value: 4.9 },
{country: "France", value: 2.8 }
],
childData: [
10, 20, 30, 40, 50
]
};
},
mounted() {
this.generateArc();
},
methods: {
generateArc() {
// 데이터 가공, 변수 선언
const svgWidth = 250;
const svgHeight = svgWidth;
const innerRadius = svgWidth / 10;
const outerRadius = svgWidth / 2;
const middleRadius = (innerRadius + outerRadius) / 2;
const sortedGDP = this.gdp.sort((a, b) => (a.value > b.value ? 1 : -1));
const sGdpValues = sortedGDP.map(d => d['value']);
const sumGDP = this.gdp.map(d => d['value']).reduce((a,b)=>a+b)
const sumTest = this.childData.reduce((a,b) => a+b)
const color = d3.scaleOrdinal(d3.schemeDark2);
// <svg> element 생성
let svg = d3
.select("#arc")
.append("svg")
.attr("width", svgWidth)
.attr("height", svgHeight);
// 스케일 설정 (실제 값 <-> 화면에 보여지기 위한 값)
const angleScale = d3
.scaleLinear()
.domain([0, sumGDP])
.range([0, 2 * Math.PI]);
const childAngleScale = d3
.scaleLinear()
.domain([0, sumTest])
.range([angleScale(sGdpValues.slice(0,4).reduce((a,b)=> a+b)), angleScale(sGdpValues.slice(0,5).reduce((a,b)=> a+b))]);
// 도넛 그리기
const arc = d3
.arc()
.innerRadius(innerRadius)
.outerRadius(middleRadius)
.startAngle((d,i) => i != 0 ? angleScale(sGdpValues.slice(0,i).reduce((a,b)=> a+b)) : 0)
.endAngle((d,i) => angleScale(sGdpValues.slice(0,i+1).reduce((a,b)=> a+b)));
const childArc = d3
.arc()
.innerRadius(middleRadius)
.outerRadius(outerRadius)
.startAngle((d,i) => i != 0 ? childAngleScale(this.childData.slice(0,i).reduce((a,b)=>a+b)) : angleScale(sGdpValues.slice(0,4).reduce((a,b)=>a+b)))
.endAngle((d,i) => childAngleScale(this.childData.slice(0,i+1).reduce((a,b)=> a+b)));
// 데이터 연결
const g = svg.append("g");
g.selectAll("path")
.data(sortedGDP)
.enter()
.append("path")
.attr("d", arc)
.attr("fill", (d, i) => color(i))
.attr("stroke", "#fff")
.attr("stroke-width", "2px")
// event
.on("mouseenter", function() {
d3.select(this)
.transition()
.duration(200)
.attr("opacity", 0.5);
})
.on("mouseout", function() {
d3.select(this)
.transition()
.duration(200)
.attr("opacity", 1);
})
const g2 = svg.append("g");
g2.selectAll("path")
.data(this.childData)
.enter()
.append("path")
.attr("d", childArc)
.attr("fill", (d, i) => color(i))
.attr("stroke", "#fff")
.attr("stroke-width", "2px")
.attr('transform',(d,i) => `translate(0,10*${i})`)
// 올바른 위치로 이동
g.attr("transform", `translate(${svgWidth/2},${svgHeight/2})`);
g2.attr("transform", `translate(${svgWidth/2},${svgHeight/2})`);
}
}
}
</script>
<style>
</style>
실행 결과
