可视化图表实现揭秘

322次阅读  |  发布于3年以前

1. 介绍

1.1 什么是数据可视化?

可视化是利用计算机图形学和图像处理技术,将数据转换成图形或者图像在屏幕上显示出来,再进行交互处理的理论、方法和技术。

数据可视化并不是简单的将数据变成图表,而是以数据为视角,看待世界。数据可视化就是将抽象概念形象化表达,将抽象语言具体化的过程。

1.2 为什么要用数据可视化

1 . 首先我们利用视觉获取的信息量绝对远远的比别的感官要多得多。 2 . 它能帮助分析的人对数据有更全面的认识,下面举个

我们看下面几组数据:

对数据进行简单的数据分析,每组数据都有两个变量 X 和 Y,然后用常用的统计算法评估其特点。

猛一看,你会觉得数据都是同一个特点。但如果通过可视化方式展示出来,就会有不同效果

3 . 人类大脑在记忆能力的限制。实际上我们观察物体的时候,我们大脑和计算机一样有长期的记忆(memory 硬盘)和短期记忆(cache 内存),只要我们让短期记忆中的文字、物体等一遍遍的巩固,它们才可能进入长期记忆。很多研究表明,在进行理解和学习的时候,图文更有效的帮助我们记忆,也更有趣,容易理解。

1.3 常见的前端开发中有什么可视化工具

对于在 Data 部门或者做跟数据相关工作的同学,一定对可视化不陌生,常见的场景有大屏、3D 展示等等。同样,现阶段前端层面涌现出多种可视化方案,这里简单罗列几种:

1.4 前端可视化图表是怎么绘制出来的

这里我们只简单介绍 2D 的绘制方案。

1 . Canvas。其基于位图的图像。其使用 JavaScript 程序绘图(动态生成),提供的功能更原始,适合图像处理、动态渲染以及大数据量绘制。优点如下:

a . 性能高,可以自己控制绘制过程。

b . 可控性高(像素级别)。

c . 内存占用恒定(与像素点个数有关)。

2 . Svg。其基于矢量的图像。适合用来做动态生成,且容易编辑。

a . 不失真,放大缩小都清晰。

b . 学习成本低,其也是一种 DOM 结构。

c . 使用方便,设计软件即可导出(icon 就是这样实现的)。

听了上面的介绍,似乎感觉对可视化有了一定的了解,但它到底是怎么绘制出来的以及交互是怎么做的呢?

2. 如何实现绘图(Canvas 版本)

先不要着急,在介绍如何绘图之前,我们先来了解几个专业名词:

看完上面的一连串专业名称,先别着急脑袋晕,下面我们看看怎么用 Canvas 绘制一条线

2.1 绘制一条线

线是可视化中最常见的图形元素了,最常见的就是折线图

一条线是由多个点来定义,按照点和点之间的连接方式不同,我们可分为 “折线” 和 “曲线”,在可视化渲染时又能分为 “虚线” 和 “实线”。

换个思路,我们用线来绘制闭合的路径,从而形成封闭区域,就能实线面积图和雷达图,就像这样。

下面我们来看看到底如何绘制一个线图呢?

2.1.1 什么是线?

我们都知道,线是由点组成的,两个相邻的点连接起来就成为一个 “段”,多个段拼装组成一条线,就像这样。

转化成程序思维我们可以得知:

2.2 实现折线

2.2.1 获取段

折线拆分为段的实现很简单,根据传入的点数据,相邻两点划为一段。下面简单演示一下(大概写个逻辑):

getSegment(points, defined) {
  segCache ← [];
  totalLength ← 0;
  for p, i
    pnext ← points[i + 1]
    if pnext
      // 两个点确定一条段 调用对应函数
      segment = CreateSegment
      // 缓存数据
      segCache ← segment
      // 计算段的长度
      segment.length ← distance
      // 计算总长度
      totalLen ·····
      // 判断是否空段
      if ···
        // 一些逻辑
  // 返回段和总长度      
}

