D3.js(Draggable and Scalable Tree)
原文链接 http://brucewar.cn/2016/10/27/D3-js-Draggable-and-Scalable-Tree/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
因为最近手上有个小的需求,设计一个可缩放和可拖拽的树形结构,我便去研读了D3官网给的一个树形的例子。
布局(Layout)
原本我以为理解了基本的选择器、元素操作、Enter、Exit就能去看实例的代码了,后来发现我错了,所以这里需要理解一下D3中布局(Layout)的概念。布局是D3中一个十分重要的概念,从布局衍生出很多图表。例如:饼状图(pie)、力导向图(force),树状图(tree)等等,基本实现了很多开源的可视化工具提供的图表。但是它又和很多可视化工具(如Echarts)有很大的不同。
相对于其它工具来说,D3较底层一点,所以初学者可能会觉得有点困难,但是一旦理解了D3布局的思想,使用起来,会比其它工具更加得心应手。首先,我阐释下D3和大部分可视化工具数据到图表的流程:
- 大部分可视化工具:数据 => 封装好的绘图函数 => 图表
- D3:数据 => Layout => 绘图所需的数据 => 绘制图形 => 图表
可以看出,D3需要自己去绘制图形,但是可以通过布局函数获得绘图所需要的数据,坏处是对初学者是一个很大的考验,好处是它能帮助我们制作出更加精密的图形。
树状图
回归正题,如何设计一个树形结构,我将从D3官网提供的示例代码分析。
页面代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>tree</title>
<style>
body{
margin: 0;
}
svg{
background-color: #eee;
}
.node circle {
cursor: pointer;
fill: #fff;
stroke: steelblue;
stroke-width: 1.5px;
}
.node text {
font-size: 11px;
}
path.link {
fill: none;
stroke: #ccc;
stroke-width: 1.5px;
}
g.detail rect{
fill: #000;
fill-opacity: .6;
rx: 5;
ry: 5;
}
g.detail text{
fill: #fff;
}
</style>
</head>
<body>
<div id="treeContainer"></div>
<script src="./dist/tree.bundle.js"></script>
</body>
</html>
因为D3示例代码是同步的形式读出整个树形数据结构,我对其进行了改造,模拟异步数据(async_city.json)。
{
"root": {
"name": "中国"
},
"中国": {
"name": "中国",
"children": [
{"name": "浙江"},
{"name": "广西"},
{"name": "黑龙江"},
{"name": "新疆"}
]
},
"浙江": {
"name": "浙江",
"children": [
{"name": "杭州"},
{"name": "宁波"},
{"name":"温州" },
{"name":"绍兴" }
]
},
"广西": {
"name": "广西",
"children": [
{"name": "桂林"},
{"name": "南宁"},
{"name": "柳州"},
{"name": "防城港"}
]
},
"桂林": {
"name": "桂林",
"children": [
{"name":"秀峰区"},
{"name":"叠彩区"},
{"name":"象山区"},
{"name":"七星区"}
]
},
"黑龙江": {
"name":"黑龙江",
"children":
[
{"name":"哈尔滨"},
{"name":"齐齐哈尔"},
{"name":"牡丹江"},
{"name":"大庆"}
]
},
"新疆" : {
"name":"新疆" ,
"children":
[
{"name":"乌鲁木齐"},
{"name":"克拉玛依"},
{"name":"吐鲁番"},
{"name":"哈密"}
]
}
}
画布
var margin = {
top: 20,
left: 50,
right: 50,
bottom: 20
};
var width = $(document).width(),
height = $(document).height(),
i = 0,
limit = 2,
root;
// draggable and scalable
var zoomListener = d3.behavior.zoom().scaleExtent([0.1, 3]).on('zoom', zoom);
function zoom(){
d3.select('svg').select('g').attr('transform', 'translate(' + d3.event.translate + ')scale(' + d3.event.scale + ')');
}
var svg = d3.select("#treeContainer").append("svg")
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom)
.call(zoomListener)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
获取异步数据
// 异步获取数据
function getData(sd, cb){
d3.json('data/async_city.json', function(err, json){
// 通过callback返回部分数据
cb && cb(json[sd.name]);
});
}
构造树
// 获取树的root
getData({name: 'root'}, function(json){
root = json;
root.x0 = height / 2;
root.y0 = width / 2;
// 初始化树根
update(root);
});
从上面的代码可以看出构造树的核心代码就是这个update函数,下面以注释的形式深入理解树形的构造。
// 新建一个树的布局
var tree = d3.layout.tree()
.size([height - margin.top - margin.bottom, width - margin.left - margin.right]);
// 因为默认的树布局是自上而下的,这里构建一个自左向右的树,故需要一个转换x和y坐标的函数
var diagonal = d3.svg.diagonal()
.projection(function(d) { return [d.y, d.x]; });
function update(source) {
var duration = d3.event && d3.event.altKey ? 5000 : 500;
/**
* 这里实际上是通过tree的nodes函数获得树形结构的每个节点的数据,包括位置信息和深度
* 返回的数据结构如下:
* [{depth: 0, name: "中国", children: [], x: 380, y: 0}]
*/
var nodes = tree.nodes(root).reverse();
// 为了让当前节点居中,故更具当前节点的depth来计算各节点的y坐标(即横向位置)
var srcDepth = source.depth;
nodes.forEach(function(d){
d.y = height / 2 + 180 * (d.depth - srcDepth);
});
// Update the nodes…
var node = svg.selectAll("g.node")
.data(nodes, function(d) { return d.id || (d.id = ++i); });
// Enter any new nodes at the parent's previous position.
var nodeEnter = node.enter().append("g")
.attr("class", "node")
.attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
.on("click", click)
.on('mouseover', function(d){
if(d.name == 'more') return;
// 鼠标hover某个节点时,显示一个详细信息的弹层
var detail = d3.select(this).append('g')
.attr('class', 'detail')
.attr('dx', d3.event.x)
.attr('dy', d3.event.y + 10);
detail.append('rect')
.attr('width', 100)
.attr('height', 100);
detail.append('text')
.attr('dx', '.35em')
.attr('dy', '2em')
.attr('text-anchor', 'start')
.text(function(d){
return 'name: ' + d.name;
});
})
.on('mousemove', function(d){
var detail = d3.select(this).select('.detail');
detail.attr('x', d3.event.x)
.attr('y', d3.event.y);
})
.on('mouseout', function(d){
if(d.name == 'more') return;
d3.select(this).select('.detail').remove();
});
nodeEnter.append("circle")
.attr("r", 1e-6)
.style("fill", function(d){ return !d.isExpand ? "lightsteelblue" : "#fff"; });
nodeEnter.append("text")
.attr("x", -10)
.attr("dy", ".35em")
.attr("text-anchor", "end")
.text(function(d) { return d.name; })
.style("fill-opacity", 1e-6);
// Transition nodes to their new position.
var nodeUpdate = node.transition()
.duration(duration)
.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });
nodeUpdate.select("circle")
.attr("r", 10)
.style("fill", function(d){ return !d.isExpand ? "lightsteelblue" : "#fff"; });
nodeUpdate.select("text")
.style("fill-opacity", 1);
// Transition exiting nodes to the parent's new position.
var nodeExit = node.exit().transition()
.duration(duration)
.attr("transform", function(d) {
if(d.name == 'more') this.remove();
return "translate(" + source.y + "," + source.x + ")";
})
.remove();
nodeExit.select("circle")
.attr("r", 1e-6);
nodeExit.select("text")
.style("fill-opacity", 1e-6);
/** Update the links...
* tree.links方法获取连线节点之间的映射,返回的数据结构如下:
* [{source: {}, target: {}}]
*/
var link = svg.selectAll("path.link")
.data(tree.links(nodes), function(d) { return d.target.id; });
// Enter any new links at the parent's previous position.
link.enter().insert("path", "g")
.attr("class", "link")
.attr("d", function(d) {
var o = {x: source.x0, y: source.y0};
return diagonal({source: o, target: o});
})
.transition()
.duration(duration)
.attr("d", diagonal);
// Transition links to their new position.
link.transition()
.duration(duration)
.attr("d", diagonal);
// Transition exiting nodes to the parent's new position.
link.exit().transition()
.duration(duration)
.attr("d", function(d) {
if(d.target.name == 'more') this.remove();
var o = {x: source.x, y: source.y};
return diagonal({source: o, target: o});
})
.remove();
// Stash the old positions for transition.
// 记录当前节点所在的位置,为node update提供位移动画
nodes.forEach(function(d) {
d.x0 = d.x;
d.y0 = d.y;
});
}
function collapse(d){
delete d._children;
delete d.isExpand;
delete d.children;
}
function expand(d){
getData({name: d.name}, function(json){
if(json && json.children){
// 获取到此节点有子节点
d._children = json.children;
d.children = d._children.slice(0, limit);
if(d._children.length > d.children.length){
d.children.push({'name': 'more'});
}
}
d.isExpand = true;
update(d);
});
}
// 异步获取数据
function getData(sd, cb){
d3.json('data/async_city.json', function(err, json){
cb && cb(json[sd.name]);
});
}
function click(d){
if(d.name == 'more'){
// 点击更多
d.parent.children = d.parent._children.slice(0, (d.parent.children.length - 1) + limit);
if(d.parent._children.length > d.parent.children.length){
d.parent.children.push({'name': 'more'});
}
update(d.parent);
}else if(d.isExpand && d.children){
// 点击展开的节点
collapse(d);
update(d);
}else{
// 点击未展开的点
expand(d);
}
}