Canvas实现连线动态效果

news2024/11/29 1:45:54

前言

这段时间一直在研究 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.coloradmin.cn/o/572623.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

java+iClientOpenlayers实现污水排放扩散模拟(湖库污染排放扩散模拟)

软件实现效果 一、应用背景 湖库污染是一个日益严峻的环境问题。随着城市化和工业化的加速发展&#xff0c;越来越多的有害物质被排放入湖库中&#xff0c;导致湖库污染加剧并扩散到周围地区。本文将探讨湖库污染扩散的原因、影响和解决方法。 首先&#xff0c;湖库污染扩散的…

Android之 软件架构发展和封装

一 简介 1.1 软件架构发展趋势是解耦&#xff0c;即分离数据层和视图层&#xff0c;使得数据层专注于业务的数据和逻辑处理。从而提高代码的可读可编辑效率&#xff0c;提高团队协作能力&#xff0c;项目的生产能力&#xff0c;降低后期维护成本。 1.2 Android架构发展MVC -…

Rocksdb相关学习

1 Basic Operations 先介绍一些 RocksDB 的基本操作和基本架构。 1.1 LSM 与 WriteBatch 参考文档5提到RocksDB 是一个快速存储系统&#xff0c;它会充分挖掘 Flash or RAM 硬件的读写特性&#xff0c;支持单个 KV 的读写以及批量读写。RocksDB 自身采用的一些数据结构如 LSM/…

STLINK V2 无法用STM32CubeProgrammer下载程序

这个问题真的挺狗的&#xff0c;先说结论&#xff0c;因为你买的STLINK V2是国产的&#xff0c;而且用的也是国产的芯片&#xff0c;不是ST的STM32F103C8T6&#xff0c;所以STM32CubeProgrammer识别不到芯片的串号&#xff0c;都是奸商为了省钱导致的。 我是去年买的一个STLINK…

数据库作业

数据库teaching中的表结构和表记录。    &#xff08;1&#xff09;学生信息表student    #student表结构      create table if not exists student (      studentno char(11) not null comment学号,      sname char(8) not null comment姓名,   …

【Unity100个实用小技巧】一行代码解决天空盒接缝问题

☀️博客主页&#xff1a;CSDN博客主页&#x1f4a8;本文由 萌萌的小木屋 原创&#xff0c;首发于 CSDN&#x1f4a2;&#x1f525;学习专栏推荐&#xff1a;面试汇总❗️游戏框架专栏推荐&#xff1a;游戏实用框架专栏⛅️点赞 &#x1f44d; 收藏 ⭐留言 &#x1f4dd;&#…

三位一体,新华三绿洲3.0数据平台聚焦五大提升

如何有效发挥出数据要素的价值&#xff1f;--这已成为行业用户在数字化转型和智能化升级中的一道必答题。 从2020年《关于构建更加完善的要素市场化配置体制机制的意见》首次明确“数据”成为五大生产要素之一&#xff0c;到去年底《中共中央、国务院关于构建数据基础制度更好…

javaExcel的导出(简单方法,不用代码写表头)

目录 一.java代码 1.controller层(/exportTradeCreditData) 2.service代码 3.将设计好的excel模板放到指定位置 4.ExcelWriter.write()方法 二.前端Vue代码 1.接口 2.代码 三.Excel模板 1.将对应的字段也就是list中的key放到你想放在的位置&#xff08;${contract.CIT…

路面积水监测传感器有哪些?路面积水传感器的作用是什么?

路面积水是指在降雨或其他因素下&#xff0c;道路表面无法及时排水而形成的水体堆积现象。路面积水不仅对交通安全造成威胁&#xff0c;还可能对道路结构和交通设施造成损害&#xff0c;严重影响了城市生命线的安全运行。近年来&#xff0c;随着物联网传感技术的兴起&#xff0…

Spring高手之路——深入理解注解驱动配置与XML配置的融合与区别

文章目录 1. 配置类的编写与Bean的注册2. 注解驱动IOC的依赖注入与XML依赖注入对比3. 组件注册4. 组件扫描4.1 使用ComponentScan的组件扫描4.2 xml中启用component-scan组件扫描4.3 不使用ComponentScan的组件扫描 5. 组件注册的其他注解6. 将注解驱动的配置与XML驱动的配置结…

