使用 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 軸。而且這兩部分是相等的。

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).

上方的交點和切點正好是我們需要的五個端點中的三個。另外兩個端點將半圓弧分成了兩個相等的部分,從而可以得到四個四分之一圓弧。

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.高亮顯示的三次 Bézier 曲線構成了心形, 下方曲線的控制點重合 (live).

下方的曲線的控制點正好和之前兩切線的交點重合。但是其他四條曲線呢?如何用三次 Bézier 曲線得到圓弧?

我們無法直接通過三次 Bézier 曲線畫出四分之一圓弧,但我們可以找到近似的方法,詳見 這篇文章

我們從一個半徑為 R 的四分之一圓弧開始,畫出圓弧端點 ( N and Q ) 的切線。切線相交於 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.三次 Bézier 曲線畫出的近似四分之一圓弧 (live).

與圓弧近似的三次曲線的控制點在切線 NPQP 上,與端點的距離為 C·R ,其中 C 是之前介紹的文章中所計算出的常量 .551915

知道這些條件之后,現在開始計算創建出星形的端點和控制點坐標。

基於我們選擇的創建心形的方式,TO0SO1 (如以下圖形所示) 是 一個正方形 ,因為它的所有邊都相等(都等於兩個相等圓的半徑)並且對角線也相等(我們說過中心點之間的距離等於交點之間的距離)。其中, O 是對角線的交點,OT 是對角線 ST 的一半。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 (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 上,可以得到 Ty 坐標是 -R/√2 (絕對值等於正方形對角線的一半),Sy 坐標是 R/√2

Illustration showing the coordinates of the vertices of the TO₀SO₁ square.正方形 TO0SO1 的所有點坐標(live).

同樣的,Ok 點位於 x 軸,所以它們的 y 坐標是 0 ,它們的 x 坐標是對角線 OOk 長度的一半: ±R/√2

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

Illustration showing TAₖBₖS quadrilaterals.四邊形 TAkBkS  (live).

上圖中, TBk 線段是直徑,所以 TBk 所對的弧是半圓弧,也就是 180° 弧,並且 Ak 將它分成了相等的兩部分 TAkAkBk,每一部分是 90° 弧,它所對的是 90° 角, ∠TOkAk∠AkOkBk

因為 ∠TOkS90° 角而且 ∠TOkAk 也是 90° 角,所以 SAk 線段也是直徑。因此在四邊形 TAkBkS 中,對角線 TBkSAk 是垂直且相等,並且相交於中點 (TOk, OkBk, SOkOkAk 相等,都是初始圓的半徑 R)。這說明四邊形 TAkBkS 是正方形並且對角線長為 2∙R

現在我們可以獲得四邊形 TAkBkS 的邊長為 2∙R/√2 = R∙√2 。因為所有角都是 90° 並且 TS 與豎軸重合,所以 TAkSBk 邊是水平的,平行於 x 軸並且它們的長度是 AkBk 點的 x 坐標: ±R∙√2.

因為 TAkSBk 是水平線,所以 AkBk 點的 y 坐標是相等的,分別等於 T (-R/√2) 和 S (R/√2) 點坐標。

Illustration showing the coordinates of the vertices of the TAₖBₖS squares.正方形 TAkBkS 的所有點坐標(live).

我們還可以知道的一點是,因為 TAkBkS 是正方形, AkBk 平行於 TS,TS 位於 y (垂直) 軸,因此線段 AkBk 是垂直的。另外, 因為 x 軸平行於線段 TAkSBk ,並且平分 TS,所以它也平分線段 AkBk

現在讓我們轉到控制點。

我們從底部曲線的重疊控制點開始。

Illustration showing the TB₀CB₁ quadrilateral.四邊形 TB0CB1 (live).

四邊形 TB0CB1 所有角度都是 90° (因為 TO0SO1 是正方形,所以 ∠T 是直角;因為線段 BkCBk 點與圓相切,因此與半徑 OkBk 垂直,所以 ∠Bk 是直角;最后,因為四邊形內角和是 360° 而其它三個角是270° ,所以 ∠C 也是 90°  ), 所以它是矩形。又因為 TB0TB1 相等,都是初始圓的直徑,因此都等於 2∙R 。所以它是邊長為 2∙R 的正方形。

現在,我們可以得出對角線 TC 等於 2∙R∙√2 。因為 C 位於 y 軸,它的 x 坐標是 0 。它的 y 坐標等於線段 OC 的長度。線段 OC 等於線段 TC 減去線段 OT2∙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).

