使用 SVG 和 JS 创建一个由星形变心形的动画

时间:2022-05-06
本文章向大家介绍使用 SVG 和 JS 创建一个由星形变心形的动画,主要内容包括想法、开始编写代码、几何图形、心形、确保两个形状对齐、在两个形状之间切换、从一个形状到另一个形状的过渡、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

序言:首先,这是一篇学习 SVG 及 JS 动画不可多得的优秀文章。我非常喜欢 Ana Tudor 写的教程。在她的教程中有大量使用 SVG 制作的图解以及实时交互 DEMO,可以说教程的所有细枝末节都可以成为学习 SVG 以及 JS 画图的资料。另一方面,这篇教程也非常枯燥,因为教程的主要篇幅是关于几何图形的数学计算,不过上过中学的人都能理解。全篇翻译完,我觉得我几乎重新温习了一遍中学的几何知识,顺便学了点英语词汇。最后还要感叹一下,想要灵活运用 SVG 画图,深厚的数学功底是不可或缺的,同时还要有敏锐的思维和牢靠的记忆力。 原文:Creating a Star to Heart Animation with SVG and Vanilla JavaScript 译者:nzbin

在 我上一篇文章中, 我讲解了如何使用纯 JavaScript 实现从一个状态到另一个状态的平滑过渡。一定要看看这篇文章,因为我会引用一些我详细解释过的东西,比如演示示例、各种定时函数公式以及如何从结束状态返回初始状态而不需要反转定时函数。

最后一个例子展示了一个从悲伤到高兴的嘴形,它是通过嘴形 pathd 属性实现的。

利用路径数据可以获得更有趣的结果,比如一颗星星变成一个心。

我们即将编写的星星变心的动画。

想法

两个形状都是使用五条 三次 Bézier 曲线 创建的。下面的交互式演示显示了各个曲线和这些曲线连接的点。单击任何曲线或点都会高亮显示,与它对应的另一个形状的曲线/点也会高亮显示。

See the Pen star vs. heart: highlight corresponding cubic Bézier curves on click by Ana Tudor (@thebabydino) on CodePen.

注意,所有这些曲线都是三次曲线,不过其中一些曲线的两个控制点是重合的。

星星和心的形状都非常简单,但制作起来还是会有一定难度。

开始编写代码

正如在 脸部动画 中看到的,我经常使用 Pug 生成这样的形状,但在这里,因为我们生成的路径数据也需要用 JavaScript 来制作路径动画,所以全部使用 JavaScript,包括计算坐标并把数值放入 d 属性中,这似乎是最好的选择。

这意味着我们不需要写太多的标签:

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

使用 JavaScript 的话, 我们先要获取 SVG 元素和 path 元素(这是星形到心形来回切换的形状)。我们在 SVG 元素上添加了 viewBox 属性,这样可以保证沿两轴方向尺寸相等并且 (0,0) 点位于视图中心。所以左上角的坐标是 (-.5*D,-.5*D), 其中 DviewBox 尺寸的数值。最后,但并非最不重要的一点是,我们创建一个对象来存储关于初始状态和结束状态的信息,以及设置 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(' '));
})();

既然已经弄明白了,现在开始讨论有趣的部分!

几何图形

端点和控制点的初始状态的坐标用于画星星,结束状态的坐标用于画心形。每个坐标的范围是它的结束值与其初始值之间的差值。在这里,需要旋转变形的形状,因为我们想让星星的角指向上方,其次我们改变 fill 实现金星到红心的变化。

但是在这两种情况下,我们如何得到端点和控制点的坐标呢?

星形

从星形开始,先画一个正五角星。曲线的端点就是五角星边的交点,控制点是五角星的顶点。

高亮显示的正五角星顶点以及边线交点就是五条三次 Bézier 曲线的控制点及端点 (live).

获取正五角星的顶点坐标 非常容易 ,只要知道它的外接圆半径 ( 或直径 ),我们可以从 SVG (为了简单起见,我们把它看成正方形,不在对它严密封装)的 viewBox 尺寸得到。但是我们怎样才能获得交叉点坐标呢?

