[Astro #14] Copland OS風UIの極北:カスタムアナログ時計とコンテキストメニューの実装

[Astro #14] Copland OS風UIの極北:カスタムアナログ時計とコンテキストメニューの実装

はじめに

前回の記事([Astro #13] マジックリンク認証の実装)でシステムのセキュリティ基盤を構築しましたが、今回は「UIのOS化」がテーマです。

標準的なWebサイトのレイアウト(左にロゴ、右にメニュー)から脱却し、『Serial Experiments Lain』のCopland OSのような、無機質でシステムチックなインターフェースを目指しました。その集大成として、Hugo時代から構想していた「カスタム可能なアナログ時計」をコンポーネント化して実装しました。

前回の記事:

Youtube:

スクリーンショット:

[Astro #14] Copland OS風UIの極北:カスタムアナログ時計とコンテキストメニューの実装 1

※画面一杯になるフルサイズ機能も追加

[Astro #14] Copland OS風UIの極北:カスタムアナログ時計とコンテキストメニューの実装 2

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

NO DATA FOUND IN THIS SECTOR.