使用 SVG 和原生 JavaScript 创建星星到心形的动画

Avatar of Ana Tudor
Ana Tudor

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

在我的 上一篇文章 中,我展示了如何使用原生 JavaScript 平滑地从一个状态过渡到另一个状态。 确保您首先查看那篇文章,因为我将在其中引用很多详细说明的内容,例如作为示例给出的演示、各种定时函数的公式,或者在从过渡的最终状态返回到初始状态时如何不反转定时函数。

最后一个示例展示了如何通过更改用于绘制嘴巴的 pathd 属性,使嘴巴的形状从悲伤变为快乐。

可以将路径数据的操作提升到一个新的水平,从而获得更有趣的结果,例如星星变形为心形。

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.
我们将要编写的星星到心形的动画。

想法

两者都是由五个 三次贝塞尔曲线 组成的。 下面的交互式演示显示了各个曲线以及这些曲线连接的点。 单击任何曲线或点都会突出显示它,以及另一个形状中对应的曲线/点。

查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen

请注意,所有这些曲线都创建为三次曲线,即使其中一些曲线的两个控制点重合。

星星和心形的形状都非常简单且不真实,但它们可以满足我们的需求。

起始代码

面部动画示例 中所示,我通常选择使用 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 以从金色星星变为深红色心形。

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

星星

在星星的情况下,我们从一个规则的五角星开始。 我们曲线的端点位于五角星边的交点处,我们使用五角星的顶点作为控制点。

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.
规则五角星,其顶点和边交点突出显示为五条三次贝塞尔曲线的控制点和端点(实时)。

给定其外接圆的半径(或直径),获取规则五角星的顶点是 非常简单的(我们将其视为 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).
规则五角星和内部规则五边形共享相同的内切圆(实时)。

因此,如果我们计算五角星的内半径,那么我们也得到了内部五边形的内半径,它与规则五边形的一条边的 中心角 一起,可以让我们得到这个五边形的 外接圆半径,这反过来又可以让我们计算其顶点坐标,而这些坐标正是五角星的边交点和三次贝塞尔曲线的端点。

我们的规则五角星由 施莱夫利符号 {5/2} 表示,这意味着它有 5 个顶点,并且,鉴于这 5 个顶点均匀分布在其外接圆上,相隔 360°/5 = 72°,我们从第一个顶点开始,跳过圆上的下一个点,并连接到第二个点(这就是符号中 2 的含义;1 将描述一个五边形,因为我们不跳过任何点,我们连接到第一个点)。 等等——我们继续跳过紧随其后的点。

在下面的交互式演示中,选择五边形或五角星以查看它们是如何构建的。

查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen

这样,我们就可以得到与规则五角星的边对应的中心角是与具有相同顶点的规则五边形对应的中心角的两倍。 我们有 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°)与五边形(右,72°)(实时)。

我们也知道五角星的外接圆半径,我们说将其作为正方形 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.
从一个直角三角形计算规则五角星的内半径,其中斜边是五角星的外接圆半径,两者之间的锐角是与五角星边对应的中心角的一半(实时)。

中心角的一半的余弦是内半径除以外接圆半径,这使我们得到内半径是外接圆半径乘以该余弦值。

现在我们有了五角星内部的小规则五边形的内半径,我们可以从一个类似的直角三角形计算其外接圆半径,该直角三角形的外接圆半径为斜边,中心角的一半为其中一个锐角,内半径为与该锐角相邻的直角边。

下图突出显示了一个直角三角形,该直角三角形由规则五边形的外接圆半径、其内半径和半条边形成。 从这个三角形,如果我们知道内半径和与五边形边对应的中心角,我们可以计算外接圆半径,因为这两个半径之间的锐角是该中心角的一半。

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.
从一个直角三角形计算规则五边形的外接圆半径,其中它是斜边,而直角边是内半径和五边形的一半边,这两个半径之间的锐角是与五边形边对应的中心角的一半(实时)。

请记住,在这种情况下,中心角与五角星的中心角不同,它是其一半(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.
端点和控制点分别分布在内部五边形的外接圆半径和五角星的外接圆半径上(实时)。

我们知道这两个圆的半径。外圆的半径是正五角星的外接圆半径,我们将其视为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;
}

为了计算点的坐标,我们使用它们所在的圆的半径以及连接它们与原点的径向线相对于水平轴的角度,如下面的交互式演示所示(拖动点以查看其笛卡尔坐标如何变化)