為了獲得其它曲線控制點的坐標,我們需要畫出經過端點的切線,它們的交點是 DkEk

Illustration showing the TOₖAₖDₖ and AₖOₖBₖEₖ quadrilaterals.四邊形 TOkAkDkAkOkBkEk  (live).

在四邊形 TOkAkDk 中,所有角都是 90° (直角),其中三個是已知的(∠DkTOk∠DkAkOk 是半徑分別在 TAk 點與切線的夾角,而 ∠TOkAk 是四分之一圓弧 TAk 所對的角),第四個角是計算出來的(所有角的和是 360° 而另外三個的和是  270°)。所以 TOkAkDk 是矩形。又因為兩個相鄰邊相等(線段OkTOkAk 都是半徑的長 R), 因此它們都是正方形。

所以對角線 TAkOkDk 等於 R∙√2 。已知 TAk 是水平的,又因為正方形對角線垂直,所以線段 OkDk 是垂直的。所以 OkDk 點的 x 坐標相等,我們已經計算過 Ok 點坐標是 ±R/√2 。因為已知 OkDk 的長度,所以也可以求出 y 坐標,等於對角線長度 (R∙√2) ,前面有負號。

同樣的,在四邊形 AkOkBkEk 中,所有角也都是 90° (直角), 其中三個是已知的(∠EkAkOk∠EkBkOk 是半徑分別在 AkBk 點與切線的夾角,而 ∠AkOkBk 是四分之一圓弧 AkBk 所對的角),第四個角是計算出來的(所有角的和是 360° 而另外三個的和是  270°), 所以 AkOkBkEk 是矩形。又因為兩個相鄰邊相等(線段OkTOkAk 都是半徑的長 R), 因此它們都是正方形。

現在,我們知道了對角線 AkBkOkEk 的長度是 R∙√2 。已知線段 AkBk 是垂直的,而且被水平軸平分,所以線段 OkEk 位於 x 軸,因此 Ek 點的 y 坐標是 0 。又因為Ok 點的 x 坐標是 ±R/√2 而且線段 OkEk 等於 R∙√2, 所以可以計算出 Ek 點坐標等於 ±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ₖ 上新計算的點的坐標 (live).

但是,這些切線交點並不是我們想要獲得的近似圓弧的控制點。我們需要的控制點位於線段 TDk, AkDk, AkEkBkEk 上,與(T, Ak, Bk)相聚大約 55% 的位置(這個數值是通過之前文章中的 C 計算出來的) 。所以端點到控制點的線段長為 C∙R

在這種情況下,控制點坐標為 1 - C 乘以 (T, Ak and Bk) 點坐標,再加上 C 乘以這些點的切線交點坐標 (DkEk)。

趕快編寫 JavaScript 代碼吧!

和編寫星形代碼一樣,先寫一個 getStarPoints(f) 函數,需要傳一個任意因子參數 (f) ,用於從 viewBox 的尺寸中獲取輔助圓的半徑。這個方法也會返回之后用到的插入點坐標數組。

在函數內部,我們計算那些在整個函數中不會改變的常量。首先是輔助圓的半徑。其次是小正方形的對角線,它的長度等於輔助圓半徑,對角線一半也是它的外接圓半徑。然后是三次曲線的端點坐標 ( T, Ak, Bk 點),沿水平方軸方向的絕對值。最后計算通過端點的切線交點坐標 ( C, Dk, Ek 點)。這些點要么是與控制點一致 (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) */;
}

在下面的交互式演示中,可以點擊查看這些點的坐標:

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.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM