如何实现可视化图表

719 阅读17分钟

介绍

什么是数据可视化?

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

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

为什么要用数据可视化

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

我们看下面几组数据

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

  • Means(平均值): X = 9Y = 7.5
  • Variance(总体方差): X = 11Y = 4.122
  • Line regression(线性回归方程): Y = 3.0 + 0.5X

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

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

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

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

  • Echarts,可以流畅的运行在PC和移动设备,且兼容绝大部分浏览器(IE 8/9/10),底层使用ZRender作为渲染引擎,提供直观、交互丰富、可高度个性化定制的数据图表
  • Antv,是蚂蚁金服新一代数据可视化解决方案,致力于提供一套简单方便、专业可靠、无限可能的数据可视化最佳实践。其包括G(可视化引擎)、G2(可视化图表)、G6(图可视化引擎)、F2(移动可视化方案)、L7(地理空间数据可视化)。
  • D3,其实一个可以基于数据来操作文档的JavaScript库,其遵循现有Web标准,可以不需要其他任何框架运行在现代浏览器中。

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

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

  1. Canvas。其基于位图的图像。其使用JavaScript程序绘图(动态生成),提供的功能更原始,适合图像处理、动态渲染以及大数据量绘制。优点如下
    1. 性能高,可以自己控制绘制过程。
    2. 可控性高(像素级别)
    1. 内存占用恒定(有像素点个数有关)
  1. Svg。其基于矢量的。适合用来做动态生成,且容易编辑。
    1. 不失真,放大缩小都清晰。
    2. 学习成本低,其也是一种DOM结构。
    1. 使用方便,设计软件即可导出(icon就是这样实现的)。

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

如何实现绘图(Canvas版本)

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

  • 包围盒。包围盒是一种求解离散点集最优包围空间的算法,基本思想是用体积稍大且特性简单的几何体(称为包围盒)来近似地代替复杂的几何对象,常见的包围盒算法有AABB包围盒、包围球以及固定方向凸包FDH。包围盒算法是进行碰撞干涉初步检测的重要方法。
  • 贝塞尔曲线,是应用于二维图形应用程序的数学曲线。其由线段和节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,它的计算参数公式为

  • 插值函数, 简单理解就是在离散数据的基础上补差连续函数,使得这条连续曲线通过全部给定的离散数据点。
  • B样条基函数。令U={u0,u1,…,um}是一个单调不减的实数序列,即ui<=ui+1,i=0,1,…,m-1。其中,ui称为节点,U称为节点矢量,用Ni,p(u)表示第i个p次B样条基函数,其定义为:


B样条基有如下性质:

    • 递推性
    • 局部支承性
    • 规范性
    • 可微性

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

绘制一条线

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

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

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

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

什么是线?

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

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

    • 点有坐标(x, y)
    • 段有起点、终点且它们都是点,还有长度以及顺序
    • 线有若干个段也有若干个点

实现折线

获取段

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

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

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

使用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
}

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

实现曲线

贝塞尔曲线

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

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

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

二次贝塞尔曲线

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


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

\

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

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

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

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

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

三次贝塞尔曲线

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

使用canvas绘制贝塞尔曲线

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

样条曲线与获取段

了解了如何绘制三次贝塞尔曲线,我们回到实际场景,一个线图会有若干个数量的点连接生成。但只使用canvas提供的功能,并不能满足这个需求。前面我们绘制折线是提出了段的概念,如果我们将一条完整的曲线拆分成多个段,每个段都是个三次贝塞尔曲线,问题好像就可以解决。那么问题就转化为如何生成多个贝塞尔曲线且它们能平滑连接。上面我们介绍概念时提出了样条曲线,可能大家也没看懂,是有些抽象。简单将就是有一个点的集合,分成多段曲线,各曲线处的连接点处可以平滑连接,转化成数学术语就是说连接点有连续的一次和二次导数且一次和二次导数相同。下面我们看个🌰

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

    • 每条三次贝塞尔曲线的起点和终点
    • 每条三次贝塞尔曲线的两个控制点

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

getSegment(points, defined){
	segCache[]
  totalLen0
  if points.len < 3
  	getSegment
  start, end, controll1, controll2
  for i0 to points.len - 2
  	firstpoints[i]
    secondpoints[i + 1]
    thirdpoints[i + 2]
    if i = 0
    	startfirst
    else 
    	startend
    
    // 计算起点、终点、控制点
    // 计算长度
    // 补算最后点
}

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

点的计算

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

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

曲线分割与长度计算

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

  1. 找到连接的点。假设我要在t=0.25的位置将当前曲线切分成两条曲线,首先我们要知道点B的位置。根据公式带入即可。
  2. 获取控制点。拿到点P之后,其为第一段的终点,第二段的起点,我们需要计算控制点。根绝数学逻辑,我们可以得出:
    • 第一段曲线的第一个控制点的运动轨迹是线段P0P1,和t线性相关
    • 第一段曲线的第二个控制点的运动轨迹是线段Q0Q1,和t线性相关
    • 第二段曲线的第一个控制点的运动轨迹是线段Q1Q2,和t线性相关
    • 第二段曲线的第二个控制点的运动轨迹是线段P2P3,和t线性相关

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

  1. 长度计算。我们可以在任意位置对三次贝塞尔曲线进行拆分了,结合二分法,控制迭代次数,结合近似长度计算函数,我们可以得到想要精度的长度值了。(代码也不写了)
  2. 获取段。现在我们需要处理最后一个点的特殊逻辑,这里将第二个点和第三个点都用最后一个点表示。
