乃木坂の神曲をWordcloud風にアニメーション化してみた

元ネタはこちら↓

https://qiita.com/riicon72/items/81d943c6cdb3ccb1b174


# なぜ行おうとしたか
元ネタでは、単なる静的なWordCloudでは、見た目にインパクトが少ないし、読者の目もすぐに離れてしまいがちです（viewsの数が少なかった😂）
動きがあれば注目を集めやすく、より楽しんでもらえるのではないかと思いました（安易ですが）

# 結果

![これ_compressed (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636287/79d46131-dde7-47e0-8bdc-873f2c2b83dc.gif)

YouTube short こちらの方が見やすいです↓
[![YouTube動画](https://img.youtube.com/vi/5j0cSnCJGk4/hqdefault.jpg)](https://youtube.com/shorts/5j0cSnCJGk4?feature=share)



……でも、むずかしかったー！
いやほんと、簡単に動かせると思ってたんですが、甘かったです

:::note warn
• WordCloud自体にアニメーション機能がない
• Python以外でやるなら？ 何を使えば？
• 歌詞の単語数が多すぎて、何が何だかわからない
:::


などなど、細かい壁に何度もぶつかりました。
特にアニメーションの自然な見せ方には、かなりの試行錯誤が必要でした


# アニメーションの方法と比較
WordCloudでできないとなった時に、ChatGPTに聞いてみました
| 方法名           | 必要環境               | 動きの自由度       | 難易度   |
| ------------- | ------------------ | ------------ | ----- |
| Streamlit更新型  | Python + Streamlit | 低〜中（見た目の更新）  | 簡単    |
| matplotlibアニメ | Pythonのみ           | 中（サイズや色変化）   | 中     |
| d3-cloud (JS) | ブラウザ + JavaScript  | 高（自由自在に動かせる） | 少し難しい |


# 実際の作業の流れ
で、なにを採用するか？
JavaScript を選択しましたが、ちょっと変更しました
もーっと簡単にしました


## 1. 形態素解析で単語の頻度を抽出しJSONファイルにする（Python）

### ✅ポイント

① 出現回数が多い上位30語を抽出する
アニメーションで可視化する際、単語数が多すぎると全体がごちゃごちゃして見づらくなります　　そのため、出現頻度の高い単語だけを抜き出します
```
most_common_words = global_counter.most_common(30)
```
→ 上位30語を取得



② JavaScriptで使える形式に変換して保存

[["単語1", 出現数1], ["単語2", 出現数2], ...] の形式に変換
JSONファイルとして保存（JavaScriptでWordCloudに使える）
```
with open("自分のPATHーーここ変更ーー/wordcloud_data.json", "w", encoding="utf-8") as f:
    json.dump(word_data, f, ensure_ascii=False, indent=2)
```

## 全体コード①

<details><summary>全体コードはこちら↓</summary>

```python:python

import pandas as pd
from janome.tokenizer import Tokenizer
from collections import Counter
import json

# ファイルパス
csv_path = '自分のCSVーここ変更ーー'
df = pd.read_csv(csv_path)

# 形態素解析器
tokenizer = Tokenizer()

# 抽出する品詞
target_pos = ("名詞", "動詞", "形容詞")

# 全体の単語カウント
global_counter = Counter()

# 各行の処理
for _, row in df.iterrows():
    lyric = str(row.get("歌詞", "")).strip()
    if not lyric:
        continue

    words = [
        token.base_form for token in tokenizer.tokenize(lyric)
        if any(token.part_of_speech.startswith(pos) for pos in target_pos)
    ]
    global_counter.update(words)

# 上位N単語を選ぶ（例：上位30語）
most_common_words = global_counter.most_common(30)

# JavaScript用に変換
word_data = [[word, freq] for word, freq in most_common_words]

# JSONとして保存
with open("自分のPATHーーここ変更ーー/wordcloud_data.json", "w", encoding="utf-8") as f:
    json.dump(word_data, f, ensure_ascii=False, indent=2)

print("✅ wordcloud_data.json を出力しました。")

```
</details>


# 2. VScode内でJSONファイルをJavaScriptで読み込み 

## ✅ポイント
① VScodeのフォルダ構成

```
nogizaka-wordcloud/
├── index.html
└── wordcloud_data.json
```


② ローカルサーバーで表示する方法（推奨）
　ローカルで .json ファイルを読み込むには、ローカルサーバーを使う必要があります。以下の方法をおすすめします
 
:::note info
方法①：VSCodeの拡張機能「Live Server」を使う（簡単）
左の「拡張機能（四つの四角アイコン）」をクリック
「Live Server」と検索して、Ritwick Dey 作のものをインストール
index.html を開いた状態で右下の「Go Live」をクリック
:::
👉 これでブラウザが自動で開いて、アニメーションが表示されるはずです。

## 全体コード（仮index.html）

```
[
  ["Happy", 6],
  ["君", 4],
  ["行く", 6],
  ["どこ", 3],
  ["何", 2]
]
```

## 全体コード（wordcloud_data.json）


<details><summary>全体コードはこちら↓</summary>

```javascript:javascript

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>乃木坂46 WordCloud風アニメーション</title>
  <style>
    body {
      font-family: 'Hiragino Maru Gothic Pro', 'Meiryo', sans-serif;
      background-color: #f4f4f4;
      text-align: center;
      margin: 20px;
    }
    h2 {
      font-size: 28px;
      margin-bottom: 8px;
    }
    .search-container {
      margin-bottom: 20px;
    }
    #search-box {
      font-size: 16px;
      padding: 6px 12px;
      width: 250px;
      border: 1px solid #ccc;
      border-radius: 6px;
    }
    canvas {
      border: 1px solid #ccc;
      cursor: default;
    }
  </style>
</head>
<body>

  <h2>乃木坂46</h2>
  <div class="search-container">
    <input type="text" id="search-box" placeholder="単語を検索..." />
  </div>
  <canvas id="cloud" width="360" height="500"></canvas> <!-- 9:16の比率を維持 -->

  <script>
    const searchBox = document.getElementById('search-box');
    const canvas = document.getElementById('cloud');
    const ctx = canvas.getContext('2d');

    let words = [];
    let hoverWord = null;
    let mouseX = 0;
    let mouseY = 0;

    fetch('wordcloud_data.json')
      .then(response => response.json())
      .then(data => {
        words = data.map(([text, freq]) => {
          const size = 20 + freq * 5;
          return {
            text,
            freq,
            size,
            x: Math.random() * (canvas.width - 100) + 50,
            y: Math.random() * (canvas.height - 100) + 50,
            dx: (Math.random() - 0.5) * 0.3,
            dy: (Math.random() - 0.5) * 0.3,
            match: false
          };
        });

        animate();
      });

    searchBox.addEventListener('input', () => {
      const query = searchBox.value.trim();
      words.forEach(w => {
        w.match = query !== "" && w.text.includes(query);
      });
    });

    function animate() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      hoverWord = null;

      for (const w of words) {
        const scale = w.match ? 1.6 : 1;
        const fontSize = w.size * scale;

        w.x += w.dx;
        w.y += w.dy;

        const halfWidth = ctx.measureText(w.text).width / 2;
        const halfHeight = fontSize / 2;

        if (w.x - halfWidth < 0 || w.x + halfWidth > canvas.width) w.dx *= -1;
        if (w.y - halfHeight < 0 || w.y + halfHeight > canvas.height) w.dy *= -1;

        ctx.font = ${fontSize}px 'Hiragino Maru Gothic Pro', 'Meiryo', sans-serif;
        ctx.fillStyle = w.match ? 'red' : hsl(${(w.freq * 30) % 360}, 60%, 40%);
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(w.text, w.x, w.y);

        const textWidth = ctx.measureText(w.text).width;
        if (
          mouseX >= w.x - textWidth / 2 &&
          mouseX <= w.x + textWidth / 2 &&
          mouseY >= w.y - fontSize / 2 &&
          mouseY <= w.y + fontSize / 2
        ) {
          hoverWord = w;
        }
      }

      if (hoverWord) {
        const tipText = ${hoverWord.text}: ${hoverWord.freq}回;
        ctx.font = "14px sans-serif";
        ctx.fillStyle = "#000";
        ctx.fillText(tipText, mouseX + 15, mouseY + 26);
      }

      requestAnimationFrame(animate);
    }

    canvas.addEventListener('mousemove', e => {
      const rect = canvas.getBoundingClientRect();
      mouseX = e.clientX - rect.left;
      mouseY = e.clientY - rect.top;
    });
  </script>

</body>
</html>　　

```
</details>

##

① fetch('wordcloud_data.json') の使い方（データの読み込み）
　外部ファイル wordcloud_data.json を非同期で読み込んで、中の単語データを words に格納する処理です
```
fetch('wordcloud_data.json')
  .then(response => response.json())
  .then(data => {
    words = data.map(([text, freq]) => {
      // ...
    });
    animate();
  });
```
② animate() 関数によるアニメーション制御
　この関数が毎フレーム呼ばれて、単語が動くようになっています
w.x += w.dx のように、各単語をちょっとずつ動かしていて、画面の端に当たったら跳ね返ります

```
function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // ... 各単語の移動、描画、当たり判定など
  requestAnimationFrame(animate);
}
```


# おわりに

最初は「ちょっと動かせたら面白いかも」くらいの軽い気持ちだったのですが、実際に作ってみると、細かい部分でかなりハマりました
少しでも面白いと思っていただけたら嬉しいです✨


:::note info
今回は 齋藤飛鳥ちゃん推し ということで、あすかちゃんがセンターを務めた以下の4曲を対象にしました：

• ここにはないもの
• 裸足でSummer
• ジコチューで行こう！
• Sing Out!

これらの歌詞を形態素解析し、出現回数が多かった上位30語 をアニメーションで可視化しています👀
:::