找回密码
 会员注册
查看: 14|回复: 0

可视化研发之线的画法:直线,曲线,动画(Canvas版)_UTF_8

[复制链接]

2

主题

0

回帖

7

积分

新手上路

积分
7
发表于 2024-9-30 18:51:23 | 显示全部楼层 |阅读模式
“线”是可视化展现中最常见的图形元素,最直观的就是折线图,如图一。图一 折线图一条线由多个点来定义,按照点与点之间的连接方式,通常将线分为“折线”和“曲线”,在画法上又分为“实线”和“虚线”,如图二:图二 折线和曲线我们也经常使用线来绘制闭合的路径,从而形成可填充区域,比如面积图和雷达图,如图三和图四。图三 面积图图四 雷达图本篇文章在 Canvas API 的基础上,为大家讲解可视化研发中线的画法封装和线的动画实现方案(整体方案建立在图形学基础上,同样适用于 WegGL 和 3D 场景)。* 0.1 线的定义前面我们提到过线的基本组成单位是“点(Point)”,两个相邻的点连接在一起成为一个“段(Segment)”,多个段拼装在一起组成一条线。如图六,这条线由 7 个点划分成的 6 个段组合而成。图六 点和段曲线的每个段的起止点会因为插值算法的不同而不同,后面我们会详细介绍。图七所示的伪代码展示了我们对线的基本定义。图七 线的定义线的绘制是以段为单位的,不同的形状的线对段的拆分逻辑和画法都是有区别的,我们从最简单的折线开始。0.2 折线画法0.2.1 获取段折线对段的拆分很简单,根据传入的点数据,相邻两点划为一段。图八 折线段的拆分如上面的代码,实现很简单,依次遍历点数据,初始化段对象。这里有一个计算段的长度的操作,段的长度在动画场景是必须参数,在非动画场景则可以不用关心。折线的段的长度计算,就是计算一个线段的长度(两点间距离),如图九所示。图九 线段的距离计算另外图八的代码中,有一段是否是空段的判断逻辑。在实际的线图应用中,我们在某些情况需要隐藏线的某些段,比如传入了空数据或者用户指定了过滤条件。图十 空段0.2.2 Canvas 中线段的画法在 Canvas 中画线段只需要两个 api——moveTo 和 lineTo。图十一展示了连接[(0,0),(300,150),(400,150)]三个点的折线。图十一 moveTo 与 lineTo从上面的示例可以看到,Canvas 中绘制线段,只需要通过 moveTo 将画笔(Canvas 绘图上下文)定位到线段的起点,然后通过 lineTo 绘制到线段的终点即可。多个首位相接的线段可以省略 moveTo,直接 lineTo。要实现图十的空段效果,只需要 moveTo 到新段的起点即可,例如:图十二 绘制空段理解了基本的 api 之后,我们回到我们的折线上来,看看以段为单位的绘制方法。0.2.3 折线绘制基于上面画线的方法,我们只需要遍历一条线中的所有段,依次连接就可以了。为了处理空段的绘制,设置一个 lineStart 的标记变量,如果处于 start 状态,会先 moveTo 到新的点,而不是 lineTo。大致的绘图流程如下:图十三 线的绘制基本流程drawSegment 方法如下:图十四 drawSegment这里你可能要疑惑,这里将线拆成段并没有什么优势,为什么不直接连接各个点呢?分成段完成了一个线的绘制的骨架,在这个骨架基础上,很多功能都会很容易的扩展。比如,线的每一段都有不同的含义,可视化层面要展现这些不同的含义需要给线赋予不同的样式。这里我们可以给 LineSegment 配置一个 LineSegConfig,独立配置每个段的样式,在绘制的过程中如果发现新的段的样式发生了变化,就可以立即进行渲染,然后开始绘制新段,灵活拼装。比如下图,末尾的红色虚线用来表示预测数据。图十五 分段渲染不同样式另外,分段会大大降低动画效果的实现成本,后面我们详细介绍。了解了折线的基本画法之后,我们来看看曲线。0.3 曲线画法0.3.1 贝塞尔曲线曲线有很多种,画曲线的方法也有很多种。由于 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的过程。下面我们还原一下一个二次贝塞尔曲线的生成过程。图十七 绘制二次贝塞尔曲线(1)如图十七,首先我们链接 P0P1,P1P2,得到两条线段。然后我们对进度 t 进行取值,比如 0.3,取一个 Q0 点,使得 P0Q0 的长度为 P0P1 总长度的 0.3 倍。图十八 绘制二次贝塞尔曲线(2)同时我们在 P1P2 上取一点 Q1,使得 P0Q0: P0P1 = P1Q1: P1P2。接下来我们再在 Q0Q1 上取一点 B,使得 P0Q0: P0P1 = P1Q1: P1P2 = Q0B0Q1,如图十九。图十九 绘制二次贝塞尔曲线(3)现在我们得到的点 B 就是二次贝塞尔曲线的上的一个点,如果我们使 t=0 开始取值,逐步递增进行插值,就会得到一系列的点 B,进行连接就会形成一条完整的曲线,如图二十。图二十 二次贝塞尔曲线绘制过程上面展示了完整的二次贝塞尔曲线的产生过程,这个过程我们经过数学推导,最终可以得到如下公式:根据这个公式,我们只要变更 t 值,就可以得到对应的点。三次贝塞尔曲线对应的,三次贝塞尔曲线由四个点组成,通过更多的迭代步骤来确定曲线的上点,如图二十一所示。完整的生成如果如图二十二所示。图二十一 三次贝塞尔曲线图二十二 三次贝塞尔曲线生成过程三次贝塞尔曲线的数学公式为:0.3.2 Canas 中如何绘制贝塞尔曲线在 canvas 中绘制二次贝塞尔曲线使用的是 quadraticCurveTo 函数,参数定义如下:函数只定义了控制点和终点,起点需要我们使用 moveTo 来确定,如图二十三的代码示例。图二十三 canvas 绘制二次贝塞尔曲线三次贝塞尔曲线使用 bezierCurveTo() 方法来绘制,参数定义如下:和二次曲线的绘制方式类似,如图二十四。图二十四 canvas 绘制三次贝塞尔曲线下面的动图展示了控制点对贝塞尔曲线形状的影响。图 28 控制点对贝塞尔曲线的影响0.3.3 样条曲线 与 获取段我们了解了如何绘制三次贝塞尔曲线,但是回到我们的线图,一个线图会有不确定数量的点被平滑的连接起来,但是目前三次贝塞尔曲线显然无法满足这个需求。我们前面谈到了分段的概念,一条完整的曲线被分成了多段,如果每一段都是一条三次贝塞尔曲线,问题就解决了。那么问题就转化成了如何构造多条能依次平滑拼接的贝塞尔曲线。在图形学中有个概念叫“样条曲线”,专业的概念有点难懂,我们这里简单理解就是将一个点的集合,分成多段曲线,各曲线处的连接点处有可以平滑连接(有连续的一次和二次导数)。关于样条曲线的连续性以及贝塞尔曲线的更多特性,读者可以参考《计算机图形学(第四版)》一书第 14 章——《样条表示》,这里我们就不深入解释了,直接看例子。图 29 一段由四条三次贝塞尔曲线拼接而成的曲线以图 29 为例,如我我们要将这条曲线分成四条三次贝塞尔曲线,我们要确定两个参数:每条三次贝塞尔曲线的起点和终点每条三次贝塞尔曲线的两个控制点只有选取合适的起点、终点和控制点,我们才能使得相邻的两条曲线可以平滑连接。样条曲线的拆分算法有很多种,这里也不详细介绍了,感兴趣的同学可以参考图形学相关书籍;JavaScript 实现可以参考 d3-shape 的 Curves 接口[1],d3-shape Curves 中的curveBasis、curveBasisClosed、curveBasisOpen、curveBundle、curveCardinal、curveCardinalClosed、curveCardinalOpen、curveCatmullRom、curveCatmullRomClosed、curveCatmullRomOpen、curveNatural、curveMonotoneX和curveMonotoneY都是基于三次贝塞尔曲线的样条实现。下面我们以 Basis 算法的实现为例,进行讲解曲线如何获取“段”。主流程Basis 算法要求点集中的点的数量至少为 3 个,然后我们利用如下逻辑进行段的获取:图 30 获取曲线的 “段” 的主流程我们的主流程逻辑很简单,循环给到的点,从当前索引位置开始向后取出 3 个点,然后根据这三个点以及当前段的起始点计算结束点和控制点。每个新段的起点是上一个相邻段的终点。随后计算当前段的长度。当前的循环逻辑不会计算到最后一个点,所以会少一个段,最后加个单独的逻辑来处理。点的计算下面来看看 Basis 算法点的计算:图 31 basis 样条算法如图 31,我们基于很简单的公式来计算各个点的值,这个公式是怎么来的呢?简单说是结合了 B 样条曲线和三次贝塞尔曲线在端点处的一阶和二阶导出得来的。这里就不深入了,否则本篇文章会严重偏离主题,感兴趣的读者请参阅计算机图形学相关书籍。总之,我们通过公式计算可以得到我们需要的点。曲线分割与长度计算计算曲线的长度并不是一件容易的事情,由于贝塞尔函数是插值函数,所以计算方法就是先对曲线进行切割,切割到足够小的范围,然后计算这一小段的曲线近似长度,再累加。0.3.1 节给出了三次贝塞尔曲线的函数,我们只需要将变量 t 取足够小的值,然后计算两个点之间的直线距离进行累加就可以,但是这种方案的性能消耗比较大。我在https://community.khronos.org/t/3d-cubic-bezier-segment-length/62363/2[2]看到一种近似方法,利用该方法可以缩减切割次数。基于三次贝塞尔曲线的函数,对一个贝塞尔曲线进行切割,很简单。我们再把图 21 拿来说明一下各点的计算。图 21第一步:找到连接点如图 21,假设我要在 t=0.25 的位置将当前曲线切分成两条曲线,首先我们要知道点 B 的位置。根据公式带入即可:图 33 根据 t 计算 3 次贝塞尔的点第二步:获取控制点拿到点 P 之后,P 就是第一段的终点,第二段的起点,这样我们只需要计算控制点即可。根据我们之前对贝塞尔曲线绘制过程的理解,我们可以得出如下结论:第一段曲线的第一个控制点的运动轨迹是线段 P0P1,和 t 线性相关第一段曲线的第二个控制点的运动轨迹是线段 Q0Q1,和 t 线性相关第二段曲线的第一个控制点的运动轨迹是线段 Q1Q2,和 t 线性相关第二段曲线的第二个控制点的运动轨迹是线段 P2P3,和 t 线性相关依据上面的结论,三次贝塞尔曲线拆分的方法就很容易实现了:图 34 贝塞尔曲线拆分图 34 所示代码中 pointAt 方法为根据 t 获取直线上点的方法。如下:图 35 根据 t 获直线上的点第三步 长度计算我们可以在任意位置对三次贝塞尔曲线进行拆分了,结合二分法,控制迭代次数,结合近似长度计算函数,我们可以得到想要精度的长度值了。如图 36。图 36 三次贝塞尔曲线的分割获取段内部细节我们都梳理清楚了,获取所有的段也很简单了。现在需要特殊处理的是最后一个点数据,这里我们将第二个点和第三个点都用最后一个点表示。图 37 basis 最后一段生成方法0.3.4 曲线画法关于曲线的所有准备工作都完成了,下面我们要把它画出来。和画折线的方法类似,我们只需要循环调用"段" 的绘制方法进行绘图即可。内部,只需要调用 bezierCurveTo 即可。如下:图 38 绘制曲线的段0.4 动画我们完成了折线和曲线的绘制,想要线通过动画的方式画出来,只需要做少量的改动。首先不论直线还是曲线我们都分成了多段,每一段都是和 t 相关的函数。0.4.1 基本方案动画和非动画的本质区别就是一次画多少的问题,我们将整条线图的绘制放置在[0,1]区间内,启动一个动画循环,每次绘图的时候更新的 t 的值,在我们上面循环绘制 segment 的代码中,将整条线图的 t 转化为每一个段内部的 t 值。段 内部根据传入的 t 值,对自身进行切割,只画应该绘制的那部分。图 39 t 值换算因为我们已经计算了每个段的长度,和总长度,所以每个段的占比由长度可以获得,此占比在和整个线图的 t 值进行换算即可。以图 39 为例,比如我们传入的 t 值为 0.1,整条线图的 0.1 换算到第一个段是 0.4,那么第一个段只需绘制前 40% 部分即可。我们在图 39 的基础上,做少量的改动。图 40 支持局部绘制如图 40,我们将外部计算的 t(percent)传入绘制段的方法内,该方法会使用我们之前介绍过的 divideCubic 方法对当前曲线进行切割,然后进行局部绘制。效果如下:图 41 动画0.4.2 和其他动画方案的对比实现线和面积的动画的方案还有整体 Clip 和生成点集两种方案,下面我们简单对比一下,以说明我们的分段绘制的优势。方案简介函数调用基于曲线的轨迹动画不规则线分段扩展预生成点集是利用曲线函数,预生成足够密度的点,然后将各点连接较多。会产生大量的绘图函数的调用支持支持可以支持,比较麻烦,也要有段的概念整体 clip绘制之前设置一个裁剪窗口,调整裁剪窗口的大小来实现动画较少不支持,不能动态计算当前 t 值的 x,y不支持。只能在一个方向上 clip,不能照顾 x,y 坐标值无序情况。不支持分段模型略一个图最多调用 n-1 次支持支持支持0.4.3 动画同步上面我们看到的动画不同的线之间虽然可以再同一时间到大终点,但是过程中在 x 方向的位移是不同步的。同步和不同步都各有需求,尤其是在面积图情况下,单个面积图实际被拆分了上下两组 segment。如图 41.图 41 基本面积图的 segement我们观察上面面积图的绘图动画,它是从左到右推进的,比如当前的 t 值绘制到图 41 的矩形框的位置,那么首先会绘制第一段,计算第 12 段应该被绘制的区间,最后填充上下两段的闭合区间。这里有一个问题,如果是相同的 t 值,带入 1 和 12 的函数,产生的 x 值是不一样的,那么绘制出来的效果就不对了,切面可能是斜的。解决这个问题做法是根据 x 或者 y 值反求 t 值,再带入目标函数中。对于三次贝塞尔曲线来说,这又是一个大难题,由于篇幅所限及代码实现的比较复杂,这里就不再讲解了,大家可以参考文后的参考资料。0.5 参考资料:[1] Curves 接口: https://github.com/d3/d3-shape[2] https://community.khronos.org/t/3d-cubic-bezier-segment-length/62363/2[3] 一个超酷的贝塞尔类库: https://pomax.github.io/bezierjs/[4] 一本超级棒的贝塞尔电子书: https://pomax.github.io/bezierinfo/[5] 关于根据 x 或 y 反算 t 的讨论: https://www.zhihu.com/question/30570430[6] 图形学必读书物:《计算机图形学》[7] 本文例子来源(字节跳动自研图表库): https://bytecharts.web.bytedance.net/数据平台前端团队,在公司内负责风神、TEA、Libra、Dorado等大数据相关产品的研发。我们在前端技术上保持着非常强的热情,除了数据产品相关的研发外,在数据可视化、海量数据处理优化、web excel、sql编辑器、私有化部署、工程工具都方面都有很多的探索和积累,有兴趣可以与我们联系。对产品有任何建议和反馈也可以直接找我们进行反馈~欢迎关注「字节前端ByteFE」点击阅读原文,快来加入我们吧!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-14 04:09 , Processed in 0.498124 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表