9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

microCMSでこんなことができた!あなたのユースケースを大募集 by microCMSAdvent Calendar 2024

Day 24

ポケポケ風デザインでNext.js × microCMSブログを作成!パック開封風ランダム記事機能も追加

Last updated at Posted at 2024-12-24

ポケポケとは

スマホでポケモンカードができるようになったアプリです。
ポケモン トレーディングカードゲーム ポケット略してポケポケです。

今回作ったページの動き

画面収録-2024-12-19-11.57.55.gif

経緯

実際にmicroCMSを使用してブログを作成したのは、一年程前です。ポケポケのデザインを見てブログのデザイン同じようにして、カテゴリーとかタグとかをきちんと作ってみよーと思い出したので改めてカテゴリーやタグなどもきちんと設定して作り直しました。

ニューモーフィズムデザインとは

私はデザインに詳しくないのですが、凹凸してるように見えるデザインです。知名度は結構ありそうですが実際に使われているページを見る機会は少ない気がします。私も案件などでこのデザインを実装したことはありません。

下記の記事に詳しいことが書いてあります。

先に完成品

ページのアイコンや、人のイラスト、タイトルなどは、なんでもよかったので、ChatGPTに作ってもらい、考えてもらいました。ページの構成はmicroCMSさんのブログを参考にさせていただきました。

記事の内容は昔に書いたものなどでてきとうです。画像はもっとてきとうです。猫の画像とかを入れたりもしてます

◾️ ----------------------- ◾️

◾️ ----------------------- ◾️

スクリーンショット 2024-12-19 12.19.19.png

作成手順

基本的な雛形は全て、microCMSさんのチュートリアルに沿って行いました。カテゴリーやタグなどの追加も全て用意されている記事に倣えば理解できるように作られていました。

私は最近Vue.jsばかり触っていてApp Routerに馴染みがないのでPage Routerのままで実装を行いました。page配下に直接ファイルを置くか、app配下にフォルダを一枚噛ませてpage.tsxにするかというくらいの違いじゃないの?という程度の認識です…。

↓このページに書いてある記事を一通りさらって、雛形を作成しました。

ニューモーフィズムデザインで実装する方法

Neumorphic Generator」と検索すれば色々と出てきますので、自分の実装したいデザインに合わせて影の濃さや色などを調整して実装していきましょう!

実際に作ってみると背景色に制限があったりと、デザインセンス皆無人間には厳しいところがありましたので色は何も使わずに白黒で作成しました。

基本的な考え方

凹凸の凸にあたる部分

  • 基本的にクリックしたり、何か押せるような部分は飛び出していると感覚的にわかりやすいかと思います

凹凸の凹にあたる部分

  • こちらは基本的にユーザーが何かアクションを起こしても変わらない静的な部分を実装するといいと思いました

あとはいい感じになるよう頑張ります

記事をランダムに表示する機能

これは完全にポケポケのパック開封的な挙動を作りたいという気持ち先行型で、その為に記事をランダムに表示する機能があったらいいのではないかとなりました。

実際は望んだ記事を見たいと思うので蛇足機能なのですが、例えばQiitaでしたら言語や技術でフィルターかけたりトレンドを表示したりなど、条件を増やしてある程度の指向性を持たせれば普通に過ごしていたら出会えなかった記事と出会える可能性も出てきそうですしあっても良いのではないでしょうか。

回転させて選ぶというのが個人的にパック開封みたいで好きなのでぜひ体験してみてください。ヘッダーの「ランダム記事」というところをクリックすると体験できます。

画面収録-2024-12-19-23.46.36.gif

簡単に作成手順の説明

ランダムな記事の選択

まず、表示する記事をランダムに選択する実装について説明します。Fisher-Yatesアルゴリズムを使用して、効率的なシャッフルを実現しています。

自分は最初に記事のlengthを取得してMath.ramdomの範囲をlengthに正規化して整数のみが出力されるようにし、対応するidの記事を選ぶという処理を10回、かつ一度選ばれた数字は除外するという処理を考えたのですがなんか分からないけど処理が遅そうだしスマートじゃない雰囲気を感じました。でもこんなありふれた問題、絶対に先人の知恵があると思ったので調べたらいい感じの回答がありましたのでFisher-Yatesアルゴリズムというやつを使いました。

→ 知っていて当然レベルだったかもしれません…

初心者解説
[1,2,3,4,5]という配列があると仮定する最初は5番目を対象とし入れ替える対象をランダムに選択する。4番目を対象とし入れ替える対象をランダムに選択する。3番目をと繰り返していく

そのほかのThree.jsなどを交えた説明は長くなりそうなので折りたたんで書きました。

Three.jsで作る横に回るカードの詳しい説明

