粒儿

用SVG和Vanilla JS框架创建一个“星形变心形”的动画效果 | CSS-Tricks

原文链接: css-tricks.com

# 用SVG和Vanilla JS框架创建一个“星形变心形”的动画效果

By Ana Tudor On

November 6, 2017

JavaScript, SVG

我写的这篇文章中, 讲述了如何用vanilla JavaScript使动画顺滑的从一种状态过渡到另一种。最好先看下那篇文章,因为在这篇文章中我们要用到一些那篇文章中讲过的内容。例如例子的演示、各种时间函数的公式、当从结束状态过渡到初始状态时不使时间函数倒转过来。都在那篇文章中做了详细讲解。

在最后的例子中,通过改变绘制嘴形的路径的属性d,我们得到了从悲伤的嘴变高兴的嘴的效果。

更高水平的控制路径数据能够带给我们更有趣的效果,例如星形变心形。

Gif recording of a star to heart animation. We start with a five-point golden star. All of its tips are rounded and one of them points up. On a first click, the golden star shape morphs into a crimson heart shape and it rotates clockwise by half a circle. On a second clip, the crimson heart shape morphs back into a golden star shape and rotates by another half a circle, completing thus a full turn.

这是我们要实现的星形变心形的动画效果。

思路

它们都是由五个三次贝塞尔曲线构成。下边的互动演示展示了每条曲线以及这些曲线相连接的点。点击任意曲线或连接点可以看到两个图形的曲线是如何相对应的。

See the Pen by thebabydino (@thebabydino) on CodePen.

可以看出所有曲线都是由三次贝塞尔曲线创建的。即使其中一些曲线的两个控制点重叠了。

构成星形和心形的形状都是极简且不符合实际的。但它们可以做到。

初始代码

表情动画的例子中可以看出, 我通常选择用 Pug(译:即jade,一种模版引擎) 生成这类形状。但在这里,由于生成的路径数据还将由JavaScript处理过渡效果。包括计算坐标以及将这些坐标放入属性d 。所以使用JavaScript来做所有的这些是最好的选择。

这意味着我们不必写很多标签:

<svg>
  <path id='shape'/>
</svg>

JavaScript中,我们首先获得元素 SVG 和元素 pathpath 是那个星形变心形再变回星形的形状。然后,我们给元素 SVG 设置viewBox属性,使得 SVG 沿两个轴的尺寸相等,并且坐标轴的原点(0,0)在 SVG 正中间。这意味着,当viewBox的尺寸值为D 时,它的左上角坐标为(-.5*D,-.5*D)。最后,这个也很重要,就是创建一个对象来存储过渡的初始和最终状态,以及一个将我们想要的值设置给 SVG 图形属性的方法。

const _SVG = document.querySelector('svg'), 
      _SHAPE = document.getElementById('shape'), 
      D = 1000, 
      O = { ini: {}, fin: {}, afn: {} };

(function init() {
  _SVG.setAttribute('viewBox', [-.5*D, -.5*D, D, D].join(' '));
})();

现在我们把这事解决了,可以开始更有趣的部分了!

图形的几何绘制

我们用终点和控制点的初始坐标来绘制星形,用它们的最终坐标来绘制心形。 每个坐标的过渡范围是它的初始值与最终值之间的差值。在这个例子中,当星形向心形转换时,我们会转动(rotate)它,因为我们想让星形的角朝上。我们还会改变填充(fill),从金色的星形变成深红色的心形。

那么,我们怎么能获得这两个图形的终点和控制点的坐标呢?

星形

在星形的例子中,我们先从一个正五角星形开始。我们的曲线(译:构成星形每个角的曲线)终点落在正五角星形边的交叉点上,我们把正五角星形的顶点作为控制点。

Illustration showing the five cubic Bézier curves forming our star and the regular pentagram created by the support lines of the segments connecting the end points and the control points of these curves. The two control points for each curve coincide and represent the vertices of a regular pentagram. In a cyclical manner, the start point of any curve is the end point of the previous one and these points are where the pentagram edges cross each other.

五个三次贝塞尔曲线的终点和控制点用黄点标识在了正五角星形的顶点和边的交叉点上 (live).

直接给定正五角星形外接圆的半径(或者直径)就可以获得五角星形的顶点。也就是我们给 SVG 的viewBox 设定的尺寸(简单起见,在这种情况下我们不考虑高填密)。但是如何获得他们的交叉点呢?

首先,我们先看下边的说明图。注意图中正五角星形中间高亮标注的小五边形。小五边形的顶点与正五角星形边的交叉点是重合的。这个小五边形显然是个正五边形(译:五个边的长度相等)。这个小正五边形的内切圆和内径跟正五角星形的是同一个。

Illustration showing a regular pentagram. The five intersection points of its edges are the vertices of a small regular pentagon whose edges are on the same support lines as the edges of the pentagram. Furthermore, the regular pentagram and its inner regular pentagon have the same incircle (and thus the same inradius).