参见thebabydino在CodePen上的Pen (@thebabydino)。

在我们的例子中,当前半径对于偶数索引点(02、…)是外圆的半径(五角星外接圆半径RCO),对于奇数索引点(13、…)是内圆的半径(内五边形外接圆半径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))
})();

结果可以在下面的Pen中看到

参见thebabydino在CodePen上的Pen (@thebabydino)。

这是一个很有希望的开始。但是,我们希望生成五角星的第一个尖端指向下方,而生成的星星的第一个尖端指向上方。目前,它们都指向右侧。这是因为我们从(3点钟方向)开始。因此,为了从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。我们在fill情况下将RGB数组设置为初始值,并使用类似的函数来生成实际的属性值

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

现在我们得到了一个漂亮的金色SVG星星,由五个三次贝塞尔曲线组成

参见thebabydino在CodePen上的Pen (@thebabydino)。

爱心

既然我们有了星星,接下来让我们看看如何得到爱心!

我们从两个相交的半径相等的圆开始,这两个圆的半径都是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.
我们从两个半径相等的圆开始,它们的中心点位于水平轴上,并且在垂直轴上相交(实时)。

接下来,我们通过上交点绘制直径,然后通过这些直径的相对点绘制切线。这些切线在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.
通过上交点构造直径,以及通过这些直径的相对端点到圆的切线,这些切线在垂直轴上相交(实时)。

上交点和直径相对的点构成了我们需要五个端点中的三个。另外两个端点将外半圆弧分成两等份,从而得到四个四分之一圆弧。

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.
突出显示构成爱心的三次贝塞尔曲线的端点以及这些曲线底部曲线重合的控制点(实时)。

底部曲线的两个控制点都与之前绘制的两条切线的交点重合。但是其他四条曲线呢?我们如何从圆弧过渡到三次贝塞尔曲线?

我们没有四分之一圆弧的等效三次贝塞尔曲线,但我们可以找到一个非常好的近似值,如这篇文章中所述。

其要点是我们从半径为R的四分之一圆弧开始,并绘制到此弧的端点(NQ)的切线。这些切线在P处相交。四边形ONPQ的所有角都等于90°(或π/2),其中三个角是通过构造得到的(O对应于90°弧,并且到该圆上一点的切线始终垂直于到同一点的径向线),最后一个角是通过计算得到的(四边形的角之和始终为360°,其他三个角之和为270°)。这使得ONPQ成为一个矩形。但ONPQ也有两条连续的边相等(OQON都是径向线,长度都等于R),这使得它成为边长为R的正方形。因此,NPQP的长度也等于R

Illustration showing the control points we need to approximate a quarter circle arc with a cubic Bézier curve.
用三次贝塞尔曲线逼近四分之一圆弧(实时)。

逼近我们弧的三次曲线的控制点位于切线NPQP上,距离端点C·R处,其中C是前面链接的文章计算出的常数,为.551915

鉴于所有这些,我们现在可以开始计算构成星星的曲线端点和控制点的坐标了。

由于我们选择构建此爱心的方式,TO0SO1(见下图)是正方形,因为它所有边都相等(都是两个相等圆之一的半径),并且它的对角线通过构造相等(我们说中心点之间的距离等于交点之间的距离)。这里,O是对角线的交点,OTST对角线的一半。TS位于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正方形(实时)。

我们可以将任何边长为l的正方形分成两个相等的等腰直角三角形,其中直角边与正方形的边重合,斜边与对角线重合。

Illustration showing how a square can be split into two congruent right isosceles triangles.
任何正方形都可以分成两个全等的等腰直角三角形(实时)。

使用其中一个直角三角形,我们可以使用勾股定理计算斜边(因此计算正方形的对角线):d² = l² + l²。这使我们得到了正方形对角线作为边长l的函数:d = √(2∙l) = l∙√2(反之,边长作为对角线的函数为l = d/√2)。这也意味着对角线的一半为d/2 = (l∙√2)/2 = l/√2

将此应用于边长为RTO0SO1正方形,我们得到Ty坐标(其绝对值等于此正方形对角线的一半)为-R/√2Sy坐标为R/√2

Illustration showing the coordinates of the vertices of the TO₀SO₁ square.
TO0SO1正方形的顶点坐标(实时)。

类似地,Ok点位于x轴上,因此它们的y坐标为0,而它们的x坐标由对角线OOk的一半给出:±R/√2

TO0SO1是正方形也意味着它的所有角都是90°(弧度制为π/2)角。