实现很简单,依次遍历点数据,初始化段对象,这里有个计算段长度的逻辑,段的长度要用后面会说到,至于长度怎么算,很简单就不说了。上面有个判断是否为空段的逻辑,之所以做这个操作是因为在实际应用中,有些业务场景需要隐藏某些段,可以看看下面的图:

2.2.2 使用 Canvas 绘制线段

Canvas 提供了两个 API —— moveTo 和 lineTo,具体操作中我们需要调用 moveTo 将画笔定位到线段的起点,然后通过 lineTo 绘制到线段的终点即可,如果多个首尾相接的线段可以忽略 moveTo(Canvas 内部存储当前上下文),直接 lineTo。

基于上述方法,我们只需要遍历一条线中所有段,依次连接就可以了,为了处理空段,我们需要设置一个 start 的标记变量,如果处于 start 状态,会先 moveTo 到新的点,而不是 lineTo,大概代码如下:

drawLine(ctx) {
  defined ← false
  // 设置开始标志(先moveTo)
  lineStart
  for i ← 0 to len
    seg ← segCache[i]
    ...
    if i = len
      lineEnd
      strokeLine
    else
        // 判断是否为空段
        if ...
          drawSeg //否
        else
          lineStart // 是
}
drawSeg(seg, ctx) {
  if lineStart
    moveTo
  ····
  drawLine
}
drawLine(x, y, ctx) {
  lineTo
}

这块可能会有个疑惑,感觉把线拆成段绘制好像更麻烦了,多了一个拆段的步骤,为什么不直接连接点呢?这样划分相当于拆分了不同结构,那么每个结构下的元素都有自己的定制化,可视化层面可能展示的样式等等不同。比如说下面的,通过这样的灵活拼装,提升了扩展性,同时在其他方面也有优势,下面会具体介绍。

2.3 实现曲线

2.3.1 贝塞尔曲线

前面我们简单介绍了贝塞尔曲线,Canvas 也支持贝塞尔二次和三次曲线,通常使用三次贝塞尔曲线画法。下面我们详细讲解一下。

Bézier curve(贝塞尔曲线)是应用于二维图形应用程序的数学曲线。贝塞尔曲线点的数量决定了曲线的阶数,一般 N 个点构成的 N-1 阶贝塞尔曲线,即 3 个点为二阶。一般我们都会要求曲线至少包含 3 个点,因为两个点的贝塞尔曲线是一条直线。按顺序,第一个点为 起点 ,最后一个点为 终点 ,其余点都为 控制点

下面以二次贝塞尔曲线为例。

2.3.1.1 二次贝塞尔曲线

给定点 P0,P1,P2,P0 和 P2 为起点和终点,P1 为控制点。从 P0 到 P2 的弧线即为一条二次贝塞尔曲线。

在这里我们要将整个曲线的绘制量化为从 0~1 的过程,用 t 为当前过程的进度,t 的区间即 0~1。每一条线都需要根据 t 生成一个点,如下图,一个点从 P0 移动到 P1,这是这条线从 0~1 的过程。

下面我们还原一下一个二次贝塞尔曲线的生成过程。

1 . 首先我们链接 P0P1,P1P2,得到两条线段。然后我们对进度 t 进行取值,比如 0.3,取一个 Q0 点,使得 P0Q0 的长度为 P0P1 总长度的 0.3 倍。

2 . 同时我们在 P1P2 上取一点 Q1,使得 P0Q0: P0P1 = P1Q1: P1P2。接下来我们再在 Q0Q1 上取一点 B,使得 P0Q0: P0P1 = P1Q1: P1P2 = Q0B:Q0Q1

现在我们得到的点 B 就是二次贝塞尔曲线的上的一个点,如果我们使 t=0 开始取值,逐步递增进行插值,就会得到一系列的点 B,进行连接就会形成一条完整的曲线。

