QGIS3でpythonプラグインを作ってみた その5 地図をクリックして地物を選択する

# QGIS3でpythonプラグインを作る その5

## 目標
[前回](https://qiita.com/ozo360/items/68733fea075c1a037367)作ったプラグインに、地図（マップキャンバス）をクリックして地物の選択ができる機能を追加する。

押さえたいところは

- マップキャンバスのイベントを取得する方法
- クリックイベントから座標を取得する方法
- 座標から対象レイヤの地物を検索する方法
- 一番近い点だけを選択状態にする方法

## 下準備
- ポイントのシェープファイルがレイヤ登録されたQGISプロジェクトを用意。ダウンロードは[こちら](https://github.com/ozo360/TestPlugin/raw/master/qgs.zip)
- 前回作ったプラグインを有効にしておく。GitHubのリポジトリは[こちら](https://github.com/ozo360/TestPlugin)
- QGISのPluginReloaderプラグインを有効にしておく（任意）

## GUI編集
QGIS付属の「Qt Designer with QGIS 3.0.0 custom widgets」を使ってGUIを編集します。
目標だけならボタン１個でいいのですが属性を表示するエディットラインも付けておきます。
![ui2.png](https://qiita-image-store.s3.amazonaws.com/0/240181/3444d993-4719-6aca-4c3c-e840af0f141a.png)

| 物理名 | 論理名 | 備考 |
|:-----|:------|:----|
| btn1 | 地物カウントボタン | **その2で使用**<br>アクティブレイヤの地物数を表示 |
| cmb | レイヤコンボ | **その3で使用**<br>選択したレイヤの地物数を表示 |
| lineEditLat | 緯度入力 | **その4で使用**<br>ポイント追加する緯度を入力 |
| lineEditLon | 経度入力 | **その4で使用**<br>ポイント追加する経度を入力 |
| btnAdd | ポイント追加ボタン | **その4で使用**<br>ポイントを追加 |
| btnMove | ポイント移動ボタン | **その4で使用**<br>ポイントを移動 |
| btnDel | ポイント削除ボタン | **その4で使用**<br>ポイントを削除 |
| btnCanvasEvent | キャンバスイベント切替ボタン | マップキャンバスイベントの切替<br>checkableをTrueに設定してトグルボタンとして使用 |
| lineEditId| id入力 | 地物属性idを表示<br>readOnlyをTrue |
| lineEditName| name入力 | 地物属性nameを表示<br>readOnlyをTrue |

## マップキャンバスのイベントを取得する
### 他のプラグインがどのように実装しているか調査する
マップキャンバスのイベントを取得する方法は手探りなので他のプラグインを覗いて調べてみました。
参考にしたのはこの２つです。
[GeoCoding](https://www.itopen.it/geocoding-qgis-plugins-released/)　[[GitHub](https://github.com/elpaso/qgis-geocoding)]
Shape Tools　[[GitHub](https://github.com/NationalSecurityAgency/qgis-shapetools-plugin)]

---
GeoCodingでは以下のようにClickToolのインスタンスをマップキャンバスのマップツールにセットしています。

```python:GeoCoding.py
        ct = ClickTool(self.iface,  self.reverse_action);
        self.previous_map_tool = self.iface.mapCanvas().mapTool()
        self.iface.mapCanvas().setMapTool(ct)
```

ClickToolはマップツールを継承したクラスでリリース（マウスアップ）イベントをオーバーライドしています。
コンストラクタでコールバック用のメソッドを受け取ってイベント時に実行する仕組みですね。

```python:Utils.py
class ClickTool(QgsMapTool):
    def __init__(self,iface, callback):
        QgsMapTool.__init__(self,iface.mapCanvas())
        self.iface      = iface
        self.callback   = callback
        self.canvas     = iface.mapCanvas()
        return None

    def canvasReleaseEvent(self,e):
        point = self.canvas.getCoordinateTransform().toMapPoint(e.pos().x(),e.pos().y())
        self.callback(point)
        return None
```
---
Shape Toolsは以下のようにgeodesicMeasureToolのインスタンスをマップキャンバスのマップツールにセットしています。

```python:shapeTools.py
    def initGui(self):
...
        # Initialize Geodesic Measure Tool
        self.geodesicMeasureTool = GeodesicMeasureTool(self.iface, self.iface.mainWindow())
...
    def measureTool(self):
        self.measureAction.setChecked(True)
        self.canvas.setMapTool(self.geodesicMeasureTool)
```

geodesicMeasureToolもマップツールを継承したクラスでプレス（クリック）イベントとムーブイベントをオーバーライドしています。
こちらは画面を持っていてクラスの中でイベント処理をしています。

```python:geodesicMeasureTool.py
class GeodesicMeasureTool(QgsMapTool):
    def __init__(self, iface, parent):
        QgsMapTool.__init__(self, iface.mapCanvas())
        self.iface = iface
        self.canvas = iface.mapCanvas()
        self.measureDialog = GeodesicMeasureDialog(iface, parent)
...
    def canvasPressEvent(self, event):
        '''Capture the coordinates when the user click on the mouse for measurements.'''
        if not self.measureDialog.isVisible():
            self.measureDialog.show()
            return
        if not self.measureDialog.ready():
            return
        pt = event.mapPoint()
        button = event.button()
        canvasCRS = self.canvas.mapSettings().destinationCrs()
        if canvasCRS != epsg4326:
            transform = QgsCoordinateTransform(canvasCRS, epsg4326, QgsProject.instance())
            pt = transform.transform(pt.x(), pt.y())
        self.measureDialog.addPoint(pt, button)
        if button == 2:
            self.measureDialog.stop()

    def canvasMoveEvent(self, event):
        '''Capture the coordinate as the user moves the mouse over
        the canvas.'''
        if self.measureDialog.motionReady():
            try:
                pt = event.mapPoint()
                canvasCRS = self.canvas.mapSettings().destinationCrs()
                if canvasCRS != epsg4326:
                    transform = QgsCoordinateTransform(canvasCRS, epsg4326, QgsProject.instance())
                    pt = transform.transform(pt.x(), pt.y())
                self.measureDialog.inMotion(pt)
            except:
                return
```

---

というわけでマップキャンバスのイベントを取得するには

- QgsMapToolを継承したクラスを用意してイベントをオーバーライドする。
- `self.iface.mapCanvas().setMapTool()`に処理したいマップツール継承クラスをセットする。

で良いようです。意外と簡単でした。

###テストしてみましょう。

```Python:test_plugin.py
from qgis.gui import QgsMapTool
...
    def dlgUpdate(self):
        if self.first_flg:
...
            self.dlg.btnCanvasEvent.toggled.connect(self.btnCanvasEventToggled)
...
    def btnCanvasEventToggled(self, checked):
        if checked:
            self.dlg.btnCanvasEvent.setText('ON')
            # 元のマップツールを退避
            self.previous_map_tool = self.iface.mapCanvas().mapTool()
            cmt = CustomMapTool(self.iface, self.CanvasPress)
            self.iface.mapCanvas().setMapTool(cmt)

        else:
            self.dlg.btnCanvasEvent.setText('OFF')
            # 元のマップツールに戻す
            self.iface.mapCanvas().setMapTool(self.previous_map_tool)

    def CanvasPress(self, event):
        QMessageBox.information(self.dlg, 'info', 'press')

class CustomMapTool(QgsMapTool):
    def __init__(self, iface, callback):
        QgsMapTool.__init__(self, iface.mapCanvas())
        self.callback = callback

    def canvasPressEvent(self, event):
        # コールバック関数を実行
        self.callback(event)
```

プラグインをリロードしてから実行してキャンバスイベント切替ボタンをクリックしてマップツールを切り替えます。
マップキャンバスをクリックするとメッセージが表示されました。
もう一度キャンバスイベント切替ボタンをクリックしてマップツールを元に戻してからもう一度マップキャンバスをクリックするとメッセージは出なくなりました。
大丈夫そうです。

##クリックイベントから座標を取得する方法
Shape Toolsの上記引用箇所にすでに答えが載っていますね。
こんな感じで取れました。

```python
        pt = event.mapPoint()
        lon = pt.x()
        lat = pt.y()
```

## 座標から対象レイヤの地物を検索する方法
クリックした地点をピンポイントで検索するAPIは無いので、クリックした地点に矩形を作ってその矩形に含まれる地物を検索することになります。
QGIS2.18にあった`maplayer.select(rect)`は無くなったので`maplayer.selectByRect(rect,SelectBehaviour)`を使います。
では矩形を作って検索してみましょう。

```python:test_plugin.py
    def CanvasPress(self, event):
        # アクティブレイヤを取得
        act_layer = self.iface.activeLayer()
        if act_layer is None or act_layer.type() != QgsMapLayer.VectorLayer:
            return

        pt = event.mapPoint()
        # 検索バッファ
        buffer = 0.001
        rect = QgsRectangle(pt.x() - buffer,
                            pt.y() - buffer,
                            pt.x() + buffer,
                            pt.y() + buffer)
        # SelectBehaviourはデフォルト（省略可能）
        act_layer.selectByRect(rect)
```

プラグインをリロードして実行するとクリックした付近のポイントが選択されました。

## 一番近い点だけを選択状態にする方法
サンプルで使用しているレイヤは地物がはまばらなので問題ありませんが、実際のデータでは隣り合うデータがある場合に選択しようと思った地物以外が選択されてしまうこともあるでしょう。
そこでクリックした地点に一番近い地物だけを選択状態にする処理を入れてみます。

まずはテスト動作時にクリックしたら複数選ばれるように検索バッファを広げてみます。

```
buffer = 0.01
```
複数地物が選択されたらクリック地点との距離を計算して最も近い地物だけ選択状にします。

```python:test_plugin.py
import math
...
        feat_list = act_layer.selectedFeatures()
        # 初期値を設定
        min = None
        fid = -1
        for feat in feat_list:
            geom = feat.geometry()
            pointxy = geom.asPoint()
            # 2点間の距離を計算
            d = math.sqrt((pointxy.x() - pt.x()) ** 2 + (pointxy.y() - pt.y()) ** 2)
            # 最短距離なら更新
            if min is None or d < min:
                min = d
                fid = feat.id()
                id = feat['id']
                name = feat['name']

        # 選択をクリア
        act_layer.removeSelection()

        if fid > -1:
            act_layer.select(fid)
            self.dlg.lineEditId.setText(str(id))
            self.dlg.lineEditName.setText(name)
```

プラグインをリロードして実行するとクリックした付近のポイントが選択されて属性が表示されました。
今回はかなり適当な場所をクリックしても最も近いポイントが選択されるようになったと思います。

## 動作確認
~~この記事のプラグインをGitHubに公開しました。
https://github.com/ozo360/TestPlugin/tree/part5~~

プラグインからアクティブレイヤの地物選択ができるようになりました。
***目的達成です。***

## まとめ
マップキャンバスのイベントを取得するには
QgsMapToolを継承したクラスを用意してイベントをオーバーライドする。
`self.iface.mapCanvas().setMapTool()`に処理したいマップツール継承クラスをセットする。

マップツールは使い終わったら元に戻す。

クリックイベントからポイントを取得するには`event.mapPoint()`を使う。

レイヤの検索は`maplayer.select(rect)`から`maplayer.selectByRect(rect,SelectBehaviour)`に変更。

2点間の距離を計算方法

```
math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
```

# 最後に
QGISの基本的操作をプラグインから操作できるようになったと思います。
困った時は今回の記事でやったように既存プラグインのソースを読むとヒントが見るかることが多いのでお勧めですし、必要ならがっつりコピーしても（ライセンス的にも）大丈夫です。
プラグイン操作に限定するため座標系について極力省いて説明をしています。実際には意識して作らないといけないことが多いかもしれないですけど、とりあえず気軽に作ってみたら何とかなるはず。

QGISプラグイン制作についての記事で、なにかリクエストがあればコメントいただけたら嬉しいです。
以上です。

## 関連記事
[QGIS3でpythonプラグインを作ってみた その1 ベース作成](https://qiita.com/ozo360/items/399d3257bf4c24bf27e4)
[QGIS3でpythonプラグインを作ってみた その2 QButtonと選択レイヤ取得について](https://qiita.com/ozo360/items/1b8ea895be8ec6d1f5f7)
[QGIS3でpythonプラグインを作ってみた その3  QComboBoxとレイヤ取得について](https://qiita.com/ozo360/items/2c20e304b63b59f07a54)
[QGIS3でpythonプラグインを作ってみた その4  地物の追加編集削除について](https://qiita.com/ozo360/items/68733fea075c1a037367)
[QGIS3でpythonプラグインを作ってみた その5 地図をクリックして地物を選択する](https://qiita.com/ozo360/items/7ab448c57eac5140f2fe)

# 本記事のライセンス

<a rel="license" href="http://creativecommons.org/licenses/by/4.0/">![クリエイティブ・コモンズ・ライセンス](https://i.creativecommons.org/l/by/4.0/88x31.png)</a>
この記事は <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">クリエイティブ・コモンズ 表示 4.0 国際 ライセンス</a>の下に提供されています。