[Astro #14] Copland OS風UIの極北:カスタムアナログ時計とコンテキストメニューの実装
はじめに
前回の記事([Astro #13] マジックリンク認証の実装)でシステムのセキュリティ基盤を構築しましたが、今回は「UIのOS化」がテーマです。
標準的なWebサイトのレイアウト(左にロゴ、右にメニュー)から脱却し、『Serial Experiments Lain』のCopland OSのような、無機質でシステムチックなインターフェースを目指しました。その集大成として、Hugo時代から構想していた「カスタム可能なアナログ時計」をコンポーネント化して実装しました。
前回の記事:
[Astro #13] Astro SSR × Resend × JWT で作るDB不要の「マジックリンク認証」 // PROTOCOL.LAIN
総当たり攻撃の脅威から個人ダッシュボードを守るため、AstroとResendを組み合わせてパスワードレス認証(マジックリンク)を導入する具体的な実装手順とハマりどころを解説します。
lain-lab.comYoutube:
[Astro] PROTOCOL: CLOCK // Custom Lain HUD
INITIALIZING PROTOCOL: CLOCKA custom HUD-style analog clock component inspired by Serial Experiments Lain (Copland OS). Built with Astro.js and pure Vanilla ...
www.youtube.comスクリーンショット:
※画面一杯になるフルサイズ機能も追加
1. ヘッダーUIのシステム化
「とってつけたようなWebサイト感」を払拭し、空間に浮かぶ「システムコマンドバー」としての質感を追求しました。
透過とブラー:空間への同調
標準的な黒い帯(ヘッダー)を排除し、background を上部から下部へ抜けるグラデーションに変更。さらに backdrop-filter: blur(5px) を適用しました。
これにより、背後で常に動いているレイマーチングのノイズや歪みをうっすらと透過させつつ、文字の視認性を確保。UIが「上に乗っている」のではなく「空間に溶け込んでいる」状態を作り出しました。
コマンド風リンク:カーソル挙動の再現
メニュー項目は単なるテキストリンクではなく、ターミナルの選択画面をイメージしています。
- ブラケット装飾: 各項目を
[TOP]のようにブラケットで囲むことで、OSの実行コマンドのような趣に。 - 左サイドボーダー: ホバー時、またはアクティブ(現在地)の時にのみ、左側に 1px の発光ラインを表示。これは古いPCモニターの選択カーソルをオマージュしており、クリックできる「実体」をユーザーに直感させます。
技術的メモ:
全体のスキャンライン・オーバーレイ(走査線)と干渉しないよう、z-indexを1000番台に設定。同時に
pointer-events: autoを明示し、背後のWebXRコンテンツへの干渉を防ぎつつ、UIとしてのクリック精度を高めています。
2. AnalogClock.astro の実装
右上に配置するアナログ時計を <AnalogClock /> として独立したコンポーネントで作成しました。単なる時計ではなく、以下の機能を持たせています。
SVGによるHUD風デザイン
標準的な時計に見えることを避け、軍用機のHUD(ヘッドアップディスプレイ)のような鋭い質感を追求しました。
- ブレード・クロック: 針を単なる
<line>ではなく、<polygon>で描画。根元を太く、先端を鋭利な形状にすることで、デジタル空間を切り裂くような「ブレード(刃)」のデザインを採用しました。 - ホログラム・マスコット: 背景にはAI(Nano Banana 2)で生成した「熊の着ぐるみを着たLain」のちびキャラを配置。サイバーなグリッド背景と透過デザインを組み合わせることで、時計の中にLainが潜んでいるような、怪しくも愛らしい「ワイヤードの守護精霊」感を演出しています。
右クリック&長押し対応のコンテキストメニュー
「自分のマシンをいじっている」感覚を重視し、ブラウザ標準の右クリックメニューを e.preventDefault() で封印。代わりにサイズ変更(Small/Large)やテーマ切り替え(Wired/Terminal)が可能な独自メニューを実装しました。
モバイル環境でもこの体験を損なわないよう、touchstart を利用したロングプレス(0.6秒)判定を導入しています。
// スマホ用:長押しでメニューを出す実装
let touchTimer;
clock.addEventListener('touchstart', (e) => {
touchTimer = setTimeout(() => {
const touch = e.touches[0];
showMenu(touch.clientX, touch.clientY);
}, 600); // 0.6秒の長押しをトリガーに設定
});
clock.addEventListener('touchend', () => clearTimeout(touchTimer));
clock.addEventListener('touchmove', () => clearTimeout(touchTimer));
遭遇したバグ:filter が作る「座標の檻」
開発中、最も不可解だったのが「メニューが画面外へ飛んでいく」挙動でした。メニューを position: fixed にし、clientX で座標を指定しているにもかかわらず、時計の枠から大きく右にズレて出現したのです。
原因は CSS のスタック文脈の仕様でした。
親要素(時計のコンテナ)に filter: drop-shadow() を適用していたため、その要素が position: fixed の新しい包含ブロック(基準点)になってしまっていたのです。
- 解決策: メニューのHTMLを時計のコンテナ
divの外(兄弟要素)に追い出すことで、基準点を正しく Viewport(画面全体)に戻し、マウス位置への正確な表示を実現しました。
3. localStorage によるパーソナライズ
「自分だけの端末感」を出すため、メニューで変更したサイズ(Small/Large)やテーマカラー(Wired/Terminal)を localStorage に保存し、ページリロード時にも設定が維持されるようにしました。
「記憶」の同期プロトコル
一度設定した色やサイズはブラウザの localStorage に書き込まれます。これにより、次に「ワイヤード」に接続した際も、前回のカスタマイズが即座に呼び出されます。
// テーマの適用と保存のロジック
function applyTheme(theme) {
// 緑系の Wired か、シアン系の Terminal かを選択
const color = theme === 'wired' ? '#00ff88' : '#00e5ff';
// CSS変数を書き換えることで、時計とメニューの色を一括制御
clock.style.setProperty('--copland-blue', color);
menu.style.setProperty('--copland-blue', color);
}
// カスタムメニューのクリックイベント内
if (action.startsWith('theme-')) {
const theme = action.replace('theme-', '');
applyTheme(theme);
// ここでローカルストレージに保存。リロードの壁を超える
localStorage.setItem('clock-theme', theme);
}
実装のポイント
- 起動時の初期化: ページロード時に
localStorage.getItemを呼び出し、保存された設定があればそれを適用、なければデフォルト値をセットする初期化関数(loadSettings)を走らせています。 - CSS変数との連携: JSで個々の要素のスタイルをいじるのではなく、CSS変数(カスタムプロパティ)を上書きすることで、コードを肥大化させずにデザインの統一性を保っています。
単なる「時計の表示」から、ユーザーが介入し、その痕跡を残せる「システムの一部」へと進化させることができました。
4. シーン切り替え時の表示制御(没入感の維持)
この時計とアップデート情報のモーダルは、メインの入り口である「最初のページ(PROJECT_LAIN)」でのみその姿を現します。NEXTボタンで他のWebXR作品へと遷移した際には、システムが自動的にこれらを「バックグラウンド」へと退避させます。
ギャラリーを邪魔しないスマートUI
WebXR作品(Audio-Reactive Terrainなど)の中には、独自のオーディオアイコンやVRボタンが右下に出現するものがあります。ここに常駐UIが重なってしまうと、視覚的なノイズになるだけでなく、操作性も著しく低下します。
そこで、AstroのView Transitions(astro:page-load)内で発火する updateScene 関数に、動的なUI管理ロジックを組み込みました。
- PROJECT_LAIN (index: 0): 時計、更新モーダル、呼び出しボタンのすべてを
display: blockまたはアクティブ状態に。 - Other Scenes: すべてを
display: noneに。これにより、ユーザーはノイズのない純粋な3D空間へと没入できます。
// updateScene 関数内での動的UI制御
function updateScene(index) {
// ...シーンのsrc更新処理など...
const clock = document.getElementById('clock-container');
const activityWindow = document.getElementById('update-modal');
const mobileBtn = document.getElementById('update-toggle-btn');
if (index === 0) {
if (clock) clock.style.display = 'block'; // 最初のページのみ時計を表示
if (mobileBtn) mobileBtn.classList.add('is-active-scene');
} else {
if (clock) clock.style.display = 'none'; // 他の作品では非表示
if (mobileBtn) {
mobileBtn.classList.remove('is-active-scene');
modal?.classList.remove('is-active'); // 開いていたモーダルも強制終了
}
}
}
COMM_LOG: astro-14-copland-os-ui-analog-clock