最终经过数据推导,我们得到了二次贝塞尔曲线公式(具体推导我们不搞了,感兴趣可以去百度看看)。

2.3.1.2 三次贝塞尔曲线

三次贝塞尔曲线由四个点组成,通过更多的迭代步骤来确定曲线上的点。

2.3.2 使用 Canvas 绘制贝塞尔曲线

在 Canvas 中绘制三次贝塞尔曲线使用 bezierCurveTo() 方法,具体参数定义可以在 MDN 上查阅,这里不罗列了。

2.3.3 样条曲线与获取段

了解了如何绘制三次贝塞尔曲线,我们回到实际场景,一个线图会有若干个数量的点连接生成。但只使用 Canvas 提供的功能,并不能满足这个需求。前面我们绘制折线是提出了段的概念,如果我们将一条完整的曲线拆分成多个段,每个段都是个三次贝塞尔曲线,问题好像就可以解决。那么问题就转化为如何生成多个贝塞尔曲线且它们能平滑连接。

上面我们介绍概念时提出了样条曲线,可能大家也没看懂,是有些抽象。简单将就是有一个点的集合,分成多段曲线,各曲线处的连接点处可以平滑连接,转化成数学术语就是说连接点有连续的一次和二次导数且一次和二次导数相同。下面我们看个

上面这个图是由多个三次贝塞尔曲线拼接而成,我们要将其划分前,需要确定几个参数:

只有当我们选择合适的起点、终点和控制点,相邻的两条曲线才能平滑连接。拆分算法很多,这里不详细介绍了(其实我也看不懂),我们实现可以直接用 d3-shape 的 Curves 接口。下面用 Basis 算法的实现用例,我们简单了解一下。

getSegment(points, defined){
  segCache ← []
  totalLen ← 0
  if points.len < 3
    getSegment
  start, end, controll1, controll2
  for i ← 0 to points.len - 2
    first ← points[i]
    second ← points[i + 1]
    third ← points[i + 2]
    if i = 0
      start ← first
    else
      start ← end

    // 计算起点、终点、控制点
    // 计算长度
    // 补算最后点
}

这段逻辑也比较简单,循环给到的点,从当前索引位置开始向后取三个点,根据这个三个点以及当前段的起始点计算结束点和控制点。每个新段的起点是上个段的终点。但是当前循环逻辑不会计算最后一个点,所以会少一段,最后加个单独逻辑处理。

2.3.4 点的计算

我们用一个简单的公式来计算各个点的值(公式结合 B 样条曲线和三次贝塞尔曲线在端点处的一阶和二阶导出得到),这里不介绍具体公式推导。

if (i === 0) {
  start = first
} else {
  start = end
}
end = Point((first.x + 4 * second.x + third.x) / 6, (first.y + 4 * second.y + third.y) / 6)
controll1 = Point((2 * first.x + second.x) / 3, (2 * first.y + second.y) / 3)
controll2 = Point((first.x + 2 * second.x) / 3), (first.y + 2 * second.y) / 3 )

2.3.5 曲线分割与长度计算

听起来这不是一个容易的事情。由于贝塞尔曲线是插值函数,所以计算只能先对曲线进行切割,然后计算足够小的这一小段的曲线近似长度,再累加。这个计算量有点大,不过有大神给了个思路 传送门 。

1 . 找到连接的点。假设我要在 t=0.25 的位置将当前曲线切分成两条曲线,首先我们要知道点 B 的位置。根据公式代入即可。

2 . 获取控制点。拿到点 B 之后,其为第一段的终点,第二段的起点,我们需要计算控制点。根据数学逻辑,我们可以得出:

根据上面结论,拆分就很简单了。(这块代码有点长,就不写了)

3 . 长度计算。我们可以在任意位置对三次贝塞尔曲线进行拆分了,结合二分法,控制迭代次数,结合近似长度计算函数,我们可以得到想要精度的长度值了。(代码也不写了)

4 . 获取段。现在我们需要处理最后一个点的特殊逻辑,这里将第二个点和第三个点都用最后一个点表示。

