Canvas实现连线动态效果

news/2024/12/21 21:49:38/

前言

这段时间一直在研究 Canvas 的动画,本文将带大家基于 Canvas 封装的 ZRender 库,了解ZRender 库中提供的 animate 绘制动画的方法,并且使用 animate 方法实现一个带有箭头流动效果的连线。

效果

在这里插入图片描述

ZRender

在介绍 ZRender 的动画之前,先弄清楚 ZRender 是什么?

ZRender是二维绘图引擎,提提供Canvas、SVG、VML等多种渲染方式。ZRender也是ECharts的渲染器。

本文重点介绍的是基于 Canvas 模式的渲染方式。

使用起来非常简单:

引入ZRender资源包

  1. 通过 npm install 的形式进行安装
$ npm install zrender
  1. 通过HTML中加载对应的 JavaScript 资源
<script src="./dist/zrender.js"></script>

初始化ZRender

在使用 ZRender 前需要初始化实例,具体方式是传入一个 DOM 容器:

const zr = zrender.init(document.getElementById('canvas'));

创建出的这个实例对应文档中 zrender 实例部分的方法和属性。

在场景中添加元素

ZRender 提供了将近 20 种图形类型,可以在文档 zrender.Displayable 下找到。

以创建一个圆为例:

const circle = new zrender.Circle({shape: {cx: 150,cy: 50,r: 40},style: {fill: 'red',stroke: '#F00'}
});
zr.add(circle);

让这个圆动起来

ZRender 提供了 zrender.Animatable.animate(path, loop)方法可以创建一个动画对象。

还是以刚刚创建的圆为例,我们让这个圆动起来:

circle.animate('shape', true).when(10000, { cx: 800}).during((obj, i) =>{console.log(i);}).start();

效果:
在这里插入图片描述
接下来我们看下各个方法的作用:

  • animate(path, loop): 创建一个动画对象。

path 参数表示对该对象的哪个元素执行动画,如 xxx.animate('a.b', true) 表示对 xxx.a.b (可能是一个 Object 类型)执行动画。
loop 参数表示是否循环动画,是个布尔值,默认为 false

  • when(time, props):定义关键帧,即动画在某个时刻的属性。

time 参数表示关键帧时刻,单位为毫秒。props 参数表示关键帧的属性,应为 Animatable 对象的属性,此处表示关键帧的时刻为 10秒,当动画在此关键帧的时候,cx 值为 800

这里涉及到一个名词:关键帧。在传统的动画制作过程中,一般都是先定义一系列的关键帧动画,然后在关键帧动画之间添加一些中间片段让动画看起来更流畅,更自然。

  • during(callback):为关键帧添加回调函数,在关键帧运行后执行。

由于人眼的视觉残留特性,要骗过我们的眼睛,理论上达到二十四分之一秒即 24帧 这个速度切换图片就能达到动画的效果,速度越快,这个动画就越细腻流畅。

一般来说,在浏览器上一秒钟会执行60次回调函数,也就是 60帧(60fps),但浏览器会尽可能保持帧率的稳定,也就是有可能会降低到其他的帧率,比如页面性能差时浏览器可能会选择降到 30fps,当浏览器的上下文不可见时会降到 4fps 左右甚至更低。

为了验证上面这个结论,可以通过下面代码进行验证:

let count = 0;
circle.animate('shape', false).when(1000, { cx: 800}).during((obj, i) =>{count += 1;console.log('count:', count)}).start();

上面这段程序 count 每次打印出来都不固定,多跑几次平均值为60

duringcallback 回调函数有两个参数,obj 表示浏览器执行到当前帧时,动画对象执行到当前帧时的值(animate中设置的动画属性),i是一个介于0到1之间的数值,用来表示从开始到 when 中指定的关键帧,浏览器已经执行的帧数占总帧数的比例。

线性插值

现在我们尝试实现开头的动画例子,先考虑将一个箭头沿着一条直线进行运动。

绘制一条直线和一个箭头:

