【三角関数解説あり】GSAPで円弧に沿ったカルーセルを実装してみる(備忘録)

今回は、GSAPで円弧に沿ったカルーセルを実装してみます。

SwiperやSplideなどを使っていろいろ試してはみましたが、自分には難しく対応できなかったため、GSAPでやってみました。

ただ、ループ処理までは対応できていません。1周目から2周目に切り替わるときのもとに戻る挙動の制御がうまくいかなかったため、そこは今後の課題としたいです。

ループ処理をしないパターンについては、下記の通り実装ができました。なお、レスポンシブ対応はしていませんので、PCでご覧いただくことを推奨します。

それでは、やっていきましょう。

HTMLの記述について

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>GSAPで円弧に沿ったカルーセルを実装してみる</title>
  <link rel="stylesheet" href="../reset.css">
  <link rel="stylesheet" href="./style.css">
</head>

<body>
  <main>
    <div id="carousel-container">
      <div id="carousel">
        <img src="./images/img_01.webp" alt="Image 1">
        <img src="./images/img_02.webp" alt="Image 2">
        <img src="./images/img_01.webp" alt="Image 3">
        <img src="./images/img_02.webp" alt="Image 4">
        <img src="./images/img_01.webp" alt="Image 5">
        <img src="./images/img_02.webp" alt="Image 6">
      </div>
    </div>
    <div id="controls">
      <button id="prev">前へ</button>
      <button id="next">次へ</button>
    </div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script src="./style.js"></script>
</body>

</html>

円弧のカルーセルを作るため、6枚の画像を用意し、360/60=60度ずつ円形に配置されるように実装していきます。

また円弧のカルーセルの下に、「前へ」と「次へ」のボタンを用意しました。

なお、GSAPはCDNで読み込んでいます。

公式サイトはこちら

上記の赤枠部分を使用しています。

CSSについて

@charset "utf-8";

/* ==========================
  初期設定
========================== */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  position: relative;
  word-wrap: break-word;
}

img {
  width: 100%;
}

/* レイアウト設定 */
#carousel-container {
  position: relative;
  height: 720px;
}

#carousel {
  width: 100%;
  height: 100%;
  position: absolute;
}

#carousel img {
  position: absolute;
  max-width: 160px;
  left: 50%;
  top: 320px;
  transform: translateX(-50%);
  transition: 0.3s ease;
}

#controls {
  text-align: center;
  margin-top: 16px;
  margin-bottom: 80px;
}

ポイントは下記部分です。max-widthで画像の大きさを決めてあげます。JavaScript側での数値調整と、こちらのmax-widthの数値を調整して、円の中心の大きさや画像の大きさをコントロールします。

#carousel img {
  position: absolute;
  max-width: 160px; /* 各画像の最大幅を指定 */
  left: 50%;
  top: 320px;
  transform: translateX(-50%);
  transition: 0.3s ease;
}

JavaScriptの記述について

"use strict";

// カルーセルコンテナ内のすべての画像を選択
const carousel = document.querySelector("#carousel");
const images = carousel.querySelectorAll("img");

// 現在表示中の画像のインデックス
let currentIndex = 0;

// 円の半径を定義
const radius = 240;

// 各画像に適用する角度のステップを計算 (全体の円周を画像の数で割った値)
const angleStep = (2 * Math.PI) / images.length;

// ボタン要素を取得
const prevButton = document.querySelector("#prev");
const nextButton = document.querySelector("#next");

// カルーセルを更新する関数
function updateCarousel() {
  // 各画像に対してループ処理
  images.forEach((img, i) => {
    // 現在のインデックスに基づいて各画像の角度を計算(各画像がカルーセルのどの位置にあるべきかを計算し、カルーセルが動的に回転する際にそれぞれの画像がどこに移動するかを決定)
    const angle = i * angleStep - currentIndex * angleStep - Math.PI / 2;
    // X座標とY座標を計算
    const x = Math.cos(angle) * radius;
    const y = Math.sin(angle) * radius;
    // 画像の回転角度を計算(画像がカルーセルの中心に向かって正面を向くように調整)
    const rotationAngle = angle * (180 / Math.PI) + 90;
    // GSAPライブラリを使用して各画像の位置と回転を設定
    gsap.set(img, {
      x,
      y,
      rotation: rotationAngle,
    });
  });
  // ボタンの状態を更新
  updateButtons();
}

