D3.js drawing simple bar chart

December 23, 2018 · 6 mins to read

에서는 enter()에 대해 가볍게 알아보았다. 중요한 개념이니 만큼 이해가 잘 안간 부분에 대해서는 꼭 검색을 통해서 이해한 후 이 글을 읽었으면 좋겠다. 이번에는 여태까지 설명했던 개념이나 기능들을 이용해서 간단하면서도 그럴듯한 bar chart를 그려보고자 한다. bar chart를 그릴때에는 아래의 데이터를 이용해 보도록 하겠다.

const data = [
  { date: 20111001, temp: 63.4, humidity: 62.7 },
  { date: 20111002, temp: 58.0, humidity: 59.9 },
  { date: 20111003, temp: 53.3, humidity: 59.1 },
  { date: 20111004, temp: 55.7, humidity: 58.8 },
  { date: 20111005, temp: 64.2, humidity: 58.7 },
  { date: 20111006, temp: 58.8, humidity: 57.0 },
  { date: 20111007, temp: 57.9, humidity: 56.7 },
  { date: 20111008, temp: 61.8, humidity: 56.8 },
  { date: 20111009, temp: 69.3, humidity: 56.7 },
  { date: 20111010, temp: 71.2, humidity: 60.1 },
  { date: 20111011, temp: 68.7, humidity: 61.1 },
  { date: 20111012, temp: 61.8, humidity: 61.5 },
  { date: 20111013, temp: 63.0, humidity: 64.3 },
  { date: 20111014, temp: 66.9, humidity: 67.1 },
  { date: 20111015, temp: 61.7, humidity: 64.6 },
  { date: 20111016, temp: 61.8, humidity: 61.6 },
  { date: 20111017, temp: 62.8, humidity: 61.1 },
  { date: 20111018, temp: 60.8, humidity: 59.2 },
  { date: 20111019, temp: 62.1, humidity: 58.9 },
  { date: 20111020, temp: 65.1, humidity: 57.2 },
  { date: 20111021, temp: 55.6, humidity: 56.4 },
  { date: 20111022, temp: 54.4, humidity: 60.7 },
  { date: 20111023, temp: 54.4, humidity: 65.1 },
  { date: 20111024, temp: 54.8, humidity: 60.9 },
  { date: 20111025, temp: 57.9, humidity: 56.1 },
  { date: 20111026, temp: 54.6, humidity: 54.6 },
  { date: 20111027, temp: 54.4, humidity: 56.1 },
  { date: 20111028, temp: 42.5, humidity: 58.1 },
  { date: 20111029, temp: 40.9, humidity: 57.5 },
  { date: 20111030, temp: 38.6, humidity: 57.7 },
  { date: 20111031, temp: 44.2, humidity: 55.1 },
  { date: 20111101, temp: 49.6, humidity: 57.9 },
  { date: 20111102, temp: 47.2, humidity: 64.6 },
  { date: 20111103, temp: 50.1, humidity: 56.2 },
  { date: 20111104, temp: 50.1, humidity: 50.5 },
  { date: 20111105, temp: 43.5, humidity: 51.3 },
  { date: 20111106, temp: 43.8, humidity: 52.6 },
  { date: 20111107, temp: 48.9, humidity: 51.4 },
  { date: 20111108, temp: 55.5, humidity: 50.6 },
]
<body>
  <script></script>
</body>

1. 일단 틀을 마련하자.

  1. 아무것도 없는 body태그에 기본적인 틀을 먼저 준비하자. chart를 적당한 위치에 삽입해 주기 위한 준비도 해야한다. 별다른 설정이 없으면 svg는 무조건 (0, 0)에서부터 그리기 시작하기 때문이다.
const WIDTH = 3 // rect width
const SVG_WIDTH = 500
const HEIGHT = 300
const margin = { top: 30, bottom: 30, left: 30, right: 30 }

