d3로 동적인 네트워크 차트 그리기 (1) - D3.js란?

유니·2022년 12월 18일
1

JavaScript

목록 보기
6/9

굉장히 오랜만의 포스팅이네요.
그동안 놀았던건 아니고.. 부스트캠프 일정이 엄청 바빴던터라 못올리고 있었습니다....🥲

paperef.com
제가 부스트캠프 팀프로젝트로 했던 논문 인용관계 시각화 서비스입니다.
이제 자유의 몸이 되었으니, 위 프로젝트에서 논문 데이터를 어떻게 네트워크 차트로 시각화 했는지 그 과정들을 조금씩 기록해 보겠습니다.

D3.js란?

Data-driven-documents. 맞춤형의, 인터랙티브한 charts/maps를 웹에 그리기위해 사용되는 자바스크립트 라이브러리

특징

  • data-driven modification of HTML and SVG elements
    data 기반으로 html, svg element를 그려 낼 수 있습니다.
  • scale functions
    data values ⇒ visual values로 스케일링 하는 함수를 제공합니다.
  • loading and transforming data (e.g. CSV data)
    csv → js array convert 기능을 제공합니다.
  • helpers for generating complex charts such as treemaps, packed circles and networks
    treemaps, packed circles, network graphs and geographic maps 등의 복잡한 차트를 building 할 수 있는 block들을 제공합니다.
  • a powerful transition system for animating between different chart states
    chart states의 변화를 부드러운 애니매이션으로 표현할 수 있다. 요소가 어떻게 나타날지, 이동할지, 사라질지를 제어할 수 있는 강력하고 간편한 transition 기능을 제공합니다.
  • powerful user interaction support, including panning, zooming and dragging
    panning, zooming과 같은 상호작용 기능을 지원합니다.

D3를 사용한 이유

  • D3는 Chart.js나 Highcharts와 같이 이미 만들어진 차트를 제공하는 라이브러리가 아닌, 더 작은 단위의 블럭들을 조합시켜 커스텀 차트를 만드는 저수준 라이브러리입니다.
  • 제가 만들고자 했던 네트워크 차트는 동적으로 데이터가 추가되고, 다양한 사용자 인터랙션이 필요했기에 세부적인 기능을 조절하기 위해서는 D3를 사용하는 것이 적합하다고 판단했습니다.
  • D3가 제공하는 메서드를 이용하면 그래프를 zooming, dragging하는 기능을 아주 쉽게 구현할 수 있습니다.

D3를 사용할때 주의해야 할 점

  • React 베이스에서 D3를 사용해야 했는데, 리액트 렌더링과 d3 렌더링이 같이 일어나면서 각자의 생명주기가 엉켜 사이드이펙트가 발생할 수 있습니다. 어느 시점에 d3에 렌더링을 위임할지, 어떤 state까지 리액트에서 관리할지 잘 고민해서 설계해야 합니다.
  • 매번 네트워크 node와 line의 좌표를 계산하는데 시간이 많이 걸린다고 하는데, 이 계산로직을 어디서, 어떻게 처리할지에 대한 고민이 필요합니다.

다양한 방법이 있겠지만.. 저의 경우는 web-worker를 이용하여 서브스레드에서 node, line 좌표 계산 + 메인스레드에서 렌더링 하는 방식으로 계산로직/렌더링로직을 분리해서 병렬처리하는 방법으로 성능개선을 했습니다. 추후 포스팅에서 좀 더 자세히 다뤄보겠습니다.

svg, D3 찍어먹기

여기서 끝내기는 좀 아쉬워서.. 차트구현 내용은 아니지만,
제가 로딩스피너로 넣었던 위상이 변하는 달모양 spinner 만드는 과정을 알아보며 svg랑도 좀 친해질겸, D3를 찍먹 해봅시다.

svg 하위에서 사용한 태그들

  • <defs>
    추후 사용될 그래픽 객체를 저장해두는 태그

  • <pattern>
    svg 영역을 채울 패턴을 정의하는 태그
    달 표면 그림을 패턴으로 정의하기위해 사용

  • <g>
    svg elements를 그룹화하는 태그

  • <circle>
    원을 그리는 태그

  • <path>
    선을 그리는 태그
    d 속성으로 선의 경로를 표현할 수 있습니다. 이 속성에 다양한 형태의 선을 정의하여 그릴 수 있는데, 저는 제가 사용한 두가지 속성 명령어에 대해서만 설명하겠습니다.
    - M x y
    좌표 x, y로 이동합니다.
    - A rx ry x축 회전각 큰 호 플래그 쓸기 방향 플래그 x y
    현재 좌표와 (x, y) 두 점을 지나면서 x축 반지름rx, y축 반지름ry, x축 회전각만큼 기울어진 타원호를 그리며 이동합니다.
    이 때, 4가지 경우의 수가 생기는데, 이걸 특정해주는게 두개의 플래그입니다.
    큰 호 플래그 - 0: 중심각이 180도 미만(작은 호) 1: 중심각이 180도 이상(큰 호)
    쓸기 방향 플래그 - 0: 음각(반시계방향), 1: 양각(시계방향)

