整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

JavaScript中的堆栈 stack heap

JavaScript中的堆栈 stack heap 队列 数据结构学习 最全图片讲解
  • (stack) 栈stack为自动分配的内存空间,它由系统自动释放;
  • 堆(heap) 堆heap是动态分配的内存,大小不定也不会自动释放。
JavaScript 中的变量分为基本类型和引用类型。
  • 基本类型 (Undefined、Null、Boolean、Number和String)
    基本类型在内存中占据
    空间小、大小固定 ,他们的值保存在栈(stack)空间,是按值来访问
  • 引用类型 (对象、数组、函数)
    引用类型占据
    空间大、大小不固定, 栈内存中存放地址指向堆(heap)内存中的对象。是按引用访问的

如下图所示:栈内存中存放的只是该对象的访问地址, 在堆内存中为这个值分配空间 。 由于这种值得大小不固定,因此不能把它们保存到栈内存中。但内存地址大小的固定的,因此可以将内存地址保存在栈内存中。 这样,当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。


当我们看到一个变量类型是已知的,就分配在栈里面,比如INT,Double等。其他未知的类型,比如自定义的类型,因为系统不知道需要多大,所以程序自己申请,这样就分配在堆里面。

上方例子得知,当我改变arr2中的数据时,arr1中数据也发生了变化,当改变str1的数据值时,arr1却没有发生改变。为什么?这就是传值与传址的区别。

因为arr1是数组,属于引用类型,所以它赋予给arr2的时候传的是栈中的地址(相当于新建了一个不同名“指针”),而不是堆内存中的对象的值。str1得到的是一个基本类型的赋值,因此,str1仅仅是从arr1堆内存中获取了一个数值,并直接保存在栈中。arr1、arr2都指向同一块堆内存,arr2修改的堆内存的时候,也就会影响到arr1,str1是直接在栈中修改,并且不能影响到arr1堆内存中的数据。

数据类型访问&&复制

基本数据类型:基本数据类型是指保存在栈内存中的简单数据段。访问方式是按值访问。

var a=1;

a=2 ;

基本类型变量的复制:从一个变量向一个变量复制时,会在栈中创建一个新值,然后把值复制到为新变量分配的位置上。

var b=a;

vara=newObject();

a.name='xz' ;

栈内存&堆内存

为了使程序运行时占用的内存最小,通常要实现垃圾回收机制。

当一个方法执行时,每个方法都会建立自己的内存栈,在这个方法内定义的变量将会逐个放入这块栈存里,随着方法的执行结束,这个方法的栈存也将自然销毁了。因此,所有在方法中定义的变量都是放在栈内存中的;

当我们在程序中创建一个对象时,这个对象将被保存到运行时数据区中,以便反复利用(因为对象的创建成本开销较大),这个运行时数据区就是堆内存。堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用(方法的参数传递时很常见),则这个对象依然不会被销毁,只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。

思考问题

demo1.
var a=1;
var b=a;
b=2;

// 这时a是?

demo1中在变量对象中的数据发生复制行为时,系统会自动为新的变量分配一个新值。var b=a执行之后,b虽然重新赋值为2,但是他们其实已经是相互独立互不影响的值了。

demo2.
var m={ a: 1, b: 2 }
var n=m;
n.a=2;

// 这时m.a的值呢?

demo2中我们通过var n=m执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在变量对象中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在变量对象中访问到的具体对象实际上是同一个。因此当我改变n时,m也发生了变化。这就是引用类型的特性。

堆栈和队列

要了解JavaScript数组的堆栈和队列方法的操作,需要先对堆栈和队列基础知识有所了解。在继续看后面的内容之前,我们先简单地了解一下堆栈和队列的概念。

栈和队列都是动态的集合,在栈中,可以去掉的元素是最近插入的那一个。栈道实现了后进先出。在队列中,可以去掉的元素总是在集合中存在的时间最长的那一个。队列实现了先进先出的策略。

堆栈的基本概念

先上张图:



栈是一种LIFO(Last-In-First-Out,后进先出)的数据结构,也就是最新添加的项最早被移除。而栈中项的插入(叫做推入)和移除(叫做弹出),只发生在一个位置——栈的顶部。

最开始栈中不含有任何数据,叫做空栈,此时栈顶就是栈底。然后数据从栈顶进入,栈顶栈底分离,整个栈的当前容量变大。数据出栈时从栈顶弹出,栈顶下移,整个栈的当前容量变小。

比如说,我们在一个箱子中放了很多本书,如果你要拿出第二本书,那么你要先把第一本书拿出来,才能拿第二本书出来;拿出第二本书之后,再把第一本书放进去。

ECMAScript为数组专门提供了 push()pop() 方法,以便实现类似的行为。 push() 方法可以接收任意数量的参数,把它们逐个添加到数组末尾,并返回修改后数组的长度。而 pop() 方法则从数组末尾移除最后一项,减少数组的length值,然后返回移除的项。

队列的基本概念

栈数据结构的访问规则是LIFO(后进先出),而队列数据结构的访问规则是FIFO(Fist-In-First-Out,先进先出)。队列在列表的末端添加项,从列表的前端移除项。如下图所示:



比如说火车站排队买票,先到的先买,买好的先走。

入队列操作其实就是在队尾追加一个元素,不需要任何移动,时间复杂度为O(1)。出队列则不同,因为我们已经架设下标为0的位置是队列的队头,因此每次出队列操作所有元素都要向前移动。如下图所示:



ECMAScript为数组专门提供了 shift()unshift() 方法,以便实现类似队列的行为。由于 push() 是向数组末端添加数组项的方法,因此要模拟队列只需一个从数组前端取得数组项的方法。实现这一操作的数组方法就是 shift() ,它能够移除数组中的第一个项并返回该项,同时将数组长度减1

顾名思义, unshift()shift() 的用途相反:它能在数组前端添加任意个数组项并返回新数组的长度。因此,同时使用 unshift()pop() 方法,可以从相反的方向来模拟队列,即在数组的前端添加数组项,从数组末端移除数组项。

push()方法

该方法是向数组末尾添加一个或者多个元素,并返回新的长度。

push()方法可以接收任意数量的参数,把它们逐个添加到数组的末尾,并返回修改后数组的长度。如:

var arr=[]; //创建一个空数组
console.log(arr); // []
console.log("入栈"); // 入栈
arr.push(1); // 将1添加到数组arr中
console.log(arr); // [1]
arr.push(2); //将2添加到数组arr中
console.log(arr); //[1,2]
arr.push([3,4]); // 将数组[3,4]添加到arr中
console.log(arr); // [1,2,[3,4]]
console.log(arr.length); // 3


在Chrome浏览器控制台输出的效果如下图所示:



pop()方法

pop()方法刚好和push()方法相反。pop()方法删除数组的最后一个元素,把数组的长度减1,并且返回它被删除元素的值,如果数组变为空,则该方法不改变数组,返回undefine值。如下代码演示:

var arr=[1,2,3,4]; //创建一个数组
console.log(arr); // [1,2,3,4]
console.log(arr.length); // 4
console.log("出栈,后进先出"); // 出栈,后进先出
arr.pop();
console.log(arr); //  // [1,2,3]
arr.pop();
console.log(arr); // [1,2]
arr.pop();
console.log(arr); // [1]
arr.pop();
console.log(arr); // []


在Chrome浏览器控制台输出的效果如下图所示:



unshift()方法

unshift()方法是向数组的开头添加一个或多个元素,并且返回新的长度。

var arr=[]; //创建一个空的数组
console.log(arr); // []
console.log("入队"); // 入队
arr.unshift(1,2,3,4); // 将1,2,3,4推入到数组arr
console.log(arr); // [1,2,3,4]
console.log(arr.length); // 4


在Chrome浏览器控制台输出的效果如下图所示:



shift()方法

shift()方法和unshift()方法恰恰相反。该方法用于把数组的第一个元素从其中删除,并返回被删除的值。如果数组是空的,shift()方法将不进行任何操作,返回undefined的值。

var arr=[1,2,3,4]; // 创建一个数组
console.log(arr); // [1,2,3,4]
arr.shift(); // 取得第一项
console.log(arr); // [2,3,4]
arr.shift(); // 取得第一项
console.log(arr); // [3,4]
arr.shift(); // 取得第一项
console.log(arr); // [4]
arr.shift(); // 取得第一项
console.log(arr); // []


在Chrome浏览器控制台输出的效果如下图所示:



简单得回忆一下:

  • push()方法可以在数组的末属添加一个或多个元素
  • shift()方法把数组中的第一个元素删除
  • unshift()方法可以在数组的前端添加一个或多个元素
  • pop()方法把数组中的最后一个元素删除

JavaScript实现类似栈和队列的行为

了解这几种方法之后,我们就可以将它们结合起来,轻松的实现类似栈和队列的行为。

实现类似栈的行为

push()pop()结合在一起,我们就可以实现类似栈的行为:

//创建一个数组来模拟堆栈
var a=new Array();
console.log(a);
//push: 在数组的末尾添加一个或更多元素,并返回新的长度
console.log("入栈");
a.push(1)
console.log(a);//----->1
a.push(2);
console.log(a);//----->1,2
a.push(3);
console.log(a);//----->1,2,3
a.push(4);
console.log(a);//----->1,2,3,4
console.log("出栈,后进先出");
console.log(a);
//pop:从数组中把最后一个元素删除,并返回这个元素的值
a.pop();//----->4
console.log(a);
a.pop();//----->3
console.log(a);
a.pop();//----->2
console.log(a);
a.pop();//----->1
console.log(a);


在Chrome浏览器控制台输出的效果如下图所示:



实现类似队列的行为

shift()push()方法结合在一起,可以像使用队列一样使用数组。即在数组的后端添加项,从数组的前端移除项:

//创建一个数组来模拟队列

//创建一个数组来模拟队列
var a=new Array();
console.log(a);
//push: 在数组的末尾添加一个或更多元素,并返回新的长度
console.log("入队");
a.push(1)
console.log(a);//----->1
a.push(2);
console.log(a);//----->1,2
a.push(3);
console.log(a);//----->1,2,3
a.push(4);
console.log(a);//----->1,2,3,4
console.log("出队,先进先出");
console.log(a);
//shift:从数组中把第一个元素删除,并返回这个元素的值
a.shift();//----->1
console.log(a);
a.shift();//----->2
console.log(a);
a.shift();//----->3
console.log(a);
a.shift();//----->4
console.log(a);


在Chrome浏览器控制台输出的效果如下图所示:



除此之外,还可以同时使用unshift()pop()方法,从相反的方向来模拟队列,即在数组的前端添加项,从数组的后端移除项。如下面的示例所示:
//创建一个数组来模拟队列

//创建一个数组来模拟队列
var a=new Array();
console.log(a);
//unshift: 在数组的前端添加一个或更多元素,并返回新的长度
console.log("入队");
a.unshift(1)
console.log(a);//----->1
a.unshift(2);
console.log(a);//----->2,1
a.unshift(3);
console.log(a);//----->3,2,1
a.unshift(4);
console.log(a);//----->4,3,2,1
console.log("出队,先进先出");
console.log(a);
//pop:从数组中把最一个元素删除,并返回这个元素的值
a.pop();//----->4
console.log(a);
a.pop();//----->3
console.log(a);
a.pop();//----->2
console.log(a);
a.pop();//----->1
console.log(a);


在Chrome浏览器控制台输出的效果如下图所示:



push()方法和unshift()方法的性能测试

