d3로 동적인 네트워크 차트 그리기 (2) - D3.js - selections & join

유니·2022년 12월 20일
2

JavaScript

목록 보기
7/9
post-thumbnail

d3-selections

selections는 data 기반의 DOM 변형객체입니다.
d3.select, d3.selectAll을 사용하면 html과 svg 요소를 선택하는 selection 객체를 반환합니다.

d3.select(selector)

selector에 해당하는 첫번째 element와 매칭됩니다.

d3.selectAll(selector)

selector에 해당하는 모든 element와 매칭됩니다.

여기서 selector는 css에서의 selector string 혹은 해당 파라미터 자체를 의미합니다.

d3.select() source

import {Selection, root} from "./selection/index.js";

export default function(selector) {
  return typeof selector === "string"
      ? new Selection([[document.querySelector(selector)]], [document.documentElement])
      : new Selection([[selector]], root);
}

d3.selectAll() source

export default function(selector) {
  return typeof selector === "string"
      ? new Selection([document.querySelectorAll(selector)], [document.documentElement])
      : new Selection([array(selector)], root);
}

⇒ 코드 내부적으로 string이 아닌 파라미터가 들어오면 그 파라미터 자체를 select

따라서 리액트에서 다음과 같이 useRef를 이용하여 돔요소를 select할 수 있습니다.

function App() {
  const svgRef = useRef();

  useEffect(() => {
    const p = d3
      .select(svgRef.current)
  }, []);

  return (
    <div>
      <svg ref={svgRef} />
    </div>
  );
}

join

selection.join(enter[, update][, exit])
selection 객체에서 사용할 수 있는 메서드로, data를 기반으로 element를 추가/수정/변경합니다.

join 함수를 사용하는 방법은 두가지가 있는데, 1️⃣파라미터로 string을 넘기거나 2️⃣파라미터로 enter, update, exit 콜백 함수를 넘기는 방법입니다.

svg.selectAll("circle")
  .data(data)
  .join("circle")

위 코드는 아래코드의 축약형입니다.

svg.selectAll("circle")
  .data(data)
  .join(
    enter => enter.append("circle"),
    update => update,
    exit => exit.remove()
  )

enter : data개수 > dom개수여서, 남아도는 data에 해당하는 selection을 처리하는 방법을 정의
update : data에 대응하는 dom요소가 존재해서, 해당 data selection을 업데이트하는 방법을 정의
exit : data개수 < dom개수여서, 사라진 data selection을 처리하는 방법을 정의

(중요) d3.select는 추후 생성/변경/삭제될 element를 가리킬 수 있다.

document.querySelectorAll 은 이미 존재하는 element 배열만을 선택하여 반환하지만, d3.selectAll은 현재 매치되는 요소가 존재하지 않으면 빈 Selection 객체를 반환하게 되고, 이 Selection 객체는 추후 binding되는 data와 joining할 element-type에 해당하는 element들로 채워질 가능성이 있습니다.

즉, 지금은 selector에 해당하는 요소가 없더라도 데이터를 binding, joining 하면서 해당 요소를 생성/삭제할 수 있습니다.

예를들어, 다음과 같은 비어있는 svg 태그와 array 가 주어진다면 각 체이닝 시점별 반환 selection은 다음과 같습니다.

html

<svg>
</svg>

js

array = [1,2,3]

d3.select("svg") //1
  .selectAll("circle") // 2
  .data(array) //3
  .join("circle") //4; 
  1. container 역할을 할 element를 가리키는 Selection을 반환합니다.

  2. 데이터 배열의 요소와 join될 엘리먼트 타입을 선택합니다. 현재는 해당 엘리먼트 타입(circle)에 매치되는 요소가 없으므로, 비어있는 NodeList를 가리키는 Selection을 반환합니다.

  3. join될 data array를 정의합니다. data의 변화를 나타내는_enter, _exit 프로퍼티에 data가 바인딩된 EnterNode 배열이 있는 Selection을 반환합니다.

  4. 실제 문서의 엘리먼트 타입으로 요소로 생성/변경/삭제하고, 이를 가리키는 Selection을 반환합니다.

실행 후 돔은 다음과 같이 변화됩니다.

<svg>
  <circle/>
  <circle/>
  <circle/>
</svg>