正五角星形和内部的正五边形的内切圆是同一个 (live)。

因此,如果我们计算出正五角星形的内径,那么也就获得了正五边形的内径。这个内径和圆心角 一起对应正五边形的边。根据这个我们就可以获得正五边形的外接圆半径 。这样就可以倒推出正五边形顶点的坐标。这些点正是正五角星形边的交叉点,也就是星形五个三次贝塞尔曲线的终点。

我们的正五角星形可以用拓扑符号 {5/2}来表示。也就是说,正五角星形有5个顶点。这5个顶点均匀分布在它的外接圆上,间隔是 360°/5 = 72°。我们从第一个点开始,跳过紧挨着的下一个点,连接到紧挨着的第二个点(这就是符号{5/2}2的含义;1 代表的意思是连接到第一个点,不跳过任何点,构成一个五边形)。照这样一直连接,就可以画出正五角星形了。

在下边的演示中,点击五边形或者五角星形按钮,查看它们是怎样被绘制的。

See the Pen by thebabydino (@thebabydino) on CodePen.

这样,我们得到正五角星形的边所对应的的圆心角是正五边形的边所对应的圆心角的二倍。那么正五边形是1·(360°/5) = 1·72° = 72° (或者1·(2·π/5)弧度),那正五角星形就是2·(360°/5) = 2·72° = 144°2·(2·π/5)弧度)。通常,一个用拓扑符号表示为{p,q}的正多边形,它的一个边所对应的圆心角就是 q·(360°/p)q·(2·π/p) 弧度)。

Illustration showing the central angle corresponding to an edge of a regular polygon: pentagram vs. pentagon. This angle is twice as big in the pentagram case as, having five points equally spaced around the circle, edges connect from one of these points to the next in the pentagon case, but always skip the first one right near and connect to the second in the pentagram case. This makes the edges and the corresponding central angles bigger.

