Skip to content
On this page

基础-绘制一个条形图


通过上一章节的学习,我们认识了“可视化苏菲世界”这个分析任务,学习了一个数据分析的模型,并且基于该模型提出了很多想要讨论的问题,甚至已经有了简单的设计手段。

但是在一个完整的数据分析的流程中,我们还处于设计阶段 也就是还停留在理论阶段。因此,这一章节我们将进入实现阶段,选择一个问题进行讨论,完成可视化图表的选择,再在浏览器里把它用 Canvas2D 和 SVG 绘制出来,让你更完整和深入地理解数据可视化。

学习是一个循序渐进的过程,所以我们先从一个比较简单的问题入手,来掌握整个实现的流程。基于简单的标准,我们选择的问题是:“哲学家、哲学问题和流派的数量关系是怎样的?”,所以我们去统计了“苏菲的世界”数据集中哲学家、哲学问题和流派的数量,得到的结果如下:

namevalue
questions17
schools25
philosophers35

想要体现这三者的数量关系,按照通用的数据分析流程,我们需要经过方案设计、数据处理、数据渲染这几个步骤。接下来,我们分别来说。

设计方案

上一章节提到的数据分析的模型主要有三部分,我们就用这三个部分的理论来分析一下“哲学家,哲学问题和流派的数量关系是怎样的?”这个问题,从而得出的我们的设计方案。

  • 认识数据:这个数据集是表格,每一条数据代表一个实体,每一个实体有明显两个属性:name 是分类属性,value 是一个数值属性。当然也有一个隐藏属性,就是它们在表格里面的索引属性:比 questions 的索引属性是 0, schools 的索引属性是 1,philosophers 的索引属性是 2,索引属性明显是分类属性。
  • 确定任务:因为我们不知道它们的关系到底是咋样的,所以这是一个发现任务,对象是哲学家、哲学问题和流派的数量的特征。
  • 设计方案:我们可以根据数据属性和任务的种类,去选择合适的可视化图表。一般来说,了解分类数据和数值属性的特征,最好的选择就是条形图。因为条水平方向的位置是分类属性,所以可以将同为分类属性的索引属性映射过去;因为同为数值属性,所以可以把 value 属性映射为条的高度;最后因为相同的道理,可以把 name 属性映射为条的颜色。

提出设计方案之后,在正式开始写代码之前,因为小册子篇幅的限制,我们先来做几个约定,让我们在有限的篇幅内可以给大家讲解更多有价值的东西。

开发约定

这里我们先介绍两个开发约定:

第一,在介绍新代码块的时候,不会显式地说新建或者修改文件或者文件夹。 文章中的代码块中的顶部会有包含该代码块文件的地址,这个地址是相对项目根目录的。如果不存在对应的文件夹和文件,就自行新建对应的文件夹或者文件。否则,对该文件夹或者文件进行修改即可。

比如我要加入以下的代码,因为目前项目中没有 hello 这个文件夹和 index.js 这个文件,所以需要新建它们,然后再增加以下的 foo 函数。

javascript
// src/hello/index.js

export function foo() {
  return 'foo';
}

第二,讲解内容很多会以注释的实行在代码中展现,不会单独说明。这样将需要实现的功能和对应的代码关联起来,一方面可以更加清楚功能的具体代码实现,另一方面也知道这段代码主要解决什么问题。

javascript
// index.js

export function foo() {
  return 'foo'; // 让调用者知道这是一个 foo 函数
}

当然最后都会把完整且可以运行的代码存储的 Github 仓库分享给大家,大家可以将代码 Clone 下来学习一下。

说了这么多废话,那么接下里我们正式进入开发环节。

数据处理

我们首先进入数据处理阶段,这个阶段的核心任务或者难点就是“如何把原始数据转换成可以直接绘制的数据?”,包括 “转换过程分为哪几个阶段?,我们接下来一点点来看。

首先加入如下代码,用来声明我们要可视化的数据和画布的维度信息。

javascript
// ch03/barchart/canvas/index.js

const data = [
  { name: "questions", value: 17 },
  { name: "schools", value: 25 },
  { name: "philosophers", value: 35 },

];

const chartWidth = 480; // 条形图的宽度
const chartHeight = 300; // 条形图的高度
const margin = 15; // 条形图的外边距

const containerWidth = chartWidth + margin * 2; // 容器的宽度
const containerHeight = chartHeight + margin * 2; // 容器的高度

接下来我们把需要编码的属性对应的值提取出来。

javascript
// ch03/barchart/canvas/index.js

const names = Array.from(data, (d) => d.name);
const values = Array.from(data, (d) => d.value);
const indices = Array.from(data, (_, i) => i);

布局