Array的push()unshift()方法都能给当前数组添加元素,不同的是,push()是在末尾添加,而unshift()则是在开头添加,从原理就可以知道,unshift()的效率是较低的。原因是,它每添加一个元素,都要把现有元素往下移一个位置。但到底效率差异有多大呢?下面来简单测试一下。

/*
关于代码中"var s=+newDate();"的技巧说明
解释如下:=+这个运算符是不存在的;
+相当于.valueOf();
+new Date()相当于new Date().valueOf()
//4个结果一样返回当前时间的毫秒数
  alert(+new Date());
  alert(+new Date);
  var s=new Date();
  alert(s.valueOf());
  alert(s.getTime());
*/
var arr=[ ];
var startTime=+new Date(); //+new Date()相当于new Date().valueOf(),返回当前时间的毫秒数
// push性能测试 
for (var i=0; i < 100000; i++) { 
  arr.push(i); 
}
var endTime=+new Date();
console.log("调用push方法往数组中添加100000个元素耗时"+(endTime-startTime)+"毫秒"); 
 
startTime=+new Date(); 
arr=[ ]; 
// unshift性能测试 
for (var i=0; i < 100000; i++) { 
  arr.unshift(i); 
}
endTime=+new Date();
console.log("调用unshift方法往数组中添加100000个元素耗时"+(endTime-startTime)+"毫秒"); 


这段代码分别执行了100000push()unshift()操作,在chrome浏览器运行一次,得到的结果如下图所示:



可见,unshift()push()要慢差不多100倍!因此,平时还是要慎用unshift(),特别是对大数组。那如果一定要达到unshift()的效果,可以借助于Array的reverse()方法,Array的reverse()的方法能够把一个数组反转。先把要放进数组的元素用push()添加,再执行一次reverse(),就达到了unshift()的效果。比如:

//创建一个数组来模拟堆栈
var a=new Array();
//使用push方法在数组的末尾添加元素
a.push(1)
a.push(2);
a.push(3);
a.push(4);
console.log("数组反转之前数组中的元素顺序");
console.log(a);//----->1,2,3,4
//Array有一个叫做reverse的方法,能够把一个数组反转。先把要放进数组的元素用push添加,再执行一次reverse,就达到了unshift的效果
a.reverse();//使用reverse方法将数组进行反转
console.log("数组反转之后数组中的元素顺序");
console.log(a);


在chrome浏览器控制台输出的效果如下图所示:



从运行结果来看,数组元素的顺序已经反转过来了。

reverse()方法的性能测试

var arr=[ ], s=+new Date; 
for (var i=0; i < 100000; i++) { 
      arr.push(i); 
}
//调用reverse方法将数组里面的100000元素的顺序反转
arr.reverse(); 
console.log("调用reverse方法将数组里面的100000元素的顺序反转耗时:"+(+new Date - s)+"毫秒");


在chrome浏览器控制台输出的效果如下图所示:



从运行效果中可以看到,reverse()方法的性能极高,可以放心使用。

总结



本文主要介绍了JavaScript数组的push()pop()shift()unshift()方法。并且如何通过组合这几种方法实现类似栈和队例的行为。

js中删除堆栈:

1:js中的splice方法

  splice(index,len,[item]) 注释:该方法会改变原始数组。

splice有3个参数,它也可以用来替换/删除/添加数组内某一个或者几个值

index:数组开始下标 len: 替换/删除的长度 item:替换的值,删除操作的话 item为空

如:arr=['a','b','c','d']

删除 ---- item不设置

arr.splice(1,1) //['a','c','d'] 删除起始下标为1,长度为1的一个值,len设置的1,如果为0,则数组不变

arr.splice(1,2) //['a','d'] 删除起始下标为1,长度为2的一个值,len设置的2

替换 ---- item为替换的值

arr.splice(1,1,'ttt') //['a','ttt','c','d'] 替换起始下标为1,长度为1的一个值为‘ttt’,len设置的1

arr.splice(1,2,'ttt') //['a','ttt','d'] 替换起始下标为1,长度为2的两个值为‘ttt’,len设置的1

添加 ---- len设置为0,item为添加的值

arr.splice(1,0,'ttt') //['a','ttt','b','c','d'] 表示在下标为1处添加一项‘ttt’

看来还是splice最方便啦

2:delete delete删除掉数组中的元素后,会把该下标出的值置为undefined,数组的长度不会变

如:delete arr[1] //['a', ,'c','d'] 中间出现两个逗号,数组长度不变,有一项为undefined

好地了解数据结构如何工作

这听起来是否熟悉:"我通过完成网上课程开始了前端开发"

您可能正在寻求提高计算机科学的基础知识,尤其是在数据结构和算法方面。 今天,我们将介绍一些常见的数据结构,并以JavaScript实施它们。

希望这部分内容可以补充您的技能!

1.Stack 堆栈

堆栈遵循LIFO(后进先出)的原理。 如果您堆叠书籍,则最上层的书籍将排在最底层的书籍之前。 或者,当您在Internet上浏览时,后退按钮会将您带到最近浏览的页面。

Stack具有以下常见方法:

· push:输入一个新元素

· pop:删除顶部元素,返回删除的元素

· peek:返回顶部元素

· length:返回堆栈中的元素数

Javascript中的数组具有Stack的属性,但是我们使用Stack()函数从头开始构建Stack

function Stack() {
this.count=0;
 this.storage={};

 this.push=function (value) {
 this.storage[this.count]=value;
 this.count++;
 }

 this.pop=function () {
 if (this.count===0) {
 return undefined;
 }
 this.count--;
 var result=this.storage[this.count];
 delete this.storage[this.count];
 return result;
 }

 this.peek=function () {
 return this.storage[this.count - 1];
 }

 this.size=function () {
 return this.count;
 }
}

2.Queue 队列

队列类似于堆栈。 唯一的区别是Queue使用FIFO原理(先进先出)。 换句话说,当您排队等候总线时,队列中的第一个将始终排在第一位。

队列具有以下方法:

· enqueue 入队:输入队列,在最后添加一个元素

· dequeue 出队:离开队列,移除前元素并返回

· front:获取第一个元素

· isEmpty:确定队列是否为空

· size:获取队列中的元素数)

JavaScript中的数组具有Queue的某些属性,因此我们可以使用数组来构造Queue的示例:

function Queue() {
 var collection=[];
 this.print=function () {
 console.log(collection);
 }
 this.enqueue=function (element) {
 collection.push(element);
 }
 this.dequeue=function () {
 return collection.shift();
 }
 this.front=function () {
 return collection[0];
 }

 this.isEmpty=function () {
 return collection.length===0;
 }
 this.size=function () {
 return collection.length;
 }
}

优先队列

队列还有另一个高级版本。 为每个元素分配优先级,并将根据优先级对它们进行排序:

function PriorityQueue() {

 ...

 this.enqueue=function (element) {
 if (this.isEmpty()) {
 collection.push(element);
 } else 
 var added=false;
 for (var i=0; i < collection.length; i++) {
 if (element[1] < collection[i][1]) {
 collection.splice(i, 0, element);
 added=true;
 break;
 }
 }
 if (!added) {
 collection.push(element);
 }
 }
 }
}

测试一下:

var pQ=new PriorityQueue();
pQ.enqueue([ gannicus , 3]);
pQ.enqueue([ spartacus , 1]);
pQ.enqueue([ crixus , 2]);
pQ.enqueue([ oenomaus , 4]);
pQ.print();

结果:

[
 [ spartacus , 1 ],
 [ crixus , 2 ],
 [ gannicus , 3 ],
 [ oenomaus , 4 ]
]

3.链表

从字面上看,链表是一个链式数据结构,每个节点由两部分信息组成:该节点的数据和指向下一个节点的指针。 链表和常规数组都是带有序列化存储的线性数据结构。 当然,它们也有差异:

单边链表通常具有以下方法:

· size:返回节点数

· head:返回head的元素

· add:在尾部添加另一个节点

· delete:删除某些节点

· indexOf:返回节点的索引

· elementAt:返回索引的节点

· addAt:在特定索引处插入节点

· removeAt:删除特定索引处的节点

/** Node in the linked list **/
function Node(element) { 
 // Data in the node
 this.element=element; 
 // Pointer to the next node 
 this.next=null;
}
 function LinkedList() { 
 var length=0; 
 var head=null; 
 this.size=function () { 
 return length; 
 } 
 this.head=function () { 
 return head; 
 } 
 this.add=function (element) { 
 var node=new Node(element); 
 if (head==null) { 
 head=node; 
 } else { 
 var currentNode=head; 
 while (currentNode.next) { 
 currentNode=currentNode.next; 
 } 
 currentNode.next=node; 
 } 
 length++; 
 } 
 this.remove=function (element) { 
 var currentNode=head; 
 var previousNode; 
 if (currentNode.element===element) { 
 head=currentNode.next; 
 } else { 
 while (currentNode.element !==element) { 
 previousNode=currentNode; 
 currentNode=currentNode.next; 
 } 
 previousNode.next=currentNode.next; 
 } 
 length--; 
 } 
 this.isEmpty=function () { 
 return length===0; 
 } 
 this.indexOf=function (element) { 
 var currentNode=head; 
 var index=-1; 
 while (currentNode) { 
 index++; 
 if (currentNode.element===element) { 
 return index; 
 } 
 currentNode=currentNode.next; 
 } 
 return -1; 
 } 
 this.elementAt=function (index) { 
 var currentNode=head; 
 var count=0; 
 while (count < index) { 
 count++; 
 currentNode=currentNode.next; 
 } 
 return currentNode.element; 
 } 
 this.addAt=function (index, element) { 
 var node=new Node(element); 
 var currentNode=head; 
 var previousNode; 
 var currentIndex=0; 
 if (index > length) { 
 return false; 
 } 
 if (index===0) { 
 node.next=currentNode; 
 head=node; 
 } else { 
 while (currentIndex < index) { 
 currentIndex++; 
 previousNode=currentNode; 
 currentNode=currentNode.next; 
 } 
 node.next=currentNode; 
 previousNode.next=node; 
 } 
 length++; 
 } 
 this.removeAt=function (index) { 
 var currentNode=head; 
 var previousNode; 
 var currentIndex=0; 
 if (index < 0 || index >=length) { 
 return null; 
 } 
 if (index===0) { 
 head=currentIndex.next; 
 } else { 
 while (currentIndex < index) { 
 currentIndex++; 
 previousNode=currentNode; 
 currentNode=currentNode.next; 
 } 
 previousNode.next=currentNode.next; 
 } 
 length--; 
 return currentNode.element; 
 }
 }

4.集合

集合是数学的基本概念:定义明确且不同的对象的集合。 ES6引入了集合的概念,它与数组有一定程度的相似性。 但是,集合不允许重复元素,也不会被索引。

一个典型的集合具有以下方法:

· values:返回集合中的所有元素

· size:返回元素数

· has:确定元素是否存在

· add:将元素插入集合

· delete:从集合中删除元素

· union:返回两组的交集

· difference:返回两组的差异

· subset:确定某个集合是否是另一个集合的子集

为了区分ES6中的集合,在以下示例中我们声明为MySet:

