D3.js学习笔记 — 入门教程

13,355 阅读34分钟

D3.js入门教程

本教程的目的在于提供一个简单易懂的入门教程。

D3.js简介

D3 的全称是(Data-Driven Documents),顾名思义可以知道是一个被数据驱动的文档。听名字有点抽象,说简单一点,其实就是一个 JavaScript 的函数库。D3 可以借助 SVG,Canvas 以及 HTML 将你的数据生动的展现出来。D3 结合了强大的可视化交互技术以及数据驱动 DOM 的技术结合起来,让你可以借助于现代浏览器的强大功能自由的对数据进行可视化。

刚开始接触 D3,很容易会联想到 jQuery,因为他们在操作元素的语法很相似。那么 D3 和 jQuery的区别是什么呢,以下粗略理解:

  • D3 是数据驱动的,而 jQuery 不是。jQuery 只是操作元素,D3 不仅可以操作元素,还可以使用其特有的 data(),enter() and exit() 方法提供数据和回调。
  • D3 应用于数据可视化,jQuery 应用于网页开发。D3 有很多数据可视化扩展插件,而 jQuery 有很多 web app 插件。
  • 两者都是 DOM 操作类 JS 库,都拥有 CSS 样式选择器和流畅的 API 且建立在 web 标准上,故而有些相似。

学习 D3 需要什么预备知识

想要通过 D3 来开启数据可视化之旅的朋友,需要什么预备知识呢?

  • HTML:超文本标记语言,用于设定网页的内容
  • CSS:层叠样式表,用于设定网页的样式
  • JavaScript:一种直译式脚本语言,用于设定网页的行为
  • DOM:文档对象模型,用于修改文档的内容和结构
  • SVG:可缩放矢量图形,用于绘制可视化的图形

D3安装

D3 官网d3js.org/

D3 是一个 JavaScript 函数库,并不需要通常所说的“安装”。它只有一个文件,在 HTML 中引用即可。 使用CDN URL d3js.org/d3.v7.min.j… 将D3.js库包含到我们的页面中,如下所示。

Example - 让我们考虑以下示例。

<!DOCTYPE html>
<html lang = "en">
   <head>
        <script src="https://d3js.org/d3.v7.min.js"></script>
   </head>
   <body>
        <script>
        // write your d3 code here.. 
        </script>
   </body>
</html>

如果使用 npm, 则可以通过 npm install d3 来安装

第一个程序 HelloWorld

<body>
  	<p></p>
  	<p></p>
    <script>
    	var p = d3.select("body")
    		.selectAll("p");
    		
        p.text("Hello World");
        //修改段落的颜色和字体大小
        p.style("color","red")
        .style("font-size","72px");
    </script>
</body>

这里涉及一个概念:选择集

使用 d3.select() 或 d3.selectAll() 选择元素后返回的对象,就是选择集。

另外,有人会发现,D3 能够连续不断地调用函数,形如:

d3.select().selectAll().text()

这称为链式语法,和 JQuery 的语法很像.

选择元素和绑定数据

选择元素和绑定数据是 D3 最基础的内容,本文将对其进行一个简单的介绍。

如何选择元素

在 D3 中,用于选择元素的函数有两个:

  • d3.select():是选择所有指定元素的第一个
  • d3.selectAll():是选择指定元素的全部

这两个函数返回的结果称为选择集。

例如,选择集的常见用法如下。

var body = d3.select("body"); //选择文档中的body元素
var p1 = body.select("p");      //选择body中的第一个p元素
var p = body.selectAll("p");    //选择body中的所有p元素
var svg = body.select("svg");   //选择body中的svg元素
var rects = svg.selectAll("rect");  //选择svg中所有的svg元素

选择集和绑定数据通常是一起使用的。

如何绑定数据

D3 有一个很独特的功能:能将数据绑定到 DOM 上,也就是绑定到文档上。这么说可能不好理解,例如网页中有段落元素 p 和一个整数 5,于是可以将整数 5 与 p 绑定到一起。绑定之后,当需要依靠这个数据才操作元素的时候,会很方便。

D3 中是通过以下两个函数来绑定数据的:

  • datum():绑定一个数据到选择集上
  • data():绑定一个数组到选择集上,数组的各项值分别与选择集的各元素绑定

相对而言,data() 比较常用。

假设现在有三个段落元素如下。

<p>Apple</p>
<p>Pear</p>
<p>Banana</p>
datum()

假设有一字符串 China,要将此字符串分别与三个段落元素绑定,代码如下:

var str = "China";

var body = d3.select("body");
var p = body.selectAll("p");

p.datum(str);

p.text(function(d, i){
    return "第 "+ i + " 个元素绑定的数据是 " + d;
});

绑定数据后,使用此数据来修改三个段落元素的内容,其结果如下:

第 0 个元素绑定的数据是 China
第 1 个元素绑定的数据是 China
第 2 个元素绑定的数据是 China

在上面的代码中,用到了一个无名函数 function(d, i)。当选择集需要使用被绑定的数据时,常需要这么使用。其包含两个参数,其中:

  • d 代表数据,也就是与某元素绑定的数据。
  • i 代表索引,代表数据的索引号,从 0 开始。

例如,上述例子中:第 0 个元素 apple 绑定的数据是 China。

data()

有一个数组,接下来要分别将数组的各元素绑定到三个段落元素上。

var dataset = ["I like dog","I like cat","I like snake"];

调用 data() 绑定数据,并替换三个段落元素的字符串为被绑定的字符串,代码如下:

var body = d3.select("body");
var p = body.selectAll("p");

p.data(dataset)
   .text(function(d, i){
        return d;
   });

这段代码也用到了一个无名函数 function(d, i),其对应的情况如下:

当 i == 0 时, d 为 I like dog。
当 i == 1 时, d 为 I like cat。
当 i == 2 时, d 为 I like snake。

此时,三个段落元素与数组 dataset 的三个字符串是一一对应的,因此,在函数 function(d, i) 直接 return d 即可。

结果自然是三个段落的文字分别变成了数组的三个字符串。

I like dog
I like cat
I like snake

代码说明:

  • 其实和datum()大体一样,只不过现在是数组元素和选择集有着对应关系

选择、插入、删除元素

选择元素

1、选择单个元素

现在我们已经知道,d3.js中选择元素的函数有select()和selectAll(),下面来详细讲解一下

假设我们的<body> 中有三个<p>,如下