这之后我们需要对每一个条进行布局,这个地方就是计算出每一个条的左下顶点的坐标。

javascript
// ch03/barchart/canvas/index.js

// 计算每一个条左下顶点的横坐标
// 位置和在数组里面的 index 有关
const step = chartWidth / names.length;
const barWidth = step * 0.8;
const xs = Array.from(indices, (i) => i * step);

// 计算每一个条左下顶点的纵坐标
// 因为所有条底部都是对齐的,所以就是图表的高度
const y = chartHeight;

映射

布局完成之后就来到了映射过程。这里我们需要把数据的 value 属性映射为条的高度,需要把数据的 name 属性映射为条的颜色。

javascript
// ch03/barchart/canvas/index.js

// 获得每一个条的高度
// 条的高度应该和 value 线性相关的
const vmax = Math.max(...values);
const barHeights = Array.from(values, (v) => chartHeight * (v / vmax));

// 获得每一个条的颜色
const nameColor = {
  questions: "#5B8FF9",
  philosophers: "#61DDAA",
  schools: "#65789B",
};

const colors = Array.from(names, (name) => nameColor[name]);

数据渲染

目前为止我们的数据已经处理完成了,接下来就进入到了数据渲染阶段。数据渲染阶段的核心任务就是是处理后的数据渲染到屏幕上了。

这个阶段需要考虑:选择什么作为承载我们可视化结果的容器,同时选择什么样的绘制技术来渲染这些数据。往往面对不同的可视化需求,不同量级的数据,我们需要选择不同的渲染技术,渲染技术的选择和学习也是其中一个难点。

毫无疑问,作为前端这个领域,承载我们可视化的结果容器就是现代浏览器。在现代浏览器中可以完成可视化的绘制技术至少有4个:HTML、Canvas2D、SVG、WebGL。HTML 的绘制不够灵活,WebGL 主要针对三维场景的可视化(虽然也可以进行二维可视化,我们之后会了解到),所有主流的 2D 可视化的绘制技术主要有两种: Canvas2D 和 SVG。

Canvas2D 和 SVG 的使用方式不同,针对的场景也不同,接下来我们就分别说说。

Canvas2D

首先,我们来看看 Canvas2D。Canvas2D 是一种指令式的绘图系统,我们调用一些绘图指令,就可以在画布上绘制对应的视觉元素。我们接下来通过绘制一个矩形和 hello world 来看看 Canvas2D 的使用方式。

首先新建一个名叫 canvas-demo 的文件夹,输入以下内容:

(1)在 HTML 文件中声明一个画布。

html
<!-- ch03/canvas-demo/draw/index.html -->

<!-- 声明一个画布,之后的可视化主要在这里发生 -->
<canvas id="canvas"></canvas>
<script src="./index.js"></script>

(2)获得绘制上下文并且设置维度信息。

javascript
// ch03/canvas-demo/draw/index.js

// 获得 canvas 容器元素
const canvas = document.getElementById("canvas");

// 设置 canvas 的样式宽高
// 样式宽高决定了 canvas 在画布上呈现的大小
canvas.style.width = 400 + "px";
canvas.style.height = 200 + "px";

// 设置 canvas 画布宽高
// 这个宽高是可以绘制区域的大小
// 样式宽高是默认等于画布宽高的
canvas.width = 400;
canvas.height = 200;

// 获得绘制的上下文
// 之后的 API 都是通过调用 context
const context = canvas.getContext("2d");

(3)绘制视觉元素

这里需要注意的 Canvas2D 的坐标系和我们课本里介绍的坐标系有点不同:它的原点是在左上角,x 轴是从左到右的,y 轴是从上到下的,大家可以参考下面的这张图。

javascript
// ch03/canvas-demo/draw/index.js

// 绘制一个矩形
context.fillStyle = "red"; // 设置填充颜色
context.strokeStyle = "yellow"; // 设置边框的颜色
context.lineWidth = 10; // 设置边框的宽度
context.strokeRect(0, 0, 100, 100); // 绘制边框
context.fillRect(5, 5, 95, 95); // 绘制填充颜色

// 绘制一段文字
context.fillStyle = "black"; // 设置文字的颜色
context.font = "25px PingFangSC-Regular, sans-serif"; // 设置文字的大小和字体
context.fillText("hello world", 150, 100); // 绘制文字

最后的绘制效果如下:

当然 Canvas2D 的能力肯定不止绘制矩形和文字,更多的基本图形:圆形、线段和路径这些我们之后会涉及,但绘制方式都大同小异。首先,我们设置一些绘制的样式,然后调用绘制命令来绘制拥有这些样式的视觉元素。所以其实 Canvas 的 API 主要分为两类,一类是设置状态的 API: context.fillStyle = 'red' ,另一类是绘制的 API:context.fillRect(0, 0, 10, 10)

