티스토리 뷰

D3로 데이터를 조작하다 보면, 이게 마치 SQL을 다루는 듯한 느낌이 든다. 그도 그럴 것이, 주어진 데이터를 가공하여 원하는 데이터만을 뽑아내는 과정도 비슷하고, select 같은 문법도 주로 쓰이기 때문이다.

이번 시간에는 주어진 csv파일을 가공하여 2차원 그래프에 나타내 위와 같은 최종본을 완성시킬 것이다. 해당 그림은 예시이고, 필터링하는 부분에서 요구하는 조건이 살짝 다르다. csv파일은 country_code, country_name, year, value의 키 값으로 이루어진 딕셔너리를 요소로 가진 배열을 담고 있다.
 

학습 목표

1. 국가별로 모아서(group) value의 최댓값과 최솟값을 차이를 구한다.
2. 최댓값과 최솟값의 차이가 큰 상위 다섯 국가를 고른다.
3. 년도별로 이들의 value를 한 그래프 안에 line으로 나타낸다. (x축은 연도, y축은 value)
4. 라인 별 다른 색상을 이용하고, 라인의 맨 우측에는 해당하는 국가의 이름을 적어준다.
 
뭐가 됐든 데이터를 가공해야하기 때문에 현재 데이터 구조앞으로 만들어야 할 데이터 구조를 파악하는 것이 중요하다.
 
현재 데이터 구조는 다음과 같다. (1)

// 1.
{
	{country_code: '', country_name: '', year: '', value: ''},
    {country_code: '', country_name: '', year: '', value: ''},
    {country_code: '', country_name: '', year: '', value: ''},
}

 
중간 과정에서 상위 5개 국가의 이름을 알기 위해선 다음 구조도 필요하겠다. (2)

// 2. 'dif'는 해당 국가의 최댓값과 최솟값의 차를 의미한다.
{
	{ country_name: '', dif: '' },
    { country_name: '', dif: '' },
    { country_name: '', dif: '' },
}

 
그리고 우리가 최종적으로 만들어야 할 데이터 구조는 다음과 같다. (3)

{
	{ 
    	country_name: '',
        values: {	
                    { year: '', value: '' },
                    { year: '', value: '' },
                    { year: '', value: '' },
                }
    }
}

 

(1) -> (2)

