JSでDVDのロゴが動くやつ作ってみた

JSで[DVDのロゴが動くやつ](https://bouncingdvdlogo.com/)を作りたくなったので作ってみました。

![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308460/634d63c6-a652-3c0d-72fd-dc832277a0ce.gif)

```jade:dvd.pug
#container
  #box(style="width: 100px; height: 100px; background: red;") DVD
button#play-button 再生
```

```scss:dvd.scss
* {
  box-sizing: border-box;
}

body {
  margin: 0;
}

#container {
  width: 500px;
  height: 500px;
  background-color: black;
        
  #box {
    font-size: 44px;
    transition: transform 1s linear 0s;
  }
}
```

```typescript:dvd.ts
// 色の文字列を格納した配列
const colors = ["red", "blue", "yellow", "green"];

// boxのHTML要素
const box = document.getElementById("box");
// boxのwidth(数値)
const boxWidth: number = Number(box.style.width.replace("px", ""));

// containerのHTML要素
const container = document.getElementById("container");

// HTML要素の現在位置を取得する
function getCurrentPosition(element: HTMLElement) {
  const elementRect = element.getBoundingClientRect();
  return {
    top: elementRect.top,
    right: elementRect.right,
    bottom: elementRect.bottom,
    left: elementRect.left
  };
}

// boxの初期位置
const boxInitialPosition = getCurrentPosition(box);
// boxの現在位置
let boxCurrentPosition = boxInitialPosition;

// 現在位置の方向
enum Direction {
  Top,
  Right,
  Bottom,
  Left
}

// containerの位置
const containerPosition = getCurrentPosition(container);

// boxの左上の頂点が移動できるx座標の最小値
const minimumX = containerPosition.left;
// x座標の最大値
const maximumX = containerPosition.right - boxWidth;
// y座標の最小値
const minimumY = containerPosition.top;
// y座標の最大値
const maximumY = containerPosition.bottom - boxWidth;

// x座標の可動域(range of motion)の距離
const romX = maximumX - minimumX;
// y座標の可動域の長さ
const romY = maximumY - minimumY;

// boxを指定した絶対位置に移動させる
function changeTranslate(x: number, y: number): void {
  box.style.transform = `translate(${x - boxInitialPosition.left}px, ${y -
    boxInitialPosition.top}px)`;
}

// boxを現在位置から指定した距離だけ移動させる
function changeTranslateFromCurrent(x: number, y: number): void {
  box.style.transform = `translate(${getCurrentPosition(box).left +
    x -
    boxInitialPosition.left}px, ${getCurrentPosition(box).top +
    y -
    boxInitialPosition.top}px)`;
}

// 配列の要素をランダムに取り出す
function getRandomFromArr<T extends any[]>(arr: T): T[number] {
  return arr[Math.floor(Math.random() * arr.length)];
}

// ピタゴラスの定理
function pythagorasTheorem(x: number, y: number) {
  return Math.sqrt(x ** 2 + y ** 2);
}

// boxの移動する速さ[px/s]
const boxSpeedPerSecond = 200;

// 再生中かのフラグ
let isPlaying = false;

// boxに付与する、transitionendイベント用のイベントハンドラ
function onTransitionEnd() {
  // boxの背景色を現在の色以外のランダムな色に変える
  const filteredColors = colors.filter(
    color => color !== box.style.backgroundColor
  );
  box.style.backgroundColor = getRandomFromArr(filteredColors);

  // 前回の位置
  const boxPrevPosition = boxCurrentPosition;
  // 現在位置
  boxCurrentPosition = getCurrentPosition(box);
  // 現在位置が上辺、右辺、下辺、左辺のどの方向にあるか
  const direction: Direction = (() => {
    switch (boxCurrentPosition.top) {
      case minimumY:
        return Direction.Top;
      case maximumY:
        return Direction.Bottom;
    }
    switch (boxCurrentPosition.left) {
      case minimumX:
        return Direction.Left;
      case maximumX:
        return Direction.Right;
    }
  })();
  // 前回の位置と現在位置の間の、x軸方向の距離とy軸方向の距離
  const prevToCurrentLength = {
    x: Math.abs(boxCurrentPosition.left - boxPrevPosition.left),
    y: Math.abs(boxCurrentPosition.top - boxPrevPosition.top)
  };
  // yに対するxの割合
  const XYrate = prevToCurrentLength.x / prevToCurrentLength.y;

  // 現在位置の方向によって場合分け
  if ([Direction.Top, Direction.Bottom].includes(direction)) {
    // 現在位置に対して向かい側に跳ね返るか
    const isBounceOppositeSide = (() => {
      if (boxCurrentPosition.left > boxPrevPosition.left) {
        // 左から右に移動した場合
        return boxCurrentPosition.left + romY * XYrate < maximumX;
      } else {
        // 右から左に移動した場合
        return minimumX < boxCurrentPosition.left - romY * XYrate;
      }
    })();

    // 現在位置と次回の位置の間の、x軸方向の距離とy軸方向の距離
    const currentToNextLength = (() => {
      if (isBounceOppositeSide) {
        const y = romY;
        return {
          x: y * XYrate,
          y
        };
      } else {
        const x =
          boxCurrentPosition.left > boxPrevPosition.left
            ? maximumX - boxCurrentPosition.left
            : boxCurrentPosition.left - minimumX;
        return {
          x,
          y: x / XYrate
        };
      }
    })();

    // 移動する時間を調整
    const currentToNextRLength = pythagorasTheorem(
      currentToNextLength.x,
      currentToNextLength.y
    );
    box.style.transitionDuration = `${currentToNextRLength /
      boxSpeedPerSecond}s`;

    // 現在位置から計算した距離だけ移動させる
    changeTranslateFromCurrent(
      (boxCurrentPosition.left > boxPrevPosition.left ? 1 : -1) *
        currentToNextLength.x,
      (direction === Direction.Top ? 1 : -1) * currentToNextLength.y
    );
  } else {
    // 現在位置に対して向かい側に跳ね返るか
    const isBounceOppositeSide = (() => {
      if (boxCurrentPosition.top > boxPrevPosition.top) {
        // 上から下に移動した場合
        return boxCurrentPosition.top + romX / XYrate < maximumY;
      } else {
        // 下から上に移動した場合
        return minimumY < boxCurrentPosition.top - romX / XYrate;
      }
    })();

    // 現在位置と次回の位置の間の、x軸方向の距離とy軸方向の距離
    const currentToNextLength = (() => {
      if (isBounceOppositeSide) {
        const x = romX;
        return {
          x,
          y: x / XYrate
        };
      } else {
        const y =
          boxCurrentPosition.top > boxPrevPosition.top
            ? maximumY - boxCurrentPosition.top
            : boxCurrentPosition.top - minimumX;
        return {
          x: y * XYrate,
          y
        };
      }
    })();

    // 移動する時間を調整
    const currentToNextRLength = pythagorasTheorem(
      currentToNextLength.x,
      currentToNextLength.y
    );
    box.style.transitionDuration = `${currentToNextRLength /
      boxSpeedPerSecond}s`;

    // 現在位置から計算した距離だけ移動させる
    changeTranslateFromCurrent(
      (direction === Direction.Left ? 1 : -1) * currentToNextLength.x,
      (boxCurrentPosition.top > boxPrevPosition.top ? 1 : -1) *
        currentToNextLength.y
    );
  }
}

// onTransitionEndイベントハンドラを付与
box.addEventListener("transitionend", onTransitionEnd);

const playButton = document.getElementById("play-button");
playButton.addEventListener("click", function() {
  if (!isPlaying) {
    // 再生中の状態にする
    isPlaying = true;
    // ボタンのテキストを「リセット」にする
    playButton.innerText = "リセット";
    // onTransitionEndイベントハンドラを付与
    box.addEventListener("transitionend", onTransitionEnd);

    // x座標の最小値超から最大値未満までの数字の連番が格納された配列
    const randomXArr = (() => {
      let arr: number[] = [];
      for (let i = minimumX + 1; i < maximumX; i++) {
        arr.push(i);
      }
      return arr;
    })();
    // y座標の最小値超から最大値未満までの数字の連番が格納された配列
    const randomYArr = (() => {
      let arr: number[] = [];
      for (let i = minimumY + 1; i < maximumY; i++) {
        arr.push(i);
      }
      return arr;
    })();

    const random = Math.random();
    // 右辺に移動させるか、下辺に移動させるかをランダムに決める
    if (random < 0.5) {
      // x座標の最小値超から最大値未満までの数字をランダムに取り出す
      const randomY = getRandomFromArr(randomYArr);
      // 斜辺の長さを求める
      const rLength = pythagorasTheorem(maximumX, randomY);
      // 移動する時間を調整
      box.style.transitionDuration = `${rLength / boxSpeedPerSecond}s`;
      // 右辺のランダムな位置に移動させる
      changeTranslate(maximumX, randomY);
    } else {
      // y座標の最小値超から最大値未満までの数字をランダムに取り出す
      const randomX = getRandomFromArr(randomXArr);
      // 斜辺の長さを求める
      const rLength = pythagorasTheorem(randomX, maximumY);
      // 移動する時間を調整
      box.style.transitionDuration = `${rLength / boxSpeedPerSecond}s`;
      // 下辺のランダムな位置に移動させる
      changeTranslate(randomX, maximumY);
    }
  } else {
    // 停止中の状態にする
    isPlaying = false;
    // ボタンのテキストを「再生」にする
    playButton.innerText = "再生";
    // 色を赤に戻す
    box.style.background = "red";
    // onTransitionEndイベントハンドラを削除
    box.removeEventListener("transitionend", onTransitionEnd);
    // トランジションをOFFにする
    box.style.transitionDuration = "0s";
    // 左上に移動させる
    changeTranslate(minimumX, minimumY);
    // 現在位置をリセット
    boxCurrentPosition = getCurrentPosition(box);
  }
});
```

<p class="codepen" data-height="265" data-theme-id="default" data-default-tab="js,result" data-user="yoshiharu2580" data-slug-hash="NWPpPvd" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="DVD">
  <span>See the Pen <a href="https://codepen.io/yoshiharu2580/pen/NWPpPvd">
  DVD</a> by Yoshiharu (<a href="https://codepen.io/yoshiharu2580">@yoshiharu2580</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

ずっと見てられますね。