可视化图表实现揭秘

趣谈前端

共 13505字,需浏览 28分钟

 · 2021-11-29

职业规划  高级前端  可视化低代码

点击上方 趣谈前端,关注公众号

回复进群,加入前端交流群

1. 介绍

1.1 什么是数据可视化?

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

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

1.2 为什么要用数据可视化

  1. 首先我们利用视觉获取的信息量绝对远远的比别的感官要多得多。

  2. 它能帮助分析的人对数据有更全面的认识,下面举个🌰

我们看下面几组数据:



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

  • Means(平均值):X = 9Y = 7.5

  • Variance(总体方差):X = 11Y = 4.122

  • Line regression(线性回归方程):Y = 3.0 + 0.5X

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



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

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

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

  • Echarts,可以流畅的运行在 PC 和移动设备,且兼容绝大部分浏览器(IE 8/9/10),底层使用 ZRender 作为渲染引擎,提供直观、交互丰富、可高度个性化定制的数据图表。

  • Antv,是蚂蚁金服新一代数据可视化解决方案,致力于提供一套简单方便、专业可靠、无限可能的数据可视化最佳实践。其包括 G(可视化引擎)、G2(可视化图表)、G6(图可视化引擎)、F2(移动可视化方案)、L7(地理空间数据可视化)。

  • D3,其实一个可以基于数据来操作文档的 JavaScript 库,其遵循现有 Web 标准,可以不需要其他任何框架运行在现代浏览器中。

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

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

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

    1. 性能高,可以自己控制绘制过程。
    2. 可控性高(像素级别)。
    3. 内存占用恒定(与像素点个数有关)。
  2. Svg。其基于矢量的图像。适合用来做动态生成,且容易编辑。

    1. 不失真,放大缩小都清晰。
    2. 学习成本低,其也是一种 DOM 结构。
    3. 使用方便,设计软件即可导出(icon 就是这样实现的)。



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

2. 如何实现绘图(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 绘制一条线

2.1 绘制一条线

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



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

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



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

2.1.1 什么是线?

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



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

  • 点有坐标(x, y)

  • 段有起点、终点且它们都是点,还有长度以及顺序

  • 线有若干个段也有若干个点

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 倍。



  1. 同时我们在 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 之后,其为第一段的终点,第二段的起点,我们需要计算控制点。根据数学逻辑,我们可以得出:

    • 第一段曲线的第一个控制点的运动轨迹是线段 P0P1,和 t 线性相关

    • 第一段曲线的第二个控制点的运动轨迹是线段 Q0Q1,和 t 线性相关

    • 第二段曲线的第一个控制点的运动轨迹是线段 Q1Q2,和 t 线性相关

    • 第二段曲线的第二个控制点的运动轨迹是线段 P2P3,和 t 线性相关

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

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

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

firstpoints[i - 2]
secondpoints[i - 1]
thirdpoints[i -1]
start ← end
end ← third

···
···
  1. 曲线画法。前面都准备好了,现在只需要调用 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 获取点击到的图形。常见的拾取方案有以下几种:

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

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

2.5.1.1 使用缓存 Canvas 方案

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

  • 在显示的 Canvas 上绘制图形
  • 在缓存(隐藏)的 Canvas 上重新绘制一下所有的图形,使用图形的索引值作为图形的颜色来绘制图形
  • 在显示的 Canvas 进行点击,获取缓存 Canvas 上对应位置的像素点,将像素的颜色转换成数字,这个数字就是图形的索引值
优缺点
  • 优点

    • 实现简单,只需要将图形绘制两遍即可

    • 拾取性能好,核心的拾取算法复杂度 O(1)

  • 缺点

    • 渲染开销加倍

    • 画布过大时获取缓存数据 getImageData() 方法开销很大,会降低快速拾取的收益

适合的场景和不适宜的场景
  • 适合的场景

    • 图形的数量比较大、重绘不频繁的场景

    • 支持局部刷新的场景效果更好

  • 不适合的场景

    • 频繁动画的场景,两倍的渲染开销和获取缓存数据方法的开销过大,性能反而降低

    • 图形的数据量很小的情况下优势不明显

性能检测
  • 绘制显示的 10000 个图形 6ms
  • 在缓存的图形 14ms ,增加了将数字转换成颜色的开销
  • 获取缓存的图片数据 getImageData() 的开销 14ms
  • 图形拾取的开销 0.1ms

2.5.1.2 使用内置 API

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

  • 绘制所有图形
  • 进行拾取时,调用 isPointInPath() 方法判断点是否在图形中。
优缺点
  • 优点

    • 实现简单,仅使用 Canvas 原生的接口

    • 不会拖慢首次渲染的时间

  • 缺点

    • 性能差,每次检测都得走一遍图形的绘制

    • 仅能检测是否被包围,不能检测是否在线上

适合的场景
  • 图形的量非常小,小于 100 个时

  • 可以配合包围盒检测、四分树检测一起使用

性能检测
  • 拾取 10000 个图形的时间 2000ms

2.5.1.3 几何包围盒检测方案

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

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

检测过程如下:

  • 反序检测所有的图形

  • 判断点是否在图形的包围盒内,如果不在,则返回 false

  • 如果图形绘制线,则判断是否在线上

  • 如果图形被填充,则判断是否被包围

优缺点
  • 优点

    • 图形检测算法比较成熟
    • 思路比较清晰,优化潜力大,可以通过各种缓存机制优化检测性能
    • 不会影响图形的渲染性能
  • 缺点

    • 实现复杂,特别是一些贝塞尔曲线和非闭合曲线的检测性能比较差
    • 在存在大量分层的场景下,每个分层上有 transform 的存在,矩阵运算大大降低运算的性能
适合的场景
  • 使用范围广

性能检测:
  • 10000 个点的检测性能 5 - 20ms

2.5.1.4 混杂拾取

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

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

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

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

2.5.1.5 总结

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

  • 在图形数量少,不需要精确拾取的场景下(移动端)可以直接使用 isPointInPath 方法

  • 在画布不频繁刷新、图形量大的场景下适合使用缓存的 Canvas 的方法

  • 使用几何算法的拾取方案几乎适合于所有的场景,但是需要配合各种缓存机制,并注意矩阵乘法带来的开销

  • 上面的几种方法可以混合使用,拾取的优化无止境,但是满足需求即可。

3. 总结

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

4. 本文引用

  1. G 渲染引擎文档

  2. 贝塞尔曲线

  3. ByteCharts 实现文档

  4. BizCharts

  5. D3


❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容
  • 关注公众号【趣谈前端】,定期分享 工程化 可视化 / 低代码 / 优秀开源




从零搭建全栈可视化大屏制作平台V6.Dooring

从零设计可视化大屏搭建引擎

Dooring可视化搭建平台数据源设计剖析

可视化搭建的一些思考和实践

基于Koa + React + TS从零开发全栈文档编辑器(进阶实战



点个在看你最好看

浏览 17
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报