今回はThree.jsとReactを組み合わせて、3D空間で横方向にのみ回転するカード達を実装しました。ランダムに記事を選出してカードを回転させる部分の実装の主要なポイントだけ解説します。

1. ランダムな記事の選択と配置

ランダムな記事の選択

まず、表示する記事をランダムに選択する実装についてですが上の方でも書いたFisher-Yatesアルゴリズムを使用して、効率的なシャッフルを実現しています。

Fisher-Yatesアルゴリズムを使用したシャッフルを実現するコードはよくわからなかったので調べました。例えばですが1000記事存在する場合、Math.random()を1000回実行してランダム性を担保するのですが、それって大変そう。最初に考えた方法ならもっと少ない回数で完了する可能性があるのにと思いました。ただ回数が肥大化する可能性もあるのでlengthの回数と決まっているの方が管理しやすそうとも思いました。

/**
 * 指定された数のランダムな記事を選択する関数
 * @param articles 全ての記事の配列
 * @param count 選択する記事の数
 * @returns 選択された記事の配列
 */
const getRandomArticles = (articles: Blogs[], count: number): Blogs[] => {
  // 記事が指定数より少ない場合は、全ての記事を返す
  if (articles.length <= count) {
    return articles;
  }

  // Fisher-Yatesアルゴリズムを使用してシャッフル
  const shuffled = [...articles];
  for (let i = shuffled.length - 1; i > 0; i--) {
    // ランダムなインデックスを生成(0からiまで)
    const j = Math.floor(Math.random() * (i + 1));
    // 要素を交換
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }

  // 最初のcount個の要素を返す
  return shuffled.slice(0, count);
};

const RandomArticle = ({ blogs, categories }: { blogs: Blogs[]; categories: any[] }) => {
  // パフォーマンスのために、useMemoを使用して再計算を防ぐ
  // useMemoを使用して分離するとパフォーマンスに良いと聞いた多用しすぎも良くないらしい
  // 今回は明らかに使った方が良さげな処理なので使用します
  const selectedBlogs = useMemo(() => getRandomArticles(blogs, 10), [blogs]);

  return (
    <>
      <Header />
      <div className={styles.container}>
        <Canvas>
          <Suspense fallback={null}>
            <Scene blogs={selectedBlogs} />
          </Suspense>
        </Canvas>
      </div>
    </>
  );
};

実装のポイント

  1. Fisher-Yatesアルゴリズム
    • 配列の最後から順に、ランダムな位置の要素と交換
    • O(n)の時間複雑度で効率的なシャッフルを実現(らしい、大体どの記事にも書いてあった)
    • 完全にランダムな順序を保証
       
  2. メモ化による最適化
    • useMemoを使用して不要な再計算を防止
    • ブログデータが変更された時のみ再計算
       
  3. エッジケースの処理
    • 記事数が要求数より少ない場合の処理(私の場合、サンプルで入れいている記事が少ないので必要でした)
    • 配列のコピーによる元データの保護

円形配置の実装

上のロジックでランダムに選択された記事を円形に配置する実装について説明します。各記事のカードは円周上に均等に配置され、円を横スクロールで回転させることで、カルーセルのような動きを実現します。

一旦円状に配置するまでの流れ

const Scene = ({ blogs }: { blogs: Blogs[] }) => {
  const groupRef = useRef<THREE.Group | null>(null);
  const { camera } = useThree();
  
  // カメラの初期位置設定(これは手作業で数字変えて良さそうなところにする)
  useEffect(() => {
    camera.position.z = 12;
  }, [camera]);

  // カードの配置計算
  const radius = 8; // 円の半径(これも実際の画面を見ながらいい感じに調整)
  // 等間隔にするやつ
  const angleStep = (Math.PI * 2) / blogs.length;

  return (
    <group ref={groupRef}>
      {blogs.map((blog, index) => {
        const angle = index * angleStep;
        const x = Math.sin(angle) * radius;
        const z = Math.cos(angle) * radius;
        return (
          <Card
            key={blog.id}
            position={[x, 0, z]}
            blog={blog}
            onClick={() => handleCardClick(blog.id)}
          />
        );
      })}
    </group>
  );
};

実装のポイント

  1. 円形配置の計算
    • angleStep: 全周(2π)をカード数で割って、カード間の角度を計算
    • radius: カードを配置する円の半径(大きすぎると広がりすぎ、小さすぎると密集)
    • 三角関数で x, z 座標を算出(y座標は縦軸揃えたいので0で固定)
       
  2. カメラの設定
    • カメラの位置(z=12)は、全てのカードが視界に収まるようにいい感じに調整
    • 視野角とカメラ距離のバランスを考慮
       
  3. グループ化
    • カード全体をgroupでラップし、一括で回転操作が可能に
    • groupRefで回転状態を管理