<p>dog</p>
<p>cat</p>
<p>pig</p>
<p>rat</p>

选择第一个元素

var p = d3.select("body")
    .select("p");
    p.style("color","red");

运行结果:

dog // 红色
cat // 黑色
pig // 黑色
rat // 黑色

代码说明:

  • var p = d3.select("body").select("p");注意这里用的是select("p")而不是selectAll。
  • p.style("color","red");这里是为text添加样式,设置颜色为红色

2、选择全部元素

var p = d3.select("body")
    .selectAll("p");
p.style("color","red");

运行结果:

dog // 红色
cat // 红色
pig // 红色
rat // 红色

代码说明:

  • 也就是把select("p")改成selectAll("p")就好

3、选择任意元素

这需要改一下元素的属性,如下

<p>dog</p>
<p class="myP2">cat</p>
<p id="myP3">pig</p>
<p>rat</p>

注意这里更改了两个<p>的属性,第二个<p>加了class属性,访问时使用 .myP2(前面加点),第三个<p>加了id属性,访问时使用#myP3(前面加#),下面演示

var p = d3.select("body")
    .selectAll(".myP2");
p.style("color","red");

运行结果:

dog // 黑色
cat // 红色
pig // 黑色
rat // 黑色

代码说明:

  • 这里也就是根据元素的属性——class属性来选择特定的元素,(id属性用法类似)

4、选择任意元素(扩展)

利用function(d,i)来选择特定元素,因为我们已经知道i在这里代表的索引号,那么可以利用条件语句来选择我们需要的元素,如:

var dataset = [3,6,9,12];
var p = d3.select("body")
    .selectAll("p")
    .data(dataset)
    .text(function(d,i){
        if(i==3){
            d3.select(this).style("color","red");
        }
        return d;
    })

运行结果:

3 // 黑色
6 // 黑色
9 // 黑色
12 // 红色

代码说明:

  • if(i==3){ d3.select(this).style("color","red"); }由这里可以看出,我们选择的是第4个元素
  • var p = d3.select("body") .selectAll("p") .data(dataset) .text(function(d,i))你可能已经之一到这样的形式语法——链式语法,在D3.js中经常用到

插入元素

D3.js中涉及两种插入函数

  • append():在选择集尾部插入元素
  • insert():在选择集前面插入元素

1、append()

var p = d3.select("body")
    .append("p")
    .text("another animal")
    .style("color","red");

运行结果:

dog // 黑色
cat // 黑色
pig // 黑色
rat // 黑色
another animal // 红色

代码说明:

  • 也就是先选择<body>这个元素,然后在其内部的最后添加一个新的<p>

2、insert()

var p = d3.select("body")
    .insert("p","#myP3")
    .text("insert an animal")
    .style("color","red");

运行结果:

dog // 黑色
cat // 黑色
another animal // 红色
pig // 黑色
rat // 黑色

代码说明:

  • insert("p","#myP3")表示在属性id为myP3的元素前面插入一个新的元素<p>,前面我们已经知道,我们将第三个<p>元素的属性id设为myP3,(pig),所以结果正确

删除元素

删除元素很简单,对于选择的元素,使用remove();即可

var p = d3.select("body")
    .select("#myP3")
    .remove();

代码说明:

  • 这样就是删除了属性id为myP3的元素

理解Update、Enter、Exit

Update、Enter、Exit是D3.js中很重要的概念。

  1. update() 当对应的元素正好满足时 ( 绑定数据数量 = 对应元素 )

实际上并不存在这样一个函数,只是为了要与之后的 enter 和 exit 一起说明才想象有这样一个函数。但对应元素正好满足时,直接操作即可,后面直接跟 text ,style 等操作即可。

  1. enter() 当对应的元素不足时 ( 绑定数据数量 > 对应元素 )

当对应的元素不足时,通常要添加元素,使之与绑定数据的数量相等。后面通常先跟 append 操作。

  1. exit() 当对应的元素过多时 ( 绑定数据数量 < 对应元素 )

当对应的元素过多时,通常要删除元素,使之与绑定数据的数量相等。后面通常要跟 remove 操作。

<body>
  <ul id = "list">
    <li></li>
    <li></li>
    <li></li>
  </ul>
</body>

update()

d3.select("#list").selectAll("li")
   .data([10, 20, 30, 40])
   .text(function(d) { return d; });

enter()

d3.select("#list").selectAll("li")
    .data([10, 20, 30, 25, 15])
    .text(function(d) 
        { return "This is pre-existing element and the value is " + d; })
    .enter()
    .append("li")
    .text(function(d) 
        { return "This is dynamically created element and the value is " + d; });

exit()

d3.select("#list").selectAll("li")
   .data([10, 20])
   .text(function(d) { return d; })
   .exit()
   .remove()

做一个简单的图表

为了做一个简单的图表,我们还是需要以下新的知识点

  • svg画布:svg绘制的是矢量图(还有canvas画布,这个是JavaScript用来绘制2D图像的,是位图)
  • rect元素:是d3中在svg中绘制矩形的元素,x:矩形左上角的 x 坐标,y:矩形左上角的 y 坐标,width:矩形的宽度,height:矩形的高度
  • g元素:分组的时候使用

有了这些知识后,我们开始绘制

1、数据准备

var marge = {top:60,bottom:60,left:60,right:60}//设置边距
var dataset = [ 250 , 210 , 170 , 130 , 90 ];  //数据(表示矩形的宽度)

2、得到svg画布,并创建分组

var svg = d3.select("svg");//得到svg画布
var g = svg.append("g")//定义一个用来装整个图表的一个分组,并设置他的位置
    .attr("transform","translate("+marge.top+","+marge.left+")");

其中.attr(xxxx)是用来设置属性的,而这里的“transform”是用来设置位置, 注意:"translate("+marge.top+","+marge.left+")"这句话本质上是一个字符串,因为其中有变量,所以使用字符串拼接

3、画出矩形

var rectHeight = 30;//设置每一个矩形的高度
g.selectAll("rect")
    .data(dataset)
    .enter()
    .append("rect")
    .attr("x",20)//设置左上点的x
    .attr("y",function(d,i){//设置左上点的y
        return i*rectHeight;
    })
    .attr("width",function(d){//设置宽
        return d;
    })
    .attr("height",rectHeight-5)//设置长
    .attr("fill","blue");//颜色填充

