[Astro] #149 Copland OS風スタートメニューの実装 — JSON駆動型多段カスケードメニューとカスタムイベント連携

[Astro] #149 Copland OS風スタートメニューの実装 — JSON駆動型多段カスケードメニューとカスタムイベント連携

はじめに

制作してきた 3D ツール・画像処理・音声プレイヤー・データ解析ツール等の Web アプリケーションが増えてきたため、画面下部のフッター領域にレトロ OS(Copland OS v7.2.1)風の多段カスケードメニューを導入しました。

メニュー構造は start_menu.json として完全に外部データ化し、Astro コンポーネント側で動的に描画しています。直リンク遷移だけでなく、ターミナル起動やファイルマネージャー表示といったページ内アクションも統一的に扱える構造に仕立てました。

モジュール / 機能内容
JSON DATA DRIVENstart_menu.json によるカテゴリ・アプリ・ツールの集中管理
CASCADE SUBMENU2段・3段のホバー追従型ネストメニュー描画
ACTION ROUTING直リンク(url)・アプリ単体(app)・コマンド発火(action)の自動判定
VIEWPORT FITサブメニューが画面下端を超えそうな場合の動的 Y 座標調整
EVENT BUSCustomEvent を使った WiredTerminal / WiredWindowManager との疎結合連携

スクリーンショット

3D & CAD カテゴリ展開

3D & CAD メニュー展開

VRM & MMD & FBX カテゴリ展開

VRM & MMD & FBX メニュー展開

動画(GIf)

3D & CAD メニュー展開

1. データ構造の設計 (start_menu.json)

メニュー項目はカテゴリごとの階層構造を持つ JSON 配列として定義します。

[
  {
    "title": "3D & CAD",
    "icon": "📐",
    "children": [
      {
        "title": "Involute Gear Generator",
        "icon": "⚙",
        "app": "/gear-generator",
        "url": "/featured/gear-generator"
      },
      {
        "title": "DXF 3D Viewer",
        "icon": "📐",
        "app": "/dxf-viewer",
        "url": "/featured/dxf-3d-viewer/"
      }
    ]
  },
  {
    "divider": true
  },
  {
    "title": "Open Terminal",
    "icon": "💻",
    "action": "ssh"
  }
]

要素のプロパティによって動作が自動的に切り替わるように設計しています。

  • url のみ: ドキュメント・解説記事への遷移
  • app のみ: Web アプリ自体の起動
  • app と url の両方: サブメニューとして「🚀 Launch App」「📖 Documentation」を自動展開
  • action: JS側でイベントを発火するコマンド(ssh でターミナル、fm でファイルマネージャー等)
  • divider: 区切り線を表示

2. Astro でのカスケードメニュー描画

Footer.astro 内で JSON データをマップし、has-submenu クラスを条件分岐させながらネスト構造を組んでいます。

---
import menuData from '../data/start_menu.json';

interface MenuItem {
  title?: string;
  icon?: string;
  url?: string;
  app?: string;
  action?: string;
  divider?: boolean;
  children?: MenuItem[];
}
---

<ul class="menu-root">
  {menuData.map((item: MenuItem) => {
    if (item.divider) {
      return <li class="menu-divider" />;
    }

    const hasChildren = item.children && item.children.length > 0;

    return (
      <li class={hasChildren ? 'has-submenu' : ''}>
        {hasChildren ? (
          <>
            <span class="menu-item-label">
              <span class="item-icon">{item.icon}</span> {item.title}
              <span class="arrow">▶</span>
            </span>
            <ul class="submenu">
              {item.children?.map((child: MenuItem) => {
                const childHasAppAndUrl = child.app && child.url;
                const grandChildren = childHasAppAndUrl
                  ? [
                      { title: "🚀 Launch App", url: child.app },
                      { title: "📖 Documentation", url: child.url }
                    ]
                  : child.children;

                const childHasSub = grandChildren && grandChildren.length > 0;

                return (
                  <li class={childHasSub ? 'has-submenu' : ''}>
                    {/* 孫要素の展開ロジック */}
                  </li>
                );
              })}
            </ul>
          </>
        ) : (
          <a href={item.url || item.app || 'javascript:void(0);'} data-action={item.action}>
            <span class="menu-item-label">
              <span class="item-icon">{item.icon}</span> {item.title}
            </span>
          </a>
        )}
      </li>
    );
  })}
</ul>

3. ハマりポイント:href=# によるリロード・ハッシュ更新問題