正多边形的一条边所对应的圆心角:正五角星形(左,144°)vs 正五边形(右,`72°)(live)。

已知正五角星形外接圆半径,也就是的viewBox尺寸。那么,已知直角三角形斜边的长(即正五角星形外接圆的半径)和锐角的度数(正五角星形一条边所对应的角度的一半),这意味着我们可以算出正五角星形的内径(这个内径与正五角星形内部的小正五边形的内径相等)。

Illustration highlighting a right triangle from where we can compute a regular pentagram's inradius. The hypotenuse of this triangle is the pentagram circumradius and the acute angle between the two is half the central angle corresponding to the pentagram edge.

通过直角,可以计算出正五角星形的内径长。这个直角的斜边等于正五角星形外接圆半径,其中一个锐角的角度等于正五角星形一条边所对应的角度的一半 (live)。

圆心角一半的余弦等于五角星形的内径比外接圆半径。就可以得出,五角星形的内径等于外接圆半径乘以这个余弦值。

现在我们得到了正五角星形内部小正五边形的内接圆半径,我们就可以计算出这个正五边形的外接圆半径了。还是通过一个小直角来计算。这个直角的斜边等于正五边形外接圆半径。一个锐角等于正五边形一条边所对应的圆心角的一半。这个锐角的一条边是这个圆心角的中直线,这个中直线是正五边形的外接圆半径。

下边的说明图中高亮标注了一个直角三角形,它是由正五边形的一条外接圆半径、内接圆半径、一个圆心角的一半构成的。如果我们已知内接圆半径和正五边形一条边所对应的圆心角,这个圆心角的一半也就是两条外接圆半径的夹角的话。用这个直角三角形我们可以计算出外接圆半径的长。

Illustration highlighting a right triangle from where we compute a regular pentagon's circumradius. The hypotenuse of this triangle is the desired circumradius, while the catheti are the pentagon inradius and half the pentagon edge. The acute angle between the two radii is half the central angle corresponding to the pentagon edge.

通过一个直角三角形计算正五边形外接圆的半径 (live)。

前文提到过,正五边形圆心角的度数与正五角星形的圆心角度数是不相等的。前者是后者的一半 (360°/5 = 72°)。

好,现在我们有了这个半径,就可以得到所有想要的点的坐标了。这些点均匀分布在两个圆上。有5个点在外层的圆上(正五角星形的外接圆),还有5个在内层的圆上(小正五边形的外接圆)。共计10个点,他们所在的半径射线的夹角是 360°/10 = 36°

Illustration showing the end and control points of the cubic curves making up our rounded tip star being distributed on two circles - the control points on an outer circle which is the circumcircle of the pentagram and the end points on an inner circle which is the circumcircle of the inner pentagon whose vertices are the points where the pentagram edges cross each other.

终点均匀分布在小正五边形的外接圆上,控制点均匀分布在正五角星形的外接圆上 (live)。

已知两个圆的半径。外层圆的半径等于正五角星形外接圆半径,也就是我们定的有点儿随意的viewBox 尺寸的一部分(.5 or .25 or.32 or 或者我们认为效果更好地尺寸)。内层圆的半径等于正五角星形内部构成的小正五边形的外接圆半径。计算这个半径的方法是:首先,通过正五角星形的外接圆半径和它的一条边所对应的圆心角计算出正五角星形的内接圆半径。这个内接圆半径与小正五边形的内接圆半径相等;然后,再通过小正五边形一条边所对应的圆心角和它的内接圆半径来计算。

所以,基于这点,我们就能够生成绘制星形的路径的数据了。绘制它所需要的数据,我们都已经有了。

那么让我们来绘制吧!并且把上边的思考过程写成代码。

首先,先创建一个getStarPoints(f) 的函数。参数 (f) 将决定根据 viewBox的尺寸获取的正五角星形外接圆半径是多少。这个函数返回一个由坐标组成的数组,之后我们会给这个数组增加数组项。

在这个函数中,我们首先计算常量:正五角星形外接圆半径(外层圆的半径)、正五角星形一条边所对应的圆心角、正五角星形内部构成的正五边形的一条边所对应的圆心角、正五角星形内部构成的正五边形的一条边所对应的圆心角、正五角星形和内部构成的正五边形共用的内接圆的半径(正五变形的顶点是正五角星形边的交叉点)、内部小正五变形的外接圆半径、需要计算坐标的点的总数、所有点所在的径向线的夹角。

然后,用一个循环来计算我们想要的点的坐标,并将它们插入坐标数组中。

const P = 5; /* number of cubic curves/ polygon vertices */

function getStarPoints(f = .5) {
  const RCO = f*D /* outer (pentagram) circumradius  */, 
        BAS = 2*(2*Math.PI/P) /* base angle for star poly */, 
        BAC = 2*Math.PI/P /* base angle for convex poly */, 
        RI = RCO*Math.cos(.5*BAS) /*pentagram/ inner pentagon inradius */, 
        RCI = RI/Math.cos(.5*BAC) /* inner pentagon circumradius */, 
        ND = 2*P /* total number of distinct points we need to get */, 
        BAD = 2*Math.PI/ND /* base angle for point distribution */, 
        PTS = [] /* array we fill with point coordinates */;

  for(let i = 0; i < ND; i++) {}

  return PTS;
}

计算坐标需要的条件:用点所在圆的半径,以及一条半径与水平轴线构成的夹角。如下面的交互式演示所示(拖动点来查看它的笛卡尔坐标如何变化):

See the Pen by thebabydino (@thebabydino) on CodePen.

在我们的例子里,当前的半径有两个。一个是外圆的半径(正五角星形的外接圆半径RCO),可以帮助算出索引值为偶数的点的的坐标(0, 2, ...)。还有一个是内接圆的半径(内部小正五边形的外接圆半径RCI),可以帮助算出索引值为奇数的点的的坐标(1, 3, ...)。当前点与圆心点的连线所构成的径向线的夹角等于点的索引值(i)乘以所有点所在的径向线的夹角(BAD,在我们的例子里恰巧是36°π/10)。

因此,循环体里的代码如下:

for(let i = 0; i < ND; i++) {
  let cr = i%2 ? RCI : RCO, 
      ca = i*BAD, 
      x = Math.round(cr*Math.cos(ca)), 
      y = Math.round(cr*Math.sin(ca));
}

由于我们给viewBox 设定的尺寸足够大,所以我们可以放心的给坐标值做四舍五入计算,舍弃小数部分,这样我们的代码看起来会更干净。

我们会把外层圆(索引值是偶数的情况)计算出的坐标值推入坐标数组中两次。因为实际上星形在这个点上有两个重叠的控制点。如果要绘制成心形,就要把这两个重叠的控制点放在别的的位置上。

for(let i = 0; i < ND; i++) {
  /* same as before */

  PTS.push([x, y]);
  if(!(i%2)) PTS.push([x, y]);
}

接下来,我们给对象O添加数据。添加一个属性(d)来储存有关路径的数据。设置一个初始值来储存数组,这个数组是由上文提到的函数计算出的点的坐标组成的。我们还创建了一个函数用来生成实际的属性值(这个例子中,曲线的两个终点坐标的差值范围是路径的数据串,浏览器根据这个数据串绘制图形)。最后,我们获得了所有已经保存了数据的属性,并将这些属性的值作为前面提到的函数的返回值:

(function init() {
  /* same as before */

  O.d = {
    ini: getStarPoints(), 
    afn: function(pts) {
      return pts.reduce((a, c, i) => {
        return a + (i%3 ? ' ' : 'C') + c
      }, `M${pts[pts.length - 1]}`)
    }
  };

  for(let p in O) _SHAPE.setAttribute(p, O[p].afn(O[p].ini))
})();

绘制的结果可以在下边的演示中看到:

See the Pen by thebabydino (@thebabydino) on CodePen.

这是一个很有前途的星形。但我们想让生成的五角星形第一个尖朝下并且由它生成的星形的第一个尖朝上。目前,他们的指向都偏右了。这是因为我们是从 开始的(对应时钟的三点位置)。所以为了能从时钟6点的位置开始,我们给getStarPoints() 函数中的每个角加 90°π/2 弧度)。

`ca = i*BAD + .5*Math.PI`

这样生成的五角星形和由它生成的星形的第一个角就都朝下了。为了旋转星形,我们需要给它的 transform 属性设置成旋转半个圆的角度。为了到达这个效果,我们首先设置初始的旋转角度为-180 。然后,我们把生成实际属性值的函数设置成这样一个函数。这个函数接收两个参数,一个是函数名字,另一个为参数,函数返回由这两个参数组成的字符串:

function fnStr(fname, farg) { return `${fname}(${farg})` };

(function init() {
  /* same as before */

  O.transform = { ini: -180,  afn: (ang) => fnStr('rotate', ang) };

  /* same as before */
})();

我们用类似的方式给我们的星形填充(fill )金色。我们给初始值设置一个 RGB 字符串,用同一个函数来给属性(fill )设置值:

(function init() {
  /* same as before */

  O.fill = { ini: [255, 215, 0],  afn: (rgb) => fnStr('rgb', rgb) };

  /* same as before */
})();

现在我们用 SVG 绘制好了一个漂亮的金色星形,它是由五个三次贝塞尔曲线构成的:

See the Pen by thebabydino (@thebabydino) on CodePen.

心形

我们已经绘制好星形了,现在来看下如何绘制心形吧!

我们先从两个半径相等并横向相交的圆开始,这两个圆都是 viewBox 尺寸的一部分(暂时定位.25)。这两个圆相交的方式为:它们中心点相连的线落在 x 轴上,它们相交点相连的线落在 y 轴上。这两条线要相等。

Illustration showing the helper circles we start with, their radii and the segments connecting their central points and their intersection points.

我们先从两个半径相等的相交的圆开始。这两个圆的圆心落在水平轴上,他们相交的点落在垂直轴上 (live)。

接着,我们画两条直径,这两条直径穿过靠上的那个交点。在直径与圆的另一个交点处画一条正切线。这两条正切线在 y 轴相交。

Illustration showing the helper circles we start with, their passing through their upper intersection point, the tangents at the diametrically opposite points and their intersection.

画两条直径,穿过两个圆相交的点中靠上的那个,并在直径与圆的另一个交点处画正切线,两条正切线在垂直轴相交 (live)。

两个圆上边的交点和两个直径与圆的另两个交点构成了我们需要的5个点中的3个。另外两个终点则是把外侧的半圆切割成两个相等弧线的中点,这使我们得到4个四分之一圆弧。

Illustration highlighting the end points of the cubic Bézier curves that make up the heart and the coinciding control points of the bottom one of these curves.

高亮显示了构成心形的三次贝塞尔曲线的终点以及靠下的那条曲线的控制点(live)。

靠下的曲线控制点很明显已经得到了,就是两条切线的交点。但是另外四条曲线的控制点呢?我们怎么能把圆弧变成三次贝塞尔曲线呢?

我们无法得到四分之一圆弧的三次贝塞尔曲线,但我们可以得到一个近似的,在这篇文章中有阐述。

这篇文章告诉我们,可以用一个值为R 的半径,和半径的切线(N 和 Q)来绘制四分之一圆弧。两条半径的切线相交于点 P。四边形 ONPQ 的四个角都等于90° (或π/2,其中三个是公理得出的(O 是90° ,两条切线与半径的夹角也是90°),最后一个是计算得出的(内角的合是 360°,其它三个角都是90°, 最后一个角也就是90°了)。这样 ONPQ 就是一个矩形。同时 ONPQ 有两个相邻的边是相等的(OQ 和 ON 的长度都等于半径R),这样它就是一个边长为R的正方形。所以 NP 和 QP 长也等于R

Illustration showing the control points we need to approximate a quarter circle arc with a cubic Bézier curve.

用三次贝塞尔曲线绘制近似四分之一圆弧的弧线 (live)。

我们用三次贝塞尔曲线绘制的近似四分之一圆弧的弧线的控制点就在切线 NP 和 QP 上,也就是从终点算起C·R的长度,C在之前提到文章中算出的值是.551915

知道了上边这些,我们可以开始计算三次贝塞尔曲线终点和控制点的坐标了,有了这些坐标,就可以构建我们的心形了。

由于我们选择这种方式构建心形, TO0SO1是一个四边相等(四个边都是由两个圆的半径构成)的 正方形 ,并且它的对角线也是相等的(这点前文有说过,两个圆心的连线与两个交点的连线相等)。这里,O 是两个对角线的交点,并且 OT 等于对角线 ST 的一半。T 和 S 在 y 轴上,所以他们的 x 坐标为 0。他们的 y 坐标对应 OT 的绝对值,也就是对角线的一半(OS 同理)。

Illustration showing how the central points and the intersection points of the two helper circles form a square.

正方形 TO0SO1 (live)。

我们可以把任意边长为l的正方形切割成两个等腰三角形。这个等腰三角形的直角边与正方形的边重合,斜边与正方形对角线重合。

Illustration showing how a square can be split into two congruent right isosceles triangles.

任意正方形可以被切割成两个等腰三角形(live)。

利用勾股定理:d² = l² + l²,我们可以计算出其中一个直角的斜边(也就是正方形的对角线)。这样根据边长就可以得出正方形对角线的长 d = √(2∙l) = l∙√2(相反,根据对角线的长就可以得出边的长 l = d/√2)。还能计算出对角线的一半d/2 = (l∙√2)/2 = l/√2

把这个应用到我们的边长为 R的 TO0SO1 正方形上,我们得到 T 点(它的绝对值等于正方形对角线的一半)的 y 坐标是 -R/√2 ,同时 S 点的 y 坐标是R/√2

Illustration showing the coordinates of the vertices of the TO₀SO₁ square.

TO0SO1 正方形四个顶点的坐标 (live)。

类似的,O1 点在 x 轴上,所以他们的 y 轴坐标为0,他们的 x 轴坐标是对角线 OO1 的一半:±R/√2

TO0SO1 是个正方形,那么它的四个角都是90°π/2圆弧)。

Illustration showing TAₖBₖS quadrilaterals.

四边形 TA1B1S (live)。

如上图所示,直线 TB1 是对角线,也就是说圆弧 TB1 是圆形的一半,或者叫做180°弧线。我们用 A1 点将这个弧分割成了相等的两半儿,得到两个相等的 90° 弧线:TA1 和 A1B1 。他们对应两个相等的 90° 角:∠TO1A1 和 ∠A1O1B1 。

根据公理 ∠TO1S 和 ∠TO1A1 都是90°的角,这证明直线 SA1 也是直径。这告诉我们在四边形 TA1B1S 中,对角线 TB1 和 SA1 是垂直且相等的,并且相交于各自的中心点(TO1、O1B1、SO1 和 O1A1 都等于圆形的的半径R)。这说明四边形 TA1B1S 是正方形,且它的对角线等于2∙R

到这里我们就可以得到四边形 TA1B1S 的边等于2∙R/√2 = R∙√2。由于正方形所有的角都是90° ,并且边 TS 与垂直轴重叠,所以边 TA1 和 SB1 是水平的,且平行于 x 轴。根据他们的长度可以算出 A1 和 B1 两点的 x 轴坐标:±R∙√2

因为 TA1 和 SB1 是水平的, 所以 A1 和 B1 两点的 y 轴坐标分别等于 T (-R/√2) 和 S (R/√2) 点。

Illustration showing the coordinates of the vertices of the TAₖBₖS squares.

正方形 TA1B1S 四个顶点坐标(live)。

我们从这里得到的另一个结论是,因为 TA1B1S 是正方形,所以 A1B1 平行于 TS ,因为 TS 在 y (垂直)轴上,所以 A1B1 也是垂直的。此外,因为 x 轴平行于 TA1 和 SB1 ,并且将 TS 平分切为两断,所以 x 轴也将 A1B1 平分切为了两断。

现在让我来看看控制点。

我们先从最下边弧线的重叠的控制点开始。

Illustration showing the TB₀CB₁ quadrilateral.

四边形 TB0CB1 (live).

四边形 TB0CB1 的所有角都等于 90° (因为 TO0SO1 是正方形所以 ∠T 是直角;因为 B1C 是圆的切线,它与半径 O1B1 垂直,并相交于 B1 点, 所以 ∠B1 是直角;因为其他三个都是直角,所以 ∠C 也是直角),所以它是个矩形。同样它有两个相邻的边相等:TB0 和 TB1。这两条线都是圆形的直径,且都等于 2∙R。最后得出结论四边形 TB0CB1 是一个边长为2∙R的正方形。

然后我们可以得到它的对角线 TC : 2∙R∙√2。因为 C 在 y 轴上,它的 x 轴坐标为 0。它的 y 轴坐标是 OC 的长度。OC 的长度等于 TC 减去 OT:2∙R∙√2 - R/√2 = 4∙R/√2 - R/√2 = 3∙R/√2

Illustration showing the coordinates of the vertices of the TB₀CB₁ square.

正方形 TB0CB1 四个顶点的坐标 (live)。

现在我们得到了最下边弧线两个重叠的控制点的坐标为(0,3∙R/√2)

为了获得其他曲线控制点的坐标,我们在他们的终点上画切线,并且获得这些切线的交叉点 D1 和 E1 。 Illustration showing the TOₖAₖDₖ and AₖOₖBₖEₖ quadrilaterals.

四边形 TO1A1D1 和 A1O1B1E1 (live)。

在四边形 TO1A1D1 中,已知所有角都是直角(90° ),其中三个是公理得出的(∠D1TO1 和 ∠D1A1O1 是由半径和切线获得的;∠TO1A1 是对应四分之一弧 TA1 的角),那么第四个角通过计算就得出也是直角。这证明 TO1A1D1 是矩形。又因为它有两个相邻的边相等(O1T 和 O1A1 等于半径 R),所以 TO1A1D1 是正方形。

这说明对角线 TA1 和 O1D1 等于 R∙√2。已知 TA1 是水平的,又正方形两个对角线是垂直的,就证明 O1D1 是垂直的。那么点 O1 和 D1 的 x 轴坐标相等,O1 的 x 轴坐标是±R/√2。因为我们知道 O1D1 的长,所以我们可以算出 y 轴坐标:如前文提到的那样用对角线的长( R∙√2)做减法。

四边形 A1O1B1E1 的情况类似。已知所有角都是直角(90°),其中三个是公理得出的(∠E1A1O1 和 ∠E1B1O1 是由半径和切线获得的;∠A1O1B1 是对应四分之一弧 A1B1 的角),那么第四个角通过计算就得出也是直角。这证明 A1O1B1E1 是矩形。又因为它有两个相邻的边相等(O1A1 和 O1B1 等于半径R),所以 A1O1B1E1 是正方形。

至此,我们得到对角线 A1B1 和 O1E1 的长为R∙√2。我们知道 A1B1 是垂直的,并且被水平轴切割成相等的两半儿,也就是 O1E1 在水平轴上,点 E1 的 y 轴坐标为0。因为点 O1 的 x 轴坐标为±R/√2,并且 O1E1 等于R∙√2,我们就可以计算出点 E1 的 x 轴坐标为:±3∙R/√2

Illustration showing the coordinates of the newly computed vertices of the TOₖAₖDₖ and AₖOₖBₖEₖ squares.

四边形 TO1A1D1 和 A1O1B1E1 的顶点坐标。(live)。

但是这些切线的交叉点并不是控制点,所以我们需要用近似圆弧形的方法来计算。我们想要的控制点在 TD1、A1D1、A1E1 和 B1E1 上,距离弧线终点(T、A1、B1)大约55%(这个值来源于前文提到的那篇文章中算出的常量C的值)的位置。也就是说从终点到控制点的距离是C∙R

在这种情况下,我们的控制点坐标为:终点(T、A1 和 B1)坐标的1 - C,加上,切线交点(D1 和 E1)坐标的 C

让我们把这些写入JavaScript代码吧!

跟星形的例子一样,我们先从函数getStarPoints(f) 开始。根据这个函数的参数 (f) ,我们可以从viewBox 的尺寸中获得辅助圆的半径。这个函数同样会返回一个坐标构成的数组,以便我们后边插入数组项。

在函数中,我们先声明常量。1、辅助圆的半径。2、边与这个辅助圆半径相等的小正方形对角线的一半。对角线的一半也是这些正方形外接圆半径。3、三次贝塞尔曲线终点的坐标值(点T、A1、B1),沿水平轴的绝对值。然后我们把注意力放在切线交点的坐标上( 点 C、D1、E1 )。这些点或者与控制点(C)重合,或者可以帮助我们获得控制点(例如点 D1 和 E1)。

function getHeartPoints(f = .25) {
  const R = f*D /* helper circle radius  */, 
        RC = Math.round(R/Math.SQRT2) /* circumradius of square of edge R */, 
        XT = 0, YT = -RC /* coords of point T */, 
        XA = 2*RC, YA = -RC /* coords of A points (x in abs value) */, 
        XB = 2*RC, YB = RC /* coords of B points (x in abs value) */, 
        XC = 0, YC = 3*RC /* coords of point C */, 
        XD = RC, YD = -2*RC /* coords of D points (x in abs value) */, 
        XE = 3*RC, YE = 0 /* coords of E points (x in abs value) */;
}

点击下边交互演示上的点,可以展示这些点的坐标:

See the Pen by thebabydino (@thebabydino) on CodePen.

现在我们可以通过终点和切线交点来获得控制点:

function getHeartPoints(f = .25) {
  /* same as before */
  const /* const for cubic curve approx of quarter circle */
        C = .551915, 
        CC = 1 - C, 
        /* coords of ctrl points on TD segs */
        XTD = Math.round(CC*XT + C*XD), YTD = Math.round(CC*YT + C*YD), 
        /* coords of ctrl points on AD segs */
        XAD = Math.round(CC*XA + C*XD), YAD = Math.round(CC*YA + C*YD), 
        /* coords of ctrl points on AE segs */
        XAE = Math.round(CC*XA + C*XE), YAE = Math.round(CC*YA + C*YE), 
        /* coords of ctrl points on BE segs */
        XBE = Math.round(CC*XB + C*XE), YBE = Math.round(CC*YB + C*YE);

  /* same as before */
}

下一步,我们要把相关的坐标合成一个数组,并将这个数组返回。在星形的例子中,我们是从最下边的弧形开始的,然后按照顺时针方向绘制,所以在这里我们用同样的方法。每个曲线,我们为控制点放入两组坐标,为终点放入一组坐标。

See the Pen by thebabydino (@thebabydino) on CodePen.

请注意,第一个曲线(最下边的那个),他的两个控制点重叠了,所以我们把相同的坐标组合推入两次。代码看起来也许并不像绘制星形时那样整洁好看,但可以满足我们的需求:

return [
  [XC, YC], [XC, YC], [-XB, YB], 
  [-XBE, YBE], [-XAE, YAE], [-XA, YA], 
  [-XAD, YAD], [-XTD, YTD], [XT, YT], 
  [XTD, YTD], [XAD, YAD], [XA, YA], 
  [XAE, YAE], [XBE, YBE], [XB, YB]
];

现在我们可以把星形的最终状态设置成函数getHeartPoints(),没有旋转,没有填充( fill)深红色。然后把当前状态设置成最终状态,以便能看到心形:

function fnStr(fname, farg) { return `${fname}(${farg})` };

(function init() {  
  _SVG.setAttribute('viewBox', [-.5*D, -.5*D, D, D].join(' '));

  O.d = {
    ini: getStarPoints(), 
    fin: getHeartPoints(), 
    afn: function(pts) {
      return pts.reduce((a, c, i) => {
        return a + (i%3 ? ' ' : 'C') + c
      }, `M${pts[pts.length - 1]}`)
    }
  };

  O.transform = {
    ini: -180, 
    fin: 0, 
    afn: (ang) => fnStr('rotate', ang)
  };

  O.fill = {
    ini: [255, 215, 0], 
    fin: [220, 20, 60], 
    afn: (rgb) => fnStr('rgb', rgb)
  };

  for(let p in O) _SHAPE.setAttribute(p, O[p].afn(O[p].fin))
})();

这个心形看上去很不错:

See the Pen by thebabydino (@thebabydino) on CodePen.

确保两个图形是对齐的

如果我们不给图形填充( fill)颜色、不旋转(transform)图形,只是看他们的骨架(`stro

e`)叠在一起。就会发现它们并没有对齐:

See the Pen by thebabydino (@thebabydino) on CodePen.

解决这个问题最简单的方法就是利用辅助圆的半径把心形向上移动一些:

`return [ /* same coords */ ].map(([x, y]) => [x, y - .09*R])`

现在我们已经对齐了,忽略我们是如何调整这两个例子的f参数的。这个参数在星形中决定了五角星形外接圆半径与viewBox尺寸的对应关系(默认值是 .5),在心形中决定了辅助圆的半径与viewBox尺寸的对应关系(默认值是 .25)。

See the Pen by thebabydino (@thebabydino) on CodePen.

在两个图形中切换

当点击的时候,我们希望能从一种图形转换成另一种。为了做到这个,我们设置一个dir变量,当我们从星形变成心形时,它的值是1。当我们从心形转换成星形时,它的值是-1。初始值是-1,已达到刚刚从心形转换成星形的效果。

然后我们在元素_SHAPE上添加一个'click'事件监听,监听的函数内容为:改变变量dir的值、改变图形的属性。这样就可以获得从一个金色星形转换成深红色心形,再变回星形的效果:

let dir = -1;

(function init() {  
  /* same as before */

  _SHAPE.addEventListener('click', e => {
    dir *= -1;

    for(let p in O)
      _SHAPE.setAttribute(p, O[p].afn(O[p][dir > 0 ? 'fin' : 'ini']));
  }, false);
})();

现在我们可以通过点击图形在两种图形中转换了:

See the Pen by thebabydino (@thebabydino) on CodePen.

在两个图形中转变

我们最终想要的并不是两个图形间唐突的切换,而是柔和的渐变效果。所以我们用以前的文章说明的插值技术来实现。

首先我们要决定转变动画的总帧数(NF),然后选择一种我们想要的时间函数:从星形到心形的的路径(path)转变我们选择ease-in-out函数,旋转角度的转变我们选择 bounce-ini-fin 函数,填充(fill)颜色转变我们选择ease-out 函数。我们先只做这些,如果之后我们改变注意了想探索其它的选项,也可以添加。

/* same as before */
const NF = 50, 
      TFN = {
        'ease-out': function(k) {
          return 1 - Math.pow(1 - k, 1.675)
        }, 
        'ease-in-out': function(k) {
          return .5*(Math.sin((k - .5)*Math.PI) + 1)
        },
        'bounce-ini-fin': function(k, s = -.65*Math.PI, e = -s) {
          return (Math.sin(k*(e - s) + s) - Math.sin(s))/(Math.sin(e) - Math.sin(s))
        }
      };

然后我们为每种属性指定转换时使用的时间函数。

(function init() {  
  /* same as before */

  O.d = {
    /* same as before */
    tfn: 'ease-in-out'
  };

  O.transform = {
    /* same as before */
    tfn: 'bounce-ini-fin'
  };

  O.fill = {
    /* same as before */
    tfn: 'ease-out'
  };

  /* same as before */
})();

我们继续添加请求变量 ID(rID)、当前帧变量 (cf) 、点击时第一个被调用并在每次显示刷新的时候都会被调用的函数update() 、当过渡结束时被调用的函数stopAni(),这个函数用来退出循环动画。在 update()函数里我们更新当前帧 cf,计算进程变量 k,判断过渡是否结束,是退出循环动画还是继续动画。

我们还会添加一个乘数变量 m ,用于防止我们从最终状态(心形)返归到最初状态(星形)时倒转时间函数。

let rID = null, cf = 0, m;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null;  
};

function update() {
  cf += dir;

  let k = cf/NF;

  if(!(cf%NF)) {
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

然后我们需要改变点击时所做的事情:

addEventListener('click', e => {
  if(rID) stopAni();
  dir *= -1;
  m = .5*(1 - dir);
  update();
}, false);

update()函数中,我们需要设置当过渡到中间值(取决于进程变量k)时的属性。如同前边的文章中所述,最好是在开始时计算出最终值和初始值之间的差值范围,甚至是在设置监听之前就设置好,所以我们的下一步是:创建一个计算数字间差值范围的函数。无论在这种情况下,还是在数组中,无论数组的嵌套有多深,都可以这个函数来设置我们想要转变的属性的范围值。

function range(ini, fin) {
  return typeof ini == 'number' ? 
         fin - ini : 
         ini.map((c, i) => range(ini[i], fin[i]))
};

(function init() {  
  /* same as before */

  for(let p in O) {
    O[p].rng = range(O[p].ini, O[p].fin);
    _SHAPE.setAttribute(p, O[p].afn(O[p].ini));
  }

  /* same as before */
})();

现在只剩下 update() 函数中有关插值的部分了。使用一个循环,我们会遍历所有我们想要从一个状态顺滑转换到另一个状态的属性。在这个循环中,我们先得到插值函数的运算结果,然后将这些属性设置成这个值。插值函数的运算结果取决于初始值(s)、当前属性(inirng)的范围(s)、我们使用的定时函数(tfn) 和进度(k):

function update() {  
  /* same as before */

  for(let p in O) {
    let c = O[p];

    _SHAPE.setAttribute(p, c.afn(int(c.ini, c.rng, TFN[c.tfn], k)));
  }

  /* same as before */
};

最后一步是编写这个插值函数。这跟获得范围值的那个函数非常相似:

function int(ini, rng, tfn, k) {
  return typeof ini == 'number' ? 
         Math.round(ini + (m + dir*tfn(m + dir*k))*rng) : 
         ini.map((c, i) => int(ini[i], rng[i], tfn, k))
};

最后获得了一个形状,当点击它时可以从星形过渡转换成心形,第二次点击的时候会变回星形!

See the Pen by thebabydino (@thebabydino) on CodePen.

这几乎就是我们想要的了:但还有一个小问题。对于像角度值这样的循环值,我们并不想在第二次点击的时候将他调转。相反,我们希望他继续顺着同一个方向旋转。通过两次点击后,正好能旋转一周,回到起点。

我们通过给代码添加一个可选的属性,稍稍调整更新函数和插值函数:

function int(ini, rng, tfn, k, cnt) {
  return typeof ini == 'number' ? 
         Math.round(ini + cnt*(m + dir*tfn(m + dir*k))*rng) : 
         ini.map((c, i) => int(ini[i], rng[i], tfn, k, cnt))
};

function update() {  
  /* same as before */

  for(let p in O) {
    let c = O[p];

    _SHAPE.setAttribute(p, c.afn(int(c.ini, c.rng, TFN[c.tfn], k, c.cnt ? dir : 1)));
  }

  /* same as before */
};

(function init() {  
  /* same as before */

  O.transform = {
    ini: -180, 
    fin: 0, 
    afn: (ang) => fnStr('rotate', ang),
    tfn: 'bounce-ini-fin',
    cnt: 1
  };

  /* same as before */
})();

现在我们得到了我们想要的最终结果:一个从金色星形变成深红色心形的形状,每次从一个状态到另一个状态顺时针旋转半圈:

See the Pen by thebabydino (@thebabydino) on CodePen.