其中g.selectAll("rect") .data(dataset) .enter() .append("rect") 我们在前面已经学过这段话表示添加足够的rect元素(也就是enter的用法)

比例尺的使用

比例尺在D3.js中是一个很重要的东西,我们可以这样理解d3.js中的比例尺——一种映射关系,从domain映射到range域(为什么会是domain和range呢?等一下你就会看到,因为我们在建立比例尺是常常会用到domain()和range()两个函数,当然不是绝对的,D3中有很多类型的比例尺)

下面介绍在本套教程中常用的两种比例尺

  • 线性比例尺
  • 序数比例尺

线性比例尺

domain域和range域都可以连续变化

线性比例尺,能将一个连续的区间,映射到另一区间。要解决柱形图宽度的问题,就需要线性比例尺。

<body>
    <script>
    	var dataset = [1.2, 2.3, 0.9, 1.5, 3.3];
    	var min = d3.min(dataset);//得到最小值
    	var max = d3.max(dataset);//得到最大值
    	var scaleLinear = d3.scaleLinear()
    		.domain([min,max])
    		.range([0,300]);
    	document.write("scaleLinear(1)输出:"+scaleLinear(1));
    	d3.select("body").append("br");//换行
    	document.write("scaleLinear(2)输出:"+scaleLinear(2));
    	d3.select("body").append("br");
    	document.write("scaleLinear(3.3)输出:"+scaleLinear(3.3));
    </script>
</body>

运行结果

scaleLinear(1)输出:12.499999999
scaleLinear(2)输出:137.5
scaleLinear(3.3)输出:300

代码说明:

  • var scaleLinear = d3.scaleLinear() .domain([min,max]) .range([0,300]);也就是[0.9,3.3]映射[0,300]

  • scaleLinear(3.3),由映射关系可以知道,这里的输出为300,实际上的输出也是这样

其中,d3.scaleLinear() 返回一个线性比例尺。domain() 和 range() 分别设定比例尺的定义域和值域。在这里还用到了两个函数,它们经常与比例尺一起出现:

d3.max() d3.min() 这两个函数能够求数组的最大值和最小值,是 D3 提供的。按照以上代码,

比例尺的定义域 domain 为:[0.9, 3.3]

比例尺的值域 range 为:[0, 300]

因此,当输入 0.9 时,返回 0;当输入 3.3 时,返回 300。当输入 2.3 时呢?返回 175,这是按照线性函数的规则计算的。

有一点请大家记住:

d3.scaleLinear() 的返回值,是可以当做函数来使用的。因此,才有这样的用法:linear(0.9)。

序数比例尺

有时候,定义域和值域不一定是连续的。domain域和range域是离散的,也就是数组。

<body>
    <script>
    	var index = [0,1,2,3,4];
    	var color = ["red","blue","yellow","black","green"];
    	var scaleOrdinal = d3.scaleOrdinal()
    		.domain(index)
    		.range(color);
    	document.write("scaleOrdinal(1)输出:"+scaleOrdinal(1));
    	d3.select("body").append("br");//换行
    	document.write("scaleOrdinal(2)输出:"+scaleOrdinal(2));
    	d3.select("body").append("br");
    	document.write("scaleOrdinal(4)输出:"+scaleOrdinal(4));
    </script>
</body>

运行结果:

scaleOrdinal(1)输出:blue
scaleOrdinal(2)输出:yellow
scaleOrdinal(4)输出:green

代码说明:

  • var scaleOrdinal = d3.scaleOrdinal() .domain(index) .range(color);建立一个序数比例尺

给柱形图添加比例尺

<body>
    <svg width="960" height="600"></svg>
    <script>
    	var marge = {top:60,bottom:60,left:60,right:60}
    	var dataset = [ 2.5 , 2.1 , 1.7 , 1.3 , 0.9 ];  
    	
    	//定义一个线性比例尺
    	var scaleLinear = d3.scaleLinear()
    		.domain([0,d3.max(dataset)])
    		.range([0,300]);
    	
    	var svg = d3.select("svg");
    	var g = svg.append("g")
    		.attr("transform","translate("+marge.top+","+marge.left+")");
    	
    	var rectHeight = 30;  //每个矩形所占的像素高度(包括空白)
    	
    	g.selectAll("rect")
    	    .data(dataset)
    	    .enter()
    	    .append("rect")
    	    .attr("x",20)
    	    .attr("y",function(d,i){
    	    	return i*rectHeight;
    	    })
    	    .attr("width",function(d){
    	    	return scaleLinear(d);//设置宽,并在这里使用比例尺
    	    })
    	    .attr("height",rectHeight-5)
    	    .attr("fill","blue");
    </script>
</body>

