ipyleafletを用いた簡単かつインタラクティブな地図データの可視化

本記事は[OPTIMIND x Acompany Advent Calendar 2021](https://qiita.com/advent-calendar/2021/optimind_acompany)の19日目の記事となります。

こんにちは。オプティマインドのエンジニアの柏原(カシハラ)といいます。
社内では主にテレマティクスデータ解析や地図データを効率に管理運用するためのシステム構築などを担当しています。  

さて、早速ですがデータ解析においては、データの素性を理解することが非常に重要になってきます。そうしたデータ理解のために、普段業務でデータ解析をされる方は折に触れて可視化をするのではないでしょうか。

この記事では、そんな時に強い味方となってくれる便利なpythonライブラリであるipyleafletについて書きたいと思います。

# foliumとipyleaflet
pythonにおける地図関係の可視化ライブラリで最も有名なものの一つに[folium](https://github.com/python-visualization/folium)があり、非常に直感的に書けるので手早く可視化をする際には重宝します。実際、私も良く使います。
コードを見た方が早いと思うので、例えば下記のように簡単にjupyter上で可視化をすることができます。

```python:test_folium.ipynb
from folium import Map, Icon, Marker

latitude = 35.16597954399553
longitude = 136.89914184408428
zoom = 15

# Mapオブジェクトを初期化
m = Map(location=[latitude, longitude], zoom_start=zoom)

# MarkerオブジェクトをMapオブジェクトへ関連付ける
marker = Marker(location=[latitude, longitude],
                icon=Icon(color="red", prefix="fa", icon="anchor"),
                popup="Optimind.inc")
m.add_child(marker)

# 下記のようにも書ける
# Marker(location=[latitude, longitude],
#        icon=Icon(color="blue", prefix="fa", icon="anchor"),
#        popup="Optimind.inc").add_to(m)

display(m)
```

![スクリーンショット 2021-12-15 0.56.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327853/3951a229-a1ed-4528-f852-4803c01a1034.png)

このようにfoliumは非常に便利ですが、地図に一度レンダリングされた情報を後から動的に書き換えることはできません。
もし変更したい場合は、再度Mapオブジェクトを初期化しレンダリングし直す必要があり、そのような方法では例えばある種のアニメーションのような可視化をしたい場合などに十分な速度で描画することが困難です。
また、場所に応じて周辺の建物オブジェクトなどを可視化したい場合には、動的に追加・削除ができればメモリに大量のデータを保持しておく必要がなくなるために滑らかにレンダリングできるでしょう。そんな時に使用できるライブラリがipyleafletです。

# ipyleafletによるインタラクティブな可視化
[ipyleaflet](https://github.com/jupyter-widgets/ipyleaflet)は[ipywidgets](https://github.com/jupyter-widgets/ipywidgets/)というjupyter上で動くインタラクティブなウィジェットライブラリをベースに開発されています。そのipywidgetsは[traitlets](https://github.com/ipython/traitlets/)というライブラリを利用しており、いわゆるオブザーバパターン[^1]の実装を使うことができるため、これによりウィジェットの変更に対してコールバックを設定して動的な更新を行うことが可能です。

これもコードを見た方がイメージしやすいと思うので、シンプルな可視化コード例をまず貼ります。  

```python:test_ipyleaflet.ipynb
from ipyleaflet import Map, Marker

latitude = 35.16597954399553
longitude = 136.89914184408428
zoom = 15


def my_callback(event, location):
    print(event, location)


m = Map(center=[latitude, longitude], zoom=zoom, scroll_wheel_zoom=True)

marker = Marker(location=[latitude, longitude])
# callbackの追加
marker.on_move(my_callback)

m.add_layer(marker)

display(m)
```

基本的にはfoliumと同様にMapオブジェクトを初期化して、そこに各種マーカやコントロールウィジェットなどの子オブジェクトをLayerとして追加していく形になります。ipyleafletはこの時、子オブジェクトに対してライブラリ側で定義されたイベントに対応したコールバックを設定することができます。例えば、Markerオブジェクトなどでは上記のようにon_moveというドラッグ&ドロップイベントをトリガーにして、任意の関数を実行することができます。
![画面収録 2021-12-18 13.41.50.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327853/d5746ab9-dacb-3a04-bd56-f0dd5e8e8dec.gif)

ここで、上記の実装ではon_moveイベントを使用していますが、オブジェクトのプロパティによっては対応付けたいイベントが存在しない時があります。例えば、popupが変更された時にコールバックを登録したいがMarkerクラスにはon_moveという移動時のイベントしか存在しないなどの状況の時です。そういう場合は、ipyleafletのLayerオブジェクトはipywidgetsのWidgetクラスを継承して作られているため、observeメソッド[^2]を使うことで対処できます。  
popupが変更された時に何かコールバックを実行したい場合は下記のようにすれば良いでしょう。

```python
def foobar_callback(change):
    print(change["new"])


marker.observe(foobar_callback, names="popup")
```
このように、ipyleafletを使えばipywidegts(の根底にあるtraitlets)の恩恵を受けて非常に簡単にコールバックを取り扱え、状況に応じて自分に必要なインタラクティブなツールを作ることができます。

# お試し可視化ツールを作る
では、そんなipyleafletを使って簡単に周辺情報の可視化ツールを作ってみます。
今回は、自分が着目している点を中心として一定範囲の矩形内に存在するガソリンスタンドを抽出して可視化します。

やりたい処理の流れはざっくり以下のような感じです。

1. 現在位置を示すMarkerを移動させる
2. on_moveコールバック内で検索矩形の境界を更新
3. 検索矩形コールバック内で範囲内のガソリンスタンドを取得し更新

まず1,2ですが、先ほども使用したon_moveイベントにコールバックを登録しその内部で検索矩形の境界を更新します。簡単です。

```python
def marker_callback(event, location):
    """ marker位置に応じて rectのboundsも更新 """
    # mはMapオブジェクト
    m.remove_layer(rect)
    rect.bounds = gen_bounds(*location)
    m.add_layer(rect)


def gen_bounds(center_lat, center_lon, d_lat=0.01, d_lon=0.015):
    """ 矩形境界を生成 """
    bottom = center_lat - d_lat / 2
    left = center_lon - d_lon / 2
    top = bottom + d_lat
    right = left + d_lon

    return ((bottom, left), (top, right))

marker = Marker(location=[latitude, longitude],
                icon=AwesomeIcon(name='car',
                                 marker_color='red',
                                 icon_color='white'))
# 検索矩形
rect = Rectangle(bounds=gen_bounds(latitude, longitude))

marker.on_move(marker_callback)

```

次に、3の範囲内のガソリンスタンドの取得には[overpassAPI](https://wiki.openstreetmap.org/wiki/JA:Overpass_API)というOSMのライブラリを使用します。こちらの詳細は割愛しますが、以下の様に範囲内のOSM情報をクエリで取得することが可能です。これも簡単です。

```python
import overpass 
oapi = overpass.API()

query = "(node[amenity=fuel]({0}, {1}, {2}, {3}););".format(
        *bounds[0], *bounds[1])

response_fuel = oapi.get(query, verbosity='geom')
```

これを使い、検索矩形境界が更新された時に実行されるコールバック内でガソリンスタンドをMarkerオブジェクトとして追加します。

```python

# まとめて管理しやすいようタグを付けておく
group_name = "fuel"

def search_callback(change):
    """ 更新されたbounds内のfuelを検索して更新 """
    bounds = change["new"]
    update_fuels(m, bounds, group_name)


def remove_layers(m, group_name):
    """ layerから特定のタグが付いたオブジェクトを削除 """
    for l in m.layers:
        if hasattr(l, "alt") and l.alt == group_name:
            m.remove_layer(l)

def update_fuels(m, bounds, group_name):
    """ bounds内のfuelを検索してMapオブジェクトに追加 """

    query = "(node[amenity=fuel]({0}, {1}, {2}, {3}););".format(
        *bounds[0], *bounds[1])

    response_fuel = oapi.get(query, verbosity='geom')
    remove_layers(m, group_name)

    for features in response_fuel["features"]:
        marker = Marker(location=features["geometry"]["coordinates"][::-1],
                        alt=group_name,
                        icon=AwesomeIcon(name="plug",
                                         marker_color='green',
                                         icon_color='white'),
                        popup=HTML(value=features["properties"]["name"]))

        m.add_layer(marker)

# ovserveメソッドを用いてrectのboundsとコールバックを対応付ける
rect.observe(search_callback, "bounds")

```

簡単ですね。
ここまでで一通りの機能が揃ったので実行してみましょう。(※)
※コード全体は[github](https://github.com/kashee337/ac_ipyleaflet)を参照ください。

![画面収録 2021-12-18 15.38.53.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327853/c56d7a64-a585-212b-afa1-ed2fab0d4f4a.gif)

ガソリンスタンド情報を取得してくるところが時間かかるので多少ラグありますが、おおよそいい感じに動いてそうです。  
今回やったケース以外でもipywidgetsのウィジェット自体とも連携可能なのでスライダーをつけて各種パラメータを変更したりなど様々な応用が考えられそうです。
例えば、ルーティングの挙動確認ツールやシンプルなアノテーションツールなんかもこれで簡単に作れそうですね。今後も色々と遊んでみようと思います。

## 最後に
ここまで読んでいただきありがとうございました。

株式会社オプティマインドでは、一緒に働く仲間を大募集中です。
カジュアル面談も大歓迎ですので、気軽にお声がけください。

https://recruit.optimind.tech/


[^1]: https://www.techscore.com/tech/DesignPattern/Observer.html/
[^2]: https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html