function MySet() { 
 var collection=[]; 
 this.has=function (element) { 
 return (collection.indexOf(element) !==-1); 
 } 
 this.values=function () { 
 return collection; 
 } 
 this.size=function () { 
 return collection.length; 
 } 
 this.add=function (element) { 
 if (!this.has(element)) { 
 collection.push(element); 
 return true; 
 } 
 return false; 
 } 
 this.remove=function (element) { 
 if (this.has(element)) { 
 index=collection.indexOf(element); 
 collection.splice(index, 1); 
 return true; 
 } 
 return false; 
 } 
 this.union=function (otherSet) { 
 var unionSet=new MySet(); 
 var firstSet=this.values(); 
 var secondSet=otherSet.values(); 
 firstSet.forEach(function (e) { 
 unionSet.add(e); 
 }); 
 secondSet.forEach(function (e) { 
 unionSet.add(e); 
 }); 
 return unionSet; } 
 this.intersection=function (otherSet) { 
 var intersectionSet=new MySet(); 
 var firstSet=this.values(); 
 firstSet.forEach(function (e) { 
 if (otherSet.has(e)) { 
 intersectionSet.add(e); 
 } 
 }); 
 return intersectionSet; 
 } 
 this.difference=function (otherSet) { 
 var differenceSet=new MySet(); 
 var firstSet=this.values(); 
 firstSet.forEach(function (e) { 
 if (!otherSet.has(e)) { 
 differenceSet.add(e); 
 } 
 }); 
 return differenceSet; 
 } 
 this.subset=function (otherSet) { 
 var firstSet=this.values(); 
 return firstSet.every(function (value) { 
 return otherSet.has(value); 
 }); 
 }
 }

5.哈希表

哈希表是键值数据结构。 由于通过键查询值的闪电般的速度,它通常用于Map,Dictionary或Object数据结构中。 如上图所示,哈希表使用哈希函数将键转换为数字列表,这些数字用作相应键的值。 要快速使用键获取价值,时间复杂度可以达到O(1)。 相同的键必须返回相同的值-这是哈希函数的基础。

哈希表具有以下方法:

· add:添加键值对

· delete:删除键值对

· find:使用键查找对应的值

Java简化哈希表的示例:

function hash(string, max) {
 var hash=0;
 for (var i=0; i < string.length; i++) {
 hash +=string.charCodeAt(i);
 }
 return hash % max;
}

function HashTable() {
 let storage=[];
 const storageLimit=4;

 this.add=function (key, value) {
 var index=hash(key, storageLimit);
 if (storage[index]===undefined) {
 storage[index]=[
 [key, value]
 ];
 } else {
 var inserted=false;
 for (var i=0; i < storage[index].length; i++) {
 if (storage[index][i][0]===key) {
 storage[index][i][1]=value;
 inserted=true;
 }
 }
 if (inserted===false) {
 storage[index].push([key, value]);
 }
 }
 }

 this.remove=function (key) {
 var index=hash(key, storageLimit);
 if (storage[index].length===1 && storage[index][0][0]===key) {
 delete storage[index];
 } else {
 for (var i=0; i < storage[index]; i++) {
 if (storage[index][i][0]===key) {
 delete storage[index][i];
 }
 }
 }
 }

 this.lookup=function (key) {
 var index=hash(key, storageLimit);
 if (storage[index]===undefined) {
 return undefined;
 } else {
 for (var i=0; i < storage[index].length; i++) {
 if (storage[index][i][0]===key) {
 return storage[index][i][1];
 }
 }
 }
 }
}

6.树

树数据结构是多层结构。 与Array,Stack和Queue相比,它也是一种非线性数据结构。 在插入和搜索操作期间,此结构非常高效。 让我们看一下树数据结构的一些概念:

· root:树的根节点,无父节点

· parent 父节点:上层的直接节点,只有一个

· children 子节点:较低层的直接节点,可以有多个

· siblings 兄弟姐妹:共享同一父节点

· leaf 叶:没有子节点

· edge 边缘:节点之间的分支或链接

· path 路径:从起始节点到目标节点的边缘

· height of node 节点高度:特定节点到叶节点的最长路径的边数

· height of tree 树的高度:根节点到叶节点的最长路径的边数

· depth of node 节点深度:从根节点到特定节点的边数

· degree of node 节点度:子节点数

这是二叉搜索树的示例。 每个节点最多有两个节点,左节点小于当前节点,右节点大于当前节点:

二进制搜索树中的常用方法:

· add:在树中插入一个节点

· findMin:获取最小节点

· findMax:获取最大节点

· find:搜索特定节点

· isPresent:确定某个节点的存在

· delete:从树中删除节点

JavaScript中的示例:

class Node {
 constructor(data, left=null, right=null) {
 this.data=data;
 this.left=left;
 this.right=right;
 }
}

class BST {
 constructor() {
 this.root=null;
 }

 add(data) {
 const node=this.root;
 if (node===null) {
 this.root=new Node(data);
 return;
 } else {
 const searchTree=function (node) {
 if (data < node.data) {
 if (node.left===null) {
 node.left=new Node(data);
 return;
 } else if (node.left !==null) {
 return searchTree(node.left);
 }
 } else if (data > node.data) {
 if (node.right===null) {
 node.right=new Node(data);
 return;
 } else if (node.right !==null) {
 return searchTree(node.right);
 }
 } else {
 return null;
 }
 };
 return searchTree(node);
 }
 }

 findMin() {
 let current=this.root;
 while (current.left !==null) {
 current=current.left;
 }
 return current.data;
 }

 findMax() {
 let current=this.root;
 while (current.right !==null) {
 current=current.right;
 }
 return current.data;
 }

 find(data) {
 let current=this.root;
 while (current.data !==data) {
 if (data < current.data) {
 current=current.left
 } else {
 current=current.right;
 }
 if (current===null) {
 return null;
 }
 }
 return current;
 }

 isPresent(data) {
 let current=this.root;
 while (current) {
 if (data===current.data) {
 return true;
 }
 if (data < current.data) {
 current=current.left;
 } else {
 current=current.right;
 }
 }
 return false;
 }

 remove(data) {
 const removeNode=function (node, data) {
 if (node==null) {
 return null;
 }
 if (data==node.data) {
 // no child node
 if (node.left==null && node.right==null) {
 return null;
 }
 // no left node
 if (node.left==null) {
 return node.right;
 }
 // no right node
 if (node.right==null) {
 return node.left;
 }
 // has 2 child nodes
 var tempNode=node.right;
 while (tempNode.left !==null) {
 tempNode=tempNode.left;
 }
 node.data=tempNode.data;
 node.right=removeNode(node.right, tempNode.data);
 return node;
 } else if (data < node.data) {
 node.left=removeNode(node.left, data);
 return node;
 } else {
 node.right=removeNode(node.right, data);
 return node;
 }
 }
 this.root=removeNode(this.root, data);
 }
}

测试一下:

const bst=new BST();
bst.add(4);
bst.add(2);
bst.add(6);
bst.add(1);
bst.add(3);
bst.add(5);
bst.add(7);
bst.remove(4);
console.log(bst.findMin());
console.log(bst.findMax());
bst.remove(7);
console.log(bst.findMax());
console.log(bst.isPresent(4));

结果:

1
7
6
false

7.Trie(发音为" try")

Trie或"前缀树"也是一种搜索树。 Trie分步存储数据-树中的每个节点代表一个步骤。 Trie用于存储词汇,因此可以快速搜索,尤其是自动完成功能。

Trie中的每个节点都有一个字母-在分支之后可以形成一个完整的单词。 它还包含一个布尔指示符,以显示这是否是最后一个字母。

Trie具有以下方法:

· add:在字典树中插入一个单词

· isWord:确定树是否由某些单词组成

· print:返回树中的所有单词

/** Node in Trie **/
function Node() { 
 this.keys=new Map(); 
 this.end=false; 
 this.setEnd=function () { 
 this.end=true; 
 }; 
 this.isEnd=function () { 
 return this.end; 
 }
}

function Trie() { 
 this.root=new Node(); 
 this.add=function (input, node=this.root) { 
 if (input.length===0) { 
 node.setEnd(); 
 return; 
 } else if (!node.keys.has(input[0])) { 
 node.keys.set(input[0], new Node()); 
 return this.add(input.substr(1), node.keys.get(input[0])); 
 } else { 
 return this.add(input.substr(1), node.keys.get(input[0])); 
 } 
 } 
 this.isWord=function (word) { 
 let node=this.root; 
 while (word.length > 1) { 
 if (!node.keys.has(word[0])) { 
 return false; 
 } else { 
 node=node.keys.get(word[0]); 
 word=word.substr(1); 
 } 
 } 
 return (node.keys.has(word) && node.keys.get(word).isEnd()) ? true : false; 
 } 
 this.print=function () { 
 let words=new Array(); 
 let search=function (node=this.root, string) { 
 if (node.keys.size !=0) { 
 for (let letter of node.keys.keys()) { 
 search(node.keys.get(letter), string.concat(letter)); 
 } 
 if (node.isEnd()) { 
 words.push(string); 
 } 
 } else { 
 string.length > 0 ? words.push(string) : undefined; 
 return; 
 } 
 }; 
 search(this.root, new String()); 
 return words.length > 0 ? words : null; 
 }
}

8.图

图(有时称为网络)是指具有链接(或边)的节点集。 根据链接是否具有方向,它可以进一步分为两组(即有向图和无向图)。 Graph在我们的生活中得到了广泛使用,例如,在导航应用中计算最佳路线,或者在社交媒体中向推荐的朋友举两个例子。

图有两种表示形式:

邻接表

在此方法中,我们在左侧列出所有可能的节点,并在右侧显示已连接的节点。

邻接矩阵

邻接矩阵显示行和列中的节点,行和列的交点解释节点之间的关系,0表示未链接,1表示链接,> 1表示不同的权重。

要查询图中的节点,必须使用"广度优先"(BFS)方法或"深度优先"(DFS)方法在整个树形网络中进行搜索。

让我们看一个用Javascript编写BFS的示例:

function bfs(graph, root) {
 var nodesLen={};
 for (var i=0; i < graph.length; i++) {
 nodesLen[i]=Infinity;
 }
 nodesLen[root]=0;
 var queue=[root];
 var current;
 while (queue.length !=0) {
 current=queue.shift();

 var curConnected=graph[current];
 var neighborIdx=[];
 var idx=curConnected.indexOf(1);
 while (idx !=-1) {
 neighborIdx.push(idx);
 idx=curConnected.indexOf(1, idx + 1);
 }
 for (var j=0; j < neighborIdx.length; j++) {
 if (nodesLen[neighborIdx[j]]==Infinity) {
 nodesLen[neighborIdx[j]]=nodesLen[current] + 1;
 queue.push(neighborIdx[j]);
 }
 }
 }
 return nodesLen;
}

测试一下:

var graph=[
 [0, 1, 1, 1, 0],
 [0, 0, 1, 0, 0],
 [1, 1, 0, 0, 0],
 [0, 0, 0, 1, 0],
 [0, 1, 0, 0, 0]
];
console.log(bfs(graph, 1));

结果:

{
 0: 2,
 1: 0,
 2: 1,
 3: 3,
 4: Infinity
}

就是这样–我们涵盖了所有常见的数据结构,并提供了JavaScript中的示例。 这应该使您更好地了解计算机中数据结构的工作方式。 编码愉快!