代码说明:

  • .attr("width",function(d){ return scaleLinear(d);//设置宽,并在这里使用比例尺 })可以发现,我们在这里使用比例尺

坐标轴

D3中没有现成的坐标轴图形,需要我们自己用其他组件拼凑而成。D3中提供了坐标轴组件, 使得我们在SVG中绘制一个坐标轴变得像添加一个普通元素那样简单为了表绘制一个坐标轴,我们还是需要以下新的知识点

  • call()函数

    定义一个坐标轴

    坐标轴是有朝向的,在这里我们以向下朝向、水平方向的坐标轴为例,其他朝向的(比如向左朝向的、垂直的坐标轴)类似, 这里是接着上一章来的,数据用的也是上一章的

//为坐标轴定义一个线性比例尺
var xScale = d3.scaleLinear()
    .domain([0,d3.max(dataset)])
    .range([0,250]);
//定义一个坐标轴
var xAxis = d3.axisBottom(xScale)//定义一个axis,由bottom可知,是朝下的
    .ticks(7);//设置刻度数目
g.append("g")
    .attr("transform","translate("+20+","+(dataset.length*rectHeight)+")")
    .call(xAxis);

代码说明:

  • var xScale = d3.scaleLinear() .domain([0,d3.max(dataset)]) .range([0,250]);这是比例尺的定义,我们应该比较熟悉

  • var xAxis = d3.axisBottom(xScale)//定义一个axis,由bottom可知,是朝下的 .ticks(7);//设置刻度数目,不过好像并不太好使

  • g.append("g") .attr("transform","translate("+20+","+(dataset.length*rectHeight)+")") .call(xAxis);

    这里需要重点说明一下

    .attr("transform","translate("+20+","+(dataset.length*rectHeight)+")")这是设置位置信息

    g.append("g").call(xAxis),这里出现了一个新的函数,这也是这一章的重点,我们知道xAxis是我们定义的一个坐标轴, 其实它本身也是一个函数,所以这句话的意思是将新建的分组<g>传给xAxis()函数,用以绘制,所以这句代码等价于xAixs (g.append("g") ) ;

    为柱状图添加坐标轴

    好了,我们已经知道怎样绘制一个坐标轴了,现在为第七章绘制的柱状图加上一个坐标轴,代码很简单

<body>
    <svg width="960" height="600"></svg>
    <script>
    	var marge = {top:60,bottom:60,left:60,right:60}
    	var dataset = [ 2.5 , 2.1 , 1.7 , 1.3 , 0.9 ];  
    	
    	var scaleLinear = d3.scaleLinear()
    		.domain([0,d3.max(dataset)])
    		.range([0,250]);
    		
    	var svg = d3.select("svg");
    	var g = svg.append("g")
    		.attr("transform","translate("+marge.top+","+marge.left+")");
    	
    	var rectHeight = 30;
    	
    	g.selectAll("rect")
    		.data(dataset)
    		.enter()
    		.append("rect")
    		.attr("x",20)
    		.attr("y",function(d,i){
    			return i*rectHeight;
    		})
    		.attr("width",function(d){
    			return scaleLinear(d);
    		})
    		.attr("height",rectHeight-5)
    		.attr("fill","blue");
    		
    	//为坐标轴定义一个线性比例尺
    	var xScale = d3.scaleLinear()
    		.domain([0,d3.max(dataset)])
    		.range([0,250]);
    	//定义一个坐标轴
    	var xAxis = d3.axisBottom(xScale)//定义一个axis,由bottom可知,是朝下的
    		.ticks(7);//设置刻度数目
    	g.append("g")
    		.attr("transform","translate("+20+","+(dataset.length*rectHeight)+")")
    		.call(xAxis);
    </script>
</body>

完整的柱状图

一个完整的柱状图应该包括的元素有——矩形、文字、坐标轴,现在,我们就来一一绘制它们,这章是前面几章的综合,这一章只有少量新的知识点,它们是

  • d3.scaleBand():这也是一个坐标轴,可以根据输入的domain的长度,等分rangeRound域(类比range域)
  • d3.range():这个比较复杂,建议去看百度(或者官方API),在这里我只讲一下这个返回一个等差数列

1、得到SVG画布

var marge = {top:60,bottom:60,left:60,right:60}
var svg = d3.select("svg");//得到SVG画布
var width = svg.attr("width");//得到画布的宽
var height = svg.attr("height");//得到画布的长
var g = svg.append("g")
    .attr("transform","translate("+marge.top+","+marge.left+")");

2、数据集

var dataset = [10,20,30,23,13,40,27,35,20];

3、分别在x方向和y方向绘制坐标轴

var xScale = d3.scaleBand()
    .domain(d3.range(dataset.length))
    .rangeRound([0,width-marge.left-marge.right]);
var xAxis = d3.axisBottom(xScale);
    
var yScale = d3.scaleLinear()
    .domain([0,d3.max(dataset)])
    .range([height-marge.top-marge.bottom,0]);
var yAxis = d3.axisLeft(yScale);

代码说明:

  • 这里出现了d3.scaleBand(),这是一个坐标轴

  • d3.range(dataset.length),前面说过,这里返回的是一个等差数列,dataset.length=9,所以返回的是0到8的等差数列, 其实d3.range()这个函数中可以有三个参数!

4、为每个矩形和对应的文字创建一个分组<g>

var gs = g.selectAll(".rect")
    .data(dataset)
    .enter()
    .append("g");

代码说明:

  • 这里有牵涉到enter(),这回大家应该熟悉了吧!这里的返回值是一个包含有dataset.length个元素的选择集

5、绘制矩形

var rectPadding = 20;//矩形之间的间隙
gs.append("rect")
.attr("x",function(d,i){
    return xScale(i)+rectPadding/2;
})	
.attr("y",function(d){
    return yScale(d);
})
.attr("width",function(){
    return xScale.step()-rectPadding;
})
.attr("height",function(d){
    return height-marge.top-marge.bottom-yScale(d);
})
.attr("fill","blue");

6、绘制文字

gs.append("text")
.attr("x",function(d,i){
    return xScale(i)+rectPadding/2;
})
.attr("y",function(d){
return yScale(d);
})
.attr("dx",function(){
    (xScale.step()-rectPadding)/2;
})
.attr("dy",20)
.text(function(d){
    return d;
})

这里也是一样,需要计算位置信息

让图表动起来

为了让图表动起来,我们还是需要以下新的知识点

  • .attr(xxx) .transition() .attr(xxx),transition()表示添加过渡,也就是从前一个属性过渡到后一个属性
  • .duration(2000),表示过渡时间持续2秒
  • .delay(500),表示延迟0.4秒后再进行过渡
  • .ease(d3.easeElasticInOut)表示过渡方式,注意这里和v3版本的区别

有了上面的基础知识后,现在我们来实现动态图表

1、为矩形添加过渡效果

gs.append("rect")
.attr("x",function(d,i){
    return xScale(i)+rectPadding/2;
})	
.attr("y",function(d){//这里是要改变的,即初始状态
    var min = yScale.domain()[0];
    return yScale(min);//可以得知,这里返回的是最大值
})
.attr("width",function(){
    return xScale.step()-rectPadding;
})
.attr("height",function(d){//这里要改变,即初始状态
    return 0;
})
.attr("fill","blue")
.transition()//添加过渡
.duration(2000)//持续时间
.delay(function(d,i){//延迟
    return i*400;
})
//.ease(d3.easeElasticInOut)//这里读者可以自己将注释去掉,看看效果(chrome浏览器会报错,但是不影响效果)
.attr("y",function(d){//回到最终状态
    return yScale(d);
})
.attr("height",function(d){//回到最终状态
    return height-marge.top-marge.bottom-yScale(d);
})

代码说明:

  • 注意上面的注释即可

2、为文字添加过渡效果

gs.append("text")
.attr("x",function(d,i){
    return xScale(i)+rectPadding/2;
})
.attr("y",function(d){
    var min = yScale.domain()[0];
    return yScale(min);
})
.attr("dx",function(){
    (xScale.step()-rectPadding)/2;
})
.attr("dy",20)
.text(function(d){
    return d;
})
.transition()
.duration(2000)
.delay(function(d,i){
    return i*400;
})
//.ease(d3.easeElasticInOut)
.attr("y",function(d){
    return yScale(d);
});

代码说明:

  • 和矩形的类似

交互式操作

与图形进行交互操作是很重要的!所谓的交互操作也就是为图形元素添加监听事件,比如说当你鼠标放在某个图形元素上面的时候,就会显示相应的文字,而当鼠标移开后,文字就会消失,或者鼠标单击一下某图形元素就会使它动起来

为了与图形元素进行交互操作,我们还是需要以下新的知识点

  • on("eventName",function);该函数是添加一个监听事件,它的第一个参数是事件类型,第二个参数是响应事件的内容
  • d3.select(this),选择当前元素

常见的事件类型

  • click:鼠标单击某元素时触发,相当于mousedown和mouseup的组合
  • mouseover:鼠标放在某元素上触发
  • mouseout:鼠标移出某元素时触发
  • mousemove:鼠标移动时触发
  • mousedown:鼠标按钮被按下时触发
  • mouseup:鼠标按钮被松开时触发
  • dblclick:鼠标双击时触发

当然还有很多,上面列出来的只是关于鼠标监听事件,还有键盘等的监听事件,在这里就不多讲了,给上官网API:developer.mozilla.org/en-US/docs/…

查看监听事件

.on("click",function(){
    console.log(d3.event);
})

这样的话你就可以在chrome浏览器的控制台看到输出的event事件了

为柱状图添加监听事件

.on("mouseover",function(){
    var rect = d3.select(this)
    .transition()
    .duration(1500)//当鼠标放在矩形上时,矩形变成黄色
    .attr("fill","yellow");
})
.on("mouseout",function(){
    var rect = d3.select(this)
    .transition()
    .delay(1500)
    .duration(1500)//当鼠标移出时,矩形变成蓝色
    .attr("fill","blue");
})

代码说明:

  • var rect = d3.select(this),熟悉编程语言的人可能比较清楚this的含义,因为我是在矩形上边添加监听,所以句代码返回的是当前矩形
  • 前面我们已经讲过transition的用法,大家应该很熟悉了。这里只是一个比较综合的案例
  • 只找到第九章的绘制柱状图的那段代码中绘制矩形的代码段的最后加上上面的代码即可

布局

布局,可以理解成 “制作常见图形的函数”,有了它制作各种相对复杂的图表就方便多了。

布局是什么 布局,英文是 Layout。从字面看,可以想到有“决定什么元素绘制在哪里”的意思。布局是 D3 中一个十分重要的概念。 D3 与其它很多可视化工具不同,相对来说较底层,对初学者来说不太方便,但是一旦掌握了,就比其他工具更加得心应手。下图展示了 D3 与其它可视化工具的区别:

  • 大部分可视化工具:数据 ———— 绘图函数 ———— 图表
  • D3:数据 ———— 布局 ———— 获得绘图所需数据 ———— 在画布上添加相应的图形 ———— 图表

可以看到,D3 的步骤相对来说较多。坏处是对初学者不方便、也不好理解。好处是能够制作出更加精密的图形。因此,我们可以据此定义什么时候选择 D3 比较好:

  • 选择 D3:如果希望开发脑海中任意想象到的图表。
  • 选择 Highcharts、Echarts 等:如果希望开发几种固定种类的、十分大众化的图表。

看起来,D3 似乎是为艺术家或发烧友准备的。有那么点意思,但请初学者也不要放弃。

如何理解布局

从上面的图可以看到,布局的作用是:将不适合用于绘图的数据转换成了适合用于绘图的数据。

因此,为了便于初学者理解,本站的教程叫布局的作用解释成:数据转换。

布局有哪些

D3 总共提供了 12 个布局:饼状图(Pie)、力导向图(Force)、弦图(Chord)、树状图(Tree)、集群图(Cluster)、捆图(Bundle)、打包图(Pack)、 直方图(Histogram)、分区图(Partition)、堆栈图(Stack)、矩阵树图(Treemap)、层级图(Hierarchy)。

12 个布局中,层级图(Hierarchy)不能直接使用。集群图、打包图、分区图、树状图、矩阵树图是由层级图扩展来的。如此一来,能够使用的布局是 11 个 (有 5 个是由层级图扩展而来)。这些布局的作用都是将某种数据转换成另一种数据,而转换后的数据是利于可视化的。

  • Bundle —- 捆图

  • Chord —- 弦图

  • Cluster —- 集群图

  • Force —- 力学图、力导向图

  • Histogram —- 直方图(数据分布图)

  • Pack —- 打包图

  • Partition —- 分区图

  • Pie —- 饼状图

  • Stack —- 堆栈图

  • Tree —- 树状图

  • Treemap —- 矩阵树图

饼状图

这一章我们来绘制一个简单的饼状图,我们只绘制构成饼状图基本的元素——扇形、文字,为了绘制一个饼状图,我们还是需要以下新的知识点

  • d3.arc( {} ),弧形生成器,用以绘制弧形,需要传入一些用以绘制弧形基本的数据的对象,例如,该对象的属性可以包括(我用官网api的示例)
var arc = d3.arc();

arc({
  innerRadius: 0,
  outerRadius: 100,
  startAngle: 0,
  endAngle: Math.PI / 2
}); // "M0,-100A100,100,0,0,1,100,0L0,0Z"
  • d3.pie(),饼状图生成器,用以产生绘制一个弧的必须的数据的对象(利用原始数据产生新的数据),使用方式:d3.pie(data)
var data = [1, 1, 2, 3, 5, 8, 13, 21];
var arcs = d3.pie()(data);
  • d3.arc().centroid(),这里使用官方api的解释,采用官网的解析:

计算由给定 arguments 生成的 generated的中间点 [x, y]. arguments 是任意的,它们会被传递给 arc 生成器的访问器。 为了与生成的弧保持一致,访问器必须是确定的。例如,相同的参数返回相同的值。中间点被定义为 (startAngle + endAngle) / 2 和 (innerRadius + outerRadius) / 2。

注意,中间点 并不是几何中心,因为几何中心点可能位于弧之外; 这个方法可以用来方便的对 labels 进行定位

  • d3.schemeCategory10,使用官方API示例,可以看出,这段代码表示一些离散的色彩

有了上面的基本知识后(如果你理解了),接下来的绘制过程就简单了

1、数据准备

var marge = {top:60,bottom:60,left:60,right:60}
var svg = d3.select("svg")
var width = svg.attr("width")
var height = svg.attr("height")
var g = svg.append("g")
    .attr("transform","translate("+marge.top+","+marge.left+")");
var dataset = [ 30 , 10 , 43 , 55 , 13 ];//需要将这些数据变成饼状图的数据

2、设置一个颜色比例尺

//设置一个color的颜色比例尺,为了让不同的扇形呈现不同的颜色
var colorScale = d3.scaleOrdinal()
    .domain(d3.range(dataset.length))
    .range(d3.schemeCategory10);

3、新建一个饼状图

//新建一个饼状图
var pie = d3.pie();

4、新建一个弧形生成器

//新建一个弧形生成器
var innerRadius = 0;//内半径
var outerRadius = 100;//外半径
var arc_generator = d3.arc()
    .innerRadius(0)
    .outerRadius(100);

5、利用饼状图生成器转换数据

//将原始数据变成可以绘制饼状图的数据,
var pieData = pie(dataset);

//在浏览器的控制台打印pieData
console.log(pieData);

控制台输出的结果:

[{
    data:30,
    endAngle:5.3261438365495835,
    index:2,
    padAngle:0,
    startAngle:4.077828874858275,
    value:30
},{
    data:10,
    endAngle:6.283185307179586,
    index:4,
    padAngle:0,
    startAngle:5.86708031994915,
    value:10
},{
    data:43,
    endAngle:4.077828874858275,
    index:1,
    padAngle:0,
    startAngle:2.288577429767399,
    value:43
},{
    data:55,
    endAngle:2.288577429767399,
    index:0,
    padAngle:0,
    startAngle:0,
    value:55
},{
    data:13,
    endAngle:5.86708031994915,
    index:0,
    padAngle:0,
    startAngle:5.3261438365495835,
    value:13
}]

可以看到,数据的格式已经成功转换,已经是可以画弧的数据了

6、开始绘制,老规矩,先为每一个扇形及其对应的文字建立一个分组

var gs = g.selectAll(".g")
    		.data(pieData)
    		.enter()
    		.append("g")
    		.attr("transform","translate("+width/2+","+height/2+")")//位置信息

7、绘制扇形

//绘制饼状图的各个扇形
gs.append("path")
    .attr("d",function(d){
        return arc_generator(d);//往弧形生成器中出入数据
    })
    .attr("fill",function(d,i){
        return colorScale(i);//设置颜色
    });

代码说明:

  • arc_generator(d);//往弧形生成器中出入数据,由官方API的示例可知(我已经在本章开头截了图), 弧形生成器所需要传入的数据就是饼状图生成器返回的数据

8、文字

//绘制饼状图上面的文字信息
gs.append("text")
.attr("transform",function(d){//位置设在中心处
    return "translate("+arc_generator.centroid(d)+")";
})
.attr("text-anchor","middle")
.text(function(d){
    return d.data;
})

注意:这里的文字获取需要 用到d.data,我们可以根据在控制台输出的数据格式可以知道,因为在转换数据后,新数据的data域才是原始数据

力导向图

力导向图(Force-Directed Graph),是绘图的一种算法。在二维或三维空间里配置节点,节点之间用线连接,称为连线。各连线的长度几乎相等, 且尽可能不相交。节点和连线都被施加了力的作用,力是根据节点和连线的相对位置计算的。根据力的作用,来计算节点和连线的运动轨迹, 并不断降低它们的能量,最终达到一种能量很低的安定状态。

力导向图能表示节点之间的多对多的关系。

为了绘制一个力导向图,我们还是需要以下新的知识点

  • d3.forceSimulation() ,新建一个力导向图,
  • d3.forceSimulation().force(),添加或者移除一个力,这里给出官方示例:
var simulation = d3.forceSimulation(nodes)
    .force("charge", d3.forceManyBody())
    .force("link", d3.forceLink(links))
    .force("center", d3.forceCenter());

这里说明一下对于d3.forceSimulation().force(name),也就是当force中只有一个参数,这个参数是某个力的名称,那么这段代码返回的是某个具体的力, (根据上面图片官方对force的说明也可以知道),例如d3.forceSimulation().force(“link”),则返回的是d3.forceLink()这个力,注意对照上面的图片, 这个用法在下面会经常被用到

  • d3.forceSimulation().nodes(),输入是一个数组,然后将这个输入的数组进行一定的数据转换,例如添加坐标什么的!这里给出官方API的解释

如果指定了 nodes 则将仿真的节点设置为指定的对象数组,并根据需要创建它们的位置和速度,然后 重新初始化 绑定的 力模型,并返回当前仿真。如果没有指定 nodes 则返回当前仿真的节点数组。

每个 node 必须是一个对象类型,下面的几个属性将会被仿真系统添加:

index - 节点在 nodes 数组中的索引
x - 节点当前的 x-坐标
y - 节点当前的 y-坐标
vx - 节点当前的 x-方向速度
vy - 节点当前的 y-方向速度

位置 ⟨x,y⟩ 以及速度 ⟨vx,vy⟩ 随后可能被仿真中的 力模型 修改. 如果 vx 或 vy 为 NaN, 则速度会被初始化为 ⟨0,0⟩. 如果 x 或 y 为 NaN, 则位置会按照 phyllotaxis arrangement 被初始化, 这样初始化布局是为了能使得节点在原点周围均匀分布。

如果想要某个节点固定在一个位置,可以指定以下两个额外的属性:

fx - 节点的固定 x-位置
fy - 节点的固定 y-位置

每次 tick 结束后,节点的 node.x 会被重新设置为 node.fx 并且 node.vx 会被设置为 0;同理 node.y 会被重新替换为 node.fy 并且 node.vy 被设置为 0;如果想要某个节点解除固定,则将 node.fx 和 node.fy 设置为 null 或者删除这两个属性。

如果指定的节点数组发生了变化,比如添加或删除了某些节点,则这个方法必须使用新的节点数组重新被调用一次以通知仿真发生了变化。仿真不会对输入数组做副本。

  • d3.forceLink.links(),这里输入的也是一个数组(边集),然后对输入的边集进行转换,等一下我们会看到,它到底被转换成什么样子的
  • tick函数,这个函数对于力导向图来说非常重要,因为力导向图是不断运动的,每一时刻都在发生更新,所以需要不断更新节点和连线的位置
  • d3.drag(),是力导向图可以被拖动

好了,有了这些基础知识后,我们开始绘制一个力导向图

1、数据准备

var marge = {top:60,bottom:60,left:60,right:60}
var svg = d3.select("svg")
var width = svg.attr("width")
var height = svg.attr("height")
var g = svg.append("g")
    .attr("transform","translate("+marge.top+","+marge.left+")");
    
//准备数据
var nodes = [//节点集
    {name:"湖南邵阳"},
    {name:"山东莱州"},
    {name:"广东阳江"},
    {name:"山东枣庄"},
    {name:"泽"},
    {name:"恒"},
    {name:"鑫"},
    {name:"明山"},
    {name:"班长"}
];

var edges = [//边集
    {source:0,target:4,relation:"籍贯",value:1.3},
    {source:4,target:5,relation:"舍友",value:1},
    {source:4,target:6,relation:"舍友",value:1},
    {source:4,target:7,relation:"舍友",value:1},
    {source:1,target:6,relation:"籍贯",value:2},
    {source:2,target:5,relation:"籍贯",value:0.9},
    {source:3,target:7,relation:"籍贯",value:1},
    {source:5,target:6,relation:"同学",value:1.6},
    {source:6,target:7,relation:"朋友",value:0.7},
    {source:6,target:8,relation:"职责",value:2}
];

2、设置一个颜色比例尺

//设置一个color的颜色比例尺,为了让不同的扇形呈现不同的颜色
var colorScale = d3.scaleOrdinal()
    .domain(d3.range(nodes.length))
    .range(d3.schemeCategory10);

3、新建一个力导向图

var forceSimulation = d3.forceSimulation()
.force("link",d3.forceLink())
.force("charge",d3.forceManyBody())
.force("center",d3.forceCenter());

其实上面那一段代码你就可以从官网上面复制下来(我也是这么干的)

4、生成节点数据

//生成节点数据
forceSimulation.nodes(nodes)
.on("tick",ticked);//这个函数很重要,后面给出具体实现和说明

注意,这里出现了tick函数,我把它的实现写到了一个有名函数ticked(以前我们是不是写的都是无名函数)

5、生成边集数据

//生成边数据
forceSimulation.force("link")
    .links(edges)
    .distance(function(d){//每一边的长度
        return d.value*100;
    })

注意,这里出现了forceSimulation.force(name)的用法,前面已经详细解释了它的返回值

6、设置图形中心位置

//设置图形的中心位置	
forceSimulation.force("center")
    .x(width/2)
    .y(height/2);

这里也是forceSimulation.force(name)的用法

7、输出顶点集和边集

//在浏览器的控制台输出
console.log(nodes);
console.log(edges);

8、绘制边

//绘制边
var links = g.append("g")
    .selectAll("line")
    .data(edges)
    .enter()
    .append("line")
    .attr("stroke",function(d,i){
        return colorScale(i);
    })
    .attr("stroke-width",1);

这里注意一下,应该先绘制边,在绘制顶点,因为在d3中,各元素是有层级关系的,先绘制的在下面

9、边上的文字

var linksText = g.append("g")
.selectAll("text")
.data(edges)
.enter()
.append("text")
.text(function(d){
    return d.relation;
})

10、老规矩,先建立用来放在每个节点和对应文字的分组<g>

var gs = g.selectAll(".circleText")
    .data(nodes)
    .enter()
    .append("g")
    .attr("transform",function(d,i){
        var cirX = d.x;
        var cirY = d.y;
        return "translate("+cirX+","+cirY+")";
    })
    .call(d3.drag()
        .on("start",started)
        .on("drag",dragged)
        .on("end",ended)
    );

注意,这里出现了drag函数,对于call函数大家应该比较熟悉了!我们也可以发现,这里使用了三个有名函数,具体实现后面会给出

11、节点和文字

//绘制节点
gs.append("circle")
    .attr("r",10)
    .attr("fill",function(d,i){
        return colorScale(i);
    })
//文字
gs.append("text")
    .attr("x",-10)
    .attr("y",-20)
    .attr("dy",10)
    .text(function(d){
        return d.name;
    })

注意,这里的文字的到需要根据转换后数据的特点得到!!!

12、ticked函数的实现

function ticked(){
    links.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;});
        
    linksText.attr("x",function(d){
        return (d.source.x+d.target.x)/2;
    })
    .attr("y",function(d){
        return (d.source.y+d.target.y)/2;
    });
        
    gs.attr("transform",function(d) { return "translate(" + d.x + "," + d.y + ")"; });
}

