本文我們主要和大家分享用SVG和Vanilla JS框架創建一個「星形變心形」的動畫效果代碼,希望能幫助大家。
它們都是由五個三次貝塞爾曲線構成。下邊的互動演示展示了每條曲線以及這些曲線相連接的點。點擊任意曲線或連接點可以看到兩個圖形的曲線是如何相對應的。
可以看出所有曲線都是由三次貝塞爾曲線創建的。即使其中一些曲線的兩個控制點重疊了。
構成星形和心形的形狀都是極簡且不符合實際的。但它們可以做到。
從表情動畫的例子中可以看出, 我通常選擇用 Pug(譯:即Jade,一種模版引擎) 生成這類形狀。但在這裡,由於產生的路徑資料也將由JavaScript處理過渡效果。包括計算座標以及將這些座標放入屬性d
。所以使用JavaScript來做所有的這些是最好的選擇。
這意味著我們不必寫很多標籤:
<svg> <path id='shape'/></svg>
JavaScript中,我們首先獲得元素 svg
和元素 path
# 。 path
是那個星形變心形再變回星形的形狀。然後,我們給元素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
),從金色的星形變成深紅色的心形。
那麼,我們怎麼能得到這兩個圖形的終點和控制點的座標呢?
在星形的例子中,我們先從一個正五角星形開始。我們的曲線(譯:構成星形每個角的曲線)終點落在正五角星形邊的交叉點上,我們把正五角星形的頂點當作控制點。
五個三次貝塞爾曲線的終點和控制點用黃點標識在了正五角星形的頂點和邊的交叉點上(Live)。
直接給定正五角星形外接圓的半徑(或直徑)就可以得到五角星形的頂點。也就是我們給 SVG 的viewBox
設定的尺寸(簡單起見,在這種情況下我們不考慮高填密)。但是如何獲得他們的交叉點呢?
首先,我們先看下邊的說明圖。注意圖中正五角星形中間高亮標註的小五邊形。小五邊形的頂點與正五角星形邊的交叉點是重疊的。這個小五邊形顯然是個正五邊形(譯:五個邊的長度相等)。這個小正五邊形的內切圓和內徑跟正五角星形的是同一個。
正五角星形和內部的正五邊形的內切圓是同一個 (Live)。
因此,如果我們計算出正五角星形的內徑,那麼也就獲得了正五邊形的內徑。這個內徑和圓心角 一起對應正五邊形的邊。根據這個我們就可以得到正五邊形的外接圓半徑 。這樣就可以倒推出正五邊形頂點的座標。這些點正是正五角星形邊的交叉點,也就是星形五個三次貝塞爾曲線的終點。
我們的正五角星形可以用拓樸符號 {5/2}
來表示。也就是說,正五角星形有5
個頂點。這5個頂點均勻分佈在它的外接圓上,間隔是 360°/5 = 72°
。我們從第一個點開始,跳過緊鄰的下一個點,連結到緊鄰的第二個點(這就是符號{5/2}
中2
的意思;1
代表的意思是連接到第一個點,不跳過任何點,構成一個五邊形)。照這樣一直連接,就可以畫出正五角星形了。
在下邊的示範中,點擊五邊形或五角星形按鈕,看看它們是如何被繪製的。
這樣,我們得到正五角星形的邊所對應的的圓心角是正五邊形的邊所對應的圓心角的二倍。則正五邊形是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°
)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; // 三次曲线、多边形顶点数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; }
计算坐标需要的条件:用点所在圆的半径,以及一条半径与水平轴线构成的夹角。如下面的交互式演示所示(拖动点来查看它的笛卡尔坐标如何变化):
在我们的例子里,当前的半径有两个。一个是外圆的半径(正五角星形的外接圆半径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)) })();
绘制的结果可以在下边的演示中看到:
这是一个很有前途的星形。但我们想让生成的五角星形第一个尖朝下并且由它生成的星形的第一个尖朝上。目前,他们的指向都偏右了。这是因为我们是从 0°
开始的(对应时钟的三点位置)。所以为了能从时钟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 绘制好了一个漂亮的金色星形,它是由五个三次贝塞尔曲线构成的:
我们已经绘制好星形了,现在来看下如何绘制心形吧!
我们先从两个半径相等并横向相交的圆开始,这两个圆都是 viewBox
尺寸的一部分(暂时定位.25
)。这两个圆相交的方式为:它们中心点相连的线落在 x
轴上,它们相交点相连的线落在 y
轴上。这两条线要相等。
我们先从两个半径相等的相交的圆开始。这两个圆的圆心落在水平轴上,他们相交的点落在垂直轴上 (Live)。
接着,我们画两条直径,这两条直径穿过靠上的那个交点。在直径与圆的另一个交点处画一条正切线。这两条正切线在 y
轴相交。
画两条直径,穿过两个圆相交的点中靠上的那个,并在直径与圆的另一个交点处画正切线,两条正切线在垂直轴相交 (Live)。
两个圆上边的交点和两个直径与圆的另两个交点构成了我们需要的5
个点中的3
个。另外两个终点则是把外侧的半圆切割成两个相等弧线的中点,这使我们得到4
个四分之一圆弧。
高亮显示了构成心形的三次贝塞尔曲线的终点以及靠下的那条曲线的控制点(Live)。
靠下的曲线控制点很明显已经得到了,就是两条切线的交点。但是另外四条曲线的控制点呢?我们怎么能把圆弧变成三次贝塞尔曲线呢?
我們無法得到四分之一圓弧的三次貝塞爾曲線,但我們可以得到一個近似的,在這篇文章中有闡述。
這篇文章告訴我們,可以用一個值為R
的半徑,和半徑的切線(N
和Q
)來繪製四分之一圓弧。兩條半徑的切線相交於點 P
。四邊形ONPQ
的四個角都等於90°
(或π / 2
,其中三個是公理得出的(O
是90°
,兩條切線與半徑的夾角也是90°
),最後一個是計算出來的(內角的合是360°
,其它三個角都是90°
, 最後一個角也就是90°
了)。 # 有兩個相鄰的邊是相等的(OQ
和ON
的長度都等於半徑R
),這樣它就是一個邊長為R
的正方形。畫出近似四分之一圓弧的弧線(Live)。 ##NP 和
QP 上,也就是從終點算起
C * R的長度,
C在先前提到文章中算出的值是
.551915。 。線也是相等的(這點前文有說過,兩個圓心的連線與兩個交點的連線相等)。 OT
等於對角線
的一半。 x 座標為0
。同理)。切割成兩個等腰三角形。 (Live)。這樣根據邊長就可以得到正方形對角線的長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)。
類似的,
O1 點在
x 軸上,所以他們的
y 軸座標為
0
x 軸座標是對角線OO1
的一半:±R/√2
TO0SO1
90°(π / 2
圓弧)。四邊形
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)
點。
方塊 TA1B1S
四個頂點座標(Live)。
我們從這裡得到的另一個結論是,因為TA1B1S
是正方形,所以A1B1
平行於TS
,因為TS
在y
(垂直)軸上,所以A1B1
也是垂直的。此外,因為x
軸平行於TA1
和SB1
,並且將TS
平分切為兩斷,所以x
#軸也將A1B1
平分切為了兩斷。
現在讓我來看看控制點。
我們先從最下邊弧線的重疊的控制點開始。
四邊形 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
。
方塊 TB0CB1
四個頂點的座標 (Live)。
現在我們得到了最下邊弧線兩個重疊的控制點的座標為(0,3 * R / √2)
。
為了得到其他曲線控制點的座標,我們在他們的終點畫切線,並且得到這些切線的交叉點 D1
和 E1
。
四邊形 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
。
四边形 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
的尺寸中获得辅助圆的半径。这个函数同样会返回一个坐标构成的数组,以便我们后边插入数组项。
在函数中,我们先声明常量。
辅助圆的半径。
边与这个辅助圆半径相等的小正方形对角线的一半。对角线的一半也是这些正方形外接圆半径。
三次贝塞尔曲线终点的坐标值(点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)}
点击下边交互演示上的点,可以展示这些点的坐标:
现在我们可以通过终点和切线交点来获得控制点:
function getHeartPoints(f = .25) { // same as before // const for cubic curve approx of quarter circle const 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}
下一步,我们要把相关的坐标合成一个数组,并将这个数组返回。在星形的例子中,我们是从最下边的弧形开始的,然后按照顺时针方向绘制,所以在这里我们用同样的方法。每个曲线,我们为控制点放入两组坐标,为终点放入一组坐标。
请注意,第一个曲线(最下边的那个),他的两个控制点重叠了,所以我们把相同的坐标组合推入两次。代码看起来也许并不像绘制星形时那样整洁好看,但可以满足我们的需求:
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)) })();
这个心形看上去很不错:
如果我们不给图形填充( fill
)颜色、不旋转(transform
)图形,只是看他们的骨架(stroke
)叠在一起。就会发现它们并没有对齐:
解决这个问题最简单的方法就是利用辅助圆的半径把心形向上移动一些:
return [ /* same coords */ ].map(([x, y]) => [x, y - .09*R])
现在我们已经对齐了,忽略我们是如何调整这两个例子的f参数的。这个参数在星形中决定了五角星形外接圆半径与viewBox
尺寸的对应关系(默认值是 .5
),在心形中决定了辅助圆的半径与viewBox
尺寸的对应关系(默认值是 .25
)。
当点击的时候,我们希望能从一种图形转换成另一种。为了做到这个,我们设置一个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); })();
现在我们可以通过点击图形在两种图形中转换了:
我们最终想要的并不是两个图形间唐突的切换,而是柔和的渐变效果。所以我们用以前的文章说明的插值技术来实现。
首先我们要决定转变动画的总帧数(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
)、当前属性(ini
和 rng
)的范围(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)) };
最后获得了一个形状,当点击它时可以从星形过渡转换成心形,第二次点击的时候会变回星形!
这几乎就是我们想要的了:但还有一个小问题。对于像角度值这样的循环值,我们并不想在第二次点击的时候将他调转。相反,我们希望他继续顺着同一个方向旋转。通过两次点击后,正好能旋转一周,回到起点。
我们通过给代码添加一个可选的属性,稍稍调整更新函数和插值函数:
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})();
现在我们得到了我们想要的最终结果:一个从金色星形变成深红色心形的形状,每次从一个状态到另一个状态顺时针旋转半圈。
相关推荐:
以上是SVG和Vanilla JS框架創建一個「星形變心形」代碼分享的詳細內容。更多資訊請關注PHP中文網其他相關文章!