首先,我们先考虑下图中五角星形中高亮显示的小五边形。由于是正五角星形,所以五角星形边线交叉得到的小五边形也是正五边形。它和五角星形有相同的 内切圆 及内切圆半径。

正五角星形和它里面的正五边形有相同的内切圆 (live).

如果我们计算五角星的内切圆半径,那么就可以得到内五边形的半径,如果再知道正五边形一条边所对的 圆心角, 就可以得到五边形的 外接圆半径,然后就可以计算出顶点坐标,这些坐标也是五角星形边线的交点坐标以及三次 Bézier 曲线的坐标。

我们的正五角星形可以用 Schläfli symbol {5/2} 表示,这说明它有 5 顶点,然后将这 5 个顶点平均分布到它的外接圆上,每个点相隔 360°/5 = 72° 。我们从第一个点开始,跳过圆上的相邻点与第二个点连接(这就是符号中的 21 表示五边形,也就是不跳过任何点,与第一个点连接)。以此类推,圆上的点依次相隔连接。

在下面的交互式演示中,可以选择五边形或五角星形,看看它们是怎样生成的。

See the Pen construct regular pentagon/ pentagram by Ana Tudor (@thebabydino) on CodePen.

这样,我们得到了正五角星形的中心角,它是正五边形圆心角的两倍。其中正五边形的圆心角是 1·(360°/5) = 1·72° = 72° (弧度 1·(2·π/5)),而正五边形为 2·(360°/5) = 2·72° = 144° (弧度为 2·(2·π/5))。通常,给定一个正多边形(不管是凸多边形还是星形多边形),使用 Schläfli symbol {p,q} 表示,与一条边相对的圆心角就是 q·(360°/p) (弧度为 q·(2·π/p))。

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

我们已经知道五角星形的外接圆半径, 它是正方形 viewBox 尺寸的一部分。这意味着可以通过直角三角形得到五角星形的内切圆半径(等于它里面的小五边形的内切圆半径),因为我们已经知道斜边(就是五角星形的外接圆半径)以及一个锐角(与边相对的圆心角的一半)。

通过直角三角形计算正五角星形的内切圆半径,其中斜边是五角星形的外接圆半径,锐角是五角星形边所对的半径夹角的一半 (live).

圆心角一半的余弦值就是内切圆半径除以外接圆半径,所以内切圆半径等于外接圆乘以余弦值。

现在已经知道了五角星形内的小正五边形的内切圆半径,我们可以通过相似的直角三角形计算外接圆半径,直角三角形的斜边就是外接圆半径,圆心角的一半是其中一个锐角,与锐角相邻的中垂线是内切圆半径。

下图中,高亮突出显示的直角三角形就是由正多边形的外接圆半径、内切圆半径以及边线的一半组成的。从这个三角形中,如果我们知道内切圆半径以及与多边形相对的圆心角(两个半径之间的锐角等于圆心角的一半),我们就可以计算出外接圆半径。

通过直角三角形计算正五边形的外接圆半径(斜边), 直角边是内切圆半径和五边形边长的一半,锐角是五边形边所对的半径夹角的一半  (live).

记住,在这种情况下,圆心角并不等于五角星形的圆心角,而是它的一半 (360°/5 = 72°).

很好,得到内切圆半径之后,我们可以得到所有想要的点坐标。它们是在两个圆上以相等角度分布的点的坐标。外圆(五角星形的外接圆)上有 5 个点,内圆(小五边形的外接圆)上也有 5 个点。总共有 10 个点,它们所在的径向线之间的角度为 360°/10 = 36°

端点及控制点分别平均分布在内五边形和五角星的外接圆上 (live).

我们已经知道这两个圆的半径。外圆的半径是正五边形的外接圆半径,我们可以取 viewBox 尺寸的任意数值(.5.25.32 或者我们觉得更好的数值)。内圆的半径是在五角星形内形成的小正五边形的外接圆半径,可以通过一条边相对的圆心角和内切圆半径计算, 而内切圆半径等于五角星形的内切圆半径,可以通过五角星形外接圆半径和圆心角计算得出。