(本文翻译自Kingsley Tan的文章《8 Common Data Structures in Javascript》, 参考 https://medium.com/better-programming/8-common-data-structures-in-javascript-3d3537e69a27)


章涵盖

  • 了解 D3 布局函数
  • 使用饼图布局绘制圆环图
  • 堆叠形状以生成堆叠条形图和流线图
  • 创建简单的图例

在上一章中,我们讨论了 D3 如何使用其形状生成器函数计算复杂形状(如曲线、面积和弧)的 d 属性。在本章中,我们将通过布局将这些形状提升到另一个层次。在 D3 中,布局是将数据集作为输入并生成新的批注数据集作为输出的函数,其中包含绘制特定可视化效果所需的属性。例如,饼图布局计算饼图每个扇区的角度,并使用这些角度批注数据集。同样,堆栈布局计算堆积形状在堆积条形图或流图中的位置。

布局不会绘制可视化效果,也不会像组件一样调用它们,也不会像形状生成器那样在绘图代码中引用。相反,它们是一个预处理步骤,用于设置数据的格式,以便准备好以您选择的形式显示。

图5.1 布局功能是用于计算绘制特定图表所需信息的数据预处理步骤。


在本章中,我们将饼图和堆栈布局与第 4 章中讨论的弧形和面积形状生成器相结合,以创建图 5.2 所示的项目。您也可以在 https://d3js-in-action-third-edition.github.io/visualizing-40-years-of-music-industry-sales/ 在线找到它。该项目可视化了 1973 年至 2019 年间音乐行业每种格式的销售情况。它的灵感来自2020年MakeoverMonday(www.makeovermonday.co.uk/week-21-2020/)举办的挑战。

图 5.2 1973 年至 2019 年音乐行业销售的可视化。这是我们将在本章中构建的项目。


虽然本章只介绍了饼图和堆栈布局,但其他布局,如和弦布局和更奇特的布局,遵循相同的原则,看完这些应该很容易理解。

在开始之前,请转到第 5 章的代码文件。您可以从本书的 Github 存储库下载它们(https://github.com/d3js-in-action-third-edition/code-files)。在名为 chapter_05 的文件夹中,代码文件按节进行组织。要开始本章的练习,请在代码编辑器中打开 5.1-Pie_layout/start 文件夹并启动本地 Web 服务器。如果您需要有关设置本地开发环境的帮助,请参阅附录 A。您可以在位于本章代码文件根目录下的自述文件中找到有关项目文件夹结构的更多详细信息。

我们将在本章中构建的三个可视化(圆环图、堆积条形图和流图)共享相同的数据、维度和比例。为了避免重复,该项目被分解为多个 JavaScript 文件,其中一个用于可视化共享的常量,另一个专门用于比例。这种方法将使我们的代码更易于阅读和修改。在生产代码中,我们可能会使用 JavaScript 导入和导出来访问不同的函数,并结合 Node 和捆绑器。在讨论前端框架时,我们将到达那里,但现在,我们将坚持一个类似遗留的项目结构,以保持对 D3 的关注。请注意,D3 库和所有 JavaScript 文件都已加载到 index.html 中。

警告

使用本章的代码文件时,在代码编辑器中仅打开一个开始文件夹或一个结束文件夹。如果一次打开章节的所有文件并使用 Live Server 扩展为项目提供服务,则数据文件的路径将无法按预期工作。

5.1 创建饼图和圆环图

在本节中,我们将使用 D3 的饼图布局来创建圆环图,您可以在图 5.2 顶部和托管项目 (https://d3js-in-action-third-edition.github.io/visualizing-40-years-of-music-industry-sales/) 上看到该圆环图。更具体地说,我们将可视化 1975 年、1995 年和 2013 年每种音乐格式的销售额细分。每个圆环图的中心将对应于相应年份在流图和下面堆叠条形图的 x 轴上的位置。

5.1.1 准备步骤

让我们花点时间建立一个策略,以确保每个图表根据 x 轴上的年份正确水平对齐。一个简单的方法是使用第4章中描述的保证金约定。随着本章的进展,我们将使用三个 SVG 容器:一个用于圆环图,一个用于流图,一个用于堆积条形图。这些容器中的每一个都具有相同的尺寸并共享相同的边距。为内部图表保留的区域(没有轴和标签的可视化效果)也将具有相同的维度并水平对齐,如图 5.3 所示。文件 js/shared-constant.js 已包含可视化共享的边距对象和维度常量。

我们还在 js/load-data 中为您加载了 CSV 数据文件.js .有关如何将数据加载到 D3 项目中的更多信息,请参阅第 4 章和第 3 章。加载数据后,我们调用函数 defineScales() 和 drawDonutCharts() ,我们将在本节中使用它们。

图 5.3 为了创建本章的项目,我们将使用三个 SVG 容器:一个用于圆环图,一个用于流线图,一个用于堆积条形图。此策略将使我们能够为内部图表保留一致的区域,并将每个图表正确对齐到另一个图表之上。


首先,让我们为圆环图追加一个 SVG 容器和一个定义为内部图表保留区域的 SVG 组。为此,我们转到 js/donut-charts.js并在函数 drawDonutCharts() 中,我们创建 SVG 容器和一个 SVG 组。在下面的代码片段中,您将看到我们在 div 内附加了 SVG 容器,ID 为 donut 。请注意,我们通过根据图表的左边距和上边距平移组来应用边距约定。

const svg=d3.select("#donut")
  .append("svg")                                #A
    .attr("viewBox", `0 0 ${width} ${height}`); #A
 
const donutContainers=svg
  .append("g")                                                      #B
    .attr("transform", `translate(${margin.left}, ${margin.top})`); #B
 

您可能想知道为什么我们需要将边距约定应用于圆环图,因为没有轴和标签的帐户空间。这是因为每个圆环图将根据其所代表的年份水平定位。由于我们希望这些年份的水平位置与下面的流线图和堆叠条形图中相同,因此我们需要考虑边际惯例。

在第 4 章中,我们讨论了极坐标以及如何通过将弧包含在 SVG 组中并将该组转换为图表中心的位置来促进饼图或圆环图的创建。通过以这种方式进行,弧线将自动围绕该中心绘制。

我们将在这里应用相同的策略,唯一的区别是我们需要考虑三个圆环图,并且它们的中心水平位置对应于它们所代表的年份,如图 5.4 所示。

图 5.4 组成圆环图的每组弧都包含在 SVG 组中。这些组根据它们所代表的年份进行水平翻译。该位置是使用 D3 刻度计算的。


要计算每个甜甜圈中心的水平位置,我们需要一个刻度。如您所知,我们使用 D3 刻度将数据(此处为年份)转换为屏幕属性,此处为水平位置。线性或时间刻度对于我们的目的来说效果很好,但我们选择波段刻度,因为我们知道我们稍后会绘制一个堆叠条形图,它将共享相同的刻度。有关频段刻度工作原理的更多说明,请参阅第 3 章。

在文件中 js/scale.js ,我们首先使用函数 d3.scaleBand() 初始化波段刻度,并将其存储在名为 xScale 的常量中。请注意我们如何在函数 defineScales() 中声明刻度的域和范围。这种方法让我们等到数据加载完成,然后再尝试使用它来设置域(一旦数据准备就绪,函数 defineScales() 从加载数据调用.js)。我们在函数外部声明常量 xScale,使其可以从其他 js 文件访问。

示例 5.1 声明波段刻度(scales.js)

const xScale=d3.scaleBand(); #A
 
const defineScales=(data)=> {
  xScale
    .domain(data.map(d=> d.year)) #B
    .range([0, innerWidth]);       #B
};

带状刻度接受离散输入作为域,并返回该范围的连续输出。在清单 5.1 中,我们使用 JavaScript map() 方法,通过每年从数据集创建一个数组来设置域。对于范围,我们传递一个数组,其中包含可用水平空间的最小值(零)和最大值(对应于内部图表的 innerWidth)。

我们回到函数 drawDonutCharts() ,正如你在清单 5.2 中看到的,我们首先声明一个名为 years 的数组,它列出了我们感兴趣的年份,这里是 1975、1995 和 2013。然后,使用 forEach() 循环,我们为感兴趣的每一年附加一个 SVG 组,并将其保存在名为 donutContainer 的常量中。最后,我们通过设置组的转换属性来翻译组。水平平移是通过调用计算的 xScale ,我们将当前年份传递到该平移,而垂直平移对应于内部图表的半高。

示例 5.2 为每个圆环图追加和翻译一个 SVG 组(圆环图.js)

const years=[1975, 1995, 2013];
years.forEach(year=> {
 
  const donutContainer=donutContainers
    .append("g")
      .attr("transform", `translate(${xScale(year)}, ${innerHeight/2})`);
 
});

5.1.2 饼图布局生成器

完成准备步骤后,我们现在可以专注于圆环图。饼图和圆环图可视化部分与整体的关系或每个扇区相对于总量表示的数量。D3 饼图布局生成器通过根据每个切片所代表的百分比计算每个切片的开始和结束角度来帮助我们。

设置数据格式

D3 的饼图生成器希望输入数据格式化为数字数组。例如,对于 1975 年,我们可以有一个数组,其中包含与每种音乐格式对应的销售额,如下所示:

const sales1975=[8061.8, 2770.4, 469.5, 0, 0, 0, 48.5];

虽然这样一个简单的数组足以生成饼图,但它会阻止我们以后根据它所代表的音乐格式为每个切片分配颜色。为了随身携带这些信息,我们可以使用一个对象数组,其中包含音乐格式的 ID 和感兴趣年份的相关销售额。

在示例 5.3 中,我们首先从加载数据集的 columns 属性中提取格式。获取数据时,例如,使用 d3.csv() 方法,D3 将一个数组附加到数据集,其中包含原始 CSV 数据集中每列的标题,并使用键 data.columns 进行访问。如果将提取的数据记录到控制台中,则会在数据数组的末尾看到它,如图 5.5 所示。

由于我们只对音乐格式感兴趣,因此我们可以过滤列数组以删除“year”标签。

图 5.5 从 CSV 文件获取数据时,D3 将数组附加到数据集,其中包含原始数据集中列的标题。可以使用键 data.columns 访问此数组。


为了准备饼图生成器的数据,我们还需要提取感兴趣的年份的数据。我们使用 JavaScript 方法 find() 隔离这些数据,并将其存储在名为 yearData 的常量中。

我们遍历格式数组,对于每种格式,我们创建一个对象,其中包含格式 id 及其感兴趣年份的相关销售额。最后,我们将这个对象推入 数组格式化数据 ,之前声明。

示例 5.3 设置饼图生成器的数据格式(圆环图.js)

const years=[1975, 1995, 2013];
const formats=data.columns.filter(format=> format !=="year"); #A
 
years.forEach(year=> {
  ...
 
  const yearData=data.find(d=> d.year===year); #B
 
  const formattedData=[]; #C
 
  formats.forEach(format=> {                                        #D
    formattedData.push({ format: format, sales: yearData[format] }); #D
  });                                                                #D
 
});

准备就绪后,格式化数据是一个对象数组,每个对象都包含格式的 id 及其感兴趣年份的相关销售额。

//=> formattedData=[
        { format: "vinyl", sales: 8061.8 },
        { format: "eight_track", sales: 2770.4 },
        { format: "cassette", sales: 469.5 },
        { format: "cd", sales: 0 },
        { format: "download", sales: 0 },
        { format: "streaming", sales: 0 },
        { format: "other", sales: 48.5 }
      ];

初始化和调用饼图布局生成器

现在数据格式正确,我们可以初始化饼图布局生成器。我们用方法 d3.pie() 构造一个新的饼图生成器,它是 d3 形状模块 (https://github.com/d3/d3-shape#pies) 的一部分。由于格式化数据是一个对象数组,我们需要告诉饼图生成器哪个键包含将决定切片大小的值。我们通过设置 value() 访问器函数来做到这一点,如以下代码片段所示。我们还将 pie 生成器存储在一个名为 pieGenerator 的常量中,以便我们可以像调用任何其他函数一样调用它。

const pieGenerator=d3.pie()
  .value(d=> d.sales);

要生成饼图布局的数据,我们只需调用饼图生成器函数,将格式化的数据作为参数传递,并将结果存储在名为 注释数据 .

const pieGenerator=d3.pie()
  .value(d=> d.sales);
const annotatedData=pieGenerator(formattedData);

饼图生成器返回一个新的带批注的数据集,其中包含对原始数据集的引用,但也包括新属性:每个切片的值、其索引及其开始和结束角度(以弧度为单位)。请注意,每个切片之间的填充也包括 padAngle 并且当前设置为零。我们稍后会改变这一点。

//=> annotatedData=[
        {
          data: { format: "vinyl", sales: 8061.8 },
          value: 8061.8,
          index: 0,
          startAngle: 0,
          endAngle: 4.5,
          padAngle: 0,
        },
        ...
      ];

请务必了解饼图布局生成器不直接参与绘制饼图。这是一个预处理步骤,用于计算饼图扇区的角度。如图5.1和5.6所述,此过程通常包括三个步骤:

  1. 格式化数据;
  2. 初始化饼图布局函数;
  3. 调用饼图布局并将格式化的数据作为参数传递。稍后,我们将使用饼图布局返回的带注释的数据集来绘制圆弧。

图 5.6 饼图布局生成器是一个预处理步骤,用于生成一个带注释的数据集,其中包含饼图每个切片的开始和结束角度。该过程通常涉及格式化我们的数据,初始化饼图生成器函数,并调用该函数以获取带注释的数据。


5.1.3 绘制圆弧

准备好带注释的数据集后,是时候生成弧线了!您将看到以下步骤与上一章中创建弧的方式非常相似。出于这个原因,我们不会解释每一个细节。如果您需要更深入的讨论,请参阅第 4 章。

在示例 5.4 中,我们首先通过调用 d3.arc() 方法及其负责设置图表内外半径、切片之间的填充以及切片角半径的各种访问器函数来初始化 arc 生成器。如果内半径设置为零,我们将获得一个饼图,而如果它大于零,我们将得到一个圆环图。

与第 4 章中使用的策略的唯一区别是,这次我们可以在声明电弧发生器的同时设置 startAngle() 和 endAngle() 访问器函数。这是因为现在,这些值包含在带注释的数据集中,我们可以告诉这些访问器函数如何通过 d.startAngle 和 d.endAngle .

要使弧出现在屏幕上,我们需要做的最后一件事是使用数据绑定模式为注释数据集中的每个对象生成一个路径元素(每个弧或切片都有一个对象)。请注意,在清单 5.4 中,我们如何为每个甜甜圈的弧指定一个特定的类名 ( 'arc-${year}' ),并将该类名用作数据绑定模式中的选择器。由于我们正在循环中创建甜甜圈,这将防止 D3 在制作新甜甜圈时覆盖每个甜甜圈。

最后,我们调用弧发生器函数来计算每条路径的 d 属性。

示例 5.4 生成和绘制圆弧(圆环图.js)

const arcGenerator=d3.arc()
  .startAngle(d=> d.startAngle) #A
  .endAngle(d=> d.endAngle)     #A
  .innerRadius(60)
  .outerRadius(100)
  .padAngle(0.02)
  .cornerRadius(3);
 
const arcs=donutContainer
  .selectAll(`.arc-${year}`) #B
  .data(annotatedData)           #B
  .join("path")                  #B
    .attr("class", `arc-${year}`)
    .attr("d", arcGenerator); #C

使用色阶

如果您保存项目并在浏览器中查看圆环图,您会发现它们的形状是正确的,但每个弧线都是漆黑的。这是正常的,黑色是 SVG 路径的默认填充属性。为了提高可读性,我们将根据每个弧线所代表的音乐格式对它们应用不同的颜色。

将正确的颜色应用于每个弧的一种简单且可重用的方法是声明色阶。在 D3 中,色阶通常使用 d3.scaleOrdinal() (https://github.com/d3/d3-scale#scaleOrdinal) 创建。序数刻度将离散域映射到离散范围。在我们的例子中,域是音乐格式的数组,范围是包含与每种格式关联的颜色的数组。

在文件比例中.js ,我们首先声明一个序数比例并将其保存在常量色阶中。然后,我们通过将 formatInfo 数组(在共享常量中可用.js的每个格式 id 映射到数组中来设置其域。我们对颜色做同样的事情,您可以根据自己的喜好进行个性化设置。在本章中,我们将重用此色阶来创建构成我们项目的所有图表。

const colorScale=d3.scaleOrdinal();
 
const defineScales=(data)=> {
 
  colorScale
    .domain(formatsInfo.map(f=> f.id))
    .range(formatsInfo.map(f=> f.color));
 
};

回到圆环图.js我们可以通过将绑定到每个弧的音乐格式 id 传递给色阶来设置弧的填充属性。

const arcs=donutContainer
  .selectAll(`.arc-${year}`)
  .data(annotatedData)          
  .join("path")                 
    .attr("class", `arc-${year}`)
    .attr("d", arcGenerator)
    .attr("fill", d=> colorScale(d.data.format));

保存您的项目并在浏览器中查看。看起来还不错!弧线已按降序显示,从最大到最小,这有助于提高可读性。我们已经可以看到音乐的面貌在 1975 年、1995 年和 2013 年间发生了怎样的变化,主导格式完全不同。

图 5.7 1975年、1995年和2013年的圆环图



5.1.4 添加标签

在第4章中,我们提到饼图有时很难解释,因为人脑不太擅长将角度转换为比率。我们可以通过在圆环图的质心上添加一个标签来提高圆环图的可读性,该标签以百分比表示每个弧的值,就像我们在上一章中所做的那样。

在示例 5.5 中,我们稍微修改了用于创建圆弧的代码(来自示例 5.4)。首先,我们使用数据绑定模式来追加 SVG 组而不是路径元素。然后,我们将路径元素(用于圆弧)和 SVG 文本元素(用于标签)附加到这些组中。由于父母将绑定数据传递给孩子,因此我们将在塑造弧线和标签时访问数据。

我们通过调用电弧发生器来绘制电弧,就像我们之前所做的那样。要设置标签的文本,我们需要计算每个弧线表示的比率或百分比。我们通过从弧的结束角度中减去弧的起始角并将结果除以 2π(以弧度为单位的完整圆覆盖的角度)来执行此计算。请注意我们如何使用括号表示法(d[“百分比”])将百分比值存储到绑定数据中。当我们需要对不同的属性进行相同的计算时,这个技巧很有用。它可以防止您多次重复计算。为了返回标签的文本,我们将计算出的百分比传递给方法 d3.format(“.0%”) ,该方法生成一个舍入百分比并在标签末尾添加一个百分比符号。

我们应用相同的策略来计算每个弧的质心,这是我们想要放置标签的位置。当设置标签的 x 属性时,我们计算相关弧的质心(使用第 4 章中讨论的技术)并将其存储在绑定数据中( d[“质心”])。然后,在设置 y 属性时,质心数组已经可以通过 d.centroid 访问。

为了使标签水平和垂直地以质心居中,我们需要将其文本锚点和主要基线属性设置为中间。我们还使用fill属性将它们的颜色设置为白色,将其字体大小增加到16px,将其字体粗细增加到500以提高可读性。

如果您保存项目并在浏览器中查看圆环图,您会发现标签在大弧上工作良好,但在较小的弧线上几乎无法读取。在专业项目中,我们可以通过将小弧的标签移动到圆环图之外来解决这个问题。对于此项目,当百分比小于 5% 时,我们根本不会通过将其填充不透明度属性设置为零来显示这些标签。

示例 5.5 在每个弧的质心上添加值标签(圆环图.js)

const arcs=donutContainer
  .selectAll(`.arc-${year}`)
  .data(annotatedData)
  .join("g") #A
    .attr("class", `arc-${year}`);
 
arcs                                               #B
  .append("path")                                  #B
    .attr("d", arcGenerator)                       #B
    .attr("fill", d=> colorScale(d.data.format)); #B
 
arcs
  .append("text") #C
    .text(d=> {
      d["percentage"]=(d.endAngle - d.startAngle) / (2 * Math.PI); #D
      return d3.format(".0%")(d.percentage);                         #D
    })
    .attr("x", d=> {              #E
      d["centroid"]=arcGenerator #E
        .startAngle(d.startAngle)  #E
        .endAngle(d.endAngle)      #E
        .centroid();               #E
      return d.centroid[0];        #E
    })                             #E
    .attr("y", d=> d.centroid[1]) #E
    .attr("text-anchor", "middle")
    .attr("alignment-baseline", "middle")
    .attr("fill", "#f6fafc")
    .attr("fill-opacity", d=> d.percentage < 0.05 ? 0 : 1) #F
    .style("font-size", "16px")
    .style("font-weight", 500);

图 5.8 带百分比标签的圆环图



作为最后一步,我们将指示圆环图所代表的年份,标签位于其中心。我们通过在每个甜甜圈容器中附加一个文本元素来做到这一点。因为我们还在循环往复年份,所以我们可以直接应用当前年份作为标签的文本。此外,由于圆环容器位于图表的中心,因此文本元素会自动正确定位。我们所要做的就是设置其文本锚点和主要基线属性,使其水平和垂直居中。

donutContainer
  .append("text")
    .text(year)
    .attr("text-anchor", "middle")
    .attr("dominant-baseline", "middle")
    .style("font-size", "24px")
    .style("font-weight", 500);

瞧!我们的圆环图是完整的。

图 5.9 带有年份标签的完整圆环图


图 5.10 回顾了创建饼图或圆环图的步骤。在第一步中,我们使用布局函数 d3.pie() 预处理数据,以获得带有注释的数据集,其中包含每个切片的角度。然后,我们使用弧发生器函数绘制弧线,该函数从注释数据集中获取角度并返回每个路径的 d 属性。最后,我们使用 SVG 文本元素添加标签以提高图表的可读性。

图 5.10 创建饼图或圆环图所涉及的主要步骤。


5.2 堆叠形状

到目前为止,我们已经处理了可以在任何传统电子表格中轻松创建的信息可视化的简单示例。但是你进入这个行业并不是为了制作类似Excel的图表。您可能希望用漂亮的数据让您的观众惊叹不已,为您的美学 je ne sais quoi 赢得奖项,并通过您随着时间的推移而变化的表现唤起深刻的情感反应。

流图是代表变化和变化的崇高信息可视化。在你开始把这些部分放在一起之前,创作似乎具有挑战性。归根结底,流图是所谓的堆积面积图的变体。这些层相互累積,并根据靠近中心的组件所占用的空间来调整上方和下方元素的面积。它似乎是有机的,因为这种吸积性模仿了许多生物的生长方式,似乎暗示了控制生物生长和衰败的各种涌现特性。我们稍后会解释它的外观,但首先,让我们弄清楚如何构建它。

我们在本书的第一部分看了一个流线图,因为它实际上并没有那么奇特。流图是一种堆积图,这意味着它与堆积条形图基本相似,如图 5.11 所示。流线图也类似于我们在上一章中构建的折线图后面的区域,只是这些区域相互堆叠。在本节中,我们将使用 D3 的堆栈和面积生成器来创建堆叠条形图,然后创建流线图。

图 5.11 流图与堆积条形图基本相似。在 D3 中,两者都是使用堆栈布局生成器创建的。


在 D3 中,创建堆积条形图或流图的步骤类似,如图 5.12 所示。首先,我们初始化一个堆栈布局生成器并设置堆栈的参数。然后,我们将原始数据集传递给堆栈生成器,堆栈生成器将返回一个新的注释数据集,指示每个数据点的下限和上限。如果我们制作一个流线图,我们还必须初始化一个面积生成器,类似于上一章中讨论的直线和曲线生成器。最后,我们将带注释的数据集绑定到制作图表所需的 SVG 形状、堆叠条形图的矩形或流图的路径。在流图的情况下,调用面积生成器来计算路径的 d 属性。我们将在以下小节中更详细地介绍这些步骤。

图 5.12 使用 D3 创建堆积图的步骤。


5.2.1 堆栈布局生成器

堆栈布局生成器是一个 D3 函数,它将具有多个类别的数据集作为输入。本章示例中使用的数据集包含 1973 年至 2019 年间每年不同音乐格式的总销售额。每种音乐格式将成为堆叠图表中的一个系列。

与前面讨论的饼图布局生成器一样,堆栈布局函数返回一个新的注释数据集,其中包含不同序列在“堆叠”到另一个时的位置。堆栈生成器是 d3 形状模块 (https://github.com/d3/d3-shape#stacks) 的一部分。

让我们将堆栈布局付诸行动,并开始在位于堆叠条形图中的函数 drawStackedBars() 中工作.js 。请注意,此函数已经包含将 SVG 容器附加到 div 的代码,ID 为 “bars”,以及内部图表的组容器。这与我们在第4章中使用的策略相同,与保证金惯例并行。

在下面的代码片段中,我们首先使用方法 d3.stack() 声明一个堆栈生成器,并将其存储在一个名为 stackGenerator 的常量中。然后,我们需要告诉生成器数据集中的哪些键包含我们要堆叠的值(将成为序列)。我们使用 keys() 访问器函数来做到这一点,我们将类别 id 数组传递给该函数,这里是每种音乐格式的标识符。我们通过映射 formatInfo 常量的 id 来创建这个数组。我们还可以使用附加到数据集的列键并过滤掉年份,就像我们在 5.1.2 节中所做的那样。

最后,我们调用堆栈生成器并将数据作为参数传递,以获得带注释的数据集。我们将新数据集存储在名为 注释数据 .

const stackGenerator=d3.stack() #A
  .keys(formatsInfo.map(f=> f.id)); #B
 
const annotatedData=stackGenerator(data); #C

如果将带注释的数据集记录到控制台中,您将看到它由多维数组组成。我们首先为每个系列提供一个数组,如图 5.13 所示,序列的 id 可通过 key 属性获得。然后,序列数组包含另一组数组,数据集中每年一个数组。最后这些数组包括相关年份类别的下限和上限以及该年份的原始数据。下限和上限分别由索引 d[0] 和 d[1] 访问,如果 d 对应于数组。

格式“乙烯基”是堆栈布局处理的第一个键。请注意,它的下限始终为零,而其上边界对应于该格式的当年销售额。然后,以下类别是“8 轨”。8 轨的下边界对应于黑胶唱片的上边界,我们将 8 轨的销量相加以获得其上限,从而创建一个堆栈。

图 5.13 堆栈布局生成器返回的带注释的数据集。


如果“堆栈”的概念还不清楚,下图可能会有所帮助。如果我们从原始数据集中仔细观察 1986 年,我们将看到音乐主要通过三种格式提供:黑胶唱片的销量为 2,825M$,盒式磁带为 5,830M$,CD 为 2,170M$。我们在图5.14的左侧显示了这些数据点,独立绘制。

当我们使用堆栈布局时,我们创建所谓的“数据列”而不是“数据点”,每列都有下限和上限。如果我们的堆栈从黑胶唱片开始,则下限为零,上边界对应于 1986 年黑胶唱片的销售额:2,825M$。然后,我们将盒式磁带的销售叠加在其上:下边界对应于黑胶唱片的上限(2,825M$),上边界是黑胶唱片和盒式磁带(8,655M$)的销售量。这个上边界成为CD销售的下限,其上边界对应于三种格式(10,825M$)的销售量相加。这些边界在带注释的数据集中通过索引(d[0]和d[1])访问。

图 5.14 堆栈布局生成器将数据点转换为堆叠数据列,并返回包含每个数据列的下限和上限的带注释的数据集。在这里,我们看到 1986 年的一个例子。


5.2.2 绘制堆积条形图

在本节中,我们将创建您在图 5.11 底部看到的堆积条形图。堆积条形图类似于我们在第 2 章和第 3 章中已经制作的条形图,只是条形图分为多个类别或系列。堆积条形图和一般的堆积可视化通常用于显示趋势随时间推移的演变。

就像我们对圆环图所做的那样,我们将使用堆栈布局返回的带注释的数据集来绘制对应于每个类别的条形。但首先,我们需要一个垂直轴的比例,将每个矩形的下边界和上边界转换为垂直位置。我们希望条形的高度与销售额成线性比例,因此我们将使用线性刻度。由于此刻度需要访问带注释的数据,因此我们将在函数 drawStackedBars() 中声明它。

刻度域从零到注释数据中可用的最大上限。我们知道,这个最大值必须存在于最后一个带注释的数据系列中,这些数据将位于图表的顶部。我们可以使用 length 属性访问这个系列( annotatedData[annotatedData.length - 1])。然后,我们使用方法 d3.max() 检索属性 d[1] 下的最大值,该值对应于上限。

垂直刻度的范围从内部图表底部的innerHeight到内部图表顶部的零(请记住,SVG垂直轴在向下方向上为正)。最后,我们将 scale 声明与方法 .nice() 链接起来,这将确保域以“nice”舍入值结束,而不是注释数据集中的实际最大值。

示例 5.6 声明垂直刻度(堆叠条.js)

const maxUpperBoundary=d3.max(annotatedData[annotatedData.length - 1], d
 ?=> d[1]);
 
const yScale=d3.scaleLinear()
  .domain([0, maxUpperBoundary])
  .range([innerHeight, 0])
  .nice();

我们现在已准备好附加条形图。为此,我们遍历带注释的数据,并逐个附加序列,如清单 5.7 中所述。我们从数据绑定模式开始,为系列数组中的每个项目或年份附加一个矩形元素(每种音乐格式都有一个系列)。请注意我们如何将与当前系列相关的类名应用于矩形并将其用作选择器。如果我们简单地使用“rect”元素作为选择器,每次执行循环时,先前创建的矩形都将被删除并替换为新矩形。

然后,我们通过调用带刻度的带宽属性来设置矩形的 x 属性,通过将当前年份传递给 xScale 来设置它们的宽度属性。y 属性对应于矩形左上角的垂直位置,由前面声明的垂直刻度返回,我们将矩形的上边界 (d[1] ) 传递到该刻度。

同样,矩形的高度是其上边界和下边界位置之间的差异。这里有一点问题。因为 SVG 垂直轴在向下方向上是正的,所以 yScale(d[0]) 返回的值高于 yScale(d[1])。我们需要从前者中减去后者,以避免为 y 属性提供负值,这会引发错误。

最后,我们通过将当前音乐格式传递给色阶来设置 fill 属性,该色阶可在每个系列的 key 属性下访问,如前面图 5.13 所示。

清单 5.7 追加堆积条(堆积条.js)

annotatedData.forEach(serie=> { #A
 
  innerChart
    .selectAll(`.bar-${serie.key}`)           #B
    .data(serie)                              #B
    .join("rect")                             #B
      .attr("class", d=> `bar-${serie.key}`) #B
      
      .attr("x", d=> xScale(d.data.year))              #C
      .attr("y", d=> yScale(d[1]))                     #C
      .attr("width", xScale.bandwidth())                #C
      .attr("height", d=> yScale(d[0]) - yScale(d[1])) #C
      .attr("fill", colorScale(serie.key));             #C
 
});

如果保存项目,您将看到条形之间没有水平空间。我们可以通过回到 xScale 的声明来解决这个问题,并将其 paddingInner() 访问器函数设置为值 20%,就像我们在第 3 章中所做的那样。

示例 5.8 在条形之间添加填充(刻度.js)

xScale
  .domain(data.map(d=> d.year))
  .range([0, innerWidth])
  .paddingInner(0.2);

为了完成我们的堆积条形图,我们需要添加轴。在清单 5.9 中,我们首先使用方法 d3.axisBottom() 声明一个底部轴,并将 xScale 作为引用传递。

我们将轴声明与方法链接起来, .tickValues() ,它允许我们陈述我们希望在图表上看到的确切刻度和标签。否则,D3 将每年提供一对刻度和标签,看起来会很局促且难以阅读。方法.tickValues()将值数组作为参数。我们使用方法 d3.range() 生成这个数组,并声明我们想要从 1975 年到 2020 年的每个整数,步长为 5。

我们还使用方法 .tickSizeOuter() 隐藏底部轴两端的刻度,我们向其传递值为零。方法tickValues()和tickSizeOuter()都可以在d3轴模块(https://github.com/d3/d3-axis)中找到,而d3.range()是d3-array模块(https://github.com/d3/d3-array)的一部分。

最后,我们使用 call() 方法将底部轴附加到图表中,在转换为底部的组中,并对左轴执行相同的操作。

清单 5.9 追加轴(堆叠条.js)

const bottomAxis=d3.axisBottom(xScale) #A
  .tickValues(d3.range(1975, 2020, 5))   #A
  .tickSizeOuter(0);                     #A
 
innerChart                                             #B
  .append("g")                                         #B
    .attr("transform", `translate(0, ${innerHeight})`) #B
    .call(bottomAxis);                                 #B
 
const leftAxis=d3.axisLeft(yScale); #C
innerChart                            #C
  .append("g")                        #C
    .call(leftAxis);                  #C

如果保存项目并在浏览器中查看它,您可能会发现轴标签有点太小。此外,如第 4 章所述,D3 将字体族“sans-serif”应用于包含轴元素的 SVG 组,这意味着项目的字体系列不会被继承。从 CSS 文件可视化中.css ,我们可以使用选择器 .tick 文本定位轴标签并修改其样式属性。在下面的代码片段中,我们更改了它们的字体系列、字体大小和字体粗细属性。

.tick text {
  font-family: 'Roboto', sans-serif;
  font-size: 14px;
  font-weight: 500;
}

完成后,堆积条形图将类似于图 5.15 中的条形图,但看起来还不像图 5.2 中的条形图或托管项目 (https://d3js-in-action-third-edition.github.io/visualizing-40-years-of-music-industry-sales/) 中的条形图。我们一会儿就到那里。

图5.15 第一版堆积条形图


5.2.3 绘制流线图

在上一小节中,我们使用堆栈布局函数生成一个带注释的数据集,从中绘制堆叠条形图的矩形。现在,我们将应用类似的策略来绘制流图。尽管流图看起来比堆积条形图更复杂,但它们很容易在 D3 中创建。主要区别在于,对于流图,我们使用带注释的数据集来追加区域,而为堆叠条形图附加矩形。

在本小节中,我们将使用函数 drawStreamGraph() ,您可以在文件流图中找到它.js 。此函数已包含将 SVG 容器附加到 div 的代码,ID 为 “streamgraph”,以及内部图表的组容器。这与我们在第4章中使用的策略相同,与保证金惯例并行。

在示例 5.10 中,我们初始化堆栈生成器并调用它来获取带注释的数据。我们还声明了一个线性刻度来计算垂直边界的位置。这与我们用于堆积条形图的代码完全相同。现在,不要担心我们正在复制代码。我们将在下一小节中回到它。

示例 5.10 声明堆栈生成器和垂直轴(流图.js)

const stackGenerator=d3.stack()           #A
  .keys(formatsInfo.map(f=> f.id));        #A
const annotatedData=stackGenerator(data); #A
 
const maxUpperBoundary=d3.max(annotatedData[annotatedData.length - 1], d
  ?=> d[1]);
const yScale=d3.scaleLinear()  #B
  .domain([0, maxUpperBoundary]) #B
  .range([innerHeight, 0])       #B
  .nice();                       #B

为了绘制堆叠区域,我们需要一个区域生成器函数,该函数将负责计算用于绘制序列的每个路径元素的 d 属性。如第4章所述,面积生成器至少使用三个访问器函数,在我们的例子中,一个用于检索每个数据点的水平位置,一个用于堆叠区域的下边界,另一个用于它们的上边界。图 5.16 说明了面积生成器如何应用于堆叠区域。

图 5.16 面积生成器 d3.area() 与三个或更多访问器函数组合在一起。当与流图的堆栈布局结合使用时,它使用每个数据点的下限和上限(y0 和 y1)来计算区域的 d 属性。


在下面的代码片段中,我们初始化了区域生成器 d3.area() 。首先,我们使用 x() 访问器函数来计算每个数据点的水平位置。由于 xScale 是波段刻度,因此它返回相关年份的每个波段开头的位置,该位置可在每个数据点的数据对象中的注释数据集中访问 ( d.data.year)。如果我们希望数据点与下面堆叠条形图的条形中心水平对齐,我们需要将数据点向右平移,宽度为条形宽度的一半,我们可以用带宽()属性计算带刻度。

然后,我们使用 y0() 和 y(1) 访问器函数来确定数据点沿每个序列的下边界和上边界的垂直位置。这个位置是用 yScale 计算的,之前声明了,我们将边界的值传递给边界值,可以通过边界数据中的数组索引访问:d[0] 表示下边界,d[1] 表示上限边界。

最后,如果我们想沿每个边界插值数据点以获得曲线而不是直线,我们使用 curve() 访问器函数。这里我们选择了曲线插值函数d3.curveCatmullRom。如前所述,曲线插值会修改数据的表示,必须谨慎选择。有关讨论和演示,请参阅第 4.2.2 节。

const areaGenerator=d3.area()
  .x(d=> xScale(d.data.year) + xScale.bandwidth()/2)
  .y0(d=> yScale(d[0]))
  .y1(d=> yScale(d[1]))
  .curve(d3.curveCatmullRom);

现在,我们已准备好绘制堆叠区域!首先,我们使用数据绑定模式为注释数据集中的每个序列生成一个 SVG 路径元素。我们调用面积生成器函数来获取每个路径的 d 属性,以及它们的填充属性的色阶。

请注意我们如何在 SVG 组中附加路径以保持标记井井有条且易于检查。这也将有助于在以后保持区域和垂直网格的适当并置。

innerChart
  .append("g")
    .attr("class", "areas-container")
  .selectAll("path")
  .data(annotatedData)
  .join("path")
    .attr("d", areaGenerator)
    .attr("fill", d=> colorScale(d.key));

在本节中,我们要做的最后一件事是向流图添加轴和标签。我们开始声明轴生成器 d3.axisLeft() 并将 yScale 作为引用传递。然后,我们使用 .call() 方法将轴元素附加到 SVG 组中。

const leftAxis=d3.axisLeft(yScale);
innerChart
  .append("g")
  .call(leftAxis);

将轴展开到网格中

我们可能会省略 x 轴,因为流图与下面的堆叠条形图水平对齐,并且此图表具有相同的 x 轴。但我们将利用这个机会讨论如何扩展轴上的刻度以在图表后面创建网格。

首先,我们需要记住,SVG 元素是按照它们在 SVG 容器中出现的顺序绘制的。因此,如果我们希望网格出现在流线图后面,我们需要先绘制它。这就是为什么以下代码片段应位于追加流图路径的代码片段之前的原因。

到目前为止,生成底部轴的代码与用于堆叠条形图的代码相同,包括 tickValues() 和 tickSizeOuter() 方法的使用。

const bottomAxis=d3.axisBottom(xScale)
  .tickValues(d3.range(1975, 2020, 5))
  .tickSizeOuter(0);
 
innerChart
  .append("g")
    .attr("class", "x-axis-streamgraph")
    .attr("transform", `translate(0, ${innerHeight})`)
    .call(bottomAxis);

要将即时报价转换为网格,我们所要做的就是使用 tickSize() 方法扩展它们的长度。通过这种方法,我们给即时报价一个对应于内部图表高度的长度,乘以 -1 使它们向上增长。请注意,我们还可以首先避免平移轴,并将此长度设置为正值,以使刻度线从上到下的方向增长。每当需要水平网格时,此方法也可以应用于左轴或右轴。

const bottomAxis=d3.axisBottom(xScale)
  .tickValues(d3.range(1975, 2020, 5))
  .tickSizeOuter(0)
  .tickSize(innerHeight * -1);

最后,我们可以选择隐藏轴底部的水平线和年份标签,方法是将它们的不透明度定为零。为此,我们使用之前赋予 x 轴容器的类名( x-axis-streamgraph ),并将其用作 CSS 文件可视化中的选择器.css .正如您在下面的代码片段中看到的,通过“ .x-axis-streamgraph path”访问的水平线的不透明度是用stroke-opacity属性管理的,而我们需要使用填充不透明度来隐藏年份标签(“ .x-axis-streamgraph文本”)。我们还可以使用 D3 style() 方法来处理流图内的不透明度.js .

.x-axis-streamgraph path {
  stroke-opacity: 0;
}
.x-axis-streamgraph text {
  fill-opacity: 0;
}

处理复杂的 svg 文本布局

最后,我们将在左侧轴上方添加一个标签,以指示此轴所代表的内容。如图 5.2 所示,或者在托管项目 (https://d3js-in-action-third-edition.github.io/visualizing-40-years-of-music-industry-sales/) 上,流图的标签分为两行,第一行带有文本“总收入(百万美元)”,第二行提到“根据通货膨胀进行调整”。

我们将使用 SVG 文本构建此标签。关于 SVG 文本,需要了解的一件事是它的行为不像 HTML 文本。例如,如果我们在 HTML 元素中添加文本,文本将根据水平可用空间自动换行或重排。SVG 文本不会这样做,每个文本元素的位置需要单独处理。

要操作 SVG 文本中的潜台词,我们可以使用 tspan 元素。将文本分解为多个 tspan,允许使用其 x、y、dx 和 dy 属性分别调整其样式和位置,前两个用于参考 SVG 容器的坐标系,后两个用于参考前一个文本元素。

  • x :文本基线的水平位置,相对于 SVG 容器的坐标系。
  • y :文本基线的垂直位置,相对于 SVG 容器的坐标系。
  • dx :相对于上一个文本元素,移动文本基线的水平位置。
  • dy :相对于上一个文本元素,移动文本基线的垂直位置。

在上述所有定义中,请务必记住,文本基线由其文本锚点属性水平控制,垂直由其主基线属性控制。

为了创建我们的标签,我们可以使用位于 SVG 文本中的三个 tspan 元素,如图 5.17 所示。如果文本元素的主基线属性设置为 hang ,则文本将显示在 SVG 容器原点的正下方和右侧。使用 dx 和 dy,我们可以根据图 5.17 分别将第二个和第三个跨度移动到它们的正确位置。

图 5.17 tspan 元素允许分别操作副词项的样式和位置。我们使用属性 dx 和 dy 来设置相对于前一个文本元素的位置。



在下面的代码片段中,我们将该策略付诸行动。首先,我们将一个文本元素附加到我们的 SVG 容器中,并将其主基线属性设置为值 hang ,这意味着文本及其子文本的基线将位于它们的正上方。

我们将文本选择保存到常量 leftAxisLabel 中,并重复使用它将三个 tspan 元素附加到文本容器中。我们将第一个tspan的文本设置为“总收入”,第二个tspan设置为“(百万美元)”,第三个tspan设置为“经通货膨胀调整”。

默认情况下,tspan 元素一个接一个地显示在同一水平线上。保存您的项目并查看标签进行确认。

const leftAxisLabel=svg
  .append("text")
    .attr("dominant-baseline", "hanging");
 
leftAxisLabel
  .append("tspan")
    .text("Total revenue");
 
leftAxisLabel
  .append("tspan")
    .text("(million USD)");
 
leftAxisLabel
  .append("tspan")
    .text("Adjusted for inflation");

要将第二个 tspan 稍微向右移动,我们可以设置其 dx 属性并为其指定值 5。要将第三个 tspan 移动到第一个和第二个 tspan 下方,我们可以使用 y 或 dy 属性并为其指定值“20”。在这种特殊情况下,这两个属性将具有相同的效果。最后,如果我们希望第三个 tspan 的左侧与 SVG 容器的左边框对齐,最好使用 x 属性并将其设置为零。

const leftAxisLabel=svg
  .append("text")
    .attr("dominant-baseline", "hanging");
 
leftAxisLabel
  .append("tspan")
    .text("Total revenue");
 
leftAxisLabel
  .append("tspan")
    .text("(million USD)")
    .attr("dx", 5);
 
leftAxisLabel
  .append("tspan")
    .text("Adjusted for inflation")
    .attr("x", 0)
    .attr("dy", 20);

通常,tspan 元素用于将不同的样式应用于文本的一部分。例如,我们可以降低第二个和第三个 tspan 元素的不透明度,使它们呈灰色,并减小第三个 tspan 的字体大小,因为与标签的其余部分相比,它传达了次要信息。

const leftAxisLabel=svg
  .append("text")
    .attr("dominant-baseline", "hanging");
 
leftAxisLabel
  .append("tspan")
    .text("Total revenue");
 
leftAxisLabel
  .append("tspan")
    .text("(million USD)")
    .attr("dx", 5)
    .attr("fill-opacity", 0.7);
 
leftAxisLabel
  .append("tspan")
    .text("Adjusted for inflation")
    .attr("x", 0)
    .attr("dy", 20)
    .attr("fill-opacity", 0.7)
    .style("font-size", "14px");

我们的流图的第一次迭代现在已经完成,如图 5.18 所示。当此类图表的垂直基线位于零时,我们通常将其命名为堆积面积图,而流线图的面积往往位于中心基线周围。在下一小节中,我们将讨论如何更改图表的基线。但在我们到达那里之前,观察堆叠条形图和堆叠面积图在这一点上的相似之处很有趣。

图 5.18 我们流线图的第一次迭代,也可以命名为堆积面积图。


5.2.4 堆栈顺序和堆栈偏移属性

通过控制序列的堆叠顺序以及它们在零基线周围的垂直定位方式,我们可以将堆积条形图和堆积面积图更进一步。此级别的控制是通过 order() 和 offset() 访问器函数实现的,这两个函数都应用于堆栈布局生成器。

让我们首先看一下 order() 访问器函数,它控制形状垂直堆叠的顺序。D3 有六个内置订单,可以作为参数传递,如图 5.19 所示。

d3.stackOrderNone 是默认顺序,这意味着如果未设置 order() 访问器函数,则应用该顺序。它按与 keys 数组中列出的顺序相同的顺序堆叠对应于每个系列的形状,从下到上。d3.stackOrderReverse颠倒了这个顺序,从底部的最后一个键开始,到顶部的第一个键结束。

d3.stackOrderAscending 计算每个序列的总和。总和最小的序列位于底部,其他序列按升序堆叠。同样,d3.stackOrder降序将总和最大的序列放在底部,并按降序堆叠序列。

最后两个订单计算每个序列达到其最大值的指数。d3.stackOrderAppearance 按序列达到峰值的顺序堆叠序列,这对于可读性非常有用,尤其是对于基线为零的堆栈。另一方面,d3.stackOrderInsideOut 将峰值最早的序列定位在图表的中间,将最新峰值的序列放在外面。此顺序非常适合形状围绕中心基线分布的流线图。

图 5.19 D3 允许使用 order() 访问器函数控制形状堆叠的顺序。在这里,我们看到堆积区域的示例,但相同的原则适用于堆积条形图。


堆栈布局的另一个访问器函数称为 offset() ,控制图表零基线的位置以及形状在其周围的分布方式。D3 有五个内置偏移量,如图 5.20 所示。

d3.stackOffsetNone 将所有形状定位在零基线上方。它是默认偏移量。

以下三个偏移分布基线上方和下方的形状。d3.stackOffsetDiverging 将正值定位在基线上方,负值定位在基线下方。此偏移最适合堆积条形图。d3.stackOffsetSilhouette 将基线移动到图表的中心。d3.stackOffsetWiggle的作用类似,但优化了基线的位置,以最小化摆动或序列的交替上下移动。这三个偏移需要调整垂直刻度的域以适应基线的位置。

最后,d3.stackOffsetExpand 规范化 0 到 1 之间的数据值,使每个索引的总和为 100%。归一化值时,垂直刻度的域也在 0 和 1 之间变化。

图 5.20 D3 允许使用 offset() 访问器函数控制形状相对于基线的位置。在这里,我们看到堆积区域和堆积条形的示例。


在创建堆叠布局时,我们通常会组合顺序和偏移量以达到所需的结果。虽然对于我们何时应该使用顺序或偏移量没有严格的规定,但目标应始终是提高可视化的可读性和/或将注意力集中在我们想要强调的故事上。

对于本章的项目,我们将使用 order() 和 offset() 访问器函数将堆积面积图转换为具有中心基线和堆积条形图以表示相对值(介于 0 和 100% 之间)的流图。

在我们开始之前需要注意的一件事是,order() 和 offset() 访问器函数可以显着更改注释数据集中携带的值。例如,通过将堆积面积图转换为流图,所表示的销售价值将不再在 24 到 000,12 之间变化,而是在 -000,12 和 000,3 之间变化。同样,如果我们使用 d0.stackOffsetExpand 来规范堆叠条形图显示的销售额,则注释数据将包含在 1 到 <> 之间。在设置垂直刻度的域时,必须考虑这些不同的值。

考虑不同 offset() 访问器函数带来的域变化的一种简单方法是确保我们始终计算注释数据集中的最小值和最大值,并相应地设置域。

在示例 5.11 中,我们首先声明两个空数组,一个存储每个序列的最小值,另一个存储最大值。然后我们遍历带注释的数据集,使用 d3.min() 和 d3.max() 找到每个序列的最小值和最大值,并将它们推送到相应的数组中。最后,我们从每个数组中提取最小值和最大值,并使用它们来设置域。

此策略可应用于流图和堆积条形图。对于堆积条形图,您可能希望从比例声明中删除 nice() 方法,以仅显示介于 0 和 1 之间的值。

示例 5.11 计算 yScale 域的最小值和最大值(堆积条形图.js + 流图.js)

const minLowerBoundaries=[]; #A
const maxUpperBoundaries=[]; #A
 
annotatedData.forEach(series=> {                     #B
  minLowerBoundaries.push(d3.min(series, d=> d[0])); #B
  maxUpperBoundaries.push(d3.max(series, d=> d[1])); #B
});                                                   #B
 
const minDomain=d3.min(minLowerBoundaries); #C
const maxDomain=d3.max(maxUpperBoundaries); #C
 
const yScale=d3.scaleLinear()
  .domain([minDomain, maxDomain]) #D
  .range([innerHeight, 0])
  .nice();

完成此修改后,您可以自由测试偏移值的任何顺序,并且 yScale 的域将自动调整。

现在,要将堆叠面积图转换为流图,我们所要做的就是将 order() 和 offset() 访问器函数链接到之前声明的堆栈生成器。在这里,我们使用订单 d3.stackOrderInsideOut 与偏移量 d3.stackOffsetSilhouette 结合使用。我们鼓励您测试一些组合,以了解它们如何影响数据表示。

const stackGenerator=d3.stack()
  .keys(formatsInfo.map(f=> f.id))
  .order(d3.stackOrderInsideOut)
  .offset(d3.stackOffsetSilhouette);

可视化提示

流线图在美学上令人愉悦,它们肯定会吸引注意力。但它们也更难阅读。当您想要概述现象随时间推移的演变时,流图是一个很好的选择。但是,如果您希望读者能够精确地测量和比较值,堆叠条形图或成对条形图是更好的选择。工具提示还可以帮助提高流图的可读性。我们将在第 7 章中构建一个。

同样,我们通过将其偏移量设置为 d3.stackOffsetExpand 来修改堆积条形图,这将规范化 0 到 1 之间的销售值。我们还将顺序设置为 d3.stackOrderDescending,以强调 CD 格式在 2000 年左右如何主导市场。再次尝试一些组合,看看它如何改变图表传达的故事焦点。

const stackGenerator=d3.stack()
  .keys(formatsInfo.map(f=> f.id))
  .order(d3.stackOrderDescending)
  .offset(d3.stackOffsetExpand);

5.3 向项目添加图例

在最后一节中,我们将讨论如何使用传统的 HTML 元素轻松构建图例,并将通过在堆叠条形图下方放置颜色图例来将其付诸实践。图例是数据可视化的重要组成部分,可帮助读者解释他们所看到的内容。

通常,图例涉及文本,我们知道 SVG 文本并不总是便于操作。如果您查看我们将在图 5.21 中构建的颜色图例,您会发现它由一系列彩色方块和标签组成,与堆叠条形图水平居中。使用 SVG 元素构建此图例将涉及计算每个矩形和文本元素的确切位置。这是可能的,但有一种更简单的方法。

图 5.21 我们将在本节中构建的颜色图例,位于堆积条形图下方。


D3 不仅用于控制 SVG 元素。它可以创建和操作任何 DOM 元素。这意味着我们可以使用传统的HTML元素构建图例,并使用CSS来定位它们。有很多方法可以继续,但这样的图例要求结构化为 HTML 无序列表 ( <ul></ul> )。带有标签的每个颜色组合都可以存储在 <li></li> 元素中,其中一个 <span></span> 元素保存颜色,另一个元素包含标签,如以下示例所示。

<ul>
  <li>
    <span> color 1 </span>
    <span> label 1 </span>
  </li>
  <li>
    <span> color 2 </span>
    <span> label 2 </span>
  </li>
 
  ...
 
</ul>

要使用 D3 构建此 HTML 结构,我们转到文件图例.js并开始在函数 addLegend() 中工作。在下面的代码片段中,我们选择带有一类 legend-container 的 div,该类已存在于索引中.html .我们将一个 ul 元素附加到这个 div 中,并给它一类颜色图例。

然后,我们使用数据绑定模式为 formatInfo 数组中包含的每种格式附加一个 li 元素,该数组在共享常量中可用.js .我们将此选择保存到一个常量中 命名 图例项 .

我们调用 legendItems 选择并将 span 元素附加到其中,并根据相关的音乐格式设置跨度的背景颜色属性。为此,我们可以直接从 格式信息 或调用色标。最后,我们附加另一个 span 元素并将其文本设置为当前格式的标签键。

const legendItems=d3.select(".legend-container")
  .append("ul")                    #A
    .attr("class", "color-legend") #A
  .selectAll(".color-legend-item") #A
  .data(formatsInfo)               #A
  .join("li")                      #A
    .attr("class", "color-legend-item");
 
legendItems                                   #B
  .append("span")                             #B
    .attr("class", "color-legend-item-color") #B
    .style("background-color", d=> d.color); #B
 
legendItems                                   #C
  .append("span")                             #C
    .attr("class", "color-legend-item-label") #C
    .text(d=> d.label);                      #C

如果您应用的类名与上一代码段中使用的类名相同,则图例应自动如图 5.21 所示。这是因为以下样式已在 base 中设置.css .请注意我们如何使用 CSS flexbox 属性 (https://css-tricks.com/snippets/css/a-guide-to-flexbox/) 来处理图例的布局。我们不会花时间解释这个样式片段,因为您可能熟悉CSS,这不是本书的重点。这里的主要要点是,有时传统的HTML元素和CSS样式比SVG更容易操作,我们可以使用D3来绑定数据和操作任何DOM元素。

.color-legend {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  margin: 0;
  padding-left: 0;
}
.color-legend-item {
  margin: 5px 12px;
  font-size: 1.4rem;
}
.color-legend span {
  display: inline-block;
}
.color-legend-item-color {
  position: relative;
  top: 2px;
  width: 14px;
  height: 14px;
  margin-right: 5px;
  border-radius: 3px;
}

您现在知道如何使用 D3 布局,如饼图和堆栈布局。在第7章中,我们将把这个项目变成一个交互式可视化。如果您接下来想这样做,请随时直接去那里。

5.4 小结

  • D3 布局是将数据集作为输入并生成新的批注数据集作为输出的函数。带注释的数据集包含绘制特定可视化效果所需的属性。布局是一个预处理步骤,用于设置数据的格式,使其准备好以您选择的形式显示。
  • 饼图布局 d3.pie() 计算饼图或圆环图的每个切片的开始和结束角度。饼图布局要求将输入数据格式化为数字数组或对象数组。数组的每个元素对应于饼图的一个切片。如果数据被格式化为对象数组,我们使用 value() 访问器函数来告诉饼图布局在对象的键下存储将存储确定切片大小的值。我们通过调用饼图布局函数并将输入数据作为参数传递来获取带注释的数据集。带批注的数据集包含饼图每个切片的开始角和结束角。要绘制饼图或圆环图的弧线,我们需要声明一个弧形生成器函数。此生成器将使用注释数据集中包含的开始和结束角度来计算用于绘制弧的 SVG 路径的 d 属性。
  • 堆栈布局 d3.stack() 计算不同序列在“堆叠”到另一个上时的位置。我们告诉堆栈布局输入数据集中的哪些键包含我们要使用 keys() 访问器函数堆叠的值。我们通过调用堆栈布局函数并将输入数据作为参数传递来获取带注释的数据集。带注释的数据集包含每个序列的下限和上限值,可通过索引(分别为 d[0] 和 d[1])访问。它还包含对输入数据的引用。为了绘制堆叠条形图,我们使用堆栈布局返回的数据来附加矩形,其位置取决于每个系列的下限和上限。为了绘制流图,我们初始化一个区域生成器函数,并使用其访问器函数来指定如何访问注释数据集中下限和上限的值。然后我们使用带注释的数据集附加 SVG 路径并使用面积生成器计算它们的 d 属性。我们通过将 order() 访问器函数链接到堆栈布局来控制形状的堆叠顺序。D3 提供六个内置订单。通过将 offset() 访问器函数链接到堆栈布局,我们控制形状在堆叠图的零基线周围的定位方式。D3 提供五个内置偏移。顺序和偏移会影响图表的域,在设置负责计算堆叠形状位置的比例时应考虑到这一点。
  • D3 的序数刻度同时具有离散输入和离散输出。它们非常适合离散色阶,其中数组中的每个元素都映射到特定颜色。
  • 图例是开发可视化的关键方面。当图例包含多个元素时,值得考虑使用传统的HTML元素构建它们并使用CSS进行布局。此方法通常比使用 SVG 形状和文本更容易。
  • 我们可以通过将文本分解为多个 tspan 元素来创建复杂的 SVG 文本布局。定位 SVG 文本时,x 和 y 属性根据 SVG 容器的原点设置文本基线的位置,而 dx 和 dy 指示相对于前一个文本元素的位置。