이처럼 binding되는 data의 변화에 따라 svg 요소를 생성/변경/제거할 수 있습니다.
d3가 왜 data-driven documents인지 알 수 있는 대목입니다.

간단한 네트워크 차트 그려보기

위에서 알아본 selections, data-join을 이용하여 간단한 네트워크 차트를 만들어봅시다.

우선 네트워크 차트를 그리기 위해서는 각 노드와 간선의 정보가 필요합니다.
이 데이터들을 각각 다음과 같이 nodes, links 로 정의해보겠습니다.

let nodes = [
  { x:100, y:100, name:"ye" },
  { x:200, y:300, name:"jun" },
  { x:300, y:200, name:"mi" },
  { x:200, y:400, name:"sung" },
]

let links = [
  { source:"ye", target:"jun" },
  { source:"jun", target:"mi" },
  { source:"mi", target:"sung" },
  { source:"sung", target:"jun" },
]

그리고 각 link의 source, target을 다시 node로 매핑하는 전처리가 필요합니다.

links = links.map(link =>
    ({
      source: nodes[nodes.findIndex(node => link.source === node.name)], 
      target: nodes[nodes.findIndex(node => link.target === node.name)]
    })
  )

나중에 사용할 d3.forceSimulation() 의 nodes, links 데이터 구조와 mapping을 모방한 방식입니다.
(솔직히 개인적으로는 이 방식이 좋은지는 잘 모르겠습니다.
nodes가 Map 타입이었으면 이터러블하면서도 매핑이 더 효율적이었을 것 같고, links의 source, target type이 string -> node로 변경되는 부분때문에 ts에서 type 정의할 때 살짝 애를 먹었습니다. 처음부터 객체였으면 더 수월했을 것 같습니다.)

html container tag 정의

html쪽 태그에서는 svg와 그 내부에 link그룹, node그룹을 표현할 g tags를 정의합니다.

<svg width="500" height="500">
  <g id="link"/>
  <g id="node"/>
</svg>

nodes draw

node는 g elements(text, circle을 그룹핑하기 위함)로 구성합니다.

const node = 
      d3.select("#node")
        .selectAll("g")
        .data(nodes)
        .join("g")
        .each(function(d) {
          d3.select(this)
            .append("circle")
            .attr("r", 5)
            .style("fill", "red");
          d3.select(this)
            .append("text")
            .text(d => d.name);
        })

function drawNodes(){
  node.attr("transform", d =>"translate("+[d.x, d.y]+")" );
}

여기서 주의할 점은 each내부 함수에서 this(각 그룹노드를 가리키는)를 사용하고 있기때문에, 이 내부함수를 화살표 함수가 아닌 함수 선언식으로 작성해야합니다.

selection.each 소스코드를 보면, callback함수에 각 node를 this로 바인딩해서 호출하는 구문을 확인할 수 있습니다.

// each.js
export default function(callback) {

  for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) {
    for (var group = groups[j], i = 0, n = group.length, node; i < n; ++i) {
      if (node = group[i]) callback.call(node, node.__data__, i, group);
    }
  }

  return this;
}

하지만 화살표 함수는 this를 가질 수 없기때문에, 자신을 둘러싼 scope의 this를 가리키게 됩니다. 그래서 call, bind, apply와 같이 this를 바인딩하는 methods도 이용할 수 없습니다.

links는 line elements로 구성합니다.

const link = 
      d3.select("#link")
        .selectAll("line")
        .data(links)
        .join("line")
        .attr("stroke", "black");

function drawLines() {
  link
    .attr("x1", d => d.source.x)
    .attr("y1", d => d.source.y)
    .attr("x2", d => d.target.x)
    .attr("y2", d => d.target.y)
}

실행구문

drawNodes();
drawLines();

코드 전문과 실행화면은 codepen을 통해서 확인 가능합니다.

profile
추진력을 얻는 중

1개의 댓글

comment-user-thumbnail
2023년 10월 13일

안녕하세요~ D3로 네트워크차트 구현 하려고 하는데 혹시 'links의 source, target type이 string -> node로 변경되는 부분때문에 ts에서 type 정의할 때 살짝 애를 먹었습니다' 이 부분에서 어떻게 해결 하셨을까요?ㅠ

답글 달기