因此,我们已经可以获得绘制五角星的路径数据,所有数据都是已知的。

现在让我们在代码中去实现它!

我们先创建一个 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 position of point in a plane (drag point) by Ana Tudor (@thebabydino) on CodePen.

在我们的例子中,偶数点 (0, 2, ...) 半径是外圆的半径(五角星外接圆半径 RCO),奇数点 (1, 3, ...) 半径是内圆半径(内五边形外接圆半径 RCI),而点的径向线与端点的夹角就是该点的索引 (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))
})();

结果可以在下面的 CodePen 中查看:

See the Pen make SVG star shape by Ana Tudor (@thebabydino) on CodePen.

这是一个好的开始。然而,我们希望生成的五角星第一个角朝下,而最终的星形第一个角朝上。目前,他们都指向右。这是因为星形是从  度(三点钟方向)开始绘制的。所以为了将六点钟方向作为起点,我们在 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 */
})();

我们也用同样的方式给星形填充金色。将 RGB 数组设置为 fill 的初始值,并使用同样的函数生成实际的属性值:

(function init() {
  /* same as before */
  
  O.fill = { ini: [255, 215, 0],  afn: (rgb) => fnStr('rgb', rgb) };
    
  /* same as before */
})();

现在,我们有了一个使用三次 Bézier 曲线及 SVG 绘制的漂亮的金色星星:

See the Pen make SVG star shape #2 by Ana Tudor (@thebabydino) on CodePen.

心形

既然已经有了星形,接下来看看如何才能得到心形!

我们从两个等径的相交圆开始画,半径都是  viewBox 尺寸的一部分(暂时为 .25 )。在这种情况下,两个相交圆的中心点连线位于 x 轴,交点连线位于 y 轴。而且这两部分是相等的。

从两个半径相等的圆开始画,它的圆心位于横轴,交线位于竖轴 (live).

接下来,我们画出通过上方交点的直径,然后画出通过直径另一点的切线。这些切线相交于 y 轴。

画出经过上方交点的直径,以及经过直径与圆相交的另一端点的切线,切线的交点位于竖轴 (live).

上方的交点和切点正好是我们需要的五个端点中的三个。另外两个端点将半圆弧分成了两个相等的部分,从而可以得到四个四分之一圆弧。

高亮显示的三次 Bézier 曲线构成了心形, 下方曲线的控制点重合 (live).

下方的曲线的控制点正好和之前两切线的交点重合。但是其他四条曲线呢?如何用三次 Bézier 曲线得到圆弧?

我们无法直接通过三次 Bézier 曲线画出四分之一圆弧,但我们可以找到近似的方法,详见 这篇文章

我们从一个半径为 R 的四分之一圆弧开始,画出圆弧端点 ( N and Q ) 的切线。切线相交于 P 点。四边形 ONPQ 的所有角都等于 90° ( 或者 π/2 ),其中三个是创建出来的(O 所对的是 90° 圆弧,所以通过圆弧端点的切线必然与通过该点的半径垂直) ,最后一个是计算出来的(四边形的内角和是 360° ,而另外三个角的和为 270°)。所以 ONPQ 是一个矩形。但是 ONPQ 也有两个相等的邻边(OQ 和 ON 是半径,长度等于 R ),所以它是边长为 R 的正方形。因此 NP 和 QP 的长度也等于 R

三次 Bézier 曲线画出的近似四分之一圆弧 (live).

与圆弧近似的三次曲线的控制点在切线 NP 和 QP 上,与端点的距离为 C·R ,其中 C 是之前介绍的文章中所计算出的常量 .551915

知道这些条件之后,现在开始计算创建出星形的端点和控制点坐标。