注意,可以发现,这里写的都是位置信息,所以你在绘制相应的图形元素的时候,位置信息就不那么重要的,而且,建议先写完这个函数后,在进行测试

13、drag

function started(d){
    if(!d3.active){
        forceSimulation.alphaTarget(0.8).restart();
    }
    d.subject.fx = d.x;
    d.subject.fy = d.y;
}
function dragged(d){
    d.subject.fx = d.x;
    d.subject.fy = d.y;
}
function ended(d){
    if(!d3.active){
        forceSimulation.alphaTarget(0);
    }
    d.subject.fx = null;
    d.subject.fy = null;
}

drag中有三个函数,在这里进行了实现,其中d.fx和d.fy表示固定坐标,例如,现在我们看到dragged函数,我们可以发现这样的代码: d.subject.fx = d.x; d.subject.fy = d.y;,也就是在拖动节点的时候,鼠标位置在哪里,节点的固定位置就在哪里, 再看到ended函数,也就是结束拖动的时候触发,可以发现,固定坐标都为空,也就是不固定,这样模拟的效果才好(你们也可以试试去掉 ended函数会发生什么,这样可以更好的理解)

树状图

树状图,可表示节点之间的包含与被包含关系。

在本章我们将利用贝塞尔曲线作为树的边,并绘制一个完整的树状图,包括节点、边、文字,在这里我们会用到一个曲线生成器-贝塞尔曲线生成器, 看到这里,你是不是应该松一口气了,因为我们在绘制饼状图的时候就用到了一个弧形生成器,这两个有类似的地方,但是还是避免不了引入新的知识点