開発初期、アクション系メニュー(action: "ssh" など)の <a> タグの href にフォールバックとして "#" を指定していました。

<!-- 🚫 問題が発生するパターン -->
<a href="#" data-action="ssh">Open Terminal</a>

この状態だと、クリック時に JavaScript 側で preventDefault() を呼んでいても、ブラウザのリンク処理やクライアントルーター(Astro の ClientRouter)の影響で URL の末尾に # が付与されてハッシュ遷移が発生し、画面が一瞬リロードされたような挙動や WebGL コンテキストの喪失(Context Lost)を引き起こします。

解決策

フォールバック先を "javascript:void(0);" に変更し、ブラウザの標準アンカー動作を完全に無効化しました。

<!-- ⭕ 修正後 -->
<a href={item.url || item.app || 'javascript:void(0);'} data-action={item.action}>
  <span class="menu-item-label">
    <span class="item-icon">{item.icon}</span> {item.title}
  </span>
</a>

4. サブメニューの画面外はみ出し防止 (Y座標自動補正)

画面下部のフッターからメニューが上に伸びる構造のため、アイテム数が多いカテゴリのサブメニューは画面下側に突き抜けてしまう問題が生じます。

mouseenter 時に getBoundingClientRect() でビューポートとの位置関係を計測し、溢れる分だけ translateY で上に持ち上げる処理を追加しました。

const submenuItems = document.querySelectorAll('.has-submenu');

submenuItems.forEach(item => {
  const submenu = item.querySelector('.submenu') as HTMLElement;
  if (!submenu) return;

  item.addEventListener('mouseenter', () => {
    // 初期化
    submenu.style.transform = 'translateY(0)';

    const itemRect = item.getBoundingClientRect();
    const submenuHeight = submenu.offsetHeight;
    const viewportHeight = window.innerHeight;

    // 画面下端から溢れる量(マージン20px)を算出
    const overflowBottom = (itemRect.top + submenuHeight) - (viewportHeight - 20);

    if (overflowBottom > 0) {
      submenu.style.transform = `translateY(-${overflowBottom}px)`;
    }
  });
});

5. イベント駆動(CustomEvent による他コンポーネント連携)

メニューで data-action を持った項目が叩かれた際、フッターコンポーネント自身が各機能の実装を抱え込むのではなく、グローバルな CustomEvent を発行して他コンポーネントへ非同期に通知します。

document.addEventListener('click', (e) => {
  const target = (e.target as HTMLElement).closest('[data-action]');
  if (!target) return;

  const action = (target as HTMLElement).dataset.action;
  if (!action) return;

  e.preventDefault();

  if (action === 'terminal') {
    const terminalInput = document.querySelector('.terminal-input') as HTMLInputElement;
    if (terminalInput) terminalInput.focus();
  } else {
    // カスタムイベントを発行して外部に通知
    window.dispatchEvent(
      new CustomEvent('wired-remote-command', {
        detail: action
      })
    );
  }

  // メニューを閉じる
  const menu = document.getElementById('start-menu');
  if (menu) menu.classList.remove('active');
});

受信側の React コンポーネント(WiredWindowManager や WiredTerminal)では、このイベントをリスンして目的の擬似ウィンドウ(SSH、ファイルマネージャー、ゲーム等)を開きます。

// WiredWindowManager.tsx などの受信側コード例
useEffect(() => {
  const handleRemoteCommand = (e: CustomEvent) => {
    const cmd = e.detail;
    if (cmd === 'ssh') setSshOpen(true);
    if (cmd === 'fm') setFmOpen(true);
    if (cmd === 'game') setArcadeOpen(true);
  };

  window.addEventListener('wired-remote-command', handleRemoteCommand as EventListener);
  return () => window.removeEventListener('wired-remote-command', handleRemoteCommand as EventListener);
}, []);

6. まとめ

  • データの一元管理 — 今後Webアプリが増えても start_menu.json に項目を追加するだけで即座にUIに反映される
  • 直感的な分類 — 3D & CAD, VRM, Live2D, Audio などのカテゴリごとに整理されアクセス性が大幅に向上
  • アンカータグの安全策 — JavaScript 制御のリンクには href="javascript:void(0);" を指定し、不用意なハッシュ変更やリロードを回避
  • 疎結合な設計 — CustomEvent を介した連携により、Astro の静的 UI と React のインタラクティブ要素を綺麗に分離

今後はゲームカテゴリ(WebXR / VRM アクションなど)の対応アプリを追加し、検索機能付きのクイックランチャーへ拡張していく予定です。