Flet の調査 - Map コントロールの使い勝手を確認する

# はじめに

2024/6 (v0.23.0) に追加された Map コントロールが面白そうなので、使い勝手を確認してみる。

![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/05a67e9f-e8da-2005-52b5-0a6235ef9cf4.gif)

## Map コントロールの情報

- ドキュメント： https://flet.dev/docs/controls/map/
- さまざまなレイヤーを含むマップを表示するために利用できるもの

# 前提事項

- Flet v0.24.1


# 調査内容

## サンプルを実行してみる

まずは、ドキュメントにあるサンプルをコピペして実行してみる。

- 問題なく表示され、初期表示はアフリカ大陸
- クリックでマーカーを置くことができる
- 右クリックで〇を置くことができる
- マウスホイールで拡大縮小できる
- ドラッグで動かすことができる
- 日本の情報もそれなりに入っている
- 一番縮小すると変な感じになるので制御可能なのか気になった（地球4.7個分ぐらい見える）
- 地図のもとは OpenStreetMap というサービス … https://www.openstreetmap.org/about

<details><summary>サンプルソース</summary>

```python
import random
import flet as ft
import flet.map as map


def main(page: ft.Page):
    marker_layer_ref = ft.Ref[map.MarkerLayer]()
    circle_layer_ref = ft.Ref[map.CircleLayer]()

    def handle_tap(e: map.MapTapEvent):
        print(
            f"Name: {e.name} - coordinates: {e.coordinates} - Local: ({e.local_x}, {e.local_y}) - Global: ({e.global_x}, {e.global_y})"
        )
        if e.name == "tap":
            marker_layer_ref.current.markers.append(
                map.Marker(
                    content=ft.Icon(
                        ft.icons.LOCATION_ON, color=ft.cupertino_colors.DESTRUCTIVE_RED
                    ),
                    coordinates=e.coordinates,
                )
            )
        elif e.name == "secondary_tap":
            circle_layer_ref.current.circles.append(
                map.CircleMarker(
                    radius=random.randint(5, 10),
                    coordinates=e.coordinates,
                    color=ft.colors.random_color(),
                    border_color=ft.colors.random_color(),
                    border_stroke_width=4,
                )
            )
        page.update()

    def handle_event(e: map.MapEvent):
        print(
            f"{e.name} - Source: {e.source} - Center: {e.center} - Zoom: {e.zoom} - Rotation: {e.rotation}"
        )

    page.add(
        ft.Text("Click anywhere to add a Marker, right-click to add a CircleMarker."),
        map.Map(
            expand=True,
            configuration=map.MapConfiguration(
                initial_center=map.MapLatitudeLongitude(15, 10),
                initial_zoom=4.2,
                interaction_configuration=map.MapInteractionConfiguration(
                    flags=map.MapInteractiveFlag.ALL
                ),
                on_init=lambda e: print(f"Initialized Map"),
                on_tap=handle_tap,
                on_secondary_tap=handle_tap,
                on_long_press=handle_tap,
                on_event=handle_event,
            ),
            layers=[
                map.TileLayer(
                    url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png",
                    on_image_error=lambda e: print("TileLayer Error"),
                ),
                map.RichAttribution(
                    attributions=[
                        map.TextSourceAttribution(
                            text="OpenStreetMap Contributors",
                            on_click=lambda e: e.page.launch_url(
                                "https://openstreetmap.org/copyright"
                            ),
                        ),
                        map.TextSourceAttribution(
                            text="Flet",
                            on_click=lambda e: e.page.launch_url("https://flet.dev"),
                        ),
                    ]
                ),
                map.SimpleAttribution(
                    text="Flet",
                    alignment=ft.alignment.top_right,
                    on_click=lambda e: print("Clicked SimpleAttribution"),
                ),
                map.MarkerLayer(
                    ref=marker_layer_ref,
                    markers=[
                        map.Marker(
                            content=ft.Icon(ft.icons.LOCATION_ON),
                            coordinates=map.MapLatitudeLongitude(30, 15),
                        ),
                        map.Marker(
                            content=ft.Icon(ft.icons.LOCATION_ON),
                            coordinates=map.MapLatitudeLongitude(10, 10),
                        ),
                        map.Marker(
                            content=ft.Icon(ft.icons.LOCATION_ON),
                            coordinates=map.MapLatitudeLongitude(25, 45),
                        ),
                    ],
                ),
                map.CircleLayer(
                    ref=circle_layer_ref,
                    circles=[
                        map.CircleMarker(
                            radius=10,
                            coordinates=map.MapLatitudeLongitude(16, 24),
                            color=ft.colors.RED,
                            border_color=ft.colors.BLUE,
                            border_stroke_width=4,
                        ),
                    ],
                ),
                map.PolygonLayer(
                    polygons=[
                        map.PolygonMarker(
                            label="Popular Touristic Area",
                            label_text_style=ft.TextStyle(
                                color=ft.colors.BLACK,
                                size=15,
                                weight=ft.FontWeight.BOLD,
                            ),
                            color=ft.colors.with_opacity(0.3, ft.colors.BLUE),
                            coordinates=[
                                map.MapLatitudeLongitude(10, 10),
                                map.MapLatitudeLongitude(30, 15),
                                map.MapLatitudeLongitude(25, 45),
                            ],
                        ),
                    ],
                ),
                map.PolylineLayer(
                    polylines=[
                        map.PolylineMarker(
                            border_stroke_width=3,
                            border_color=ft.colors.RED,
                            gradient_colors=[ft.colors.BLACK, ft.colors.BLACK],
                            color=ft.colors.with_opacity(0.6, ft.colors.GREEN),
                            coordinates=[
                                map.MapLatitudeLongitude(10, 10),
                                map.MapLatitudeLongitude(30, 15),
                                map.MapLatitudeLongitude(25, 45),
                            ],
                        ),
                    ],
                ),
            ],
        ),
    )


ft.app(main)
```
</details>


