Learning
레슨 5 / 8·20분

레이아웃 (트리, 포스, 파이)

D3 레이아웃이란?

D3 레이아웃은 원본 데이터를 특정 시각화 형태에 맞는 좌표와 크기로 변환하는 알고리즘입니다. 데이터를 넣으면 시각화에 필요한 x, y 좌표, 크기, 각도 등을 자동으로 계산해 줍니다.

트리 레이아웃 (Tree Layout)

트리 레이아웃은 계층 구조 데이터를 트리 형태로 시각화합니다. 조직도, 파일 시스템, 카테고리 분류 등에 적합합니다.

javascript
// 계층 구조 데이터
var treeData = {
  name: 'CEO',
  children: [
    {
      name: 'CTO',
      children: [
        { name: '프론트엔드팀' },
        { name: '백엔드팀' },
        { name: 'DevOps팀' },
      ],
    },
    {
      name: 'CFO',
      children: [
        { name: '재무팀' },
        { name: '회계팀' },
      ],
    },
    {
      name: 'CMO',
      children: [
        { name: '마케팅팀' },
        { name: '영업팀' },
      ],
    },
  ],
};

var width = 600, height = 400;
var svg = d3.select('#tree')
  .append('svg')
  .attr('width', width)
  .attr('height', height);

var g = svg.append('g')
  .attr('transform', 'translate(50, 30)');

// 계층 구조 생성
var root = d3.hierarchy(treeData);

// 트리 레이아웃 적용
var treeLayout = d3.tree()
  .size([width - 100, height - 80]);

treeLayout(root);

// 연결선 그리기
g.selectAll('.link')
  .data(root.links())
  .join('path')
  .attr('class', 'link')
  .attr('d', d3.linkVertical()
    .x(function(d) { return d.x; })
    .y(function(d) { return d.y; }))
  .attr('fill', 'none')
  .attr('stroke', '#999')
  .attr('stroke-width', 1.5);

// 노드 그리기
var nodes = g.selectAll('.node')
  .data(root.descendants())
  .join('g')
  .attr('class', 'node')
  .attr('transform', function(d) {
    return 'translate(' + d.x + ',' + d.y + ')';
  });

nodes.append('circle')
  .attr('r', 6)
  .attr('fill', function(d) {
    return d.children ? '#4f46e5' : '#10b981';
  });

nodes.append('text')
  .attr('dy', -12)
  .attr('text-anchor', 'middle')
  .attr('font-size', '12px')
  .text(function(d) { return d.data.name; });

포스 레이아웃 (Force Layout)

포스 레이아웃은 물리 시뮬레이션을 사용하여 노드를 배치합니다. 노드 사이의 인력과 척력으로 자연스러운 네트워크 그래프를 만듭니다.

javascript
var nodes = [
  { id: 'React', group: 1 },
  { id: 'Vue', group: 1 },
  { id: 'Angular', group: 1 },
  { id: 'Node.js', group: 2 },
  { id: 'Express', group: 2 },
  { id: 'Next.js', group: 3 },
  { id: 'Nuxt', group: 3 },
  { id: 'TypeScript', group: 4 },
];

var links = [
  { source: 'React', target: 'Next.js' },
  { source: 'Vue', target: 'Nuxt' },
  { source: 'Node.js', target: 'Express' },
  { source: 'React', target: 'TypeScript' },
  { source: 'Vue', target: 'TypeScript' },
  { source: 'Angular', target: 'TypeScript' },
  { source: 'Next.js', target: 'Node.js' },
];

var width = 500, height = 400;
var svg = d3.select('#force')
  .append('svg')
  .attr('width', width)
  .attr('height', height);

var colorScale = d3.scaleOrdinal(d3.schemeCategory10);

// 포스 시뮬레이션 생성
var simulation = d3.forceSimulation(nodes)
  .force('link', d3.forceLink(links).id(function(d) { return d.id; }).distance(80))
  .force('charge', d3.forceManyBody().strength(-200))
  .force('center', d3.forceCenter(width / 2, height / 2))
  .force('collision', d3.forceCollide().radius(30));

// 연결선
var link = svg.selectAll('.link')
  .data(links)
  .join('line')
  .attr('stroke', '#999')
  .attr('stroke-opacity', 0.6)
  .attr('stroke-width', 2);

// 노드
var node = svg.selectAll('.node')
  .data(nodes)
  .join('g');

node.append('circle')
  .attr('r', 15)
  .attr('fill', function(d) { return colorScale(d.group); })
  .attr('stroke', '#fff')
  .attr('stroke-width', 2);

node.append('text')
  .text(function(d) { return d.id; })
  .attr('text-anchor', 'middle')
  .attr('dy', 30)
  .attr('font-size', '11px');

// 시뮬레이션 갱신
simulation.on('tick', function() {
  link
    .attr('x1', function(d) { return d.source.x; })
    .attr('y1', function(d) { return d.source.y; })
    .attr('x2', function(d) { return d.target.x; })
    .attr('y2', function(d) { return d.target.y; });

  node.attr('transform', function(d) {
    return 'translate(' + d.x + ',' + d.y + ')';
  });
});

파이 레이아웃 (Pie Layout)

javascript
var data = [
  { label: 'JavaScript', value: 40 },
  { label: 'TypeScript', value: 30 },
  { label: 'Python', value: 20 },
  { label: 'Go', value: 10 },
];

var width = 400, height = 400;
var radius = Math.min(width, height) / 2;

var svg = d3.select('#pie')
  .append('svg')
  .attr('width', width)
  .attr('height', height)
  .append('g')
  .attr('transform', 'translate(' + width / 2 + ',' + height / 2 + ')');

var color = d3.scaleOrdinal()
  .domain(data.map(function(d) { return d.label; }))
  .range(d3.schemeSet2);

// 파이 레이아웃
var pie = d3.pie()
  .value(function(d) { return d.value; })
  .sort(null);

// 호(arc) 생성기
var arc = d3.arc()
  .innerRadius(0)          // 0이면 파이, > 0이면 도넛
  .outerRadius(radius - 20);

var labelArc = d3.arc()
  .innerRadius(radius - 60)
  .outerRadius(radius - 60);

// 파이 조각 그리기
svg.selectAll('.slice')
  .data(pie(data))
  .join('path')
  .attr('d', arc)
  .attr('fill', function(d) { return color(d.data.label); })
  .attr('stroke', '#fff')
  .attr('stroke-width', 2);

// 라벨 추가
svg.selectAll('.label')
  .data(pie(data))
  .join('text')
  .attr('transform', function(d) {
    return 'translate(' + labelArc.centroid(d) + ')';
  })
  .attr('text-anchor', 'middle')
  .attr('font-size', '13px')
  .text(function(d) { return d.data.label; });
  • d3.hierarchy() — 원시 데이터를 계층 구조 객체로 변환
  • d3.tree() — 트리 레이아웃 (정돈된 수직/수평 트리)
  • d3.cluster() — 클러스터 레이아웃 (리프 노드가 같은 깊이)
  • d3.treemap() — 트리맵 (면적 비례 사각형)
  • d3.pack() — 원형 패킹 레이아웃
  • d3.forceSimulation() — 물리 기반 포스 레이아웃
  • d3.pie() — 파이/도넛 레이아웃
  • d3.arc() — 호(arc) 경로 생성기
💡

포스 레이아웃에 d3.drag()를 추가하면 사용자가 노드를 드래그하여 재배치할 수 있습니다. node.call(d3.drag().on("start", ...).on("drag", ...).on("end", ...)) 패턴으로 구현합니다.