基于我们选择的创建心形的方式,TO0SO1 (如以下图形所示) 是 一个正方形 ,因为它的所有边都相等(都等于两个相等圆的半径)并且对角线也相等(我们说过中心点之间的距离等于交点之间的距离)。其中, O 是对角线的交点,OT 是对角线 ST 的一半。T 和 S 都位于 y 轴,所以它们的 x 坐标为 0 。它们的 y 坐标的绝对值等于 OT 线段的长度,也是对角线(OS 线段)的一半。

正方形 TO0SO1 (live).

我们将所有的正方形分解成边长为 l 的两个等腰三角形,其中直角边等于正方形边长,斜边等于对角线长度。

任何正方形都可以分成两个全等的等腰直角三角形 (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

正方形 TO0SO1 的所有点坐标(live).

同样的,Ok 点位于 x 轴,所以它们的 y 坐标是 0 ,它们的 x 坐标是对角线 OOk 长度的一半: ±R/√2

TO0SO1 是一个正方形,所以它的所有角度都是 90°(弧度为 π/2 ) 。

四边形 TAkBkS  (live).

上图中, TBk 线段是直径,所以 TBk 所对的弧是半圆弧,也就是 180° 弧,并且 Ak 将它分成了相等的两部分 TAk 和 AkBk,每一部分是 90° 弧,它所对的是 90° 角, ∠TOkAk 和 ∠AkOkBk 。

因为 ∠TOkS 是 90° 角而且 ∠TOkAk 也是 90° 角,所以 SAk 线段也是直径。因此在四边形 TAkBkS 中,对角线 TBk 和 SAk 是垂直且相等,并且相交于中点 (TOk, OkBk, SOk 和 OkAk 相等,都是初始圆的半径 R)。这说明四边形 TAkBkS 是正方形并且对角线长为 2∙R

现在我们可以获得四边形 TAkBkS 的边长为 2∙R/√2 = R∙√2 。因为所有角都是 90° 并且 TS 与竖轴重合,所以 TAk 和 SBk 边是水平的,平行于 x 轴并且它们的长度是 Ak 和 Bk 点的 x 坐标: ±R∙√2.

因为 TAk 和 SBk 是水平线,所以 Ak 和 Bk 点的 y 坐标是相等的,分别等于 T (-R/√2) 和 S (R/√2) 点坐标。

正方形 TAkBkS 的所有点坐标(live).

我们还可以知道的一点是,因为 TAkBkS 是正方形, AkBk 平行于 TS,TS 位于 y (垂直) 轴,因此线段 AkBk 是垂直的。另外, 因为 x 轴平行于线段 TAk 和 SBk ,并且平分 TS,所以它也平分线段 AkBk 。

现在让我们转到控制点。

我们从底部曲线的重叠控制点开始。

四边形 TB0CB1 (live).

四边形 TB0CB1 所有角度都是 90° (因为 TO0SO1 是正方形,所以 ∠T 是直角;因为线段 BkC 在 Bk 点与圆相切,因此与半径 OkBk 垂直,所以 ∠Bk 是直角;最后,因为四边形内角和是 360° 而其它三个角是270° ,所以 ∠C 也是 90°  ), 所以它是矩形。又因为 TB0 和 TB1 相等,都是初始圆的直径,因此都等于 2∙R 。所以它是边长为 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

正方形 TB0CB1 的顶点坐标 (live).

因此我们得到了底部曲线两个相似控制点的坐标 (0,3∙R/√2).

为了获得其它曲线控制点的坐标,我们需要画出经过端点的切线,它们的交点是 Dk 和 Ek 。

四边形 TOkAkDk 和 AkOkBkEk  (live).

在四边形 TOkAkDk 中,所有角都是 90° (直角),其中三个是已知的(∠DkTOk 和 ∠DkAkOk 是半径分别在 T 和 Ak 点与切线的夹角,而 ∠TOkAk 是四分之一圆弧 TAk 所对的角),第四个角是计算出来的(所有角的和是 360° 而另外三个的和是  270°)。所以 TOkAkDk 是矩形。又因为两个相邻边相等(线段OkT 和 OkAk 都是半径的长 R), 因此它们都是正方形。

所以对角线 TAk 和 OkDk 等于 R∙√2 。已知 TAk 是水平的,又因为正方形对角线垂直,所以线段 OkDk 是垂直的。所以 Ok 和 Dk 点的 x 坐标相等,我们已经计算过 Ok 点坐标是 ±R/√2 。因为已知 OkDk 的长度,所以也可以求出 y 坐标,等于对角线长度 (R∙√2) ,前面有负号。

同样的,在四边形 AkOkBkEk 中,所有角也都是 90° (直角), 其中三个是已知的(∠EkAkOk 和 ∠EkBkOk 是半径分别在 Ak 和 Bk 点与切线的夹角,而 ∠AkOkBk 是四分之一圆弧 AkBk 所对的角),第四个角是计算出来的(所有角的和是 360° 而另外三个的和是  270°), 所以 AkOkBkEk 是矩形。又因为两个相邻边相等(线段OkT 和 OkAk 都是半径的长 R), 因此它们都是正方形。

现在,我们知道了对角线 AkBk 和 OkEk 的长度是 R∙√2 。已知线段 AkBk 是垂直的,而且被水平轴平分,所以线段 OkEk 位于 x 轴,因此 Ek 点的 y 坐标是 0 。又因为Ok 点的 x 坐标是 ±R/√2 而且线段 OkEk 等于 R∙√2, 所以可以计算出 Ek 点坐标等于 ±3∙R/√2

正方形 TOₖAₖDₖ 和 AₖOₖBₖEₖ 上新计算的点的坐标 (live).

但是,这些切线交点并不是我们想要获得的近似圆弧的控制点。我们需要的控制点位于线段 TDk, AkDk, AkEk 和 BkEk 上,与(T, Ak, Bk)相聚大约 55% 的位置(这个数值是通过之前文章中的 C 计算出来的) 。所以端点到控制点的线段长为 C∙R

在这种情况下,控制点坐标为 1 - C 乘以 (T, Ak and Bk) 点坐标,再加上 C 乘以这些点的切线交点坐标 (Dk 和 Ek)。

赶快编写 JavaScript 代码吧!

和编写星形代码一样,先写一个 getStarPoints(f) 函数,需要传一个任意因子参数 (f) ,用于从 viewBox 的尺寸中获取辅助圆的半径。这个方法也会返回之后用到的插入点坐标数组。

在函数内部,我们计算那些在整个函数中不会改变的常量。首先是辅助圆的半径。其次是小正方形的对角线,它的长度等于辅助圆半径,对角线一半也是它的外接圆半径。然后是三次曲线的端点坐标 ( T, Ak, Bk 点),沿水平方轴方向的绝对值。最后计算通过端点的切线交点坐标 ( C, Dk, Ek 点)。这些点要么是与控制点一致 (C),要么可以帮助我们获得控制点 (可以参考计算 Dk 和 Ek 点的方法)。

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 heart structure - end and intersection points by Ana Tudor (@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 star vs. heart: corresponding cubic Bézier curves (annotated, highlight on click) by Ana Tudor (@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 make SVG heart shape by Ana Tudor (@thebabydino) on CodePen.

确保两个形状对齐

但是如果将两个形状放到一起,不使用 fill 或者 transform,只有 stroke, 可以看到两个形状并没有对齐:

See the Pen SVG star vs. heart alignment by Ana Tudor (@thebabydino) on CodePen.

解决这个问题最简单的方法是让心形根据辅助圆半径的大小缩放:

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

现在可以很好的对齐了, 不管怎样调整 f 因数。在星形中,这个因数决定了相对于 viewBox 尺寸的五角星外接圆半径 (默认是 .5) ;在心形中,它决定了同样相对于 viewBox 尺寸的辅助圆半径 (默认是 .25)。

See the Pen star-heart alignment for various f factors by Ana Tudor (@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 toggle between star and heart on click by Ana Tudor (@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 SVG + plain JS: star to heart & back (click) by Ana Tudor (@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 #CodeVember #15 - no library star or heart this? by Ana Tudor (@thebabydino) on CodePen.