Illustration showing TAₖBₖS quadrilaterals.
TAkBkS四边形(实时)。

在上图中,TBk线段是直径线段,这意味着TBk弧是半圆或180°弧,我们用Ak点将其分成两等份,得到两个相等的90°弧——TAkAkBk,它们对应于两个相等的90°角,∠TOkAk∠AkOkBk

已知∠TOkS90°角,并且∠TOkAk根据构造也是90°角,由此得出SAk线段也是直径线段。这使得在TAkBkS四边形中,对角线TBkSAk相互垂直且相等,并在中点处相交(TOkOkBkSOkOkAk都等于初始圆的半径R)。这意味着TAkBkS四边形是正方形,其对角线长度为2∙R

由此我们可以得到TAkBkS四边形的边长为2∙R/√2 = R∙√2。由于正方形的所有角都是90°角,并且TS边与垂直轴重合,这意味着TAkSBk边是水平的,平行于x轴,并且它们的长度给出了AkBk点的x坐标:±R∙√2

由于TAkSBk是水平线段,因此AkBk点的y坐标分别等于T-R/√2)和SR/√2)点的y坐标。

Illustration showing the coordinates of the vertices of the TAₖBₖS squares.
TAkBkS正方形顶点的坐标(动态演示)。

我们还可以从这里得到,由于TAkBkS是正方形,AkBk平行于TS(位于y(垂直)轴上),因此AkBk线段是垂直的。此外,由于x轴平行于TAkSBk线段,并且它与TS相交,因此它也与AkBk线段在中点处相交。

现在让我们继续讨论控制点。

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

Illustration showing the TB₀CB₁ quadrilateral.
TB0CB1四边形(动态演示)。

TB0CB1四边形的所有角都等于90°∠T因为TO0SO1是正方形,∠Bk因为BkC线段在Bk处与圆相切,因此垂直于该点的半径线OkBk;最后,∠C只能是90°,因为四边形的内角和为360°,而其他三个角之和为270°),这使得它成为一个矩形。它还有两条连续的边相等——TB0TB1都是初始正方形的直径,因此都等于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

Illustration showing the coordinates of the vertices of the TB₀CB₁ square.
TB0CB1正方形顶点的坐标(动态演示)。

因此,我们现在得到了底部曲线两个重合控制点的坐标为(0,3∙R/√2)

为了得到其他曲线控制点的坐标,我们通过它们的端点画切线,然后得到这些切线的交点DkEk

Illustration showing the TOₖAₖDₖ and AₖOₖBₖEₖ quadrilaterals.
TOkAkDkAkOkBkEk四边形(动态演示)。

TOkAkDk四边形中,我们发现所有角都是90°(直角),其中三个角是根据构造得到的(∠DkTOk∠DkAkOk分别是TAk处半径线和切线之间的角,而∠TOkAk是对应于四分之一圆弧TAk的角),第四个角则是通过计算得到的(四边形的内角和为360°,而其他三个角之和为270°)。这使得TOkAkDk成为矩形。由于它们有两条连续的边相等(OkTOkAk是长度为R的半径线段),因此它们也是正方形。

这意味着对角线TAkOkDk长度为R∙√2。我们已经知道TAk是水平的,并且由于正方形的对角线相互垂直,因此OkDk线段是垂直的。这意味着OkDk点具有相同的x坐标,我们已经计算出Okx坐标为±R/√2。由于我们知道OkDk的长度,我们也可以得到y坐标——它们是对角线长度(R∙√2),前面加上负号。

类似地,在AkOkBkEk四边形中,我们发现所有角都是90°(直角),其中三个角是根据构造得到的(∠EkAkOk∠EkBkOk分别是AkBk处半径线和切线之间的角,而∠AkOkBk是对应于四分之一圆弧AkBk的角),第四个角则是通过计算得到的(四边形的内角和为360°,而其他三个角之和为270°)。这使得AkOkBkEk成为矩形。由于它们有两条连续的边相等(OkAkOkBk是长度为R的半径线段),因此它们也是正方形。

由此,我们得到对角线AkBkOkEk长度为R∙√2。我们知道AkBk线段是垂直的,并且被水平轴分成两半,这意味着OkEk线段位于此轴上,并且Ek点的y坐标为0。由于Ok点的x坐标为±R/√2,并且OkEk线段长度为R∙√2,我们也可以计算出Ek点的x坐标——它们为±3∙R/√2