下面再介绍一下 Canvas2D 坐标变换能力,我们可以对其进行平移、缩放和旋转等坐标系变换操作,具体的使用方式和效果参考下面这个例子:

html
<!-- ch03/canvas-demo/transform/index.html -->

<!-- 声明一个画布,之后的可视化主要在这里发生 -->
<canvas id="canvas"></canvas>
<script src="./index.js"></script>
javascript
// ch03/canvas-demo/transform/index.js

/*
 * 这里需要补上获得绘制上下文并且设置维度信息的内容。
 */
context.fillStyle = "red";
context.fillRect(0, 0, 50, 50);
context.fillStyle = "red";
context.fillRect(0, 0, 50, 50);

// 进行一系列坐标变换
context.fillStyle = "blue";
context.translate(50, 50);
context.rotate(-Math.PI / 6);
context.scale(2, 3);
context.fillRect(0, 0, 50, 50);

最后得到的绘制效果如下图:

知道了 Canvas2D 的基本用法,接下来我们就可以把上面处理好的数据渲染出来了。

html
<!-- ch03/barchart/canvas/index.html -->

<canvas id="container-canvas"></canvas>
<script src="./index.js"></script>
javascript
// ch03/barchart/canvas/index.js

const canvas = document.getElementById("container-canvas");
canvas.style.width = containerWidth + "px";
canvas.style.height = containerHeight + "px";

// 下面把画布宽高设置为样式宽高的两倍主要是为了解决模糊问题
// 这个地方就不详细展开了,感兴趣的可以自行查阅
canvas.width = containerWidth * 2;
canvas.height = containerHeight * 2;

const context = canvas.getContext("2d");
context.scale(2, 2); // 抵消将画布宽高设置为样式宽高两倍的影响

context.translate(margin, margin); // 将坐标原点移动到绘制图表的区域

for (const index of indices) {
  // 将需要绘制的属性取出来
  const color = colors[index];
  const x = xs[index];
  const barHeight = barHeights[index];
  const value = values[index];
  // 绘制条
  context.fillStyle = color;
  context.fillRect(x, y - barHeight, barWidth, barHeight);

  // 绘制值
  context.textAlign = "center";
  context.textBaseline = "middle";
  context.fillStyle = "white";
  context.font = "25px PingFangSC-Regular, sans-serif";
  context.fillText(value, x + barWidth / 2, y - barHeight / 2);
}

最后的绘制结果如图:

SVG

接下来,我们再看看第二种绘图方式:SVG(Scalable Vector Graphics),可缩放矢量图,它是浏览器支持的一种基于 XML 语法的图像格式。相对于 Canvas2D 这种指令式的绘图系统来讲,SVG 是一种声明式的绘图系统,它的使用方式和普通的 DOM 元素非常像,所以使用起来比较简单。下面我们通过绘制和上面相同的内容来看看 SVG 的使用方法,新建一个名叫 svg-demo 的文件夹。

(1)基础图形的绘制

SVG 的坐标系和上面提到的 Canvas2D 所用的坐标系是一样的。

html
<!-- ch03/svg-demo/draw/index.html -->

<!-- width,height 可以简单理解为样式宽高 -->
<!-- viewBox 可以简单理解为画布宽高 -->
<svg
  xmlns="http://www.w3.org/2000/svg"
  version="1.1"
  width="400"
  height="200"
  viewBox="0, 0, 400, 200"
>
  <rect
    height="95"
    width="95"
    x="5"
    y="5"
    stroke="yellow"
    stroke-width="10"
    fill="red"
  />
  <text
    fill="black"
    font-family="PingFangSC-Regular, sans-serif"
    font-size="25"
    x="150"
    y="100"
  >
    hello world
  </text>
</svg>

下面是绘制的效果:

可以发现通过 SVG 绘制出来的效果和 Canvas2D 绘制出来的效果几乎一摸一样(除了边框的粗细以外)。但是绘制的方式却完全不同,在 SVG 中我们只用在 <svg></svg> 插入对应的 SVG 元素,然后设置它们的属性就好了,这和 DOM 的使用方式非常类似。当然除了上面展示的 <rect /><text /> 标签,还有绘制其他图形的标签,比如 <circle /><path /><line /> 这些,我们后面会介绍。

(2)坐标变换

在 SVG 实现坐标变换的其中一种方式是使用 <g /> 标签,给它设置 transform 属性,它将对它的所有子元素进行相应的变换。

html
<!-- ch03/svg-demo/transform/index.html -->