// 「次へ」ボタンがクリックされたときの処理
function nextImage() {
  // 最後の画像ではない場合にインデックスを1つ増やす
  if (currentIndex < images.length - 1) {
    currentIndex++;
    updateCarousel();
  }
}

// 「前へ」ボタンがクリックされたときの処理
function prevImage() {
  // 最初の画像ではない場合にインデックスを1つ減らす
  if (currentIndex > 0) {
    currentIndex--;
    updateCarousel();
  }
}

// ボタンの活性・非活性状態を更新する関数
function updateButtons() {
  // 最初の画像の場合は「前へ」ボタンを無効化
  prevButton.disabled = currentIndex === 0;
  // 最後の画像の場合は「次へ」ボタンを無効化
  nextButton.disabled = currentIndex === images.length - 1;
}

// 初期表示のためにカルーセルを更新
updateCarousel();

// ボタンにクリックイベントリスナーを追加
prevButton.addEventListener("click", prevImage);
nextButton.addEventListener("click", nextImage);

コメントアウトで何をやっているのかを記載しています。

下記で円の中心の大きさを調整できます。

// 円の半径を定義
const radius = 240;

全体の円周=(2 * Math.PI)を、スライダーの画像の数=images.lengthで割ることで、各画像間の角度が計算できます。

// 各画像に適用する角度のステップを計算 (全体の円周を画像の数で割った値)
const angleStep = (2 * Math.PI) / images.length;

iはforeach内で0から始まり、今回は6枚画像があるため5まで順番に入って計算していくイメージです。i * angleStepでカルーセルの中心から見たときの画像iの角度位置を示します。

currentIndexは現在フォーカスされているカルーセルの画像のインデックスです。currentIndex * angleStepで現在フォーカスされているカルーセルの角度位置を示します。

Math.PI / 2は角度を90度調整するために記述しています。これを設定しないと下記のように画像の開始位置が時計の針の3時の位置になってしまいます。このため90度調整しています。Math.PIは180度を表しています。数学を思い出していただくと「π」を使っていたと思いますが、それがMath.PIに置き換わったイメージです。

// 現在のインデックスに基づいて各画像の角度を計算(各画像がカルーセルのどの位置にあるべきかを計算し、カルーセルが動的に回転する際にそれぞれの画像がどこに移動するかを決定)
const angle = i * angleStep - currentIndex * angleStep - Math.PI / 2;

画像のX座標とY座標の位置、画像がカルーセルの中心に向かって正面を向く調整をします。

// X座標とY座標を計算
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;

// 画像の回転角度を計算(画像がカルーセルの中心に向かって正面を向くように調整)
const rotationAngle = angle * (180 / Math.PI) + 90;

Math.cos(angle) * radiusの計算について

radiusは240と定義しています。これは円の半径を表します。

問題は、Math.cos(angle) です。angelには角度が入ります。計算方法は先述していますが、一旦シンプルな値で考えてみましょう。

例えばangleが0度、90度、180度と続けて入っているとします。すると計算式が下記のようになります。

  • Math.cos(0)*240
  • Math.cos(90)*240
  • Math.cos(180)*240

これを下記の三角関数表に基づいて、値に変換していきます。

三角関数表参考

上記表に基づくとcos(0)の値は「1」であることが分かります。こちらの要領で進めていくとcos(90)は「0」、cos(180)は「-1」になります。

Math.cosのMathはなんなのかというと、これを入れることでJavaScript側で指定された角度の余弦を返します。自分で三角関数表を見て数値を入れて計算しなくても自動的に計算してくれるようになります。

Math.cos()について