d3.csv('파일명.csv')
	.then(data => {
        const countries = data.map(d => {
            return {
                country_code: d.country_code,
                country_name: d.country_name,
                year: +d.year,
                value: +d.value
            }
        });

        const countryData = Object.values(countries);
        const groupedData = d3.group(countryData, d => d.country_name);
        const countryDifs = Array.from(groupedData, ([country_name, data]) => ({
        country_name,
        dif: d3.max(data, d => d.value) - d3.min(data, d => d.value)
        }));
        const topCountries = countryDifs.sort((a, b) => b.dif - a.dif).slice(0, 5);
    }

year과 value를 숫자 형태로 바꾸는 건 저번 시간에 했으니 넘어가도록 하자.
countryData는 countries라는 매핑된 데이터를 딕셔너리를 요소로 가진 배열로 변환한다.
이어서 group() 메서드를 통해 데이터의 country_name으로 그룹화를 한다. group() 메서드는 딕셔너리를 반환하기 때문에 구조는 다음과 같다.

groupedData = { 'k': {
						{country_code: '', country_name: 'k', year: '', value: ''},
                        {country_code: '', country_name: 'k', year: '', value: ''},
                        {country_code: '', country_name: 'k', year: '', value: ''},
					},
           	}

다음으로는 countryDifs라는 배열을 만들 차례다. 배열로 변환해 주는 것이기 때문에 Object.values 써야 한다고 생각하겠지만, 해당 배열에서는 groupedData 딕셔너리의 value값에서 value의 최댓값, 최솟값을 찾아 그 차를 구해야 하므로 Array.from()의 두 번째 매개변수를 이용하여 원하는 값으로 매핑 후 할당을 해야 할 것이다. 하고 나면 countryDifs의 구조는 다음과 같다.

countryDifs = {	{country_name: '', dif: ''},
				{country_name: '', dif: ''},
                {country_name: '', dif: ''},
            }

마지막으로 해당 값을 dif의 내림차순으로 정렬, 상위 5개의 값만 뽑아내어 topCountries에 할당한다.
 

(2) -> (3)

중간과정으로 만든 topCountries는 country_name과 dif를 키로 가지고 있는 딕셔너리의 배열이다. dif는 사실상 상위 5개 국가를 구하기 위해서만 필요했고, 이들 국가를 구했으니 다시 국가별 연도별 value 모두 담고 있는 배열로 다시 매핑해주어야 한다.

const timeSeriesData = topCountries.map(({ country_name }) => ({
        country_name,
        values: countryData
            .filter(d => d.country_name === country_name)
            .map(d => ({ year: d.year, value: d.value })),
        }));

마지막으로 선언하는 timeSeriesData는 topCountries를 매핑하는데, topCountries의 나라 이름과 countryData의 나라 이름이 일치하는 것으로 필터링, 이어서 그 중 year, value값만 가져오도록 매핑한다. 값을 비교할 때 '=='가 아닌 '==='를 쓰는 이유는 두 값의 타입까지 일치하는지 확인하기 위함이고, 이 편이 더욱 안전하게 데이터를 가져올 수 있다.
 

svg 크기 설정 및 x, y 축 그리기

총 그래프를 그릴 크기는 900 x 600px이고, 축을 그릴 공간과 선 데이터 옆 국가 이름을 쓸 공간을 남겨놔야하기에 마진을 설정해준다.

const margin = { top: 5, right: 100, bottom: 50, left: 50 };
const width = 900 - margin.left - margin.right;
const height = 600 - margin.top - margin.bottom;

const svg = d3.select('body')
      .append('svg')
      .attr('width', width + margin.left + margin.right)
      .attr('height', height + margin.top + margin.bottom)
      .append('g')
      .attr('transform', `translate(${margin.left}, ${margin.top})`);

// x축 스케일 설정
const xScale = d3.scaleTime()
  .domain(d3.extent(timeSeriesData[0].values, d => d.year))
  .range([0, width]);

// y축 스케일 설정
const yScale = d3.scaleLinear()
  .domain([0, d3.max(timeSeriesData, d => d3.max(d.values, d => d.value))])
  .range([height, 0]);

// x축 그리기
svg.append('g')
  .attr('transform', `translate(0,${height})`)
  .call(d3.axisBottom(xScale).tickFormat(d3.format('d')));

// y축 그리기
svg.append('g')
  .call(d3.axisLeft(yScale));

'g'는 좌표를 옮겨주는 녀석이다. 그래프의 축을 그려넣기 위해 왼쪽과 위쪽에 마진을 두었다.
xScale과 yScale은 말 그대로 크기이다. 다만 yScale의 range가 height부터 0인 이유는 y축의 눈금이 아래서부터 위로 써지기 때문이다.
x, y축은 해당 위치만큼 이동하여 axisBottom, axisLeft 메서드를 통해 그려주면 된다.
 

선 그래프 그리기, 색상 설정

이제 축까지 그렸으니 그래프를 그려보자. 선으로 그려줄 건데, 우리는 미리 정해놓은 x, y scale에 맞춰 그릴 것이기 때문에 해당 좌표는 x, y scale에 따를 것이다. 생상 또한 상위 5개 국가이므로, 5개의 색을 국가의 이름 별로 하나씩 매칭시켜준다.

// line 함수 설정
const line = d3.line()
  .x(d => xScale(d.year))
  .y(d => yScale(d.value));
  
// 색상 스케일 설정
const cScale = d3.scaleOrdinal()
  .domain(timeSeriesData.map(d => d.country_name))
  .range(['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00']);

line에 데이터를 매개변수로 쓴다면, 데이터의 key 'year'의 값은 x좌표, 'value'의 값은 y좌표에 해당될 것이다.

// 국가별로 선 그리기
svg.selectAll('path')
  .data(timeSeriesData)
  .enter()
  .append('path')
  .attr('class', 'line')
  .attr('d', d => line(d.values))
  .style('stroke', d => cScale(d.country_name))
  .style('stroke-width', '2px')
  .style('fill', 'none');

 

해당하는 선 옆에 나라 이름 적기

// country name을 표시할 g 요소 추가
svg.selectAll('country-name')
.data(timeSeriesData)
.enter()
.append('g')
.attr('class', 'country-name')
.append('text')
.text(d => d.country_name)
.attr('x', width) // x 위치
.attr('y', d => yScale(d.values[d.values.length - 1].value)) // y 위치
.style('font-size', '12px')
.style('font-weight', 'regular')
.style('fill', d => cScale(d.country_name))
.style('text-anchor', 'start')
.attr('alignment-baseline', 'bottom');

마지막으로 나라 이름을 적 줄 g요소를 추가하고, 나라 이름을 적어준다(text). y값은 데이터의 마지막 값의 위치를 가져오면 된다.

전체 코드

d3.csv('data/life_expectancy_by_country.csv')
    // country_code,country_name,year,value
	.then(data => {
        const countries = data.map(d => {
            return {
                country_code: d.country_code,
                country_name: d.country_name,
                year: +d.year,
                value: +d.value
            }
        });

        // countries 딕셔너리를 배열로 변환합니다.
        const countryData = Object.values(countries);
        console.log(countryData);
        // country_code로 그룹화하여 각 국가별로 최대값과 최소값을 계산합니다.
        const groupedData = d3.group(countryData, d => d.country_name);
        console.log(groupedData);
        const countryStats = Array.from(groupedData, ([country_name, data]) => ({
        country_name,
        dif: d3.max(data, d => d.value) - d3.min(data, d => d.value)
        }));

        // 최댓값과 최솟값의 차이로 정렬하여 상위 5개를 선택합니다.
        const topCountries = countryStats
        .sort((a, b) => b.dif - a.dif)
        .slice(0, 5);
        console.log(topCountries);
        // 각 국가별로 연도별 수치값의 시계열 데이터를 생성합니다.
        const timeSeriesData = topCountries.map(({ country_name }) => ({
        country_name,
        values: countryData
            .filter(d => d.country_name == country_name)
            .map(d => ({ year: d.year, value: d.value })),
        }));

        console.log(timeSeriesData);
        drawLineChart(timeSeriesData);
	})
 	.catch(error => {
        console.error(error);
        console.error('Error loading the data');
});


function drawLineChart(timeSeriesData) {
    // svg 요소 크기 설정
    const margin = { top: 5, right: 100, bottom: 50, left: 50 };
    const width = 900 - margin.left - margin.right;
    const height = 600 - margin.top - margin.bottom;
  
    // x축 스케일 설정
    const xScale = d3.scaleTime()
      .domain(d3.extent(timeSeriesData[0].values, d => d.year))
      .range([0, width]);
  
    // y축 스케일 설정
    const yScale = d3.scaleLinear()
      .domain([0, d3.max(timeSeriesData, d => d3.max(d.values, d => d.value))])
      .range([height, 0]);
  
    // 색상 스케일 설정
    const cScale = d3.scaleOrdinal()
      .domain(timeSeriesData.map(d => d.country_name))
      .range(['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00']);

    // line 함수 설정
    const line = d3.line()
      .x(d => xScale(d.year))
      .y(d => yScale(d.value));
  
    // svg 요소 생성
    const svg = d3.select('body')
      .append('svg')
      .attr('width', width + margin.left + margin.right)
      .attr('height', height + margin.top + margin.bottom)
      .append('g')
      .attr('transform', `translate(${margin.left}, ${margin.top})`);
  
    // 국가별로 선 그리기
    svg.selectAll('path')
      .data(timeSeriesData)
      .enter()
      .append('path')
      .attr('class', 'line')
      .attr('d', d => line(d.values))
      .style('stroke', d => cScale(d.country_name))
      .style('stroke-width', '2px')
      .style('fill', 'none');
  
    // x축 그리기
    svg.append('g')
      .attr('transform', `translate(0,${height})`)
      .call(d3.axisBottom(xScale).tickFormat(d3.format('d')));
  
    // y축 그리기
    svg.append('g')
      .call(d3.axisLeft(yScale));

    // country name을 표시할 g 요소 추가
    svg.selectAll('country-name')
    .data(timeSeriesData)
    .enter()
    .append('g')
    .attr('class', 'country-name')
    .append('text')
    .text(d => d.country_name)
    .attr('x', width) // x 위치
    .attr('y', d => yScale(d.values[d.values.length - 1].value)) // y 위치
    .style('font-size', '12px')
    .style('font-weight', 'regular')
    .style('fill', d => cScale(d.country_name))
    .style('text-anchor', 'start')
    .attr('alignment-baseline', 'bottom');
}
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함