// 直线
const line = new zrender.Polyline({shape: {points: [[334, 374],[463, 374]]},style: {stroke: '#FF6EBE'}
});
// 三角形
const triangle = new zrender.Polygon({shape: {points: [[0, -5],[5, 0],[-5, 0],],},style: {fill: 'blue',},z: 2,
});// ZRender以逆时针为正
triangle.rotation = -Math.PI / 2;
triangle.position = [334, 374];zr.add(line);
zr.add(triangle);

三角形的坐标位置通过 position 属性进行设置,通过 rotation 属性对三角形进行旋转,设置箭头的朝向。

效果:
在这里插入图片描述
让箭头动起来:

triangle.__t = 0;
triangle.animate('', true).when(3000, {__t: 1}).during((obj, i) => {triangle.position = [334 + 129 * i, 374]}).start();
zr.add(line);
zr.add(triangle);

triangle 上设置了一个 __t 属性,when方法定义关键帧,当3秒的时候,__t 属性值为 1。在during的回调函数中计算0-3秒之间每一帧triangle的位置,并通过 position 属性实时修改 triangle 的坐标。

triangle.position = [334 + 129 * i, 374]

其中 334 是起始横坐标,129 是从 A 运动到 B 点之间的总距离,374 是纵坐标,i 表示运行到当前关键帧的比例。

效果:
在这里插入图片描述
上述公式也可以使用线性插值公式替换。

线性插值函数,常称为 lerp,一般是这样定义的:

function lerp(min, max, fraction) {return (max - min ) * fraction + min;
}

fraction 是一个介于 0 到 1 之间的数,当 fraction 取 0,lerp 返回 min(最小值),当fraction 取 1 时,lerp 返回 max (最大值),当 fraction 取 0.5 时,取最大值和最小值之间的一半。

利用线性插值函数的特性,可以完美应用到两点之间的运动轨迹的计算。ZRender库内置了对lerp函数的支持,函数签名如下:

/*** 插值两个点*/
zrender.vector.lerp(输出值, 起点坐标, 终点坐标, 系数);

注意,输入值、起点坐标和终点坐标是用向量数组的形式来表达。

改造后的结果如下:

triangle.animate('', true).when(3000, {__t: 1}).during((obj, i) => {zrender.vector.lerp(triangle.position,[334, 374],[463, 374],i);}).start();

了解了直线上箭头的运动原理,现在我们开始回到开头的示例,实现折线上箭头运动。

定义一个变量,用来存储折线的路径:

const points = [[334, 374],[463, 374],[463, 346],[541, 346],[541, 361]
];
// 直线
const line = new zrender.Polyline({shape: {points},style: {stroke: '#FF6EBE'}
});

计算每个坐标带点到起始点之间的距离之和:

// [0, 129, 157, 235, 250]
let accLenList = [0];
for (let i =1; i< points.length; i++) {const p1 = points[i-1];const p2 = points[i];const dist = zrender.vector.dist(p1, p2);accLenList.push(accLenList[i-1] + dist);
}

zrender.vector.distzrender 提供的计算向量之间距离的方法。

计算运动到每个点时,所占总运动距离的比例:

// [0, 0.516, 0.628, 0.94, 1]
let percentList = accLenList.map((acc) => {return acc / accLenList[accLenList.length-1];
});

设置箭头的初始位置:

triangle.position = [points[0][0], points[0][1]];

在during回调函数里面判断当前帧是在哪段曲线内,并计算当前线段内的运动轨迹

let frame = 1;
triangle.animate('', true).when(3000, {__t: 1}).during((obj, i) => {for(let j = 1; j< percentList.length; j++) {if (i > percentList[j-1] && i < percentList[j]) {frame = j;break;}}zrender.vector.lerp(triangle.position,points[frame - 1],points[frame],(i-percentList[frame-1])/(percentList[frame]-percentList[frame-1]))}).start();

效果如下:
在这里插入图片描述
现在还有个小问题,就是箭头的方向没有随着线段的弯曲进行调整,我们接着修改代码:

