はじめに
3D地理空間データの可視化を提供するオープンソースJavaScriptライブラリであるCesiumJSと、高速で柔軟性の高いフレームワークであるSvelte(今回はv.4)を組み合わせて使用する際の、カスタムストアの利点と実装方法について解説する。
CesiumJSのViewerクラス
Viewer
クラスはCesiumJSにおける言わば「核」となるクラスで、地形モデルの切り替え、レイヤー追加削除、エンティティ追加削除、カメラの制御などに用いられる。
Viewerクラスについてはこちら
ユーザーは主にこのViewer
クラスを用いて地図を制御することとなる。
今回はこのViewer
クラスに言及する。
コンポーネント間でのViewer操作
複数のコンポーネントからViewerを操作うる場合、通常は多くのコンポーネントを渡す「バケツリレー」を要する。
バケツリレーはデータフローの単純, 明確化できる一方で、保守性/可読性・パフォーマンスの低下などの問題がある。
※ バケツリレーは決して悪ではなく、時と場合によっては有効である。
例えば以下のようなケースを想定する。
- サイドバーで地形モデルを変更・レイヤーの追加削除を行う。
- 計測モーダルで指定したポイントの標高やポイント間の標高を計測する。
この場合、Svelteでは以下のフォルダ構成とする。
src/
├── route
│ └── +page.svelte // ルートコンポーネント
└── components
├── MapPane.svelte // 地図描画用コンポーネント
├── Sidebar.svelte // サイドバー用コンポーネント
└── measurement
├── MeasurementModal.svelte // 計測モーダル
├── Distance.svelte // 距離測定用コンポーネント
└── Elevation.svelte // 標高取得用コンポーネント
※ 説明で必要なコンポーネントのみ列挙
仮に各コンポーネントでViewerを制御しようとした場合、
MapPane.svelteで定義したViewerは以下のように各コンポーネントに渡す必要がある。
※Viewerを使用するのは赤いコンポーネントのみ
仮にViewer制御の責務をMapPane.svelteに限定した場合、「バケツリレー」はさらに複雑化するだろう。
そこで、Viewerの制御をストアに持たせるようにしてみる。
カスタムストアについて
上記でViewerの制御をストアに持たせると表現したが、正しくは「ストアで保持するViewerの制御を合わせてストア(カスタムストア)で行う」である。
Svelteにはビルドインでストア機能 svelte/store をサポートしている。特にsvelte/storeにはカスタムストアなる強力な機能が存在する。
状態の更新時に任意のロジックを介入することができる。これにより単純な値の保持だけでなく、より複雑な状態の管理や副作用の制御を行うことができる。
気になった人は是非、公式チュートリアルを試してほしい。
このカスタムストアを活用することで、Viewerの制御を全てストア(以下viewerStore)に持たせることができる。
こうすることで各コンポーネントは必要な時にviewerStoreを呼び出せば良いだけなので、関心, 責務の分離、保守性/可読性の向上などのメリットを享受できる。
viewerStore移行後のフローは以下の通り
必要なコンポーネントはviewerStoreからViewerを制御し、MapPane.svelteではViewerをsubscribe(常に最新の状態を読み込む)すれば良くなる。
最初にMapPane.svelteからviewerStoreの初期化を行う必要があるが、グラフでは省略している。
カスタムストア「viewStore」の実装
さて今回はviewStoreに、以下の関数を用意する。
- Viewerの初期化
- カメラのビューを設定
- 地形データの変更
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することで、他のコンポーネントからの変更内容をリアクティブに反映することができる。
<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を更新している。<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を扱う最低限のコードに限定
-
エンティティ(ポリゴン)の追加
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) } });
-
エンティティ(ポリゴン)の削除
import { viewerStore } from '$lib/store/viewer'; viewerStore.removeEntityById('polygon');
-
その他: 各コンポーネントで直接Viewerを制御
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ライフを