header wave

Post

프로젝트) 대한민국 지역별 암환자 dashboard 프로젝트

2022-09-20 AM 07/12
#react
#d3
#project
#javascript

공공데이터 포털에서 시각화할 데이터를 찾던 중, 대한민국 지역별 암환자 (남/여)의 데이터가 있어서 dashboard로 현황을 시각화해보려 한다.

csv파일 형식으로 올라와 있어서, git gist를 통해 csv 파일을 업로드 했다.

https://gist.github.com/himchan94/d6578bbd50155664c2b070fa5ece38aa

korean_cancer korean_cancer. GitHub Gist: instantly share code, notes, and snippets. gist.github.com

img.png

View

뷰는 여러 다음과 같은 형식으로 계획했다.

img.png

  1. 사이드바에는 추후에 다른 대시보드도 추가하기 위해 제작했다.

  2. d3로 만들어야 할 것들은 map, piechart, barchart 세가지로 구성된다.

버전관리

이번 프로젝트에서는 기능 및 컴포넌트 제작 후 commit을 통해 버전을 저장하며 진행할 계획이다.

그리고 추가기능 및 에러수정에서는 branch를 통해 문제 해결후 merge를 통해서 버전을 관리해볼 계획이다.

제작과정

1) 뷰 만들기

styled-components를 사용해서 뷰를 만들었다.

컴포넌트는 헤더, 사이드바를 구성하는 요소는 component 폴더에, page 요소에는 각종 데이터가 들어갈 페이지를 넣었다.

리액트의 장점은 컴포넌트의 재사용성이다.

현재, 헤더, 사이드바, 페이지로 크게 조각을 냈지만,

더 작은 단위로  ex) text 컴포넌트, div를 사용한 wrapper 컴포넌트 등으로 쪼개면 활용성이 증가될 것이다.

2) 데이터 불러오기

시각화 프로젝트에서 가장 중요하다고 생각하는 것은 데이터를 불러오는 것이라고 생각한다. 이번 프로젝트에서 사용할 데이터는 지역별 암환자 수(csv), 한국지도(geoJson)다.

d3에서 csv, json파일을 내려받아 사용할 수 있도록 디코딩해주는 기능을 제공한다. (d3.csv, d3.json)

이를 사용해서 리액트 커스텀 훅을 만들어서, 데이터를 사용했다.

암환자 데이터 custom hook

import { useState, useEffect } from "react";
import { csv } from "d3";

const csvUrl =
  "https://gist.githubusercontent.com/himchan94/d6578bbd50155664c2b070fa5ece38aa/raw/c20e0762c34f6a7eaa2ce82e39b84ba2cb469635/cancer.csv";

export const useCancerData = () => {
  // state 초기값 지정
  const [data, setData] = useState(null);

  // csv 파일을 디코딩했을 때 male, female 암환자 수가 string 형태로 나와 number로 형변환
  // total이란 키 값에 male + female 값을 세팅하는 함수
  const row = (d) => {
    d["male"] = +d["male"];
    d["female"] = +d["female"];
    d["total"] = d["male"] + d["female"];
    return d;
  };

  // 데이터 콘솔 확인
  if (data) console.log(data);

  // useEffect를 통해서 렌더링 이후(처음에는 null 값을 반환), 새로운 값을 반환
  useEffect(() => {
    // csv(url, 콜백함수)를 통해 데이터를 세팅
    csv(csvUrl, row).then((d) => {
      // 데이터를 내림차순으로 정렬하기 위해 sort 메서드를 사용
      const sorted = d.sort((a, b) => b.total - a.total);

      // state 값을 내려받은 데이터로 변경
      setData(sorted);
    });
  }, []);

  return data;
};

지도 데이터 custom hook

import React, { useState, useEffect } from "react";
import { json } from "d3";
import { feature } from "topojson";

const jsonUrl =
  "https://gist.githubusercontent.com/himchan94/801fc9f88b1a86d4667c6a50f8205f2c/raw/5f993a5a59c8ca21074096560ae0076f30075297/SouthKoreaMap.json";

// geoJson 파일에서 한반도 column 선택
const geoColumn = "skorea_provinces_2018_geo";

export const useMapData = () => {
  const [data, setData] = useState(null);

  //data 콘솔로 확인
  if (data) console.log(data);

  useEffect(() => {
    json(jsonUrl).then((topology) => {
      // console.log("topo", topology);

      //topojson 라이브러리의 feature 기능을 통해 geoJson  파일형식을 topoJson 형태로 변경한다.
      //geoJson 형식보다, topoJson이 더욱 가볍다.

      // state 변경
      setData({
        land: feature(topology, topology.objects[geoColumn]),
      });
    });
  }, []);

  return data;
};

3) 데이터 플로우

본격적으로 시각화 데이터를 사용한 차트 컴포넌트들을 만들기 전에 데이터의 흐름에 대해서 생각해봤다.

각자의 컴포넌트에서 커스텀훅을 사용해서 데이터를 불러오는 방법도 있겠지만, 그러면 무의미한 렌더링이 반복될 수 있다.