const svg = d3
  .select('body')
  .append('svg')
  .attr('width', SVG_WIDTH)
  .attr('height', HEIGHT)
  .style('margin', `${margin.top} ${margin.left}`)
  1. rect태그에 data를 binding시켜주고 적당히 스타일을 적용한다.
svg
  .append('g')
  .selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', (d, i) => i * WIDTH * 2)
  .attr('y', d => HEIGHT - d.temp)
  .attr('height', d => d.temp)
  .attr('width', WIDTH)
  .attr('fill', 'blue')

귀엽게 생긴 bar chart를 확인된다. 하지만 문제가 있다. 그래프가 너무 작아서 시인성이 낮고, 범례가 없어 대체 어떤 데이터에 대한 그래프인지 알기가 힘들다. 이 두 가지 문제를 하나씩 해결해 보자. 일단은 화면에 비해 너무 작은 그래프의 위치와 크기를, scale을 통해 적절한 비율로 바꿔보자.

2. Scale

꼭 D3.js를 이용하지 않더라도 data visualization을 하다 보면 반드시 부딪히게 되는 문제가 바로 이 scale문제이다. 예를들자면, 극단적으로 작은값과 극단적으로 큰 값이 공존하는 경우 값 = 원의 지름의 공식대로 원을 그리게 되면 어떻게 될까? 한 원은 아주 작겠지만 다른 원은 아주 클 것이다. 이것은 아주 효율적이지 못한 방법이다. 개인적인 생각으로 data visualization이란 데이터를 날것 그대로 보여주는 것이 아니라 적당히 가공하여 데이터의 경향성을 보여주는 것이 목적이라고 생각하기 때문이다.

그렇기 때문에 데이터의 정확한 값 자체는 조금 왜곡되더라도 위에서 말한 경향성을 보여주기 위해 값을 조금 조정할 필요가 있다. 이를테면 bar의 높이를 svg의 높이에 대해 bar에 binding된 값의 비율로 정하는 것이다.

const barHeight = bar.value / svg.height

이렇게 하면 bar에 binding된 값은 왜곡되겠지만, 전체적인 경향성을 좀 더 시각적으로 쉽게 받아들일 수 있게된다. 다행히도, D3.js에는 이런 scale을 조작하는 작업을 도와주는 라이브러리가 이미 존재한다.

const yScale = d3
  .scaleLinear() // 1
  .domain(d3.extent(data.map(d => d.temp))) // 2
  .range([HEIGHT - margin.top, margin.bottom]) // 3
  1. 연속된 데이터를 scale할 수 있는 함수 호출.
  2. d3.extent(Array<any>)함수는 배열의 값들 중 min, max를 자동으로 반환함. 결국 data.temp의 배열을 순회하며 최종적으로는 [min, max]의 배열을 반환함. 이 [min, max]값을 domain() 함수를 통해 주입함.
  3. domain()에 넘겨준 [min, max] 값을 바탕으로 range([number, number])함수는 파라미터로 넘겨받은 두 정수사이의 비율로 자동 계산해주는 함수를 반환한다 한국말 어렵다. 설명이 좀 어려운데, 예시를 보면 한 번에 이해할 수 있다.
const scale: (n: number) => number = d3.scaleLinear().domain([10, 100]).range([1, 10])

console.log(scale(50)) // 5

scale은 일괄적으로 value를 1/10해주는 함수라고 예상해 볼 수 있다. 만약 range([1, 9])라면 4.5가 나올것이다. 우리는 이 helper함수를 통해 각 value가 scale된 값을 쉽게 적용할 수 있다. 위에서 만들어둔 yScale함수를 적용해 보자.

svg
  .append('g')
  .selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', (d, i) => i * WIDTH * 2)
  .attr('y', d => yScale(d.temp)) // apply scale
  .attr('height', d => HEIGHT - yScale(d.temp)) // apply scale
  .attr('width', WIDTH)
  .attr('fill', 'blue')

