[d3] scale과 axis를 이용해 축을 그리자.

d3시리즈top

현재 d3.js는 2016년 6월 새롭게 런칭된 4.x버전으로 기존에 출간된 대부분의 책들의 API와 맞지 않습니다. 변변한 한글문서가 없고 친절한 입문자용 컨텐츠가 부족해 차근차근 학습할 수 있는 시리즈를 진행합니다.

scaletop

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이고 아래로 갈수록 증가하는 형태라 값을 거꾸로 기술하는 편이 더 시각적으로 좋기 때문입니다.

axistop

차트에서 축은 너무나 자주 사용되므로 축을 생성하는 기능을 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>

세로축이 이쁘게 그려진걸 볼 수 있습니다.
screenshot_1

이 그림의 빨간선과 코드를 비교해보죠.

  1. 우선 가로로 50만큼 이동시킨 것은 위의 코드 중에 translate(50, 0) 이 부분입니다. 이게 없으면 왼쪽에 가려져버립니다. 거의 숙어처럼 축은 translate와 같이 사용될 운명입니다.
  2. 위 아래 20씩 패딩이 성립한 이유는 .range([h – 20, 0 + 20]); 이렇게 위아래 20을 뒀기 때문입니다. 반대로 말하자면 축함수는 range의 값을 픽셀크기로 인식하여 그린다는 것을 알 수 있습니다.

에, 사소한 group, join, selector 따위는 나중에 이해해보죠. 우선은 d3.select가 제이쿼리$처럼 셀렉터로 작동한다고 이해하면 attr의 작동도 쉽게 이해됩니다.
call체인은 걍은 경우는 인자로 받은 함수에게 현재의 셀렉터를 넘겨주는 식으로 작동하는데, d3의 axisLeft 자체가 그러한 함수를 반환하기 때문에 전부 은닉되어 잘 보이지 않습니다.
현 시점에 입문하신 상태라면 그냥 숙어처럼 일반은 외워서 써보는걸로 하죠 ^^;

scaleTimetop

간단히 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에 넣어주면 됩니다.
그럼 전체적으로 아래와 같은 화면을 보게 됩니다.
screenshot_2

새삼스러운 얘기지만 화면에서의 위치를 잘 잡기 위해서 range에 padding을 넣는다던가 translate를 잘 사용해야 하는 부분은 귀찮기도 하고 반복적이기도 해서 d3가 내장할 레벨은 아니지만 실무적으로는 이를 래핑한 함수를 사용하는 경우가 대부분입니다.

재활용가능한 클래스top

여태까지의 코드를 정리해보면 다음과 같습니다.

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객체가 제거되고 외부 변수의 도움없이 깔끔하게 정리되었습니다.

결론top

d3에 기초가 되는 scale과 axis를 알아봤습니다. scale은 단지 axis에만 사용되는게 아니라 모든 데이터에 다 적용됩니다(당연하게도) 흐름을 보면

  1. 차트용 데이터가 주어지면
  2. 화면의 크기, 표현하려는 형태 등을 고려하여 scale을 설정하고
  3. scale을 바탕으로 한 axis를 만들어내며
  4. 각 데이터를 scale로 변환해가면서 차트를 그려나간다

라는 식입니다. 이러한 d3의 기능 중 일부를 추출하여 axis 클래스도 정의해봤네요.
다음에서는 간단한 선그래프를 그려보도록 하겠습니다.