↓初期表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/a165360a-d543-6e3b-57a1-34668151a889.png)

↓日本に移動した表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/172c9553-1d38-d399-5670-5f3151be95d3.png)

↓一番縮小した表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/e89f0602-3e1f-3ef6-da02-34dbb92bb4f8.png)

## 初期表示位置を変えてみる

初期表示位置を東京駅に変えてみる。

- コントロールの定義に設定があるので変更した
- ★1 で緯度と経度を指定するとそこが中心になるので、東京駅の緯度経度を調べて設定
- ★2 は、zoomが 4.2 だと足りないのでちょうどよさそうな 16 に変更

↓変更箇所抜粋
```python
        map.Map(
            expand=True,
            configuration=map.MapConfiguration(
                initial_center=map.MapLatitudeLongitude(35.6809591, 139.7673068), # ★1
                initial_zoom=16, # ★2
                interaction_configuration=map.MapInteractionConfiguration(
                    flags=map.MapInteractiveFlag.ALL
                ),
```

↓変更後の初期表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/c3699663-f4e5-7cf8-916c-e11f2a6ddff2.png)

## マーカーの表示を変更してみる

サンプルのマーカー表示がいまいち見えづらいので、見えやすい表示に改善する。

- Icon だけだと地図との境界がなくて見えづらい
- IconButton なども試したが、CircleAvatar に Icon を入れるが一番良さそうである
- 試しに食事場所をプロットするイメージのアイコンにしてみた

↓変更箇所抜粋
```python
    def handle_tap(e: map.MapTapEvent):
        print(
            f"Name: {e.name} - coordinates: {e.coordinates} - Local: ({e.local_x}, {e.local_y}) - Global: ({e.global_x}, {e.global_y})"
        )
        if e.name == "tap":
            marker_layer_ref.current.markers.append(
                map.Marker(
                    content=ft.CircleAvatar( # ここを変更
                        content=ft.Icon(ft.icons.DINING_OUTLINED),
                        color=ft.colors.RED,
                        bgcolor=ft.colors.WHITE,
                    ),
                    coordinates=e.coordinates,
                )
            )
```

↓元の表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/df611c43-ce1d-caa9-29a9-93ae28c2ca44.png)

↓変更後の表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/11f30f6f-b0b4-9a56-772d-7c3a2c393072.png)

## 縮小しすぎるのを防ぐ

デフォルトで最大まで縮小すると小さくなり過ぎるので制御をいれる。

- これは min_zoom を設定するだけで縮小を止めることが可能
- 今回は、min_zoom=12 ぐらいがちょうど良かった
- 同様に拡大も行き過ぎるので、max_zoom=18 を設定した
- Map を使うときは min_zoom, max_zoom を指定した方が良さそう

↓変更箇所抜粋
```python
        map.Map(
            expand=True,
            configuration=map.MapConfiguration(
                initial_center=map.MapLatitudeLongitude(35.6809591, 139.7673068),  # ★1
                initial_zoom=16,  # ★2
                interaction_configuration=map.MapInteractionConfiguration(
                    flags=map.MapInteractiveFlag.ALL,
                ),
                min_zoom=12,  # ここを追加
                max_zoom=18,  # ここを追加
```