さきほどの計算結果は下記のようになります。

  • Math.cos(0)*240=240
  • Math.cos(90)*240=0
  • Math.cos(180)*240=-240

では、どうしてxではcos、yではsinが使われているのでしょうか。これを理解するために直角三角形で説明できたらと思います。

円の座標計算の公式があるのですが、それが下記の図を用いて考えるとわかりやすくなります。ちなみθ(シーター)は角度のことです。いつもは数字を入れていますが、θという文字を代わりに入れました。rは円の半径、xはx軸の値、yはy軸の値になります。

ここからは覚え方があって、cosθからやっていきます。

cosθは直角三角形に対して、cという文字を書いたときにrとxの辺を順番に通っているのが分かります。

これを式にしたとき、下記のようになります。ここは暗記する部分も含みますが、個人的には覚えやすいです。

cosθ=x/r(r・xの順に通るからあーるぶんのえっくす)

これをxが左側になるように式を変更していくと、下記になります。

x=r*cosθ

同様にsinθもやってみましょう。筆記体のsを書いたときrとyの辺を順番に通っているのが分かります。

これを式にしたとき、下記のようになります。

sinθ=y/r(r・yの順に通るからあーるぶんのわい)

これをyが左側になるように式を変更していくと、下記になります。

y==r*sinθ

それでは、下記を見てみましょう。xではcos、yではsinが使われていますね。

// X座標とY座標を計算
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;

画像の回転角度を計算

画像がカルーセルの中心に向かって正面を向くように調整しています。

// 画像の回転角度を計算(画像がカルーセルの中心に向かって正面を向くように調整)
const rotationAngle = angle * (180 / Math.PI) + 90;

angleは、カルーセルの中心から見たときの画像の角度(ラジアン単位)です。

後ほどGSAPで角度を設定する場面があります。度数法で使用するために(180 / Math.PI)をangelにかけて変換しています。90を足しているのは、先ほど90度傾けてカルーセルの位置を時計の針の12時の位置に調整したため、その分値を足しています。

あとはgsap.set()を用いてx座標、y座標、回転する値を設定すればOKです。

gsap.set()の詳細はこちら

gsap.set(img, {
  x,
  y,
  rotation: rotationAngle,
});

ボタンについて(前へ・次へ)

現在のインデックスの順番が0の場合は、前へのボタンを非活性に。現在のインデックスがカルーセルに設定している画像の数より1小さい場合(インデックスの番号が0から始まるため総画像枚数から1引いた値を設定しています)、次へのボタンを非活性にという対応をしています。

// ボタンの活性・非活性状態を更新する関数
function updateButtons() {
  // 最初の画像の場合は「前へ」ボタンを無効化
  prevButton.disabled = currentIndex === 0;
  // 最後の画像の場合は「次へ」ボタンを無効化
  nextButton.disabled = currentIndex === images.length - 1;
}

ここでボタンがクリックされたときの処理をしています。例えば、次へボタンの場合は、現在のインデックスが総画像枚数から1引いた値より小さい場合、現在のインデックスが1増えて、updateCarouselの内容が更新されるようになっています。

続いて実際に動かすにはボタンをクリックしたときのイベントリスナーを追加する必要があるため、追記します。

// 「次へ」ボタンがクリックされたときの処理
function nextImage() {
  // 最後の画像ではない場合にインデックスを1つ増やす
  if (currentIndex < images.length - 1) {
    currentIndex++;
    updateCarousel();
  }
}

// 「前へ」ボタンがクリックされたときの処理
function prevImage() {
  // 最初の画像ではない場合にインデックスを1つ減らす
  if (currentIndex > 0) {
    currentIndex--;
    updateCarousel();
  }
}

// ボタンにクリックイベントリスナーを追加
prevButton.addEventListener("click", prevImage);
nextButton.addEventListener("click", nextImage);

長かったですが、以上です。ループ処理でもうまくいく方法が見つかればまた紹介できたらと思います。

わかりにくい部分もあるかもしれませんが、参考になっていると嬉しいです。