作者|数澜UED团队

没有飞线的地图就像一个发际线上移的中年人一样平淡无奇。 —— By 胖子

每年春运和双十一的统计图都因为有飞线动效才更加吸引眼球,今天要为大家带来一根漂亮飞线要用什么姿势生成才能。

SVG

本篇是主讲SVG来绘制飞线的,所以强大的SVG必定能完成我们绘制飞线效果的各种需求。首先我先为各位介绍下完成这根线需要用到的一些小知识点。

Path元素

path元素
是SVG基本形状中最强大的一个,它不仅能创建其他基本形状,还能创建更多其他形状。这里我们只需要用它来绘制一条曲线。

首先我们先创建好这根曲(tou)线(fa)。

OK,这根头发我们已经在屏幕上放好了,如果你将path元素的曲线无限放大会发现,其实它是由非常多的坐标点相互连接组成的。这个时候脑洞放一下,如果我们能获取到这些点是不是就是获取了线的绘制轨迹。就可以逐帧绘制飞线了动效了。


那要如何来获取和使用这些坐标点呢?

勤奋的查阅MDN,我发现这个问题强大的SVG已经帮我们解决了,可以使用getTotalLengthgetPointAtLength这两个方法来搞定。

SVGPathElement.getTotalLength

但因为SVG中绘制的都是矢量图,所以path元素不存在是由若干个点构成的,所以调用该方法会返回该path元素从起始点到终点的总长度(浮点数)。

尽管和预期有所差别,但搭配上下面的getPointAtLength方法我们依然能完成之前预想的实现方法。

SVGPathElement.getPointAtLength

调用该方法会根据传入到起点的距离值来计算返回对应的path元素坐标点的位置x、y值。

通过组合使用这两方法,我们可以自己定义这段轨迹上有有多少个坐标点,并且可以获取对应这些点的坐标值。

下面我们使用D3来操作这些DOM节点获取对应的节点数据信息

首先我们需要先定义好飞线轨迹是由多少个点构成的:

const pointNum = 1500

接下来我们可以通过方法将获取到的轨迹总长度进行平分得到单位长度unit,然后再调用getPointAtLength获取对应距离的坐标值。

const pointNum = 1500const path = d3.select('#line')const pathline = path.node()const totalLength = pathline.getTotalLength()const points = []const unit = totalLength / pointNumfor (let i = 0; i <= pointNum; i += 1) {  points.push(pathline.getPointAtLength(i * unit))}

接下来我们就可以通过这些数据绘制飞线动效了!

接下来我们就可以通过这些数据绘制飞线动效了!

接下来我们就可以通过这些数据绘制飞线动效了!

重要的话我们来强调三遍。

飞线动效-1

如下图,其实实现飞线具体头部深、尾部浅效果可以通过绘制若干透明度逐渐递减的圆来达到。(Echarts飞线使用类似思路)

接下来所需要做的就是让上面的飞线像下图的矩形一样,让它按照对应的轨迹路线来进行移动。

但由于飞线是由若干个圆重叠组成的,所以不能像矩形一样只需要控制一个元素的xy值就搞定运动行为。尤其是如下图这样的曲线运动的情况。

为此我们需要声明一个飞线类,首先需要定义飞线的长度、样式速度等特性。

由于之前已经声明好该路径轨迹拆分成多少段了,所以在此我们取个巧定义飞线的长度是其中lineLen段的长度,设定速度为每次渲染移动speed段。

class FlyLine {  totalNum = 1500  lineLen = 150  speed = 15  radius = 2.5  fill = 'rgb(255, 200, 65)'  circles = []  constructor(){    // percent的用处会在后面体现    this.percent = this.lineLen  }}

上面的说明看不懂?灵魂画手图片解析

定义好飞线的特性变量之后,接下来我们可以绘制具体的飞线了。

因为飞线是若干circle元素堆叠成的,所以我们在此提炼出一个公有的画圆方法:

class FlyLine {  ...  ...  _drawCircle(cx, cy, i) {    const {radius, circles, fill} = this    if (circles[i]) {      circles[i].attr("cx", cx).attr("cy", cy)    } else {      circles.push(        circleG1          .append("circle")          .attr("cx", cx)          .attr("cy", cy)          .attr("r", radius)          .attr("fill", fill)          .attr('fill-opacity', i * 0.001)      )    }  }}

根据传入位置、索引值创建或更新circle元素的位置和元素的透明度。

现在我们来绘制第一个静态的飞线:

首先需要确定绘制飞线是由多少段小线段组成的(实际是由多少个圆相临近堆叠成的),接着我们就可以按照由浅及深的顺序开搞了。

class FlyLine {  ...  ...  _drawFlyLine(){    const {points, percent, lineLen} = this    for (let i = percent - lineLen, j = 0; i < percent; i += 1, j += 1) {      this._drawCircle(points[i].x, points[i].y, j)    }  } }

class FlyLine {    ...    ...    animate() {      const {lineLen, speed, totalNum} = this      this._drawFlyLine()      this.percent = this.percent + speed > totalNum ? len : this.percent + speed      requestAnimationFrame(() => this.animate())    }  }

这下之前定义的percent就派上用场了

此时的percent就如同for循环中常用的i变量一样,逐渐自增speed,当到头就归零重新往复。

现在整个飞线动效的逻辑都清晰了:

FlyLine.animate方法本质上就是个复读机,一遍一遍的让percent变量由小到大变化,控制飞线由起始点到轨迹终点移动。

FlyLine._drawFlyLine方法的作用就是根据percent变量的值创建or更新飞线位置。

FlyLine._drawCircle就更不用说了,苦逼小弟,创建or更新circle元素的属性。percent变量更新一次,它要被苦逼的调用lineLen次。

现在这根飞线终于好好的动起来啦,真TM不容易。为了讲明白废了我不少(无用的)脑细胞。

当然,这个方法还不够完美,有许多需要优化的点,例如:

  1. 飞线的长度不能超过我们对轨迹分割的段数。
  2. 画一根飞线就要生成/更新几百个circle元素,浪费浏览器性能。

抛砖引玉,希望能够给大家提供一个好的思路来制作出更酷炫的飞线动效来。

飞线动效-2

算了,等不及你们来引玉了。我自己再继续开搞吧。

在上面提到的绘制一个飞线要上百个circle元素,这样非常浪费浏览器性能。

有没有好点的办法解决这个优秀前端不能忍受的痛呢?有!还真有!!

下面让我们开搞!!

我们知道NB的path元素可以绘制任意图形,上文中的飞线轨迹也是这样得到的。

这个时候我就在想了,D3相当NB了。它的过渡(transition)效果也是相当可以的。为什么我们不能直接拿来绘制飞线动效呢?

首先我们知道D3拥有attrTween这个属性过渡方法,我们可以在其中返回插值函数,根据传入的进度值不断变化元素的属性,呈现过渡动画效果。

现在先让我们用path画一根直线:

const path = container  .append('path')  .attr('fill', 'none')  .attr('stroke', 'none')  .attr('d', 'M50, 50  600, 50')  .attr('id', 'line')const pathline = path.node()const len = pathline.getTotalLength()const animate = () => {  container.select('#flyline').remove()  container.append('path')  .attr('stroke', '#19D0DC')  .attr('fill', 'none')  .attr('id', 'flyline')  .attr('stroke-width', '3px')  .transition()  .duration(5000)  .attrTween('d', function(d) {    const coord = path.attr('d').replace(/(M|Q)/g, '').match(/((\d|\.)+)/g)    var x1 = +coord[0], y1 = +coord[1] // 起点    return function(t) {      const p = pathline.getPointAtLength(t * len)      return `M${x1}, ${y1} ${p.x}, ${p.y}`    }  })}setInterval(animate, 5200)

已知直线路径长度和起点,并且这根线也不会拐弯,所以直接根据插值函数传入的进度值,通过使用getPointAtLength方法得到对应时刻的坐标值更新path元素的"d"属性即可。

直的搞定了,现在就是考验我们的时候了。我们需要使用熟练的技巧将耿直的它给掰弯了。

下图是一根二次贝塞尔曲线的绘制过程。因为轨迹已知,所以在各个阶段的起始点都是可以通过getPointAtLength方法获得的。唯一需要计算的只有不同阶段贝塞尔曲线控制点的位置。可以看到绘制它的过程中需要持续更新控制点,为此我去查了下二次贝塞尔曲线控制点的计算公式。

.attrTween('d', function(d) {    const coord = path.attr('d').replace(/(M|Q)/g, '').match(/((\d|\.)+)/g)    var x1 = +coord[0], y1 = +coord[1], // 起点        x2 = +coord[2], y2 = +coord[3], // 控制点        x3 = +coord[4], y3 = +coord[5]; // 终点    return function(t) {      const p = pathline.getPointAtLength(t * len)         // 根据插值方法进度实时计算当前控制点位置      const x = (1 - t) * x1 + t * x2      const y = (1 - t) * y1 + t * y2      return `M${x1}, ${y1} Q${x},${y} ${p.x}, ${p.y}`    }  })

代码下过如下:

这根线终于能做到从头飞到尾了,但是尾巴有点长。这可急坏老父亲了,长残了将来可怎么找对象啊!?

别急,毕竟他是生在我中国的一根线。线丑不怕,美颜相机来凑啊!

我们可以用滤镜来先来帮它磨磨皮

SVG为我们提供了蒙板遮罩等功能,我们只需要在蒙板中定义了一个透明度从内到外逐渐降低径向渐变的圆。然后让他一直跟着飞线的头移动就好了。

const mCircle = d3.select('#m-circle')  .attrTween(function(d){    ...    const x = (1 - t) * x1 + t * x2    const y = (1 - t) * y1 + t * y2    mCircle.attr('cx', x)      .attr('cy', y)  })

美颜后的效果:

 参考资料:

1. 地图与飞线

2. 贝塞尔曲线原理

参考DEMO链接:

https://codepen.io/Narcissus_Li

更多文章推荐:

数据可视化的意义与案例分享

惊! 大屏还能长这样!

家谱可视化案例分享

如何给数据选到合适的图表?