D3.js(Draggable and Scalable Tree)

387 阅读5分钟

因为最近手上有个小的需求,设计一个可缩放和可拖拽的树形结构,我便去研读了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);
	}
}

可以从github.com/brucewar/pr…获取示例代码