UNDP GeoHubでMaplibreのスタイル更新する際に工夫していること

:::note info
この記事は [MapLibre Advent Calendar 2023](https://qiita.com/advent-calendar/2023/maplibre) 19日目の記事です。
:::

## UNDP GeoHubについて

国連開発計画（UNDP）ではGeoHubという新しいWebGISプラットフォームをオープンソースで開発しています。世界中に点在するカントリーオフィスが持っているGISデータを一箇所で全て管理し、特別なGISのスキルがないユーザーでも、ArcGISなどの特別なライセンスなしに容易にデータのアップロード、地図のスタイリング、分析、作成した地図の共有などができるようにするためです。

GeoHubでは全ての環境を以下のようなAzure上に構築しています。

![image](https://raw.githubusercontent.com/UNDP-Data/geohub/develop/documentation/diagrams/geohub.svg)

GeoHubのソースコードは以下のGitHubレポジトリにあります。

https://github.com/UNDP-Data/geohub

また以下のウェブサイトでGeoHubを公開しており、現時点で4386のデータセットをクラウドオプティマイズドなラスタ形式（COG）またはベクタ形式(PMTiles)で公開しています。GitHubアカウントがあれば誰でもログインして、ご自身のGISデータをアップロードしてオープンデータとして公開可能です。

https://geohub.data.undp.org/

GeoHubではsvelte/sveltekitを使用してフロントエンドを開発しています。そして、地図描画にMaplibreを多用しています。本記事はGeoHubにおいてMaplibreのスタイル編集をする際に工夫していることを述べます。

## setPaintPropertyとsetLayoutPropertyのバグ

Maplibreでは一般的にこの二つの関数を使用することでスタイル情報を更新することができます。

以下の記事でsetLayoutPropertyを使ってレイヤのvisibilityを変更する方法について書かれていました。基本的に同様にすることで他のすべてのプロパティを動的に変更可能です。

https://qiita.com/asahina820/items/bdf1789a45c530b68b91

しかし、このsetPaintProperty/setLayoutPropertyにはバグ（おそらくMapbox時代から）があり、同関数を使用して更新した後に、`map.getStyle()`を使ってスタイル情報を取得した際に、変更内容が反映されません。

GeoHubでは`map.getStyle()`を使用して、ユーザーがスタイル編集後の状態をデータベースに保存して復元できるようにしていますので、これは問題でした。

## setStyleを使用することで解決

この問題はsetPaintProperty/setLayoutPropertyを使った直後に、同じくsetStyle()関数を使用することで回避することができます。

GeoHubではsvelte/sveltekitを使用しており、svelteのstoreをカスタマイズして、setLayoutProperty/setPaintPropertyを以下のようにラップしてあげることで解決できました。

```ts
import { writable } from 'svelte/store';
import type { Map as MaplibreMap, StyleSetterOptions } from 'maplibre-gl';

export const MAPSTORE_CONTEXT_KEY = 'maplibre-map-store';

export type MapStore = ReturnType<typeof createMapStore>;

// map store for maplibre-gl object
export const createMapStore = () => {
	const { set, update, subscribe } = writable<MaplibreMap>(undefined);

	/**
	 * Update Maplibre's PaintProperty
	 *
	 * Note.
	 * setPaintProperty does render map canvas with new given property value.
	 * But in some cases, it does not actually update style.json object in Map instance.
	 * Because of this problem of Maplibre, the function is going to update style.json directly and call `setStyle` function.
	 *
	 * @param layerId The ID of the layer to set the paint property in.
	 * @param name The name of the paint property to set.
	 * @param value The value of the paint property to set. Must be of a type appropriate for the property, as defined in the MapLibre Style Specification.
	 * @param options Options object.
	 */
	const setPaintProperty = (
		layerId: string,
		name: string,
		value: unknown,
		options?: StyleSetterOptions
	) => {
		update((state) => {
			if (state) {
				state.setPaintProperty(layerId, name, value, options);

				const style = state.getStyle();
				const layer = style?.layers?.find((l) => l.id === layerId);
				if (layer) {
					if (!layer.paint) {
						layer.paint = {};
					}
					if (value) {
						layer.paint[name] = value;
					} else {
						if (layer.paint[name]) {
							delete layer.paint[name];
						}
					}
					state.setStyle(style);
				}
			}

			return state;
		});
	};

	/**
	 * Update Maplibre's LayoutProperty
	 *
	 * Note.
	 * setLayoutProperty does render map canvas with new given property value.
	 * But in some cases, it does not actually update style.json object in Map instance.
	 * Because of this problem of Maplibre, the function is going to update style.json directly and call `setStyle` function.
	 *
	 * @param layerId The ID of the layer to set the paint property in.
	 * @param name The name of the paint property to set.
	 * @param value The value of the paint property to set. Must be of a type appropriate for the property, as defined in the MapLibre Style Specification.
	 * @param options Options object.
	 */
	const setLayoutProperty = (
		layerId: string,
		name: string,
		value: unknown,
		options?: StyleSetterOptions
	) => {
		update((state) => {
			if (state) {
				state.setLayoutProperty(layerId, name, value, options);

				const style = state.getStyle();
				const layer = style?.layers?.find((l) => l.id === layerId);
				if (layer) {
					if (!layer.layout) {
						layer.layout = {};
					}
					if (value) {
						layer.layout[name] = value;
					} else {
						if (layer.layout[name]) {
							delete layer.layout[name];
						}
					}
					state.setStyle(style);
				}
			}

			return state;
		});
	};

	return {
		subscribe,
		update,
		set,
		setPaintProperty,
		setLayoutProperty
	};
};
```

上記のソースコードのGitHubレポジトリでの場所は以下のとおりです。

https://github.com/UNDP-Data/geohub/blob/develop/sites/geohub/src/stores/map.ts

## Map storeオブジェクトをcontext APIで設定する

svelteではcontext APIを使用して、親コンポーネントでMaplibreのMapオブジェクトを設定し、子コンポーネントからgetContextを使用してMapオブジェクトにアクセスできます。

```ts
import { MAPSTORE_CONTEXT_KEY, type MapStore, createMapStore } from '$stores';
import { setContext } from 'svelte';

const mapStore: MapStore = createMapStore()
const map: Map = new Map({})
$mapStore = map
setContext(MAPSTORE_CONTEXT_KEY, mapStore);
```

## 子コンポーネントでgetContext

```ts
import {
    MAPSTORE_CONTEXT_KEY,
    type MapStore,
} from '$stores';
import { getContext, onMount } from 'svelte';

const map: MapStore = getContext(MAPSTORE_CONTEXT_KEY);

map.setLayoutProperty('layer id', 'property name', newValue)
```

svelteでは$シンタックスを使用することでstoreではなく変数に直接アクセスできますが、$なしで使用すると、storeの変数自体にアクセスできます。上記のコードでは、Maplibre本来のものでなく、カスタマイズしたsetLayoutPropertyを呼び出していることになります。

以上のようにラップしたsetLayoutProperty/setPaintPropertyを呼び出すことで、最新のスタイル情報が常時保持され、getStyle()で取得できるようになります。

例えば以下のコードでは、svelteのコンポーネント上で、レイヤのopacityを更新しています。

https://github.com/UNDP-Data/geohub/blob/develop/sites/geohub/src/components/maplibre/OpacitySlider.svelte

## まとめ

Maplibreでスタイル更新後、そのスタイルをどこかデータベースまたはファイルとして保存しておきたいというのは結構あることだと思います。その際にsetLayoutProperty/setPaintPropertyにはクリティカルなバグがあるという点に気づいていただけたら幸いです。
このバグを回避するための方法をsvelteを使用して提示しましたが、同様のことは他のフレームワーク（reactなど）でも可能かと思います。
GeoHubでは他にもMaplibreを使用して色々なことをしています。ご興味のある方はリポジトリのコードを見てみると面白いかもしれません。
GeoHubへのバグ報告、機能追加リクエスト、質問などがありましたら、ご自由にGitHubまでお寄せいただければと思います。