D3.js로 노드 시뮬레이션 구현하기
D3.js로 노드 시뮬레이션 구현하기
Force Simulation 이해하기
Force Simulation은 입자들 간의 힘과 운동을 시뮬레이션하는 D3.js의 기능이다. 노드와 링크로 구성된 네트워크 데이터를 자동으로 레이아웃하는 데 주로 사용된다.
기본 개념
1
2
3
4
5
// 기본 시뮬레이션 생성
const simulation = d3.forceSimulation()
.force('charge', d3.forceManyBody()) // 노드 간 인력/척력
.force('center', d3.forceCenter(width / 2, height / 2)) // 중심력
.force('link', d3.forceLink()) // 노드 간 연결
주요 Force 타입
forceManyBody()
: 노드 간 인력 또는 척력forceCenter()
: 전체 노드를 중심으로 끌어당기는 힘forceLink()
: 연결된 노드 간의 힘forceCollide()
: 노드 간 충돌 방지forceX()
,forceY()
: 특정 x, y 좌표로 끌어당기는 힘
시뮬레이션 제어: alpha, alphaTarget, restart
force simulation은 alpha라는 값을 사용하여 시뮬레이션의 진행 상태를 관리한다.
alpha()
: 현재 시뮬레이션의 alpha 값을 반환한다. 초기값은 1이며, 시뮬레이션이 진행됨에 따라 점차 0으로 감소한다. alpha 값이 0에 가까워지면 시뮬레이션이 안정화되었다고 판단한다.alphaTarget([target])
: 시뮬레이션의 목표 alpha 값을 설정한다. 이 값을 설정하면 시뮬레이션은 현재 alpha 값에서 목표 alpha 값으로 점진적으로 변화한다. 드래그 이벤트와 같이 사용자의 인터랙션이 발생했을 때 시뮬레이션을 다시 활성화하는 데 유용하다.alphaMin([min])
: 시뮬레이션이 멈추는 최소 alpha 값을 설정한다. alpha 값이 이 값 아래로 내려가면 시뮬레이션이 자동으로 멈춘다. 기본값은 0.001이다.alphaDecay([decay])
: alpha 값이 감소하는 비율을 설정한다. 값이 클수록 alpha 값이 빠르게 감소하여 시뮬레이션이 빠르게 안정화된다. 기본값은 0.0228이다.velocityDecay([decay])
: 노드의 속도가 감소하는 비율을 설정한다. 마찰력과 유사한 역할을 하며, 값이 클수록 노드가 빠르게 멈춘다. 기본값은 0.4이다.restart()
: 시뮬레이션을 다시 시작. alpha 값을 초기값(1)으로 설정하고 시뮬레이션을 재개한다. 주로 alphaTarget()과 함께 사용하여 사용자의 인터랙션에 따라 시뮬레이션을 다시 활성화한다.
아래는 드래그 이벤트에서 이 속성들을 활용하는 예시이다.
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart(); // 드래그 시작 시 alphaTarget을 0.3으로 설정하고 시뮬레이션 재시작
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0); // 드래그 종료 시 alphaTarget을 0으로 설정
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
네트워크 그래프 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 1. 데이터 준비
const nodes = [
{ id: 'A', group: 1 },
{ id: 'B', group: 1 },
{ id: 'C', group: 2 },
{ id: 'D', group: 2 }
];
const links = [
{ source: 'A', target: 'B', value: 1 },
{ source: 'B', target: 'C', value: 2 },
{ source: 'C', target: 'D', value: 1 }
];
// 2. SVG 생성
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height);
// 3. 시뮬레이션 설정
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links)
.id(d => d.id)
.distance(100))
.force('charge', d3.forceManyBody()
.strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30));
// 4. 링크 그리기
const link = svg.append('g')
.selectAll('line')
.data(links)
.join('line')
.style('stroke', '#999')
.style('stroke-width', d => Math.sqrt(d.value));
// 5. 노드 그리기
const node = svg.append('g')
.selectAll('circle')
.data(nodes)
.join('circle')
.attr('r', 20)
.style('fill', d => d.group === 1 ? 'red' : 'blue')
.call(drag(simulation)); // 드래그 기능 추가
// 6. 시뮬레이션 업데이트 함수
simulation.on('tick', () => {
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);
node
.attr('cx', d => d.x)
.attr('cy', d => d.y);
});
드래그 기능 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 드래그 함수 정의
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
실전 예제: 인터랙티브 네트워크 그래프
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// 1. 데이터 설정
const data = {
nodes: [
{ id: '1', name: 'Node 1', group: 1, size: 10 },
{ id: '2', name: 'Node 2', group: 1, size: 15 },
{ id: '3', name: 'Node 3', group: 2, size: 20 },
// ... 더 많은 노드
],
links: [
{ source: '1', target: '2', weight: 1 },
{ source: '2', target: '3', weight: 2 },
// ... 더 많은 링크
]
};
// 2. 색상 스케일 설정
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
// 3. 시뮬레이션 생성
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links)
.id(d => d.id)
.distance(d => 100 / d.weight))
.force('charge', d3.forceManyBody()
.strength(d => -50 * d.size))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => d.size * 2));
// 4. 줌 기능 추가
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', zoomed);
svg.call(zoom);
function zoomed(event) {
container.attr('transform', event.transform);
}
const container = svg.append('g');
// 5. 링크 그리기
const link = container.append('g')
.selectAll('line')
.data(data.links)
.join('line')
.style('stroke', '#999')
.style('stroke-opacity', 0.6)
.style('stroke-width', d => Math.sqrt(d.weight));
// 6. 노드 그리기
const node = container.append('g')
.selectAll('g')
.data(data.nodes)
.join('g')
.call(drag(simulation));
// 노드 원 추가
node.append('circle')
.attr('r', d => d.size)
.style('fill', d => colorScale(d.group))
.style('stroke', '#fff')
.style('stroke-width', 1.5);
// 노드 레이블 추가
node.append('text')
.text(d => d.name)
.style('font-size', '12px')
.attr('x', 0)
.attr('y', d => d.size + 15)
.style('text-anchor', 'middle');
// 7. 툴팁 추가
const tooltip = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
node.on('mouseover', function(event, d) {
tooltip.transition()
.duration(200)
.style('opacity', .9);
tooltip.html(`
이름: ${d.name}<br/>
그룹: ${d.group}<br/>
크기: ${d.size}
`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', function() {
tooltip.transition()
.duration(500)
.style('opacity', 0);
});
// 8. 시뮬레이션 업데이트
simulation.on('tick', () => {
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);
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
시뮬레이션 파라미터 조정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 시뮬레이션 강도 조정
simulation
.force('charge')
.strength(-100); // 척력 조정
// 링크 거리 조정
simulation
.force('link')
.distance(150); // 링크 길이 조정
// 시뮬레이션 속도 조정
simulation
.velocityDecay(0.3) // 마찰력 (0~1)
.alphaMin(0.001) // 최소 알파값
.alphaDecay(0.0228); // 알파값 감소율
.alphaTarget(0.1); // 천천히 안정화되도록 설정
This post is licensed under CC BY 4.0 by the author.