let frame = 1;
triangle.animate('', true).when(3000, {__t: 1}).during((obj, i) => {for(let j = 1; j< percentList.length; j++) {if (i > percentList[j-1] && i < percentList[j]) {frame = j;break;}}const angle =- Math.atan2(points[frame][1] - points[frame - 1][1],points[frame][0] - points[frame - 1][0],);triangle.rotation = angle - Math.PI / 2;zrender.vector.lerp(triangle.position,points[frame - 1],points[frame],(i-percentList[frame-1])/(percentList[frame]-percentList[frame-1]))}).start();

通过 Math.atan2 函数计算折线之间的拐角度数,zrender的旋转角度和canvas的旋转角度是相反的,zrender是逆时针方向为正的,canvas以顺时针方向为正的。

更多精彩文章,欢迎关注我的公众号:前端架构师笔记

参考资料

  1. 理解动画中的线性插值
  2. Canvas动画🔥上——动画原理及匀速、变速运动(大量示例及代码)

http://www.ppmy.cn/news/90691.html

相关文章

震坤行平台商品详情页面数据

震坤行&#xff08; &#xff08;zkh.com&#xff09; 商品详情页面数据通常包括以下信息&#xff1a; 商品名称、型号、品牌、颜色、大小等基本属性商品主图和详细图集&#xff0c;包括多角度展示、细节展示等商品描述&#xff0c;包括功能介绍、使用方法、注意事项等商品价格…

【C++进阶之路】模板

前言 假如需要你写一个交换函数&#xff0c;交换两个相同类型的值&#xff0c;这时如果交换的是int 类型的值&#xff0c;你可能会写一个Swap函数&#xff0c;其中参数是两个int类型的&#xff0c;假如再让你写一个double类型的呢&#xff1f;你可能又要写一个Swap的函数重载&…

牛顿-莱布尼茨公式

前置知识&#xff1a;黎曼积分的概念 牛顿-莱布尼茨公式 设 f f f在 [ a , b ] [a,b] [a,b]上可积&#xff0c;令 F ( x ) ∫ a x f ( t ) d t F(x)\int_a^xf(t)dt F(x)∫ax​f(t)dt 则 &#xff08;1&#xff09; F F F在 [ a , b ] [a,b] [a,b]上连续 &#xff08;2&…

如何清理harbor的磁盘空间

博客主页&#xff1a;https://tomcat.blog.csdn.net 博主昵称&#xff1a;农民工老王 主要领域&#xff1a;Java、Linux、K8S 期待大家的关注&#x1f496;点赞&#x1f44d;收藏⭐留言&#x1f4ac; 目录 registry garbage-collectharbor自带的清理工具docker image prune -a…

基于SSM的校园办公管理系统的设计与实现(源码完整)

项目描述 临近学期结束&#xff0c;还是毕业设计&#xff0c;你还在做java程序网络编程&#xff0c;期末作业&#xff0c;老师的作业要求觉得大了吗?不知道毕业设计该怎么办?网页功能的数量是否太多?没有合适的类型或系统?等等。这里根据你想解决的问题&#xff0c;今天给…

ROS学习——利用电脑相机标定

一、 安装usb-cam包和标定数据包 sudo apt-get install ros-kinetic-usb-cam sudo apt-get install ros-kinetic-camera-calibration 要把kinetic改成你自己的ros版本 。 二、启动相机 roslaunch usb_cam usb_cam-test.launch 就会出现一个界面 可以通过下面命令查看相机…

Flutter 可冻结的侧滑表格 sticky-headers-table 结合 NestedScrollView 吸顶悬浮的使用实践

最近在做flutter web的开发&#xff0c;需要做一个类似云文档中表格固定顶部栏和左侧栏的需求&#xff0c;也就是冻结列表的功能 那么在pub上呢也有不少的开源库&#xff0c;比如&#xff1a; table_sticky_headers data_table_2 如果说只是简单的表格和吸顶&#xff0c;那么这…

vue3前台查询使用多个字典项并且和后台交互

目录 一、前端使用 1.前台vue3接口使用 dictManege.ts 2.前台使用该接口地方 3.前台反显地方 其他几个都一样&#xff0c;这里使用在state中定义的idTypeList,在上面赋值&#xff0c;在这里使用 二、后端使用 4.后端controller接口实现 其中使用字典String[]来接收 放…