SVG をモーフィングさせる処理を自作する

## この記事の概要

SVG をモーフィングさせたいなと思うことがあります。

有名なのは GSAP の [MorphSVG](https://gsap.com/docs/v3/Plugins/MorphSVGPlugin/) な気がするのですが、[Plemium プラン以上でしか使えず年額 $149](https://gsap.com/pricing/) です。


ちょっとしたアニメーションをつけるだけにしては高額なので、自作できないかなと思い立ちました。

非常に簡易なもの＆条件はありますが、とりあえず作れたので記事にしてみました。

## 方針

1. svg 要素のうち、変形前の`path`の`d`属性を取得する
1. 変形後の`d`の中身を用意しておく
1. `d`属性の中の`M`や`L`を除き、数値だけの配列に変換する
1. 線形補完の関数を用意し、2 つの配列を渡す
1. `requestAnimationFrame`を使い変形させる

## HTML

アニメーションをテストする目的なので、簡素なものを用意しただけです。

```html
<body>
  <h1>Self-made SVG Morphing</h1>
  <svg
    width="100"
    height="100"
    viewBox="0 0 100 100"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      id="morph-path"
      d="M49.6025 13L92.9038 88L49.6025 88L6.30127 88L49.6025 13Z"
      fill="#D9D9D9"
    />
  </svg>
  <button id="morph-button">Morph</button>
</body>
```

現時点の見た目はこのようになっています。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/cb8192b3-cecb-5466-be77-b197d58c780a.png)

## JavaScript

完成形を載せます。
テスト的に作っているので`path2`をハードコーディングしてしまっていますが、外から渡すようにすれば再利用可能になるはずです。

```javascript
document.addEventListener("DOMContentLoaded", function () {
  const morphButton = document.getElementById("morph-button");
  const pathElement = document.getElementById("morph-path");

  const path1 = pathElement.getAttribute('d');
  const path2 = "M88 12L88 88L12 88L12 12L88 12Z";

  function lerp(start, end, t) {
    return start + (end - start) * t;
  }

  function parsePath(path) {
    return path.match(/[-+]?[0-9]*\.?[0-9]+/g).map(Number);
  }

  function generatePathString(commands, values) {
    let valueIndex = 0;
    return commands.reduce((pathString, command) => {
      let commandValues = values.slice(valueIndex, valueIndex + command.length);
      valueIndex += command.length;
      return pathString + command.cmd + commandValues.join(" ");
    }, "");
  }

  function morphPaths(path1, path2, t) {
    const commands = path1.match(/[a-zA-Z][^a-zA-Z]*/g).map((command) => {
      const numbers = command.slice(1).match(/[-+]?[0-9]*\.?[0-9]+/g) || [];
      return { cmd: command[0], length: numbers.length };
    });

    const values1 = parsePath(path1);
    const values2 = parsePath(path2);

    const morphedValues = values1.map((val, i) => lerp(val, values2[i], t));

    return generatePathString(commands, morphedValues);
  }

  morphButton.addEventListener("click", () => {
    let t = 0;
    function animate() {
      t += 0.05;
      if (t > 1) t = 1;
      const morphedPath = morphPaths(path1, path2, t);
      pathElement.setAttribute("d", morphedPath);
      if (t < 1) requestAnimationFrame(animate);
    }
    animate();
  });
});
```

このように変化します。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/5c54c74d-a7a3-e8df-2da4-4356de6b567f.gif)

もう少し複雑な形状でも問題ありません。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/fb24eb1a-4482-9a6c-4ee8-fa8eeffeddbd.gif)

### lerp()

線形補完の関数です。

### parsePath()

`d`の文字列から数値だけを抽出し、配列に変換しています。

### generatePathString()

新しいパス文字列を生成しています。

### morphPaths()

`path1`と`path2`の間で補完されたパスを生成しています。

`M`, `L`などと数値を分離し、数値部分を線形補完しています。

### requestAnimationFrame()

今回はボタンのクリックイベントハンドラーの中で呼び出し、実行しています。

Intersection Observer API を使えば画面に入ってきたタイミングで変形させることもできます。

## 現状の制約

- 1 つの`path`にしか対応できていない
- 変形前後でポイントの数が同じでないといけない
    - 最初の例では三角形から四角形に変形しているものの、三角形の一辺にポイントを仕込んである
- 変形前後でコマンドが同じでないといけない
    - 例えば`V`はパラメーターを 1 つしかとらず、`L`は 2 つとる
    - このとき、配列の数があわなくなり、表示がおかしくなる

このように、複雑なイラストを動かしたりするには機能が不足しています。
ただ、幾何形態をちょろっと動かす程度なら問題なさそうです。

「スクロール量にあわせて背景に敷き詰めた画像が少し変形する」くらいであれば使えますし、装飾として効果的なときもありそうです。

状況を見て使ってみたいです。

## 追記

続編のような記事も投稿しました。

https://qiita.com/xrxoxcxox/items/70134a08ff7b34e1088f