Windows下搭建paddlenlp 语义检索系统

windos下搭建paddlenlp 语义检索系统 之前搭建paddleocr的时候&#xff0c;创建了paddle的虚拟环境&#xff0c;顺便也装了paddlenlp的库&#xff0c;就想着直接用这个&#xff0c;然后语义检索模型本身没有问题了&#xff0c;可以正常推理了。但在搭建pipline的时候出现问题&…

工程测量--学习笔记

1、测量学的概念 测量学是研究地球的形状、大小以及地表&#xff08;包括地面上各种物体&#xff09;的几何形状及其空间位置的科学。 2、工程测量的概念 工程测量是运用测量学的基本原理和方法为各类工程服务。 3、测量工作分类 测量工作包括测定和测设两部分。 测定是指使用…

算法|2.异或运算

算法|2.异或运算 1.不用额外变量交换两个数的值 题意&#xff1a;不用额外变量交换&#xff08;数组中&#xff09;两个数的值 解题思路&#xff1a; 使用异或运算的性质 代码及运行结果&#xff1a; 2.找到唯一出现奇数次的数字 题意&#xff1a;一个数组中有一种数出现了…

这才是网络安全最系统的学习路线(建议收藏)

01 什么是网络安全 网络安全可以基于攻击和防御视角来分类&#xff0c;我们经常听到的 “红队”、“渗透测试” 等就是研究攻击技术&#xff0c;而“蓝队”、“安全运营”、“安全运维”则研究防御技术。 无论网络、Web、移动、桌面、云等哪个领域&#xff0c;都有攻与防两面…

一些小的问题

是否是质数&#xff1f; #include <stdio.h> #include <stdbool.h>bool is_prime(int num);int main() {int num;printf("请输入一个整数&#xff1a;");scanf("%d", &num);if (is_prime(num)) {printf("%d是质数。\n", num);} …

【萌新指南】如何获得铁粉?快收下我为你精心定制的涨粉秘籍吧

文章目录 前言"铁粉"介绍"铁粉"规则"铁粉"获取高质量博客坚持写博客参与活动 尾声 前言 目前博主的"铁粉"数量 "铁粉"介绍 "铁粉"是为了帮助博主解决上面提到的问题和困惑&#xff0c; CSDN 设计的一个功能&…

STM8、STM8S003F3P6 通过ZM470SX-MP模组实现lora通信

背景 现在物联网就是很火&#xff0c;lora是避免不开的&#xff0c;也有个项目采用STM8S003F3P6 使用周立功的lora模组ZM470SX-MP实现lora通信。 原理图 废话少说&#xff0c;上原理图 这个原理图我找了很久都没有找到&#xff0c;指示找到了管脚图&#xff0c;这个原理图非…

Linux——线程的同步与互斥

目录 模拟抢火车票的过程 代码示例 thread.cc Thread.hpp 运行结果 分析原因 tickets减到-2的本质 解决抢票出错的方案 临界资源的概念 原子性的概念 加锁 定义 初始化 销毁 代码形式如下 代码示例1&#xff1a; 代码示例2&#xff1a; 总结 如何看待锁 申…

2.自然语言处理NLP:词映射为向量——词嵌入(word embedding)

1. 什么是词嵌入&#xff08;word2vec&#xff09; &#xff1a; 把词映射为向量&#xff08;实数域&#xff09;的技术 2. 为什么不采用one-hot向量&#xff1a; one-hot词向量无法准确表达不同词之间的相似度&#xff0c;eg&#xff1a;余弦相似度&#xff0c;表示夹角之间的…

创新案例|Amazon如何打造增长飞轮保持每年20%以上的营收增速

作为世界五百强中的头部企业&#xff0c;亚马逊的价值定位经历了三次转变&#xff0c;从成为“地球上最大的书店”&#xff0c;到成为最大的综合网络零售商&#xff0c;再到成为“最以客户为中心的企业”&#xff0c;亚马逊最终以“客户中心”破除了对企业价值定位的束缚&#…