在我的 上一篇文章 中,我展示了如何使用原生 JavaScript 平滑地从一个状态过渡到另一个状态。 确保您首先查看那篇文章,因为我将在其中引用很多详细说明的内容,例如作为示例给出的演示、各种定时函数的公式,或者在从过渡的最终状态返回到初始状态时如何不反转定时函数。
最后一个示例展示了如何通过更改用于绘制嘴巴的 path
的 d
属性,使嘴巴的形状从悲伤变为快乐。
可以将路径数据的操作提升到一个新的水平,从而获得更有趣的结果,例如星星变形为心形。

想法
两者都是由五个 三次贝塞尔曲线 组成的。 下面的交互式演示显示了各个曲线以及这些曲线连接的点。 单击任何曲线或点都会突出显示它,以及另一个形状中对应的曲线/点。
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
请注意,所有这些曲线都创建为三次曲线,即使其中一些曲线的两个控制点重合。
星星和心形的形状都非常简单且不真实,但它们可以满足我们的需求。
起始代码
如 面部动画示例 中所示,我通常选择使用 Pug 生成此类形状,但在这里,由于我们生成的路径数据也需要使用 JavaScript 进行过渡操作,因此完全使用 JavaScript(包括计算坐标并将它们放入 d
属性中)似乎是最佳选择。
这意味着我们不需要在标记方面编写太多内容。
<svg>
<path id='shape'/>
</svg>
在 JavaScript 方面,我们首先获取 SVG 元素和 path
元素——这是从星星变形为心形并返回的形状。 我们还在 SVG 元素上设置了一个 viewBox
属性,以便其沿两个轴的尺寸相等,并且 (0,0)
点位于正中间。 这意味着左上角的坐标为 (-.5*D,-.5*D)
,其中 D
是 viewBox
尺寸的值。 最后但并非最不重要的是,我们创建一个对象来存储有关过渡的初始状态和最终状态以及如何将插值值转换为我们需要在 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
以从金色星星变为深红色心形。
好的,但是我们如何在两种情况下获得端点和控制点的坐标呢?
星星
在星星的情况下,我们从一个规则的五角星开始。 我们曲线的端点位于五角星边的交点处,我们使用五角星的顶点作为控制点。
给定其外接圆的半径(或直径),获取规则五角星的顶点是 非常简单的(我们将其视为 SVG 的 viewBox
大小的一部分,为了简单起见,这里认为是正方形,在这种情况下我们不会进行紧密填充)。 但是我们如何获得它们的交点呢?
首先,让我们考虑一下下图中五角星内部突出显示的小五边形。 由于五角星是规则的,因此其顶点与五角星边交点重合的小五边形也是规则的。 它与五角星具有相同的 内切圆,因此具有相同的内半径。
因此,如果我们计算五角星的内半径,那么我们也得到了内部五边形的内半径,它与规则五边形的一条边的 中心角 一起,可以让我们得到这个五边形的 外接圆半径,这反过来又可以让我们计算其顶点坐标,而这些坐标正是五角星的边交点和三次贝塞尔曲线的端点。
我们的规则五角星由 施莱夫利符号 {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)
)。
144°
)与五边形(右,72°
)(实时)。我们也知道五角星的外接圆半径,我们说将其作为正方形 viewBox
大小的一部分。 这意味着我们可以从一个直角三角形中获得五角星的内半径(它等于小五边形的内半径),在这个直角三角形中,我们知道斜边(它是五角星的外接圆半径)和一个锐角(与五角星边对应的中心角的一半)。
中心角的一半的余弦是内半径除以外接圆半径,这使我们得到内半径是外接圆半径乘以该余弦值。
现在我们有了五角星内部的小规则五边形的内半径,我们可以从一个类似的直角三角形计算其外接圆半径,该直角三角形的外接圆半径为斜边,中心角的一半为其中一个锐角,内半径为与该锐角相邻的直角边。
下图突出显示了一个直角三角形,该直角三角形由规则五边形的外接圆半径、其内半径和半条边形成。 从这个三角形,如果我们知道内半径和与五边形边对应的中心角,我们可以计算外接圆半径,因为这两个半径之间的锐角是该中心角的一半。
请记住,在这种情况下,中心角与五角星的中心角不同,它是其一半(360°/5 = 72°
)。
好的,现在我们有了这个半径,我们可以获得我们想要的所有坐标。 它们是分布在两个圆上等角度的点的坐标。 我们在外圆(五角星的外接圆)上有 5
个点,在内圆(小五边形的外接圆)上有 5
个点。 总共有 10
个点,它们所在的径向线之间夹角为 360°/10 = 36°
。
我们知道这两个圆的半径。外圆的半径是正五角星的外接圆半径,我们将其视为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)。
在我们的例子中,当前半径对于偶数索引点(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))
})();
结果可以在下面的Pen中看到
参见thebabydino在CodePen上的Pen (@thebabydino)。
这是一个很有希望的开始。但是,我们希望生成五角星的第一个尖端指向下方,而生成的星星的第一个尖端指向上方。目前,它们都指向右侧。这是因为我们从0°
(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轴上。我们还认为这两条线段相等。
接下来,我们通过上交点绘制直径,然后通过这些直径的相对点绘制切线。这些切线在y轴上相交。
上交点和直径相对的点构成了我们需要五个端点中的三个。另外两个端点将外半圆弧分成两等份,从而得到四个四分之一圆弧。
底部曲线的两个控制点都与之前绘制的两条切线的交点重合。但是其他四条曲线呢?我们如何从圆弧过渡到三次贝塞尔曲线?
我们没有四分之一圆弧的等效三次贝塞尔曲线,但我们可以找到一个非常好的近似值,如这篇文章中所述。
其要点是我们从半径为R
的四分之一圆弧开始,并绘制到此弧的端点(N和Q)的切线。这些切线在P处相交。四边形ONPQ的所有角都等于90°
(或π/2
),其中三个角是通过构造得到的(O对应于90°
弧,并且到该圆上一点的切线始终垂直于到同一点的径向线),最后一个角是通过计算得到的(四边形的角之和始终为360°
,其他三个角之和为270°
)。这使得ONPQ成为一个矩形。但ONPQ也有两条连续的边相等(OQ和ON都是径向线,长度都等于R
),这使得它成为边长为R
的正方形。因此,NP和QP的长度也等于R
。
逼近我们弧的三次曲线的控制点位于切线NP和QP上,距离端点C·R
处,其中C
是前面链接的文章计算出的常数,为.551915
。
鉴于所有这些,我们现在可以开始计算构成星星的曲线端点和控制点的坐标了。
由于我们选择构建此爱心的方式,TO0SO1(见下图)是正方形,因为它所有边都相等(都是两个相等圆之一的半径),并且它的对角线通过构造相等(我们说中心点之间的距离等于交点之间的距离)。这里,O是对角线的交点,OT是ST对角线的一半。T和S位于y轴上,因此它们的x坐标为0
。它们的y坐标的绝对值等于OT线段,即对角线的一半(就像OS线段一样)。
我们可以将任何边长为l
的正方形分成两个相等的等腰直角三角形,其中直角边与正方形的边重合,斜边与对角线重合。
使用其中一个直角三角形,我们可以使用勾股定理计算斜边(因此计算正方形的对角线):d² = l² + 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
。
类似地,Ok点位于x轴上,因此它们的y坐标为0
,而它们的x坐标由对角线OOk的一半给出:±R/√2
。
TO0SO1是正方形也意味着它的所有角都是90°
(弧度制为π/2
)角。
在上图中,TBk线段是直径线段,这意味着TBk弧是半圆或180°
弧,我们用Ak点将其分成两等份,得到两个相等的90°
弧——TAk和AkBk,它们对应于两个相等的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
)点的y坐标。
我们还可以从这里得到,由于TAkBkS是正方形,AkBk平行于TS(位于y(垂直)轴上),因此AkBk线段是垂直的。此外,由于x轴平行于TAk和SBk线段,并且它与TS相交,因此它也与AkBk线段在中点处相交。
现在让我们继续讨论控制点。
我们从底部曲线的重合控制点开始。
TB0CB1四边形的所有角都等于90°
(∠T因为TO0SO1是正方形,∠Bk因为BkC线段在Bk处与圆相切,因此垂直于该点的半径线OkBk;最后,∠C只能是90°
,因为四边形的内角和为360°
,而其他三个角之和为270°
),这使得它成为一个矩形。它还有两条连续的边相等——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
。
因此,我们现在得到了底部曲线两个重合控制点的坐标为(0,3∙R/√2)
。
为了得到其他曲线控制点的坐标,我们通过它们的端点画切线,然后得到这些切线的交点Dk和Ek。
在TOkAkDk四边形中,我们发现所有角都是90°
(直角),其中三个角是根据构造得到的(∠DkTOk和∠DkAkOk分别是T和Ak处半径线和切线之间的角,而∠TOkAk是对应于四分之一圆弧TAk的角),第四个角则是通过计算得到的(四边形的内角和为360°
,而其他三个角之和为270°
)。这使得TOkAkDk成为矩形。由于它们有两条连续的边相等(OkT和OkAk是长度为R
的半径线段),因此它们也是正方形。
这意味着对角线TAk和OkDk长度为R∙√2
。我们已经知道TAk是水平的,并且由于正方形的对角线相互垂直,因此OkDk线段是垂直的。这意味着Ok和Dk点具有相同的x坐标,我们已经计算出Ok的x坐标为±R/√2
。由于我们知道OkDk的长度,我们也可以得到y坐标——它们是对角线长度(R∙√2
),前面加上负号。
类似地,在AkOkBkEk四边形中,我们发现所有角都是90°
(直角),其中三个角是根据构造得到的(∠EkAkOk和∠EkBkOk分别是Ak和Bk处半径线和切线之间的角,而∠AkOkBk是对应于四分之一圆弧AkBk的角),第四个角则是通过计算得到的(四边形的内角和为360°
,而其他三个角之和为270°
)。这使得AkOkBkEk成为矩形。由于它们有两条连续的边相等(OkAk和OkBk是长度为R
的半径线段),因此它们也是正方形。
由此,我们得到对角线AkBk和OkEk长度为R∙√2
。我们知道AkBk线段是垂直的,并且被水平轴分成两半,这意味着OkEk线段位于此轴上,并且Ek点的y坐标为0
。由于Ok点的x坐标为±R/√2
,并且OkEk线段长度为R∙√2
,我们也可以计算出Ek点的x坐标——它们为±3∙R/√2
。
好的,但是这些切线的交点并不是我们获得圆弧近似值所需的控制点。我们想要的控制点位于TDk、AkDk、AkEk和BkEk线段上,距离曲线端点(T、Ak、Bk)大约55%
(此值由前面提到的文章中计算出的常数C
给出)。这意味着从端点到控制点的线段长度为C∙R
。
在这种情况下,我们的控制点的坐标为端点(T、Ak和Bk)坐标的1 - C
倍,加上端点切线交点(Dk和Ek)坐标的C
倍。
所以让我们把这些都放到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) */;
}
下面的交互式演示显示了这些点的坐标(点击即可查看)。
查看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)。
确保形状对齐一致
但是,如果我们将这两个形状一个叠加在另一个上面,不使用fill
或transform
,只使用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
类型函数,以及用于 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)、当前属性的范围(s) (ini
和 rng
)、我们使用的时序函数 (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。
非常酷的动画。几乎值得学习几何并执行恶魔仪式。
动画很酷……但我对本文中的几何图印象最深刻。哇。做得好。
我被告知不会有数学…… :)
很棒的文章!终于有一篇不是 jQuery 或 Angular 教程的文章了。如今,似乎如此多的重点都放在抽象和工具上,以至于我们很少受到挑战。
绝对的!虽然 AngularJS 和 jQuery 在某些情况下很有用,但最好使用 VanillaJS 或根本不使用 JS 来创建此类内容。我知道如果这是我唯一需要脚本的东西,我将讨厌调用整个框架或库。
Ana Tudor,干得好!
这真的很酷。
或者只使用 Greensock 并将其压缩成两行……
演示链接,否则我就说胡说八道! :)
您有 Greensock 示例吗?我很感兴趣。
看一看
https://greensock.com/morphSVG
如果您更喜欢这样做,没问题,您可以选择,但我写了最近几篇文章(1),面向那些希望动脑并了解幕后发生的事情的人,而不是发出 5 个 HTTP 请求并包含超过 450KB 的 JS 库(因为上面链接中的第一个示例正是这样做的)来实现他们可以用不到 1% 的代码量实现的效果。
尤其是在当前的环境下,每个人都在抱怨页面膨胀。
(1) 因为归根结底,上一篇文章实际上是关于如何进行过渡部分的,而这篇特定文章主要是关于如何获取开始和结束形状的几何形状,这是变形插件无法帮助解决的问题。变形插件仍然需要传入开始和结束路径数据。
我只想说,我非常感谢 Ana 的简短版本,胜过 Greensock 示例。首先,我不必向任何人付费才能使用 Ana 使用的方法,其次,Ana 的版本确实更短。
虽然您可以在 Greensock 版本中在您这边只使用两行代码,但总的来说,由于包含了整个 Greensock 插件(通过 HTTP 请求获取大量额外库),Greensock 版本使用的代码行数远多于 VanillaJS 版本。
如果编写一个更长更慢的版本(在您这边)可以为查看该版本的每个人节省时间,那么更长更慢的 VanillaJS 版本绝对值得。
很棒的文章(我♥几何),但请不要在生产环境中使用它。除非您有很多时间浪费,否则使用 Greensock 之类的库是正确的解决方案。
如果您只是为了这个动画而包含 Greensock,那么您可能不需要这个动画。如果您的网站有很多动画,那么您已经在使用 Greensock(或其他一些库),因此使用它不会产生任何额外成本。
即使你很喜欢数学并且现在有时间浪费,你仍然不应该在生产环境中使用它,因为将来某个时候你(或者维护你代码的人(很有可能他们不像你那么喜欢数学))会有上百万的事情要做,而某个来自市场部的家伙会想出一个主意,他们应该对动画中形状的曲率进行 A/B 测试,看看是否能提高收入,而你将不得不多次计算,并在一个小时内完成,因为这“只是改动了一点点”。
同样,这是一篇很棒的文章,概念绝对值得学习,但除非你想在将来毁了某人的一天,否则在生产环境中使用库。
小心。
我真的很讨厌这样的评论,这里是一个学习的地方,我喜欢这篇文章,我不在乎你是否可以用 X 库用 3 个字符来实现它,这篇文章的巨大价值在于学习,以及投入到解释每个细节的努力。很棒的文章。
拥有大量的最炫酷、高端的插件动画可能会让网站看起来非常漂亮,但如果人们对网站加载时间过长感到恼火,他们很可能会关闭浏览器标签页,并在心里记下永远不要再访问该网站。
动画很棒,但就像任何东西一样,应该适度使用。如果你可以使用 HTML/CSS/原生 JavaScript 实现简单的动画,并且加载速度极快,并且它有其用途,那就制作这个动画。更好的是,只用 HTML/CSS 来实现它。使用的 JavaScript 越少,就越容易避免与冲突脚本相关的问题。
我并不是说永远不应该使用 Greensock,但我真的不明白仅仅为了避免编写长代码(可以添加注释)和数学计算(如果单独记录出来,就像 Ana 的文章一样,对任何人来说都应该很容易调整)而使用它的意义何在。
我认为 Greensock 应该用于纯粹为了动画作为内容而设置的应用程序或网站,或者可能用于像 CodePen 这样的网站,在这些网站上,大量用户可能需要 Greensock 功能来发布他们的代码片段。但还有很多东西不适合这两种情况。
就我个人而言,我为自己坚持使用最少的 HTTP 请求、流线型和压缩的代码以及尽可能少地使用语言来创建可在 10 毫秒或更短时间内加载的最小化设计而感到自豪。我的用户似乎很欣赏我为加快网站加载速度所做的额外工作,我绝对感谢像 Ana 这样的人,他们可以编写详细的教程,讲解如何在不依赖于笨重的库和框架来实现每个小功能的情况下完成操作,这反过来又使得快速加载时间成为可能。
很棒的文章。感谢您通过您精彩的绘画分享所有细节。
谢谢!
做得好,我同意,图中的几何图形很棒。我可能要重新开始学习数学了。
我不太明白你的意思,但将形状更改为不同的形状应该是具有艺术感的人的工作,而不是技术人员的工作。
稍微更改形状也不是 Greensock 所做的。在你看到的所有示例中,你向 Greensock 提供一个要变形为的形状和一个要变形到的形状。Greensock 不会生成这些形状。这些形状是由具有足够艺术感的人创建的。
我之前文章中描述的纯 JS 技术也是如此。在这篇文章中,我只是解释了一个我使用它的演示。我有一个关于如何几何生成星星和心形的思路,并且付诸实践。
但是,我解释的变形技术对任何其他形状(几何生成的或非几何生成的)都适用。例如,在这个演示中,我只是从维基百科获取了两个 SVG 形状,稍微调整了一下,并将它们用作初始形状和最终形状。不需要几何计算。我所做的只是引入了一些零长度线,以便在路径数据中具有相同的曲线到曲线、线到线的序列。如果有人想要其中一条曲线的不同曲率,没问题,给我带有新曲率的新路径数据,我只需将其粘贴到旧曲线的相应位置即可。它不会改变我的 JS 代码。
如果你有一个动画只获取两个简单的 SVG 并将其变形(很可能使用像 Greensock 这样的库),那么你是正确的。创建 SVG 可能应该是设计师的工作。
我不认识任何不懂技术的工程师,他们会知道如何使用本文中的代码更改星星的形状。
我实际上只是使用“更改形状”作为将来可能需要修改的内容。本文中的数学非常有趣,但不是大多数人每天都会使用的东西。这意味着将来某个时候(如果在生产环境中使用),这个动画将需要修改,而修改它的人(即使是创建它的人)可能不得不重新学习这些数学知识。
这个故事的寓意是:所有代码最终都会变成遗留代码,对于大多数软件项目来说,开发人员的时间是最昂贵的成本。
我不认识任何不懂技术的工程师,他们会知道如何根据本文中的代码更改星星的形状。
我的观点只是本文中的数学非常有趣,但不是大多数人每天都会使用的东西,并且由于所有代码最终都会变成遗留代码,因此有人将不得不重新学习这些数学知识才能对其进行修改。
不需要不懂技术的人来修改代码。他们只需要在图形编辑器中编辑形状/创建另一个形状,拖动控制点等等,然后传递新的 SVG 结果即可。
使用的任何 SVG 都不影响变形代码(这再次是上一篇文章的主题,而不是本文)。我在这里选择使用 JS 生成形状的事实与变形部分绝对无关(再次强调,变形是 Greensock 做的事情,而不是绘制形状)。
我可以完全删除所有生成代码,并将星星的路径数据放在
ini
属性中,将心形的路径数据放在路径元素的fin
属性中,调整 JS 使其从这些属性而不是生成函数中获取坐标,就是这样。这正是我在上面链接的演示中所做的。注释和数字 9 不是几何生成的,我只是从维基百科获取的。