Canvas实现连线动态效果

2,655 阅读6分钟

前言

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

效果

箭头流动效果图.gif

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();

效果:

圆动画.gif

接下来我们看下各个方法的作用:

  • 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 属性对三角形进行旋转,设置箭头的朝向。

效果:

image.png

让箭头动起来:

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 表示运行到当前关键帧的比例。

效果:

运动的箭头.gif

上述公式也可以使用线性插值公式替换。

线性插值函数,常称为 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();

效果如下:

箭头方向有问题的运动.gif

现在还有个小问题,就是箭头的方向没有随着线段的弯曲进行调整,我们接着修改代码:

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动画🔥上——动画原理及匀速、变速运动(大量示例及代码)