first ← points[i - 2]
second ← points[i - 1]
third ← points[i -1]
startend
end ← third

···
···
  1. 曲线画法。前面都准备好了,现在只需要调用canvas的api就能画线了。

怎么处理动画

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

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

方案

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

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

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

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

交互

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

canvas的拾取方案

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

  • 使用缓存 Canvas 通过颜色拾取图形
  • 使用 Canvas 内置的 API 拾取图形
  • 使用几何图形包围盒
  • 混杂上面的几种方式

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

使用缓存canvas方案

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

  • 在显示的 Canvas 上绘制图形
  • 在缓存(隐藏)的 Canvas 上重新绘制一下所有的图形,使用图形的索引值作为图形的颜色来绘制图形
  • 在显示的 Canvas 进行点击,获取缓存 Canvas 上对应位置的像素点,将像素的颜色转换成数字,这个数字就是图形的索引值
优缺点分析:
  • 优点
    • 实现简单,只需要将图形绘制两遍即可
    • 拾取性能好,核心的拾取算法复杂度 O(1)
  • 缺点
    • 渲染开销加倍
    • 画布过大时获取缓存数据 getImageData() 方法开销很大,会降低快速拾取的收益
适合的场景和不适宜的场景
  • 适合的场景
    • 图形的数量比较大、重绘不频繁的场景
    • 支持局部刷新的场景效果更好
  • 不适合的场景
    • 频繁动画的场景,两倍的渲染开销和获取缓存数据方法的开销过大,性能反而降低
    • 图形的数据量很小的情况下优势不明显
性能检测
  • 绘制显示的10000个图形 6ms
  • 在缓存的图形 14ms ,增加了将数字转换成颜色的开销
  • 获取缓存的图片数据 getImageData() 的开销 14ms
  • 图形拾取的开销 0.1ms

使用内置API

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

  • 绘制所有图形
  • 进行拾取时,调用 isPointInPath() 方法判断点是否在图形中。
优缺点:
  • 优点
    • 实现简单,仅使用 Canvas 原生的接口
    • 不会拖慢首次渲染的时间
  • 缺点
    • 性能差,每次检测都得走一遍图形的绘制
    • 仅能检测是否被包围,不能检测是否在线上
适合的场景
  • 图形的量非常小 < 100 个时
  • 可以配合包围盒检测、四分树检测一起使用
性能检测
  • 拾取 10000 个图形的时间 2000ms

几何包围盒检测方案

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

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

检测过程如下:

  • 反序检测所有的图形
  • 判断点是否在图形的包围盒内,如果不在,则返回 false
  • 如果图形绘制线,则判断是否在线上
  • 如果图形被填充,则判断是否被包围
优缺点:
  • 优点
    • 图形检测算法比较成熟
    • 思路比较清晰,优化潜力大,可以通过各种缓存机制优化检测性能
    • 不会影响图形的渲染性能
  • 缺点
    • 实现复杂,特别是一些贝塞尔曲线和非闭合曲线的检测性能比较差
    • 在存在大量分层的场景下,每个分层上有 transform 的存在,矩阵运算大大降低运算的性能
适合的场景
  • 使用范围广
性能检测:
  • 10000 个点的检测性能 5 - 20ms

混杂拾取

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

  • 包围盒 + 缓存 Canvas:使用缓存 Canvas 时需要缓存的 Canvas 的大小跟原始 Canvas 的大小保持一致,但是可以仅仅创建 1*1 的缓存 Canvas, 先通过计算是否在图形的包围盒内,将所有包含拾取点的图形在这个一像素的画布上进行绘制(需要进行 translate 将画布中心定位到拾取的点上),然后对这一像素进行颜色的检测。

注意:这种混杂模式对于简单图形”圆“、”矩形“ 的拾取并不比单纯的几何算法更快。

  • 包围盒 + isPointInPath: 简单的图形使用几何算法,复杂的很多填充的图形可以使用包围盒检测和 Canvas 内置的 isPointInPath 来检测。

总结

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

  • 在图形数量少,不需要精确拾取的场景下(移动端)可以直接使用 isPointInPath 方法
  • 在画布不频繁刷新、图形量大的场景下适合使用缓存的 Canvas 的方法
  • 使用几何算法的拾取方案几乎适合于所有的场景,但是需要配合各种缓存机制,并注意矩阵乘法带来的开销
  • 上面的几种方法可以混合使用,拾取的优化无止境,但是满足需求即可。

总结

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

本文引用

  1. G渲染引擎文档
  2. 贝塞尔曲线
  3. ByteCharts实现文档
  4. BizCharts
  5. D3