2. PlaneGeometryにHTMLコンテンツを貼り付けて記事カードを作成

Three.jsのPlaneGeometryにHTMLコンテンツを貼り付ける実装について説明します。単なる3Dオブジェクトではなく、取得したデータを動的に貼り付けてインタラクティブなHTMLコンテンツを3D空間に表示します。

const Card = ({ position, blog, onClick }) => {
  const meshRef = useRef<THREE.Mesh | null>(null);
  const [isRevealed, setIsRevealed] = useState(false);
  const [isContentVisible, setIsContentVisible] = useState(false);

  // カードがみんな正面を向いていてイメージと異なるためカードが常に中心を向くように
  useFrame(() => {
    if (meshRef.current) {
      const cardPosition = new THREE.Vector3(...position);
      const toCenter = new THREE.Vector3();
      toCenter.copy(cardPosition);
      toCenter.negate();
      toCenter.y = 0;
      toCenter.normalize();
      const angle = Math.atan2(toCenter.x, toCenter.z) + Math.PI;
      meshRef.current.rotation.y = angle;
    }
  });

  return (
    <mesh ref={meshRef} position={position}>
      <planeGeometry args={[1.5, 2.5]} />
      <meshPhysicalMaterial
        color="white"
        roughness={0.1}
        metalness={0}
        clearcoat={1}
        clearcoatRoughness={0.1}
        side={THREE.DoubleSide}
      />
      <Html
        transform
        scale={[0.15, 0.15, 0.15]}
        position={[0, 0, 0.01]}
        distanceFactor={10}
        center
        occlude
      >
        <div className={styles.article_card}>
          <div className={`${styles.card_content} ${isContentVisible ? styles.revealed : ''}`}>
            {/* カードのコンテンツ */}
          </div>
          <div className={`${styles.card_cover} ${isRevealed ? styles.hidden : ''}`}>
            <div className={styles.cover_content}>
              <span className={styles.question_mark}>?</span>
              <p className={styles.cover_text}>Click to Reveal</p>
            </div>
          </div>
        </div>
      </Html>
    </mesh>
  );
};

実装のポイント

  1. 3Dオブジェクトの基本設定
    • PlaneGeometry: 平面の大きさを[1.5, 2.5]に設定(アスペクト比を考慮し記事カードのデザインに合わせて)
    • meshPhysicalMaterial: 物理ベースのマテリアルで見た目を改善
      • roughness: 表面の粗さ(低めに設定で光沢感を出す)
      • clearcoat: クリアコート層による光沢効果(この辺はとりあえず色々入れました…)
      • side: 両面表示で裏面も見えるように(裏側にも反転した文字が出てしまうので)
         
  2. HTMLコンテンツの配置
    • transform: HTMLを3D変換の対象に
    • scale: HTMLコンテンツを適切なサイズに縮小(大きすぎると重くなる)
    • position: わずかに前面に出して重なりを防止
    • distanceFactor: カメラからの距離による大きさ調整
    • center: コンテンツを中央揃え
       
  3. アニメーションと状態管理
    • isRevealed: カードの表示状態を管理
    • isContentVisible: コンテンツの表示タイミングを制御
    • クリック時の段階的なアニメーション

3. 回転のロジック

スムーズな回転とカードの整列を実現する実装について説明します。ドラッグやタッチ操作による回転と、離した後の正面に一番近いカードが正面に整形される自動整列を組み合わせています。

// 角度の正規化(-π から π の範囲に収める)
const normalizeAngle = (angle: number) => {
  let normalized = angle % (Math.PI * 2);
  if (normalized > Math.PI) {
    normalized -= Math.PI * 2;
  } else if (normalized < -Math.PI) {
    normalized += Math.PI * 2;
  }
  return normalized;
};

// 最短回転角の計算(これは、正面時自動整列させる際に1→10枚目の時に1,2,3,...10となり遠回りして戻ってしまう現象の為)
const getShortestRotation = (current: number, target: number) => {
  const diff = normalizeAngle(target - current);
  if (diff > Math.PI) {
    return diff - Math.PI * 2;
  } else if (diff < -Math.PI) {
    return diff + Math.PI * 2;
  }
  return diff;
};

// 最も近いカードのインデックスを取得
const getNearestCardIndex = () => {
  if (!groupRef.current) return 0;

  const currentRotation = normalizeAngle(groupRef.current.rotation.y);
  const cardCount = blogs.length;
  const anglePerCard = (Math.PI * 2) / cardCount;

  let nearestIndex = Math.round(-currentRotation / anglePerCard);
  while (nearestIndex < 0) nearestIndex += cardCount;
  return nearestIndex % cardCount;
};