为了绘制一个树状图,我们还是需要以下新的知识点

  • d3.hierarchy(),层级布局,需要和tree生成器一起使用,来得到绘制树所需要的节点数据和边数据
  • d3.hierarchy().sum(),后序遍历。这里给出官方API的解释,虽然对于绘制一个基本的树状图用不到这个函数

从当前 node 开始以 post-order traversal 的次序为当前节点以及每个后代节点调用指定的 value 函数,并返回当前 node。这个过程会为每个节点附加 node.value 数值属性,属性值是当前节点的 value 值和所有后代的 value 的合计,函数的返回值必须为非负数值类型。value 访问器会为当前节点和 每个后代节点进行评估,包括内部结点;如果你仅仅想让叶节点拥有内部值,则可以在遍历到叶节点时返回 0。例如 这个例子, 使用如下设置等价于 node.count:

root.sum(function(d) { return d.value ? 1 : 0; });
  • d3.tree(),创建一个树状图生成器
  • d3.tree().size(),定义树的大小
  • d3.tree().separation(),定义邻居节点的距离,这里给出官方API的示例

如果指定了 seperation, 则设置间隔访问器为指定的函数并返回当前系统树布局。如果没有指定 seperation 则返回当前的间隔访问器,默认为:

function separation(a, b) {
  return a.parent == b.parent ? 1 : 2;
}