따라서 모든 차트 컴포넌트가 모이는 페이지 컴포넌트에서 커스템 훅을 사용해서 모든 데이터를 받아온 뒤

props 형태로 차트 컴포넌트에 분할해주는 데이터 흐름의 생각했다.

img.png

4) 지도 차트 만들기

d3 document를 참고해서 지도를 그렸다.

import { geoPath, geoMercator, geoBounds, geoCentroid, geoDistance } from "d3";

const width = 100;
const height = 100;

const projection = geoMercator().translate([width / 2, height / 2]);
const path = geoPath(projection);

export const Marks = ({ mapdata: { land }, tooltip, setCity, city }) => {
  // geoBounds를 통해 최소 / 최대의 위도 경도를 배열로 나타내준다 (배율확대를 위해서 필요하다)
  const bounds = geoBounds(land);

  //  geoCentroid를 통해 지도의 정중앙 위도 경도를 배열로 나타내준다.
  const center = geoCentroid(land);

  // 최대 최소의 위도 경도 사이의 거리를 구한다.
  const distance = geoDistance(bounds[0], bounds[1]);

  // 지도 전체 크기(heigt)에 대한 거리별 비율에 곱을 하여 배율을 설정한다. (현재 1배율)
  const scale = (height / distance / Math.sqrt(2)) * 1;

  //  배율을 적용하고 지도를 정중앙에 위치 시킨다.
  projection.scale(scale).center(center);

  return (
    <>
      <g className="marks">
        {land.features.map((feature, idx) => {
          return (
            <g key={idx}>
              {" "}
              <path
                className="land"
                d={path(feature)}
                onMouseOver={(e) => {
                  tooltip.current.style.visibility = "visible";
                }}
                onMouseMove={(e) => {
                  tooltip.current.style.top = `${e.pageY - 50}px`;
                  tooltip.current.style.left = `${e.pageX - 50}px`;
                  tooltip.current.innerHTML = `${feature.properties.name}`;
                }}
                onMouseLeave={(e) => {
                  tooltip.current.style.visibility = "hidden";
                }}
                onClick={(e) => {
                  setCity(feature.properties.name);
                }}
                fill={city === feature.properties.name ? "red" : "gainsboro"}
              />
            </g>
          );
        })}
      </g>
    </>
  );
};

추가적으로 마우스가 지도 위에 올라갔을 때 해당 지역을 보여주는 툴팁을 만들었다.

이때 useRef 훅을 통해서 Tooltip 돔요소를 리액트에서 선택하여 조작했다.

export const KoreanMap = ({ mapdata, setCity, city }) => {
  const tooltip = useRef();

  if (!mapdata) {
    return <pre>Loading...</pre>;
  }

  return (
    <>
      <h1>{city}</h1>
      <Tooltip ref={tooltip} />
      <svg
        width="100%"
        height="100%"
        viewBox="0 0 100 100"
        perserveaspectratio="none"
      >
        <Marks
          mapdata={mapdata}
          tooltip={tooltip}
          setCity={setCity}
          city={city}
        />
      </svg>
    </>
  );
};

const Tooltip = styled.div`
  visibility: hidden;
  position: absolute;
  background-color: red;
  font-size: 1rem;
`;

5) Bar 차트

bar 차트는 scaleBand()를 통해서 그래프의 x축을 지정할 수 있었다. (x축은 지역을 나타난다.)

하지만 y축인 지역별 총 환자 수를 나타낼 때 문제가 발생했다.

처음에는 y축을 linerScale을 사용해서 나타냈다.

지역별 암환자의 최대값의 약 5만명인 반면 최소값은 1000명도 안 되었다. 최대값과 최소값의 차이가 크니 linear하게 y축 높이를 나타내니, 최소값의 지역은 높이가 아예 안 보였다.

그래서 대체 방안으로, lirearScale이 아닌 logScale로 y축을 표현했다.

img.png

logScale

(로그로 표현하면 값이 아무리 커지더라도, 포인트 몇개로 표현할 수 있는 장점이 있다.)

img.png

Bar chart

로그스케일을 사용하니 위처럼 그래프가 그려졌다. 아쉬운 점은 tick 사이의 간격이 logScale이라 일정하지 않아서 막대의 높이만으로 값을 추정할 수 없는 점이 아쉬웠다.

그래서 대체방안으로 막대 위에 mouse가 hover되면 툴팁을 통해서 값을 읽을 수 있도록 했다.

6) Pie 차트

pie 차트는 해당 지역 내에서의 남/여 암환자 수를 나타내여 했다.

현재 커스텀 훅을 통해서 받은 데이터는 {지역, 남자 암환자 수, 여자 암환자 수} 포맷이다.

pie 차트로 쉽게 그리기 위해서는, 데이터 포맷을 약간 바꿔줘야 했다.

{남자 : { 지역, 암환자 수}, 여자: {지역, 암환자 수}} 같은 포맷으로 변경한다면 같은 지역 내의 남/여 암환자를 구해서 볼 수 있게 되며, Pie차트를 그리기 쉽게 된다