目标

借由数个范例的模仿,学习D3与JS的知识点,逐渐掌握数据处理、常规可视化图表、交互、动效、定制可视化图表的能力。

执行策略

  1. 前期先零散学习,每学完一个例子后再汇整梳理。
  2. 案例与课本交替学习。

项目1:气候可视化

课程来源:https://www.udemy.com/course/how-to-visualize-data-with-d3/learn/lecture/17527824#overview

截圖 2020-03-08 下午10.54.15.png

图片中的可视化,将1850年到2017年的温度数据给可视化出来,蓝色代表在平均温度之下,红色代表在平均温度之上,全球暖化显而易见,此可视化造成许多讨论。此作品的作者为气象科学家 Ed Hawkins。作品链接:https://showyourstripes.info/

Day 1:读取数据

截圖 2020-03-08 下午10.33.07.png

绘制可视化之前,需要先读取数据,然后再对数据进行处理。用D3读取数据并加以处理、再可视化,这个流程使用了回调函数(callback function)。在主要的函数里面嵌套了其他的函数。例如上图,主要的函数是processData,回调函数是then的graph函数,processData是对数据的处理,graph函数是对处理好的数据绘制出来。

Day 2:数据处理

昨天完成了对的数据初步读取,今天需要截取出要呈现在可视化上的数据:每一年的平均温度与该年年份。原始数据不必要的东西较多,这时候可以定义变量,然后限定条件,并返回在限定条件下的数据,例如此范例,定义了year与avg两个变量,并对avg的数据限定为“J-D”这个当年分的平均气温:

截圖 2020-03-09 下午11.25.51.png

Day 3:制作容器

今天制作一个绘图容器给可视化容身之处。先选定id为land的div,并且设置了数个小长条(每一个小长条用来绘制当年的气温数据)的长与宽。然后使用d3自带的链接方法,制造一个用来绘制可视化的svg,先是贴在land上,然后再设置长与宽。这个过程,就完成了可视化容器的制作。

3.png

Day 4:在容器内添加rect,且设置属性

今天的任务是在容器(svg)内添加绑定数据的rect(用来绘制气温颜色的方块)。首先是选择所有尚未创建的rect,然后指定数据(因为在graph里面,数据为data),然后使用enter方法将rect与data关联起来。此时已经创建好绑定数据的rect,这个过程类似loop方法(将每一条数据依序与每一个方块绑定)。然后继续设置这些方块的长宽与位置。

4-1.png

已经生成了所有的rect,等待更多的可视化设置:
4-2.png

Day 5:设置方块的位置

昨天的方块堆叠在一起,是因为对所有的方块设置的固定的x参数;今天使用function来根据数据的index依序设置每一个方块的位置,另外为了保持代码简洁,使用了arrow function。这个设置方块位置的代码目前对我而言有个困惑,为什么D3会知道(d,i)的d是data且i是index,是因为自动继承了graph funciton的设置?

5-1.png5-2.png

Day 6:设置数据到颜色的映射(domain&range)

今天是项目1的最后一次打卡,解决了从数据到颜色的映射,并将数据绘制出来。今天主要有两个变量:avgDatalinearScaleForData

avgData 用来处理 data,data 的格式为 year 与 avg,但只需要保留 avg 即可,故使用 map function 来解决这个问题。

linearScaleForData 用来处理数据到颜色范围的映射,使用的scale是scaleLinear,并用d3.min与d3.max来找出数据的极大极小值,然后再用range去映射到colors。
6-1.png

在最后 stripes 的样式设置,style的填充方式是先使用 Math.round 将 linearScaleForData(d.avg) 从 floating numbers 变成 integer,然后再放入colors[]里,就完成了数据到可视化的映射过程。
6-2.png

成果:
6-3.png

困惑:

  1. avgData与d.avg有何不同?avgData是array,d.avg是啥?
  2. the data which is going to be put in the scale must be integer, why?
  3. colors[data] will generate the data visualization, colors(data) won’t, why?

Day 7:复习

梳理这个项目的简要代码逻辑:在 html 嵌入 readData function,并在 js 里展开 readData 的架构。readData 由两个 callback 组成:processData 与 graph。processData 用来转换数据格式,从string 到 array,且筛选出要绘制的数据。graph 用来创建与设置可视化的元素。

7.jpeg

项目2:D3.js In Action —— Ch2. Information visualization data flow

Day 8: 2.1 Working with data - 2.1.1 loading data

  1. d3.csv() d3.json() 使用相同的格式来读取数据:先定义数据档案的路径,再定义回调函数。
  1. d3.csv("cities.csv", (error,data) => {console.log(error,data)});
  2. d3.csv("cities.csv", d => console.log(d));
  1. d3.xhr 包括了 d3.csv()d3.json()d3.json 等等,适合用于数据为动态更新的api(异步读取数据),若数据为静止不变的,例如地图,可以之间在script里面引用,或是用import(Node)、require(ES2015)。

备注1:在对文件进行请求时,XHR代表当前页面执行时的网络请求(ajax请求),JS代表当前页面加载的JS文件。

备注2:因为D3以及改版到V5,上述的异步回调方法稍旧,已改为使用promises:

  1. d3.csv("file.csv").then(function(data){console.log(data);});

Day 9:Promises 学习

来源1:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises
来源2:https://es6.ruanyifeng.com/#docs/promise

因为 D3.js V5 在数据处理上改用 promises,故今天的任务是大致了解 promises 的概念。
原始的 callback 函数:

  1. function successCallback(result) {
  2. console.log("Audio file ready at URL: " + result);}
  3. function failureCallback(error) {
  4. console.error("Error generating audio file: " + error);}
  5. createAudioFileAsync(audioSettings, successCallback, failureCallback);

使用 promises 简化后:

  1. createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

或是更简洁表示:

  1. const promise = createAudioFileAsync(audioSettings);
  2. promise.then(successCallback, failureCallback);

Day 10:2.1 Working with data - 2.1.2 Formatting data - categorical data

这个章节主要讨论数据的 scale 处理,以 categorical data 为例:

  1. var sampleArray = [423,124,66,424,58,10,900,44,1];
  2. var qScale = d3.scaleQuantile().domain(sampleArray).range([0,1,2]);

从原始数据 sampleArray 到 [0, 1, 2] 如何映射呢?首先使用 scaleQuantile 并将数据 domain (d即data)结合,最后告诉代码要映射的范围 range (r即result)。

例子:

  1. qScale(1211) //为2

Day 11:2.1 Working with data - 2.1.2 Formatting data - nesting & extent

继续昨天的 scale 主题,探讨对关系型数据进行分类(nest)与计算数据的极大极小值(extent)。

11-1.png

nest key 指定数据字段,用于分类。例如 key(d => d.user) 即以 user 的数据字段来分类数据。

11-2.png

疑问:不懂 el => +el.population + 含义,类似for loop?

Day 12:2.2 Data-binding - 2.2.1. Selections and binding

练习D3的选择器(selection)与数据绑定(binding),使用书本的代码时遇到了未知的问题,目前还不知道如何解决。

12-1.png

12-2.png

Day 13:Debugging

昨天的已解决,改成promises即可:
12-3.png

Day 14:2.3. Data presentation style, attributes, and content

目前遇到的问题是,console没有报错,但图没有显示,还不知道问题在哪里。

14.png

项目3:D3 course 2018 taught by Curran

鉴于项目二的书籍使用较旧版本的D3,在代码转换上稍微麻烦,改用另外一个线上免费课程。

Day 15:Basic JS - Program Sturcture

简要复习 while loop 与 for loop。
15.png

Day 16:Basic JS - Defining a function

  1. const square = function(x) { return x*x };

等同于

  1. function square(x){return x*x;}

等同于

  1. square = (x) => x*x;

使用 arrow function 时,若有多个 statement,需要使用{}。

Day 17:Basic JS - Recursion

Recursion:对自我进行调用的函数。

  1. function power(base, exponent) {
  2. if (exponent == 0){return 1;}
  3. else {return base * power(base, exponent -1)}
  4. }

另外一个例子:

  1. const factorial = n =>
  2. n === 0
  3. ? 1
  4. : n * factorial(n - 1);

Day 18:Data structures - object and array

Object 由 {} 组成,array 由 [] 组成。Object 可以被包含在 array 里面,例如:

  1. [{name: 'Accord'}, {name: 'Fiat'}]

若要在 array 里面添加新的 object,可以使用 push 方法:

  1. cars.push({
  2. make: 'Nissan',
  3. model: 'Leaf',
  4. year: 2012,
  5. price: 1800
  6. });

若想要打印 cars 里面所有的 price,可以使用 for loop:

  1. for(let i = 0; i < cars.length; i++){
  2. const car = cars[i];
  3. console.log(cars.price);
  4. }