间隔访问器用来设置相邻的两个叶节点之间的间隔。指定的间隔访问器会传递两个叶节点 a 和 b,并且必须返回一个期望的间隔值。 这些节点通常是兄弟节点,如果布局将两个节点放置到相邻的位置,则可以通过配置间隔访问器设置相邻节点之间的间隔控制其期望的距离

我们可以看到,我们更常用的是第二种方法

  • node.descendants()得到所有的节点,已经经过转换的数据
  • node.links(),得到所有的边,已经经过转换的数据
  • d3.linkHorizontal(),创建一个贝塞尔生成曲线生成器,当然不止只有水平的,还有垂直的(d3.linkVertical()),这里给出官方API的示例

返回一个新的 link 生成器,生成的曲线在曲线的终点和起点处的切线是水平方向的。例如在 tree diagram 中对 links 进行可视化时,可以定义为:

var link = d3.linkHorizontal()
    .x(function(d) { return d.y; })
    .y(function(d) { return d.x; });

可以发现,点的x坐标和y坐标交换了位置,所有变成了水平的(现在你或许有主意怎样会做一个垂直的树状图了)

好!现在来绘制一个树状图

1、数据准备

//定义边界
var marge = {top:50, bottom:0, left:10, right:0};
var svg = d3.select("svg");
var width = svg.attr("width");
var height = svg.attr("height");
var g = svg.append("g")
    .attr("transform","translate("+marge.top+","+marge.left+")");