↓min_zoom=12 で最大縮小した様子
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/42099bb2-d3a9-615b-2f66-d1375da950da.png)

↓max_zoom=18 で最大拡大した様子
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/0321e1db-ff2c-a643-386c-18c017620331.png)


## 情報が入力できるようにしてみる

マーカーを置くときに情報を入力するステップを追加する。

- マーカーを配置後、BottomSheet を表示
- 保存ボタンをクリックしたら情報を保存する（とりあえず保存はしない）
- キャンセルならマーカーを削除する

↓変更箇所抜粋（BottomSheet の実装）
```python
    def handle_bs_save(e):
        # TODO: ここで情報を保存する
        page.close(bs)

    def handle_bs_cancel(e):
        del marker_layer_ref.current.markers[-1]  # 最後のマーカーを削除する
        page.update()
        page.close(bs)

    def bs_open():
        tf_comment.value = ""  # コメントクリア
        page.open(bs)

    tf_comment = ft.TextField(
        label="コメント", multiline=True, max_lines=5, autofocus=True
    )

    bs = ft.BottomSheet(
        dismissible=False,
        content=ft.Container(
            padding=20,
            content=ft.Column(
                tight=True,
                controls=[
                    ft.Text("情報"),
                    tf_comment,
                    ft.Row(
                        [
                            ft.ElevatedButton("保存", on_click=handle_bs_save),
                            ft.ElevatedButton("キャンセル", on_click=handle_bs_cancel),
                        ]
                    ),
                ],
            ),
        ),
    )
```

↓変更箇所抜粋（tapでBottomSheetを表示）
```python
    def handle_tap(e: map.MapTapEvent):
        ・・・省略・・・
        if e.name == "tap":
            marker_layer_ref.current.markers.append(
                map.Marker(
                    content=ft.CircleAvatar(  # ここを変更
                        content=ft.Icon(ft.icons.DINING_OUTLINED),
                        color=ft.colors.RED,
                        bgcolor=ft.colors.WHITE,
                    ),
                    coordinates=e.coordinates,
                )
            )
            page.update()
            bs_open()  # BottomSheetを表示
```

↓実際の動き
![Animation1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/703a10ec-c8e6-1871-3c3a-363db4fce34c.gif)

## マーカーをクリックしたら情報を表示する

配置したマーカーをクリックしたら情報を表示するようにする。

- マーカーをクリックできるように Container で囲い、クリックの処理を足す
- BottomSheet の表示を新規と編集で出し分ける
- 編集のときは、保存するか、単に閉じるだけにする

↓変更箇所抜粋（BottomSheet を編集の場合に対応）
```python
    def bs_open(edit=False):
        tf_comment.value = ""  # コメントクリア
        buttons[1].visible = True
        buttons[2].visible = False
        if edit: # 編集の場合
            tf_comment.value = "ダミーコメント" # TODO:保存してある情報を持ってくる
            buttons[1].visible = False
            buttons[2].visible = True
        page.open(bs)
    ・・・省略・・・
    
    buttons = [
        ft.ElevatedButton("保存", on_click=handle_bs_save),
        ft.ElevatedButton("キャンセル", on_click=handle_bs_cancel),
        ft.ElevatedButton("閉じる", on_click=handle_bs_close),
    ]

    bs = ft.BottomSheet(
        dismissible=False,
        content=ft.Container(
            padding=20,
            content=ft.Column(
                tight=True,
                controls=[
                    ft.Text("情報"),
                    tf_comment,
                    ft.Row(buttons),
                ],
            ),
        ),
    )
```

↓変更箇所抜粋（Marker の設定を変更）
```python
    def handle_tap(e: map.MapTapEvent):
        ・・・省略・・・
        if e.name == "tap":
            marker_layer_ref.current.markers.append(
                map.Marker(
                    content=ft.Container(
                        ft.CircleAvatar(  # ここを変更
                            content=ft.Icon(ft.icons.DINING_OUTLINED),
                            color=ft.colors.RED,
                            bgcolor=ft.colors.WHITE,
                        ),
                        on_click=lambda e: bs_open(True), # クリックでBottomSheetを開く
                    ),
                    ・・・省略・・・
```

↓実際の動き
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/05a67e9f-e8da-2005-52b5-0a6235ef9cf4.gif)

# まとめ

Map コントロールを使えば、地図上に情報をプロットすることが簡単にできることが確認できた。
マーカーにはコントロールが指定できるので、工夫すればいろいろなことができそうではある。




以上です。