疑问:为啥 () 里面的 i 是用 let,但 {} 里面的 car 是用 const?

另外,也可以使用 forEach 来打印 cars 里面所有的 price:

  1. const printCarPrice = car => {
  2. console.log(car.price);
  3. }; // undefined
  4. cars.forEach(printCarPrice); // 打印所有的 price

Day 19:Asynchronous programming

以下面的代码来解释何谓异步编程:

  1. setTimeout(() => console.log("Tick"), 1000);

setTimeout 可以指定后面代码的执行时间,例如 console.log 打印 “Tick” 的时间是 1 秒。

上述的方法是 callback,新版 JS 已改用 promises (可参考 Day 9):

  1. let myPromose = new Promise((resolve, reject) => {
  2. setTimeout(() => resolve(), 2000);
  3. });
  4. myPromise.then(() => {
  5. console.log('Promise resolved');
  6. });

另外一个例子:

  1. let waitSeconds = numSeconds => new Promise(resolve => {
  2. const message = `${numSeconds} seconds have passed!`;
  3. setTimeout(() => resolve(message), numSeconds * 1000);
  4. })
  1. waitSeconds(2)
  2. .then(message => console.log(message));
  3. // 2 seconds have passed!

Day 20:快速读取 D3

今天的内容较为简单,在html里面测试是否成功读取d3文件,在html里面使用如下的代码:

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <title>Warming Stripes</title>
  5. <meta charset="utf-8">
  6. <script type="text/javascript" src="./d3.js"></script>
  7. <script>
  8. console.log(d3);
  9. </script>
  10. </head>
  11. <body>
  12. <div id="land"></div>
  13. </body>
  14. </html>

之前在调用d3时一直忽略这个简单的方法。

Day 21:Draw a circle - part 1

今天的教程主要是使用 webpack 来执行 d3 代码,但犹豫教程是在线上的代码环境执行,不用安装 webpack,但我在本地环境下需要使用 webpack,目前卡在不知道如何在本地部署 webpack。不过目前在 bundle.js 写代码还是可以顺利执行。
21-1.png21-2.png

Day 22:Draw a circle - part 2

继续昨天的示例,用 d3 来绘制一个笑脸,这里的代码使用到之前比较不熟悉的方法,需要多去了解。
22-1.png22-2.png

Day 23:Draw a circle - part 3

基于前两天的基础,完成了笑脸的绘制。
23-1.png23-2.png

Day 24:Making a bar chart - part 1

今天主要的任务是清洗数据。从联合国网站上下载人口数据,并只保留2020年的数据与各国名称,其余多余的内容删除,并转换成csv格式。
24-1.png

24-2.png

Day 25:Making a bar chart - part 2

读取数据是今天的任务。另外,因为本地端运行代码需要 webpack,所以今天开始改用作者的线上工具来运行代码(已经整合了webpack),不然在学习过程中会一直遇到问题。之后有时间再去部署本地的 webpack。这个线上工具使用起来还挺方便的,数据一下子就读取出来了:

25.png

Day 26:Making a bar chart - part 3

目前读取到的数据格式为 string,但我们需要它为 number 格式。解决的方法是使用 forEach 对所有的数据进行处理,代码如下:

  1. csv("population.csv").then(data => {
  2. data.forEach(d => {
  3. d.population = +d.population;
  4. });
  5. console.log(data);
  6. });

26.png

Day 27:Making a bar chart - part 4

这几天的大任务是为每一个数据生成方块,今天的小任务是先把方块画出来。需要使用 D3 的 data join 方法。

先生成一个 render 函数,并在里面指定方块的数据与绘制效果:

  1. const render = data => {
  2. // make one rectangle for each row
  3. // D3 Data Join
  4. svg.selectAll("rect").data(data)
  5. .enter().append("rect")
  6. .attr("width",300)
  7. .attr("height",300)
  8. };

在数据执行函数(?)里面调用 render 函数,以完成方块的绘制:

  1. csv("population.csv").then(data => {
  2. data.forEach(d => {
  3. d.population = +d.population * 1000;
  4. });
  5. render(data);
  6. });

方块绘制出来后有个问题,目前都叠在一起:

27.png

Day 28:Making a bar chart - part 5

要解决叠在一起的问题,会需要用到以下三个 d3 的方法:scaleLinear, max, scaleBand。scaleLinear 用来处理 x 的位置(绑定 d.population),scaleBand 用来处理 y 的位置(绑定 d.country)。

28.png

另外,因为要让 bar chart 横着放,所以在 svg 的绘制上,需要指定 y 的 attr。

  1. .attr("y", d => yScale(d.country))

Day 29:Making a bar chart - part 6

使用 margin convention,来调整可视化的排版。先创立 margin、innerWidth、innerHeight的变量:

  1. const margin = {top:10, right:20, bottom: 20, left: 20};
  2. const innerWidth = width - margin.left - margin.right;
  3. const innerHeight = height - margin.top - margin.bottom;

然后在之前的代码基础上,修改width、height至新的变量innerWidth、innerHeight:

  1. const xScale = scaleLinear()
  2. .domain([0, max(data, d => d.population)])
  3. .range([0, innerWidth]);
  4. const yScale = scaleBand()
  5. .domain(data.map(d => d.country))
  6. .range([0, innerHeight]);
  7. const g = svg.append("g")
  8. .attr("transform", `translate(${margin.left},${margin.top})`);

29.png

Day 30:Making a bar chart - part 7

最后一步是添加坐标轴。先设置 y 轴的坐标轴:

  1. const yAxis = axisLeft(yScale);

然后用下面的代码即可在 y 轴绘制出坐标轴,function(selection)是在 D3 很常用的方法:

  1. // 两者择一
  2. yAxis(g.append("g"));
  3. g.append("g").call(yAxis);

30.png

Day 31:Customizing axes of a bar chart - part 1

处理坐标轴的数据格式,例如2000000,可以利用 d3 的 format 将这个数据改成 2M。

  1. const xAxis = axisBottom(xScale)
  2. .tickFormat(format(".3s"));
  3. g.append("g").call(xAxis)
  4. .attr("transform", `translate(${0},${innerHeight})`);

但实际操作时报错,目前不知道问题在哪里。

Day 32:Making a scatter plot - part 1

scalePoint 用于 scatter plot;scaleBand 用于 bar chart。散点图的属性设置,跟长条图不一样的地方,是cx、cy、r,长条图用的是y、width、height。

  1. import {
  2. scalePoint
  3. } from 'd3';
  4. g.selectAll('circle').data(data)
  5. .enter().append('circle')
  6. .attr('cy', d => yScale(yValue(d)))
  7. .attr('cx', d => xScale(xValue(d)))
  8. .attr('r', 20);

32.png

Day 33:Making a scatter plot - part 2

调整可视化的细节。若觉得 x 轴跟散点图太靠近,可以增加 padding 的数值:

  1. const yScale = scalePoint()
  2. .domain(data.map(yValue))
  3. .range([0, innerHeight])
  4. .padding(1);

因为 China 跟 India 的散点太远,阅读不便,所以在视觉上增加横轴线条:

  1. const yAxis = axisLeft(yScale)
  2. .tickSize(-innerWidth);
  3. g.append('g')
  4. .call(yAxis)
  5. .selectAll('.domain')
  6. .remove();
  7. const xScale = scaleLinear()
  8. .domain([0, max(data, xValue)])
  9. .range([0, innerWidth])
  10. .nice(); // 用于快速调整轴线细节

33.png

Day 34:Making a scatter plot - part 3

使用新的数据集-mpg。首先是载入数据:

  1. csv('https://vizhub.com/curran/datasets/auto-mpg.csv').then(data => {
  2. data.forEach(d => {
  3. d.mpg = +d.mpg;
  4. d.cylinders = +d.cylinders;
  5. d.displacement = +d.displacement;
  6. d.horsepower = +d.horsepower;
  7. d.weight = +d.weight;
  8. d.acceleration = +d.acceleration;
  9. d.year = +d.year;
  10. });
  11. render(data);
  12. });

然后根据数据类型调整部分代码:

  1. const xValue = d => d.cylinders;
  2. const yValue = d => d.mpg;
  3. const xScale = scaleLinear()
  4. .domain(extent(data, xValue))
  5. .range([0, innerWidth])
  6. .nice();
  7. const yScale = scaleLinear()
  8. .domain(extent(data, yValue))
  9. .range([0, innerHeight]);

需注意,extent 是需要使用()包围,而不是[]。另外,yScale不需要使用 padding (为什么?),否则会报错。

Day 35:Making an area plot - part 1

面积图用到的是时间序列数据,对时序数据的处理,可以用 JS 原生的 Date() 方法:

  1. d.timestamp = new Date(d.timestamp);

结果就是:Sat Mar 21 2015 05:00:00 GMT+0800。顺利读取正确的数据格式。

