初心者がJanai Coffeeの仕組みを解明してみた

## はじめに
私は、HTML, CSS, JSの基本的なことを習っていた時に、[Janai CoffeeのHP](https://janaicoffee.tokyo/)に出会いました。当時の私は、簡単なWebサイトは作れるようになっていましたが、このような遊び心のあるWebサイトを作成するのに必要な知識がありませんでした。

そこで、自分である程度MDNのサイトが読めるようになった頃、Janai CoffeeのHPの仕組みを解明しようと思いました。

※Janai Coffeeの仕掛けのネタバレを喰らいたくない方は、一度Janai CoffeeのHPを訪れて、謎解きをして仕掛けを理解してからもう一度この記事にお越しいただければ幸いです。

Janai CoffeeのHPには、Nuxt.jsが使用されているみたいなのですが、私はReactを使用する人間、かつ新しいフレームワークが登場した時にも使えるように、プレーンなJavaScriptだけを使用して書くことにしました。

## 使用した技術
Janai CoffeeのHPの仕掛けを実現するために使用した技術です。
### [Canvas](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API)
Canvasとは、 JavaScript と HTML の <canvas> 要素によってグラフィックを描く方法のことです。Janai Coffeeでは、仕掛けに連動してしたから迫り上がってくる波のようなアニメーションに使用されています。

### [Event handling](https://developer.mozilla.org/ja/docs/Web/Events/Event_handlers)
イベントの扱い（Event Handling）は、ユーザーが特定の要素を選択したり、クリックしたり、カーソルを当てたり、ユーザーがキーボードのキーを押す時に、何かしらの処理をする時に使用します。
Janai Coffeeでは、仕掛けをマウスで押下（スマホの場合はタップ）した時の処理をするのに、使用されています。

### [SVG](https://developer.mozilla.org/ja/docs/Web/SVG)
SVGとは、テキストベースで、どんなサイズでもきれいにレンダリングできるWebの技術です。Janai Coffeeでは、仕掛けの円状の画像や仕掛けの真ん中のロゴがSVG画像でした。
今回はCanva(有料プラン)で画像を作成し、Width 990px、Height 990pxの SVG画像として保存して使用してます。しかし、SVG画像の良さを活かしたやり方ではなく簡単な使い方をしてしまいました。SVGに関しては今後も学び、JavaScriptでSVG画像を操作できるようになりたいと考えています。

## 全体コードの補足
### 補足1: passiveについて
`addEventListener`や`removeEventListener`を使用する際に、Optionsとして、`passive : true;` を追加しています。
optionsの使用方法は、[AddEventListerのページ](https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener)に書かれています。
addEventListenerでスクロール処理中の性能が大幅に低下しないようにするため、`passive`は、デフォルトで`false`になっており、`event.preventDefault`が使用できないようになっています。今回は、`preventDefault`を使用したかったので、イベントハンドラーには、`passive:true`を設定しました。

(今回はイベントハンドラーには全て`passive:true`の設定をしました。よくMDNを読むと、touchstartなどの特定のイベントやブラウザによって指定しなくて良いものもあります。`removeEventListener`にはいらないのに`passive: true;`を書いてしまったかもしれません。実際に実務で使用する際は、よくお確かめください。)

### 補足2: 仕掛けの構造

JavaScriptの記述に、以下のような3行があります。
```js
const range = 90;
const radius = 150;
const innerRadius = 90;
```
これは仕掛けの画像に対して当たり判定をする範囲をどのくらいにするかを設定したのものです。

実際に使用した画像（以下の画像）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726585/7a7fce7d-d211-cd9c-a6b9-005dcc8a5ed4.png" width="300px"/>

 の上を、カーソルや指でなぞるとき、ユーザーはどうしても、文字の範囲を超えてしまうことがあります。文字の範囲から内側に外れても外側に外れても良いように、当たり判定の範囲(range)を大きめに設定しています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726585/4f58cd2e-7b92-57a9-da05-90b9b8463330.png" width="300px"/>

onMouseMove関数の中で、「画像の中心の座標とユーザーの指やポインターとの距離が、innerRadiusよりも小さい場合や、innerRadius+rangeより大きい場合は、`initStyle`関数を呼び出して、処理を初期化する」という処理を行っています。この処理は、以下のコードで実現しています。

```js
if (
  distanceToCenter < innerRadius ||
  distanceToCenter > innerRadius + range
) {
  initStyle();
}
```

そして、当たり判定をするコードは、以下のような考え方で実装しています。onMouseMove関数で以下の考え方を実現しています。

まず、ユーザーがポインターや指を置いた場所の座標を取得して、そこから、中心から40度離れた場所に、縦横90pxの点を作成します。同じことを8回繰り返して8つの点を作成します。（最初に置いた場所が、画像の星の位置だとしたら、星の位置から右回りに8個の点が作成されます。）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/726585/f63d02cd-cfa7-bc1f-8e23-ab1001411fdc.png" width="300px"/>

それぞれの点(point)の四角(かつinnerRadiusとinnerRadius+range)との間にユーザーのポインターや指が入ったら、それぞれの点(point)のpassed(通過した可動かを判定する)をtrueにします。

全ての点のpassedがtrueになり、かつ、もう一度1番目の点の内側にユーザーのポインターや指が入ったら、背景色が変化したり仕掛けの画像がimage1からimage2に変わる仕組みです。

## 補足3: canvasのアニメーションについて
canvasのアニメーションは、ChatGPTを使用して、
- サインカーブを描く
- サインカーブと、canvasの枠に囲われた下半分を塗り潰す
- サインカーブにアニメーションをつける

という手順で実装しました。
特にサインカーブにアニメーションをつける部分は、setTimeOut関数を使用して、マイフレームごとに、サインカーブを変化させるという手法を用いています。
MDNのCanvasの[基本的なアニメーション](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Basic_animations)の紹介するページには、setInterval関数を使用する方法も紹介されています。



## コード全体
html, css, jsを全て、index.htmlに記入しました。これをコピペしたら実際に挙動が確認できると思います。iOS 17.2.1でも動作しました。


```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Points and Background Color Change</title>
    <style>
      body {
        margin: 0;
        height: 100vh;
        transition: background-color 0.5s ease;
      }
      #box {
        width: 330px;
        height: 330px;
        background-color: transparent;
        position: relative;
        margin: 0px auto;
        padding:50px 0;
      }
      .black {
        background-color: #001625;
      }
      .white {
        background-color: #fff;
      }
      .canvas-wave {
        left: 0;
        z-index: 1000;
        min-width: 320px;
        pointer-events: none;
        position: fixed;
        transition: all 0.55s ease;
        width: 100vw;
      }
      .is-page-bar .canvas-bg {
        background-color: #fff;
      }
      .canvas-bg {
        background-color: #1d0e04;
        height: 100vh;
        min-width: 320px;
        position: absolute;
        top: 298px;
        width: inherit;
      }
    </style>
  </head>
  <body class="white">
    <div
      class="canvas-wave"
      id="canvas-container"
      style="opacity: 0; bottom: 0px"
    >
      <div class="canvas-bg"></div>
      <canvas
        id="sineCanvas"
        width="600px"
        height="300px"
      ></canvas>
    </div>
    <div id="box">
      <img id="image1" src="image1.svg" alt="" width="330px", height="330px">
      <img id="image2" src="image2.svg" alt="" width="330px", height="330px" display="false">
    </div>

    <script>
      const range = 90;
      const radius = 150;
      const innerRadius = 90;
      const bodyEl = document.body;
      const boxEl = document.getElementById("box");
      const canvas = document.getElementById("sineCanvas");
      const canvasWaveEl = document.getElementById("canvas-container");
      let points = [];
      let pointCounter = 0;
      let passiveSupported = false;
      document.getElementById("image2").style.display = "none";

      //関数の定義
      function initStyle() {
        // Remove existing points
        points = [];
        pointCounter = 0;
        canvasWaveEl.style.bottom = 0;
        canvasWaveEl.style.opacity = 0;
        // console.log("初期化");

        boxEl.removeEventListener(
          "mousemove",
          onMouseMove,
          passiveSupported ? { passive: true } : false
        );
        boxEl.removeEventListener(
          "mouseup",
          onMouseUp,
          passiveSupported ? { passive: true } : false
        );
        //SP用のイベントハンドラー
        boxEl.removeEventListener(
          "touchmove",
          onMouseMove,
          passiveSupported ? { passive: true } : false
        );
        boxEl.removeEventListener(
          "mouseup",
          onMouseUp,
          passiveSupported ? { passive: true } : false
        );
      }

      function onMouseDown(e) {
        e.preventDefault();
        const oneTenthHeight = document.body.height / 10;
        const box = document.getElementById("box");
        const boxRect = box.getBoundingClientRect();
        const boxCenterX = boxRect.left + boxRect.width / 2;
        const boxCenterY = boxRect.top + boxRect.height / 2;
        const mouseX = e.clientX ? e.clientX : e.touches[0].clientX;
        const mouseY = e.clientY ? e.clientY : e.touches[0].clientY;

        const distanceToCenter = Math.sqrt(
          Math.pow(mouseX - boxCenterX, 2) + Math.pow(mouseY - boxCenterY, 2)
        );

        // console.log(`中心からの距離${distanceToCenter}`);

        if (
          distanceToCenter >= innerRadius &&
          distanceToCenter <= innerRadius + range
        ) {
          const angle = Math.atan2(mouseY - boxCenterY, mouseX - boxCenterX);

          for (let i = 0; i < 9; i++) {
            const pointAngle = angle + (i * (2 * Math.PI)) / 9;
            const pointX = boxCenterX + Math.cos(pointAngle) * radius; // 半径(radius)は150px
            const pointY = boxCenterY + Math.sin(pointAngle) * radius;

            points.push({
              left: pointX - range / 2,
              top: pointY - range / 2,
              right: pointX + range / 2,
              bottom: pointY + range / 2,
              passed: false,
            });
          }

          boxEl.addEventListener(
            "mousemove",
            onMouseMove,
            passiveSupported ? { passive: true } : false
          );
          boxEl.addEventListener(
            "mouseup",
            onMouseUp,
            passiveSupported ? { passive: true } : false
          );
          //SP用のイベントハンドラー
          boxEl.addEventListener(
            "touchmove",
            onMouseMove,
            passiveSupported ? { passive: true } : false
          );
          boxEl.addEventListener(
            "touchend",
            onMouseUp,
            passiveSupported ? { passive: true } : false
          );
        }
      }

      function onMouseMove(e) {
        e.preventDefault();

        const box = document.getElementById("box");
        const boxRect = box.getBoundingClientRect();
        const boxCenterX = boxRect.left + boxRect.width / 2;
        const boxCenterY = boxRect.top + boxRect.height / 2;
        let currentPoint = points[pointCounter];
        const mouseX = e.clientX ? e.clientX : e.touches[0].clientX;
        const mouseY = e.clientY ? e.clientY : e.touches[0].clientY;
        // console.log(`mouse* (${mouseX}, ${mouseY}), currentPoint* (${currentPoint.left+range/2},${currentPoint.top+range/2})`);

        if (
          points[points.length - 1].passed &&
          mouseX >= points[0].left &&
          mouseX <= points[0].right &&
          mouseY >= points[0].top &&
          mouseY <= points[0].bottom
        ) {
          //円を一周した時の変化
          canvasWaveEl.style.bottom == 0;
          canvasWaveEl.style.opacity == 0;
          if (bodyEl.classList.contains("white")) {
            bodyEl.classList.add("black");
            bodyEl.classList.remove("white");
            canvasWaveEl.classList.add("is-page-bar");
            document.getElementById("image1").style.display = "none";
            document.getElementById("image2").style.display = "block";
          } else {
            bodyEl.classList.add("white");
            bodyEl.classList.remove("black");
            canvasWaveEl.classList.remove("is-page-bar");
            document.getElementById("image2").style.display = "none";
            document.getElementById("image1").style.display = "block";
          }
          initStyle();
        }

        const distanceToCenter = Math.sqrt(
          Math.pow(mouseX - boxCenterX, 2) + Math.pow(mouseY - boxCenterY, 2)
        );

        if (
          distanceToCenter < innerRadius ||
          distanceToCenter > innerRadius + range
        ) {
          initStyle();
        }

        //通過pointを通過するとpointの色が変わり、waveが上に押し上げてくる
        if (
          mouseX >= currentPoint.left &&
          mouseX <= currentPoint.right &&
          mouseY >= currentPoint.top &&
          mouseY <= currentPoint.bottom
        ) {
          currentPoint.passed = true;
          canvasWaveEl.style.bottom = pointCounter * 10 + "vh";
          canvasWaveEl.style.opacity = pointCounter * 0.1;
          pointCounter == 8 ? (pointCounter = 0) : pointCounter++;
          // console.log(
          //   `currentPointのpassedが${currentPoint.passed}、pointCounterが${pointCounter}になりました`
          // );
        }
      }

      function onMouseUp(e) {
        initStyle();
        boxEl.removeEventListener(
          "mousemove",
          onMouseMove,
          passiveSupported ? { passive: true } : false
        );
        boxEl.removeEventListener(
          "mouseup",
          onMouseUp,
          passiveSupported ? { passive: true } : false
        );
        //PC用のイベントハンドラー
        boxEl.removeEventListener(
          "touchmove",
          onMouseMove,
          passiveSupported ? { passive: true } : false
        );
        boxEl.removeEventListener(
          "touchend",
          onMouseUp,
          passiveSupported ? { passive: true } : false
        );
      }

      //初期化する
      initStyle();

      //mousedownやtouchstartを検知する
      boxEl.addEventListener(
        "mousedown",
        onMouseDown,
        passiveSupported ? { passive: true } : false
      );
      boxEl.addEventListener(
        "touchstart",
        onMouseDown,
        passiveSupported ? { passive: true } : false
      );

      /*
       * canvasを使用して、sineCanvasに、絵を描き、波打つようなアニメーションを作成する
       */

      // Get the canvas element
      const ctx = canvas.getContext("2d");
      let animationFactor = 0;

      function resize() {
        canvas.width = window.innerWidth;
      }

      // Animation function
      function draw() {
        // Clear canvas
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // Update animation factor based on time
        animationFactor += 0.8;
        animationFactor >= 30000
          ? (animationFactor = 0)
          : (animationFactor += 1);

        // Draw the sine curve
        ctx.beginPath();
        ctx.moveTo(0, canvas.height / 2);
        for (let x = 0; x < canvas.width; x++) {
          const y =
            30 * Math.sin(((0.5 * x + animationFactor) * Math.PI) / 180) +
            canvas.height / 2;
          ctx.lineTo(x, y);
        }
        ctx.lineTo(canvas.width, canvas.height);
        ctx.lineTo(0, canvas.height);
        ctx.closePath();
        canvasWaveEl.classList.contains("is-page-bar")
          ? (ctx.fillStyle = "#fff")
          : (ctx.fillStyle = "#1d0e04"); // Fill color for the sine curve enclosed area
        ctx.fill();

        let world = () => {
          resize();
          draw();
        };

        // Request next frame
        setTimeout(world, 1000 / 60); // Adjust the timeout for desired animation speed
      }

      // Start the animation
      draw();


    </script>
  </body>
</html>

```

## 終わりに
読んでいただきありがとうございました。何かアドバイスやご指摘があればコメントしていただけると幸いです。