This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,504 @@
<audio id="audio" title="31 | 针对海量数据,如何优化性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/16/4de5826f092f7782458cfdd30e646616.mp3"></audio>
你好,我是月影。
前两节课我们一起学习了Canvas2D和WebGL性能优化的一些基本原则和处理方法。在正确运用这些方法后我们能让渲染性能达到较高的程度满足我们项目的需要。
不过,在数据量特别多的时候,我们会遇到些特殊的渲染需求,比如,要在一个地图上标记非常多的地理位置点(数千到数万),或者在地图上同时需要渲染几万条黑客攻击和防御数据。这些需求可能超过了常规优化手段所能达到的层次,需要我们针对数据和渲染的特点进行性能优化。
今天我通过渲染动态地理位置的例子来和你说说如何对特殊渲染需求迭代优化。不过我今天用到特殊优化手段只是一种具体的方法和手段你可以借鉴他去理解思路但千万不要陷入到思维定式中。因为解决这些特殊渲染需求并没有固定的路径或方法它是一个需要迭代优化的过程需要我们对WebGL的渲染机制非常了解并深入思考才能创造出最适合的方法来。在我们实际的工作里还有许多其他的方法可以使用你一定要根据自己的实际情况随机应变。
## 渲染动态的地理位置
我先来看今天要实现的例子。在地图可视化应用中,渲染地理位置信息是一类常见的需求,例如在这张地图上,我们就用许多不同颜色的小圆点标注出了美国一些不同的地区。
<img src="https://static001.geekbang.org/resource/image/ca/4a/caebcea9dd803d01fa4188faf98f9f4a.jpeg" alt="">
如果我们要实现这些静态的标准点方法其实很简单用Canvas2D或者WebGL都可以轻松实现。就算点数量比较多也没关系因为一次性渲染对性能影响也不会很大。不过如果我们想让圆点运动起来比如做出一种闪烁或者呼吸灯的效果那我们就要考虑点的数量对性能的影响了。
那面对这一类特殊的渲染的需求,我们该怎么办呢?下面,我们先用常规的做法来实现,然后在这个方法上不断迭代优化。为了方便你理解,我就不绘制地图了,只绘制这些随机的小圆点。
最简单的做法当然是一个一个圆绘制上去,也就是先创建圆的几何顶点数据,然后对每个圆设置不同的参数来分别绘制。实现代码如下:
```
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
uniform vec2 xy;
uniform float uTime;
uniform float bias;
void main() {
vec3 pos = vec3(a_vertexPosition, 1);
float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
mat3 m = mat3(
scale, 0, 0,
0, scale, 0,
xy, 1
);
gl_Position = vec4(m * pos, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
function circle(radius = 0.05) {
const delta = 2 * Math.PI / 32;
const positions = [];
const cells = [];
for(let i = 0; i &lt; 32; i++) {
const angle = i * delta;
positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; 31) {
cells.push([0, i, i + 1]);
}
}
return {positions, cells};
}
const COUNT = 500;
function init() {
const meshData = [];
const {positions, cells} = circle();
for(let i = 0; i &lt; COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
const uniforms = {};
uniforms.u_color = [
Math.random(),
Math.random(),
Math.random(),
1];
uniforms.xy = [
2 * Math.random() - 1,
2 * Math.random() - 1,
];
uniforms.bias = Math.random();
meshData.push({
positions,
cells,
uniforms,
});
}
renderer.uniforms.uTime = 0;
renderer.setMeshData(meshData);
}
init();
function update(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(update);
}
update(0);
```
上面的代码非常简单关键思路就是用circle生成顶点信息然后对每个需要绘制的圆应用circle顶点信息并设置不同的unifom参数最后在shader中根据参数进行绘制就可以了。
不过如果我们这么做的话整体的性能就会非常低比如在绘制500个圆的时候浏览器的帧率就掉到十几fps了。那我们该怎么优化呢
<img src="https://static001.geekbang.org/resource/image/83/78/83f68759c4913bea230fc52d08ac9578.jpeg" alt="">
## 优化大数据渲染的常见方法
我们通过前面的学习已经知道渲染次数和每次渲染的顶点计算次数是影响渲染性能的要素,所以优化大数据渲染的思路方向自然就是减少渲染次数和减少几何体顶点数了。
### 1. 使用批量渲染优化
在学完Canvas和WebGL的性能优化之后我们知道在绘制大量同种几何图形的时候通过减少渲染次数来提升性能最好的做法是直接使用批量渲染。针对今天的例子也是一样我们稍微修改一下上面的代码用实例渲染来代替逐个渲染代码如下
```
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec4 color;
attribute vec2 xy;
attribute float bias;
uniform float uTime;
varying vec4 vColor;
void main() {
vec3 pos = vec3(a_vertexPosition, 1);
float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
mat3 m = mat3(
scale, 0, 0,
0, scale, 0,
xy, 1
);
vColor = color;
gl_Position = vec4(m * pos, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
varying vec4 vColor;
void main() {
gl_FragColor = vColor;
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
function circle(radius = 0.05) {
const delta = 2 * Math.PI / 32;
const positions = [];
const cells = [];
for(let i = 0; i &lt; 32; i++) {
const angle = i * delta;
positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; 31) {
cells.push([0, i, i + 1]);
}
}
return {positions, cells};
}
const COUNT = 200000;
function init() {
const {positions, cells} = circle();
const colors = [];
const pos = [];
const bias = [];
for(let i = 0; i &lt; COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
colors.push([
Math.random(),
Math.random(),
Math.random(),
1
]);
pos.push([
2 * Math.random() - 1,
2 * Math.random() - 1
]);
bias.push(
Math.random()
);
}
renderer.uniforms.uTime = 0;
renderer.setMeshData({
positions,
cells,
instanceCount: COUNT,
attributes: {
color: {data: [...colors], divisor: 1},
xy: {data: [...pos], divisor: 1},
bias: {data: [...bias], divisor: 1},
},
});
}
init();
function update(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(update);
}
update(0);
```
你可以比较一下上面的代码和前一个例子代码的差异这里我们使用实例渲染将之前的uniform变量替换成attribute变量其他的逻辑几乎不变。我们这么做了之后即使渲染100000个点浏览器的帧率也能达到30fps以上性能提升了超过2000倍
<img src="https://static001.geekbang.org/resource/image/10/81/10cc6bc21a8d9d89e06760ddf4118381.jpeg" alt="">
之所以批量渲染的性能比逐个渲染要高得多是因为我们通过减少绘制次数大大减少了JavaScript与WebGL底层交互的时间。不过使用批量渲染绘制20000个点就达到我们的性能极限了吗显然没有我们还可以运用其他的优化手段。
### 2. 使用点图元优化
绘制规则的图形我们还可以使用点图元。还记得吗我们说过WebGL的基本图元包括点、线、三角形等等。前面我们绘制圆的时候都是用circle函数生成三角网格然后通过三角形绘制的。这样我们绘制一个圆需要许多顶点。但实际上这种简单的图形我们还可以直接采用点图元。
**点图元造型**
在WebGL中点图元是最简单的图元它用来显示画布上的点。在顶点着色器里我们可以设置gl_PointSize来改变点图元的大小所以我们就可以用点图元来表示一个矩形。我们看下面这个例子。
```
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
void main() {
gl_PointSize = 0.2 * uResolution.x;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
void main() {
gl_FragColor = vec4(0, 0, 1, 1);
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
mode: renderer.gl.POINTS,
positions: [[0, 0]],
});
renderer.render();
```
如上面代码所示我们将meshData的mode设为gl.POINTS只绘制一个点0, 0。在顶点着色器中我们通过gl_PointSize来设置顶点的大小。由于gl_PointSize的单位是像素所以我们需要传一个画布宽高uResolution进去然后将gl_Position设为0.2 * uResolution这就让这个点的大小设为画布的20%,最终在画布上就呈现出一个蓝色矩形。
<img src="https://static001.geekbang.org/resource/image/47/fb/47b1af96385ce25571f0892b1474c6fb.jpeg" alt="">
注意,这里你可以回顾一下之前我们采用的常规的方法绘制的矩形,我们是将矩形剖分为两个三角形,然后用填充三角形来绘制的。而这里,我们用点图元,好处是我们只需要一个顶点就可以绘制,而不需要用四个顶点、两个三角形来填充。
现在我们通过点图元改变gl_PointSize绘制出了矩形那怎么才能绘制出其他图形呢实际上答案在我们前面学过的课程里——使用距离场和造型函数。
在上面例子的基础上,我们修改一下顶点着色器和片元着色器。具体代码如下:
首先是顶点着色器代码。
```
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
varying vec2 vResolution;
varying vec2 vPos;
void main() {
gl_PointSize = 0.2 * uResolution.x;
vResolution = uResolution;
vPos = a_vertexPosition;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
```
然后是片元着色器代码。
```
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vResolution;
varying vec2 vPos;
void main() {
vec2 st = gl_FragCoord.xy / vResolution;
st = 2.0 * st - 1.0;
float d = distance(st, vPos);
d = 1.0 - smoothstep(0.195, 0.2, d);
gl_FragColor = d * vec4(0, 0, 1, 1);
}
```
经过前面课程的学习你应该对造型函数的实现原理比较熟悉了这里我们就是通过计算到圆心的距离得出距离场然后通过smoothstep将一定距离内的图形绘制出来这样就得到一个蓝色的圆。
<img src="https://static001.geekbang.org/resource/image/62/35/6220f0e98ae2cfc95189df136d59bb35.jpeg" alt="">
用这样的思路呢,我们就可以得到新的绘制大量圆的方法了。这种思路实现圆的代码如下:
```
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec4 color;
attribute float bias;
uniform float uTime;
uniform vec2 uResolution;
varying vec4 vColor;
varying vec2 vPos;
varying vec2 vResolution;
varying float vScale;
void main() {
float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
gl_PointSize = 0.05 * uResolution.x * scale;
vColor = color;
vPos = a_vertexPosition;
vResolution = uResolution;
vScale = scale;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
varying vec4 vColor;
varying vec2 vPos;
varying vec2 vResolution;
varying float vScale;
void main() {
vec2 st = gl_FragCoord.xy / vResolution;
st = 2.0 * st - vec2(1);
float d = step(distance(vPos, st), 0.05 * vScale);
gl_FragColor = d * vColor;
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
const COUNT = 200000;
function init() {
const colors = [];
const pos = [];
const bias = [];
for(let i = 0; i &lt; COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
colors.push([
Math.random(),
Math.random(),
Math.random(),
1
]);
pos.push([
2 * Math.random() - 1,
2 * Math.random() - 1
]);
bias.push(
Math.random()
);
}
renderer.uniforms.uTime = 0;
renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
mode: renderer.gl.POINTS,
enableBlend: true,
positions: pos,
attributes: {
color: {data: [...colors]},
bias: {data: [...bias]},
},
});
}
init();
function update(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(update);
}
update(0);
```
可以看到我们没有采用前面那样通过circle函数来生成圆的顶点数据而是直接使用gl.POINTS来绘制并在着色器中用距离场和造型函数来画圆。这么做之后我们大大减少了顶点的运算原先我们每绘制一个圆需要32个顶点、30个三角形而现在用一个点就解决了问题。这样一来就算我们要渲染200000个点帧率也可以保持在50fps以上性能又提升了超过一倍
<img src="https://static001.geekbang.org/resource/image/9f/a7/9fa5964587dbb8edf192d3de95c938a7.jpeg" alt="">
## 其他方法
这里举上面的这个例子主要是想说明一个问题即使是使用WebGL不同的渲染方式性能的差别也会很大甚至会达到数千倍的差别。因此在可视化业务中我们一定要学会**根据不同的应用场景来有针对性地进行优化**。说起来简单要做到这一点并不容易你需要对WebGL本身非常熟悉而且对于GPU的使用、渲染管线等基本原理有着比较深刻的理解。这不是一朝一夕可以做到的需要持续不断地学习和积累。
就像有些同学使用绘图库ThreeJS或者SpriteJS来绘图的时候做出来的应用性能很差就会怀疑是图形库本身的问题。实际上这些问题很可能不是库本身的问题而是我们使用方法上的问题。换句话说是我们使用的绘图方式并不是最适用于当前的业务场景。而ThreeJS、SpriteJS这些通用的绘图库也并不会自己针对特定场景来优化。
因此单纯使用图形库我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因我没有把这门课程的重点放在库的API的使用上而是深入到图形渲染的底层原理。只有掌握了这些你才能真正学会如何驾驭图形库做出高性能的可视化解决方案来。
针对场景的性能优化方法其实非常多,我刚才讲的也只是几种典型的情况。为了帮助你在实战中慢慢领悟,我再举几个例子。不过我要提前说一下,我不会具体去讲这些例子的代码,只会重点强调常用的思路。学会这些方法之后,你再在实践中慢慢应用和体会就会容易很多了。
**1. 使用后期处理通道优化**
我们已经学习过使用后期处理通道的基本方法。实际上后期处理通道十分强大它最重要的特性就是可以把各种数据存储在纹理图片中。这样在迭代处理的时候我们就可以用GPU将这些数据并行地读取和处理从而达到非常高效地渲染。
这里是一个OGL官网上[例子](https://oframe.github.io/ogl/examples/?src=post-fluid-distortion.html)它就是用后期处理通道实现了粒子流的效果。这样的效果在其他图形系统中或者WebGL不使用后期处理通道是不可能做到的。
<img src="https://static001.geekbang.org/resource/image/88/2d/886d5b36c0ab94294yy378ab67f0ea2d.gif" alt="">
这里的具体实现比较复杂但其中最关键的一点是我们要将每个像素点的速度值保存到纹理图片中然后利用GPU并行计算的能力对每个像素点同时进行处理。
**2. 使用GPGPU优化**
还有一种优化思路和后期处理通道很像刚好OGL官网也有这个例子它使用了一种叫做GPGPU的方式也叫做通用GPU方式就是把每个粒子的速度保存到纹理图片里实现同时渲染几万个粒子并产生运动的效果。
<img src="https://static001.geekbang.org/resource/image/07/d6/07ed4788452a2e214c61fb634fcdd8d6.gif" alt="">
**3. 使用服务端渲染优化**
最后一种优化思路,是从我之前做过的一个可视化项目中提取出来的。当时,我需要渲染数十万条历史数据的记录,如果单纯在前端渲染,性能会成为瓶颈。但由于这些数据都是历史数据,因此针对这个场景我们可以在服务端进行渲染,然后直接将渲染后的图片输出给前端。
要使用服务端渲染,我们可以使用 [Node-canvas-webgl](https://github.com/akira-cn/node-canvas-webgl) 这个库它可以在Node.js中启动一个Canvas2D和WebGL环境这样我们就可以在服务端进行渲染然后再将结果缓存起来直接提供给客户端。
## 要点总结
这节课我们主要讲了针对业务的不同应用场景进行性能优化的思路和方法。尤其是在海量数据的情况下,特定优化手段显得十分重要,甚至有可能产生上千倍的性能差距。
结合今天的例子对于要绘制大量圆形的场景我们用常规的处理方法渲染500个元素都比较吃力一旦我们使用了批量渲染就可以把性能一下子提升两千倍以上能够轻松渲染10万个元素如果我们再使用点图元结合造型函数的方法就能轻松渲染20万个以上的元素了。像这样的优化方法需要我们理解业务场景并对WebGL和GPU、渲染管线等有深入的理解再在项目实践中慢慢积累。
除此以外我们还简单介绍了其他的一些优化手段包括使用后期处理通道、使用GPGPU和使用服务端渲染。你可以结合我给出的例子去深入理解。
## 小试牛刀
今天,我们使用点图元结合造型函数的思路,绘制了正方形和圆,你还能绘制出其他不同图形吗?比如圆、正方形、菱形、花瓣、苹果或葫芦等等。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码](https://github.com/akira-cn/graphics/tree/master/performance-more)
## 推荐阅读
[1] [Post Fluid Distortion](https://oframe.github.io/ogl/examples/post-fluid-distortion.html)
[2] [GPGPU Particles (General-Purpose computing on Graphics Processing Units)](https://oframe.github.io/ogl/examples/gpgpu-particles.html)
[3] [Node-canvas-webgl](https://github.com/akira-cn/node-canvas-webgl)

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="32 | 数据之美:如何选择合适的方法对数据进行可视化处理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/yy/95805ab2a62c874d0dd35cfa7f2c53yy.mp3"></audio>
你好,我是月影。
我们知道,可视化包括视觉和数据两大部分。通过前面的课程,我们完成了可视化中视觉呈现部分的学习,学会了用某种技术把数据展现给用户,产生丰富的、生动的、炫酷的视觉效果。今天,我们正式进入数据篇,开始学习数据处理。
你可能会问,学习可视化设计一定要学会处理数据吗?答案是一定要学。因为在可视化项目中,我们关注的信息经常会隐藏在大量原始数据中,而原始数据又包含了太过丰富的信息。其中大部分信息不仅对我们来说根本没用,还会让我们陷入信息漩涡,忽略掉真正重要的信息。
因此,只有深入去理解数据,学会提炼、处理以及合理地使用数据,我们才能成为一名优秀的可视化工程师。
那数据究竟是怎么从原始数据中获取的,又是怎么被我们用可视化的方式表达出来的呢?其实方法有很多,不过这节课我先举三种方法,让你对可视化数据处理手段有一个全面的认知,后几节课我们再深入讲解一些比较通用的数据处理技巧。
## 从原始数据中过滤出有用的信息
首先,我们明确一点,在可视化中,我们处理数据的目的就是,从数据中梳理信息,让这些信息反应出数据的特征或者规律。一个最常用的技巧就是按照某些属性对数据进行过滤,再将符合条件的结果展现出来,最终让数据呈现出我们希望用户看到的信息。
这么说可能还不太好理解我们来看一个简单的例子。假设现在有一个小公园公园有四个区域分别是广场、休闲区、游乐场以及花园。每天上午8点、中午12点、下午6点以及晚上8点这四个时间公园管理处会通过航拍收集4个区域上人群的分布信息得到每天人群分布的数据之后公园管理者就能够利用这些数据来优化公园的娱乐设施了。
具体该怎么做呢?利用可视化来解决这个问题会非常简单,思路就是先把人群的分布数据绘制成合适的可视化图表,从中分析出人群分布的规律。
这里,我仿造了一组人群数据,将它放在[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/data/park-people/data.json)里,数据格式如下:
```
[{
&quot;x&quot;: 456,
&quot;y&quot;: 581,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;f&quot;
}, {
&quot;x&quot;: 293,
&quot;y&quot;: 545,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 26,
&quot;y&quot;: 470,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 254,
&quot;y&quot;: 587,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 385,
&quot;y&quot;: 257,
&quot;time&quot;: 8,
&quot;gender&quot;: &quot;m&quot;
},
...]
```
数据是JSON格式数组中的每一项表示一个游客x、y是拍摄位置time是时间gender是性别。
想要表现人群分布的规律我们可以用这个数据来绘制一个散点图它能非常直观呈现出原始数据。绘制方法非常简单就是根据x、y坐标将一个小圆点标记在公园的某个位置上代码如下
```
function draw(data) {
const context = canvas.getContext('2d');
context.fillStyle = 'rgba(255, 0, 0, 0.5)';
for(let i = 0; i &lt; data.length; i++) {
const {x, y} = data[i];
context.beginPath();
const spot = context.arc(x, y, 10, 0, Math.PI * 2);
context.fill();
}
}
fetch('data.json').then((res) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data);
});
```
最终绘制出来的效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/f7/1c/f7265e0e16788f20f4916617e8c9471c.jpeg" alt="">
我们可以看到这样绘制出来的分布图上显示了每天访问公园的人群他们很均匀地分散在公园各处似乎并没有什么特殊的地方。这其实是因为我们并没有根据其他属性来过滤这些数据。我们可以先试着根据时间来过滤。我们修改一下代码给draw方法添加一个过滤函数。
```
function draw(data, filter = null) {
if(filter) data = data.filter(filter);
const context = canvas.getContext('2d');
context.fillStyle = 'rgba(255, 0, 0, 0.5)';
for(let i = 0; i &lt; data.length; i++) {
const {x, y} = data[i];
context.beginPath();
const spot = context.arc(x, y, 10, 0, Math.PI * 2);
context.fill();
}
}
fetch('data.json').then((res) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data, ({time}) =&gt; time === 8);
// draw(data, ({time}) =&gt; time === 12);
// draw(data, ({time}) =&gt; time === 18);
// draw(data, ({time}) =&gt; time === 20);
});
```
把数据按照8点、12点、18点、20点分别过滤之后我们就能得到不同时间的游客散点图如下图所示。
<img src="https://static001.geekbang.org/resource/image/4e/bf/4eb0cec6dc008ec550c5f23e7b23fcbf.jpeg" alt="">
我们先看左上角也就是8点钟的时候游客大部分会集中在广场结合这个时间点他们可能是在晨练而游乐场几乎没有游客因为8点的时候游乐场还没开始营业。接着我们来看右上角也就是中午12点这个时候游客大部分集中在游乐场说明此时是游乐场的高峰时间。然后是左下角18点的时候你会发现一天中这个时间的游客数量是最多的并且集中在广场上的游客也最多我们推测他们正在进行健身活动。最后右下角也就是20点这个时候公园临近关门所以游客已经很少了。
就这样,我们得到了不同时间段,游客集中活动的场所。接下来,我们可以再把性别这个属性加上,看看还有什么分布规律,修改后的代码如下所示。我们用蓝色标记男游客,用红色标记女游客。
```
function draw(data, filter = null) {
if(filter) data = data.filter(filter);
const context = canvas.getContext('2d');
for(let i = 0; i &lt; data.length; i++) {
const {x, y, gender} = data[i];
context.fillStyle = gender === 'f' ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 0, 255, 0.5)';
context.beginPath();
const spot = context.arc(x, y, 10, 0, Math.PI * 2);
context.fill();
}
}
fetch('data.json').then((res) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data, ({time}) =&gt; time === 12);
});
```
标记完我们再来看一下12点的游客散点图。
<img src="https://static001.geekbang.org/resource/image/2d/25/2d2cfaa2240d8ae77514eeaee888a825.jpeg" alt="">
我们看到,集中再游乐场和休闲区的主要是男游客,而女游客更喜欢呆在花园。这可能是和游乐场的游乐设施以及休闲区的设计有关。
到这里,我们关于公园游客的可视化分析就告一段落了。通过我们分析得出的规律,对游乐场改进游乐设施和日常管理是有实际的参考作用。
因为这里的数据是我仿造的数据,所以不一定符合真实情况,不过这并不重要。通过这个例子,我主要是想让你体会数据可视化分析的一般过程,通常我们通过数据过滤和展示,从中提取出有用信息,以便于做出后续的决策,这就是数据可视化的价值所在。只要数据是客观的,分析过程是合理的,那数据表现出来的结果就是具有实际意义的。
## 强化展现形式让用户更好地感知
在前面的例子里,我们用散点图呈现游客信息并从中分析出有用的内容,这种形式直观有效,但是展现形式略显单调。除了合理的数据分析以外,数据可视化有时候通过强化展现形式,让用户更好地感知数据表达的内容。这样能够帮助需要关注该数据的用户,更好地把握整体信息。
我在GitHub看到一个非常合适的例子我们来看一下。
>
空气质量和我们生活质量息息相关,那在过去的几年里,雾霾和蓝天交替,成为我们生活的一部分。近几年来,国家一直在大力治理空气污染。那在这种情况下,我们的空气质量到底有没有变好呢?
一名[亚赛](https://wangyasai.github.io/)同学写了一个[北京空气质量2015-2018的可视化展现](https://wangyasai.github.io/BeijingAirNow/)利用每天北京空气的AQI值绘制了色条他还用心地让每一天对应了一个地标的当天实拍照片。这不仅增加了项目整体的趣味性也强化了用户的直观认知。
[<img src="https://static001.geekbang.org/resource/image/36/d6/3627ff3a70270ce759ec38ebf7d7a5d6.gif" alt="" title="北京空气质量2015-2018可视化展现图片来源wangyasai.github.io)">](https://wangyasai.github.io/Work/beijingsky.html)
如上面示意图所示我们将每一年的数据按照AQI排序后可以明显看出灰色的区域在逐年减少这说明北京的空气质量的确是在逐年好转的。
这个项目的代码在亚赛同学的[GitHub仓库](https://github.com/wangyasai/wangyasai.github.io/tree/master/Work)里我就不拿出来细讲了。因为这个可视化效果的实现原理并不复杂而且这节课我们更应该学习和理解用数据进行可视化展现的思路而不是代码实现细节。当然如果你想搞懂代码那你可以深入分析一下GitHub仓库里的源码。这个项目代码具体实现是依赖一个叫做p5.js的图形库它也是一个很棒也很有趣的图形库用来学习可视化也非常合适如果你有兴趣可以去看一下。
如果你想要亲自动手实现一个这样的可视化项目,我也建议你借鉴亚赛同学这个项目中的数据和思路,对它进行一些改进。
## 将信息的特征具象化
到现在为止,你可能认为可视化都是需要使用真实数据来呈现的,数据越真实、越详细,可视化效果呢就越好。如果有了这个想法,说明你有一点陷入到思维定式中了。实际上,有时候我们并不要求数据越真实越详细,甚至不要求绝对真实的数据,只需要把数据的特征抽象和提取出来,再把代表数据最鲜明的特征,用图形化、令人印象深刻的形式呈现出来,这就已经是成功的可视化了。
比如下面这张图,就用可视化的方式解释了数据、信息、知识、见解、智慧和影响力。这种可视化呈现的数据并不是真实、准确地,而是带有趣味性的,通过对信息特征进行抽取,让看的人形成了一种视觉认知。
<img src="https://static001.geekbang.org/resource/image/60/9f/607a51a7115e678c93f614e9d781c69f.jpeg" alt="" title="数据-信息-知识-洞见-智慧-影响力(图片来源gapingvoid)">
其实这样的可视化例子还有很多比如Matt Might教授绘制的“[图解博士是什么](http://matt.might.net/articles/phd-school-in-pictures/)”也非常有趣。由于图片很长,我在这里就不列出来了。虽然它的数据不是基于海量数据提取的,但却是一组概念的具象化,所以它毫无疑问也是一个非常成功的可视化方案例。
除此之外Manu Cornet的组织架构图也用非常形象的方法绘制出了各个知名公司的组织架构差异。它的数据当然也不是各个公司详细的组织架构数据而是根据每个公司组织架构特征直接图形化形成的。
[<img src="https://static001.geekbang.org/resource/image/48/1a/48636b60eec64da7fyye1064907d0c1a.jpeg" alt="" title="(图片来源bonkersworld)">](https://bonkersworld.net/organizational-charts)
看了前面这些例子,你可能会有疑问,第三种方法似乎和原始数据并没有关联,而是直接用信息特征来完成的可视化,那第一、二种方法和数据处理过程和它又有什么关系呢?
实际上,我们使用第三种方法,也就是信息特征具象化的前提,是我们真正掌握了我们需要的信息特征,而这些特征的提取和掌握,正是通过前面两种方法迭代出来的!用一句话总结就是,数据可视化本身是一个不断迭代的过程。
具体过程是,我们先进行原始数据的信息收集和分类处理,再通过原始方法表达出有用的信息,接着通过强化展现形式,让信息的核心特征变得更加鲜明,经过这一轮或者几轮的迭代,我们就可能拿到最本质的信息了,最终我们再把这些信息具象化,就可以达到令人印象深刻的效果了。
所以,对原始数据进行不断迭代,就是数据可视化的基本方法论。我希望你能牢牢记住这句话,并且在实践中认真去做。
## 要点总结
这节课,我们主要讲了对数据进行可视化处理的三种常见方法。
第一种,是从原始数据中过滤出有用的信息。这是数据可视化处理的第一步,也是最基础的方法。第二种,是强化数据的展现形式,让用户更好地感知我们要表达的信息。这是我们在第一步的基础上对数据进行的加工处理。而第三种,是把数据的特征具象化,然后用图形表达。这是我们在第一、二步的基础上,对数据进一步的抽象和提取。如果达到这一步,我们甚至有可能完全脱离原始数据,不依赖原始数据,而是着眼于数据特征的表现形式。
这三种方法层层递进,是数据可视化的基本方法论,而数据可视化本身,其实就是使用这些方法对数据信息进行不断迭代和构建的过程。
## 小试牛刀
你能借助我们前面说的北京空气质量,这个可视化例子中的代码,实现一个你想要的可视化展现吗?你可以不完全按照亚赛同学的方法,多加些自己的创意,以及我们前面学过的图形学技巧,相信你能做出非常好的效果。当然了,我也知道这个挑战有点难,但整个实现的过程能让你把学到的图形学知识融会贯通,所以我还是建议你尝试一下。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整实例代码](https://github.com/akira-cn/graphics/tree/master/data/park-people)
## 推荐阅读
[1] [北京空气质量2015-2018的可视化展现](https://wangyasai.github.io/BeijingAirNow/)
[2] [图解博士是什么](http://matt.might.net/articles/phd-school-in-pictures/)

View File

@@ -0,0 +1,194 @@
<audio id="audio" title="33 | 数据处理(一):可视化数据处理的一般方法是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/6d/45bf979362d5001a77878f5220d3be6d.mp3"></audio>
你好,我是月影。
在数据处理的过程中,我们经常遇到两种情况:一种是数据太少,我们没法找到有用的信息,也就无法进行可视化呈现。另一种是数据太多,信息纷繁复杂,我们经常会迷失在信息海洋中,无法选择合适的可视化呈现方式,最终也表达不了多少有意义的内容。
那你可能想问了,想要解决这两种情况,我们能用上节课讲过的三种数据处理方法吗?事实上,上节课的方法是数据可视化的基本方法论,你可以在可视化过程中借鉴它们的思路,但是它们并不系统。
因此,我们在探索数据可视化的时候,还需要一个合理的数据可视化分析过程作为参照。从这一节课开始,我们就来系统地讨论数据处理的一般方法。
## 数据可视化的一般过程
针对课程一开始这两种情况,就算是不学数据处理的一般思路,我们也知道,如果你的数据太少,你要想办法获取更多的数据,而如果你的数据太多,那你就需要学会运用正确的方法不断迭代、筛选。而且,数据过多的情况我们遇到得更多。
当你学会在众多复杂的数据中准确地抽取信息,把这些数据的某一面可视化出来的时候,你就已经能够轻松地从数据中得到你想要的内容。通过这个过程,有可能让你从数据的一面获得启发,从而发现数据其他方面的有趣内容,进而产生出更多不同的图表,让数据呈现出更多的意义。
所以在数据可视化中你有什么样的数据其实是最重要的而我们的可视化手段会随着具体数据集的不同而不同。因此我们一般会围绕4个问题对可视化过程进行迭代它们分别是你有什么数据你想从数据中了解什么信息你想用什么样的可视化方式呈现以及你看到了什么它有意义吗并且前一个问题的答案都会影响后一个问题的答案。
结合这几个问题,我把数据可视化的一般过程用一个流程图画了出来。接下来,我们就分别说说每一步具体是怎么操作的。
<img src="https://static001.geekbang.org/resource/image/6b/c2/6b2c01e696e7432461de6e82753fccc2.jpeg" alt="" title="可视化迭代过程">
### 你有什么数据?
我们在实际处理可视化数据的时候,经常会遇到可视化项目最终产出的结果与设计预期和客户的期望相差甚远。
比如说产品经理经常会提出一些需求可能是要我们实现某种复杂的可视化大屏或者指定一些竞品中的图表形式让我们模仿有时候甚至会在数据给到我们之前先完成UI设计。可是等到我们最终拿到数据之后却发现数据的信息不足以支撑这些图表的展现形式或者数据的内容在这些图表上表现得不好。
其实这不是产品经理能力不足、也不是可视化工程师能力不足,而是我们一开始就搞错了步骤,我们应该先分析真实数据,找到数据的特点,比如我们可以按照时间、地点、性别对数据进行分类。然后,我们再研究具体的数据呈现形式。因此,做可视化项目的第一步,就是要先了解自己掌握的数据,而不同的数据要了解的内容不同,我们要根据实际情况来具体分析。
### 你想从数据中了解到什么信息?
在得到并且了解原始数据之后,接下来我们该怎样着手获取想要的信息呢?我们[以第27节课](https://time.geekbang.org/column/article/277226)用到的GitHub贡献数据为例。当时我们想实现GitHub用户代码贡献图表GitHub贡献数据已经被平台加工过平台已经把每个用户的贡献信息整理出来了所以这类数据非常简单我们直接用就可以了。
不过我们可以想象一下GitHub贡献数据的原始数据长什么样。我认为它应该包含了每个GitHub代码仓库提交的日志并且每一条日志对应一个提交人和一个提交时间。要把这些数据整理成GitHub用户代码贡献数据GitHub平台需要对这些原始数据按日期和提交人进行归总统计出每个用户在具体某个日期的代码提交次数。
假设,现在我们不想要统计每个用户每天的代码贡献数据,而是要统计每个代码仓库每天的代码贡献数据。那么,我们只需要对原始数据换一种方式处理,将日志不按照提交人进行归总,而是按照提交的仓库进行归总。这样我们就可以得到数据的另一面,从而得到一个完全不同的可视化内容,也就是代码仓库每日的代码贡献次数。
所以,当我们确定了想要表达的信息之后,如果数据中有,我们就直接拿来用,如果没有,我是需要自己来处理的。
### 使用何种可视化方式呈现?
当确定了想要了解的信息之后,会有很多视觉呈现方式供我们选择。那我们该如何选择呢?核心原则就是一条:当为数据选择正确的呈现形式时,我们要学会从不同的角度思考数据,把握住最希望被用户关注到的那些内容,然后用直观的、有趣的以及更加印象深刻的方式来进行表达。
你可能会说,道理我都懂,可具体该怎么做呀?
其实具体怎么做并没有定论我们只能通过例子慢慢积累经验随着经验的丰富你就能慢慢找到设计合适的可视化方式的感觉。比如说现在你就可以想想我在下面给出的这个例子。例子中给出的数据图表是Tim De Chant的世界人口地图。其中一共有6个城市每个城市的人口密度不同如果我们要把全球70亿人都放在任意密度的城市中这个城市会有多大呢
<img src="https://static001.geekbang.org/resource/image/10/3f/10f90401ba636e6027c1355c503b2d3f.jpeg" alt="">
我的建议你可以好好想到底怎么做,有机会的话一定要去实践。在实践的过程中,当你尝试不同的标尺、颜色、形状、大小和几何图形的时候,就会看到可以值得探索的视觉呈现形式了。另外,给数据增加直观性和趣味性,也能够让朴实的数据立刻生动起来。这也是实践你学过的视觉呈现技术的最好时机。
### 你看见了什么?它有意义吗?
可视化数据之后,我们需要从中挖掘出一些有价值的信息,包括但不限于数据信息的规律(增加、减少、离群值等),以及数据信息的异常(明显差异、规律改变)等等,然后将这些规律和异常归因,帮助我们真正了解数据背后有价值的内容。这些正是可视化的意义所在。
客观的数据是有说服力的,因为客观数据一旦产生就不会改变,所以,我们一般认为这样的数据就是事实。但有用的信息往往隐藏得比较深,因此,数据可视化最终目的就是将这些信息挖掘出来,呈现在人们眼前。
到这里,数据可视化的一般过程我们就讲完了。总之,在对数据进行四个步骤的迭代过程中,不同的数据以及不同的数据处理方式,在迭代中会产生不同的呈现形式。为了让你更深刻地理解这一点,我就以上节课游乐场的数据为例,带你体验一次数据处理的全过程。
## 实战演练:对公园中的游客进行数据可视化
首先我们来看我们有什么数据。我们的原始数据的格式就像下面记录的一样,有时间、地点和性别。
```
[{
&quot;x&quot;: 456,
&quot;y&quot;: 581,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;f&quot;
}, {
&quot;x&quot;: 293,
&quot;y&quot;: 545,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
...
}]
```
接下来我们看一下我们想了解什么,假设我们想了解公园一天中的**游客变化规律**,那么我们可以用分类的思路处理数据。在上节课,我们就对数据进行了简单的**时间分类**、**地点分类**和**性别分类**。这里我们用d3的数据变换Transformations模块将原始数据处理成我们想要的模式。
这里,我详细说说我们用到的[d3.rollups](https://github.com/d3/d3-array/blob/v2.7.1/README.md#rollups) 它可以对数据进行分组然后汇总。这个接口设计得比较函数式functional)它接受3个参数第一个参数是要处理的数据也就是上面的原始数据后面两个参数是两个函数算子第一个算子表示对数据分组进行汇总的方式这里是使用length来汇总也就是统计数据的条目数。第二个算子则表示对数据进行分组的属性这里是用时间属性进行分组。最后我们在分组之后再对数据进行一次排序因为我们要按照时间从小到大进行排序。
```
(async function() {
const data = await (await fetch('data.json')).json();
const dataset = d3.rollups(data, v =&gt; v.length, d =&gt; d.time).sort(([a],[b]) =&gt; a - b);
...
}());
```
经过分组和排序之后,我们从原始数据得到了如下的新数据:
```
[[8, 145], [12, 141], [18, 191], [20, 23]]
```
现在我们只有8点、12点、18点、20点这4个时间段的数据我们还需要把游客为0的时间信息补全。假设公园是早晨6点开门晚上22点关门那么6点、22点的游客数应该是0补充的数据如下
```
dataset.unshift([6, 0]);
dataset.push([22, 0]);
```
我们补全的数据是一个二维数组,其中每个元素的第一个值是时间,第二个值是当前时间的公园内人数。
接着,我们就要进行第三步了:确定用哪种可视化方式呈现数据。因为要呈现游客的变化规律,所以我们最终决定使用折线图来呈现,那我们就要把数据转换成要显示的折线上的点坐标。
```
const points = [];
dataset.forEach((d, i) =&gt; {
const x = 20 + 20 * d[0];
const y = 300 - d[1];
points.push(x, y);
});
```
然后我们用SpriteJS创建Polyline元素把这个折线点坐标传给它。最后我们把这个元素给添加到 layer 上,就可以将它显示出来了。
```
const p = new Polyline();
p.attr({
points,
lineWidth: 4,
strokeColor: 'green',
smooth: true,
});
fglayer.append(p);
```
因为还要考虑到游客是随时间变化的所以我们要给它增加一个坐标轴。这里我们是用SpriteSvg来绘制坐标轴的SpriteSvg是一个特殊元素它能够创建一个SVG对象然后把它以图像方式绘制到Canvas上。你发现了没有这就是我们前面说过的SVG和Canvas的混合使用方式它可以把SVG作为图像绘制这样既能使用SVG来灵活地改变图形又可以用Canvas来高性能地渲染。
```
const scale = d3.scaleLinear()
.domain([0, 24])
.range([0, 480]);
const axis = d3.axisBottom(scale)
.tickValues(dataset.map(d =&gt; d[0]));
const axisNode = new SpriteSvg({
x: 20,
y: 300,
flexible: true,
});
d3.select(axisNode.svg)
.attr('width', 600)
.attr('height', 60)
.append('g')
.call(axis);
axisNode.svg.children[0].setAttribute('font-size', 20);
fglayer.append(axisNode);
```
在创建坐标轴的时候我们需要给坐标轴设置一个scaled3中间应用了很多函数式编程思想这里的scale也是一个函数算子它是由高阶函数d3.scaleLinear创建的我们给它设置domain从0到24表示一天的24个小时range从0到480表示占据480像素宽度。
然后我们再通过d3.axisBottom高阶函数用创建的scale来生成一个具体的坐标轴算子axis然后对SVG对象应用这个算子就可以绘制出坐标轴的图像了。最终的效果如下图所示
<img src="https://static001.geekbang.org/resource/image/92/yy/92fc979762a3499822231054a69248yy.jpeg" alt="" title="公园24小时游客人数变化图">
最后我们能从这张图上看出些什么信息呢在这张图上我们看到公园的游客数有两个高峰期分别是早8点和傍晚6点而12点的游客是比较少晚上8点之后到10点闭园之前游客的数量是最少的。
这样,我们就得到了一天中游园人数的变化趋势,这对公园的管理策略是有一些参考价值的。这个数据还很粗糙,因为我们的原始数据的信息量并不大。不过如果我们继续收集不同天的数据,并进一步分析游客在不同日期、不同地点的人数情况,或者对游客的性别进行分析,我们就可能得到更多有趣的信息,但究竟能获得什么样的有用信息,还要看原始数据情况和我们实际着手进行迭代的方式。
总之,当我们把更多的信息集中在一起的时候,我们就能做更多的事情了,比如,我们可以分析游客数和当月平均气温的关系,或者分析交通趋势和公园游客趋势的关系,以及分析天气与游园游客性别的相关性等等,并且我们还能利用这些数据来帮助公园后续的建设和管理决策。
## 要点总结
这节课我们讲了数据可视化的一般过程它是一个需要反复迭代的过程而且在每一轮迭代中我们都可以以这4个问题作为参照分别是你有什么数据你想从数据中了解什么信息你想用哪种可视化方式呈现你看见了什么它有意义吗
按照这四个问题,我们在迭代过程中可以用分类的方式来处理数据,对数据分类,是最常用的处理原始数据的方式,也符合我们的思维习惯。数据的分类可以带来信息结构化,从而帮助我们提取想要的内容。也就能一步步达成我们想要的结果,然后我们再逐步细化迭代,就能做出更好的可视化效果来。
最后,我还想再强调一下,从本质上来说,可视化过程是对数据进行分析、提取有效信息、设计展现形式的不断迭代过程,今天我们讲的例子虽然简单,但更加复杂的例子,也无外乎是同样的处理过程,只不过那个时候,我们可能需要处理更多的数据,经过更多轮迭代。
## 小试牛刀
我们的原始数据里有性别数据,你可以根据性别数据进行过滤,修改上面的例子,看看按照性别分类的游客趋势图与不按性别分类的游客趋势图有什么不同吗?或者,除了折线图,你能把每个时间段的游客性别比例用并饼状图显示出来吗?以及,你还可以试着按照公园地点进行分组,再显示更细分的饼图效果。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data/park-people)

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="34 | 数据处理(二):如何处理多元变量?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/28/196bdd87cf3a1af3b4dbe228cf608c28.mp3"></audio>
你好,我是月影。
数据处理是一门很深的学问,想要学好它,我们不仅需要掌握很复杂的理论,还需要不断地积累经验。不过,其中也有一些基础的数据处理技巧,掌握它们,我们就能更好地入门可视化了。
比如我们上节课重点讲解的数据分类就是其中一种非常基础的数据处理技巧也是数据处理的第一步。这一节课我会以处理2014年北京市的天气历史数据为例来和你进一步讨论数据处理的基础技巧包括从数据到图表的展现以及处理多元变量的方法。
## 从数据到图表展现
一般来说,我们拿到的原始数据通常可以组织成表格的形式,表格中会有很多列,每一列都代表一个变量。比如,我们拿到的这份天气历史数据,它看起来可能是下面这样的:
<img src="https://static001.geekbang.org/resource/image/24/48/24b87b9076ab5df4c9c8ed486a6dc948.jpg" alt="">
这里有许多变量,比如时间、最高气温、平均气温、最低气温、最高湿度、平均湿度、最低湿度、露点等等。一般的情况下,我们会将其中我们最关心的一个变量平均气温,用一个图表展现出来。具体怎么做呢?我们可以来动手操作一下。
这份数据是csv格式的是一张表我们先用D3.js将数据读取出来然后结构化成JSON对象。
```
const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData);
const dataset = data.filter(d =&gt; new Date(d.Date).getMonth() &lt; 3)
.map(d =&gt; {return {temperature: Number(d['Temperature(Celsius)(avg)']), date: d.Date, category: '平均气温'}});
console.log(dataset);
```
如上面代码所示我们通过fetch读取csv的数据。CSV文件格式是用逗号和回车分隔的文本所以我们用.text()读取内容。然后我们使用d3的csvParse方法将数据解析成JSON数组。最后我们再通过数组的filter和map将我们感兴趣的数据取出来。这里我们截取了1月到3月的平均气温数据。
取到了想要的数据接下来我们就可以将它展示出来了这一步我们可以使用数据驱动框架。在预习篇我们讲过数据驱动框架是一种特殊的库它们更专注于处理数据的组织形式将数据呈现交给更底层的图形系统DOM、SVG、Canvas或通用图形库SpriteJS、ThreeJS去完成。
但是,为了方便你理解,这里我就不使用数据驱动框架了,而是直接采用一个图表库[QCharts](https://www.qcharts.cn/)它是一个基于SpriteJS设计的图表库。与数据驱动框架相比图表库虽然减少了灵活性但是使用上更加方便通过一些简单的配置我们就可以完成图表的渲染。
用来展示平均气温最常见的图表就是折线图展示折线图的过程可以简单分为4步第一步是创建图表Chart并传入数据第二步是创建图形Visual)这里我们创建的是折线图所以使用Line对象第三步是创建横、纵两个坐标轴Axis、提示ToolTip和一个图例Legend最后一步是将图形、坐标轴、提示和图例都添加到图表上。具体的代码如下
```
const { Chart, Line, Legend, Tooltip, Axis } = qcharts;
const chart = new Chart({
container: '#app'
});
let clientRect={bottom:50};
chart.source(dataset, {
row: 'category',
value: 'temperature',
text: 'date'
});
const line = new Line({clientRect});
const axisBottom = new Axis({clientRect}).style('grid', false);
axisBottom.attr('formatter', d =&gt; '');
const toolTip = new Tooltip({
title: arr =&gt; {
return arr.category
}
});
const legend = new Legend();
const axisLeft = new Axis({ orient: 'left',clientRect }).style('axis', false).style('scale', false);
chart.append([line, axisBottom, axisLeft, toolTip, legend]);
```
这样,我们就将图表渲染到画布上了。
<img src="https://static001.geekbang.org/resource/image/3c/fd/3c35aed4df29c2617d6d877fed588ffd.jpg" alt="">
## 处理多元变量
刚才我们已经成功将平均气温这个变量用折线图展示出来了,但在很多数据可视化场景里,我们不只会关心一个变量,还会关注多个变量,比如,我们需要同时关注温度和湿度数据。那怎么才能把多个变量绘制在同一张图表上呢?换句话说,同一张图表怎么展示多元变量呢?
### 在一张图表上绘制多元变量
最简单的方式是直接在图表上同时绘制多个变量,每个变量对应一个图形,这样一张图表上就同时显示多个图形。
我们直接以刚才的代码为例,现在,我们修改例子中的代码,直接添加平均湿度数据,代码如下:
```
const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData).filter(d =&gt; new Date(d.Date).getMonth() &lt; 3);
const dataset1 = data
.map(d =&gt; {
return {
value: Number(d['Temperature(Celsius)(avg)']),
date: d.Date,
category: '平均气温'}
});
const dataset2 = data
.map(d =&gt; {
return {
value: Number(d['Humidity(%)(avg)']),
date: d.Date,
category: '平均湿度'}
});
```
然后我们修改图表的数据将温度dataset1和湿度dataset2数据都传入图表代码如下
```
chart.source([...dataset1, ...dataset2], {
row: 'category',
value: 'value',
text: 'date'
});
```
这样,我们就得到了同时显示温度和湿度数据的折线图。
<img src="https://static001.geekbang.org/resource/image/8a/f5/8a988aff89201fabea2d63629663ebf5.jpg" alt="">
### 用散点图分析变量的相关性
不过你应该也发现了把温度和深度同时绘制到一张折线图之后我们很难直观地看出温度与湿度的相关性。所以如果我们希望了解2014年全年北京市温度和湿度之间的关联性我们还得用另外的方式。那都有哪些方式呢
一般来说要分析两个变量的相关性我们可以使用散点图散点图有两个坐标轴其中一个坐标轴表示变量A另一个坐标轴表示变量B。这里我们将平均温度、相对湿度数据获取出来然后用QCharts的散点图Scatter来渲染。具体的代码和示意图如下
```
const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData);
console.log(data);
const dataset = data
.map(d =&gt; {
return {
temperature: Number(d['Temperature(Celsius)(avg)']),
humdity: Number(d['Humidity(%)(avg)']),
category: '平均气温与湿度'}
});
const { Chart, Scatter, Legend, Tooltip, Axis } = qcharts;
const chart = new Chart({
container: '#app'
});
let clientRect={bottom:50};
chart.source(dataset, {
row: 'category',
value: 'temperature',
text: 'humdity'
});
const scatter = new Scatter({
clientRect,
showGuideLine: true,
});
const toolTip = new Tooltip({
title: (data) =&gt; '温度与湿度:',
formatter: (data) =&gt; {
return `温度:${data.temperature}C 湿度:${data.humdity}% `
}
});
const legend = new Legend();
const axisLeft = new Axis({ orient: 'left',clientRect }).style('axis', false).style('scale', false);
const axisBottom = new Axis();
chart.append([scatter, axisBottom, axisLeft, toolTip, legend]);
```
<img src="https://static001.geekbang.org/resource/image/de/01/dec1994d0a99794182f581924d17ef01.jpg" alt="">
从这个图表我们可以看出,平均温度和相对湿度并没有相关性,所以点的空间分布比较随机。事实上也是如此,气温和绝对湿度有关,但相对湿度因为已经考虑过了温度因素,所以就和气温没有相关性了。
那你可能会有疑问,相关的图形长什么样呢?我们可以用另外两个变量,比如露点和平均温度,来试试看能不能画出相关的散点图。
我们先来说说什么是露点。在空气中水汽含量不变, 并且气压一定的情况下, 空气能够冷却达到饱和时的温度就叫做露点温度, 简称露点, 它的单位与气温相同。
从定义里我们知道,露点和温度与湿度都有相关性。接下来,我们来看一下露点和温度的相关性在散点图中是怎么体现的。很简单,我们只要修改一下上面代码里的数据,把平均湿度换成平均露点温度就行了。
```
const dataset = data
.map(d =&gt; {
return {
temperature: Number(d['Temperature(Celsius)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均气温与露点'}
});
```
这样,我们最终呈现出来的散点图具有典型的数据正相关性,也就是说图形的点更集中在对角线附近的区域。
<img src="https://static001.geekbang.org/resource/image/48/1a/483712c909fcfd962072a2c19caaeb1a.jpg" alt="">
我们还可以把湿度数据也加上。
```
const dataset = data
.map(d =&gt; {
return {
value: Number(d['Temperature(Celsius)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均气温与露点'}
});
const dataset2 = data
.map(d =&gt; {
return {
value: Number(d['Humidity(%)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均湿度与露点'}
});
...
chart.source([...dataset, ...dataset2], {
row: 'category',
value: 'value',
text: 'tdp'
});
```
我们发现,平均湿度和露点也是成正相关的,不过露点与温度的相关性更强,因为散点更集中一些。
<img src="https://static001.geekbang.org/resource/image/6d/86/6dcd043da6c8338865313105733f5286.jpg" alt="">
为了再强化理解,我们还可以看一组强相关的数据,比如平均温度和最低温度,你会发现,图上的散点基本上就在对角线上。
<img src="https://static001.geekbang.org/resource/image/2b/07/2b2c8f25ed11aeb7a00aac436a2f4e07.jpg" alt="">
总的来说,两个数据的散点越集中在对角线,说明这两个数据的相关性越强,当然这么说还不够严谨只是方便我们记忆而已。这里,我找到了一张散点图和相关性之间关系的总结图,你可以多了解一下。
<img src="https://static001.geekbang.org/resource/image/44/61/44ac95977e2143d67c079ec10a9c7661.jpg" alt="">
### 散点图的扩展
通过前面的例子,我们可以看到,用散点图可以分析出数据的二元变量之间的相关性,这对数据可视化场景的信息处理非常有用。不过,散点图也有明显的局限性,那就是它的维度只有二维,所以它一般只能处理二元变量,超过二维的多元变量的相关性,它处理起来就有些力不从心了。
不过,我们还不想放弃散点图在相关性上的优异表现。所以在处理高维度数据时,我们可以对散点图进行扩展,比如引入颜色、透明度、大小等信息来表示额外的数据维度,这样就可以处理多维数据了。
举个例子,我在下面给出了一张根据加州房产数据集制作的散点图。其中,点的大小代表街区人口数量、透明度代表人口密度,而颜色代表房价高低,并且加上经纬度代表点的位置。这个散点图一共表示了五维的变量(经度、纬度、人口总数、人口密度、房价高低),将它们都呈现在了一张图上,这在一定程度上表达了这些变量的相关信息。
[<img src="https://static001.geekbang.org/resource/image/bc/61/bcbf60ff83cf90a4d5e05d4f78b2b161.jpeg" alt="" title="图片来源:知乎">](https://zhuanlan.zhihu.com/p/141118125)
这里,我带你做个简单的分析。从这张图上,我们很容易就可以得出两个结论,第一个是,房价比较高的区域集中于两个中心,并且都靠近海湾。第二个是房价高的地方对应的人口密集度也高。
这就是用散点图处理多维数据的方法了。
### 其他图表形式
事实上,处理多维信息,我们还可以用其他的图表展现形式,比如用晴雨表来表示数据变化的趋势就比较合适。北大可视化实验室在疫情期间就制作了一张疫情数据晴雨表,你能明显看出每个省份每天的疫情变化。如下所示:
[<img src="https://static001.geekbang.org/resource/image/48/93/4837642324296791cd00230938bc2e93.jpg" alt="" title="图片来源mp.weixin.qq.com">](https://mp.weixin.qq.com/s/Nq0-p6z1GO869XaS0zliiw)
再比如,还有[平行坐标图](https://ww2.mathworks.cn/help/stats/examples/visualizing-multivariate-data.html)。平行坐标图也有横纵两个坐标轴并且把要进行对比的五个不同参数都放在了水平方向的坐标上。在下面的示意图中绘制了所有4缸、6缸或8缸汽车在五个不同参数变量上的对比。
<img src="https://static001.geekbang.org/resource/image/23/26/23948ddb9761907d3fd1316c506d3726.jpeg" alt="" title="左为平行坐标图,右为热力图">
此外,我们还可以用[热力图](https://www.jianshu.com/p/a575e53bcaa9)、[三维直方图](https://www.jianshu.com/p/a575e53bcaa9)、[三维气泡图](http://https://zhuanlan.zhihu.com/p/147243101)等等其他的可视化形式来展现多维度的信息。
<img src="https://static001.geekbang.org/resource/image/d8/1e/d805e7f932c3403c9732fec8bae1141e.jpeg" alt="" title="左为三维直方图,右为三维气泡图">
总之,这些数据展现形式的基本实现原理,我们都在前面的视觉篇中讲过了。在掌握了视觉基础知识之后,我们就可以用自己想要的呈现形式,自由发挥,设计出各种直观的、形式有趣的图表了。
## 要点总结
这一节课,我们主要讨论数据到图表的展现以及如何处理多元变量。
在数据到图表的展现中我们首先用d3.js把原始数据从csv中读取出来然后选择我们需要的数据用简单的图表库比如使用QCharts图表库进行渲染。渲染过程可以分为4步分别是创建图表对象 Chart并传入数据创建图形Visual创建坐标轴、提示和图例把图形、坐标轴、提示和图例添加到图表对象中完成渲染。
在处理多元变量的时候,我们可以用散点图表示变量的相关性。对于超过二维的数据,我们可以扩展散点图,调整颜色、大小、透明度等等手段表达额外的信息。除了散点图之外,我们还可以用晴雨表、平行坐标图、热力图、三维直方图、气泡图等等图表,来表示多维数据的相关性。
到这里,我们用两节课的时间讲完了可视化的数据处理的基础部分。这部分内容如果再深入下去,就触达了数据分析的领域了,这也是一个和可视化密切相关的学科。那我也说过,可视化的重点,一是数据、二是视觉,视觉往深入研究,就进入渲染引擎、游戏等等领域,数据往深入研究,就进入数据分析的领域。所以,在可视化的入门或者说是基础阶段,掌握我现在讲的这些基础知识就够了。当然,如果你想深入研究也是好事,你可以参考我在课后给出的文章好好阅读一下。
## 小试牛刀
<li>
我在GitHub代码仓库里放了两份数据一份是我们今天讲课用到的另一份是[2013到2018年的全国各地空气质量数据](https://github.com/akira-cn/graphics/blob/master/data/weather/2013-2018.csv)。你能把2014年北京每日PM2.5的数据用折线图表示出来吗你还能结合这两份数据用散点图分析平均气温和PM2.5指数的相关性吗?
</li>
<li>
你可以模仿我北大可视化实验室的疫情晴雨表实现一个2018年全国各城市空气质量晴雨表吗
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码见GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data/weather)
## 推荐阅读
[1] [从1维到6维-多维数据可视化策略](https://zhuanlan.zhihu.com/p/147243101)
[2] [QCharts](https://www.qcharts.cn/)

View File

@@ -0,0 +1,339 @@
<audio id="audio" title="35| 设计(一):如何让可视化设计更加清晰?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/0e/fe642bb08a0004e297395d36c9faef0e.mp3"></audio>
你好,我是月影。
在实际的可视化项目中,我们经常会遇到一种情况:用户期望所有的可视化图表都是简单明了的。实际上,这是不现实的。
因为我们拿到原始数据之后,第一步是分析数据,也就是从各种不同的角度尝试去观察数据,确定我们希望用户了解的信息。这些信息如果是简单清晰的,那么可视化结果就是简单直观的。如果用户想要了解的数据规律本身就很复杂,那么可视化图表所能做的事情,也只能是尽可能清晰地展现用户关注的重要信息,屏蔽干扰信息,来降低用户理解数据的难度。
因此,我们要明白,在任何时候,制作可视化图表都是为了帮助人们理解抽象的数据,不管这些数据多复杂,都要尽可能让读者快速理解。如何才能做到这一点呢?简单来说,就是你要学会了解人们是怎样看数据的,然后将数据呈现得符合人们的思维和理解习惯。
接下来,我们就通过几个例子来学习一下,都有哪些方法可以轻松地把人们的注意力集中在数据信息上。
## 分清信息主次,建立视觉层次
我们可以先想这么一个问题:第一次看图表的时候,你都会注意哪些信息?如果是我的话,我总会试图在图表上找到什么有趣的东西。实际上,在看任何东西的时候,我们的眼睛总是倾向于识别那些引人注目的东西,比如,明亮的颜色,较大的物体,有特点的符号等等。因此,我们做可视化的时候,应当用醒目的颜色突出显示数据,把被淡化的其他视觉元素当作背景。其实,这就是我们今天要讲的第一个方法,建立视觉层次。
我们以上一节课平均温度与露点的散点图为例,来说说这具体是怎么做。
<img src="https://static001.geekbang.org/resource/image/28/b4/28084f718e2b89ebcd335d695f1e5eb4.jpg" alt="">
我们知道,一个可视化图表应该包括图形、图例、提示信息、坐标轴等等元素。其中,图形是最重要的元素,所以它一般会在图表最核心的区域呈现。上图中,我们就使用了比较鲜明的蓝色来突出图形。至于左侧和下方的坐标轴,我们就用比较淡的灰黑色来显示。背景中的辅助线存在感最弱,因为它们是用来辅助用户更认真地阅读图表、理解数值的,不是主要元素,所以我们会用非常淡的颜色把它们显示在背景里。这些元素就构成了一个有鲜明视觉层次感的图表。
不过,就这个图表而言,我们还可以把它做得更好。因为,我们实际上希望表达给用户的信息还包含了平均气温与露点的正相关性,如果用户对这个数学关系比较敏感,完全可以通过散点分布了解到它们的正相关性,但是对其他不敏感的用户来说,我们可以添加曲线图来引导他们。接下来,我们一起来看具体的做法,从中你就可以体会到建立视觉层次的思路了。
第一步,我们处理一下数值,将数据按照气温高低排序,然后对相同温度的数据进行分组,最后将相同温度下的平均露点温度计算出来。
```
// 露点排序
let dataset2 = [...dataset].sort((a, b) =&gt; a.tdp - b.tdp);
// 对相同露点的温度进行分组
dataset2 = dataset2.reduce((a, b) =&gt; {
let curr = a[a.length - 1]
if (curr &amp;&amp; curr.tdp === b.tdp) {
curr.temperature.push(b.temperature)
} else {
a.push({
temperature: [b.temperature],
tdp: b.tdp
})
}
return a
}, []);
// 最后将露点平均温度计算出来
dataset2 = dataset2.map(d =&gt; {
d.category = '露点平均气温'
d.temperature = Math.round(d.temperature.reduce((a, b) =&gt; a + b) / d.temperature.length)
return d
})
```
在计算好数据之后,我们将散点和曲线两个数组都传给图表对象。
```
const chart = new Chart({
container: '#app'
});
let clientRect={bottom:50};
chart.source([...dataset, ...dataset2], {
row: 'category',
value: 'temperature',
text: 'tdp'
});
```
最后,我们就能分别用散点和曲线图来呈现数据了
```
const ds = chart.dataset;
const d1 = ds.selectRows(&quot;平均气温与露点&quot;);
const d2 = ds.selectRows('露点平均气温');
// 散点图
const scatter = new Scatter({
clientRect,
showGuideLine: true,
}).source(d1);
// 曲线图
const line = new Line().source(d2);
line.style('line', function(attrs, data, i) {
return { smooth: true, lineWidth: 3, strokeColor: '#0a0' };
});
line.style('point', function(attrs) {
return { display: 'none' };
});
```
<img src="https://static001.geekbang.org/resource/image/b3/38/b3b23baea83a11635ece2ec92911fc38.jpg" alt="">
这个图表分为三个层次:第一层,我们用曲线描绘气温与平均露点的关系,随着气温升高,平均露点温度也会升高,二者的总体趋势保持一致。第二层,就是我们保留的散点图,我们可以通过它看出具体某一次记录的温度和露点数据,还可以从分布规律看出相关性的强度,因此它能够表达的信息比曲线图还要多一些。第三层,我们使用了坐标轴和辅助线作为背景。总之,像这样层次分明的图表,非常有助于我们快速理解图表上的信息。
## 选择合适图表,直观表达信息
建立视觉层次是第一种集中注意力的方法,第二种方法其实和它有点类似,就是用合适的图表更加直观地去表达信息。
还记得我们在32节课中讲的公园游客散点图吗我们将男游客和女游客分别标记之后可以看到不同区域在某个时刻的游客性别分布比如下图就是公园12点游客分布情况。
<img src="https://static001.geekbang.org/resource/image/51/be/5138f18cc2c7f86fba3ff77d9d564cbe.jpeg" alt="" title="公园12点游客散点图">
不过,散点图虽然能够一眼看出不同性别游客在四个区域的大致分布,但不够直观。如果我们要更精细地分析的话,还是应该对数据呈现方式进行改进。
在表达某个单组变量的分布状况的时候,使用饼图是一个比较直观的方式。
比如我们可以用一组饼图来表示中午12点公园游客在四个区域的性别分布让它对应上面那张散点图。
第一步是处理数据。我们就以给12点的游客分类为例。我们根据游客所在的区域和性别来统计人数对应到下面的代码就是countPeople函数中实现的逻辑。
```
function count(d, dataset) {
let place;
if(d.x &lt; 300 &amp;&amp; d.y &lt; 300) {
place = 'square';
} else if (d.x &gt;= 300 &amp;&amp; d.y &lt; 300) {
place = 'sections';
} else if (d.x &gt;= 300 &amp;&amp; d.y &gt;= 300) {
place = 'garden';
} else {
place = 'playground';
}
dataset[place] = dataset[place] || [
{
gender: '男游客',
people: 0,
},
{
gender: '女游客',
people: 0,
}
];
if(d.gender === 'f') {
dataset[place][0].people++;
} else {
dataset[place][1].people++;
}
return dataset;
}
function groupData(data) {
const dataset = {};
for(let i = 0; i &lt; data.length; i++) {
const d = data[i];
if(d.time === 12) {
const p = count(d, dataset);
}
}
return dataset;
}
const dataset = groupData(data);
```
经过这么分类之后,现在的数据集看起来就是下面这样:
<img src="https://static001.geekbang.org/resource/image/20/52/20aa36362508cb80f5c80bd0dc38e352.jpg" alt="">
第二步我们用qchars来创建四个饼图。在图中我们用橙色表示女游客绿色表示男游客。这样我们就能明显看出四个区域中男、女游客的分布情况了。具体的代码如下
```
const { Chart, Pie, Legend, Tooltip, theme} = qcharts;
theme.set({
colors: ['#71dac7', '#d57a77'],
});
Object.entries(dataset).forEach(([key, dataset]) =&gt; {
const chart = new Chart({
container: `#${key}`
});
chart.source(dataset, {
row: 'gender',
value: 'people',
text: 'gender'
});
const pie = new Pie({
radius: 0.7,
animation: {
duration: 700,
easing: 'bounceOut'
}
});
const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });
const toolTip = new Tooltip();
chart.append([pie, legend, toolTip]);
});
```
<img src="https://static001.geekbang.org/resource/image/bd/b8/bd61e696aaee79304f7ef69d65b74db8.jpeg" alt="">
虽然饼图表示的结果非常简单和直观,但它也有缺点,就是一张饼图实际上能表示的信息很少。一个饼图一次只能表示一组单维度数据,而性别又只有男和女两种,所以我们为了表示四个区域,不得不用四张饼图,这会非常麻烦。
那我们可以尝试用一张图表来表示更多维度的信息吗?这当然可以。我们可以尝试把前面的四张饼图合并成一张**嵌套饼图**,它由两个饼状图组成,中间小的饼状图是女性在四个区域的分布情况,大的饼图是男性在四个区域的分布情况。
<img src="https://static001.geekbang.org/resource/image/77/93/7787c6b16acd23dc3272a7e5227eb893.jpeg" alt="">
它的具体实现方法是首先我们在前面groupData的基础上对数据进行进一步处理将数据对象扁平化然后添加place属性。
```
const dataset = [];
Object.entries(groupData(data)).forEach(([place, d]) =&gt; {
d[0].place = `${place}: 男`;
d[1].place = `${place}: 女`;
dataset.push(...d);
});
```
这样处理数据之后,新的数据结构如下:
<img src="https://static001.geekbang.org/resource/image/e3/4d/e3fa5dc8468bd6907115651074997c4d.jpg" alt="">
然后,我们可以在这个数据结构的基础上绘制两个饼图,代码如下:
```
const { Chart, Pie, Legend, Tooltip, theme} = qcharts;
const chart = new Chart({
container: `#container`
});
chart.source(dataset, {
row: 'place',
value: 'people'
});
const ds = chart.dataset;
const pie = new Pie({
radius: 0.4,
pos: [0, 0]
}).source(ds.selectRows(dataset.filter(d =&gt; d.gender === '女游客').map(d =&gt; d.place)));
const pie2 = new Pie({
innerRadius: 0.5,
radius: 0.7
}).source(ds.selectRows(dataset.filter(d =&gt; d.gender === '男游客').map(d =&gt; d.place)));
const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });
chart.append([pie2, pie, legend]);
```
这样我们就用一张图表直观地显示了中午12点公园内四个区域中男女游客的分布情况了。不过实际上这张图表和前面的四个饼图表达的信息不同那四张饼图表达的是某个区域男女游客分布的情况而这张图则是表达男女游客分别在四个区域的分布情况它们正好是互补的。
那么有没有办法将游客性别和游客区域的分布情况融合在一起表达呢?这也是可以的。我们可以用堆叠的直方图,或者更加美观的南丁格尔玫瑰图来表达。这里,我选择用南丁格尔玫瑰图。
南丁格尔玫瑰图是一种圆形的直方图,用半径来表示数量和数据之间的区别。在这一张图上我们可以看出,四个区域的总人数分布,以及每个区域男女游客数量分布。
与上面的嵌套饼图一样我们也要先把数据扁平化不过这里要稍微修改了一下place属性。因为我们要将数据堆叠所以让男女游客数据里在同一个区域的游客统计的place属性保持一致就行了。
```
const dataset = [];
Object.entries(groupData(data)).forEach(([place, d]) =&gt; {
d[0].place = place;
d[1].place = place;
dataset.push(...d);
});
```
明白了原理,我们就可以绘制出南丁格尔玫瑰图了。
```
const { Chart, PolarBar, Legend, Tooltip, theme} = qcharts;
const chart = new Chart({
container: `#container`
});
theme.set({
colors: ['#71dac7', '#d57a77'],
});
chart.source(dataset, {
row: 'gender',
value: 'people',
text: 'place',
});
const bar = new PolarBar({
stack: true,
radius: 0.8,
groupPadAngle: 15,
}).style(&quot;pillar&quot;, {
strokeColor: &quot;#FFF&quot;,
lineWidth: 1,
});
const tooltip = new Tooltip();
const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });
chart.append([bar, tooltip, legend]);
```
<img src="https://static001.geekbang.org/resource/image/cc/35/cc13d019daa4df3b52b844cc988f4435.jpeg" alt="">
具体的方法讲完了,我们做个总结。使用南丁格尔玫瑰图,我们能把人群在公园区域的分布和性别分布规律显示在一张图上,让更多的信息呈现在一张图表上。这既能节省空间,也便于人们高效率地获取更多信息。但是,太多的信息聚集也会显著增加图表复杂度,减少图表的直观程度。就像这张南丁格尔图一样,它虽然简单,但直观性仍然不如之前用四个饼图和一个嵌套饼图的表达形式。所以在我们实际可视化项目中,需要根据实际情况选择合适的解决方案,大部分情况下,我们需要在直观性和信息聚集程度上做一个取舍。
## 改变图形属性,强化数据差异
除了直观表达信息外,我们还可以采用一些其他的手段,比如,改变颜色、大小、形状等等,以此来强化数据之间的差异,这也是增强可视化图表中信息表达的一种手段。
这次我们就不具体实现了,直接来看一个成熟的例子。比如说,北大可视化实验室设计的全国新冠病毒肺炎疫情晴雨表,就是使用颜色和方块大小,将增量数据很直观地表达出来,从而宏观地呈现出疫情的发展态势,这对于揭示疫情拐点来说非常有帮助。
<img src="https://static001.geekbang.org/resource/image/2a/6b/2a7d805c3b787bd47c42da227b8c746b.jpg" alt="">
颜色和面积、折线图的方向、直方图的高度差,这些方式都能比较鲜明地体现出数据之间差异。除此之外,我们在绘制图表的时候,还可以使用背景网格线,来辅助用户观察数据间的差异,发现数据之间的变化规律。
说到强调数据差异,我还想给你说一种比较完美的图表,它就是股市中常用的蜡烛图,又叫做**K线图**。<br>
<img src="https://static001.geekbang.org/resource/image/40/4c/405837d612ae534b634bb3ae0d05c74c.jpeg" alt="">
我要先说明一点我不鼓励任何人进行投机性的炒股。但不得不说股市里的K线图是一个非常成功的可视化案例这个图表用颜色来强化数据的增减还包含了许多其他有用的信息让想要了解股市市场的人能够从中分析出商品或者股票的价格走势再做出相应的决策。
总之,从这些优秀的案例中我们知道,在可视化实现中,我们应该重视数据之间的比较,用一些图形属性来强调数据的差异,这对加强图表的表现力非常有效。
## 要点总结
这节课,我们学习了让可视化设计更加清晰的三种方法。首先,我们应该学会建立视觉层次,信息有主次之分,我们要把重要的信息突出显示,减少次要信息对比,以及干扰信息的存在感。
其次,我们要学会用合适的图表来直观地表达信息,比如说,一般情况下,我们会用饼图来描述变量值的分布,用嵌套饼图来展现更多信息,用南丁格尔玫瑰图聚合多维度信息。
最后,我们还应该重视数据之间的比较。大部分情况下,我们会使用一些图形属性,比如更改图形颜色、形状等等,来强调数据之间的差异,这样能够增强图表的表现力。
总的来说,可视化设计的许多方法,其实都是源于实践经验的积累。想要学好,真的没有捷径可走,今天我讲的这三种方法也都是特例。学会它们之后,你还是要多练习,争取做到举一反三,这样才能在可视化设计这条路上走得更远。
## 小试牛刀
你可以利用我放在[GitHub](https://github.com/akira-cn/graphics/tree/master/data/weather)仓库里的天气和空气质量数据,设计出可视化案例,展现出和散点图不一样的内容吗?比如说,你可以让图表展现不同月份下,晴天和雾霾天的分布率。
当然,如果你有更好的创意,我非常鼓励你把它们分享出来,期待在留言区看到你的作品。今天就讲到这里,我们下节课见!
## 源码
[示例代码详见GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data)

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="36 | 设计(二):如何理解可视化设计原则?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/7e/0ff97f142cb6975efce0f0b5423bd77e.mp3"></audio>
你好,我是月影。
工作中,很多新人在进行可视化设计的时候,往往因为想要表达的内容太多,或者不知道该怎么突出重点信息,而设计不出好看的图表。所以他们总会来问我,有没有可以用来参考的设计原则啊?
今天,我就把我从实战中总结出的一些经验,整理出的基本设计原则分享给你,主要有四个,分别是简单清晰原则、视觉一致性原则、信息聚焦原则,以及高可访问性原则。接下来,我们就一一来说。
## 简单清晰原则
当我们刚开始进行可视化设计的时候总会认为,只有用尽可能多的数据,才能做出十分酷炫的效果。但实际上,可视化真正的价值并不是这些看上去酷炫的效果,而是准确地表达信息内容。一味地堆砌数据,只会让真正有用的信息淹没在干扰信息里,从而影响人们解读信息中真正有价值的内容。
因此,简单清晰原则是可视化设计中最重要的一条基本原则。下面,我们来看一个反面例子。
[<img src="https://static001.geekbang.org/resource/image/49/6d/490ac56e5fc4198acc68e5d0c703e86d.jpeg" alt="" title="图片来源cloud.tencent.com">](https://cloud.tencent.com/developer/article/1092742)
这是国际茶叶委员会制作的全球茶叶消费排行榜图表,这张图表本身其实问题不是很大。不过,如果引用这张图表是为了说明喝茶最多的不是中国人,就有点太复杂了。我相信你第一眼甚至看不到中国的数据在哪,就更别说看懂这张图了。
另外这张图底部的直方图表示消费上方的连线和最上面的横条表示什么也不够清晰。如果我们仔细研究还是可以看出最上方的横条表示茶叶产量其中33.7%供应国内中国人均只消费了566克茶叶。由此我们可以得出的结论是中国是产茶大国但茶叶消费数量不是最多的茶叶更多会出口给印度、越南、土耳其和欧洲的一些国家。
那这张该怎么优化呢?首先,我们要明确这张图到底要让我们知道什么。如果这张图想让我们知道中国产茶和茶叶销量的结论,那么我们可以把中国的部分用更加醒目的颜色标记出来。如果只需要研究茶叶消费,那么我们可以完全忽略上面关于生产的部分,直接用更简单的销量直方图展示图表结果。
其次,我们完全不需要列出这么多的国家,列出更少的国家可以让用户更快地抓住重点。所以我们直接用下面的简单的直方图表达,就可以表示我们要表达的内容了。
<img src="https://static001.geekbang.org/resource/image/bd/ff/bd1076146c0b00ba49b818cfyya0e6ff.jpeg" alt="">
如果想把产茶的信息也包含进来,我们可以再增加一个饼图。
<img src="https://static001.geekbang.org/resource/image/4a/03/4a9a6d569bdea90d44218947689e2603.jpeg" alt="">
这两张图加起来已经足够明确地表示出我们想要表达的信息了,也比原始的图表更加简单直观。当然,你也可能觉得还是原始的图信息量大,显得更加有用,这一点就见仁见智了,可视化设计的好坏本来就没有定论,原则也只是参考。
## 视觉一致性原则
几乎在前面的所有实战案例里,我都强调过颜色的运用。在可视化中,颜色对于强化信息有着非常大的帮助。配色良好的图表,不仅看起来赏心悦目,也能帮助我们快速定位到想要关注的信息。
虽然严格上来说配色是UI设计师该负责的事儿但是作为可视化工程师我们也应该了解一些基本的配色原则。一般来说有两种比较常用的配色方案分别是互补色方案和同色系方案。
第一种配色方案是,当想要突出数据之间的差异时,我们可以用**互补色**来增强对比效果。那么什么是互补色呢?你还记得我们在[第10节课](https://time.geekbang.org/column/article/260922)里学过的色彩表示吗其中HSV颜色表示法是用色相HUE、饱和度Saturation和明度Value来表示色值的。所谓的互补色就是指在饱和度和明度相同的情况下色相值相差180度的一对颜色。因为互补色色相差距最大所以它们并列时会产生强烈的视觉对比效果这样能够起到强调差异的作用。
当然我们实际进行数据对比的时候并不会严格要求两个颜色是差异180度的互补色而是会采用差异较大的差值比较接近180度的两种颜色这样也算是互补色。下面这张图就使用了红、绿互补色的配色方案。
<img src="https://static001.geekbang.org/resource/image/1d/bf/1d7b028610e569f539bf86fdef5b70bf.jpeg" alt="">
另外一种配色方案是采用同色系,利用不同深浅的同色系颜色来表示不同的数据。同色系方案的对比没有这么强烈,它从视觉上给人的感觉更柔和,而且色彩的一致也能够减少我们看图表时的视觉疲劳,从而让人保持注意力集中,帮助我们理解图表信息。下面这张图就采用同色系配色方案。
<img src="https://static001.geekbang.org/resource/image/82/9b/82456812e205a9bb737d72efd272be9b.jpeg" alt="">
在具体项目中,使用互补色还是同色系方案不是绝对的,我们要看具体的应用场景:如果你想要突出数据项之间的差异,那么采用对比色方案;如果你想要让人长时间关注,尤其是一些复杂的大型图表,那么采用同色系方案就是更好的选择。
## 信息聚焦原则
在前面的课程中,我们已经知道,长度、高度、大小、形状、颜色、透明度等等都可以用来表示变量。这样,我们就能在一张图上表示多元变量,同时我们改变这些属性还可以让信息更加聚焦。
比如,下面这张示例图表就是一个非常好的[例子](https://echarts.apache.org/examples/zh/editor.html?c=wind-barb)。
<img src="https://static001.geekbang.org/resource/image/f2/55/f23c22f1311458875f6fe56d33146855.jpeg" alt="">
这是一张天级预报的图表图表上同时显示了温度、天气、风向、风速、浪高这些变量每个变量都采用了不同的形式来展示区分度很好内容非常清晰也很聚焦。另外图表上的除了用不同的y轴高度来表示风速之外还采用红色、黄色、绿色这三种颜色标记了不同的风速等级这就是用两种方式表示同一个元素。
一般来说,我们用一个图属性来表示一个变量,比如箭头方向表示风向,高度表示风速,但如果我们要强调某个变量的时候,我们也可以用超过一个属性来表示,比如用颜色和高度同时表示风速,这就起到了强调的作用。类似的[示例](https://echarts.apache.org/examples/zh/editor.html?c=bar3d-punch-card&amp;gl=1)还有下面这张图表。
<img src="https://static001.geekbang.org/resource/image/70/88/7010097cc45c45e353f7ae1b4a425188.jpeg" alt="">
在这张图表上,为了强调产品的生产量,我们用立方体的高度和颜色同时表示了这一个维度的变量。
总之,我们可以将相关的多元变量聚合在一张图表上,用来更聚焦地表达多元信息。不过,这么做的时候,我们要确定我们需要的信息真的包括了这些多元变量,并且它们彼此是有相关性的,否则我们还是应该考虑将它们拆分或者过滤掉无用的信息,这样才不违背前面的简单清晰原则。
## 高可访问性原则
如果你是一名前端工程师,我相信你应该听过网页设计的可访问性,或者说无障碍原则。因为可视化本身和前端密不可分,所以在可视化设计中,我们也同样需要考虑设计高可访问性。
可视化的无障碍设计主要体现在色彩系统上。要知道我们的用户可能包含视觉障碍人群而且我们的图表可能呈现在不同的设备上从高端的4K显示屏、普通液晶显示器到投影仪、彩色打印机或者黑白打印机。
[<img src="https://static001.geekbang.org/resource/image/a2/52/a2d457yy89d62d5dfc96108b5fbe4252.jpeg" alt="">www.woshipm.com](http://www.woshipm.com/pd/719815.html)
因此,即使我们设计的颜色在我们看来已经足够有差异性了,也可能在一些低色彩分辨率的设备上表现得不那么友好,甚至会给视觉障碍人士带来困扰。
这就要求我们在设计上要尽量避免对视觉障碍人士不太友好的配色,比如,用黄色和黄绿色来区分内容。在使用同色系配色方案的时候,我们也要注意色彩在明亮度和饱和度上要有足够的差异,以便于在黑白打印机等设备上打印出来的图表也有足够的区分度。
还有一种情况是,如果重要的变量要依赖于颜色,而你又不能保证色彩的可访问性时,采用前面的方法,用其他属性和颜色一起来表示目标变量,这样既能够起到强调作用,也预防了单纯使用颜色对视觉障碍人士带来困扰。
除此之外我们还可以用一些色彩检查工具来辅助配色。在Photoshop中选择**视图-&gt;校样设置-&gt;红色盲型/绿色盲型**之后就能看到我们设计的图表颜色在视觉障碍人群眼中的效果了。如果你不怎么用Photoshop还可以访问 [color-blindness.com](https://www.color-blindness.com/coblis-color-blindness-simulator/)的在线服务,通过上传图片来检查你的配色。
除了颜色之外,文字提示信息也需要考虑可访问性。首先,提示字体的大小要适中,并且足够清晰。其次,要对于老年人和视力不好的人群提供缩放字体的功能,这样能够在很大程度上改善可访问性。
虽然网页本身提供了文字内容缩放的功能但是图表库可能没有考虑文字缩放后的布局呈现。比如Echarts的这个图表如果我们放大浏览器上的文字它们就会叠在一起完全不可阅读这就是一个可访问性不太好的反面案例。
<img src="https://static001.geekbang.org/resource/image/bf/ff/bf59e80aa18e8035e1283165416974ff.jpeg" alt="">
总之,图表设计的目的是给人阅读的,我们只有在可访问性上下功夫,才能让更多的人读懂图表,更好地发挥出它的价值。
## 要点总结
这一节课,我们讨论了可视化设计的四个基本原则。
首先是简单清晰原则,要明确我们的图表要表达的信息,只呈现给用户希望让用户看到的内容,不要增加太多干扰项,保持图表的简洁清晰,有助于读者快速获得有用的信息。
其次是视觉一致性原则,在配色的时候,配色原则要统一。我们可以采用补色方案,也可以采用同色系方案,它们各有利弊。补色方案可以增强对比,在我们要强调数据差异性的时候,一般采用补色方案,而同色系方案在视觉上更柔和,不容易疲劳,在比较复杂的或者需要用户长时间关注的图表里,采用同色系方案是一种更好的选择。
第三是信息聚焦原则,我们可以用多个属性将多元信息聚集在一个图表里,让读者能够更高效率地获取信息。不过我们要确保信息确实是有关联的,需要且易于读者理解的,否则的话,我还是建议将它们拆分到不同图表,这样才不违背简单清晰的原则。
最后,图表设计要考虑可访问性,也就是无障碍原则。在配色上要考虑到视觉障碍人士,尽量不要让他们产生困扰,另外如果网页能支持文字的缩放功能,就会对老年人和视力不佳的人群更友好,从而让更多人读懂图表,更好地发挥出它的价值。
## 小试牛刀
在前面几节课中,我留下了一些练习题,不知道你都完成了吗?如果你完成了,你可以回顾一下自己实现的可视化图表,看看是否符合或者违反了我们今天讲的四个原则。如果违反了某些原则,你可以尝试去改进,再把改进前、后的对比结果分享到留言区。
如果你已经完成得非常好了没什么可改进了也可以用我在GitHub仓库提供的[两份数据](https://github.com/akira-cn/graphics/tree/master/data/weather),来试着实现符合这四个原则的高质量可视化图表。
这四个简单又实用的设计原则,你学会了吗?如果你的朋友正在为设计图表而困扰,欢迎你把这节课分享给他,说不定,问题就解决了呢?好了,期待在留言区看到你的作品。今天就讲到这里,我们下节课见!
## 推荐阅读
[Echarts 图表库](https://echarts.apache.org/zh/index.html)是一个不错的开源图表库,其中有许多[经典的例子](https://echarts.apache.org/examples/zh/index.html),我建议你认真看一下,想一想它们是否符合或违反了前面总结的四个原则,并试着学习、理解和使用这些图表。

View File

@@ -0,0 +1,368 @@
<audio id="audio" title="37 | 实战(一):如何使用图表库绘制常用数据图表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/77/246c9yya0d28e812d93fa9b8ce626377.mp3"></audio>
你好,我是月影。
图表是我们在可视化中展示数据常用的方式之一,常见的有柱状图、折线图、饼图等等,我们之前也都一一实现过。如果我让你总结一下图表的实现方法,你能说出来吗?总结不出来也没关系,这节课,我们就一起梳理一下实际项目中常用的制图方法。
实现图表有两种方式,一是使用现成的图表库,二是使用数据驱动框架,前者胜在简单易用,后者则更加灵活。所以,我们会用两节课的时间分别来讲,使用图表库和使用数据驱动框架的制图思路。
因为使用图表库更加简单,所以这一节课我们先来讲怎么使用它实现图表。另外,这节课的实践性比较强,我建议你跟着我的讲解一块儿动手去写,这样能更快地理解课程的内容。
## 课前准备
说了这么多我们今天到底会用哪些图表库呢我们主要会使用我们比较熟悉的SpriteJS和QCharts来绘制图表。其他的图表库例如更常用的[ECharts](https://echarts.apache.org/),它在图表实现上的原理和用法和我们今天讲的基本相同,所以学完了今天的内容,你完全可以根据它的教程文档去自学。
好了确定了要使用的工具之后我们就要配置和加载SpriteJS和QCharts。具体怎么做呢
最简单的方式是我们直接通过CDN用script标签来加载SpriteJS和QCharts打包好的文件。
```
&lt;script src=&quot;https://unpkg.com/spritejs/dist/spritejs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://unpkg.com/@qcharts/core@1.0.25/dist/index.js&quot;&gt;&lt;/script&gt;
```
如果是完整的工程项目的话你也可以使用webpack来打包和加载模块。这里有一个[Quick Start](https://github.com/qcharts/quickstart)你可以fork这个项目按照其中的配置项来设置。当加载完成之后我们就可以开始绘制基础的图表了。
## QCharts图表的基本用法
QCharts图表由图表Chart对象及其子元素构成。其中图表对象的子元素包含图形Visual和其他插件Plugin)。图形是必选元素,其他的插件都是可选元素。
图表在构建的时候需要传入一个DOM元素这个元素可以是页面上任意一个块级元素QCharts用这个元素作为容器来创建Canvas画布并其还会根据容器的大小来设置Canvas画布的大小。默认情况下图表会根据画布大小来自动适配。
接下来我们看一下具体的操作。首先我们创建一个图表对象设置它的容器设置成一个id为app的元素。
```
const { Chart, Line } = qcharts;
const chart = new Chart({
container: '#app'
});
```
创建了这个容器之后,我们就可以为它绑定数据,假设我们绑定一份销售数据。
```
const data = [
{ date: '05-01', category: '图例一', sales: 15.2 },
{ date: '05-02', category: '图例一', sales: 39.2 },
{ date: '05-03', category: '图例一', sales: 31.2 },
{ date: '05-04', category: '图例一', sales: 65.2 },
{ date: '05-05', category: '图例一', sales: 55.2 },
{ date: '05-06', category: '图例一', sales: 75.2 },
{ date: '05-07', category: '图例一', sales: 95.2 },
{ date: '05-08', category: '图例一', sales: 100 }
];
chart.source(data, {
row: 'category',
value: 'sales',
text: 'date'
});
```
如上面代码所示我们将数据内容与图表对象通过chart.source方法绑定起来。绑定数据的时候我们可以指定数据的行、数值和文本这些设置会被图形Visual和其他插件使用。这里我们将行设为category这个字段数值设为sales字段文本设为data字段。
设置之后图表并没有马上显示出来。这是因为我们还没有给图表指定图形。QCharts支持多种图形对象比如Line、Area、Bar等等。假设我们选择了Line对象那我们只需要创建它然后将它添加到chart对象的子元素中就可以了。
```
const line = new Line();
chart.append([line]);
```
这样,我们就让图形显示出来了,效果如下:
<img src="https://static001.geekbang.org/resource/image/b6/9f/b6a87f9984082b6a02a99b484262ef9f.jpeg" alt="">
了解了QCharts图表的基本用法之后我们一起进入实践环节吧。
## QCharts绘制折线图、面积图、柱状图和饼图的方法
我们先来讲讲折线图、面积图、柱状图和饼图的实现方法,因为之前已经实现过很多次了,所以理解起来比较容易。
首先是折线图它是可视化中最常用的图表之一。刚才我们用Line图形绘制了一条折线但它还不是完整的折线图。完整的折线图除了有图形以外还要有表示数据的坐标轴、提示信息和图例这些都需要在QCharts中由插件来完成。
接下来,我们就继续给前面的代码添加元素。首先,我们给它增加两个坐标轴,分别是底部和左侧的。
我们通过Axis类来创建axis对象默认的坐标轴是在底部不用修改再通过.style改变它的样式属性比如我们将"grid"设置为false这样画布上不会显示纵向的网格线。
```
const axisBottom = new Axis().style(&quot;grid&quot;, false);
```
然后我们同样创建一个axis对象通过设置orient: “left” 让它朝左,这样就成功创建了左侧的坐标轴。同样,我们也要把它样式中的"axis"属性设置为false这样画布上就不会显示坐标轴的实线了。
```
const axisLeft = new Axis({ orient: &quot;left&quot; })
.style(&quot;axis&quot;, false);
```
最后我们将两个坐标轴添加到chart对象的子元素中去就可以将坐标轴显示出来。
<img src="https://static001.geekbang.org/resource/image/62/84/62c0556935f2756c6eac5a88057eb784.jpeg" alt="">
添加完坐标轴之后我们再添加图例Legend和提示Tooltip。最简单的方式是我们直接创建两个对象然后将它们添加到charts对象的子元素中。
```
const legend = new Legend();
const tooltip = new Tooltip();
chart.append([line, axisBottom, axisLeft, legend, tooltip]);
```
添加了图例和提示信息后的图表如下图所示:
<img src="https://static001.geekbang.org/resource/image/58/85/5896a90b8979ecaf081726195f632985.jpeg" alt="">
**接下来我们再来说说怎么用QCharts绘制面积图和柱状图。**
学会了折线图的绘制方法我们就可以用相同的思路非常简单地绘制其他图形了比如说我们可以简单将Line对象换成Area或Bar对象这样就可以用同样的数据绘制出面积图或柱状图。这是为什么呢
<img src="https://static001.geekbang.org/resource/image/2b/bf/2b29397b83cdbe7630bd88982d717cbf.jpeg" alt="" title="将 Line 改成 Area 绘制出面积图">
<img src="https://static001.geekbang.org/resource/image/c7/fd/c70fe007a4e74124a8e23afbb59175fd.jpeg" alt="" title="将 Line 改成 Area 绘制出柱状图">
因为像折线图、面积图、柱状图这些表现形式不同的图表,它们能够接受同样格式的数据,只是想要侧重表达的信息不同而已。一般来说,折线图强调数据变化趋势,柱状图强调数据的量和差值,而面积图同时强调数据量和变化趋势。在实际项目中,我们要根据不同的需求选择不同的基本图形。
如果要强调整体和局部比例,我们还可以选择绘制饼图。与折线图、面积图、柱状图相比,饼图不用配置图例,因为图例会自动生成,而且饼图也不需要坐标轴,所以使用起来更加简单。
```
const data = [
{ date: '05-01', sales: 15.2 },
{ date: '05-02', sales: 39.2 },
{ date: '05-03', sales: 31.2 },
{ date: '05-04', sales: 65.2 },
{ date: '05-05', sales: 55.2 },
{ date: '05-06', sales: 75.2 },
{ date: '05-07', sales: 95.2 },
{ date: '05-08', sales: 100 }
];
chart.source(data, {
row: 'date',
value: 'sales',
text: 'date'
});
const pie = new Pie();
const legend = new Legend();
const tooltip = new Tooltip();
chart.append([pie, legend, tooltip]);
```
上面的代码用同样的数据绘制了一个饼图。
<img src="https://static001.geekbang.org/resource/image/12/7d/12dffaec3578995169de1db76491947d.jpeg" alt="">
## QCharts绘制雷达图、仪表盘和玉玦图、南丁格尔玫瑰图
讲完了这些常见的基础图表,我们再来看几个比较有趣的图表。
首先是雷达图,它一般用来绘制多组固定数量的数据,可以比较直观地显示出这组数据的特点。我们经常会在游戏中看见它的应用,比如,下面这张图表就显示了三国武将诸葛亮的各方面数据。
<img src="https://static001.geekbang.org/resource/image/96/ae/96b37e3a3267470ce6a95e3c358dcbae.jpeg" alt="">
雷达图的实现代码如下:
```
const data = [
{ date: '体力', category: '诸葛亮', sales: 100 },
{ date: '武力', category: '诸葛亮', sales: 69 },
{ date: '智力', category: '诸葛亮', sales: 100 },
{ date: '统帅', category: '诸葛亮', sales: 95 },
{ date: '魅力', category: '诸葛亮', sales: 95 },
{ date: '忠诚', category: '诸葛亮', sales: 100 },
];
chart.source(data, {
row: 'category',
value: 'sales',
text: 'date'
});
const radar = new Radar();
radar.style('section', (d) =&gt; ({ opacity: 0.3 }));
const legend = new Legend({ align: ['center', 'bottom'] });
const tooltip = new Tooltip();
chart.append([radar, legend, tooltip]);
```
除此之外,还有一些其他类型的图表,可以用来展示特殊的信息。比较典型的有仪表盘,它可以显示某个变量的进度。
<img src="https://static001.geekbang.org/resource/image/6a/9e/6a21ab85d811fa8a6abyy30ab0516b9e.jpeg" alt="">
仪表盘实现代码比较简单,如下所示:
```
const gauge = new Gauge({
min: 0,
max: 100,
percent:60,
lineWidth: 20,
tickStep: 10
});
gauge.style('title', { fontSize: 36 });
chart.append(gauge);
```
如果我们要显示多个变量的进度,还可以使用玉玦图。
<img src="https://static001.geekbang.org/resource/image/b7/82/b76efc4a8c052fb8d11ba976ef039782.jpeg" alt="">
对应的代码也非常简单,如下所示:
```
const data = [
{
type: '政法干部',
count: 6654
},{
type: '平安志愿者',
count: 5341
},{
type: '人民调解员',
count: 3546
},{
type: '心理咨询师',
count: 4321
},{
type: '法律工作者',
count: 3923
},{
type: '网格员',
count: 5345
}
].sort((a, b) =&gt; a.count - b.count);
chart.source(data, {
row: 'type',
text: 'type',
value: 'count'
});
const radialBar = new RadialBar({
min: 0,
max: 10000,
radius: 0.6,
innerRadius: 0.1,
lineWidth: 10
});
radialBar.style('arc', { lineCap: 'round' });
const legend = new Legend({
orient: 'vertical',
align: ['right', 'center'],
});
chart.append([radialBar, legend, new Tooltip()]);
```
最后是南丁格尔玫瑰图,它可以显示多维度的信息,比如下图就显示了男女游客在公园四个区域内的分布情况。南丁格尔玫瑰图的绘制思路和代码,我们在[第35节课](https://time.geekbang.org/column/article/288323)已经说过,这里就不再多说了。如果你还不熟悉,可以再去回顾一下。
<img src="https://static001.geekbang.org/resource/image/47/a8/47eca31ddbf1cb2423aa3db23bcabca8.jpeg" alt="">
## QCharts绘制图表组合
我们刚才讲的都是在图表上绘制单一变量,那要想在图表上聚合多元变量,比如在一张天气图表上同时展示降水量、气温,我们可以同时绘制多个图形来表示。
我们可以分两种情况来讨论最简单的情况是用相同的图形来绘制不同的变量。这个时候我们可以直接通过不同category来绘制多个图形。比如下面的数据就是用两组category分别绘制了两条折线。
```
const data = [
{ date: &quot;1月&quot;, catgory: &quot;降水量&quot;, val: 15.2 },
{ date: &quot;2月&quot;, catgory: &quot;降水量&quot;, val: 19.2 },
{ date: &quot;3月&quot;, catgory: &quot;降水量&quot;, val: 11.2 },
{ date: &quot;4月&quot;, catgory: &quot;降水量&quot;, val: 45.2 },
{ date: &quot;5月&quot;, catgory: &quot;降水量&quot;, val: 55.2 },
{ date: &quot;6月&quot;, catgory: &quot;降水量&quot;, val: 75.2 },
{ date: &quot;7月&quot;, catgory: &quot;降水量&quot;, val: 95.2 },
{ date: &quot;8月&quot;, catgory: &quot;降水量&quot;, val: 135.2 },
{ date: &quot;9月&quot;, catgory: &quot;降水量&quot;, val: 162.2 },
{ date: &quot;10月&quot;, catgory: &quot;降水量&quot;, val: 32.2 },
{ date: &quot;11月&quot;, catgory: &quot;降水量&quot;, val: 32.2 },
{ date: &quot;12月&quot;, catgory: &quot;降水量&quot;, val: 15.2 },
{ date: &quot;1月&quot;, catgory: &quot;气温&quot;, val: 2.2 },
{ date: &quot;2月&quot;, catgory: &quot;气温&quot;, val: 3.2 },
{ date: &quot;3月&quot;, catgory: &quot;气温&quot;, val: 5.2 },
{ date: &quot;4月&quot;, catgory: &quot;气温&quot;, val: 6.2 },
{ date: &quot;5月&quot;, catgory: &quot;气温&quot;, val: 8.2 },
{ date: &quot;6月&quot;, catgory: &quot;气温&quot;, val: 15.2 },
{ date: &quot;7月&quot;, catgory: &quot;气温&quot;, val: 25.2 },
{ date: &quot;8月&quot;, catgory: &quot;气温&quot;, val: 23.2 },
{ date: &quot;9月&quot;, catgory: &quot;气温&quot;, val: 24.2 },
{ date: &quot;10月&quot;, catgory: &quot;气温&quot;, val: 16.2 },
{ date: &quot;11月&quot;, catgory: &quot;气温&quot;, val: 12.2 },
{ date: &quot;12月&quot;, catgory: &quot;气温&quot;, val: 6.6 },
];
chart.source(data, {
row: &quot;catgory&quot;,
value: &quot;val&quot;,
text: &quot;date&quot;,
});
const line = new Line({ axisGap: true });
...
```
<img src="https://static001.geekbang.org/resource/image/b8/92/b88746440cae3937beb3c638b9bda392.jpeg" alt="">
如果我们想用不同类型的图形来展示多个变量在QCharts中我们只需要创建多个不同的图形对象然后把它们都添加到chart对象的子元素中去就可以了。
```
const ds = chart.dataset;
const d1 = ds.selectRows(&quot;降水量&quot;);
const line = new Line({ axisGap: true })
.source(d1)
.style(&quot;point&quot;, { strokeColor: &quot;#fff&quot; });
const d2 = ds.selectRows(&quot;气温&quot;);
const bar = new Bar().source(d2);
bar.style(&quot;pillar&quot;, { fillColor: &quot;#6CD3FF&quot; });
```
如上面代码所示我们先可以通过chart.dataset拿到通过.source绑定给chart对象的数据集然后通过selectRows分别将降水量和气温数据过滤出来。接着我们分别创建Line和Bar两个图形对象再将降水量和气温数据分别绑定给它们。最后我们将这两个对象同时添加到chart子元素列表里就可以将两个不同类型的图形显示出来了具体的效果如下
<img src="https://static001.geekbang.org/resource/image/f8/62/f8688798100f4a9yyc04c9d34fc79262.jpeg" alt="">
## 要点总结
这节课我们主要学习了QCharts图表库的使用。
QCharts是基于SpriteJS的简单可视化图表库我们通过它可以绘制各种类型的图表。一般来说我们是先创建图表对象然后绑定数据接着添加图形对象以及其他的插件包括图例和提示。通过将图形和插件以子元素的形式添加到图表对象上就能把图表内容最终显示出来了。
我们可以很方便地根据数据特点和业务需要,用数据绘制折线图、面积图、柱状图、饼图、雷达图等图表,还可以绘制特殊的仪表盘和玉玦图。另外,如果要显示多维数据,我们也可以用稍微复杂一些的南丁格尔玫瑰图。
最后,我们还可以把多维度变量聚合在一个图表中来显示不同的图形组合。具体操作是,我们先筛选数据,然后创建不同类型的图形对象,最后将它们都添加到图表对象的子元素中。
总的来说我们今天讲的其实都是QCharts图表库最基础、最常用的方法。QCharts还提供了众多其他类型的图表以及灵活操作图表样式的API。如果你有兴趣继续钻研可以通过我课后给出的参考链接进一步学习。
## 小试牛刀
你学会了使用不同图表来表达不同数据了吗你可以试着使用GitHub仓库里的北京市天气数据和空气质量数据实现一个温度、湿度、风速、空气质量的聚合图表吗如果用来展示温度、湿度、风速和空气质量的图形都不相同就更好了。
这些常用的制图方法你都学会了吗?下节课,我们会接着来学,怎么使用数据驱动框架来表达不同数据,挑战才刚刚开始呢,我们下节课再见!
## 源码
课程代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/qcharts)
## 推荐阅读
[QCharts官网](https://www.qcharts.cn/#/home)

View File

@@ -0,0 +1,328 @@
<audio id="audio" title="38 | 实战(二):如何使用数据驱动框架绘制常用数据图表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/f0/2711de9972222c1ea5b9504809df6cf0.mp3"></audio>
你好,我是月影。
上一节课,我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单,基本上我们只需要准备好数据,然后根据图形需要的数据格式创建图形,再添加辅助插件,就可以将图表显示出来了。
图表库虽然使用上简单但灵活性不高对数据格式要求也很严格我们必须按照各个图表的要求来准备数据。而且图形和插件的可配置性完全取决于图表库设计者开放的API给开发者的自由度很少。
今天我们就来说说使用数据驱动框架来实现图表的方式。这类框架以D3.js为代表提供了数据处理能力以及从数据转换成视图结构的通用API并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活不受图表类型对应API的制约。不过因为图表库只要调用API就能展现内容而数据驱动框架需要我们自己去完成内容的呈现所以它在使用上就没有图表库那么方便了。
使用图表库和使用数据驱动框架的具体过程和差别,我这里准备了一个对比图,你可以看一下。
<img src="https://static001.geekbang.org/resource/image/a7/bc/a7ed3169666071df64de4e4bab239fbc.jpg" alt="" title="使用图表库(左)和使用数据驱动框架(右)渲染图表的流程对比">
不过这么讲还是比较抽象,接下来,我们还是通过绘制条形图和力导向图,来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。
## 课前准备
与上一节课差不多我们还是需要使用SpriteJS只不过今天我们将QCharts换成D3.js。
```
&lt;script src=&quot;https://unpkg.com/spritejs/dist/spritejs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://d3js.org/d3.v6.js&quot;&gt;&lt;/script&gt;
```
使用上面的代码我们就能加载SpriteJS和D3.js用它们来完成常用图表的绘制了。
## 使用D3.js绘制条形图
我们先来绘制条形图,条形图与柱状图差不多,都是用图形的长度来表示数据的多少。只不过,横向对比的条形图,更容易让我们看到各个数据之间的大小,而纵向的柱状图可以同时比较两个变量之间的数据差别。
用D3.js绘制图表不同于使用Qcharts我们需要创建SpriteJS的容器。通过前面的课程我们已经知道SpriteJS创建场景Scene对象作为其他元素的根元素容器。接下来我们一起看下具体的操作过程。
```
const container = document.getElementById('stage');
const scene = new Scene({
container,
width: 1200,
height: 1200,
});
```
如上面代码所示我们先创建一个Scene对象与QCharts的Chart对象一样它需要一个HTML容器这里我们使用页面上一个id为stage的元素。我们设置了参数width和height为1200也就是把Canvas对象的画布宽高设为1200 * 1200。
接着我们准备数据。与使用QCharts必须要按照格式给出JSON数据不同使用D3.js的时候数据格式比较自由。这里我们直接用了一个数组
```
const dataset = [125, 121, 127, 193, 309];
```
然后我们使用D3.js的方法对数据进行映射
```
const scale = d3.scaleLinear()
.domain([100, d3.max(dataset)])
.range([0, 500]);
```
D3.js在设计上采用了一些函数式编程思想这里的.scaleLinear、.domain和.range都是高阶函数它们返回一个scale函数这个函数把一组数值线性映射到某个范围这里我们就是将数值映射到500像素区间数值是从100到309。
那么这个scale函数要怎么使用呢别着急我们先往下看。
有了数据dataset和处理数据的scale方法之后我们使用d3-selection这是d3中的一个子模块我们是通过CDN来加载d3的所以已经默认包含了d3-selection来创建并选择layer对象。
在SpriteJS中场景Scene可以由多个Layer构成针对每个Layer对象SpriteJS都会创建一个实际的Canvas画布。
```
const fglayer = scene.layer('fglayer');
const s = d3.select(fglayer);
```
如上面的代码所示我们先创建了一个fglayer它对应一个Canvas画布然后通过d3.select(fglayer)将对应的fglayer元素经过d3包装后返回。
接着我们在fglayer元素上进行迭代操作。你先认真看完代码我再来解释。
```
const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];
const chart = s.selectAll('sprite')
.data(dataset)
.enter()
.append('sprite')
.attr('x', 450)
.attr('y', (d, i) =&gt; {
return 200 + i * 95;
})
.attr('width', scale)
.attr('height', 80)
.attr('bgcolor', (d, i) =&gt; {
return colors[i];
});
```
我们从第2行代码开始看其中selectAll用来返回fglayer下的sprite子元素对于SpriteJS来说sprite元素是基本元素用来表示一个图形。不过现在fglayer下还没有任何子元素所以selectAll(sprite)本应该返回空的元素但是d3通过data方法迭代数据集也就是之前有5个元素的数组然后通过执行enter()和append(sprite)这样就在fglayer下添加了5个sprite子元素。enter()方法是告诉d3-selection当数据集的数量大于selectAll选中的元素数量时通过append添加元素补齐数量。
从第6行代码开始我们给每个sprite元素迭代设置属性。注意append之后的attr是默认迭代设置每个sprite元素的属性如果是常量就直接设置如果是不同的值就通过迭代算子来设置。迭代算子有两个参数第一个是dataset中对应的数据第二个是迭代次数从0开始因为有五项数据所以会迭代5次。如果你对jQuery比较熟悉你应该能比较容易理解上面这种批量迭代操作的形式。
最后我们根据数据集的每个数据依次设置一个sprite元素将x坐标值设置为450y坐标值设置为从200开始每个元素占据95个像素值然后将width设置为用scale计算后的数据项的值这里我们就用到前面linearScale高阶函数生成的scale函数直接将它作为算子。我们将height值设为固定的80表示元素的高度。这样一来元素之间就会有 95 - 80即15像素的空隙。最后我们给元素设置一组不同的颜色值。
我们最终显示出来的效果如下图:
<img src="https://static001.geekbang.org/resource/image/8e/c6/8e24ca6f4bfb4byyd71554yydc431bc6.jpg" alt="">
这里我们在画布上显示了五个不同颜色的矩形条,它们对应数组的 125、121、127、193、309。但它还不是一个完整的图表我们需要给它增加辅助信息比如坐标轴。添加坐标轴的代码如下所示。
```
const axis = d3.axisBottom(scale).tickValues([100, 200, 300]);
const axisNode = new SpriteSvg({
x: 420,
y: 680,
});
d3.select(axisNode.svg)
.attr('width', 600)
.attr('height', 60)
.append('g')
.attr('transform', 'translate(30, 0)')
.call(axis);
axisNode.svg.children[0].setAttribute('font-size', 20);
fglayer.append(axisNode);
```
如上面代码所示,我们通过 d3.axisBottom 创建一个底部的坐标。我们可以通过tickValues给坐标轴传要显示的刻度值这里我们显示100、200、300三个刻度。同样我们可以用scale函数将这些数值线性映射到500像素区间值从100到309。
axisBottom本身是一个高阶函数它返回axis函数用来绘制坐标轴不过这个函数是使用svg来绘制坐标轴的。好在SpriteJS支持SpriteSvg对象它可以绘制一个SVG图形然后将这个图形以WebGL或者Canvas2D的方式绘制到画布上。
我们先创建SpriteSvg类的对象axisNode然后通过d3.select选中对象的svg属性进行正常的svg属性设置和创建svg元素操作最终将axisNode添加到fglayer上这样就能将坐标轴显示出来了。
<img src="https://static001.geekbang.org/resource/image/3f/dc/3fbbca34bd9bd53df1d79c64b0ecd3dc.jpg" alt="">
这样我们就实现了一个简陋的条形图。简陋是因为和QCharts的柱状图相比它现在只有图形主体部分和一个简单的x坐标轴缺少y坐标轴、图例、提示信息、辅助网格等信息不过这些用D3.js也都能创建我觉得这部分内容你可以自己试着实现我就不多说了如果遇到问题记得在留言区提问。
总的来说在创建简单的图表的时候使用D3.js比直接使用图表库还是要复杂很多的。但比较好的一点是D3.js对数据格式没有太多硬性要求我们可以直接使用一个简单的数组然后在后面绘图的时候再进行迭代。那麻烦一点的是因为没有现成的图表对象所以我们要自己处理数据、显示属性的映射好在D3.js提供了linearScale这样的工具函数来创建数据映射。
处理好数据映射之后我们需要自己通过d3-selection来遍历元素完成属性的设置从而把图形渲染出来。而且对于坐标轴等其他附属信息d3也没有现成的对象我们也需要通过遍历元素进行绘制。
这里顺便提一下虽然我们使用SpriteJS作为图形库来讲解但d3并没有强制限定图形库所以我们无论是采用SVG、原生Canvas2D还是WebGL又或者是采用ThreeJS等其他图形库都可以进行渲染。只不过d3-selection依赖于DOM操作所以SVG和SpriteJS这种与DOM API保持一致的图形系统使用起来会更加方便一些。
## 使用D3.js绘制力导向图
讲完了用D3.js绘制简单条形图的方法接下来我们看看怎么用D3.js绘制更加复杂的图形比如力导向图。
力导向图也是一种比较常见的可视化图表,它非常适合用来描述关系型信息。比如下图就是一个经典的力导向图应用。
<img src="https://static001.geekbang.org/resource/image/d1/45/d10baf06984e9b356a13e3bd2dc92845.gif" alt="">
我们看到,力导向图不仅能够描绘节点和关系链,而且在移动一个节点的时候,图表各个节点的位置会跟随移动,避免节点相互重叠。
那么究竟如何用D3.js实现一个简单的力导向图呢我们来看一个例子。
力导向图顾名思义我们通过模拟节点之间的斥力来保证节点不会相互重叠。在D3.js中提供了模拟斥力的方法。
```
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d =&gt; d.id)) //节点连线
.force('charge', d3.forceManyBody()) // 多实体作用
.force('center', d3.forceCenter(400, 300)); // 力中心
```
如上面代码所示我们创建一个d3的力模型对象simulation通过它来模拟示例然后我们设置节点连接、多实体相互作用、力中心点。
接着,我们读取数据。这里我准备了一份[JSON数据](https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json)。我们可以用d3.json来读取数据它返回一个Promise对象。
```
d3.json('https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json').then(graph =&gt; {
...
});
```
我们先用力模型来处理数据:
```
simulation
.nodes(graph.nodes)
.on('tick', ticked);
simulation.force('link')
.links(graph.links);
```
接着,我们再绘制节点:
```
d3.select(layer).selectAll('sprite')
.data(graph.nodes)
.enter()
.append('sprite')
.attr('pos', (d) =&gt; {
return [d.x, d.y];
})
.attr('size', [10, 10])
.attr('border', [1, 'white'])
.attr('borderRadius', 5)
.attr('anchor', 0.5);
```
然后,我们再绘制连线:
```
d3.select(layer).selectAll('path')
.data(graph.links)
.enter()
.append('path')
.attr('d', (d) =&gt; {
const [sx, sy] = [d.source.x, d.source.y];
const [tx, ty] = [d.target.x, d.target.y];
return `M${sx} ${sy} L ${tx} ${ty}`;
})
.attr('name', (d, index) =&gt; {
return `path${index}`;
})
.attr('strokeColor', 'white');
```
这里我们依然是用d3-selection的迭代给SpriteJS的sprite和path元素设置了一些属性这些属性有的与我们的数据建立关联有的是单纯的样式。这里面没有特别难的地方我就不一一解释了最好的理解方法是实践所以我建议你亲自研究一下示例代码修改一些属性看看结果有什么变化这样能够加深理解。
将节点和连线绘制完成之后,力导向图的初步结果就呈现出来了。
<img src="https://static001.geekbang.org/resource/image/a1/cb/a17d7c08d84fd101a8d2a991cb61e4cb.gif" alt="">
因为力向导图有一个特点就是在我们移动一个节点的时候其他节点也会跟着移动。所以我们还要实现拖动节点的功能。D3.js支持处理拖拽事件所以我们只要分别实现一下对应的事件回调函数完成时间注册就可以了。首先是三个事件回调函数。
```
function dragstarted(event) {
if(!event.active) simulation.alphaTarget(0.3).restart();
const [x, y] = [event.subject.x, event.subject.y];
event.subject.fx0 = x;
event.subject.fy0 = y;
event.subject.fx = x;
event.subject.fy = y;
const [x0, y0] = layer.toLocalPos(event.x, event.y);
event.subject.x0 = x0;
event.subject.y0 = y0;
}
function dragged(event) {
const [x, y] = layer.toLocalPos(event.x, event.y),
{x0, y0, fx0, fy0} = event.subject;
const [dx, dy] = [x - x0, y - y0];
event.subject.fx = fx0 + dx;
event.subject.fy = fy0 + dy;
}
function dragended(event) {
if(!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = nul
```
其中dragstarted处理开始拖拽的事件这个时候我们通过前面创建的simulation对象启动力模拟记录一下当前各个节点的x、y坐标。因为默认的坐标是DOM事件坐标我们通过layer.toLocalPos方法将它转换成相对于layer的坐标。接着dragged处理拖拽中的事件同样也是转换x、y坐标计算出坐标的差值然后更新fx、fy也就是事件主体的当前坐标。最后我们用dragended处理拖住结束事件清空fx和fy。
接着我们将三个事件处理函数注册到layer的canvas上
```
d3.select(layer.canvas)
.call(d3.drag()
.container(layer.canvas)
.subject(dragsubject)
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
```
这样就实现了力导向图拖拽节点的交互d3会自动根据新的节点位置计算布局避免节点的重叠。
<img src="https://static001.geekbang.org/resource/image/01/f1/016ea964bba39df48d0894e39c45e0f1.gif" alt="">
## 要点总结
这节课,我们主要学习了使用数据驱动框架来绘制图表。
与直接使用图表库不同,使用数据驱动框架不要求固定格式的数据格式,而是通过对原始数据的处理和对容器迭代、创建新的子元素,并且根据数据设置属性,来完成从数据到元素结构和属性的映射,然后再用渲染引擎将它最终渲染出来。
那你可能有疑问了我们应该在什么时候选择图表库什么时候选择数据驱动框架呢通常情况下当需求比较明确可以用图表库并且样式通过图表库API设置可以实现的时候我们倾向于使用图表库但是当需求比较复杂或者样式要求灵活多变的时候我们可以考虑使用数据驱动框架。
数据驱动框架可以灵活实现各种复杂的图表效果我们前面举的两个图表例子虽然只是个例但也会在实战项目中经常用到。除此之外使用D3.js和SpriteJS还可以实现其他复杂的图表比如说地图或者一些3D图表以及我们在前面的课程中实现的3Dgithub代码贡献图就是使用D3.js和SpriteJS来实现的。
D3.js和SpriteJS的使用都比较复杂你是不可能用一节课系统掌握的我们只有继续深入学习并动手实践、积累经验才能在可视化项目中得心应手地使用它们来实现各种各样的可视化需求。
## 小试牛刀
最后我给你出了两个实践题。希望你能结合D3.js和SpriteJS的官方文档花点时间仔细阅读和学习再通过动手实践和反复练习最终掌握它们。
1. 请你完善我们课程中讲到的条形图给它实现y轴、图例和提示信息。
1. 你可以将上一节课用QCharts图表库实现的图表改用D3.js实现吗动手试一试体会一下它们使用方式和思路上的不同。
关于可视化图表的实战课程就讲到这里了,如果你对于图表绘制,还有什么疑问和困惑,欢迎你在留言区告诉我。我们下节课再见!
## 源码
课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/d3-spritejs)
## 推荐阅读
[D3.js的官方文档](https://d3js.org/)
[SpriteJS的官方文档](https://spritejs.org/#/)

View File

@@ -0,0 +1,353 @@
<audio id="audio" title="39 | 实战(三):如何实现地理信息的可视化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/7d/6976f390987yy974bc1a0697a2a19a7d.mp3"></audio>
你好,我是月影。
前段时间,我们经常能看到新冠肺炎的疫情地图。这些疫情地图非常直观地呈现了世界上不同国家和地区,一段时间内的新冠肺炎疫情进展,能够帮助我们做好应对疫情的决策。实际上,这些疫情地图都属于地理位置信息可视化,而这类信息可视化的主要呈现方式就是地图。
在如今的互联网领域地理信息可视化应用非常广泛。除了疫情地图我们平时使用外卖订餐、春运交通、滴滴打车这些App中都有地理信息可视化的实现。
那地理信息可视化该如何实现呢?今天,我们就通过一个疫情地图的实现,来讲一讲地理信息可视化该怎么实现。
假设我们要使用世界地图的可视化来呈现不同国家和地区从2020年1月22日到3月19日这些天的新冠肺炎疫情进展。我们具体该怎么做呢主要有四个步骤分别是准备数据、绘制地图、整合数据和更新绘制方法。下面我们一一来看。
## 步骤一:准备数据
新冠肺炎的官方数据在WHO网站上每天都会更新我们可以直接找到2020年1月22日到3月19日的数据将这些数据收集和整理成一份JSON文件。这份JSON文件的内容比较大我把它放在Github上了你可以去[Github仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/covid-data.json)查看这份数据。
有了JSON数据之后我们就可以将这个数据和世界地图上的国家一一对应。那接下来的任务就是准备世界地图想要绘制一份世界地图我们也需要有世界地图的地理数据这也是一份JSON文件。
地理数据通常可以从开源社区中获取公开数据,或者从相应国家的测绘部门获取当地的公开数据。这次用到的世界地图的数据,我们是通过开源社区获得的。
一般来说地图的JSON文件有两种数据格式一种是GeoJSON另一种是TopoJSON。其中GeoJSON是基础格式它包含了描述地图地理信息的坐标数据。举个简单的例子
```
{
&quot;type&quot;:&quot;FeatureCollection&quot;,
&quot;features&quot;: [
{
&quot;type&quot;:&quot;Feature&quot;,
&quot;geometry&quot;:{
&quot;type&quot;:&quot;Polygon&quot;,
&quot;coordinates&quot;:
[
[[117.42218831167838,31.68971206252246],
[118.8025942451759,31.685801564127132],
[118.79961418869482,30.633841626314336],
[117.41920825519742,30.637752124709664],
[117.42218831167838,31.68971206252246]]
]
},
&quot;properties&quot;:{&quot;Id&quot;:0}
}
]
}
```
上面的代码就是一个合法的GeoJSON数据它定义了一个地图上的多边形区域坐标是由四个包含了经纬度的点组成的代码中一共是五个点但是首尾两个点是重合的
那什么是TopoJSON格式呢TopoJSON格式就是GeoJSON格式经过压缩之后得到的它通过对坐标建立索引来减少冗余数据压缩能够大大减少JSON文件的体积。
因为这节课的重点主要是地理信息的可视化绘制而GeoJSON和TopJSON文件格式的具体规范又比较复杂不是我们课程的重点所以我就不详细来讲了。如果你有兴趣进一步学习可以参考我在课后给出的资料。
这节课我们直接使用我准备好的两份世界地图的JSON数据就可以了一份是[GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-geojson.json),一份是[TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-topojson.json)。接下来,我们会分别来讲怎么使用它们来绘制地图。
## 步骤二:绘制地图
将数据绘制成地图的方法有很多种我们既可以用Canvas2D、也可以用SVG还可以用WebGL。除了用WebGL相对复杂用Canvas2D和SVG都比较简单。为了方便你理解我选择用比较简单的Canvas2D来绘制地图。
首先我们将GeoJSON数据中coordinates属性里的经纬度信息转换成画布坐标这个转换被称为地图投影。实际上地图有很多种投影方式但最简单的方式是**墨卡托投影**也叫做等圆柱投影。它的实现思路就是把地球从南北两极往外扩先变成一个圆柱体再将世界地图看作是贴在圆柱侧面的曲面经纬度作为x、y坐标。最后我们再把圆柱侧面展开经纬度自然就被投影到平面上了。
[<img src="https://static001.geekbang.org/resource/image/cc/f4/cc45e95168fbfaf5bb76df694c13e3f4.jpg" alt="" title="墨卡托投影">](https://zh.wikipedia.org/wiki/%E9%BA%A5%E5%8D%A1%E6%89%98%E6%8A%95%E5%BD%B1%E6%B3%95)
墨卡托投影是最常用的投影方式,因为它的坐标转换非常简单,而且经过墨卡托投影之后的地图中,国家和地区的形状与真实的形状仍然保持一致。但它也有缺点,由于是从两极往外扩,因此高纬度国家的面积看起来比实际的面积要大,并且维度越高面积偏离的幅度越大。
在地图投影之前,我们先来明确一下经纬度的基本概念。经度的范围是-180度到180度负数代表西经正数代表东经。纬度的范围是-90度到90度负数代表南纬正数代表北纬。
接下来我们就可以将经纬度按照墨卡托投影的方式转换为画布上的x、y坐标。对应的经纬度投影如下图所示。
<img src="https://static001.geekbang.org/resource/image/b0/c4/b06a496725cdff471bf531ab3721ddc4.jpg" alt="">
注意精度范围是360度而维度范围是180度而且因为y轴向下所以计算y需要用1.0减一下。
所以对应的换算公式如下:
```
x = width * (180 + longitude) / 360;
y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下
```
其中longitude是经度latitude是纬度width是Canvas的宽度height是Canvas的高度。
那有了换算公式,我们将它封装成投影函数,代码如下:
```
// 将geojson数据用墨卡托投影方式投影到1024*512宽高的canvas上
const width = 1024;
const height = 512;
function projection([longitude, latitude]) {
const x = width * (180 + longitude) / 360;
const y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下
return [x, y];
}
```
有了投影函数之后我们就可以读取和遍历GeoJSON数据了。
我们用fetch来读取JSON文件将它包含地理信息的字段取出来。根据GeoJSON规范这个字段是features字段类型是数组然后我们通过forEach方法遍历这个数组。
```
(async function () {
const worldData = await (await fetch('./assets/data/world-geojson.json')).json();
const features = worldData.features;
features.forEach(({geometry}) =&gt; {
...遍历数据
...进行投影转换
...进行绘制
});
}();
```
在forEach迭代的时候我们可以拿到features数组中每一个元素里的geometry字段这个字段中包含有coordinates数组coordinates数组中的值就是经纬度值我们可以对这些值进行投影转换最后调用drawPoints将这个数据画出来。绘制过程十分简单你直接看下面的代码就可以理解。
```
function drawPoints(ctx, points) {
ctx.beginPath();
ctx.moveTo(...points[0]);
for(let i = 1; i &lt; points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.fill();
}
```
完整的代码我放在了[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/convid-vis/mercator.html)中,你可以下载到本地运行。这里,我直接把运行的结果展示给你看。
<img src="https://static001.geekbang.org/resource/image/37/c7/373977623609e83d8911f679240d7dc7.jpg" alt="">
以上就是利用GeoJSON数据绘制地图的全过程。这个过程非常简单我们只需要将coordinate数据进行投影然后根据投影的坐标把轮廓绘制出来就可以了。但是GeoJSON数据通常比较大如果我们直接在Web应用中使用有些浪费带宽也可能会导致网络加载延迟所以使用TopoJSON数据是一个更好的选择。
举个例子同样的世界地图数据GeoJSON格式数据有251KB而经过了压缩的TopoJSON数据只有84KB体积约为原来的1/3。
尽管体积比GeoJSON数据小了不少但是TopoJSON数据经过了复杂的压缩之后我们在使用的时候还需要对它解压把它变成GeoJSON数据。可是如果我们自己写代码去解压实现起来比较复杂。好在我们可以采用现成的工具对它进行解压。这里我们可以使用GitHub上的[TopoJSON官方仓库](https://github.com/topojson/topojson)的JavaScript模块来处理TopoJSON数据。
这个转换简单到只用一行代码就可以完成,转换完成之后,我们就可以用同样的方法将世界地图绘制出来了。具体的转换过程我就不多说了,你可以自己试一试。转换代码如下:
```
const countries = topojson.feature(worldData, worldData.objects.countries);
```
## 步骤三:整合数据
有了世界地图之后下一步就是将疫情的JSON数据整合进地图数据里面。
在GeoJSON或者TopoJSON解压后的countries数据中除了用geometries字段保存地图的地区信息外还用properties字段来保存了其他的属性。在我们这一份地图数据中properties只有一个name属性对应着不同国家的名字。
我们打开[TopoJSON文件](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/world-topojson.json)就可以看到在contries.geometries下的properties属性中有一个name属性对应国家的名字。
<img src="https://static001.geekbang.org/resource/image/33/b6/331fb9c48b9c6245446190a9f19078b6.jpeg" alt="">
这个时候,我们再打开[疫情的JSON数据](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/convid-data.json)我们会发现疫情数据中的contry属性和GeoJSON数据里面的国家名称是一一对应的。
<img src="https://static001.geekbang.org/resource/image/a8/70/a8975be77a6e8f3bdde2450c198e3f70.jpeg" alt="">
这样我们就可以建立一个数据映射关系将疫情数据中的每个国家的疫情数据直接写入到GeoJSON数据的properties字段里面。
接着,我们增加一个数据映射函数:
```
function mapDataToCountries(geoData, convidData) {
const convidDataMap = {};
convidData.dailyReports.forEach((d) =&gt; {
const date = d.updatedDate;
const countries = d.countries;
countries.forEach((country) =&gt; {
const name = country.country;
convidDataMap[name] = convidDataMap[name] || {};
convidDataMap[name][date] = country;
});
});
geoData.features.forEach((d) =&gt; {
const name = d.properties.name;
d.properties.convid = convidDataMap[name];
});
}
```
在这个函数里我们先将疫情数据的数组转换成以国家名为key的Map然后将它写入到TopoJSON读取出的Geo数据对象里。
最后我们直接读取两个JSON数据调用这个数据映射函数就完成了数据整合。
```
const worldData = await (await fetch('./assets/data/world-topojson.json')).json();
const countries = topojson.feature(worldData, worldData.objects.countries);
const convidData = await (await fetch('./assets/data/convid-data.json')).json();
mapDataToCountries(countries, convidData);
```
因为整合好的数据比较多,所以我只在这里列出一个国家的示例数据:
```
{
&quot;objects&quot;: {
&quot;countries&quot;: {
&quot;type&quot;: &quot;GeometryCollection&quot;,
&quot;geometries&quot;: [{
&quot;arcs&quot;: [
[0, 1, 2, 3, 4, 5]
],
&quot;type&quot;: &quot;Polygon&quot;,
&quot;properties&quot;: {
&quot;name&quot;: &quot;Afghanistan&quot;,
&quot;convid&quot;: {
&quot;2020-01-22&quot;: {
&quot;confirmed&quot;: 1,
&quot;recovered&quot;: 0,
&quot;death&quot;: 0,
},
&quot;2020-01-23&quot;: {
...
},
...
}
}
},
...
```
## 步骤四:将数据与地图结合
将全部数据整合到地理数据之后,我们就可以将数据与地图结合了。在这里,我们设计用不同的颜色来表示疫情的严重程度,填充地图,确诊人数越多的区域颜色越红。要实现这个效果,我们先要创建一个确诊人数和颜色的映射函数。
我把无人感染到感染人数超过10000人划分了7个等级每个等级用不同的颜色表示
- 若该地区无人感染,渲染成 #3ac 颜色
- 若该地区感染人数小于10渲染成rgb(250, 247, 171)色
- 若该地区感染人数10~99人渲染成rgb(255, 186, 66)色
- 若该地区感染人数100~499人渲染成rgb(234, 110, 41)色
- 若该地区感染人数500~999人渲染成rgb(224, 81, 57)色
- 若该地区感人人数1000~9999人渲染成rgb(192, 50, 39)色
- 若该地区感染人数超10000人渲染成rgb(151, 32, 19)色
对应的代码如下:
```
function mapColor(confirmed) {
if(!confirmed) {
return '#3ac';
}
if(confirmed &lt; 10) {
return 'rgb(250, 247, 171)';
}
if(confirmed &lt; 100) {
return 'rgb(255, 186, 66)';
}
if(confirmed &lt; 500) {
return 'rgb(234, 110, 41)';
}
if(confirmed &lt; 1000) {
return 'rgb(224, 81, 57)';
}
if(confirmed &lt; 10000) {
return 'rgb(192, 50, 39)';
}
return 'rgb(151, 32, 19)';
}
```
然后我们在绘制地图的代码里根据确诊人数设置Canvas的填充信息
```
function drawMap(ctx, countries, date) {
date = formatDate(date); // 转换日期格式
countries.features.forEach(({geometry, properties}) =&gt; {
... 读取当前日期下的确诊人数
ctx.fillStyle = mapColor(confirmed); // 映射成地图颜色并设置到Canvas上下文
... 执行绘制
});
```
我们先把data参数设为2020-01-22这样一来我们就绘制出了2020年1月22日的疫情地图。
<img src="https://static001.geekbang.org/resource/image/d7/65/d79fe31cc6b92340077ccb5e1f085865.jpg" alt="" title="2020年1月22日疫情地图">
可是疫情的数据每天都会更新如果想让疫情地图随着日期自动更新我们该怎么做呢我们可以给地图绘制过程加上一个定时器这样我们就能得到一个动态的疫情地图了它会自动显示从1月22日到当前日期疫情变化。这样我们就能看到疫情随时间的变化了。
```
const startDate = new Date('2020/01/22');
let i = 0;
const timer = setInterval(() =&gt; {
const date = new Date(startDate.getTime() + 86400000 * (++i));
drawMap(ctx, countries, date);
if(date.getTime() + 86400000 &gt; Date.now()) {
clearInterval(timer);
}
}, 100);
drawMap(ctx, countries, startDate);
```
<img src="https://static001.geekbang.org/resource/image/68/92/68676fbffedcac02dfa178d603025292.gif" alt="">
## 要点总结
这节课,我们讲了实现地理信息可视化的通用步骤,一共可以分为四步,我们一起来回顾一下。
第一步是准备数据包括地图数据和要可视化的数据。地图数据有GeoJSON和TopoJSON两个规范。相比较而言TopoJSON数据格式经过了压缩体积会更小比较适合Web应用。
第二步是绘制地图。要绘制地图,我们需要将地理信息中的坐标信息投影到地图上,最简单的投影方式是使用墨卡托投影。
第三步是整合数据,我们要把可视化数据和地图的地理数据集成到一起,这一步我们可以通过定义数据映射函数来实现。
最后一步,就是将数据与地图结合,根据整合后的数据结合地图完成最终的图形绘制。
总的来说无论我们要实现多么复杂的地理信息可视化地图核心的4个步骤是不会变的只不过其中的每一步我都可以替换具体的实现方式比如我们可以使用其他的投影方式来代替墨卡托投影来绘制不同形状的地图。
## 课后练习
<li>
我们今天选择使用Canvas来绘制地图是因为它使用起来十分方便。其实使用SVG绘制地图也很方便你能试着改用SVG来实现今天的疫情地图吗这和使用Canvas有什么共同点和不同点
</li>
<li>
我们今天使用的墨卡托投影是最简单的投影方法它的缺点是让高纬度下的国家看起来比实际的要大很多。你能试着使用D3.js的[d3-geo](https://github.com/d3/d3-geo)模块中提供的其他投影方式来实现地图吗?
</li>
3.如果 我们要增加交互,让鼠标移动到某个国家区域的时候,这个区域高亮,并且显示国家名称、疫情确诊数、治愈数以及死亡数,这该怎么处理呢?你可以尝试增加这样的交互功能,来完善我们的地图应用吗?
好啦,今天的地理信息可视化实战就到这里了。欢迎你把实现的地图作品放在留言区,也欢迎把这节课转发出去,我们下节课见!
## 源码
[1] [新冠肺炎数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/convid-data.json)<br>
[2] [GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-geojson.json)<br>
[3] [TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-topojson.json)<br>
[4] 完整的示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/mercator.html)
## 推荐阅读
[1] [GeoJSON标准格式学习](https://www.jianshu.com/p/852d7ad081b3)<br>
[2] [GeoJSON和TopoJSON][reference_end](https://blog.xcatliu.com/2015/04/24/geojson_and_topojson/)<br>
[3] [GeoJSON规范](https://geojson.org/geojson-spec.html)<br>
[4] [TopoJSON规范](https://github.com/topojson/topojson-specification/blob/master/README.md)

View File

@@ -0,0 +1,403 @@
<audio id="audio" title="40| 实战如何实现3D地球可视化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/28/ea5067ed98990893ee174644d7321828.mp3"></audio>
你好,我是月影。
前几节课我们一起进行了简单图表和二维地图的实战这节课我们来实现更炫酷的3D地球可视化。
3D地球可视化主要是以3D的方式呈现整个地球的模型视觉上看起来更炫酷。它是可视化应用里常见的一种形式通常用来实现全球地理信息相关的可视化应用例如全球黑客攻防示意图、全球航班信息示意图以及全球贸易活动示意图等等。
因为内容比较多所以我会用两节课来讲解3D地球的实现效果。而且由于我们的关注点在效果因此为了简化实现过程和把重点聚焦在效果上我就不刻意准备数据了我们用一些随机数据来实现。不过即使我们要实现的是包含真实数据的3D可视化应用项目前面学过的数据处理方法仍然是适用的。这里我就不多说了。
在学习之前你可以先看一下我们最终要实现的3D地球可视化效果先有一个直观的印象。
<img src="https://static001.geekbang.org/resource/image/2d/e5/2d3a38yy1b1a0974a830a277150a54e5.gif" alt="">
如上面动画图像所示我们要做的3D可视化效果是一个悬浮于宇宙空间中的地球它的背后是一些星空背景和浅色的光晕并且地球在不停旋转的同时会有一些不同的地点出现飞线动画。
接下来,我们就来一步步实现这样的效果。
## 如何实现一个3D地球
第一步我们自然是要实现一个旋转的地球。通过前面课程的学习我们知道直接用SpriteJS的3D扩展就可以方便地绘制3D图形。这里我们再系统地说一下实现的方法。
### 1. 绘制一个3D球体
首先我们加载SpriteJS和3D扩展最简单的方式还是直接使用CDN上打包好的文件代码如下
```
&lt;script src=&quot;http://unpkg.com/spritejs/dist/spritejs.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js&quot;&gt;&lt;/script&gt;
```
加载完成之后我们创建场景对象添加Layer代码如下
```
const {Scene} = spritejs;
const container = document.getElementById('container');
const scene = new Scene({
container,
});
const layer = scene.layer3d('fglayer', {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
```
与2D的Layer不同SpriteJS的3D扩展创建的Layer需要设置相机。这里我们设置了一个透视相机视角为35度位置为 0, 0, 5
接着是创建WebGL的Program我们通过Layer对象的createProgram来创建代码如下
```
const {Sphere, shaders} = spritejs.ext3d;
const program = layer.createProgram({
...shaders.GEOMETRY,
cullFace: null,
});
```
SpriteJS的3D扩展内置了一些常用的Shader比如shaders.GEOMETRY 就是一个符合Phong反射模型的几何体Shader所以这次我们直接使用它。
接着我们创建一个球体它在SpriteJS的3D扩展中对应Sphere对象。
```
const globe = new Sphere(program, {
colors: '#333',
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
```
我们给球体设置颜色、宽度、高度和半径这些默认的属性然后将它添加到layer上这样我们就能在画布上将这个球体显示出来了效果如下所示。
<img src="https://static001.geekbang.org/resource/image/11/21/11f33d265ded0f54973860671f265d21.jpeg" alt="">
现在,我们只在画布上显示了一个灰色的球体,它和我们要实现的地球还相差甚远。别着急,我们一步一步来。
### 2. 绘制地图
上节课,我们已经讲了绘制平面地图的方法,就是把表示地图的 JSON 数据利用墨卡托投影到平面上。接下来我们也要先绘制一张平面地图然后把它以纹理的方式添加到我们创建的3D球体上。
不过,与平面地图采用墨卡托投影不同,作为纹理的球面地图需要采用**等角方位投影**(Equirectangular Projection)。d3-geo模块中同样支持这种投影方式我们可以直接加载d3-geo模块然后使用对应的代码来创建投影。
从CDN加载d3-geo模块需要加载以下两个JS文件
```
&lt;script src=&quot;https://d3js.org/d3-array.v2.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://d3js.org/d3-geo.v2.min.js&quot;&gt;&lt;/script&gt;
```
然后,我们创建对应的投影:
```
const mapWidth = 960;
const mapHeight = 480;
const mapScale = 4;
const projection = d3.geoEquirectangular();
projection.scale(projection.scale() * mapScale).translate([mapWidth * mapScale * 0.5, (mapHeight + 2) * mapScale * 0.5]);
```
这里,我们首先通过 d3.geoEquirectangular 方法来创建等角方位投影再将它进行缩放。d3的地图投影默认宽高为960 * 480我们将投影缩放为4倍也就是将地图绘制为 3480 * 1920大小。这样一来它就能在大屏上显示得更清晰。
然后我们通过tanslate将中心点调整到画布中心因为JSON的地图数据的0,0点在画布正中心。仔细看我上面的代码你会注意到我们在Y方向上多调整一个像素这是因为原始数据坐标有一点偏差。
通过我刚才说的这些步骤我们就创建好了投影接下来就可以开始绘制地图了。我们从topoJSON数据加载地图。
```
async function loadMap(src = topojsonData, {strokeColor, fillColor} = {}) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(data, data.objects.countries);
const canvas = new OffscreenCanvas(mapScale * mapWidth, mapScale * mapHeight);
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
return drawMap({context, countries, strokeColor, fillColor});
}
```
这里我们创建一个离屏Canvas用加载的数据来绘制地图到离屏Canvas上对应的绘制地图的逻辑如下
```
function drawMap({
context,
countries,
strokeColor = '#666',
fillColor = '#000',
strokeWidth = 1.5,
} = {}) {
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
```
这样我们就完成了地图加载和绘制的逻辑。当然我们现在还看不到地图因为我们只是将它绘制到了一个离屏的Canvas对象上并没有将这个对象显示出来。
### 3. 将地图作为纹理
要显示地图为3D地球我们需要将刚刚绘制的地图作为纹理添加到之前绘制的球体上。之前我们绘制球体时使用的是SpriteJS中默认的shader它是符合Phong光照模型的几何材质的。因为考虑到地球有特殊光照我们现在自己实现一组自定义的shader。
```
const vertex = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vNormal;
varying vec2 vUv;
varying vec4 vColor;
uniform vec3 pointLightPosition; //点光源位置
void main() {
vNormal = normalize(normalMatrix * normal);
vUv = uv;
vColor = color;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragment = `
precision highp float;
precision highp int;
varying vec3 vNormal;
varying vec4 vColor;
uniform sampler2D tMap;
varying vec2 vUv;
uniform vec2 uResolution;
void main() {
vec4 color = vColor;
vec4 texColor = texture2D(tMap, vUv);
vec2 st = gl_FragCoord.xy / uResolution;
float alpha = texColor.a;
color.rgb = mix(color.rgb, texColor.rgb, alpha);
color.rgb = mix(texColor.rgb, color.rgb, clamp(color.a / max(0.0001, texColor.a), 0.0, 1.0));
color.a = texColor.a + (1.0 - texColor.a) * color.a;
float d = distance(st, vec2(0.5));
gl_FragColor.rgb = color.rgb + 0.3 * pow((1.0 - d), 3.0);
gl_FragColor.a = color.a;
}
`;
```
我们用上面的Shader来创建Program。这组Shader并不复杂原理我们在视觉篇都已经解释过了。如果你觉得理解起来依然有困难可以复习一下视觉篇的内容。接着我们创建一个Texture对象将它赋给Program对象代码如下。
```
const texture = layer.createTexture({});
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
```
现在,画布上就显示出了一个中心有些亮光的球体。
<img src="https://static001.geekbang.org/resource/image/ae/0d/aeb4a6736810d9d5674b48b1e683800d.jpeg" alt="">
从中我们还是看不出地球的样子。这是因为我们给的texture对象是一个空的纹理对象。接下来我们只要执行loadMap方法将地图加载出来再添加给这个空的纹理对象然后刷新画布就可以了。对应代码如下
```
loadMap().then((map) =&gt; {
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
```
最终,我们就显示出了地球的样子。
<img src="https://static001.geekbang.org/resource/image/b6/a3/b637c0ae60390d16ed72a17749a8d9a3.jpeg" alt="">
我们还可以给地球添加轨迹球控制并让它自动旋转。在SpriteJS中非常简单只需要一行代码即可完成。
```
layer.setOrbit({autoRotate: true}); // 开启旋转控制
```
<img src="https://static001.geekbang.org/resource/image/06/47/063675af883cf21766ba63af91f74347.gif" alt="">
这样我们就得到一个自动旋转的地球效果了。
## 如何实现星空背景
不过,这个孤零零的地球悬浮在黑色背景的空间里,看起来不是很吸引人,所以我们可以给地球添加一些背景,比如星空,让它真正悬浮在群星闪耀的太空中。
要实现星空的效果第一步是要创建一个天空包围盒。天空包围盒也是一个球体Sphere对象只不过它要比地球大很多以此让摄像机处于整个球体内部。为了显示群星天空包围盒有自己特殊的Shader。我们来看一下
```
const skyVertex = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
uniform mat3 normalMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const skyFragment = `
precision highp float;
precision highp int;
varying vec2 vUv;
highp float random(vec2 co)
{
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
// Value Noise by Inigo Quilez - iq/2013
// https://www.shadertoy.com/view/lsf3WH
highp float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix( mix( random( i + vec2(0.0,0.0) ),
random( i + vec2(1.0,0.0) ), u.x),
mix( random( i + vec2(0.0,1.0) ),
random( i + vec2(1.0,1.0) ), u.x), u.y);
}
void main() {
gl_FragColor.rgb = vec3(1.0);
gl_FragColor.a = step(0.93, noise(vUv * 6000.0));
```
上面的代码是天空包围盒的Shader实际上它是我们使用二维噪声的技巧来实现的。在第16节课中也有过类似的做法当时我们是用它来模拟水滴滚过的效果。
<img src="https://static001.geekbang.org/resource/image/72/91/72fbc484227f00ec4dcf7f2729c8f391.jpeg" alt="">
但在这里我们通过step函数和vUv的缩放将它缩小之后最终呈现出来星空效果。
<img src="https://static001.geekbang.org/resource/image/e2/c5/e2dfac7b34718ecc347969f807a6f9c5.jpeg" alt="">
对应的创建天空盒子的JavaScript代码如下
```
function createSky(layer, skyProgram) {
skyProgram = skyProgram || layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
layer.append(skyBox);
return skyBox;
}
createSky(layer);
```
不过,光看这些代码,你可能还不能完全明白,为什么二维噪声技巧就能实现星空效果。那也不要紧,完整的示例代码在[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)中最好的理解方式还是你自己试着手动修改一下skyFragment中的绘制参数看看实现出来效果你就能明白了。
## 要点总结
这节课我们讲了实现3D地球可视化效果的方法以及给3D地球添加天空背景的方法。
要实现3D地球效果我们可以使用SpriteJS和它的3D扩展库。首先我们绘制一个3D球体。然后我们用topoJSON数据绘制地图注意地图的投影方式必须选择等角方位投影。最后我们把地图作为纹理添加到3D球体上这样就绘制出了3D地球。
而要实现星空背景我们需要创建一个天空盒子它可以看成是一个放大很多倍的球体包裹在地球的外面。具体的思路就是我们创建一组特殊的Shader通过二维噪声来实现星空的效果。
说的这里你可能会有一些疑问我们为什么要用topoJSON数据来绘制地图而不采用现成的等角方位投影的平面地图图片直接用它来作为纹理那样不是能够更快绘制出3D地球吗的确这样确实也能够更简单地绘制出3D地球但这么做也有代价就是我们没有地图数据就不能进一步实现交互效果了比如说点击某个地理区域实现当前国家地区的高亮效果了。
那在下节课我们就会进一步讲解怎么在3D地球上添加交互效果以及根据地理位置来放置各种记号。你的疑问也都会一一解开。
## 小试牛刀
我们说如果不考虑交互可以直接使用更简单的等角方位投影地图作为纹理来直接绘制3D地球。你能试着在网上搜索类似的纹理图片来实现3D地球效果吗
另外你可以找类似的其他行星的图片比如火星、木星图片来实现3D火星、木星的效果吗
最后,你也可以想想,除了星空背景,如果我们还想在地球外部实现一层淡绿色的光晕,又该怎么做呢(提示:你可以使用距离场和颜色插值来实现)?
<img src="https://static001.geekbang.org/resource/image/bc/39/bcc477ea54486f586e2b90715c944439.gif" alt="">
今天的3D地球可视化实战就到这里了。欢迎把你实现的效果分享到留言区我们一起交流。也欢迎把这节课转发出去我们下节课见
## 源码
[课程完整示例代码详](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)

View File

@@ -0,0 +1,600 @@
<audio id="audio" title="41 | 实战如何实现3D地球可视化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/c2/4c0b3a1be4caa1b8d1a6577920e295c2.mp3"></audio>
你好,我是月影。
上节课我们实现了一个有着星空背景的3D地球效果。但这个效果还比较简单在某些可视化大屏项目中我们不仅要呈现视觉效果还要允许用户与可视化大屏中呈现的内容进行交互。所以这节课我们会先给这个3D地球添加各种交互细节比如让地球上的国家随着我们鼠标的移动而高亮接着我们再在地球上放置各种记号比如光柱、地标等等。
## 如何选中地球上的地理位置?
我们先来解决上节课留下的一个问题为什么我们在绘制3D地球的时候要大费周章地使用topoJSON数据而不是直接用一个现成的等角方位投影的世界地图图片作为球体的纹理。这是因为我们想让地球能够和我们的鼠标进行交互比如当点击到地图上的中国区域的时候我们想让中国显示高亮这是纹理图片无法实现的。接下来我们就来看看怎么实现这样交互的效果。
### 实现坐标转换
实现交互效果的难点在于坐标转换。因为鼠标指向地球上的某个区域的时候我们通过SpriteJS拿到的是当前鼠标在点击的地球区域的一个三维坐标而这个坐标是不能直接判断点中的区域属于哪个国家的我们需要将它转换成二维的地图经纬度坐标才能通过地图数据来获取到当前经纬度下的国家或地区信息。
那如何实现这个坐标转换呢首先我们的鼠标在地球上移动的时候通过SpriteJS我们拿到三维的球面坐标代码如下
```
layer.setRaycast();
globe.addEventListener('mousemove', (e) =&gt; {
console.log(e.hit.localPoint);
});
...
skyBox.attributes.raycast = 'none';
```
上面的代码中有一个小细节我们将天空包围盒的raycast设置成了none。为什么要这么做呢因为地球包围在天空盒子内这样设置之后鼠标就能穿透天空包围盒到达地球如果不这么做天空盒子就会遮挡住鼠标事件地球也就捕获不到事件了。这样一来当鼠标移动到地球上时我们就可以得到相应的三维坐标信息了。
接下来,我们要将三维坐标信息转换为经纬度信息,那第一步就是将三维坐标转换为二维平面坐标。
```
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
```
这个球面和平面坐标转换实际上就是将空间坐标系从球坐标系转换为平面直接坐标系。具体的转换方法是我们先将球坐标系转为圆柱坐标系再将圆柱坐标系转为平面直角坐标系。具体的公式推导过程比较复杂我们没必要深入理解你只要会用我给出的unproject函数就可以了。如果你对推导原理有兴趣可以回顾[第15课](https://time.geekbang.org/column/article/266346),自己来推导一下,或者阅读[这篇文章](https://zhuanlan.zhihu.com/p/34485962)。
拿到了二维平面直角坐标之后,我们可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度,代码如下:
```
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
```
接着我们实现一个通过经纬度拿到国家信息的函数。这里我们直接通过d3.geoContains方法从countries数据中拿到对应的国家信息。
```
function getCountryInfo(latitude, longitude, countries) {
if(!countries) return {index: -1};
let idx = -1;
countries.features.some((d, i) =&gt; {
const ret = d3.geoContains(d, [longitude, latitude]);
if(ret) idx = i;
return ret;
});
const info = idx &gt;= 0 ? {...countries.features[idx]} : {};
info.index = idx;
return info;
}
```
这样一来我们只要修改mousemove方法就可以知道我们的鼠标移动在哪个国家之上了。
```
globe.addEventListener('mousemove', (e) =&gt; {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, countries);
if(country.properties) {
console.log(country.properties.name);
}
});
```
### 高亮国家地区的方法
下一步我们就可以实现一个方法来高亮鼠标移动到的国家或地区了。要高亮对应的国家或地区其实处理起来并不复杂。我们先把原始的非高亮的图片另存一份然后根据选中国家的index信息从contries原始数据中取出对应的那个国家用不同的填充色fillStyle再绘制一次最后更新texture和layer就可以将高亮区域绘制出来了。代码如下
```
function highlightMap(texture, info, countries) {
if(texture.index === info.index) return;
const canvas = texture.image;
if(!canvas) return;
const idx = info.index;
const highlightMapContxt = canvas.getContext('2d');
if(!imgCache) {
imgCache = new OffscreenCanvas(canvas.width, canvas.height);
imgCache.getContext('2d').drawImage(canvas, 0, 0);
}
highlightMapContxt.clearRect(0, 0, mapScale * mapWidth, mapScale * mapHeight);
highlightMapContxt.drawImage(imgCache, 0, 0);
if(idx &gt; 0) {
const path = d3.geoPath(projection).context(highlightMapContxt);
highlightMapContxt.save();
highlightMapContxt.fillStyle = '#fff';
highlightMapContxt.beginPath();
path({type: 'FeatureCollection', features: countries.features.slice(idx, idx + 1)});
highlightMapContxt.fill();
highlightMapContxt.restore();
}
texture.index = idx;
texture.needsUpdate = true;
layer.forceUpdate();
```
仔细看上面的代码你会发现这里我们实际上做了两点优化一是我们在texture对象上记录了上一次选中区域的index。如果移动鼠标时index没发生变化说明鼠标仍然在当前高亮的国家内没有必要重绘。二是我们保存了原始非高亮图片。之所以这样做是因为我们只要将保存的非高亮图片通过drawImage一次绘制然后再绘制高亮区域就可以完成地图高亮效果而不需要每次都重新用Path来绘制整个地图了因而大大减少了Canvas2D绘图指令的数量显著提升了性能。
实现了这个函数之后我们改写mousemove事件处理函数就能将这个交互效果完整地显示出来了。具体的代码和效果图如下
```
globe.addEventListener('mousemove', (e) =&gt; {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, countries);
highlightMap(texture, country, countries);
});
```
<img src="https://static001.geekbang.org/resource/image/14/e8/14ea287ce617789ce37cd2e3d40ee4e8.gif" alt="">
## 如何在地球上放置标记?
通过选中对应的国家,我们可以实现鼠标的移动的高亮交互效果。接下来,我们来实现另一个交互效果,在地球的指定经纬度处放置一些标记。
### 如何计算几何体摆放位置?
既然要把物体放置在地球指定的经纬坐标处那我们接下来的操作依然离不开坐标转换。首先我们知道几何体通常默认是以中心点为0,0但我们放置的时候却需要将物体的底部放置在球面上所以我们需要对球面坐标位置进行一个坐标变换。
<img src="https://static001.geekbang.org/resource/image/82/06/82406267ec504e29b8d975789a4f8a06.jpg" alt="">
因此,我们在实现放置函数的时候,会通过 latlngToPosition 先将经纬度转成球面坐标pos再延展到物体高度的一半因为球心的坐标是0,0所以pos位置就是对应的三维向量我们使用scale就可以直接将它移动到我们要的高度了。
```
function setGlobeTarget(globe, target, {latitude, longitude, transpose = false, ...attrs}) {
const radius = globe.attributes.radius;
if(transpose) target.transpose();
if(latitude != null &amp;&amp; longitude != null) {
const scale = target.attributes.scaleY * (attrs.scale || 1.0);
const height = target.attributes.height;
const pos = latlngToPosition(latitude, longitude, radius);
// 要将底部放置在地面上
pos.scale(height * 0.5 * scale / radius + 1);
attrs.pos = pos;
}
target.attr(attrs);
const sp = new Vec3().copy(attrs.pos).scale(2);
target.lookAt(sp);
globe.append(target);
}
```
这里的 latlngToPosition 是前面 positionToLatlng 的反向操作,也就是先用 projection 函数将经纬度映射为地图上的直角坐标,然后用直角坐标转球面坐标的公式,将它转为球面坐标。具体的实现代码如下:
```
/**
* 将经纬度转换为球面坐标
* @param {*} latitude
* @param {*} longitude
* @param {*} radius
*/
function latlngToPosition(latitude, longitude, radius = 1) {
const [u, v] = projection([longitude, latitude]);
return project(u, v, radius);
}
/**
* 将平面地图坐标转换为球面坐标
* @param {*} u
* @param {*} v
* @param {*} radius
*/
function project(u, v, radius = 1) {
u /= mapScale * mapWidth;
v /= mapScale * mapHeight;
const pLength = Math.PI * 2;
const tLength = Math.PI;
const x = -radius * Math.cos(u * pLength) * Math.sin(v * tLength);
const y = radius * Math.cos(v * tLength);
const z = radius * Math.sin(u * pLength) * Math.sin(v * tLength);
return new Vec3(x, y, z);
}
```
有了这个位置之后我们将物体放上去并且让物体朝向球面的法线方向。这一步我们可以用lookAt函数来实现。不过lookAt函数是让物体的z轴朝向向量方向而我们绘制的一些几何体比如圆柱体其实是要让y轴朝向向量方向所以这种情况下我们需要对几何体的顶点做一个转置操作也就是将它的顶点向量的x、y、z的值轮换一下让x = y、y = z、 z = x。这么做之后我们就可以在地球表面摆放几何体了。
### 摆放光柱
我们先在地球的指定位置上放置一些光柱,光柱通常可以用来标记当前位置是一个重要的地点。光柱效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/2d/67/2d02f5fd187b659b47a299baa4fe9e67.gif" alt="">
这个效果怎么实现呢因为光柱本身是圆柱体所以我们可以用Cylindar对象来绘制。而光柱的光线还会随着高度衰减对应的Shader代码如下
```
const beamVertx = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vNormal;
varying vec4 vColor;
uniform vec4 ambientColor; // 环境光
uniform float uHeight;
void main() {
vNormal = normalize(normalMatrix * normal);
vec3 ambient = ambientColor.rgb * color.rgb;// 计算环境光反射颜色
float height = 0.5 - position.z / uHeight;
vColor = vec4(ambient + 0.3 * sin(height), color.a * height);
vec3 P = position;
P.xy *= 2.0 - pow(height, 3.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const beamFrag = `
precision highp float;
precision highp int;
varying vec3 vNormal;
varying vec4 vColor;
void main() {
gl_FragColor = vColor;
```
实现Shader的代码并不复杂在顶点着色器里我们可以根据高度减少颜色的不透明度。另外我们还可以根据高度对xy也就是圆柱的截面做一个扩展 P.xy *= 2.0 - pow(height, 3.0),这样就能产生一种光线发散(顶部比底部略大)的效果了。
于是对应的addBeam函数实现如下所示。它就是根据参数创建对应的圆柱体对象并把它们添加到地球对应的经纬度位置上。
```
function addBeam(globe, {
latitude,
longitude,
width = 1.0,
height = 25.0,
color = 'rgba(245,250,113, 0.5)',
raycast = 'none',
segments = 60} = {}) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if(layer) {
const r = width / 2;
const scale = radius * 0.015;
const program = layer.createProgram({
transparent: true,
vertex: beamVertx,
fragment: beamFrag,
uniforms: {
uHeight: {value: height},
},
});
const beam = new Cylinder(program, {
radiusTop: r,
radiusBottom: r,
radialSegments: segments,
height,
colors: color,
});
setGlobeTarget(globe, beam, {transpose: true, latitude, longitude, scale, raycast});
return beam;
}
}
```
### 摆放地标
除了摆放光柱我们还可以摆放地标。地标通常表示当前位置产生了一个重大事件。地标实现起来会比光柱更复杂一些它由一个定位点Spot和一个动态的标记Marker共同组成。摆放了地标的地图效果如下图所示
<img src="https://static001.geekbang.org/resource/image/bc/4f/bc82d420e44aed51be20a16f63c9434f.gif" alt="">
想要实现它第一步我们还是要实现对应的Shader。不过我们这次需要实现两组Shader。首先是spot的顶点着色器和片元着色器实现起来也非常简单。在顶点着色器中我们根据uWidth扩展x、y坐标根据顶点绘制出一个特定大小的平面图形。在片元着色器中我们让图形的中心稍亮一些让边缘亮度随着距离衰减这么做是为了增强视觉效果。不过由于分辨率的原因具体的效果在截图中可能体现不出来你可以运行示例代码把地球局部放大来实际观察和体会一下。
```
const spotVertex = `
precision highp float;
precision highp int;
attribute vec4 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform float uWidth;
uniform float uSpeed;
uniform float uHeight;
varying vec2 st;
void main() {
float s = 0.0 + (0.2 * uWidth * position.w);
vec3 P = vec3(s * position.xy, 0.0);
st = P.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const spotFragment = `
precision highp float;
precision highp int;
uniform vec2 uResolution;
uniform vec3 uColor;
uniform float uWidth;
varying vec2 st;
void main() {
float d = distance(st, vec2(0));
gl_FragColor.rgb = uColor + 1.5 * (0.2 * uWidth - 2.0 * d);
gl_FragColor.a = 1.
```
接着我们实现marker的顶点着色器和片元着色器它们稍微复杂一些。
```
const markerVertex = `
precision highp float;
precision highp int;
attribute vec4 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform float uTime;
uniform float uWidth;
uniform float uSpeed;
uniform float uHeight;
varying float time;
void main() {
time = mod(uTime, 1.5 / uSpeed) * uSpeed + position.z - 1.0;
float d = clamp(0.0, uWidth * mix(1.0, 0.5, min(1.0, uHeight)), time);
float s = d + (0.1 * position.w);
vec3 P = vec3(s * position.xy, uHeight * time);
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const markerFragment = `
precision highp float;
precision highp int;
uniform vec2 uResolution;
uniform vec3 uColor;
varying float time;
void main() {
float t = clamp(0.0, 1.0, time);
gl_FragColor.rgb = uColor;
gl_FragColor.a = 1.0 - t
```
在顶点着色器里我们根据时间参数uTime来调整物体定点的高度。这样当我们设置uHeight参数时marker就能呈现出立体的效果。
有了这两组着色器之后我们再实现两个函数用来分别生成spot和marker的顶点函数代码如下
```
function makeSpotVerts(radis = 1.0, n_segments) {
const vertex = [];
for(let i = 0; i &lt;= n_segments; i++) {
const theta = Math.PI * 2 * i / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
return {
position: {data: vertex, size: 4},
};
}
function makeMarkerVerts(radis = 1.0, n_segments) {
const vertex = [];
for(let i = 0; i &lt;= n_segments; i++) {
const theta = Math.PI * 2 * i / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
const copied = [...vertex];
vertex.push(...copied.map((v, i) =&gt; {
return i % 4 === 2 ? 0.33 : v;
}));
vertex.push(...copied.map((v, i) =&gt; {
return i % 4 === 2 ? 0.67 : v;
}));
return {
position: {data: vertex, size: 4},
};
}
```
这两个函数都是用生成正多边形顶点的算法来生成对应的顶点它们的区别是spot只生成一组顶点因为是平面图形所以z坐标为0而marker则生成三组不同高度的顶点组成立体的形状。
接着我们再实现一个初始化函数用来生成spot和marker对应的WebGLProgram函数代码如下
```
function initMarker(layer, globe, {width, height, speed, color, segments}) {
const markerProgram = layer.createProgram({
transparent: true,
vertex: markerVertex,
fragment: markerFragment,
uniforms: {
uTime: {value: 0},
uColor: {value: new Color(color).slice(0, 3)},
uWidth: {value: width},
uSpeed: {value: speed},
uHeight: {value: height},
},
});
const markerGeometry = new Geometry(layer.gl, makeMarkerVerts(globe.attributes.radius, segments));
const spotProgram = layer.createProgram({
transparent: true,
vertex: spotVertex,
fragment: spotFragment,
uniforms: {
uTime: {value: 0},
uColor: {value: new Color(color).slice(0, 3)},
uWidth: {value: width},
uSpeed: {value: speed},
uHeight: {value: height},
},
});
const spotGeometry = new Geometry(layer.gl, makeSpotVerts(globe.attributes.radius, segments));
return {
program: markerProgram,
geometry: markerGeometry,
spotGeometry,
spotProgram,
mode: 'TRIANGLE_STRIP',
}
```
最后我们实现addMarker方法将地标添加到地球上。这样我们就实现了绘制地标的功能。
```
function addMarker(globe, {
latitude,
longitude,
width = 1.0,
height = 0.0,
speed = 1.0,
color = 'rgb(245,250,113)',
segments = 60,
lifeTime = Infinity} = {}) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if(layer) {
let mode = 'TRIANGLES';
const ret = initMarker(layer, globe, {width, height, speed, color, segments});
const markerProgram = ret.program;
const markerGeometry = ret.geometry;
const spotProgram = ret.spotProgram;
const spotGeometry = ret.spotGeometry;
mode = ret.mode;
if(markerProgram) {
const pos = latlngToPosition(latitude, longitude, radius);
const marker = new Mesh3d(markerProgram, {model: markerGeometry, mode});
const spot = new Mesh3d(spotProgram, {model: spotGeometry, mode});
setGlobeTarget(globe, marker, {pos, scale: 0.05, raycast: 'none'});
setGlobeTarget(globe, spot, {pos, scale: 0.05, raycast: 'none'});
layer.bindTime(marker.program);
if(Number.isFinite(lifeTime)) {
setTimeout(() =&gt; {
layer.unbindTime(marker.program);
marker.dispose();
spot.dispose();
marker.program.remove();
spot.program.remove();
}, lifeTime);
}
return {marker, spot};
}
}
```
## 要点总结
今天,我们在上节课的基础上学习了与地球交互,以及在地球上放置标记的方法。
与地球交互的实现过程我们可以总结为四步首先我们通过将三维球面坐标转换为经纬度坐标再通过topoJSON的API获取当前选中的国家或地区信息然后在离屏Canvas上将当前选中的国家或地区高亮显示最后更新纹理和重绘layer。这样高亮显示的国家或地区就出现在3D地球上了。
而要在地球上放置标记我们先要计算几何体摆放位置然后实现标记对应的shader创建WebGLProgram最后将标记添加到地球表面对应经纬度换算的球面坐标位置处用lookAt让它朝向法线方向就可以了。
到这里地球3D可视化的核心功能我们就全部实现了。实际上除了这些功能以外我们还可以添加一些更加复杂的标记比如两个点之间连线以及动态的飞线。这些功能实现的基本原理其实和放置标记是一样的所以只要你掌握了我们今天讲的思路就能比较轻松地解决这些需求了。
## 小试牛刀
实际上地球上不仅可以放置普通的单点标记还可以用曲线将两个地理点连接起来。具体的方法是在两个点之间计算弧线或贝塞尔曲线然后将这些连线生成并绘制出来。在SpriteJS的3D扩展中有一个Path3d对象它可以绘制空间中的三维曲线。你能试着实现两点之间的连线吗如果很轻松就能实现你还可以试着添加动画实现两点之间的飞线动画效果。提示你可以通过官方文档来学习Path3d的用法实现两点之间的连线。在实现飞线动画效果的时候你可以参照GitHub仓库里的代码来进行学习来理解我是怎么做的
欢迎把你实现的效果分享到留言区,我们一起交流。也欢迎你把这节课转发出去,我们下节课见!
## 源码
示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)
## 推荐阅读
[球坐标与直角坐标的转换](https://zhuanlan.zhihu.com/p/34485962)

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="42 | 如何整理出我们自己的可视化工具集?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/9b/169c4556ea87100eb2ff21b128c8289b.mp3"></audio>
你好,我是月影。
我们知道可视化虽然可以认为是前端的一个小分支但总体来说它也是一个比较有广度的领域从内容上大体包括数据图表、图形绘制、地理信息可视化、3D场景和数字孪生应用等等从设备上包括移动端、PC浏览器和可视化大屏等等。
作为刚刚入门可视化领域的工程师,我们经常会因为知识面不够广、工具不够丰富,而寄望于用单一的工具来解决我们遇到的所有问题,所谓“手里有锤子,看什么都像是钉子”。想想也知道,这并不能很好地处理所有的可视化应用。因为,一旦我们选择了错误的、不适合于当前场景的工具,就会让最终实现的结果不理想,或者开发效率不高。
因此,想要成为一名优秀的可视化工程师,我们必须要知道如何丰富自己手中的工具集,来应对各种场景和挑战。丰富工具集,并不意味着我们需要从头开发一切适合各种场景使用的工具。大部分时候,我们可以利用行业发展和社区的力量,站在巨人的肩膀上,使用各种成熟的工具,来更好地完成我们的项目。
那今天,我带你一起系统地梳理目前行业里,适合可视化应用的优秀且成熟的工具,来丰富你的工具箱,让你在面对各种可视化需求时能够游刃有余。
首先我们来看看可视化应用的主体需求分类搞清楚了这些我们才能选择出更合适的工具来完成这些需求。根据可视化场景主体需求一般可以分成5种分别是绘制基本图形、绘制基础图表、绘制关系图和流程图、绘制地理信息以及绘制三维模型和数字孪生应用。接下来我就一一来说。
<img src="https://static001.geekbang.org/resource/image/88/a8/8869a44444710e25556fd1b801a400a8.jpeg" alt="">
## 选择绘制基本图形的工具
我们先来说说绘制基本图形要选择的工具。简单情况下我们用浏览器原生的四类基本图形系统就可以完成绘制分别是HTML/CSS、SVG、Canvas和WebGL。
HTML/CSS比较简单也有很多丰富的前端领域的工具这里就不多说了。SVG原生的API使用起来也并不复杂不过我们也可以用一些成熟的绘图库它能够让我们更方便地绘制各种图形。我比较推荐 [Snap.svg](http://snapsvg.io/) 这个库它提供的API能够非常方便地绘制各种SVG图形。尤其值得赞扬的是它提供了一套[交互式教学文档](http://snapsvg.io/start/),能够让你快速上手整个库的使用。
如果你使用Canvas和WebGL来绘制图形可选的工具就比较多了。比较成熟的2D渲染引擎包括 [Fabric.js](https://github.com/fabricjs/fabric.js)、[Pixi.js](https://github.com/pixijs/pixi.js) 等比较成熟的2D/3D引擎包括 [SpriteJS](https://spritejs.org/#/)、[P5.js](https://github.com/processing/p5.js) 等以及比较成熟的3D引擎包括 [ThreeJS](https://github.com/mrdoob/three.js)、[Babylon.js](https://github.com/BabylonJS/Babylon.js) 等。这些库各有特点,它们的官方使用文档和示例都比较完善,我建议你多花一些时间去学习它们的使用方法。这能帮助你在绘制基本图形的时候,灵活选用适合的库。
另外既然说到了Canvas2D绘制图形那我还要提一下[Rough.js](https://github.com/rough-stuff/rough)这个库。这是个非常有特点的库,它能够绘制出带有手绘风格的图形,比如,我们在[第5课](https://time.geekbang.org/column/article/255584)就使用过它。
我刚才说的这些工具对于绘制基本的2D/3D图形来说已经足够了。但是功能相对更强大和丰富的库体积也会更大一些使用上也会复杂一些所以我们还是应该根据实际需求复杂程度来灵活选择。
## 选择绘制基础图表的工具
绘制基础图表是可视化项目中很常见的需求,我们可以采用图表库和数据驱动框架来完成。常用的成熟图表库包括 [ECharts](https://echarts.apache.org/)、[Chart.js](https://github.com/chartjs/Chart.js)、[Highcharts](https://github.com/highcharts/highcharts)、[AntV G2](https://antv-2018.alipay.com/zh-cn/g2/3.x/index.html) 这4种。大部分图表库在使用上大同小异效果也差别不大好像选什么都没有差别。但在选择图表库的时候我们也需要考虑底层的图形系统或图形库这关系到复杂图表渲染和交互的性能另外在同时需要绘制图表和基本图形的时候选择统一的图形系统可以保持一致性也能更好地实现图表与图形的协同交互。
这里我就说几个常见的搭配组合。如果你使用SpriteJS作为底层图形库还可以选择[QCharts](https://www.qcharts.cn/#/home)。如果你是在移动端设备渲染图表,可以考虑使用[AntV F2](https://antv-2018.alipay.com/zh-cn/f2/3.x/index.html),这是一个专为移动端场景设计的图表库。如果你要绘制更加灵活的图表,以及关系图和流程图,可以选择数据驱动框架,例如[D3.js](https://github.com/d3/d3)。不过D3.js虽然很好用但它是一个足够复杂的框架如果你希望在可视化领域深入发展最好能再多花一些时间彻底掌握它的使用方法。
最后,我还想再说说[Vega](https://vega.github.io/vega/)这个库。这也是一个图表库它定义了一套基于JSON规范的可视化语法以声明式的方式来绘制各种图表。最关键的是Vega定义可视化语法规范的思路对我们自己设计和实现图表库有着非常大的借鉴意义。如果你打算自己设计一套图表库我希望你能好好研究一下Vega相信你会有所收获的。
## 选择绘制关系图和流程图的工具
接着,我们再来说一类特殊的图表,比如关系图、流程图、脑图等等。我们一般将它们单独归为一类应用,称为图可视化。
图可视化怎么实现呢?我们在[第38课](https://time.geekbang.org/column/article/291822)里使用D3.js实现过相关的例子。除了D3.js以外还有一类直接绘制这些图形的图可视化库常用的有[Mermaid.js](https://github.com/mermaid-js/mermaid)、[Sigma.js](http://sigmajs.org/)以及[AntV G6](https://antv-2018.alipay.com/zh-cn/g6/3.x/index.html)等等。
其中Mermaid.js量级更轻主要是以声明的方式来绘制各种流程图。而Sigma.js和AntV G6的功能更丰富实现的图可视化不仅类型更多还能包含复杂的用户交互效果。
此外,还有一个特殊的库[Dagre](https://github.com/dagrejs/dagre)。它是绘制流程图的底层库,主要是用来计算图的元素布局,使用它再结合图形库,我们就能自己实现一个绘制流程图的图可视化库。这个图可视化库实现起来也不是很难,我把这个任务留在本节课的末尾,你可以试着去挑战一下。
## 选择地理信息可视化工具
前面两节课我们一起完成了3D地理信息可视化的实战。地理信息可视化是可视化项目中很重要的一类应用大部分可视化大屏应用场景都会包含地图或者3D地球因为许多数据本就和地理信息相关。由于需求很多因此市场上有许多地理信息可视化的库可供我们选择。
比较成熟的地理信息可视化库包括[MapBox](https://www.mapbox.com/)、[MapTalks](https://maptalks.org/)、[Leaflet.js](https://leafletjs.com/)、[MapV](https://github.com/huiyan-fe/mapv)、[AntV L7](https://antv-2018.alipay.com/zh-cn/l7/1.x/index.html)等等它们都支持简单的geoJSON、topoJSON数据和分片加载的瓦片地图。另外d3的子模块[d3-geo](https://github.com/d3/d3-geo)也能够处理地理信息可视化,尤其是它提供了多种地图投影,非常适合与其他库联动,实现各种不同的地图场景应用。
总体而言地理信息可视化是可视化领域里比较复杂的方向这些地理信息可视化库的功能也较为丰富使用场景很多如果详细来讲的话内容足够支撑起一门单独的课程了。我们前面花了3节课来讲的地理信息可视化案例其实也只是给地理信息可视化应用开了一个头如果你有兴趣深入学习可以通过这些地理信息可视化库再结合实战来真正深入掌握地理信息可视化的方法这也是成为优秀可视化工程师的必经之路。
## 处理三维模型和数字孪生应用
在学习3D图形的绘制的时候我讲到3D绘制一般有两种方式一种是加载静态的3D模型数据然后将3D物体渲染出来。这些3D模型数据通常是通过设计工具离线生成的。这种应用场景在游戏领域比较常见。
[<img src="https://static001.geekbang.org/resource/image/1a/37/1af60e98b43a3b35a6e42e75d38e8937.jpeg" alt="" title="SpriteJS加载的3D模型数据图片来源spritejs.org">](https://spritejs.org/demo/#/3d/fox)
另一类则是动态加载3D几何模型用前面绘制基本图形的工具就可以实现。在可视化应用中这类场景通常更普遍。
[<img src="https://static001.geekbang.org/resource/image/be/f9/be76cd097e373b9c225dd361f58ee2f9.jpeg" alt="" title="动态加载的几何图形图片来源OGL">](https://oframe.github.io/ogl/examples/?src=scene-graph.html)
不过在可视化领域中有一类应用也会用到非常多的3D模型那就是数字孪生应用。所谓数字孪生是对物理世界实体或系统的数字化表达。简单来说就是在虚拟世界中通过3D渲染来还原真实物理世界这需要我们将现实世界中的物体模型化为虚拟世界中的3D几何体。
[<img src="https://static001.geekbang.org/resource/image/3b/fa/3bf6256eabca8c8d5a0bcd152154fdfa.jpeg" alt="" title="物理世界和数字孪生示意图图片来源36kr.com">](https://36kr.com/p/1723581366273)
在这样的应用场景中,有时候我们可以考虑采用游戏的方式,使用游戏引擎和框架,例如[Unity](https://store.unity.com/products/unity-pro?gclid=CjwKCAjwq_D7BRADEiwAVMDdHsaPnsc1S8jvT8yY47lLFn_jH6WvSTdhlDwf5RJtrC6Leu3LN--2HhoCUqIQAvD_BwE)或者[虚幻引擎](https://www.unrealengine.com/zh-CN/)来完成我们的可视化应用。当然,这也就进入了另一个领域,游戏创作的领域。这部分内容,我们简单了解一下就可以了。
## 要点总结
可视化有着丰富的使用场景大致上可以分为5类分别是绘制基本图形、绘制基础图表、图可视化、地理信息可视化以及数字孪生。
这些场景各自有适合的工具库和框架可供我们选择,我在这里整理了一个脑图,供你在具体应用中参考。
<img src="https://static001.geekbang.org/resource/image/4d/53/4d546e0ff09bfa513767ba71612c5e53.jpg" alt="">
这是我们专栏最后一个模块的最后一节课了在这几个月的时间里我们先后学习了图形学与数学、视觉基础、视觉高级以及数据处理的相关知识。其中我们通过一些可视化实战学习了部分图形系统与图形库和工具的使用这当中主要包括SpriteJS、D3.js、QCharts等等。这和我们这节课列出的各类工具相比可以说是非常少了。
我想有的同学可能会有疑问,还有这么多的工具我们并没有详细来讲,那我学习到这里,算是真正入门可视化了吗?我想说的是,工具和库都是为我们服务的,正确使用这些工具和库的基础,其实就是我们前面学过的图形学与数学基础、视觉呈现技术,以及数据处理的原则,这些内容才是可视化的本质。而上层的工具和库的使用虽然复杂,但并不难,基本上照着文档学习和实践,就能够一步步掌握。
而如果缺少扎实的基础,那么在使用工具遇到缺失功能或者性能问题的时候,你就可能会束手无策。因此,这门课,更多的还是为你打好基础,领你进入可视化的世界,今后的路,就要靠你自己一步一步地走了。
## 小试牛刀
今天我特意提到了Dagre这个库。它是一个基础库经常用来给流程图的元素布局不涉及渲染的部分。你能用它作为基础再以SpriteJS为渲染引擎来实现一个高效、强大的流程图库吗提示在API方面你可以借鉴Mermaid.js
除此之外这节课我们介绍的工具和库非常多你肯定很难在短时间内完全掌握但我也不希望你只是泛泛了解。所以我希望你能选择自己最感兴趣的同类库中的两个库利用我之前在GitHub仓库里留的北京天气和空气质量数据分别使用它们做一些可视化应用比较两个库在使用上的不同以及各自的优、劣势。
欢迎把你实现的效果分享到留言区,也欢迎你把自己在工具整理上的心得体会分享出来,我们一起交流!
## 推荐阅读
[1] [Snap.svg](http://snapsvg.io/)、[2] [Fabric.js](https://github.com/fabricjs/fabric.js)、[3] [Pixi.js](https://github.com/pixijs/pixi.js)
[4] [SpriteJS](https://spritejs.org/#/)、[5] [P5.js](https://github.com/processing/p5.js)、[6] [ThreeJS](https://github.com/mrdoob/three.js)
[7] [Babylon.js](https://github.com/BabylonJS/Babylon.js)、[8] [Rough.js](https://github.com/rough-stuff/rough)、[9] [ECharts](https://echarts.apache.org/)
[10] [Chart.js](https://github.com/chartjs/Chart.js)、[11] [Highcharts](https://github.com/highcharts/highcharts)、[12] [AntV G2](https://antv-2018.alipay.com/zh-cn/g2/3.x/index.html)
[13] [QCharts](https://www.qcharts.cn/#/home)、[14] [AntV F2](https://antv-2018.alipay.com/zh-cn/f2/3.x/index.html)、[15] [Vega](https://vega.github.io/vega/)
[16] [Mermaid.js](https://github.com/mermaid-js/mermaid)、[17] [Sigma.js](http://sigmajs.org/)、[18] [AntV G6](https://antv-2018.alipay.com/zh-cn/g6/3.x/index.html)
[19] [Dagre](https://github.com/dagrejs/dagre)、[20] [MapBox](https://www.mapbox.com/)、[21] [MapTalks](https://maptalks.org/)
[22] [Leafletjs](https://leafletjs.com/)、[23] [MapV](https://github.com/huiyan-fe/mapv)、[24] [AntV L7](https://antv-2018.alipay.com/zh-cn/l7/1.x/index.html)
[25] [d3-geo](https://github.com/d3/d3-geo)、[26] [Unity](https://store.unity.com/products/unity-pro?gclid=CjwKCAjwq_D7BRADEiwAVMDdHsaPnsc1S8jvT8yY47lLFn_jH6WvSTdhlDwf5RJtrC6Leu3LN--2HhoCUqIQAvD_BwE)、[27] [Unreal Engine](https://www.unrealengine.com/zh-CN/)