구현방법

<circle cx="50" cy="50" r="49" fill="black" /> <!-- 🌑 -->
<path ref={pathRef} d="..." fill="white" /> <!-- 밝은부분 -->
<circle cx="50" cy="50" r="50" fill="url(#image11)" /> <!-- 반투명한 달 표면 레이어 -->

어두운부분(원)밝은부분(반원 + 타원 or 반원 - 타원)달표면 레이어를 차례로 쌓은다음, path의 d 속성(밝은부분의 궤적)을 100ms마다 변경하는 방법으로 구현했습니다.
우선 달의 밝은부분을 그리기 위해서는 원호, 타원호 궤적 2개가 필요합니다.

원호, 타원호 두개와 쓸기방향 플래그를 잘 바꿔가면서 그려주면 되겠군요.

M x y A rx ry x-axis-rotation large-arc-flag sweep-flag x y A 안쪽타원 rx ry x-axis-rotation large-arc-flag sweep-flag x y

: 고정값들

접선좌표, 바깥쪽 원의 반지름, 안쪽타원의 ry는 고정값이고, 반원과 안쪽타원의 larg-arc-flag는 모두 0으로 고정이 가능합니다.(180도 궤적이니)
따라서, 반원의 sweep-flag, 안쪽타원의 rx, sweep-flag 세가지만 계산해주면 됩니다.

안쪽 타원의 rx 는 r * cos(t) (아래그림참고), 쓸기 방향 플래그만 🌑 -> 🌓 -> 🌕 -> 🌗 이 시점에 잘 바꿔서 그리면 됩니다.

그림실력이 이게 최선이네요... 저 두꺼운 선이 구의 입체면이라고 생각해주세요.

최종적으로, path를 계산하는 함수는 다음과 같이 만들어졌습니다.

const calculateMoonLightPath = (radian: number) => {
  return `M 50 0
			A 50 50 0 0 ${Math.floor(radian / Math.PI) % 2} 50 100
			A ${50 * Math.cos(radian)} 50 0 0 ${Math.floor((radian / Math.PI) * 2) % 2} 50 0`;
};

코드 전문

import * as d3 from 'd3';
import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import theme from '../style/theme';

const calculateMoonLightPath = (radian: number) => {
  return `M 50 0
			A 50 50 0 0 ${Math.floor(radian / Math.PI) % 2} 50 100
			A ${50 * Math.cos(radian)} 50 0 0 ${Math.floor((radian / Math.PI) * 2) % 2} 50 0`;
};

const MoonLoader = () => {
  const pathRef = useRef<SVGPathElement>(null);

  useEffect(() => {
    let radian = Math.PI;
    const timer = d3.interval(() => {
      d3.select(pathRef.current).attr('d', calculateMoonLightPath(radian));
      radian += Math.PI / 10;
    }, 100);
    return () => timer.stop();
  }, []);

  return (
    <MoonContainer>
      <svg width="50" height="50" viewBox="0 0 100 100" style={{ filter: 'url(#inset-shadow)' }}>
        <defs>
          <pattern id="image11" x="0" y="0" patternUnits="userSpaceOnUse" height="100" width="100">
            <image x="0" y="0" height="100" width="100" xlinkHref="https://www.icalendar37.net/lunar/api/i.png"></image>
          </pattern>
        </defs>
        <g>
          <circle cx="50" cy="50" r="49" stroke="0" fill={theme.COLOR.primary4} />
          <path ref={pathRef} fill={theme.COLOR.offWhite} />
          <circle cx="50" cy="50" r="50" strokeWidth="0" fill="url(#image11)" />
        </g>
      </svg>
    </MoonContainer>
  );
};

const MoonContainer = styled.div`
  text-align: center;
`;

export default MoonLoader;
  • d3.select(selector)
    selector가 string이면 해당 selector와 매칭되는 첫번째 element와 매칭되며, string이 아니면 파라미터 그 자체(여기서는 path 돔요소)와 매칭되는 Selection을 반환합니다.

  • Selection.attr("d", string)
    선택요소의 d 속성을 변경합니다.

  • d3.interval(callback, delay)
    주어진 delay시간마다 함수를 반복해서 실행합니다.
    callback : (elapsed) ⇒ void;
    delay : callback을 수행시킬 interval time.

참고) 달의 위상은 ← 방향으로 변해야합니다. 거꾸로 돌리지 않게 조심.... (이거 때문에 유튜브 중등과학 시청함)

위의 방법으로 리액트 + D3코드로 path를 100ms마다 변형시켰습니다

그럼.. 잘 돌아갑니다 ^^

profile
추진력을 얻는 중

2개의 댓글

comment-user-thumbnail
2022년 12월 21일

달 모양 로더를 구현하기 위한 과정과 결과가 모두 아름답네요,,

1개의 답글