可视化是利用计算机图形学和图像处理技术,将数据转换成图形或者图像在屏幕上显示出来,再进行交互处理的理论、方法和技术。
数据可视化并不是简单的将数据变成图表,而是以数据为视角,看待世界。数据可视化就是将抽象概念形象化表达,将抽象语言具体化的过程。
我们看下面几组数据:
对数据进行简单的数据分析,每组数据都有两个变量 X 和 Y,然后用常用的统计算法评估其特点。
猛一看,你会觉得数据都是同一个特点。但如果通过可视化方式展示出来,就会有不同效果
对于在 Data 部门或者做跟数据相关工作的同学,一定对可视化不陌生,常见的场景有大屏、3D 展示等等。同样,现阶段前端层面涌现出多种可视化方案,这里简单罗列几种:
这里我们只简单介绍 2D 的绘制方案。
听了上面的介绍,似乎感觉对可视化有了一定的了解,但它到底是怎么绘制出来的以及交互是怎么做的呢?
先不要着急,在介绍如何绘图之前,我们先来了解几个专业名词:
B 样条基有如下性质:
看完上面的一连串专业名称,先别着急脑袋晕,下面我们看看怎么用 Canvas 绘制一条线
线是可视化中最常见的图形元素了,最常见的就是折线图
一条线是由多个点来定义,按照点和点之间的连接方式不同,我们可分为 “折线” 和 “曲线”,在可视化渲染时又能分为 “虚线” 和 “实线”。
换个思路,我们用线来绘制闭合的路径,从而形成封闭区域,就能实线面积图和雷达图,就像这样。
下面我们来看看到底如何绘制一个线图呢?
我们都知道,线是由点组成的,两个相邻的点连接起来就成为一个 “段”,多个段拼装组成一条线,就像这样。
转化成程序思维我们可以得知:
折线拆分为段的实现很简单,根据传入的点数据,相邻两点划为一段。下面简单演示一下(大概写个逻辑):
getSegment(points, defined) { segCache ← [];
totalLength ← 0;
for p, i
pnext ← points[i + 1]
if pnext
// 两个点确定一条段 调用对应函数
segment = CreateSegment
// 缓存数据
segCache ← segment
// 计算段的长度
segment.length ← distance
// 计算总长度
totalLen ·····
// 判断是否空段
if ···
// 一些逻辑
// 返回段和总长度
}
实现很简单,依次遍历点数据,初始化段对象,这里有个计算段长度的逻辑,段的长度要用后面会说到,至于长度怎么算,很简单就不说了。上面有个判断是否为空段的逻辑,之所以做这个操作是因为在实际应用中,有些业务场景需要隐藏某些段,可以看看下面的图:
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 的弧线即为一条二次贝塞尔曲线。
在这里我们要将整个曲线的绘制量化为从 0~1 的过程,用 t 为当前过程的进度,t 的区间即 0~1。每一条线都需要根据 t 生成一个点,如下图,一个点从 P0 移动到 P1,这是这条线从 0~1 的过程。
下面我们还原一下一个二次贝塞尔曲线的生成过程。
现在我们得到的点 B 就是二次贝塞尔曲线的上的一个点,如果我们使 t=0 开始取值,逐步递增进行插值,就会得到一系列的点 B,进行连接就会形成一条完整的曲线。
最终经过数据推导,我们得到了二次贝塞尔曲线公式(具体推导我们不搞了,感兴趣可以去百度看看)。
三次贝塞尔曲线由四个点组成,通过更多的迭代步骤来确定曲线上的点。
在 Canvas 中绘制三次贝塞尔曲线使用 bezierCurveTo() 方法,具体参数定义可以在 MDN 上查阅,这里不罗列了。
了解了如何绘制三次贝塞尔曲线,我们回到实际场景,一个线图会有若干个数量的点连接生成。但只使用 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
// 计算起点、终点、控制点
// 计算长度
// 补算最后点
}
这段逻辑也比较简单,循环给到的点,从当前索引位置开始向后取三个点,根据这个三个点以及当前段的起始点计算结束点和控制点。每个新段的起点是上个段的终点。但是当前循环逻辑不会计算最后一个点,所以会少一段,最后加个单独逻辑处理。
我们用一个简单的公式来计算各个点的值(公式结合 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 )
听起来这不是一个容易的事情。由于贝塞尔曲线是插值函数,所以计算只能先对曲线进行切割,然后计算足够小的这一小段的曲线近似长度,再累加。这个计算量有点大,不过有大神给了个思路 传送门。
根据上面结论,拆分就很简单了。(这块代码有点长,就不写了)
first ← points[i - 2]
second ← points[i - 1]
third ← points[i -1]
start ← end
end ← third
···
···
前面我们遗留了一个问题,为什么需要计算长度?
我们已经完成了线的绘制,如何做少量的改动实现动画呢?我们可以了解到不管直线和曲线,我们都分了很多段,而这些段都是和 t 相关的。
动画的本质就是在一定的时间内绘制某一部分区域,我们将整个线条区域划分到 [0, 10] 区间,启动一个循环,每次绘图时更新 t 的值,在上面循环绘制 segment 的代码中,将整条线图的 t 转化为每一个段内部的 t 值,段内部根据 t 值对自身切割,只画应该绘制的那部分即可。
由于我们已经计算了每个段的长度和总长度,所以每个段的占比可以计算,此占比再和整个线图的 t 值进行换算即可。这个思路其实就是 局部绘制。
但对于面积图,其实会分为两组 segment 绘制,绘制时我们会发现在同一个 t 时,在 x 方向的位移是不同步的。绘制动画从左向右推进,比如绘制第一段时,计算第一段应该被绘制的区间,最后填充上下两段的闭合区间,但有个问题,如果相同的 t,代入不同组 segment 的函数中,产生的 x 值不一样,那么绘制的效果就不对了,切面会是斜的。
解决这个问题做法是根据 x 或者 y 值反求 t 值,再代入目标函数中。对于三次贝塞尔曲线来说,这又是一个大难题,由于篇幅所限及代码实现的比较复杂,这里不讲了(其实我不会,但这有地方会)。
交互无非是点一点,摸一摸。但从上面我们得知,一条线有那么多点,怎么知道鼠标触发的是那个点呢?
绘制时 Canvas 不会保存绘制图形的信息,一旦绘制完成用户在浏览器中其实是一个由无数像素点组成的图片,用户点击时无法从浏览器自带的 API 获取点击到的图形。常见的拾取方案有以下几种:
上面的各种拾取方案各有利弊,下面来详细的介绍各种方案的实现方式和一些问题,最后对比一下性能。
使用缓存的 Canvas 来进行图形的拾取步骤如下:
Canvas 标签提供了一个接口 isPointInPath() 来获取对应的点是否在绘制的图形内部,操作步骤如下:
最开始我们提到了包围盒,现在有了使用的地方。
Canvas 上绘制的图形都是标准的几何图形,点、线、面的检测在几何算法中比较成熟,每个图形在绘制时都会给其生成一个包围盒并保存,当拾取图形时可以直接使用数据运算检测。
检测过程如下:
在实例的应用过程中并非使用某一种拾取方案,通常将多种拾取方案混合使用,大致分为以下方案:
注意
:这种混杂模式对于简单图形” 圆 “、” 矩形 “ 的拾取并不比单纯的几何算法更快。在 Canvas 上拾取图形时的方案选择与用户的场景密切相关,不同的场景适用的方案也不同:
上述全文介绍了什么是可视化,紧接着我们分析了线图的实现方案以及图形的交互实现。总结来说,可视化无时无刻不存在在我们身边,看起来好像充满神秘色彩,但我们仔细研究会发现,实现可视化并不是一件难事,上述流程如果有出错的地方,还请批评指正。
紧追技术前沿,深挖专业领域
扫码关注我们吧!