35.png

Day 36:Making an area plot - part 2

因为更换了数据类型,若继续使用原来的设置,会有如下的结果产生:

36-1.png

修改后的结果:

36-2.png

详细比较:

x轴的scale。原来:scaleLinear,修改:scaleTime,如此可以调整 x 轴的显示格式。

y轴的range。原来:[0, innerHeight],修改:[innerHeight, 0],如此可以把数据的顺序置换。因为extent(data, yValue)到range的映射,数据最小值是y轴的最低部,所以映射到innerHeight。

面积图。原来:

  1. g.selectAll('circle').data(data)
  2. .enter().append('circle')
  3. .attr('cy', d => yScale(yValue(d)))
  4. .attr('cx', d => xScale(xValue(d)))
  5. .attr('r', circleRadius);

更新:

  1. const lineGenerator = line()
  2. .x(d => xScale(xValue(d))) // not .x(xValue)
  3. .y(d => yScale(yValue(d))) // not .y(yValue)
  4. .curve(curveBasis);
  5. g.append('path')
  6. .attr('class', 'line-path')
  7. .attr('d', lineGenerator(data))

1、不需要用到 data(data),这个是data join,但面积图只有一条线,所以不需要这个功能。
2、在给x与y赋值的时候,需要注意将数据映射到scale——xScale(xValue(d)));不能直接将数据给x与y——.x(xValue)。
3、.curve(curveBasis)用来让线看起来较为圆滑,可以同时使用css的stroke-linejoin:round;功能。

Day 37:Making an area plot - part 3

从折线图到面积图,代码API的使用上有所不同。面积图会需要制定x0、x1或y0、y1。若面积图跟坐标轴没有对齐,可以试着将xScale的.nice()给删除。

37.png

Day 38:General update patterns - part 1

学习四个数据更新方法:Enter、Exit、Update、Merge,并涵盖了四个主题:Animated Transition、Object constancy、Nested element、Singular elements。

基础设置:

  1. import { select, range } from 'd3';
  2. const svg = select('svg');
  3. const width = +svg.attr('width');
  4. const height = +svg.attr('height');
  5. const makeFruit = type => ({ type });
  6. const fruits = range(5)
  7. .map(() => makeFruit('apple'));
  8. svg.selectAll('circle').data(fruits)
  9. .enter().append('circle')
  10. .attr('cx', (d, i) => i * 100)
  11. .attr('cy', height/2)
  12. .attr('fill', 'red')
  13. .attr('r', 50);

Day 39:General update patterns - part 2

对昨天的代码进行解释。下面的代码创建了data join

  1. .selectAll().data()

使用.selectAll(),可以让d3知道有哪些物件要跟数据绑定,但此时物件为空。在使用.data()时,可以让d3知道有哪些数据要跟物件绑定。此时使用.enter().append()可以完成这个绑定。

  1. // Eat an apple. .pop() removes a data point
  2. fruits.pop();
  3. svg.selectAll('circle').data(fruits)
  4. .exit().remove();

39.png

Day 40:Let’s make a map with D3.js

成果:
40-1.png40-2.png

