CesiumJS × Svelte4におけるカスタムストアの活用

# はじめに

3D地理空間データの可視化を提供するオープンソースJavaScriptライブラリである[CesiumJS](https://cesium.com/platform/cesiumjs/)と、高速で柔軟性の高いフレームワークである[Svelte](https://svelte.jp/)（今回はv.4）を組み合わせて使用する際の、カスタムストアの利点と実装方法について解説する。

# CesiumJSのViewerクラス

`Viewer` クラスはCesiumJSにおける言わば「核」となるクラスで、地形モデルの切り替え、レイヤー追加削除、エンティティ追加削除、カメラの制御などに用いられる。

Viewerクラスについては[こちら](https://cesium.com/learn/ion-sdk/ref-doc/Viewer.html)

ユーザーは主にこの`Viewer` クラスを用いて地図を制御することとなる。

今回はこの`Viewer` クラスに言及する。

# コンポーネント間でのViewer操作

複数のコンポーネントからViewerを操作うる場合、通常は多くのコンポーネントを渡す「バケツリレー」を要する。

バケツリレーはデータフローの単純, 明確化できる一方で、保守性/可読性・パフォーマンスの低下などの問題がある。

※ バケツリレーは決して悪ではなく、時と場合によっては有効である。

例えば以下のようなケースを想定する。

1. サイドバーで地形モデルを変更・レイヤーの追加削除を行う。
2. 計測モーダルで指定したポイントの標高やポイント間の標高を計測する。 

この場合、Svelteでは以下のフォルダ構成とする。

```css
src/
├── route
│   └── +page.svelte                  // ルートコンポーネント
└── components
    ├── MapPane.svelte                // 地図描画用コンポーネント
    ├── Sidebar.svelte                // サイドバー用コンポーネント
    └── measurement
        ├── MeasurementModal.svelte   // 計測モーダル 
        ├── Distance.svelte           // 距離測定用コンポーネント
        └── Elevation.svelte          // 標高取得用コンポーネント
```

 ※ 説明で必要なコンポーネントのみ列挙

仮に各コンポーネントでViewerを制御しようとした場合、 

MapPane.svelteで定義したViewerは以下のように各コンポーネントに渡す必要がある。

![Frame 8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336887/bd9ecb93-6e15-5fc7-fe07-0397684e177e.png)


※Viewerを使用するのは赤いコンポーネントのみ 

仮にViewer制御の責務をMapPane.svelteに限定した場合、「バケツリレー」はさらに複雑化するだろう。

そこで、Viewerの制御をストアに持たせるようにしてみる。

# カスタムストアについて

上記でViewerの制御をストアに持たせると表現したが、正しくは「ストアで保持するViewerの制御を合わせてストア（カスタムストア）で行う」である。

Svelteにはビルドインでストア機能 [svelte/store](https://svelte.jp/docs/svelte-store) をサポートしている。特にsvelte/storeにはカスタムストアなる強力な機能が存在する。

状態の更新時に任意のロジックを介入することができる。これにより単純な値の保持だけでなく、より複雑な状態の管理や副作用の制御を行うことができる。

気になった人は是非、公式[チュートリアル](https://learn.svelte.dev/tutorial/custom-stores)を試してほしい。

このカスタムストアを活用することで、Viewerの制御を全てストア（以下viewerStore）に持たせることができる。
こうすることで各コンポーネントは必要な時にviewerStoreを呼び出せば良いだけなので、関心, 責務の分離、保守性/可読性の向上などのメリットを享受できる。

viewerStore移行後のフローは以下の通り

![Frame 9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336887/ea8adbd3-015c-b899-3c3d-a684ec345626.png)


必要なコンポーネントはviewerStoreからViewerを制御し、MapPane.svelteではViewerをsubscribe（常に最新の状態を読み込む）すれば良くなる。 

 最初にMapPane.svelteからviewerStoreの初期化を行う必要があるが、グラフでは省略している。

# カスタムストア「viewStore」の実装

さて今回はviewStoreに、以下の関数を用意する。

- Viewerの初期化
- カメラのビューを設定
- 地形データの変更

```tsx
import { writable } from "svelte/store";
import {
  Viewer,
  Cartesian3,
  CesiumTerrainProvider,
  IonResource,
  Entity
} from "cesium";

// カスタムストア作成
function createViewerStore() {
  let viewer: Viewer | null = null;

  const { subscribe, set } = writable({
    viewer: null as Viewer | null,
    attributions: [] as string[]
  });

  // Viewerの初期化
  function init(containerId: string, options?: any) {
    (window as any).CESIUM_BASE_URL = '/node_modules/cesium/Build/Cesium/';
    viewer = new Viewer(containerId, options);
    set({ viewer, attributions: [] });
  }

  // カメラのビューを設定
  function setView(latitude: number, longitude: number, height: number) {
    if (!viewer) return;
    viewer.camera.setView({
      destination: Cartesian3.fromDegrees(longitude, latitude, height),
      orientation: {
        heading: 0,
        pitch: -0.5,
        roll: 0
      }
    });
  }

  // 地形データの変更（Cesium ion使用例）
  async function changeTerrain(assetId: number) {
    if (!viewer) return;
		viewer.terrainProvider = await CesiumTerrainProvider.fromUrl(
			IonResource.fromAssetId(assetId)
		);
  }

  // エンティティの追加
  function addEntity(entity: Entity.ConstructorOptions) {
    if (!viewer) return;
    viewer.entities.add(entity);
  }

  // エンティティの削除
  function removeEntityById(entityId: string) {
    if (!viewer) return;
    const existingEntity = viewer.entities.getById(entityId);
    if (existingEntity) {
      viewer.entities.remove(existingEntity);
    }
  }

  return {
    subscribe,
    init,
    setView,
    changeTerrain,
    addEntity,
    removeEntityById
  };
}

export const viewerStore = createViewerStore();
```

## 使用例

- Viewerの初期化、初期カメラ位置の制御、Viewer・attributeの同期
    
    viewerStoreをsubscribeすることで、他のコンポーネントからの変更内容をリアクティブに反映することができる。
    
    ```tsx
    <script lang="ts">
    	import { onMount } from 'svelte';
    	import { Viewer } from 'cesium';
    	import 'cesium/Build/Cesium/Widgets/widgets.css';
    
    	import { viewerStore } from '$lib/store/viewer';
    
    	let viewer: Viewer | null = null;
    	let attributions: string[] = [];
    
    	// Viewer・attributeの同期
    	$: viewerStore.subscribe((state) => {
    		viewer = state.viewer;
    		attributions = state.attributions;
    	});
    
    	$: attributionHtml = attributions.join(' | ');
    	onMount(async () => {
    		// Viewerの初期化
    		viewerStore.init('cesiumContainer');
    		// 初期カメラ位置の制御
    		viewerStore.setView(35.66, 139.76, 600);
    	});
    </script>
    
    <div class="absolute w-full h-full">
    	<div id="cesiumContainer" class="absolute cesiumContainer w-full h-full"></div>
    	<div id="attribution">{@html attributionHtml}</div>
    </div>
    ```
    
    ※ viewerStoreを扱う最低限のコードに限定
    
- 地形データの変更
viewerStoreのchangeTerrainを呼び出すことで、ストアのViewを更新している。
    
    ```tsx
    <script lang="ts">
    	import type { CesiumTerrain } from '$lib/types/terrains';
    	import { viewerStore } from '$lib/store/viewer';
    	import { terrainStore } from '$lib/store/terrain';
    	
    	$: terrains = terrainStore;
    
    	function switchTerrain(terrain: CesiumTerrain) {
    		viewerStore.changeTerrain(terrain.assetId);
    	}
    </script>
    
    <div>
    	{#each terrains as terrain}
    		<div class="flex items-center px-2 h-10 border border-b-slate-400">
    			<input
    				type="radio"
    				name="terrain"
    				class="mr-2"
    				checked={activeTerrain === terrain}
    				on:change={() => {
    					switchTerrain(terrain);
    				}}
    			/>
    			<div class="overflow-hidden whitespace-nowrap">
    				{terrain.name}
    			</div>
    		</div>
    	{/each}
    </div>
    ```
    
    ※ viewerStoreを扱う最低限のコードに限定
    
- エンティティ（ポリゴン）の追加
    
    ```tsx
    import { viewerStore } from '$lib/store/viewer';
    
    viewerStore.addEntity({
        id: 'polygon',
        polygon: {
            hierarchy: new CallbackProperty(() => {
                return new PolygonHierarchy(Object.values(points));
            }, false),
            material: Color.RED.withAlpha(0.2)
        }
    });
    ```
    
- エンティティ（ポリゴン）の削除
    
    ```tsx
    import { viewerStore } from '$lib/store/viewer';
    
    viewerStore.removeEntityById('polygon');
    ```


- その他: 各コンポーネントで直接Viewerを制御
    ```tsx
    import { viewerStore } from '$lib/store/viewer';
    import { get } from 'svelte/store';
    
    const viewer = get(viewerStore).viewer;

    // イベントハンドラーを定義
    const handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
    ```

# まとめ

高速で柔軟性がありローコードを達成できるSvelteを用いることで、CesiumJSはさらに使いやすいライブラリへと進化するでしょう。ぜひお試しを。

 CesiumJSを用いた計測系などの記事も書く予定なのでお楽しみに。

では良いCesiumライフを