React + TypeScript でモーフィングする SVG コンポーネントを作る

## この記事の概要

以下の記事を書きました。

https://qiita.com/xrxoxcxox/items/527784edb3cf87fb9274

素朴な HTML と JavaScript だけで実現したのですが、実際に使うことを考えると、もう少し再利用性を高めたいです。

というわけで、React コンポーネントとして実装し直し、それも記事にしました。

:::note
この記事は、上記の記事を読んでいるという前提で進めます。
:::

## 完成形

最初に、完成形を載せておきます。

```tsx
import { useCallback, useEffect, useRef } from "react";

type PathPair = {
  beforePath: string;
  afterPath: string;
  fill?: string;
}

type MorphingSVGProps = {
  paths: PathPair[];
  trigger: boolean;
  width?: number;
  height?: number;
}

export function MorphingSVG({
  paths,
  trigger,
  width,
  height,
}: MorphingSVGProps) {
  const pathRefs = useRef<(SVGPathElement | null)[]>([]);

  const lerp = useCallback((start: number, end: number, t: number): number => {
    return start + (end - start) * t;
  }, []);

  const parsePath = useCallback((path: string): number[] => {
    return path.match(/[-+]?[0-9]*\.?[0-9]+/g)?.map(Number) || [];
  }, []);

  const generatePathString = useCallback(
    (commands: { cmd: string; length: number }[], values: number[]): string => {
      let valueIndex = 0;
      return commands.reduce((pathString, command) => {
        const commandValues = values.slice(
          valueIndex,
          valueIndex + command.length
        );
        valueIndex += command.length;
        return pathString + command.cmd + commandValues.join(" ");
      }, "");
    },
    []
  );

  const morphPaths = useCallback(
    (path1: string, path2: string, t: number): string => {
      const commands =
        path1.match(/[a-zA-Z][^a-zA-Z]*/g)?.map((cmd) => {
          const numbers = cmd.slice(1).match(/[-+]?[0-9]*\.?[0-9]+/g) || [];
          return { cmd: cmd[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);
    },
    [parsePath, generatePathString, lerp]
  );

  useEffect(() => {
    if (trigger) {
      let t = 0;

      const animate = () => {
        t += 0.02;
        if (t > 1) t = 1;
        paths.forEach((pathPair, index) => {
          const morphedPath = morphPaths(
            pathPair.beforePath,
            pathPair.afterPath,
            t
          );
          if (pathRefs.current[index]) {
            pathRefs.current[index]!.setAttribute("d", morphedPath);
          }
        });
        if (t < 1) {
          requestAnimationFrame(animate);
        }
      };

      requestAnimationFrame(animate);
    }
  }, [trigger, paths, morphPaths]);

  return (
    <svg
      width={width ? width : 100}
      height={height ? height : 100}
      viewBox={`0 0 ${width ? width : 100} ${height ? height : 100}`}
      xmlns="http://www.w3.org/2000/svg"
    >
      {paths.map((pathPair, index) => (
        <path
          key={index}
          ref={(el) => (pathRefs.current[index] = el)}
          d={pathPair.beforePath}
          fill={pathPair.fill ? pathPair.fill : "#000"}
        />
      ))}
    </svg>
  );
}
```

次のように利用します。

```tsx
import { useState } from "react";
import { MorphingSVG } from "./components/MorphingSVG";

function App() {
  const [trigger, setTrigger] = useState(false);

  const paths = [
    {
      beforePath: "M50 50L150 50L150 150L50 150Z",
      afterPath: "M115 50L180 115L115 180L50 115Z",
      fill: "#f00",
    },
    {
      beforePath: "M49.6025 13L92.9038 88L49.6025 88L6.30127 88L49.6025 13Z",
      afterPath: "M88 12L88 88L12 88L12 12L88 12Z",
      fill: "#00f",
    },
  ];

  return (
    <div style={{ display: "grid", justifyItems: "center" }}>
      <h1>Self-made SVG Morphing</h1>
      <MorphingSVG
        paths={paths}
        trigger={trigger}
        width={200}
        height={200}
      />
      <button onClick={() => setTrigger(true)}>Morph</button>
    </div>
  );
}

export default App;
```

## 前の記事の内容から変わったこと

- 複数の`path`を指定できるようになった
    - それぞれのパスの色も変えられる
- 変形タイミングが操作しやすくなった
    - `trigger`に`true`が渡ったタイミングで変形する
    - 上に書いたように`onClick`を起点にするも良し、他のフックと同じタイミングで変形するも良し
- svg 要素の大きさを変えられるようになった

## 説明

`PathPair`の形式で`props`を受け取れるようにすることで、複数のパスが一度に変形できています。
また、個別に`fill`も設定できるようになっています。

また、`props`として`width`や`height`も用意したので、svg 要素そのものの大きさも変えることができます。

これにより、画面内に複数の svg を配置し、それぞれを動かしたいときでも楽に扱えるようになりました