d3시리즈
현재 d3.js는 2016년 6월 새롭게 런칭된 4.x버전으로 기존에 출간된 대부분의 책들의 API와 맞지 않습니다. 변변한 한글문서가 없고 친절한 입문자용 컨텐츠가 부족해 차근차근 학습할 수 있는 시리즈를 진행합니다.
scale
d3의 scale은 쉽게 말해 어떤 범위의 숫자를 다른 범위의 숫자로 변경해주는 함수입니다. 이를 더 확장해 숫자 이 외의 값에도 적용할 수 있습니다만, 우선 숫자의 확장이란 개념으로 접근해보죠.
예를들어 가장 흔하게 사용하는 변경은 백분률입니다. 0에서 4955의 범위가 있을 때 2345가 어디쯤인지 감을 잡기 위해 다음과 같은 방식으로 백분률로 고칩니다.
2345 / 4955 * 100 = 47.32
이를 통해 전체를 100으로 본다면 47정도의 위치에 있는 숫자구나 하는 감을 잡습니다. 이를 구현한게 scale입니다. 이를 코드로 표현하면 다음과 같습니다.
const scale = d3.scaleLinear().domain([0, 4955]).range([0, 100]); console.log(scale(2345)); //47.32
위에서 예로든 백분률의 경우 1차 비례식을 통해 변환되므로(즉 원하는 숫자에 100/4955 를 곱하기만 하면 되므로) 선형변환이라입니다. 해서 d3.scaleLinear로부터 시작하는 것이죠. 하지만 어떤 수를 다른 수로 변환하는 방법은 다앙해서 d3에서는 다음과 같은 scale함수를 제공합니다.
- d3.scaleLinear()
- d3.scalePower()
- d3.scaleLog()
- d3.scaleIdentity()
- d3.scaleTime()
이 중 이 글에서는 scaleTime과 scaleLinear를 다룹니다. 일단 위의 함수로 생성된 scale함수는 추가적인 속성을 설정할 수 있는데 그 중 가장 중요한게 domain과 range입니다.
둘 다 배열 1개를 인자로 받고 배열 안에 2개의 원소로 시작과 끝의 범위를 기술합니다. domain이 실제 값의 범위라면 range가 백분률처럼 변환하고 싶은 값의 범위입니다.
- 재밌는 부분은 range에서 시작과 끝을 반대로 기술하여 역으로 값이 나오게할 수도 있다는 점입니다. 이 방법은 실제로 많이 사용되는데 그래프가 아래서 위로 솓아오르는게 자연스러운 반면 svg의 좌표계는 젤 위가 0이고 아래로 갈수록 증가하는 형태라 값을 거꾸로 기술하는 편이 더 시각적으로 좋기 때문입니다.
axis
차트에서 축은 너무나 자주 사용되므로 축을 생성하는 기능을 d3안에 내장시켜뒀습니다. 축에서는 눈금을 표현해야하고 적당한 값을 넣어줘야하는 등 복잡한 일이 많습니다만, 이러한 복잡성을 d3가 간단히 처리해줍니다.
2차원의 차트에서 축이 올 수 있는 자리는 상하좌우이므로 다음과 같은 함수가 제공됩니다.
- d3.axisTop(scale)
- d3.axisBottom(scale)
- d3.axisLeft(scale)
- d3.axisRight(scale)
또한 축을 생성할 때 위에서 설명했던 scale을 넘겨주면 range의 범위를 적절히 판단하여 축을 생성하게 됩니다.
실제로 축과 scale을 사용하여 화면에 세로축을 하나 그려보죠.
<svg id="graph" width="100%" height="100%"> <g id="yaxis"></g> <g id="xaxis"></g> </svg> <script>
const resizer =_=>{ const h = document.getElementById('graph').offsetHeight; const yscale = d3.scaleLinear() .domain([0, 4955]) //실제값의 범위 .range([h - 20, 0 + 20]); //변환할 값의 범위(역으로 처리했음!), 위아래 패딩 20을 줬다! d3.select('#yaxis') .attr('transform', 'translate(50, 0)') //살짝 오른쪽으로 밀고 .call(d3.axisLeft(yscale)); //축함수를 넘기면 알아서 그려줌. }; window.addEventListener('resize', resizer); resizer();
</script>
세로축이 이쁘게 그려진걸 볼 수 있습니다.
이 그림의 빨간선과 코드를 비교해보죠.
- 우선 가로로 50만큼 이동시킨 것은 위의 코드 중에 translate(50, 0) 이 부분입니다. 이게 없으면 왼쪽에 가려져버립니다. 거의 숙어처럼 축은 translate와 같이 사용될 운명입니다.
- 위 아래 20씩 패딩이 성립한 이유는 .range([h – 20, 0 + 20]); 이렇게 위아래 20을 뒀기 때문입니다. 반대로 말하자면 축함수는 range의 값을 픽셀크기로 인식하여 그린다는 것을 알 수 있습니다.
에, 사소한 group, join, selector 따위는 나중에 이해해보죠. 우선은 d3.select가 제이쿼리$처럼 셀렉터로 작동한다고 이해하면 attr의 작동도 쉽게 이해됩니다.
call체인은 걍은 경우는 인자로 받은 함수에게 현재의 셀렉터를 넘겨주는 식으로 작동하는데, d3의 axisLeft 자체가 그러한 함수를 반환하기 때문에 전부 은닉되어 잘 보이지 않습니다.
현 시점에 입문하신 상태라면 그냥 숙어처럼 일반은 외워서 써보는걸로 하죠 ^^;
scaleTime
간단히 scale과 axis를 살펴보고 그려봤습니다. 변치훈님은 scale을 축척으로 번역했습니다. 적절한 단어지만 좀 어려운 감이 있어 그냥 scale로 뒀습니다. 기초적인 축에 더해 이번엔 날짜를 값으로 하는 가로축을 생성해보죠. 이 경우 domain의 범위는 Date객체가 됩니다.
const w = document.getElementById('graph').offsetWidth; const xscale = d3.scaleTime() .domain([new Date(2016, 8, 26), new Date(2016, 8, 31)]) //범위를 날짜로 .range([50, w - 50]); //위의 y축이 가로50을 차지했으니 그만큼 밀자 const xaxis = d3.axisTop(xscale) .tickFormat(d3.timeFormat('%m/%d')) //표시할 형태를 포메팅한다. .ticks(d3.timeDay); //틱단위를 1일로 d3.select('#xaxis') .attr('transform', 'translate(0,20)')//세로20만큼 내려서 전개 .call(d3.axisTop(xscale));
위의 코드도 마찬가지로 위의 resizer에 넣어주면 됩니다.
그럼 전체적으로 아래와 같은 화면을 보게 됩니다.
새삼스러운 얘기지만 화면에서의 위치를 잘 잡기 위해서 range에 padding을 넣는다던가 translate를 잘 사용해야 하는 부분은 귀찮기도 하고 반복적이기도 해서 d3가 내장할 레벨은 아니지만 실무적으로는 이를 래핑한 함수를 사용하는 경우가 대부분입니다.
재활용가능한 클래스
여태까지의 코드를 정리해보면 다음과 같습니다.
const resizer =_=>{ const ty = 20, tx = 50, graph = document.getElementById('graph'); const xscale = d3.scaleTime().domain([new Date(2016, 8, 26), new Date(2016, 8, 31)]) .range([tx, graph.offsetWidth - tx]); const yscale = d3.scaleLinear().domain([0, 4955]) .range([graph.offsetHeight - ty, 0 + ty]); d3.select('#xaxis').attr('transform', `translate(0,${ty})`) .call(d3.axisTop(xscale).tickFormat(d3.timeFormat('%m/%d').ticks(d3.timeDay)); d3.select('#yaxis').attr('transform', `translate(${tx},0)`) .call(d3.axisLeft(yscale)); }; window.addEventListener('resize', resizer); resizer();
위의 코드는 제한적이고 응용할 수 없는 하드코딩 형태라 클래스화 할 필요가 있습니다. 이미 위에서 설명한 내용을 바탕으로 단숨에 클래스를 전개해보죠.
const Axis = Class{ //scale타입, 상하좌우, 도메인값, 범위값, 패딩값 constructor(type, pos, d0, d1, r0, r1, p0, p1){ this.scaleType(type); this.position(pos); this._listener = {}; this._padding = []; this._domain = []; this._range = [r0, r1]; this._rangeV = []; this.domain(d0, d1).padding(p0, p1); } name(p, v){ //이름변경용함수 return p + v.charAt(0).toUpperCase() + v.substr(1).toLowerCase(); } scaleType(type){ //스케일타입을 결정함 this.scale = d3[this.name('scale', this.type = type)](); return this; } timeFormat(v){ //시간포멧을 틱에 적용 this.axis.tickFormat(d3.timeFormat(v)); return this; } tick(...a){ //일반적인 틱설정이나 시간문자열을 보내면 편리하게 처리 if(a.length == 1 && typeof a[0] == 'string'){ this.axis.ticks(d3[this.name('time', a[0])]); }else{ this.axis.ticks.apply(this.axis, a); } return this; } position(pos){//위치를 재설정한다 this.axis = d3[this.name('axis', pos)](this.scale); return this; } domain(d0, d1){//도메인영역을 결정 this._domain[0] = d0 || 0; this._domain[1] = d1 || 0; this.scale.domain(this._domain); return this; } padding(v0, v1){//범위의 패딩을 결정 this._padding[0] = v0 || 0; this._padding[1] = v1 || 0; return this.range(); } range(...a){//범위를 정하면서 패딩을 반영함 if(a.length == 2) this._range[0] = a[0] || 0, this._range[1] = a[1] || 0; this._rangeV[0] = this._range[0] + this._padding[0]; this._rangeV[1] = this._range[1] - this._padding[1]; this.scale.range(this._rangeV); return this; } select(s){//셀렉터지정 if(s != undefined) this._select = typeof s == 'string' ? d3.select(s) : s; return this; } translate(tx, ty){//축변환지정 let isUpdated = false; if(typeof tx == 'number' && tx != this.tx) this.tx = tx, isUpdated = true; if(typeof ty == 'number' && ty != this.ty) this.ty = ty, isUpdated = true; if(isUpdated) this._select.attr('transform', `translate(${this.tx},${this.ty})`); return this; } render(select, tx, ty){ //이상의 정보를 바탕으로 렌더링함. if(select != undefined) this.select(select).translate(tx, ty); this._select.call(this.axis); return this; } listener:function(k, f){//내부바인딩이 완료된 리스너를 관리한다. if(f){ this._listener[k] =_=>f.call(this); return this; } return this._listener[k]; } };
이제 이를 바탕으로 위의 x축을 재구성해보면 다음과 같아집니다.
const x = new Axis('time', 'top') .domain(new Date(2016, 8, 26), new Date(2016, 8, 31)) .padding(50, 50).timeFormat('%m/%d').tick('day') .select('#xaxis').translate(0, 20) .listener('resize', function(){ this.range(0, document.getElementById('graph').offsetWidth).render(); }); window.addEventListener('resize', x.listener('resize')); x.listener('resize')();
매번 호출될때 생성되는 다수의 d3객체가 제거되고 외부 변수의 도움없이 깔끔하게 정리되었습니다.
결론
d3에 기초가 되는 scale과 axis를 알아봤습니다. scale은 단지 axis에만 사용되는게 아니라 모든 데이터에 다 적용됩니다(당연하게도) 흐름을 보면
- 차트용 데이터가 주어지면
- 화면의 크기, 표현하려는 형태 등을 고려하여 scale을 설정하고
- scale을 바탕으로 한 axis를 만들어내며
- 각 데이터를 scale로 변환해가면서 차트를 그려나간다
라는 식입니다. 이러한 d3의 기능 중 일부를 추출하여 axis 클래스도 정의해봤네요.
다음에서는 간단한 선그래프를 그려보도록 하겠습니다.
recent comment