ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 코로나19 백신 접종 현황 차트 페이지 만들기 - (7) line chart
    Node.js/예제 2021. 6. 3. 21:28

     

    라인 차트는 기본적으로 컬럼 차트와 유사하고 만드는 방식도 거의 비슷하다. 그러나 컬럼 차트의 목적이 다른 항목들 간 값을 비교하는 것이라면 라인 차트는 하나의 항목에 대해 값의 변화량을 관찰하기 위해 주로 사용된다는 점에서 차이가 있다.

    라인 차트는 시간 경과에 따라 데이터 흐름을 파악하는 데에 유용하다. 또한 여러 개의 라인을 통해 동등한 기준 내 다른 항목들 간 변화량의 차이를 비교할 수도 있다.

    여기에서는 백신 접종자수 비율 상위 5개 국가의 백만명 당 월별 백신 접종 횟수 데이터를 사용하였다. 국가 수가 많다보니 차트 형태를 단순화하기 위해 컬럼 차트에서 사용한 기준으로 상위 5개국으로 대상을 제한하였으며, 일별 데이터를 사용할 경우 데이터가 지나치게 많아 차트가 복잡해지거나 보여줄 기간을 제한하게 되므로 월별 데이터로 집계하여 데이터를 단순화하였다.

     

    여러 조건과 그룹화를 필요로 하므로 쿼리가 복잡해진다. 먼저 1) 백신 접종자수 비율을 기준으로 상위 5개국을 알아내야 하며, 2) 데이터를 월별로 나누어야 한다. 3) 그리고 각 월별 데이터를 다시 국가별로 나누고, 4) 그 데이터를 그룹별로 각각 더해서 국가별/월별 합계를 구해야 한다.

    엘라스틱서치 SQL은 일반 SQL에서 사용 가능한 복잡한 조건이 제대로 먹히지 않는 경우가 종종 있다. group by 조건 다중으로 사용되는 경우 의도한 대로 데이터가 조회되지 않을 수 있기 때문에 이번 차트에서는 Query DSL을 사용하여 데이터를 조회하였다.

    router.post("/line", async (req, res) => {
      try {
        const cQuery = `
          SELECT country, MAX(people_fully_vaccinated_per_hundred) as value
          FROM "country-vaccinations"
          WHERE people_fully_vaccinated_per_hundred > 0
          AND people_fully_vaccinated > 100000
          GROUP BY country
          ORDER BY value DESC
          LIMIT 5
        `;
    
        const cData = await getDataUsingSql(cQuery);
        const countries = cData.rows.map(el => el[0]);
        const condData = countries.map(el => {
          return {
            "match": {
              "country": el
            }
          }
        });
    
        const query = {
          "query": {
            "bool": {
              "should": condData
            }
          },
          "aggs": {
            "group_by_month": {
              "date_histogram": {
                "field": "date",
                "interval": "month"
              },
              "aggs": {
                "group_by_country": {
                  "terms": {
                    "field": "country"
                  },
                  "aggs": {
                    "monthly_vaccinations_per_million": {
                      "sum": {
                        "field": "daily_vaccinations_per_million"
                      }
                    }
                  },
                },
              },
            }
          }
        }
    
        const data = await getDataQdsl("country-vaccinations", query, "agg");
    
        const groupByMonth = data["group_by_month"]["buckets"];
        const dateValue = groupByMonth.map(el => el["key_as_string"].split("-01T")[0]);
    
        const obj = {};
        groupByMonth.forEach(gbCountry => {
          const buckets = gbCountry["group_by_country"]["buckets"];
          buckets.forEach(data => {
            if (obj[data.key]) obj[data.key].push(data["monthly_vaccinations_per_million"]["value"]);
            else obj[data.key] = [data["monthly_vaccinations_per_million"]["value"]];
          })
        });
    
        for (let key in obj) {
          if (obj[key].length !== dateValue.length) {
            obj[key].unshift(0);
          }
        }
    
        const vaccinationData = [];
        for (let key in obj) {
          const dataObj = {};
          dataObj["name"] = key;
          dataObj["data"] = obj[key];
          vaccinationData.push(dataObj);
        }
    
        const resultData = {
          dateValue,
          vaccinationData
        }
    
        res.json(resultData);
      } catch (err) {
        console.error(err);
      }
    });

    먼저 SQL문을 사용하여 백신 접종자수 비율 상위 5개국을 조회한 후, 이 데이터를 Query DSL에서 사용하기 위해 condData 변수에 { match: { country: 국가명 } } 의 형태를 가진 데이터의 배열로 변환하여 저장하였다.

    그룹화를 위한 Query DSL은 다음과 같이 작성하였다. 먼저, query.bool.should에 위에서 구한 condData 배열을 값으로 전달하였다. SQL의 where절 처럼 작동하여, query.bool.should에서 country 컬럼이 국가명 값과 일치하는지(match)를 확인한다. 

    그룹화는 aggs로 설정한다. 첫 번째 그룹화는 월별로 데이터를 묶는 것이다. aggs 다음에 사용하는 group_by_month는 조회 데이터의 alias이므로 임의로 작성해주면 된다. 그러면 조회된 데이터의 이름이 group_by_month로 지정된다. 

    그룹화하고자 하는 대상이 date인 경우 date_histogram을 사용한다. field의 값은 date 형식을 가진 컬럼의 컬럼명으로 지정하고, interval에서는 그룹화할 시간 기준을 지정한다. 월별 데이터를 구해야 하므로 month로 지정하였다.

    그룹화 내에서 또 그룹화가 필요한 경우 상위 그룹 내에서 aggs를 다시 지정하면 된다. 2단계 그룹화에서는 alias를 group_by_country로 지정하였다. 키워드를 기준으로 그룹화를 한다면 terms를 사용한다. terms 내에서 field의 값으로 country 컬럼을 지정하였다.

    최종적으로 집계 함수의 결과값을 구한다. aggs를 두 번째 그룹화 내에서 한번 더 사용한다. 결과값의 alias는 monthly_vaccinations_per_million으로 지정하였다. sum 집계를 하기 위해 다음 필드명으로 sum을 지정하고 sum의 대상이 될 컬럼을 그 다음에서 field의 값으로 지정한다.

     

    getDataQdsl 함수로 조회하면 다음과 같은 형태로 출력된다. 중첩되어있는 object의 경우 단순 console.log로는 자세한 내용 확인이 불가능하다. util 모듈을 사용하여 console.log(util.inspect(data, { showHidden: false, depth: null })); 로 조회하면 모든 내용을 출력할 수 있다(util 모듈은 기본 제공 모듈이므로 설치 없이 require하여 사용 가능).

    1차 그룹화의 alias로 지정한 group_by_month가 전체 object의 키로 지정되었다. 그 값으로 buckets라는 이름의 배열이 들어있다. buckets 배열은 2차 그룹화(group_by_country)에 대한 object와 그 키(날짜 데이터)로 구성되어 있고, 각 object는 마찬가지로 buckets라는 배열을 지닌다. 두 번째 buckets의 각 데이터에는 2차 그룹화의 키와, 각 키의 document 수, sum으로 집계한 값이 들어있다.

     

    highchart의 라인차트는 x축 카테고리를 배열로 전달해주어야 한다. 따라서 월별 키값(group_by_month의 key_as_string)을 "연도-월" 형태로 파싱하여 배열에 추가하였다. 이 데이터를 dateValue라는 이름의 변수에 저장하였다.

    차트를 그리기 위해 { name: 항목값, data: [값이 담긴 배열] }의 형태가 담긴 배열을 전달해준다 이 배열의 길이가 곧 라인의 개수가 된다. name의 값은 1개의 라인의 이름이고, data의 값은 x축의 항목에 대응되는 y축의 값이다. 따라서 이 배열의 길이는 x축에서 사용되는 배열의 길이와 같아야 한다. 이 데이터는 vaccinationData라는 이름의 변수에 저장하였다. vaccinationData를 완성하기 위한 코드가 길고 복잡해보이지만 단순히 쿼리 조회 결과를 순회하면서 새 객체에 저장한 결과일 뿐이므로 과정이 아주 복잡하지는 않다.

     

    basicline.js

    function drawLineChart() {
      $.ajax({
        url: "line",
        type: "POST",
        dataType: "json",
        success: function (result) {
          const { dateValue, vaccinationData } = result;
          Highcharts.chart('basicline', {
    
            title: {
              text: 'Monthly Vaccinations Per Million, 2020.12-2021.05<br>(Fully Vaccinated Per Hundred TOP5 Countries)'
            },
            credits: {
              enabled: false
            },
            exporting: {
              enabled: false
            },
            subtitle: {
              text: ''
            },
    
            yAxis: {
              title: {
                text: ''
              }
            },
    
            xAxis: {
              categories: dateValue
            },
    
            legend: {
              layout: 'vertical',
              align: 'right',
              verticalAlign: 'middle'
            },
    
            tooltip: {
              formatter: function () {
                return this.point.category + '<br><b>' + this.series.name + ': </b><b>' + addComma(this.point.y) + '</b>';
              }
            },
    
            series: vaccinationData,
    
            responsive: {
              rules: [{
                condition: {
                  maxWidth: 500
                },
                chartOptions: {
                  legend: {
                    layout: 'horizontal',
                    align: 'center',
                    verticalAlign: 'bottom'
                  }
                }
              }]
            }
          });
        },
        error: function (request, status, error) {
          console.error(error);
        }
      });
    }

    전달받은 데이터의 변수명을 그대로 사용하였다. dateValue는 xAxis.categories의 값으로 지정한다. 그리고 vaccinationData는 series의 값으로 지정한다.

     

Designed by Tistory.