[JavaScript] Lain Clock プリセット管理機能の実装 — WebGL設定の保存・復元・JSON Export/Import

[JavaScript] Lain Clock プリセット管理機能の実装 — WebGL設定の保存・復元・JSON Export/Import

はじめに

前回の記事

[JavaScript] Lain Clock スマホ対応 — UI折りたたみ・カメラ自動調整・設定リアルタイムプレビュー」

に引き続き、今回は「Lain Clock」における プリセット管理機能(設定の保存・復元・JSONファイル入出力) の実装内容を解説します。

概要

本機能では、ユーザーが調整した時計の配色、表示スケール、床のシェーダーパターン、デフォルトモーションなどの各種パラメータを一括で管理・持ち運びできるようにしました。

主な実装機能

  • プリセットの保存: 現在の設定状態(currentSettings)に任意の名称を付けてLocalStorageに保存。
  • プリセットの復元(Restore): 保存された設定を読み込み、アプリケーションに適用。
  • JSONファイルのExport: 登録済みプリセット(単体および全件)をJSONファイルとしてローカルへダウンロード。
  • JSONファイルのImport: 外部のJSONファイルを読み込み、重複しないUnique IDを付与してリストに追加。

モジュール構成

機能の分離を図るため、データ操作ロジックとUI制御モジュールに分離して実装しています。

  • ./src/preset-store.js: LocalStorageへのアクセス、JSONシリアライズ/デシリアライズ、ファイルIOを担当。
  • ./src/ui/settings-panel.js: 設定UIの描画、イベントハンドリング、設定適用時のリロード制御を担当。

実装コード

1. プリセット管理モジュール (./src/preset-store.js)

プリセットデータのCRUD操作と、Blob / FileReader APIを使用したJSONファイルの入出力を集約したモジュールです。

// ./src/preset-store.js

const PRESETS_STORAGE_KEY = "clock-app-presets-v1";

/**
 * 全プリセットの取得
 */
export function getAllPresets() {
  try {
    const raw = localStorage.getItem(PRESETS_STORAGE_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch (e) {
    console.warn("getAllPresets failed", e);
    return [];
  }
}

/**
 * 設定オブジェクトをプリセットとして保存
 */
export function savePreset(name, settings) {
  const presets = getAllPresets();
  const newPreset = {
    id: `preset_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
    name: name || `Preset ${new Date().toLocaleDateString()}`,
    createdAt: Date.now(),
    settings: JSON.parse(JSON.stringify(settings)),
  };

  presets.push(newPreset);
  localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets));
  return newPreset;
}

/**
 * プリセットの削除
 */
export function deletePreset(id) {
  const presets = getAllPresets().filter((p) => p.id !== id);
  localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets));
}

/**
 * JSONファイルへExport (全件または指定ID)
 */
export function exportPresetsToJson(targetId = null) {
  const all = getAllPresets();
  const targets = targetId ? all.filter((p) => p.id === targetId) : all;

  if (targets.length === 0) return;

  const payload = {
    app: "PROTOCOL.LAIN",
    type: "clock-presets",
    version: 1,
    exportedAt: Date.now(),
    presets: targets,
  };

  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);

  const a = document.createElement("a");
  a.href = url;
  a.download = `lain_presets_${new Date().toISOString().slice(0, 10)}.json`;
  a.click();

  URL.revokeObjectURL(url);
}

/**
 * JSONファイルからImport
 */
export function importPresetsFromJson(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (e) => {
      try {
        const data = JSON.parse(e.target.result);

        if (data.app !== "PROTOCOL.LAIN" || !Array.isArray(data.presets)) {
          throw new Error("無効なプリセットファイル形式です。");
        }

        const existing = getAllPresets();
        let importedCount = 0;

        data.presets.forEach((imported) => {
          if (!imported.id || !imported.settings) return;

          // ID重複回避処理
          const isDuplicate = existing.some((p) => p.id === imported.id);
          const finalPreset = {
            ...imported,
            id: isDuplicate ? `preset_${Date.now()}_${Math.random().toString(36).substring(2, 5)}` : imported.id,
          };

          existing.push(finalPreset);
          importedCount++;
        });

        localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(existing));
        resolve(importedCount);
      } catch (err) {
        reject(err);
      }
    };

    reader.onerror = () => reject(reader.error);
    reader.readAsText(file);
  });
}

2. 設定パネルUIとの統合 (./src/ui/settings-panel.js)

設定パネル最上部に「Presets」セクションを動的に挿入し、操作イベントをバインドします。

// ./src/ui/settings-panel.js 内のイベント処理部分(抜粋)

settingsBody.addEventListener("click", async (ev) => {
  const btn = ev.target.closest("[data-act]");
  if (!btn) return;

  const act = btn.dataset.act;
  const id = btn.dataset.id;

  // プリセットの保存
  if (act === "presetSave") {
    collectCurrentSettings();
    const name = prompt("プリセット名を入力してください", `Preset ${new Date().toLocaleTimeString()}`);
    if (!name) return;
    savePreset(name, currentSettings);
    renderPresetsSection(settingsBody);
    return;
  }

  // プリセットの復元(Apply)
  if (act === "presetApply") {
    const presets = getAllPresets();
    const target = presets.find((p) => p.id === id);
    if (!target) return;

    // 設定を保存してページをリロード
    saveSettings(cloneSettings(target.settings));
    location.reload();
    return;
  }

  // プリセットの削除
  if (act === "presetDelete") {
    if (!confirm("このプリセットを削除しますか?")) return;
    deletePreset(id);
    renderPresetsSection(settingsBody);
    return;
  }

  // エクスポート・インポートのトリガー
  if (act === "presetExportAll") return exportPresetsToJson(null);
  if (act === "presetExportSingle") return exportPresetsToJson(id);
  if (act === "presetImportTrigger") {
    settingsBody.querySelector("#inputPresetFile")?.click();
    return;
  }
});

技術的なポイントと設計判断

1. WebGL / Three.js 状態同期における location.reload() の採用

復元処理(presetApply)において、JavaScript上のオブジェクト値を書き換えるだけでは、すでに生成されているThree.jsのメッシュ、マテリアルのUniform変数、シェーダープロパティ等へ即座に完全反映されない問題が発生します。

リアルタイム更新関数を全要素(床テクスチャ・シェーダーパラメータ・時計のUI構造)に対して構築するとコードの結合度が高まり、リソースの破棄漏れ(メモリリーク)などの原因となります。

そのため、復元時には以下のフローを採用しました。

  1. 対象プリセットの settings オブジェクトを saveSettings() でLocalStorage(カレント設定領域)に上書き保存する。
  2. location.reload() を呼び出してアプリケーション全体を再読み込みする。

このアプローチにより、最小限のコード複雑度で、Three.jsの描画コンテキストおよびDOM要素を確実に初期化・正しく復元できます。

2. JSON Import 時の ID 重複回避

外部ファイルから読み込まれるプリセットデータは、既存のLocalStorage内にすでに存在する id と衝突する可能性があります。 importPresetsFromJson 内部において、existing.some(...) による重複チェックを行い、重複が検知された場合はタイムスタンプと乱数を組み合わせた新IDを割り振ることで、データ損失を防いでいます。


まとめ

今回の実装により、パラメータ調整結果の永続化、別環境への移行、および状態復元が確実に動作する仕組みが整いました。