DOM 要素のまわりを文字列がグリグリ動く Train Animation を offset-path で 実装する

DOM 要素の外周に沿って文字列が列車のように動くアニメーションを実装してみました。
なお「トレインアニメーション」というのは私が勝手に命名した造語で、ひとつの DOM 要素を列車の車両に見立てています。該当する名称が既に存在する場合はお教えください…。

## デモ
<p class="codepen" data-height="480" data-default-tab="result" data-slug-hash="LYBPvWy" data-user="mimonelu" style="height: 480px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mimonelu/pen/LYBPvWy">
  Train Animation</a> by mimonelu (<a href="https://codepen.io/mimonelu">@mimonelu</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>
割とモダンな機能を使っています。環境によっては動作しないかもしれません。

## コード
なるべく汎用的に利用できるように実装しているので、コピペでもそこそこ動作すると思います。たぶん。

```html
<style>
.train-container {
  --train-size: 2em;
  position: relative;

  /* NOT REQUIRED */
  background: url("https://baconmockup.com/640/480/") center center no-repeat;
  background-size: cover;
  border-radius: 1em;
  box-shadow: 0 0 0 var(--train-size) rgba(255, 0, 0, 0.75) inset;
  width: 480px;
  height: 360px;
}
.train {
  pointer-events: none;
  position: absolute;
  top: calc(var(--train-size) / 2);
  left: calc(var(--train-size) / 2);
  width: calc(100% - var(--train-size));
  height: calc(100% - var(--train-size));
}
.train > div {
  animation: train-animation 8000ms linear infinite;
  animation-delay: calc(var(--i) * 100ms);
  position: absolute;
  user-select: none;
  visibility: hidden;

  /* NOT REQUIRED */
  color: rgb(255, 255, 255);
  font-weight: bold;
}
@keyframes train-animation {
  0% {
    offset-distance: 0%;
    visibility: visible;
  }
  100% {
    offset-distance: 100%;
  }
}
</style>

<div class="train-container">
  <div class="train" data-train-text="🍖 お肉食べたい 🍖 ONIKU TABETAI 🍖 I want to eat meat 🍖 Quiero comer carne 🍖 我想吃肉 🍖">
  </div>
</div>

<script>
document.querySelectorAll('.train').forEach(train => {
  const r = 16
  const w = train.clientWidth
  const h = train.clientHeight
  const offsetPath = `offset-path: path("M${r},0 H${w - r} Q${w},0 ${w},${r} V${h - r} Q${w},${h} ${w - r},${h} H${r} Q0,${h} 0,${h - r} V${r} Q0,0 ${r},0 Z")`
  train.innerHTML = ''
  Array.from(train.getAttribute('data-train-text') || '').reverse().forEach((character, i) => {
    const trainCharacter = document.createElement('div')
    trainCharacter.innerText = character
    trainCharacter.style.cssText = `--i: ${i}; ${offsetPath};`
    train.appendChild(trainCharacter)
  })
})
</script>
```

## ミソ
### JavaScript の流れ
1. `.train` 要素の外周に沿った SVG パスを作成し、 `offset-path: path(...);` として保存する。 `H` `V` で四辺の直線パスを作成し、 `Q` で角丸を作成している
2. `.train[data-train-text="..."]` で指定された文字列を分解し、1文字ずつ DOM 要素を作成する
3. 2 の DOM 要素のインライン CSS に 1 で作成した `offset-path` と、通し番号 `--i: N;` を CSS 変数として付与する（後述）
4. `.train` 要素に 2 の DOM 要素を追加する
5. 後は CSS にお任せ

実際のところ、 JavaScript なしでも実装は可能です。が、果てしなくダルい作業にはなりますね。

### HTML/CSS のミソ
* `1文字 == 1要素` である
* `offset-path` で指定した SVG パス上の暫時的な位置を `offset-distance` によって割合指定している
* 要素を「ずらす」ために `animation-delay` で `前述の通し番号 * 100ms` 分、アニメーションの開始時間をずらしている
* `offset-path` によるアニメーションでは要素の「原点」に注意。デフォルトでは要素の左上ではなく中心になる。原点の位置は `offset-anchor` で変更できるようだが、反映されなかったため未使用。この仕様による位置ズレは `.train` の各プロパティで調整している

Sass やフレームワークのテンプレート構文を使えばもっとスマートに記述できるはず。

## 注意点
### 細かい調整が必要
`animation-duration` と `animation-delay` 、コンテナ要素のサイズ、文字列の長さ（＝ DOM 要素の数）、これら4つの要素が絶妙なバランスで噛み合うように調整する必要がある、と思います（場合によりけり）。がんばってください。

### コンテナ要素がリサイズされると「脱線」する
デモではコンテナ要素のサイズを絶対指定しているため「脱線」する恐れは少ないと思いますが、仮に `width: 64vmin;` のような相対指定にした上でブラウザをリサイズすると必ず脱線します。 `offset-path: path(...);` で指定したパスは `px` 単位になるからですね。ここを CSS だけでどうにかできれば良かったんですが、どうにもできませんでした…。 `offset-path: url(...);` に SVG タグを指定する方法なども試してみましたが、 Chrome では認識すらされず。コンテナサイズを observe してスクリプトを再実行するなどの施策が必要かもしれません。

### 文字列でなくても良い
デザインの話になりますが、必ずしも文字列を表示する必要はありません。あくまでも「お飾り」なので、丸とか四角などの図形でも OK 。そうなるとコードも若干シンプルになるでしょう。

## い　　か　　が　　で　　し　　た　　か
発端は、とあるカラースキームを提供するウェブサイトで見かけたアニメーションでした。で、興味がわいたので自分でも実装してみようと。ぶっちゃけ何の役にも立たないし下手すると邪魔にしかならないので使い所はないかもしれませんが、にぎやかし要員として使っていただければ。