var scale = svg.append("g")
    .attr("transform","translate("+marge.top+","+marge.left+")");
//数据
var dataset = {
    name:"中国",
    children:[
        {
            name:"浙江",
            children:[
                {name:"杭州" ,value:100},
                {name:"宁波",value:100},
                {name:"温州",value:100},
                {name:"绍兴",value:100}
            ]
        },
        {
            name:"广西",
            children:[
                {
                    name:"桂林",
                    children:[
                        {name:"秀峰区",value:100},
                        {name:"叠彩区",value:100},
                        {name:"象山区",value:100},
                        {name:"七星区",value:100}
                    ]
                },
                {name:"南宁",value:100},
                {name:"柳州",value:100},
                {name:"防城港",value:100}
            ]
        },
        {
            name:"黑龙江",
            children:[
                {name:"哈尔滨",value:100},
                {name:"齐齐哈尔",value:100},
                {name:"牡丹江",value:100},
                {name:"大庆",value:100}
            ]
        },
        {
            name:"新疆" , 
            children:
            [
                {name:"乌鲁木齐"},
                {name:"克拉玛依"},
                {name:"吐鲁番"},
                {name:"哈密"}
            ]
        }
    ]
};

可以发现,数据本来就以树的形式存储

2、创建一个层级布局

var hierarchyData = d3.hierarchy(dataset)
.sum(function(d){
    return d.value;
});

这时候如果你打印hierarchyData的话,会得到一个以树形式组织的树

3、创建一个树状图

//创建一个树状图
var tree = d3.tree()
    .size([width-400,height-200])
    .separation(function(a,b){
        return (a.parent==b.parent?1:2)/a.depth;
    })

4、初始化树状图,也就是传入数据,并得到绘制树基本数据

var treeData = tree(hierarchyData);

5、得到边和节点(已经完成转换的)

var nodes = treeData.descendants();
var links = treeData.links();

6、输出边和节点

//输出节点和边
console.log(nodes);
console.log(links);

7、创建一个贝塞尔生成曲线生成器

var Bézier_curve_generator = d3.linkHorizontal()
    .x(function(d) { return d.y; })
    .y(function(d) { return d.x; });

其实这里我就是复制官网的示例

8、绘制边

//绘制边
g.append("g")
.selectAll("path")
.data(links)
.enter()
.append("path")
.attr("d",function(d){
    var start = {x:d.source.x,y:d.source.y};
    var end = {x:d.target.x,y:d.target.y};
    returnzier_curve_generator({source:start,target:end});
})
.attr("fill","none")
.attr("stroke","yellow")
.attr("stroke-width",1);

attr("d",function(d),这个函数需要注意一下,我们可以类别上次饼状图的画法,这里传入贝塞尔生成曲线生成器的参数有要求 (其实饼状图的输入参数我们也是按照特定的格式输入的),具体要求的格式,官方API有给出:

像这样的格式!然后再对照上面的代码看就很容易理解了

9、老规矩,先建立用来放在每个节点和对应文字的分组<g>

var gs = g.append("g")
.selectAll("g")
.data(nodes)
.enter()
.append("g")
.attr("transform",function(d){
    var cx = d.x;
    var cy= d.y;
    return "translate("+cy+","+cx+")";
});

10、绘制节点和文字

//绘制节点
gs.append("circle")
    .attr("r",6)
    .attr("fill","white")
    .attr("stroke","blue")
    .attr("stroke-width",1);
    
//文字
gs.append("text")
    .attr("x",function(d){
        return d.children?-40:8;
    })
    .attr("y",-5)
    .attr("dy",10)
    .text(function(d){
        return d.data.name;
    })

注意一下,.attr("x",function(d){ return d.children?-40:8; }) 这段代码表示,如果某节点有子节点,则对应的文字前移