首先下载geojson的数据,并在import里面输入json。因为数据的内容有些不需要,可以创建变量来保留会用到的部分:

  1. json('https://unpkg.com/world-atlas@1.1.4/world/110m.json')
  2. .then(data => {
  3. const countries = feature(data, data.objects.countries);
  4. }

完成数据的导入后,继续在import里面输入geopath与各类的projecttion。geopath用来绘制图形(这段代码嵌入json里面):

  1. svg.selectAll('path')
  2. .data(countries.features)
  3. .enter().append('path')
  4. .attr('class', 'country')
  5. .attr('d', pathGenerator);

projection 有数种选择,例如geoMercator()、geoOrthographic()、geoNaturalEarth1()。

目前遇到一个问题:无法将网页的background颜色与地图底图的颜色区分,教程可以区分,但我实际做区分不了。比对代码并没有发现不同处。

Day 41:Cheap Tricks for Interaction

41.png
两个目标:在 CSS 使用 hover 效果、创建提示框。

在 CSS 使用 hover 效果,较为简单:

  1. .country:hover {
  2. fill:lightgrey;
  3. }

创建提示框(较为复杂,不懂的地方较多):

  1. Promise.all([
  2. tsv('https://unpkg.com/world-atlas@1.1.4/world/110m.tsv'),
  3. json('https://unpkg.com/world-atlas@1.1.4/world/110m.json')
  4. ]).then(([tsvData, topoJSONdata]) => {
  5. const countryName = {};
  6. tsvData.forEach(d =>{
  7. countryName[d.iso_n3] = d.name;
  8. })
  9. const countries = feature(topoJSONdata, topoJSONdata.objects.countries);
  10. svg.selectAll('path')
  11. .data(countries.features)
  12. .enter().append('path')
  13. .attr('class', 'country')
  14. .attr('d', pathGenerator)
  15. .append('title')
  16. .text(d => countryName[d.id]);
  17. });

Day 42:Building a tree visualization of workd countries - part 1

截至目前为止,已经学了基础的散点图、长条图、地图、基础交互,这次的任务要着手处理阶层数据。视频分成六个部分:

  • constructing a ode=link tree visualization
  • adding text labels to the nodes
  • using the margin convention
  • tweaking label alignment and size
  • padding and zooming
  • using a custom font

Day 43:Building a tree visualization of worlk countries - part 2

任何项目第一步皆是先读取数据。d3.hierarchy 用来处理已经阶层化的数据,若数据还没有阶层化,则需要使用d3.stratify进行处理。目前使用的数据已经阶层化,所以使用d3.hierarchy。

  1. json('data.json')
  2. .then(data => {
  3. console.log(data);
  4. });

参照d3的文档,初步绘制图形。linkPathGenerator用来绘制link(x、y的设置比较让人困惑,目前也不知道如何解释)。

  1. json('data.json')
  2. .then(data => {
  3. const root = hierarchy(data);
  4. const links = treeLayout(root).links();
  5. const linkPathGenerator = linkHorizontal()
  6. .x(d => d.y)
  7. .y(d => d.x)
  8. svg.selectAll('path').data(links)
  9. .enter().append('path')
  10. .attr('d', linkPathGenerator)
  11. });

42.png

Day 44:Building a tree visualization of world countries - part 3

昨天的图表会有那样的问题是因为继承了path的原始设定,此时只需要去css调整一下即可:

  1. path {
  2. fill:none;
  3. stroke:black;
  4. }
  • adding text labels to the nodes
    需留意root.descendants这个处理数据的方法:
  1. svg.selectAll('text').data(root.descendants())
  2. .enter().append('text')
  3. .attr('x', d => d.y)
  4. .attr('y', d => d.x)
  5. .text(d => d.data.data.id)

44.png

Day 45:Building a tree visualization of world countries - part 4

今天的任务是调整标签的位置与尺寸。

调整文字的位置:

  1. .attr('dy', '0.32em')

新增文字的阴影效果,让文字易读:

  1. text {
  2. text-shadow:
  3. -1px -1px 3px white,
  4. -1px 1px 3px white,
  5. 1px -1px 3px white,
  6. 1px 1px 3px white,
  7. }

45.png

Day 46:Building a tree visualization of world countries - part 5

因为文字超过页面视图,所以需要调整 margin,并使用 g 来设置边界:

  1. const innerWidth = width - margin.left - margin.right;
  2. const innerHeight = height - margin.top - margin.bottom;
  3. const treeLayout = tree().size([innerHeight, innerWidth]);
  4. const g = svg
  5. .attr('width', width)
  6. .attr('height', height)
  7. .append('g')
  8. .attr('transform', 'translate(${margin.left},${margin.top})');

使用 anchor 加上逻辑判断,来设置部分文字标签的摆放位置:

  1. .attr('text-anchor', d => d.children ? 'middle' : 'start')

46.png

Day 47:Building a tree visualization of world countries - part 6

加入以下的代码,即可对可视化进行缩放:

  1. svg.call(zoom().on('zoom', () => {
  2. g.attr('transform', event.transform);
  3. }))

47.png

Day 48:Choropleth map - part 1

这次的主题是绘制区域热力图,使用的代码基于在原先的地图作品上,并做了些调整。在 JS 中,先使用 fill 来测试是否能够控制颜色,若无法控制,则需要检查 CSS 是否对 fill 的颜色有设置,因为 CSS 的优先级高于 JS。

48.png

Day 49:Choropleth map - part 2

目前的代码量较多且都放在同一个文件里面,可以透过创建 moduel 的方式来减少部分代码。

  1. import {loadAndProcessData} from 'loadAndProcessData'
  2. loadAndProcessData().then(countries => {
  3. });

然后创建 loadAndProcessData.js 用来在别的文件读取这段代码。

  1. import { feature } from 'topojson';
  2. import { tsv, json } from 'd3';
  3. export const loadAndProcessData = () =>
  4. Promise
  5. .all([
  6. tsv('https://unpkg.com/world-atlas@1.1.4/world/50m.tsv'),
  7. json('https://unpkg.com/world-atlas@1.1.4/world/50m.json')
  8. ])
  9. .then(([tsvData, topoJSONdata]) => {
  10. const rowById = tsvData.reduce((accumulator, d) => {
  11. accumulator[d.iso_n3] = d;
  12. return accumulator;
  13. }, {});
  14. const countries = feature(topoJSONdata, topoJSONdata.objects.countries);
  15. countries.features.forEach(d => {
  16. Object.assign(d.properties, rowById[d.id])
  17. });
  18. return countries;
  19. });

Day 50:Scatter plot w/ menus

昨天的图表因为有代码导致图画不出来,也找不到bug,所以就先跳过去。这次的任务是创建带有下拉框的散点图,下拉框可以筛选数据。

首先是以散点图的代码为基础,创建了dropdownMenu.js:

  1. export const dropdownMenu = (selection, props) => {
  2. const {
  3. options
  4. } = props;
  5. };

然后在 index.js 调用刚才创建的代码块:

  1. import { dropdownMenu } from './dropdownMenu'

Day 51:Scatter plot w/ menus - part 2

调用 dropdownMenu 后,先做一个简单版本的 menu 来测试:

  1. dropdownMenu(select('body'), {
  2. options: ['1', '2', '3']
  3. })

然后在 dropdownMenu.js 的文件里面安插如下的代码:

  1. export const dropdownMenu = (selection, props) => {
  2. const {
  3. options
  4. } = props;
  5. let select = selection.selectAll('select').data([null]);
  6. select = select.enter().append('select').merge(select);
  7. const option = select.selectAll('option').data(options);
  8. option.enter().append('option').merge(option)
  9. .attr('value', d => d)
  10. .text(d => d);
  11. };

此时,menu 已经被创建出来了,但被掩盖在 svg 之下,需要继续调整。

51.png

项目4:Fullstack Data Visualization with D3

这是一本电子书,作者为 Amelia Wattenberger,书本有比较多关于高阶可视化的教学。前一个课程到最后有比较多跟图表不相干的教学,代码难度也较大,故跳过那个课程。

Day 52:Making your first chart - part 1

新的课程在代码的逻辑使用上也跟之前的课程有所不同。在这个课程中,调用代码的方式如下:

  1. async function drawLineChart() {
  2. // write your code here
  3. }
  4. drawLineChart()

第一个课程是在html去调用,这一块之后完成100天的打卡后要回来汇总思考。

首先一样是读取数据:

  1. async function drawLineChart() {
  2. const dataset = await d3.json("./../../my_weather_data.json");
  3. console.log(dataset);
  4. }
  5. drawLineChart()

Day 53:Making your first chart - part 2

以 accesor (存取器)的方式来读取数据:

  1. const yAccessor = d => d.temperatureMax;
  2. const dateParser = d3.timeParse("%Y-%m-%d");
  3. const xAccesor = d => dateParser(d.date);

作者提到,用这种方式比较少见,但是自己的经验总结出来的,有如下的优点:

  1. 容易修改:图表的数据或是设计样式要修改时,使用 accesor 改起来比较方便
  2. 便于记录:可以快速回忆是使用了数据的哪个字段
  3. 易于思考:使用这种方式可以帮助我们去思考要用数据的哪个字段

Day 54:Making your first chart - part 3

在绘制图表时,可以将图表分成两个区域:wrapper以及bounds。Bounds为纯粹的图表本身,而wrapper是图表之外的元素——如坐标轴——的绘制区域。

margin跟bounds的设置如下:
54.png
Creating our scales 的部分不赘述。

Day 55:Making your first chart - part 3

在绘制坐标轴时,较为冗余的方法是:

  1. const yAxisGenerator = d3.axisLeft()
  2. .scale(yScale)
  3. const yAxis = bounds.append("g")
  4. yAxisGenerator(yAxis)

可以使用 .call 来连接代码:

  1. const yAxis = bounds.append("g")
  2. .call(yAxisGenerator)

在绘制x坐标轴时,坐标轴的位置会被置顶,此时使用如下的代码去调整坐标轴的位置:

  1. const xAxis = bounds.append("g")
  2. .call(xAxisGenerator)
  3. .style("transform", `translateY(${
  4. dimensions.boundedHeight
  5. }px`)

finished–
55.png

Day 56:Making a Scatterplot - part 1

绘制图表的7个步骤:

  1. 读取数据
  2. 创建图表维度
  3. 绘制canvas(chart area & bounds element)
  4. 创建scale
  5. 绘制数据
  6. 绘制细节
  7. 设置交互

为了让图表可以自适应页面大小(匹配最小的),这里使用 d3.min 方法。d3.min 相较于原生的 Math.min 有如下的优点:

  1. d3.min 忽略 nulls/undefined。Math.min 会把它们计算为0。
  2. d3.min 忽略无法被转换成数值的 value。Math.min 会返回 NaN。
  3. d3.min 在我们需要使用 accessor 时,不用额外新增数组。
  4. d3.min 对空数值会返回 undefined。 Math.min 则返回 Infinity。
  5. d3.min 返回的数值是自然排序,方便处理 strings。Math.min 按照数值大小排序。

Day 57:Making a Scatterplot - part 2

粗略绘制散点的代码:

  1. dataset.forEach(d => {
  2. bounds
  3. .append("circle")
  4. .attr("cx", xScale(xAccessor(d)))
  5. .attr("cy", yScale(yAccessor(d)))
  6. .attr("r", 5)
  7. })

会有如下的问题:

  1. 代码嵌套,比较难阅读
  2. 跑代码两次,就会绘制两组散点。需要更新数据。

比较好的处理方式:

  1. const dots = bounds.selectAll("circle")
  2. .data(dataset)
  3. .append("circle")
  4. .attr("cx", d => xScale(xAccessor(d)))
  5. .attr("cy", d => yScale(yAccessor(d)))
  6. .attr("r", 5)
  7. .attr("fill", "cornflowerblue")

Day 58:Making a Scatterplot - part 3

绘制剩余细节。坐标轴的绘制,x、y轴的思路差不多:

  1. const xAxisGenerator = d3.axisBottom().scale(xScale)
  2. const xAxis = bounds.append("g")
  3. .call(xAxisGenerator)
  4. .style("transform", `translateY(${dimensions.boundedHeight}px)`)
  5. const xAxisLabel = xAxis.append("text")
  6. .attr("x", dimensions.boundedWidth / 2)
  7. .attr("y", dimensions.boundedHeight / 2)
  8. .attr("fill", "black")
  9. .style("font-size", "1.4em")
  10. .html("Dew point (&deg;F)")
  11. const yAxisGenerator = d3.axisLeft()
  12. .scale(yScale)
  13. .ticks(4)
  14. const yAxis = bounds.append("g")
  15. .call(yAxisGenerator)
  16. const yAxisLabel = yAxis.append("text")
  17. .attr("x", -dimensions.boundedHeight / 2)
  18. .attr("y", -dimensions.margin.left + 10)
  19. .attr("fill", "black")
  20. .style("font-size", "1.4em")
  21. .text("Relative humidity")
  22. .style("transform", "rotate(-90deg)")
  23. .style("text-anchor", "middle")

Day 59:Making a Scatterplot - part 4

在原有的图表基础上添加颜色 scale。首先是创建颜色的 accessor 与 颜色 scale:

  1. const colorAccessor = d => d.cloudCover
  2. const colorScale = d3.scaleLinear()
  3. .domain(d3.extent(dataset, colorAccessor))
  4. .range(["skyblue", "darkslategrey"])

然后在点的代码上稍微修改一下,把原本写死的颜色置换成 colorScale:

  1. const dots = bounds.selectAll("circle")
  2. .data(dataset)
  3. .enter().append("circle")
  4. .attr("cx", d => xScale(xAccessor(d)))
  5. .attr("cy", d => yScale(yAccessor(d)))
  6. .attr("r", 4)
  7. .attr("fill", d => colorScale(colorAccessor(d)))

59.png

Day 60:Making a Bar Chart - part 1

一样分成七个步骤:

  1. 获取数据
  2. 制造维度
  3. 绘制canvas
  4. 制造scale
  5. 绘制数据
  6. 绘制其余细节
  7. 设置交互
  1. async function drawBars() {
  2. const dataset = await d3.json("./../../my_weather_data.json")
  3. //console.log(dataset)
  4. const metricAccessor = d => d.humidity
  5. }
  6. drawBars()

60.png

Day 61:Making a Bar Chart - part 2

设置 wrapper 与 bounds,用来承载图表的绘制空间:

  1. const width = 600
  2. let dimensions = {
  3. width: width,
  4. height: width * 0.6,
  5. margin: {
  6. top: 30,
  7. right: 10,
  8. bottom: 50,
  9. left: 50,
  10. },
  11. }
  12. dimensions.boundedWidth = dimensions.width
  13. - dimensions.margin.left
  14. - dimensions.margin.right
  15. dimensions.boundedHeight = dimensions.height
  16. - dimensions.margin.top
  17. - dimensions.margin.bottom

Day 62:Making a Bar Chart - part 3

绘制 canvas。尝试自己写了一遍,发现还是会写错,对代码的思路还没完全熟悉。设置 wrapper 会用到 attr,而设置 bounds 会用到 style。为什么会有这个区别?

  1. const wrapper = d3.select("#wrapper")
  2. .append("svg")
  3. .attr("width", dimensions.width)
  4. .attr("height", dimensions.height)
  5. const bounds = wrapper.append("g")
  6. .style("transform", `translate(${
  7. dimensions.margin.left
  8. }px, ${
  9. dimensions.margin.top
  10. }px)`)

Day 63:Making a Bar Chart - part 4

创建 x 轴的 sacle:

  1. const xScale = d3.scaleLinear()
  2. .domain(d3.extent(dataset, metricAccessor))
  3. .range([0, dimensions.boundedWidth])
  4. .nice()

接下来应该是创建 y 轴的 scale,但作者指出,要创建 scale 之前,需要对数据有所理解,所以先展示不创建 scale。

Day 64:Making a Bar Chart - part 5

制作 historgram 的分箱:

  1. const binsGenerator = d3.histogram()
  2. .domain(xScale.domain())
  3. .value(metricAccessor)
  4. .thresholds(12)
  5. const bins = binsGenerator(dataset)

首先定义 x 轴的 scale,然后再使用 d3.hostogram 去绘制图表,一样是 domain 到 range 的映射,但这里没有使用 range 而是 value,需要特别注意。

Day 65:Making a Bar Chart - part 6

今天的任务是设置 y 轴的 scale。设置方式跟之前一样,不赘述。

  1. const yAccessor = d => d.length
  2. const yScale = d3.scaleLinear()
  3. .domain([0, d3.max(bins, yAccessor)])
  4. .range([dimensions.boundedHeight, 0])
  5. .nice()

Day 66:Making a Bar Chart - part 7

使用 来绘制长条图,这会需要用到四个变量: x、y、width, 与 height。另外, 每一个 bar 的宽度需要从 x0、x1的差分来决定。

  1. const binsGroup = bounds.append("g")
  2. const binGroups = binsGroup.selectAll("g")
  3. .data(bins)
  4. .enter().append("g")
  5. const barPadding = 1
  6. const barRects = binGroups.append("rect")
  7. .attr("x", d => xScale(d.x0) + barPadding / 2)
  8. .attr("y", d => yScale(yAccessor(d))
  9. .attr("width", d => d3.max([
  10. 0,
  11. xScale(d.x1) - xScale(d.x0) - barPadding
  12. ]))
  13. .attr("height", d => dimensions.boundedHeight
  14. - yScale(yAccessor(d))
  15. )
  16. .attr("fill", "cornflowerblue")

Day 67:Making a Bar Chart - part 8

为图表增添坐标轴的标签,最主要的问题是找到文字锚点的位置,需要透过简单的计算可以找到:

  1. const barText = binGroups.filter(yAccessor)
  2. .append("text")
  3. .attr("x", d => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
  4. .attr("y", d => yScale(yAccessor(d)) - 5)
  5. .text(yAccessor)
  6. .style("text-nchor", "middle")
  7. .attr("fill", "darkgrey")
  8. .style("font-size", "12px")
  9. .style("font-family", "sans-serif")

Day 68:Making a Bar chart - part 9

额外的绘制:增添平均线。利用 d3.mean 来计算平均数的位置:

  1. const meanLine = bounds.append("line")
  2. .attr("x1", xScale(mean))
  3. .attr("x2", xScale(mean))
  4. .attr("y1", -15)
  5. .attr("y2", dimensions.boundedHeight)
  6. .attr("stroke", "maroon")
  7. .attr("stroke-dasharray", "2px 4px")

x1 与 x2 会影响到线的绘制,若没有设置 x2,则绘制出来的效果是从 0 到 平均数位置的斜线。

另外,对这条线加上标签:

  1. const meanLabel = bounds.append("text")
  2. .attr("x", xScale(mean))
  3. .attr("y", -20)
  4. .text("mean")
  5. .attr("fill", "maroon")
  6. .style("font-size", "12px")
  7. .style("text-anchor", "middle")

特别注意需要设置 text-anchor,否则标签的位置不会居中。

68.png

绘制 x 轴:

  1. const xAxisGenerator = d3.axisBottom()
  2. .scale(xScale)
  3. const xAxis = bounds.append("g")
  4. .call(xAxisGenerator)
  5. .style("transform", `translateY(${dimensions.boundedHeight}px)`)
  6. const xAxisLabel = xAxis.append("text")
  7. .attr("x", dimensions.boundedWidth / 2)
  8. .attr("y", dimensions.margin.bottom - 10)
  9. .attr("fill", "black")
  10. .style("font-size", "1.4em")
  11. .text("humidity")

68-2.png

Day 69:Making a Bar chart - part 10

根据之前的基础,绘制分页形式的图表。先将之前绘制图表的代码包在 drawHistogram 里面,然后在代码最下面调用这个函数(否则会画不出来):

  1. async function drawBars() {
  2. const dataset
  3. const metricAccessor
  4. // 略
  5. const drawHistogram = metric => {
  6. // 略
  7. }
  8. const metrics // 用于绘制每一个分页的图表标题
  9. metrics.forEach(drawHistogram)
  10. }
  11. drawBars()

69.png

Day 70:Making a Bar chart - part 11

今天的任务是给图表增加易读性。

  1. wrapper.attr("role", "figure")
  2. .attr("tabindex", "0")
  3. .append("title")
  4. .text("Histogram looking at the distribution of humidity in 2016")

把之前删除的 bindGroup 加回来:

  1. const binsGroup = bounds.append("g")
  2. .attr("tabindex", "0")
  3. .attr("role", "list")
  4. .attr("aria-label", "histogram bars")

这一段代码让每一个柱条可以点击:

  1. const binGroups = binsGroup.selectAll("g")
  2. .data(bins)
  3. .enter().append("g")
  4. .attr("tabindex", "0")
  5. .attr("role", "listitem")
  6. .attr("aria-label", d => `There were ${
  7. yAccessor(d)
  8. } days between ${
  9. d.x0.toString().slice(0, 4)
  10. } and ${
  11. d.x1.toString().slice(0, 4)
  12. } humidity levels.`)

70.png

Day 71:Animations and Transitions - part 1

几种绘制动画的方法:

  • SVG ,浏览器不支援,只能定义静态的动画
  • CSS transition,此书用到的图表基本上都可以用CSS实现动画
  • d3.transition

先以CSS为例,在之前的 histogram 基础上加入 transition:

  1. .bin rect {
  2. fill: cornflowerblue;
  3. transition: height 1s ease-out,
  4. y 1s ease-out;
  5. }

此种动画较为符合用户心智,长条图的更新涉及到高度的变化。

Day 72:Animations and Transitions - part 2

对标签进行设置,在 y 轴方向上加入动画,让整体的动画过度更为自然:

  1. const barText = binGroups.select("text")
  2. .attr("x", d => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
  3. .attr("y", 0)
  4. .style("transform", d => `translateY(${
  5. yScale(yAccessor(d)) - 5
  6. }px)`)
  7. .text(d => yAccessor(d) || "")

另外,在平均数的线也可以继续动画设置:

  1. const meanLine = bounds.selectAll(".mean")
  2. .attr("y1", -20)
  3. .attr("y2", dimensions.boundedHeight)
  4. .style("transform", `translateX(${xScale(mean)}px)`)

并在 CSS 加上 transition: transform 1s ease-out 即可。

Day 73:Animations and Transitions - part 3

开始讲解 d3.transition。在以下的需求使用 d3.transition 而不是 css:

  1. 当需要将多个动画依序进行
  2. 当在动画结束后要做某个动作
  3. 当某个 property 的动画CSS不支持
  4. 当要同时与动画增加或删除元素
  5. 当要中断动画
  6. 当要自定义动画

Day 74:Animations and Transitions - part 4

使用 d3.transition,并 console 结果:

  1. const barRects = binGroups.select("rect")
  2. .transition()
  3. .attr("x", d => xScale(d.x0) + barPadding)
  4. .attr("y", d => yScale(yAccessor(d)))
  5. .attr("height", d => dimensions.boundedHeight - yScale(yAccessor(d)))
  6. .attr("width", d => d3.max([
  7. 0,
  8. xScale(d.x1) - xScale(d.x0) - barPadding
  9. ]))
  10. console.log(barRects)

比较奇怪的是,console出来的结果没有 transition,需要再去查明原因。

Day 75:Animations and Transitions - part 5

使用 style 来对图表的颜色进行设置,这样可以避免被 CSS 给覆盖;设置颜色用来观察 d3.transition 的特性:

  1. const barRects = binGroups.select("rect")
  2. .transition().duration(1200).ease(d3.easeBounceOut)
  3. .attr("x", d => xScale(d.x0) + barPadding)
  4. .attr("y", d => yScale(yAccessor(d)))
  5. .attr("height", d => dimensions.boundedHeight - yScale(yAccessor(d)))
  6. .attr("width", d => d3.max([
  7. 0,
  8. xScale(d.x1) - xScale(d.x0) - barPadding
  9. ]))
  10. .transition()
  11. .style('fill', 'cornflowerblue')

另外,对 text 设置 transition 以及 duration, 也可以看到 d3.transition 是如何对这些元素进行动画处理。

Day 76:Animations and Transitions - part 6

这一部份学得比较困惑,关于使用 exit 对动画进行更新后的处理,后续再找时间回来复习。

  1. const oldBinGroups = binGroups.exit()
  2. oldBinGroups.selectAll("rect")
  3. .style("fill", "red")
  4. .transition(exitTransition)
  5. .attr("y", dimensions.boundedHeight)
  6. .attr("height", 0)
  7. oldBinGroups.selectAll("text")
  8. .transition(exitTransition)
  9. .attr("y", dimensions.boundedHeight)
  10. oldBinGroups
  11. .transition(exitTransition)
  12. .remove()

Day 77:Animations and Transitions - part 7

对折线图进行动画设置。

  1. const xAxis = bounds.select(".x-axis")
  2. .transition().duration(1000)
  3. .call(xAxisGenerator)
  4. const line = bounds.select(".line")
  5. .transition().duration(1000)
  6. .attr("d", lineGenerator(dataset))

Day 78:Animations and Transitions - part 8

设置x轴的动画效果:

  1. const xAxis = bounds.select(".x-axis")
  2. .transition().duration(1000)
  3. .call(xAxisGenerator)

设置折线的动画效果:

  1. const line = bounds.select(".line")
  2. .transition().duration(1000)
  3. .attr("d", lineGenerator(dataset))

Day 79:Animations and Transitions - part 9

对折线图进行动画设置的最后一个部分,但仍有未解的bug,线消失了:

  1. bounds.append("rect")
  2. .attr("class", "freezing")
  3. const clip = bounds.append("g")
  4. .attr("clip-patn", "url(#bounds-clip-path)")
  5. clip.append("path")
  6. .attr("class", "line")
  7. bounds.append("path")
  8. .attr("class", "line")
  9. bounds.append("g")
  10. .attr("class", "x-axis")
  11. .style("transform", `translateY(${dimensions.boundedHeight}px)`)
  12. bounds.append("g")
  13. .attr("class", "y-axis")
  14. bounds.append("defs")
  15. .append("clipPath")
  16. .attr("id", "bounds-clip-path")
  17. .append("rect")
  18. .attr("width", dimensions.boundedWidth)
  19. .attr("height", dimensions.boundedHeight)
  20. bounds.append("rect")
  21. .attr("class", "freezing")

79.png

Day 80:Interactions

原生的事件监听器 addEventListener() 可以监控使用者的鼠标、键盘、滑动、触控、缩放等事件。

  1. function onClick(event) {
  2. // do something
  3. }
  4. addEventListener("click", onClick)

Day 81:Interactions - part 2

D3 的 .on() 方法可以生成事件监听器,以下面的代码为例:

  1. async function createEvent() {
  2. const recColors = [...]
  3. const rects = d3.select(...)...
  4. rects.on("mouseenter", function(datum, index, nodes) {
  5. console.log({datum, index, nodes})
  6. }
  7. }

在 async function 里面,定义了方块颜色与方块,给方块绑定 .on() 的方法,从 console.log 的结果可以看到,.on() 可以接受3种参数:数据、索引、被选择到的节点。

81.png

Day 82:Interactions - part 3

想要改变当前方块的颜色,得先创建一个selection。比较简单的方法:

  1. rects.on("mouseenter", function(datum, index, nodes){
  2. console.log(this)
  3. })

使用 this 可以选择到该物件。

  1. rects.on("mouseenter", function(datum, index, nodes){
  2. d3.select(this).style("fill", datum)
  3. })

82.png

Day 83:Interactions - part 4

修改代码让方块在鼠标移出后恢复原本的颜色。

  1. rects.on("mouseenter", function(datum, index, nodes){
  2. d3.select(this).style("fill", datum)
  3. })
  4. .on("mouseout", function(){
  5. d3.select(this).style("fill", "lightgrey")
  6. })

Day 84:Interactions - part 5

销毁事件监听器,可以避免内存泄露等问题。借由使用带有 null 的 .on() 就可以销毁事件监听器:

  1. // destroy our events after 3 seconds
  2. setTimeout(() => {
  3. rects
  4. .dispatch("mouseout")
  5. .on("mousemove", null)
  6. .on("mouseout", null)
  7. }, 3000)

Day 85:Interactions - part 6

以 bar chart 为例子,在图表上叠加鼠标交互。先用 css 来增加这个交互:

  1. .bin rect:hover {
  2. fill: purple;
  3. }

针对 tooltip,需要使用JS来设置,简要代码如下:

  1. binGroups.select("rect")
  2. .on("mouseenter", onMouseEnter)
  3. .on("mouseleave", onMouseLeave)
  4. function onMouseEnter(datum) {
  5. }
  6. function onMouseLeave(datum) {
  7. }

Day 86:Interactions - part 7

对tooltip的样式进行设置:

  1. .tooltip {
  2. /*opacity: 0;*/
  3. position: absolute;
  4. top: -12px;
  5. left: 0;
  6. padding: 0.6em 1em;
  7. background: #fff;
  8. text-align: center;
  9. border: 1px solid #ddd;
  10. z-index: 10;
  11. transition: all 0.2s ease-out;
  12. pointer-events: none;
  13. }

Day 87:Interactions - part 8

让tooltip可以跟着鼠标的hover去更新数据,但目前的位置还是固定的。

  1. binGroups.select("rect")
  2. .on("mouseenter", onMouseEnter)
  3. .on("mouseleave", onMouseLeave)
  4. const tooltip = d3.select("#tooltip")
  5. function onMouseEnter(datum) {
  6. tooltip.select("#count")
  7. .text(yAccessor(datum))
  8. }

87.png

Day 88:Interactions - part 9

让tooltip同时展示range与count这两个数据字段。目前展示的数据过于精确(小数点后的数值全部展示出来),此时可以用 d3.format 来解决这个问题。

  1. tooltip.select("#range")
  2. .text([
  3. formatHumidity(datum.x0),
  4. formatHumidity(datum.x1)
  5. ].join(" - "))

Day 89:Interactions - part 10

改变tooltip的位置,在每次鼠标hover的时候需要出现在该柱图上。

  1. const x = xScale(datum.x0)
  2. + (xScale(datum.x1) - xScale(datum.x0)) / 2
  3. + dimensions.margin.left
  4. const y = yScale(yAccessor(datum))
  5. + dimensions.margin.top
  6. tooltip.style("transform", `translate(`
  7. + `${x}px,`
  8. + `${y}px`
  9. + `)`)

但在展示上仍需要继续调整。

Day 90:Interactions - part 11

基于tooltip自己的高度与宽度来调整其位置,所以需要使用transform: translate()。原本就是使用这个方法,但还需要借助 css 的 calc 方法:

  1. tooltip.style("transform", `translate(`
  2. + `calc( -50% + ${x}px),`
  3. + `calc( -100% + ${y}px)`
  4. + `)`)

可以前后对比一下两个方式的差异。

90.png

Day 91:Interactions - part 12

这次要针对散点图进行tooltip弹出的设置,一样是使用 selectAll 选择需要有交互的元素,即 circle,然后加上鼠标滑动的function,再分别定义这些function:

  1. bounds.selectAll("circle")
  2. .on("mouseenter", onMouseEnter)
  3. .on("mouseleave", onMouseLeave)
  4. const tooltip = d3.select("#tooltip")
  5. function onMouseEnter(datum, index) {
  6. }
  7. function onMouseLeave() {
  8. }

Day 92:Interactions - part 13

要在 tooltip 上展示两个数值:x 轴的 dew point,以及 y 轴的 humidity。先设置 onMouseEnter()的内容:

  1. function onMouseEnter(datum, index) {
  2. const formatHumidity = d3.format(".2f")
  3. tooltip.select("#humidity")
  4. .text(formatHumidity(yAccessor(datum)))
  5. const formatDewPoint = d3.format(".2f")
  6. tooltip.select("#dew-point")
  7. .text(formatDewPoint(xAccessor(datum)))
  8. }

Day 93:Interactions - part 14

再额外添加时间的展示,但目前数据是以srting来展示时间字段,对理解上不是特别友好,故需要继续特别处理。

先使用 d3.timeParse 来转换string成时间字段,再使用 d3.timeFormat 来处理展示的样式,最后嵌入tooltip里面:

  1. const dateParser = d3.timeParse("%Y-%m-%d")
  2. const formatDate = d3.timeFormat("%B %A %-d, %Y")
  3. console.log(dateParser(datum.date))
  4. // Tue Oct 02 2018 00:00:00 GMT+0800
  5. console.log(formatDate(dateParser(datum.date)))
  6. // October Tuesday 2, 2018
  7. tooltip.select("#date")
  8. .text(formatDate(dateParser(datum.date)))

然后继续再同样的function里面设置tooltip的样式:

  1. tooltip.style("transform", `translate(`
  2. + `calc( -50% + ${x}px),`
  3. + `calc( -100% + ${y}px)`
  4. + `)`)

最后在 onMouseLeave 里面设置tooltip在鼠标移开后消失:

  1. function onMouseLeave() {
  2. tooltip.style("opacity", 0)
  3. }

93.png

Day 94:Interactions - part 15

目前在hover散点图时,需要在每一个点的正上方才能弹出tooltip,这在交互上不太友好。此时可以借助 Voronoi 算法来改进这个问题。借用 delaunay 方法,嵌入在绘制数据的代码模块:

  1. const drawDots = (dataset) => {
  2. const delaunay = d3.Delaunay.from(
  3. dataset,
  4. d => xScale(xAccessor(d)),
  5. d => yScale(yAccessor(d))
  6. )
  7. const voronoi = delaunay.voronoi()
  8. bounds.selectAll(".voronoi")
  9. .data(dataset)
  10. .enter().append("path")
  11. .attr("class", "voronoi")
  12. .attr("d", (d,i) => voronoi.renderCell(i))
  13. .attr("stroke", "salmon")
  14. }
  15. drawDots(dataset)

94.png

Day 95:Interactions - part 16

昨天绘制的voronoi图有点问题,因为没有设置宽高,导致绘制出来的图形不完整,分别对xman与ymax进行设置即可解决这个问题:

  1. const voronoi = delaunay.voronoi()
  2. voronoi.xmax = dimensions.boundedWidth
  3. voronoi.ymax = dimensions.boundedHeight

95.png

Day 96:Interactions - part 17

在鼠标hover散点时,要改变散点的颜色,所以使用如下的代码:

  1. const dayDot = bounds.append("circle")
  2. .filter(d => d == datum)
  3. .style("fill", "maroon")

但会有个问题,这个效果显示的散点会被遮挡,鼠标交互是无法改变散点的排序。需要重新写别的代码来解决这个问题。

Day 97:Interactions - part 18

要解决这个问题,可以重新绘制散点:

  1. function onMouseEnter(datum, index) {
  2. const dayDot = bounds.append("circle")
  3. .attr("class", "tooltipDot")
  4. .attr("cx", xScale(xAccessor(datum)))
  5. .attr("cy", yScale(yAccessor(datum)))
  6. .attr("r", 7)
  7. .style("fill", "maroon")
  8. .style("point-event", "none")
  9. }
  10. function onMouseLeave() {
  11. d3.selectAll(".tooltipDot").remove()
  12. }

使用这段代码可以解决原本遇到的问题,但我遇到不知名的bug,无法进行此交互。

Day 98:Interactions - part 19

今天换最后一个可视化图表示例来进行交互绘制:折线图。先设置交互:

  1. const listeningRect = bounds.append("rect")
  2. .attr("class", "listening-rect")
  3. .attr("width", dimensions.boundedWidth)
  4. .attr("height", dimensions.boundedHeight)
  5. .on("mousemove", onMouseMove)
  6. .on("mouseleave", onMouseLeave)

此时 listeningRect 会让图表区域变成黑色:

98.png

Day 99:Interactions - part 20

针对方块是黑色的问题,对CSS进行调整即可:

  1. .listening-rect {
  2. fill:transparent;
  3. }

接着,设置交互的相关function,跟前面的例子一样:

  1. const tooltip = d3.select("#tooltip")
  2. function onMouseMove(){
  3. }
  4. function onMouseLeave(){
  5. }

这里需要思考当鼠标hover的时候,我们如何知道具体的位置?前面的例子使用了datum、index、nodes在此显然不适用。使用 this 方法也只能返回rect元素。

Day 100:Interactions - part 21

要显示tooltip,需要需要知道hover在哪一个日期,转换x的位置数据到日期。要将range映射到domain,可以使用 intert()方法。

  1. function onMouseMove(){
  2. const mousePosition = d3.mouse(this)
  3. console.log(mousePosition)
  4. const hoverDate = xScale.intert(mousePosition[0])
  5. }

这样就可以知道是hover在哪一个日期上,接下来需要去知道最靠近的数据点是哪一个。

Day 101:Interactions - part 22

使用 d3.scan 可以帮助找到一个变量是否匹配到筛选过的清单。

d3.scan 包含了两个参数:1.array;2. comparator function (可选)

分成下面三个步骤:1.使用 Math.abs() 来转换距离为绝对距离;2.获取最靠近hover的date的index;3.获取index的数据点

  1. function onMouseMove(){
  2. // use Math.abs() to convert that distance to an absolute distance
  3. const getDistanceFromHoveredDate = d => Math.abs(
  4. xAccessor(d) - hoveredDate)
  5. // get the index of the closest data point to our hovered date
  6. const closestIndex = d3.scan(dataset, (a,b) => (
  7. getDistanceFromHoveredDate(a) - getDistanceFromHoveredDate(b)
  8. ))
  9. // grab the data point at that index
  10. const closestDataPoint = dataset[closestIndex]
  11. console.table(closestDataPoint)
  12. }

Day 102:Interactions - part 23

对tooltip进行数据与样式的设置:

  1. function onMouseMove(){
  2. // grab the data point at that index
  3. const closestDataPoint = dataset[closestIndex]
  4. console.table(closestDataPoint)
  5. const closestXValue = xAccessor(closestDataPoint)
  6. const closestYValue = yAccessor(closestDataPoint)
  7. const formatDate = d3.timeFormat("%B %A %-d, %Y")
  8. tooltip.select("#date")
  9. .text(formatDate(closestXValue))
  10. const x = xScale(closestXValue) + dimensions.margin.left
  11. const y = yScale(closestYValue) + dimensions.margin.top
  12. tooltip.style("transform", `translate(`
  13. + `calc( -50% + ${x}px),`
  14. + `calc ( -100% + ${y}px)`
  15. + `)`)
  16. tooltip.style("opacity", 1)
  17. }
  18. function onMouseLeave(){
  19. tooltip.style("opacity", 0)
  20. }

但目前遇到一个bug,tooltip不会跟随鼠标移动,还需要去找出具体的问题。

Day 103:Interactions - part 24

本章节最后一个教程。目前hover在折线图上时,无法得知目前hover的位置,此教程将会在折线图上加入一个圆点,来辅助图表理解。

  1. const tooltipCircle = bounds.append("circle")
  2. .attr("r", 4)
  3. .attr("stroke", "#af9358")
  4. .attr("fill", "white")
  5. .attr("stroke-width", 2)
  6. .style("opacity", 0)
  7. function onMouseMove(){
  8. tooltipCircle
  9. .attr("cx", xScale(closestXValue))
  10. .attr("cy", yScale(closestYValue))
  11. .style("opacity", 1)
  12. }
  13. function onMouseLeave(){
  14. tooltipCircle.style("opacity", 0)
  15. }

104.png

Day 104:Making a map - part 1

绘制choropleth map,并使用 d3-geo模块。为了绘制地图,需要下载shapefile并且转换成GeoJSON,这个转换需要使用gdal。下载gdal的时间比较久,约数十分钟。下载好后,在终端使用下面的代码来进行转换:

  1. ogr2ogr -f GeoJSON ./world-geojson2.json ./ne_50m_admin_0_countries.shp

Day 105:Making a map - part 2

读取世界地图的json档案,并用console.log来观看数据的维度有哪一些:

  1. const countryShapes = await d3.json("./../world-geojson.json")
  2. console.log(countryShapes)

有四个维度:crs、features、name、type。针对features继续深入,可以看到我们感兴趣的数据,例如经纬度、国家信息。

接下来,要创建accessor function,用来获取国家ID(进而获取人口成长数据集的指标)。另外,在hover国家时也希望展示国家的名称。

  1. const countryNameAccessor = d => d.properties["NAME"]
  2. const countryIdAccessor = d => d.properties["ADM0_A3_IS"]

Day 106:Making a map - part 3

读取国家人口数据,因为数据包含在这个可视化用不到的维度,所以定义一个metric来只使用这个我们感兴趣的维度:

  1. const dataset = await d3.csv("./../data_bank_data.csv")
  2. const metric = "Population growth (annual %)"

为了以一种比较简便的方式来关联国家id与数值,所以制造一个object来完成这个目的,并对数据进行处理:

  1. let metricDataByCountry = {}
  2. dataset.forEach(d => {
  3. if(d["Series Name"] != metric) return
  4. metricDataByCountry[d["Country Code"]] = +d["2017 [YR2017]"] || 0
  5. })

遍历一遍所有的数据维度,若Series Name等于metric,则对metricDataByCountry新增一个数值。

106.png

Day 107:Making a map - part 4

今天的任务是制造图表的维度。

  1. let dimensions = {
  2. width: window.innerWidth * 0.9,
  3. margin:{
  4. top:10,
  5. right:10,
  6. bottom:10,
  7. left:10,
  8. },
  9. }
  10. dimensions.boundedWidth = dimensions.width
  11. - dimensions.margin.left
  12. - dimensions.margin.right

Day 108:Making a map - part 5

关于映射方式。要绘制平面地图,一定会需要用到映射,从3D向2D投射的方式有好几种,最常见的是墨卡托投影,此投影方式能够较好地呈现国家形状,但在维度较高的地图则会有过度放大面积的问题。

Day 109:Making a map - part 6

完成图表维度的制作。使用 .fitWidth() 方法,可以根据GeoJSON的数值来更新projection的大小。

  1. const sphere = ({type:"Sphere"})
  2. const projection = d3.geoEqualEarth()
  3. .fitWidth(dimensions.boundedWidth, sphere)
  4. const pathGenerator = d3.geoPath(projection)
  5. const [[x0, y0], [x1, y1]] = pathGenerator.bounds(sphere)
  6. dimensions.boundedHeight = y1
  7. dimensions.height = dimensions.boundedHeight
  8. + dimensions.margin.top
  9. + dimensions.margin.bottom

Day 110:Making a map - part 7

绘制画布。

  1. const wrapper = d3.select("#wrapper")
  2. .append("svg")
  3. .attr("width", dimensions.width)
  4. .attr("height", dimensions.height)
  5. const bounds = wrapper.append("g")
  6. .style("transform", `translate(${
  7. dimensions.margin.left
  8. }px, ${
  9. dimensions.margin.top
  10. }px)`)

Day 111:Making a map - part 8

生成scale,把人口数值转换成颜色。

  1. const metricValues = Object.values(metricDataByCountry)
  2. const maxChange = d3.max([-metricValueExtent[0], metricValueExtent[1]])
  3. const colorScale = d3.scaleLinear()
  4. .domain([-maxChange, 0, maxChange])
  5. .range(["indigo", "white", "darkgreen"])

Day 112:Making a map - part 9

绘制数据——先绘制地球外形。

  1. const earth = bounds.append("path")
  2. .attr("class", "earth")
  3. .attr("d", pathGenerator(sphere))

Day 113:Making a map - part 10

绘制数据——经纬度。

  1. const graticuleJson = d3.geoGraticule10()
  2. const graticule = bounds.append("path")
  3. .attr("class", "graticule")
  4. .attr("d", pathGenerator(graticuleJson))

113.png

Day 114:Making a map - part 11

绘制数据——国家。

  1. const cuntries = bounds.selectAll(".country")
  2. .data(countryShapes.features)
  3. .enter().append("path")
  4. .attr("class", "country")
  5. .attr("d", pathGenerator)
  6. .attr("fill", d=> {
  7. const metricValue = metricDataByCountry[countryIdAccessor(d)]
  8. if (typeof metricValue == "undefined") return "#e2e6e9"
  9. return colorScale(metricValue)
  10. })

114.png

Day 115:Making a map - part 12

绘制剩余部分——图例的标题。

  1. const legendGroup = wrapper.append("g")
  2. .attr("transform", `translate(${
  3. 120
  4. },${
  5. dimensions.width < 800
  6. ? dimensions.boundedHeight - 30
  7. : dimensions.boundedHeight * 0.5
  8. })`)
  9. const legendTitle = legendGroup.append("text")
  10. .attr("y", -23)
  11. .attr("class", "legend-title")
  12. .text("Population growth")

Day 116:Making a map - part 13

绘制剩余部分——图例的副标题。

  1. const legendByline = legendGroup.append("text")
  2. .attr("y", -9)
  3. .attr("class", "legend-byline")
  4. .text("Percent change in 2017")

Day 117:Making a map - part 14

绘制剩余部分——图例的主图。

  1. const defs = wrapper.append("defs") // for storing the gradient
  2. const legendGradiendId = "legend-gradient" // define a variable to hold the id of the gradient
  3. const gradient = defs.append("linearGradient")
  4. .attr("id", legendGradiendId)
  5. .selectAll("stop")
  6. .data(colorScale.range())
  7. .enter().append("stop")
  8. .attr("stop-color", d => d)
  9. .attr("offset", (d, i) => `${
  10. i * 100 / 2
  11. }%`)
  12. const legendWidth = 120
  13. const legendHeight = 16
  14. const legendGradient = legendGroup.append("rect")
  15. .attr("x", -legendWidth / 20)
  16. .attr("height", legendHeight)
  17. .attr("width", legendWidth)
  18. .style("fill", `url(#${legendGradiendId})`)

117.png

Day 118:Making a map - part 15

在图例上面标注最大、最小值。目前为止没有很好地对齐,还需要继续调整。

  1. const legendValueRight = legendGroup.append("text")
  2. .attr("class", "legend-value")
  3. .attr("x", legendWidth / 2 + 10)
  4. .attr("y", legendHeight / 2)
  5. .text(`${d3.format(".1f")(maxChange)}%`)
  6. const legendValueLeft = legendGroup.append("text")
  7. .attr("class", "legend-value")
  8. .attr("x", -legendWidth / 2 + 10)
  9. .attr("y", legendHeight / 2)
  10. .text(`${d3.format(".1f")(-maxChange)}%`)
  11. .style("text-anchor", "end")

118.png

Day 119:Making a map - part 16

利用浏览器的功能,在地图上标示出自己的位置。

  1. navigator.geolocation.getCurrentPosition(myPosition => {
  2. const [x, y] = projection([
  3. myPosition.coords.longitude,
  4. myPosition.coords.latitude
  5. ])
  6. const myLocation = bounds.append("circle")
  7. .attr("class", "my-location")
  8. .attr("cx", x)
  9. .attr("cy", y)
  10. .attr("r", 0)
  11. .transition().duration(500)
  12. .attr("r", 10)
  13. })

119.png

Day 120:Making a map - part 17

加入交互,展示提示框。

  1. countries.on("mouseenter", onMouseEnter)
  2. .on("mouseleave", onMouseLeave)
  3. const tooltip = d3.select("#tooltip")
  4. function onMouseEnter(datum){
  5. tooltip.style("opacity", 1)
  6. }
  7. function onMouseLeave(datum){
  8. tooltip.style("opacity", 0)
  9. }

但细节还需要调整。

Day 121:Making a map - part 18

修改tooltip——让鼠标交互时可以随着更改国家名称与数值。

  1. function onMouseEnter(datum){
  2. tooltip.style("opacity", 1)
  3. const metricValue = metricDataByCountry[countryIdAccessor(datum)]
  4. tooltip.select("#country")
  5. .text(countryNameAccessor(datum))
  6. tooltip.select("#value")
  7. .text(`${d3.format(",.2f")(metricValue || 0)}%`)
  8. }

Day 122:Making a map - part 19

调整tooltip,让它可以随着鼠标移动。

  1. const [centerX, centerY] = pathGenerator.centroid(datum)
  2. const x = centerX + dimensions.margin.left
  3. const y = centerY + dimensions.margin.top
  4. tooltip.style("transform", `translate(`
  5. + `calc( -50% + ${x}px),`
  6. + `calc( -100% + ${y}px)`
  7. + `)`)

120.png

Day 123:Making a map - part 20

地图绘制在这告一段落,简要复习绘制地图的步骤:
1、读取世界地图的json档案、国家人口数据
2、绘制图表的基础维度(宽度、边距、地图映射方式)
3、绘制空白画布、用scale转换数据
4、绘制数据(地球外形、经纬度、国家)
5、绘制剩余部分(图例、提示框)