d3.js 의 force layout은 네트워크 다이어그램을 볼 수 있는 강력한 툴이지만, 점의 갯수가 많아질 때 점과 선들이 한데 뭉쳐서 알아보기 힘들다는 단점이 있다. 이러한 단점을 해결하기 위해 여기서는 force layout을 확대축소할 수 있도록 코드를 수정하는 방법을 설명하겠다. force layout을 다루는 기본적인 방법은 알고 있다고 전제하였다.
다음은 d3.js 의 force layout의 샘플과 소스코드다.
소스코드 원본에서 json을 불러오는 부분만 수정한 것이다. 원본은 여기에 있다.
기본적으로 점들의 drag 기능을 제공한다.
(바로 아래의 움직이는 force layout을 보려면 크롬의 경우에는 주소창 우측에서 '안전하지 않은 스크립트 로드'를 눌러야 한다)
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 | <!DOCTYPE html> <meta charset="utf-8"> <head> </head> <body> <SCRIPT src="http://d3js.org/d3.v3.min.js"></SCRIPT> <SCRIPT> var graph = { "nodes": [ { "name": "Myriel", "group": 1 }, { "name": "Napoleon", "group": 1 }, // 중간 생략 { "name": "Mme.Hucheloup", "group": 8 } ], "links": [ { "source": 1, "target": 0, "value": 1 }, { "source": 2, "target": 0, "value": 8 }, // 중간 생략 { "source": 76, "target": 58, "value": 1 } ] }; var width = 640, height = 400; var color = d3.scale.category20(); var force = d3.layout.force() .charge(-120) .linkDistance(30) .size([width, height]); var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height); force .nodes(graph.nodes) .links(graph.links) .start(); var link = svg.selectAll(".link") .data(graph.links) .enter().append("line") .attr("class", "link") .style("stroke-width", function (d) { return Math.sqrt(d.value); }); var node = svg.selectAll(".node") .data(graph.nodes) .enter().append("circle") .attr("class", "node") .attr("r", 5) .style("fill", function (d) { return color(d.group); }) .call(force.drag); node.append("title") .text(function (d) { return d.name; }); force.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("cx", function (d) { return d.x; }) .attr("cy", function (d) { return d.y; }); }); </SCRIPT> </body> | cs |
우선 기본적인 zoom을 위해 몇가지 필요한 부분들을 추가하고 수정해야 한다. 우선 본격적으로 force layout에 대한 코드를 작성하기 이전에 zoom에 대한 부분을 추가한다. var color =.....의 밑줄에 넣어주면 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // scale이 적용되는 공간 정의 var xScale = d3.scale.linear() .domain([0, width]) .range([0, width]); var yScale = d3.scale.linear() .domain([0, height]) .range([0, height]); //zoom 기능 정의. scaleExtent 부분을 수정하여 zoom의 한계를 조정할 수 있다. var zoomer = d3.behavior.zoom().x(xScale).y(yScale).scaleExtent([0.1, 8]) .on("zoom", zoom); //위에서 호출한 zoom 함수를 정의. //혹시 모를 나중을 위해 곧바로 tick()을 호출하지 않고, zoom 함수를 한번 더 거쳤다. function zoom() { tick(); }; | cs |
svg 공간을 정의한 바로 뒷부분에 앞에서 정의한 zoomer기능을 호출해둔다.
1 2 3 4 5 6 7 | //기존에 있던 부분 var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height); // 이부분을 추가한다. svg.call(zoomer); | cs |
tick 함수를 수정한다. 기존 코드에서는 force.on에서 tick을 바로 정의하고 있었지만, 함수 호출을 위해 별도로 분리시켜준다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | //기존 코드의 force.on 부분을 수정해서 tick 함수를 정의한다. force.on("tick",tick); function tick(){ // link 부분 좌표들 바깥으로 scale 함수들을 덮어씌운다. link.attr("x1", function (d) { return xScale(d.source.x); }) .attr("y1", function (d) { return yScale(d.source.y); }) .attr("x2", function (d) { return xScale(d.target.x); }) .attr("y2", function (d) { return yScale(d.target.y); }); //node는 svg 문법의 규칙 따라 transform으로 바꾸어 주어야 한다. node.attr("transform", function (d) { return "translate(" + xScale(d.x) + "," + yScale(d.y) + ")"; }); }; | cs |
이제 아래와 같이 zoom 과 pan이 가능해졌다. 이제 node들이 뭉쳐있는 곳을 확대해서 볼 수 있다.
그런데 문제가 있다. 점을 drag해보면 상당히 부자연스럽게 이동한다. drag할 때 node의 위치를 계산하게 되는데, zoom에 의한 xScale과 yScale 변화가 drag 부분에서 계산되지 않아서 발생하는 현상이다. 이 문제를 해결하기 위해 force에서 기본으로 제공하는 drag함수를 호출하지 않고 drag 함수를 재정의 하여 사용해야 한다. 위에서 zoom을 정의해준 부분 밑에 아래의 코드를 추가한다.
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 | //drag 함수를 새로 정의한다. var drag = d3.behavior.drag() .origin(function (d) { return d; }) // .on("dragstart", dragstarted) .on("drag", dragged) .on("dragend", dragended); // 드래그가 시작될 경우. function dragstarted(d) { d3.event.sourceEvent.stopPropagation(); //다른 이벤트 전달 중지 d.fixed |= 2; //d3.js 오리지널 소스의 force.drag에서 참고함. //드래그하는 점의 진동이 발생하지 않도록 임시로 fix 한다. } //드래그 되는 동안. function dragged(d) { //마우스의 위치를 잡아낸 뒤, //invert 계산을 거쳐 zoom이 없을 경우의 x,y 스케일을 알아냄 //zoom 이 포함된 좌표를 계산하는 것은 tick()의 역할임. var mouse = d3.mouse(svg.node()); d.x = xScale.invert(mouse[0]); d.y = yScale.invert(mouse[1]); // 앞에서 임시로 fix해준 점의 변위 (px, py)를 직접 입력해 줌 d.px = d.x; . d.py = d.y; force.resume(); // force layout이 움직이도록 resume함. } //drag가 끝난 후. function dragended(d) { d.fixed &= ~6; //d3 오리지널 소스의 force.drag에서 참고함. // 앞에서 fix해준 점을 다시 풀어줌 } | cs |
새로 정의된 drag 함수를 호출한다. node 변수 끝부분의 force.drag 부분을 아래와 같이 수정한다.
1 2 3 4 5 6 7 8 | var node = svg.selectAll(".node") .data(graph.nodes) .enter().append("circle") .attr("class", "node") .attr("r", 5) .style("fill", function (d) { return color(d.group); }) .call(drag); // 바로 이 부분을 수정한다. | cs |
이제 필요한 부분을 모두 수정하였다. force layout의 zoom과 pan, 그리고 점의 드래그까지 완벽하게 동작할 것이다. 최종 수정된 force layout과 소스는 아래에 덧붙여두었다.
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 | <SCRIPT> var graph = { "nodes": [ { "name": "Myriel", "group": 1 }, { "name": "Napoleon", "group": 1 }, // 중간 생략 { "name": "Mme.Hucheloup", "group": 8 } ], "links": [ { "source": 1, "target": 0, "value": 1 }, { "source": 2, "target": 0, "value": 8 }, // 중간 생략 { "source": 76, "target": 58, "value": 1 } ] }; var width = 640, height = 400; var color = d3.scale.category20(); var xScale = d3.scale.linear() .domain([0, width]) .range([0, width]); var yScale = d3.scale.linear() .domain([0, height]) .range([0, height]); var zoomer = d3.behavior.zoom().x(xScale).y(yScale).scaleExtent([0.1, 8]).on("zoom", zoom); function zoom() { tick(); }; var drag = d3.behavior.drag() .origin(function (d) { return d; }) .on("dragstart", dragstarted) .on("drag", dragged) .on("dragend", dragended); function dragstarted(d) { d3.event.sourceEvent.stopPropagation(); d.fixed |= 2; } function dragged(d) { var mouse = d3.mouse(svg.node()); d.x = xScale.invert(mouse[0]); d.y = yScale.invert(mouse[1]); d.px = d.x; d.py = d.y; force.resume(); } function dragended(d) { d.fixed &= ~6; } var force = d3.layout.force() .charge(-120) .linkDistance(30) .size([width, height]); var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height); svg.call(zoomer); force .nodes(graph.nodes) .links(graph.links) .start(); var link = svg.selectAll(".link") .data(graph.links) .enter().append("line") .attr("class", "link") .style("stroke-width", function (d) { return Math.sqrt(d.value); }); var node = svg.selectAll(".node") .data(graph.nodes) .enter().append("circle") .attr("class", "node") .attr("r", 5) .style("fill", function (d) { return color(d.group); }) .call(drag); node.append("title") .text(function (d) { return d.name; }); force.on("tick",tick); function tick(){ link.attr("x1", function (d) { return xScale(d.source.x); }) .attr("y1", function (d) { return yScale(d.source.y); }) .attr("x2", function (d) { return xScale(d.target.x); }) .attr("y2", function (d) { return yScale(d.target.y); }); node.attr("transform", function (d) { return "translate(" + xScale(d.x) + "," + yScale(d.y) + ")"; }); }; </SCRIPT> | cs |