scale을 적용하자 bar의 높이가 알아볼 수 있을만큼 커졌다. 물론 bar에 binding된 값은 왜곡되었지만 아까 말했던 경향성은 충분히 알 수 있게 되었다. 그렇다면 x축도 scale을 적용해보자.

const parseTime = d3.timeParse('%Y%m%d') // 1
data.forEach(d => {
  d.date = parseTime(d.date) // 2
})

const xScale = d3
  .scaleTime()
  .domain(d3.extent(data.map(d => d.date)))
  .range([margin.left, SVG_WIDTH - margin.right])
  1. 원래 데이터의 time format을 통해 시간데이터를 파싱해 주는 함수를 만든다.
  2. FP를 좋아하는 한 사람으로서는 보고있기 힘든 코드지만 기존의 데이터 배열에서 date부분을 파싱하여 d3에서 사용 가능한 형태로 반환한다.

다 되었다면 마찬가지 방법으로 x 좌표도 스케일을 적용해 보자.

svg
  .append('g')
  .selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', d => xScale(d.date)) // apply scale
  .attr('y', d => yScale(d.temp))
  .attr('height', d => HEIGHT - yScale(d.temp))
  .attr('width', WIDTH)
  .attr('fill', 'blue')

3. Axis

Scale을 통해 어느 정도 시인성있는 모습으로 그래프가 바뀌었다면 이제는 사람들이 좀 더 쉽게 그래프를 해석할 수 있게 흔히 말하는 범례를 추가해 주어야 한다. 이제는 어느정도 예상할수 있을텐데 d3에는 이미 준비되어있다.

// create x axis
const xAxis = d3.axisBottom(xScale).tickFormat(d3.timeFormat('%Y-%m-%d'))

// append
svg
  .append('g')
  .call(xAxis)
  .attr('transform', `translate(0, ${HEIGHT - maring.bottom})`)

그런데 이상한 일이 벌어졌다. 아마 그래프와 범례가 겹쳐서 보일 것이다. 처음 그래프를 그릴때 이 범례를 생각하지 않고 사이즈를 계산했기 때문에 벌어지는 일이다. yScale과 y좌표 값을 조금 수정해 주자.

const yScale = d3
  .scaleLinear()
  .domain(d3.extent(data.map(d => d.temp)))
  .range([HEIGHT - margin.top - margin.bottom, margin.bottom])

svg
  .append('g')
  .selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', d => xScale(d.date))
  .attr('y', d => yScale(d.temp))
  .attr('height', d => HEIGHT - margin.bottom - yScale(d.temp))
  .attr('width', WIDTH)
  .attr('fill', 'blue')

간단하다! 마찬가지 방법으로 y축의 범례도 추가해 주자.

const yAxis = d3.axisLeft(yScale)

svg.append('g').call(yAxis).attr('transform', `translate(${margin.left}, 0)`)

4. Conclusion

조금 복잡하지만, 이제 그럴듯 한 모양의 그래프를 그릴 수 있게 되었다. 우리는 다음과 같은 순서로 그래프를 그렸다.

  1. 그래프를 그릴 수 있는 ‘틀’을 준비했다.
  2. 데이터를 보고 효율적으로 데이터를 보여줄 수 있는 그래프의 ‘모양새’를 생각했다.
  3. ‘틀’안에서 데이터의 경향성을 효과적으로 보여주기위해 scale을 고민하고, 적용했다.
  4. 데이터를 쉽게 알아볼 수 있게 ‘범례’를 추가했다.

이쯤되면 d3이 많이 익숙해졌을 것이다. 그리고 한 가지 사실을 깨닫게 되었을텐데, 우리가 필요한 것은 거의 d3에 정의가 되어있고 그것을 잘 찾아서 적용하는 방법을 고민해야 한다는 것이다.

다음번 글에는, 간단한 interaction을 비롯하여 그래프를 업데이트하는 방법을 정리해서 공유할 생각이다.


참고


© 2021, Built with Gatsby