SVG上に要素を走らせる

# 始めに
SVG上にDOM要素を走らせるコードを書いたのでやり方を記事にまとめました。
サンプルは以下になります。

<p class="codepen" data-height="473" data-theme-id="0" data-default-tab="result" data-user="wintyo" data-slug-hash="aXQrgj" style="height: 473px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid black; margin: 1em 0; padding: 1em;" data-pen-title="SVG上に要素を走らせる">
  <span>See the Pen <a href="https://codepen.io/wintyo/pen/aXQrgj/">
  SVG上に要素を走らせる</a> by wintyo (<a href="https://codepen.io/wintyo">@wintyo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

これの実装は簡単に言えばSVGのpathから`getPointAtLength`でSVG上の座標を取得できるので、その位置情報を元にDOMを移動しています。
詳細は次から説明していきます。

# HTMLの設置とCSSの設定
まずHTMLの構成は以下のようになっています。`.map-inner`の中にsvgとdiv要素を配置し、親である`.map-inner`をrelativeにすることでdiv要素の位置を調整します。absoluteを使ってdiv要素の開始位置を(0, 0)にしてSVGの位置と合わせます。
ちなみに動かす要素はクルマを想定したので`car`というクラスにしています。

```html:html
<div class="map">
  <div class="map-inner">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 777.18 582.94"><defs><style>.cls-1{fill:none;stroke:#000;stroke-miterlimit:10;}</style></defs><title>path</title><g id="レイヤー_2" data-name="レイヤー 2"><g id="レイヤー_1-2" data-name="レイヤー 1"><path id="path" class="cls-1" d="M213.87,535.64C150.17,500,96.78,445.41,55.8,382.57c-19.86-30.47-37.11-63.3-46.68-98.93A255.22,255.22,0,0,1,5.59,167.17C16.5,113.7,45.77,64,88.37,33.17S186.86-8.32,236,8.33c46.83,15.87,83.59,53.56,122.29,85.72s85.59,60.84,134.25,53.43C534.94,141,569,108.75,604.33,83s81.3-46.17,120-26.65c22.86,11.53,38.58,35.52,45.93,61.21s7.24,53.12,5.22,79.89a583.42,583.42,0,0,1-11.43,79c-2.81,13.05-6.2,26.3-13.45,37.25-9.93,15-25.87,24-41.2,32.38l-61.87,34c-22.87,12.56-46.68,26-61.27,48.49-14.82,22.87-18.09,52.09-30.92,76.27C493.65,621.14,302.26,585.15,213.87,535.64Z"/></g></g></svg>
    <div class="car" id="car"></div>    
  </div>
</div>
```

```css:css
.map {
  margin: 0 auto;
  padding: 10px;
  max-width: 400px;
}

.map-inner {
  position: relative;
}

.car {
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 20px;
  background-color: blue;
}
```

# JSの設定
## 1. SVGの読み込み
まず以下のSVG情報を読み込みます。

* svg要素の取得
* SVGのviewBoxの取得
* path要素の取得
* pathの最大の長さを取得

svg要素は毎フレームのサイズを取得するために使います。
viewBoxの取得は、実際に表示する位置を計算するために使います。path要素から座標を取得しますが、それはDOMの座標ではなくSVGの表示領域上の位置を示しているので、計算してDOMの座標系に変換する必要があります。
SVG要素の取得はコードが少し長くなるので関数化して余計な変数は見えないようにまとめました。

path要素は座標の取得のために変数に保存しておきます。`.getPointAtLength(0)`とかで、その位置の座標が取得されます。この長さの最大値は`.getTotalLength()`でわかるので、これを変数に保存しておきます。

```javascript:SVG情報の取得
/**
 * SVG情報を取得する
 * @returns {{ element: HTMLElement, viewBox: { x: number, y: number, width: number, height: number }}}
 */
function getSvgInfo() {
  const svgElement = document.querySelector('svg');
  const viewBoxParams = svgElement.getAttribute('viewBox').split(' ').map((param) => +param);
  return {
    element: svgElement,
    viewBox: {
      x: viewBoxParams[0],
      y: viewBoxParams[1],
      width: viewBoxParams[2],
      height: viewBoxParams[3]
    }
  };
}

// SVG情報を取得する
const svgInfo = getSvgInfo();
const pathElement = svgInfo.element.querySelector('#path');
const maxPathLength = pathElement.getTotalLength();
```

## 2. 座標の取得とDOMの設定
`.getPointAtLength`で位置を取得します。ただし前述した通りSVG内の座標なので、DOM上の座標に変換します。`[SVGの位置] / [SVG内のサイズ]`で割合が出るので、それに`[DOMのサイズ]`で掛けて求めています。ただしコードの見た目から`[SVGの位置] * [DOMのサイズ] / [SVG内のサイズ]`にしています。
変換したらあとはそのまま`transform`に入れるだけです。

```javascript:座標の取得とDOMの設定
// 今の位置のSVG座標を求める
const pt = pathElement.getPointAtLength(0);
  
// DOM上の座標を求める
const svgClientRect = svgInfo.element.getBoundingClientRect();
const x = pt.x * svgClientRect.width / svgInfo.viewBox.width;
const y = pt.y * svgClientRect.height / svgInfo.viewBox.height;  

// スタイルを更新する
carElement.style.transform = `translate3d(${x}px, ${y}px, 0) translate3d(-50%, -50%, 0)`;
```

## 3. アニメーションループする
これで最初の時の位置は設定できたので、後はループして進むようにします。
ループには`requestAnimationFrame`を使いますが、一定の速度を保つために前のフレームとの経過時間を比較して移動するようにします。差分の計算もグローバル変数を使って実装してもいいですが、変数の数が増えるのが嫌だったのでクラスでラップしました。詳細の実装の方はCodePenの方を参照してください。

```javascript:アニメーションループする
const SPEED = 300;  // 1秒あたりに進む量
let pathLength = 0; // 現在のSVG pathの位置
AnimationFrame.on((elapsed) => {
  // 次の位置に進める
  pathLength = (pathLength + SPEED * elapsed / 1000) % maxPathLength;

  // 今の位置のSVG座標を求める
  const pt = pathElement.getPointAtLength(pathLength);

  // 以降は2.と同じ
});
```

## 4. 角度も設定する
最後に一つ前の位置を使って角度も調整します。

```javascript:角度調整
let prevPt = pathElement.getPointAtLength(pathLength);  // 一つ前の座標を先に計算する
AnimationFrame.on((elapsed) => {
  // 省略

  // 角度の計算
  const rotation = (Math.atan2(pt.y - prevPt.y, pt.x - prevPt.x)) * 180 / Math.PI;

  // スタイルを更新する
  carElement.style.transform = `translate3d(${x}px, ${y}px, 0) translate3d(-50%, -50%, 0) rotate(${rotation}deg)`;

  // 一つ前の座標を持っておく
  prevPt = pt;
});
```

# 補足
## objectタグで読み込む場合
SVGを直接埋め込まず、objectタグでやる場合は以下のような感じになります。

```html:objectでSVG画像を読み込む場合
<object
  id="map"
  data="map.svg"
  type="image/svg+xml"
></object>
```

objectタグを使うと中身の要素を取得するには以下のように`.contentDocument`でobject内のdocumentを取得する必要があります（CodePenではこれが上手くいかなかったので直接埋め込んでいます）

```js:objectタグからsvgタグを取得する
/**
 * object要素からsvg情報を取得する
 * @param {element} objectElement - objectタグ要素
 * @return {Promise} - resolveでsvg情報を返す
 */
function getSvgInfoFromObjectElement(objectElement) {
  return new Promise((resolve) => {
    const loadSvgElement = () => {
      const svgElement = objectElement.contentDocument ? objectElement.contentDocument.querySelector('svg') : null;
      if (svgElement) {
        const viewBoxParams = svgElement.getAttribute('viewBox').split(' ').map((param) => +param);
        resolve({
          element: svgElement,
          viewBox: {
            x: viewBoxParams[0],
            y: viewBoxParams[1],
            width: viewBoxParams[2],
            height: viewBoxParams[3]
          }
        });
      } else {
        // まだSVGを読み込めていない場合はonloadしてから再度取得しにいく
        objectElement.onload = loadSvgElement;
      }
    };
    loadSvgElement();
  });
}

getSvgInfoFromObjectElement(document.getElementById('map'))
  .then((svgInfo) => {
    console.log(svgInfo);
  });
```

## 線上を走らせたい場合
サンプルでは線の中心を走らせていますが、これを線の上にしたい場合は以下のように最後にtranslateYを追加したらいいです。（なんかもうどういう動きしているのかわからなくなりそうですが・・・）

```javascript
carElement.style.transform = `... translateY(-40%)`;
```

transformの設定は後ろから適応されるので、図にかくとこんな感じですね。

![transform.png](https://qiita-image-store.s3.amazonaws.com/0/187768/99812712-7efe-66c6-72b0-4d4a8905e4a6.png)