Maplibre GL JSで地図上に植林してみた

:::note info
これは [MIERUNE AdventCalendar 2022](https://qiita.com/advent-calendar/2022/mierune)　4日目の記事です。
昨日は @yuskesuzki@github さんによる [Django GEOS API で地図上にちゃんとした魔法陣を描く](https://qiita.com/yuskesuzki@github/items/f82be9768b084333dc6d) でした。
:::

今回は[Maplibre GL JS](https://maplibre.org/)を使用して、地図上に木を植えたいと思います。

## 地図上に木を出現させる

まずは、Maplibreの[ポップアップ](https://maplibre.org/maplibre-gl-js-docs/example/popup/)を使用して地図上に木を表示します。マップを生成し、マップの中心にポップアップを生成します。このとき、ポップアップの[options](https://maplibre.org/maplibre-gl-js-docs/api/markers/#popup-parameters)を以下のようにします。

`closeOnClick: false`
地図をクリックした時にポップアップが消えないようにします。

`closeButton: false`
ポップアップの閉じるボタンを非表示にします。

`anchor: "bottom"`
ポップアップの位置が地図の移動に合わせて自動で動かないように`bottom`で固定します。

```js:JavaScript
// 地図の生成
const map = new maplibregl.Map({
  container: "map",
  style: {
    version: 8,
    sources: {
      osm: {
        type: "raster",
        tiles: ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"],
        tileSize: 256,
        attribution:
          "© <a href='https://www.openstreetmap.org/copyright/ja' target='_blank'>OpenStreetMap</a> contributions"
      }
    },
    layers: [
      {
        id: "osm",
        type: "raster",
        source: "osm",
        minzoom: 0,
        maxzoom: 24
      }
    ]
  },
  center: [141.361, 43.064],
  zoom: 12,
  pitch: 60
});

// 地図が読み込まれた後の処理 
map.on("load", () => {

  // ポップアップの生成
  const popup = new maplibregl.Popup({
    closeOnClick: false,
    closeButton: false,
    anchor: "bottom"
  });

  // ポップアップを地図の中心に表示
  popup.setLngLat(map.getCenter()).setHTML("").addTo(map);
});
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/b4f8cb65-4ee9-f250-9e45-c4add98aedb6.png)

`setHTML()`の中は空文字なので、ポップアップの中には何も文字が表示されません。
そしたら、このポップアップをCSSでカスタマイズして木に変身させます。

MaplibreのポップアップはHTML要素なので、Chromeの開発者ツールを使用してポップアップの要素を検証することで、デフォルトのCSSスタイリングを確認することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/df371199-0c90-a2d7-8178-21f5c0c85f1a.png)


MaplibreのポップアップのCSSセレクターは親要素の`.mapboxgl-popup`と子要素の`.mapboxgl-popup-tip`と`.mapboxgl-popup-content`が存在し、CSSによるスタイリングができます（もっと細いセレクターが存在しますが今回は省略）。
`.mapboxgl`の部分を`.maplibregl`に変えてもスタイリングは可能ですが、[Mapbox GL JS](https://docs.mapbox.com/jp/mapbox-gl-js/overview/)でも使用できる`.mapboxgl`で記述する方が個人的にはおすすめです。

今回はポップアップのデフォルトのCSSスタイリングを上書きするようにCSSを記述してるので、通常のdiv要素よりも余分にCSSを記述しています。[疑似要素](https://developer.mozilla.org/ja/docs/Web/CSS/Pseudo-elements)の`::before`と`::after`を使ってパーツを増やし、重ね合わせることで、すこし立体感のある木を表現しています。



```css:CSS
/*　ポップアップの三角形の部分　（樹幹に変える）*/
.mapboxgl-popup .mapboxgl-popup-tip {
  z-index: 0;
  order: 1;
  transform: rotate(180deg);
  border-top: 100px solid rgb(51, 157, 33);
  border-right: 30px solid transparent;
  border-left: 30px solid transparent;
  filter: drop-shadow(0 1px 8px rgba(0, 0, 0, 0.241));
}

/*　ポップアップの四角形の部分（幹に変える）　*/
.mapboxgl-popup .mapboxgl-popup-content {
  z-index: 0;
  pointer-events: none;
  align-self: center;
  padding: 0px;
  border-radius: 0;
  margin: 0px;
  width: 20px;
  height: 60px;
  background: #e17b33;
  box-shadow: 0px 0px 16px -6px rgba(0, 0, 0, 0.6);
}

.mapboxgl-popup .mapboxgl-popup-content::after {
  content: "";
  position: absolute;
  width: 60px;
  height: 20px;
  display: block;
  background-color: rgb(51, 157, 33);
  border-radius: 45%;
  transform: translateX(-20px) translateY(-8px);
  filter: drop-shadow(0 10px 4px rgba(0, 0, 0, 0.217));
}

.mapboxgl-popup .mapboxgl-popup-content::before {
  content: "";
  position: absolute;
  width: 20px;
  height: 10px;
  bottom: -5px;
  background-color: #e17b33;
  border-radius: 50%;
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/0a420bfb-8e34-f8e2-927d-3ddfe5a0d8ed.png)
ポップアップが木に大変身しました。

## CSSアニメーションを追加する

地面から生えてきた感じを表現したいので[`animation`](https://developer.mozilla.org/ja/docs/Web/CSS/animation)プロパティと[`@keyframes`](https://developer.mozilla.org/ja/docs/Web/CSS/@keyframes)を使ってCSSアニメーションを追加します。

```diff_css:CSS
.mapboxgl-popup .mapboxgl-popup-tip {
  z-index: 0;
  order: 1;
  transform: rotate(180deg);
  border-top: 100px solid rgb(51, 157, 33);
  border-right: 30px solid transparent;
  border-left: 30px solid transparent;
  filter: drop-shadow(0 1px 8px rgba(0, 0, 0, 0.241));
+ animation: borderTip 1s ease;
}

+ @keyframes borderTip {
+  0% {
+    border-top: 0px solid rgb(51, 157, 33);
+    border-right: 0px solid transparent;
+    border-left: 0px solid transparent;
+    border-filter: drop-shadow(0 0px 0px rgba(0, 0, 0, 0.241));
+  }
+  100% {
+    border-top: 100px solid rgb(51, 157, 33);
+    border-right: 30px solid transparent;
+    border-left: 30px solid transparent;
+  }
+ }

.mapboxgl-popup .mapboxgl-popup-content {
  z-index: 0;
  pointer-events: none;
  align-self: center;
  padding: 0px;
  border-radius: 0;
  margin: 0px;
  width: 20px;
  height: 60px;
  background: #e17b33;
  box-shadow: 0px 0px 16px -6px rgba(0, 0, 0, 0.6);
+ animation: transformContent 1s ease;
}

+ @keyframes transformContent {
+  0% {
+    width: 0px;
+   　　height: 0px;
+    transform: translateX(0px);
+  }
+  100% {
+    width: 20px;
+    height: 60px;
+  }
+ }

.mapboxgl-popup .mapboxgl-popup-content::after {
  content: "";
  position: absolute;
  width: 60px;
  height: 20px;
  display: block;
  background-color: rgb(51, 157, 33);
  border-radius: 45%;
  transform: translateX(-20px) translateY(-8px);
  filter: drop-shadow(0 10px 4px rgba(0, 0, 0, 0.217));
+ animation: transformContentAfter 1s ease;
}

+ @keyframes transformContentAfter {
+  0% {
+    width: 0px;
+    height: 0px;
+    border-radius: 50%;
+    transform: translateX(0px) translateY(0px);
+    filter: drop-shadow(0 0px 0px rgba(0, 0, 0, 0.217));
+  }
+  100% {
+    width: 60px;
+    height: 20px;
+    border-radius: 45%;
+    transform: translateX(-20px) translateY(-8px);
+  }
+ }

.mapboxgl-popup .mapboxgl-popup-content::before {
  content: "";
  position: absolute;
  width: 20px;
  height: 10px;
  bottom: -5px;
  background-color: #e17b33;
  border-radius: 50%;
+ animation: transformContentBefore 1s ease;
}

+ @keyframes transformContentBefore {
+  0% {
+    width: 0px;
+    height: 0px;
+    bottom: 0px;
+    transform: translateX(0px);
+  }
+  100% {
+    width: 20px;
+    height: 10px;
+    bottom: -5px;
+  }
+ }
```

![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/4ce2bb1b-f43c-e6c7-9aff-e33564f52dab.gif)

地面からニョキニョキ生えるようになりました。

## クリックイベントの追加

もっとたくさん植林したいので、クリックしたところに木（ポップアップ）が生成されるようにマップのクリックイベントを追加します。

```js:JavaScript
// 地図をクリックした時の処理
map.on("click", (e) => {
  const popup = new maplibregl.Popup({
    closeOnClick: false,
    closeButton: false,
    anchor: "bottom"
  });
  // クリックした地点に木を生やす
  popup.setLngLat(e.lngLat).setHTML("").addTo(map);
});
```

これで地図上に植林ができるようになりました！。実際に地図をクリックして植林してみましょう！
<p class="codepen" data-height="422.91015625" data-default-tab="result" data-slug-hash="ZERmWMX" data-user="satoshi7190" style="height: 422.91015625px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/satoshi7190/pen/ZERmWMX">
  Maplibre click Tree Popup</a> by satoshi7190 (<a href="https://codepen.io/satoshi7190">@satoshi7190</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

これで好きなだけ植林ができますね。


## 自動で植林する

しかし、1本1本手動で植林するのは面倒なので、自動化して一片にたくさん植林できるようにします。

`map.on("load", ()`を追加し、[Turf.js](https://turfjs.org/)の[`randomPoint()`](https://turfjs.org/docs/#randomPoint)を使って複数のポイントをランダムに生成して、[`map()`](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map)のループ処理を使ってランダムに複数本の木が生えるようにします（とりあえず今回は50本）。ポイントを生成するには生成する範囲（bbox）の情報が必要なので、[`map.getBounds()`](https://maplibre.org/maplibre-gl-js-docs/api/map/#map#getbounds)でマップ生成時の初期画面のbboxを取得します。

この時に地図が傾いていると、bboxが上方向にずれるので、地図の初期表示の`pitch`を0にして、bboxを取得した後に[`map.setPitch(60)`](https://maplibre.org/maplibre-gl-js-docs/api/map/#map#setpitch)で地図を傾けるようにしています。
※個人的な好みなので、地図を傾ける必要がない場合は不要です。

```diff_javascript:JavaScript
// 地図の生成
const map = new maplibregl.Map({
  container: "map",
  style: {
    version: 8,
    sources: {
      osm: {
        type: "raster",
        tiles: ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"],
        tileSize: 256,
        attribution:
          "© <a href='https://www.openstreetmap.org/copyright/ja' target='_blank'>OpenStreetMap</a> contributions"
      }
    },
    layers: [
      {
        id: "osm",
        type: "raster",
        source: "osm",
        minzoom: 0,
        maxzoom: 24
      }
    ]
  },
  center: [141.361, 43.064],
  zoom: 12,
- pitch: 60
+ pitch: 0
});

// 地図が読み込まれた後の処理 
map.on("load", () => {

+ //　初期画面のbboxを取得
+ const bbox = [
+   map.getBounds()._sw.lng,
+   map.getBounds()._sw.lat,
+   map.getBounds()._ne.lng,
+   map.getBounds()._ne.lat
+ ];

+ //　ポイントをランダムに生成
+ const point = turf.randomPoint(50, { bbox: bbox });

+ //　地図を傾ける
+ map.setPitch(60);

+ //　ループ処理で植林
+ point.features.map((data) => {
      const popup = new maplibregl.Popup({
        closeOnClick: false,
        closeButton: false,
        anchor: "bottom"
      });

-     popup.setLngLat(map.getCenter()).setHTML("").addTo(map);
+     popup.setLngLat(data.geometry.coordinates).setHTML("").addTo(map);
+ });
});
```

## ループ処理のタイミングをずらす

一度に複数の木（ポップアップ）に同じタイミングでCSSアニメーションを実行すると処理が重くなるので、`map()`のループ処理を一定間隔でタイミングをずらして実行したいと思います。

`map()`のループ処理の中に[`setTimeout()`](https://developer.mozilla.org/ja/docs/Web/API/setTimeout)を記述して、非同期処理にします。`map()`の第二引数（`index`）で、配列のインデックス番号を取得しています。インデックス番号に`setTimeout()`の第二引数の実行タイミングの秒数`100`を乗算することで、二番目の配列の処理は200ミリ秒後。三番目の配列の処理は300ミリ秒後・・・といった感じに非同期処理のタイミングがずらせるので、ループ処理の個々の操作を遅延させ、指定した秒数ごとに順番に処理させることができます。

```diff_javascript:JavaScript
// 地図が読み込まれた後の処理 
map.on("load", () => {

  //　初期画面のbboxを取得
  const bbox = [
    map.getBounds()._sw.lng,
    map.getBounds()._sw.lat,
    map.getBounds()._ne.lng,
    map.getBounds()._ne.lat
  ];

  //　ポイントをランダムに生成
  const point = turf.randomPoint(50, { bbox: bbox });

  //　地図を傾ける
  map.jumpTo({ pitch: 60 });

  //　ループ処理で植林
- point.features.map((data) => {
+ point.features.map((data, index) => {
+ 
+   //タイミングをずらして順番に処理
+   setTimeout(() => {
      const popup = new maplibregl.Popup({
        closeOnClick: false,
        closeButton: false,
        anchor: "bottom"
      });

      popup.setLngLat(data.geometry.coordinates).setHTML("").addTo(map);
+   }, 100 * index);
  });
});
```


これで、植林が自動化されました。
<p class="codepen" data-height="600.01171875" data-default-tab="result" data-slug-hash="zYaMqaW" data-user="satoshi7190" style="height: 600.01171875px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/satoshi7190/pen/zYaMqaW">
  Maplibre Tree Popup</a> by satoshi7190 (<a href="https://codepen.io/satoshi7190">@satoshi7190</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>


こうしたループを遅延させるやりかたはアニメーションを一定間隔で順番に実行したい時に便利です。マップのカメラを順番に移動させたい時にも使えます。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Forest Rate in Japan.<a href="https://twitter.com/hashtag/MapLibre?src=hash&amp;ref_src=twsrc%5Etfw">#MapLibre</a> <a href="https://twitter.com/hashtag/MapBox?src=hash&amp;ref_src=twsrc%5Etfw">#MapBox</a> <a href="https://t.co/9ePDbvuA6s">pic.twitter.com/9ePDbvuA6s</a></p>&mdash; Satoshi Komatsu (@satoshi7190) <a href="https://twitter.com/satoshi7190/status/1588798221866110976?ref_src=twsrc%5Etfw">November 5, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## まとめ

MaplibreのポップアップはCSSで好き勝手に魔改造できるので、さまざまな表現が可能になります。[マーカー](https://maplibre.org/maplibre-gl-js-docs/example/add-a-marker/)も同様にCSSで真改造できるので、18日目の記事ではMaplibreのマーカーについて紹介したいと思います

:::note info
明日は@northprintさんによる[地図で音を鳴らす](https://qiita.com/northprint/items/9a8950dc9f363ca470a4)です！お楽しみにー
:::