[Astro] #149 Copland OS風スタートメニューの実装 — JSON駆動型多段カスケードメニューとカスタムイベント連携
はじめに
制作してきた 3D ツール・画像処理・音声プレイヤー・データ解析ツール等の Web アプリケーションが増えてきたため、画面下部のフッター領域にレトロ OS(Copland OS v7.2.1)風の多段カスケードメニューを導入しました。
メニュー構造は start_menu.json として完全に外部データ化し、Astro コンポーネント側で動的に描画しています。直リンク遷移だけでなく、ターミナル起動やファイルマネージャー表示といったページ内アクションも統一的に扱える構造に仕立てました。
| モジュール / 機能 | 内容 |
|---|---|
| JSON DATA DRIVEN | start_menu.json によるカテゴリ・アプリ・ツールの集中管理 |
| CASCADE SUBMENU | 2段・3段のホバー追従型ネストメニュー描画 |
| ACTION ROUTING | 直リンク(url)・アプリ単体(app)・コマンド発火(action)の自動判定 |
| VIEWPORT FIT | サブメニューが画面下端を超えそうな場合の動的 Y 座標調整 |
| EVENT BUS | CustomEvent を使った WiredTerminal / WiredWindowManager との疎結合連携 |
スクリーンショット
3D & CAD カテゴリ展開
VRM & MMD & FBX カテゴリ展開
動画(GIf)
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 アクションなど)の対応アプリを追加し、検索機能付きのクイックランチャーへ拡張していく予定です。