[Astro #15] Copland OS: SVG clock 無段階サイズ変更と、SPA特有のバグを攻略

[Astro #15] Copland OS: SVG clock 無段階サイズ変更と、SPA特有のバグを攻略

はじめに

前回の記事で実装したCopland OS風のアナログ時計。
とりあえず動く「v1.0」としては十分でしたが、自分のNAVI(デスクトップ)に常駐させるUIとしては、まだいくらかの窮屈さと「脆さ」が残っていました。

今回は、UIの自由度を解放する無段階サイズ変更スライダーの実装と、AstroやNext.jsのようなSPA(Single Page Application)環境で避けては通れない「ページ遷移時のJSバグ」を徹底的にクリーンアップした、v1.10.x系へのアップデートの軌跡をまとめます。

ワイヤードを渡り歩いても決して壊れない、真の「遍在性」を持ったガジェットへ進化させます。

前回の記事:

スクリーンショット:

右クリックメニューに「HIDE」非表示を実装。

[Astro #15] Copland OS: SVG clock 右クリックメニューに「HIDE」非表示を実装。

非表示後は、画面の右隅にCLOCKタブを表示し、タブをクリックで再度時計を表示します。
タブはドラッグで上下に移動可能。

[Astro #15] Copland OS: SVG clock 非表示後は、画面の右隅にCLOCKタブを表示し、タブをクリックで再度時計を表示します。

更にサイズ変更スライダーを実装して、サイズを細かく調整できるようにしました。

[Astro #15] Copland OS: SVG clock サイズ変更スライダーを実装して、サイズを細かく調整

1. UIの進化:固定サイズからの解放

これまでは右クリックメニューから「Small / Large」といった固定サイズを選ぶ方式でしたが、これではユーザー(自分)のディスプレイ環境や気分に完璧にフィットさせることができません。

そこで、80px〜500pxまでの無段階スライダーを右クリックメニュー内に直接埋め込むUXへと刷新しました。

画面外への「はみ出し」を防ぐ動的計算

ただスライダーを置くだけでは、巨大化させた時に画面外へ見切れてしまいます。そこで、メニューを開くたびに「現在の画面サイズの短い方」を基準にして、スライダーの最大値を動的に制限する処理を入れました。

function updateSliderRange() {
  // 画面の短い方の 90% を最大値にする
  const maxSafeSize = Math.floor(Math.min(window.innerWidth, window.innerHeight) * 0.9);
  sizeSlider.max = maxSafeSize.toString();
}

これで、スマホだろうとウルトラワイドモニターだろうと、常に安全な範囲内で最大限のスケールアウトが可能になります。


2. 「黙認していた不具合」との決別

ここからが今回の本題です。長らく見て見ぬ振りをしていた「ページを切り替えて戻ってくると、時計が操作不能になったりエラーを吐く」という現象。これはSPA特有の罠でした。

幽霊アニメーション問題(メモリリーク)

ページ遷移によって時計のDOM要素が消滅しているにもかかわらず、裏側では requestAnimationFrame による針の回転アニメーションが永遠に走り続け、null エラーをコンソールに吐き続けていました。

解決策: 要素が存在しない場合はループを断ち切る「ガード句」を入れ、アニメーションを成仏させます。

function updateClock() {
  const secondEl = document.getElementById('second');
  const minuteEl = document.getElementById('minute');
  const hourEl   = document.getElementById('hour');

  // 全ての要素が存在するかチェック(欠けていたら即終了し、次フレームを呼ばない)
  if (!secondEl || !minuteEl || !hourEl) return;

  /* ... 針の角度計算と適用 ... */

  requestAnimationFrame(updateClock);
}

イベントリスナー増殖問題

SPA環境では window オブジェクトがリセットされません。そのため、ページを行き来するたびに window.addEventListener('mousemove', ...) が重複して登録され、新旧のドラッグ処理が干渉して時計がフリーズしていました。

解決策: 登録前に必ず古いリスナーを剥がす(クリーンアップする)初期化関数を作ります。

// 匿名関数ではなく、名前付き関数として定義(後で剥がせるようにするため)
const onMouseMove = (e: MouseEvent | TouchEvent) => { doDrag(e); };
const onMouseUp = () => { endDrag(); };

function initEvents() {
  // 1. 二重登録を防ぐために一度消す
  window.removeEventListener('mousemove', onMouseMove);
  window.removeEventListener('mouseup', onMouseUp);

  // 2. 改めて登録
  window.addEventListener('mousemove', onMouseMove);
  window.addEventListener('mouseup', onMouseUp);
}
// 実行
initEvents();

この一手間で、何度ページを跨いでも常にフレッシュな操作感が維持されます。


3. ブラウザの「仕様」という罠

機能を実装し、localStorage を使ってリロード時にサイズを復元できるようにした直後、奇妙なバグに遭遇しました。 「リロードすると、時計のサイズは200pxなのに、スライダーのつまみは100pxの位置に戻ってしまう」という現象です。

原因は <input type="range"> のブラウザ標準仕様にありました。 Rangeのデフォルトの max100 です。JavaScriptで初期化する際、max を引き上げる前に sizeSlider.value = "200" をセットしようとすると、ブラウザが「最大100だから200は無理!」と判断し、勝手に100に切り詰めてしまっていたのです。

解決策: 初期化のシーケンス(順番)を厳密に制御します。

// --- 最終・初期設定シーケンス ---
const savedSize = localStorage.getItem('clock-size');
const initialSize = savedSize ? parseInt(savedSize) : 80;

// 1. まずRange(max値)を確定させる(これが後続の制限を解く鍵)
updateSliderRange();

// 2. その後でサイズを適用し、スライダーのvalueを更新する
applySize(initialSize);

「Rangeの最大値を確定させてから、値を流し込む」。この基本ルールを守ることで、1pxのズレもなくUIが同期するようになりました。


4. 完成した「v1.10.3」の全コード

UIの自由度と、SPAにおける堅牢性を両立した最終的なコンポーネントコードです。
(※スタイルシート部分は前回の記事とほぼ共通のため、核となるHTMLとScript部分を掲載します)



<script>
  // -------------------------------------------------------
  // SPAイベント重複防止(クリーンアップ)
  // -------------------------------------------------------
  const onMouseMove = (e: MouseEvent | TouchEvent) => { doDrag(e); doTabDrag(e); };
  const onMouseUp = () => { endDrag(); endTabDrag(); };

  function initEvents() {
    window.removeEventListener('mousemove', onMouseMove);
    window.removeEventListener('mouseup', onMouseUp);
    window.removeEventListener('touchmove', onMouseMove);
    window.removeEventListener('touchend', onMouseUp);

    window.addEventListener('mousemove', onMouseMove);
    window.addEventListener('mouseup', onMouseUp);
    window.addEventListener('touchmove', onMouseMove, { passive: false });
    window.addEventListener('touchend', onMouseUp);
  }
  initEvents();

  // -------------------------------------------------------
  // 針アニメーション(幽霊対策済み)
  // -------------------------------------------------------
  function updateClock() {
    const secondEl = document.getElementById('second');
    const minuteEl = document.getElementById('minute');
    const hourEl   = document.getElementById('hour');

    if (!secondEl || !minuteEl || !hourEl) return; // ガード句

    const now = new Date();
    const s = now.getSeconds();
    const m = now.getMinutes();
    const h = now.getHours();

    secondEl.style.transform = `rotate(${(s / 60) * 360}deg)`;
    minuteEl.style.transform = `rotate(${(m / 60) * 360 + (s / 60) * 6}deg)`;
    hourEl.style.transform   = `rotate(${(h / 12) * 360 + (m / 60) * 30}deg)`;

    requestAnimationFrame(updateClock);
  }
  updateClock();

  // -------------------------------------------------------
  // スライダーとUI適用ロジック
  // -------------------------------------------------------
  const clock = document.getElementById('clock-container')!;
  const sizeSlider = document.getElementById('size-slider') as HTMLInputElement;
  const sizeValue  = document.getElementById('size-value')!;

  function updateSliderRange() {
    const maxSafeSize = Math.floor(Math.min(window.innerWidth, window.innerHeight) * 0.9);
    sizeSlider.max = maxSafeSize.toString();
  }

  function applySize(size: string | number) {
    const numSize = typeof size === 'string' ? parseInt(size) : size;
    sizeValue.textContent = numSize.toString();
    sizeSlider.value = numSize.toString();

    const dim = `${numSize}px`;
    clock.style.width = dim;
    clock.style.height = dim;
  }

  sizeSlider.addEventListener('input', (e) => {
    const val = (e.target as HTMLInputElement).value;
    applySize(parseInt(val));
    localStorage.setItem('clock-size', val);
  });

  // --- 初期化シーケンス ---
  const savedSize = localStorage.getItem('clock-size');
  const initialSize = savedSize ? parseInt(savedSize) : 80;

  updateSliderRange(); // 1. Range確定
  applySize(initialSize); // 2. 値適用

  /* ※ドラッグやHIDE/SHOW、テーマ変更のロジックは前回同様に実装 */
</script>

おわりに

「とりあえず動く」ものを作るのと、「どんな環境でどう使われても壊れない」ものを作るのとでは、越えなければならない壁の高さが全く違います。

今回は数時間という短期間で、機能追加・バグ発見・デプロイを繰り返すアジャイル(あるいはバイブコーディング的)な開発となりましたが、結果として非常にソリッドなUIコンポーネントに仕上がりました。


COMM_LOG: astro-15-copland-clock-v1-10

NO DATA FOUND IN THIS SECTOR.