// 最も近いカードへの整列
const alignToNearestCard = () => {
  if (isAutoRotating.current || isDragging) return;

  const nearestIndex = getNearestCardIndex();
  const anglePerCard = (Math.PI * 2) / blogs.length;
  const targetAngle = normalizeAngle(-nearestIndex * anglePerCard);
  const currentRotation = normalizeAngle(groupRef.current?.rotation.y || 0);
  const shortestRotation = getShortestRotation(currentRotation, targetAngle);
  setTargetRotation(currentRotation + shortestRotation);
  isAutoRotating.current = true;
};

// アニメーションフレームでの回転更新
useFrame(() => {
  if (groupRef.current) {
    const currentRotation = normalizeAngle(groupRef.current.rotation.y);
    const normalizedTarget = normalizeAngle(targetRotation);
    const rotationDiff = getShortestRotation(currentRotation, normalizedTarget);

    if (Math.abs(rotationDiff) > 0.001) {
      const speed = isDragging ? 0.05 : 0.1;
      groupRef.current.rotation.y += rotationDiff * speed;
      lastMoveTime.current = Date.now();
      setIsMoving(true);
    } else {
      setIsMoving(false);
      isAutoRotating.current = false;

      if (!isDragging && Date.now() - lastMoveTime.current > 300) {
        alignToNearestCard();
      }
    }
  }
});

実装のポイント

  1. 角度の管理
    • normalizeAngle: 角度を-πからπの範囲に正規化
    • getShortestRotation: 現在位置から目標位置までの最短回転角を計算
    • これにより、常に最短経路で回転(想定外の逆回転を防ぐ)
       
  2. カードの整列
    • getNearestCardIndex: 現在の回転角から最も近いカードを特定
    • alignToNearestCard: 最も近いカードに向けて自動回転
    • 回転中やドラッグ中は整列処理をスキップ
       
  3. アニメーション制御
    • useFrame: 毎フレーム回転状態を更新
    • ドラッグ中は遅め、自動回転は速めの速度設定
    • 微小な差分(0.001)以下になったら回転停止
    • 一定時間(300ms)動きがない場合に整列開始(遅いと感じたら縮める)
       
  4. 状態管理
    • isDragging: ドラッグ操作の状態
    • isMoving: 回転アニメーション中の状態
    • isAutoRotating: 自動整列中の状態
    • これらの状態に応じて適切な処理を実行

まとめ

Three.jsとReactを組み合わせて色々頑張ると、以下のようなランダム記事カードギャラリーが実現できました

  1. 視覚的な特徴
    • 円形に配置された3Dカード(ポケポケみたい)
    • 物理ベースのマテリアルによる高品質な表示(回転時に画質が劣化してしまう問題も途中起きた)
    • HTMLコンテンツの3D表示
       
  2. インタラクション
    • スムーズな回転アニメーション
    • 直感的なドラッグ操作
    • 離した後の自動整列
    • クリックによるカードの展開アニメーション
       
  3. 技術的な特徴
    • Three.jsの3D機能とReactのコンポーネント管理の統合
    • パフォーマンスを考慮した実装
    • 複雑な回転計算の最適化

実装のポイントは、分解して一つ一つ実装していくことでした。

  1. ランダムに10記事を選ぶ
  2. 円形に並べる
  3. ドラッグで動かせるようにする
  4. 止まった時に最も正面に近いやつを検知して正面に持ってくる
  5. 他に起きている微妙な挙動を調整する

特に回転の処理では、角度の正規化や最短経路の計算が重要なポイントでした。AIに聞きながらなんとか出来ました。また、memo化や様々な状態を管理したりで、個人的にスムーズなユーザー体験を目指しました。

まとめ

改めて便利なツールに囲まれているなという実感

  • AI
    わざわざ言うことでもありませんが、こういうあまり何も考えず作りたい時にポンと画像を出力してくれたりと、なんとなくいい感じのフリー画像を探す時間が必要ないと言うのはストレスフリーでした。
     
  • microCMS
    無料で3個まで作れて、少し前からテンプレートなども使用してクイックに使えるようになったり日本製ということもあり日本語のわかりやすいドキュメントも充実していたりとheadlessCMS初心者にはとてもありがたいです
     
  • 色んなジェネレータ
    shadow系などの値を多く設定しなくていけないCSSを使う時などもお世話になりますが、今回もお世話になりました

作りたい動き起点の勉強

今回はポケポケのデザインとパック開封の挙動起点で色々作り直したり作ったりしましたが、結論から言うと結構楽しかったです。別に解決したい課題とかないけどもデザインや技術自体が目的で開発するのもいい時間だなと改めて思いました。

今後も追加したい機能や動きがあれば実験する場所として使っていこうと思います。

最後までお読みいただきありがとうございました。🙇

完成品

◾️ ----------------------- ◾️

◾️ ----------------------- ◾️

参考にした記事やページ

9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?