first ← points[i - 2]
second ← points[i - 1]
third ← points[i -1]
start ← end
end ← third

···
···

5 . 曲线画法。前面都准备好了,现在只需要调用 Canvas 的 API 就能画线了。

2.4 怎么处理动画

前面我们遗留了一个问题,为什么需要计算长度?

我们已经完成了线的绘制,如何做少量的改动实现动画呢?我们可以了解到不管直线和曲线,我们都分了很多段,而这些段都是和 t 相关的。

2.4.1 方案

动画的本质就是在一定的时间内绘制某一部分区域,我们将整个线条区域划分到 [0, 10] 区间,启动一个循环,每次绘图时更新 t 的值,在上面循环绘制 segment 的代码中,将整条线图的 t 转化为每一个段内部的 t 值,段内部根据 t 值对自身切割,只画应该绘制的那部分即可。

由于我们已经计算了每个段的长度和总长度,所以每个段的占比可以计算,此占比再和整个线图的 t 值进行换算即可。这个思路其实就是 局部绘制

但对于面积图,其实会分为两组 segment 绘制,绘制时我们会发现在同一个 t 时,在 x 方向的位移是不同步的。绘制动画从左向右推进,比如绘制第一段时,计算第一段应该被绘制的区间,最后填充上下两段的闭合区间,但有个问题,如果相同的 t,代入不同组 segment 的函数中,产生的 x 值不一样,那么绘制的效果就不对了,切面会是斜的。

解决这个问题做法是根据 x 或者 y 值反求 t 值,再代入目标函数中。对于三次贝塞尔曲线来说,这又是一个大难题,由于篇幅所限及代码实现的比较复杂,这里不讲了(其实我不会, 但这有地方会 )。

2.5 交互

交互无非是点一点,摸一摸。但从上面我们得知,一条线有那么多点,怎么知道鼠标触发的是那个点呢?

2.5.1 Canvas 的拾取方案

绘制时 Canvas 不会保存绘制图形的信息,一旦绘制完成用户在浏览器中其实是一个由无数像素点组成的图片,用户点击时无法从浏览器自带的 API 获取点击到的图形。常见的拾取方案有以下几种:

上面的各种拾取方案各有利弊,下面来详细的介绍各种方案的实现方式和一些问题,最后对比一下性能。

2.5.1.1 使用缓存 Canvas 方案

使用缓存的 Canvas 来进行图形的拾取步骤如下:

优缺点
适合的场景和不适宜的场景
性能检测

2.5.1.2 使用内置 API

Canvas 标签提供了一个接口 isPointInPath() 来获取对应的点是否在绘制的图形内部,操作步骤如下:

优缺点
适合的场景
性能检测

2.5.1.3 几何包围盒检测方案

最开始我们提到了包围盒,现在有了使用的地方。

Canvas 上绘制的图形都是标准的几何图形,点、线、面的检测在几何算法中比较成熟,每个图形在绘制时都会给其生成一个包围盒并保存,当拾取图形时可以直接使用数据运算检测。

检测过程如下:

优缺点
适合的场景
性能检测:

2.5.1.4 混杂拾取

在实例的应用过程中并非使用某一种拾取方案,通常将多种拾取方案混合使用,大致分为以下方案:

2.5.1.5 总结

在 Canvas 上拾取图形时的方案选择与用户的场景密切相关,不同的场景适用的方案也不同:

3. 总结

上述全文介绍了什么是可视化,紧接着我们分析了线图的实现方案以及图形的交互实现。总结来说,可视化无时无刻不存在在我们身边,看起来好像充满神秘色彩,但我们仔细研究会发现,实现可视化并不是一件难事,上述流程如果有出错的地方,还请批评指正。

4. 本文引用

1 . G 渲染引擎文档

2 . 贝塞尔曲线

3 . ByteCharts 实现文档

4 . BizCharts

5 . D3

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8