Illustration showing the coordinates of the newly computed vertices of the TOₖAₖDₖ and AₖOₖBₖEₖ squares.
新计算得到的TOₖAₖDₖ和AₖOₖBₖEₖ正方形顶点的坐标(动态演示)。

好的,但是这些切线的交点并不是我们获得圆弧近似值所需的控制点。我们想要的控制点位于TDkAkDkAkEkBkEk线段上,距离曲线端点(TAkBk)大约55%(此值由前面提到的文章中计算出的常数C给出)。这意味着从端点到控制点的线段长度为C∙R

在这种情况下,我们的控制点的坐标为端点(TAkBk)坐标的1 - C倍,加上端点切线交点(DkEk)坐标的C倍。

所以让我们把这些都放到JavaScript代码中!

就像在五角星案例中一样,我们从一个getStarPoints(f)函数开始,该函数依赖于一个任意因子(f),该因子将帮助我们从viewBox大小获取辅助圆的半径。此函数还返回一个坐标数组,我们稍后将其用于插值。

在函数内部,我们计算在整个函数中不会改变的内容。首先,辅助圆的半径。然后是边长等于此辅助圆半径的小正方形的半对角线,半对角线也是这些正方形的外接圆半径。之后是三次曲线的端点(TAkBk点)的坐标,对于水平轴上的坐标,使用绝对值。然后我们继续计算通过端点切线的交点(CDkEk点)的坐标。这些坐标要么与控制点(C)重合,要么可以帮助我们得到控制点(DkEk就是这种情况)。

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) */;
}

下面的交互式演示显示了这些点的坐标(点击即可查看)。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

现在我们还可以从端点和通过端点切线的交点得到控制点。

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 */
}

接下来,我们需要将相关坐标放入一个数组并返回此数组。在五角星的情况下,我们从底部曲线开始,然后顺时针方向绘制,所以这里也这样做。对于每条曲线,我们推送两组控制点的坐标,然后推送一组当前曲线结束点的坐标。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

请注意,在第一条(底部)曲线的情况下,两个控制点重合,因此我们推送了两次相同的坐标对。代码看起来不像五角星案例那样简洁,但它足够用了。

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

这使得我们得到了一个好看的 心形。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

确保形状对齐一致

但是,如果我们将这两个形状一个叠加在另一个上面,不使用filltransform,只使用stroke,我们会发现对齐效果很糟糕。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

解决此问题的最简单方法是将心形向上移动一个取决于辅助圆半径的距离。

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

现在,无论我们在任何情况下如何调整f因子,对齐效果都更好。这个因子在五角星案例中决定五角星外接圆相对于viewBox大小的比例(默认为.5),在心形案例中决定辅助圆相对于相同viewBox大小的比例(默认为.25)。

查看 thebabydino 在 CodePen 上创作的 @thebabydino

在两种形状之间切换

我们希望通过点击从一种形状切换到另一种形状。为了做到这一点,我们设置了一个方向变量 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);
})();

现在我们通过点击在两种形状之间切换。

查看 thebabydino 在 CodePen 上创作的 @thebabydino

从一种形状变形到另一种形状

然而,我们真正想要的不是从一种形状到另一种形状的突然变化,而是一个渐变的过程。因此,我们使用上一篇文章中解释的插值技术来实现这一点。

我们首先确定过渡的总帧数 (NF),并选择我们想要使用的时序函数类型——用于将 path 形状从星星过渡到爱心的 ease-in-out 类型函数,用于旋转角度的 bounce-ini-fin 类型函数,以及用于 fillease-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)、当前属性的范围(s) (inirng)、我们使用的时序函数 (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))
};

这最终使我们得到一个形状,该形状在点击时从星星变形到爱心,并在第二次点击时返回到星星!

查看 thebabydino 在 CodePen 上创作的 @thebabydino

几乎是我们想要的——仍然存在一个微小的问题。对于角度值之类的循环值,我们不希望在第二次点击时回退半个圆。相反,我们希望继续沿相同方向再转半个圆。将第二次点击之后的半个圆与第一次点击之后的半个圆加在一起,我们就得到一个完整的圆,所以我们回到了起点。

我们通过添加一个可选的连续性属性并稍微调整更新和插值函数来将其转换为代码。

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 */
})();

现在我们得到了我们一直追求的结果:一个形状,它从金色的星星变形为深红色的爱心,并在每次从一个状态过渡到另一个状态时顺时针旋转半个圆。

查看 thebabydino 在 CodePen 上创作的 @thebabydino