<svg
  xmlns="http://www.w3.org/2000/svg"
  version="1.1"
  width="400"
  height="200"
  viewBox="0, 0, 400, 200"
>
  <rect height="50" width="50" x="0" y="0" fill="red" />
  <g transform="translate(50, 50) rotate(-30) scale(2, 3) ">
    <rect height="50" width="50" x="0" y="0" fill="blue" />
  </g>
</svg>

上看的代码同样可以获得如 Canvas2D 绘制的效果:

在了解了 SVG 的基本使用方式之后,就可以通过 SVG 来渲染我们处理好的数据了。首先,我们把 Canvas 2D 绘制相关的内容注释掉。

html
<!-- ch03/barchart/svg/index.html -->

<svg id="container-svg"></svg>
<script src="./index.js"></script>
javascript
// ch03/barchart/svg/index.html

/*
 * 这里要补上数据处理的内容
 */

// 直接使用 document.createElement 是不行的
function createSVGElement(type) {
  return document.createElementNS("http://www.w3.org/2000/svg", type);
}

const svg = document.getElementById("container-svg");
// 设置 svg 的坐标原点和大小
svg.setAttribute("width", containerWidth);
svg.setAttribute("height", containerHeight);
svg.setAttribute("viewBox", [0, 0, containerWidth, containerHeight]);

// 创建一个 g 元素用于平移
const g = createSVGElement("g");
g.setAttribute("transform", `translate(${margin}, ${margin})`);
svg.appendChild(g);

for (const index of indices) {
  // 取得对应的属性
  const color = colors[index];
  const x = xs[index];
  const barHeight = barHeights[index];
  const value = values[index];

  // 绘制条
  const rect = createSVGElement("rect");
  rect.setAttribute("x", x);
  rect.setAttribute("y", y - barHeight);
  rect.setAttribute("fill", color);
  rect.setAttribute("width", barWidth);
  rect.setAttribute("height", barHeight);
  g.appendChild(rect);

  // 绘制值
  const text = createSVGElement("text");
  text.textContent = value;
  text.setAttribute("text-anchor", "middle");
  text.setAttribute("fill", "white");
  text.setAttribute("font-family", "PingFangSC-Regular, sans-serif");
  text.setAttribute('font-size', 25);
  text.setAttribute("alignment-baseline", "middle");
  text.setAttribute("x", x + barWidth / 2);
  text.setAttribute("y", y - barHeight / 2);

  g.appendChild(text);
}

最后绘制的结果如下图:

SVG VS Canvas2D

就这样,不知不觉,我们就用 SVG 和 Canvas2D 从 0 到 1 绘制了一个简单的条形图,而且效果几乎一模一样。那么,它们分别有什么特点,以及分别适合什么样的绘制场景呢?

SVG 的优点是方便交互,因为它也有 DOM 结构,可以方便地监听事件。但是性能方面却有所影响:如果我们要绘制的图形非常复杂,这些元素节点的数量就会非常多。而节点数量多,就会大大增加 DOM 树渲染和重绘所需要的时间。

相比来说,Canvas 交互实现就不太容易,因为对每个图形的拾取(判断鼠标点位置在哪个图形上)需要开发者自己实现(很多渲染引擎会解决这个问题,我们后面会看到),但是它的绘制性能却相对较优。

所以当数据量不大且侧重交互的情况,用 SVG 比较合适;当数据量较大的时候用 Canvas 比较合适。

回答问题

总结了一下我们的绘制技术,但是我们不能忘记我们绘制图表的初衷:回答问题。虽然目前我们绘制的条形图很简单,连坐标轴,图例这些都没有,但是已经足够解决我们的问题了:哲学家,哲学问题和流派的数量关系是怎样的?

可以发现在《苏菲的世界》这本书中提到的流派的数量比哲学问题多,哲学家又比流派的数量多,说明大多数的哲学问题都有多个流派尝试解答,大多数流派都有多个哲学家。虽然这个结论很简单,或者说整个分析任务都很简单,但是“麻雀虽小,五脏俱全”,已经足够我们一窥数据分析的大部分东西了。

小结

这一章我们学习了 Canvas 和 SVG 两种 Web 绘图技术,同时也简单了解一下绘制条形图的流程,最后解决了前面提出的一个问题。但是可以发现这样用可视化分析数据的效率还是很低的,如果我们在解答后面的问题的时候,还需要这样写这么多代码,那不知道要分析道猴年马月去了。

大家不要着急,你们存在的顾虑前人早就考虑到啦。下一节我们就从理论上先介绍一种加快我们制作图表的语法:可视化语法,然后在从不同层级上看看世界上目前有哪些优秀的图表制作工具,它们又是怎样简化我们的绘制流程的!

想看完整代码,点这里