QGIS版Amazon Location Service PluginをOSSで公開
2年ぶりにQGISプラグインの開発をしました。これまでにQGISプラグイン本を執筆したり、いくつかのQGISプラグインをリリースした経験がありますが、久しぶりのQGISの開発がとても楽しかったです。
今回のAmazon Location Serviceを利用したQGISプラグインの開発は、おそらく世界初の試みで、このプラグインをOSSとして公開することにしました。まだすべての機能を実装できていませんが、今後さらに機能を追加していく予定です。
位置情報技術はさまざまな分野で活用されています。このプラグインを通じて、より多くの方々にAmazon Location Serviceの便利さや可能性を知っていただけることを期待しています。ぜひ一度お試しください!
今回は、このプラグインの利用方法について紹介します。
事前準備
Amazon Location Serviceのリソース構築
事前に、Amazon Location Serviceのリソースを構築します。
下記から選択しリソースを構築します。
- AWSマネジメントコンソール: GUIを使って手動でリソースを設定します。
- AWS CDK: コードを使ってインフラを自動化します。
- AWS CloudFormation: テンプレートを使ってリソースを自動構築します。
プラグインの利用方法
QGISプラグインのインストール
QGISプラグインをインストールします。プラグインは公式リポジトリに登録されており、QGIS上から直接インストール可能です。
- 「プラグイン」→「プラグインを管理およびインストール」を選択
- 「Amazon Location Service」で検索
メニュー
プラグインがインストールされたらメニューが表示されます。メニューは、Config、Map、Place、Routes、Termsの5種類があります。
- Config: 各リソース名とAPIキーを設定
- Map: 地図表示機能
- Place: ジオコーディング機能
- Routes: ルーティング機能
- Terms: 利用規約ページを表示
Config機能
各種設定機能です。リージョン名、APIキー、Map名、Place名、Routes名を設定します。
- 「Config」メニューをクリック
- 各リソース名とAPIキーを設定
- Region: ap-xxxxx
- API Key: v1.public.xxxxx
- Map Name: Mapxxxxx
- Place Name: Placexxxxx
- Routes Name: Routesxxxxx
- 「Save」をクリック
Map機能
地図表示機能です。取得したベクトルタイルを使用して、QGIS上にベクトルタイルレイヤーを作成します。
- 「Map」メニューをクリック
- 「Map Name」を選択
- 「Add」をクリック
- 背景地図がレイヤで表示
※ QGISはすべてのベクトルタイルスタイルに対応していないため、一部のスタイルが表示されない場合があります。
Place機能
ジオコーディング機能です。取得した住所データを使用して、QGIS上にポイントレイヤを作成します。
- 「Place」メニューをクリック
- 「Select Function」を選択
- 「Get Location」をクリック
- 検索したい位置をクリック
- 「Search」をクリック
- 検索結果がレイヤで表示
Routes機能
ルーティング機能です。取得したルートデータを使用して、QGIS上にラインレイヤを作成します。
- 「Routes」メニューをクリック
- 「Select Function」を選択
- 「Get Location(Starting Point)」をクリック
- 始点をクリック
- 「Get Location(End Point)」をクリック
- 終点をクリック
- 「Search」をクリック
- 検索結果がレイヤで表示
Terms機能
利用規約の表示機能です。
- 「Terms」メニューをクリック
- 利用規約ページがブラウザで表示
プラグインのコード
次に、プラグインのコードを一部紹介します。
全体構成
location_service/
├── LICENSE
├── __init__.py
├── location_service.py
├── metadata.txt
├── ui/
│ ├── __init__.py
│ ├── icon.png
│ ├── config/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── config.ui
│ │ ├── config.png
│ └── terms/
│ ├── __init__.py
│ ├── terms.py
│ ├── terms.png
│ ├── terms.ui
│ └── map/
│ ├── __init__.py
│ ├── map.py
│ ├── map.ui
│ ├── map.png
│ └── place/
│ ├── __init__.py
│ ├── place.py
│ ├── place.ui
│ ├── place.png
│ └── routes/
│ ├── __init__.py
│ ├── routes.py
│ ├── routes.ui
│ ├── routes.png
├── utils/
│ ├── __init__.py
│ ├── click_handler.py
│ ├── configuration_handler.py
│ ├── external_api_handler.py
└── functions/
├── __init__.py
├── map.py
├── place.py
├── routes.py
metadata.txt
QGISプラグインの設定ファイルです。プラグイン名、バージョン、アイコンのパスなどのメタデータを記載しています。
[general]
name=Amazon Location Service
description=QGIS Plugin for Amazon Location Service
about=This plugin uses the functionality of Amazon Location Service in QGIS.
qgisMinimumVersion=3.0
version=1.1
#Plugin main icon
icon=ui/icon.png
author=Yasunori Kirimoto
email=info@dayjournal.dev
homepage=https://github.com/dayjournal/qgis-amazonlocationservice-plugin
tracker=https://github.com/dayjournal/qgis-amazonlocationservice-plugin/issues
repository=https://github.com/dayjournal/qgis-amazonlocationservice-plugin
tags=aws,amazonlocationservice,map,geocoding,routing
category=
location_service.py
メインの処理です。プラグインのUI初期化と各種機能の設定を行います。
import os
from typing import Optional, Callable
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QAction, QWidget
from PyQt5.QtCore import Qt
from .ui.config.config import ConfigUi
from .ui.map.map import MapUi
from .ui.place.place import PlaceUi
from .ui.routes.routes import RoutesUi
from .ui.terms.terms import TermsUi
class LocationService:
"""
Manages the Amazon Location Service interface within a QGIS environment.
"""
MAIN_NAME = "Amazon Location Service"
def __init__(self, iface) -> None:
"""
Initializes the plugin interface, setting up UI components
and internal variables.
Args:
iface (QgsInterface): Reference to the QGIS app interface.
"""
self.iface = iface
self.main_window = self.iface.mainWindow()
self.plugin_directory = os.path.dirname(__file__)
self.actions = []
self.toolbar = self.iface.addToolBar(self.MAIN_NAME)
self.toolbar.setObjectName(self.MAIN_NAME)
self.config = ConfigUi()
self.map = MapUi()
self.place = PlaceUi()
self.routes = RoutesUi()
self.terms = TermsUi()
for component in [self.config, self.map, self.place, self.routes]:
component.hide()
def add_action(
self,
icon_path: str,
text: str,
callback: Callable,
enabled_flag: bool = True,
add_to_menu: bool = True,
add_to_toolbar: bool = True,
status_tip: Optional[str] = None,
whats_this: Optional[str] = None,
parent: Optional[QWidget] = None,
) -> QAction:
"""
Adds an action to the plugin menu and toolbar.
Args:
icon_path (str): Path to the icon.
text (str): Display text.
callback (Callable): Function to call on trigger.
enabled_flag (bool): Is the action enabled by default.
add_to_menu (bool): Should the action be added to the menu.
add_to_toolbar (bool): Should the action be added to the toolbar.
status_tip (Optional[str]): Text for status bar on hover.
whats_this (Optional[str]): Longer description of the action.
parent (Optional[QWidget]): Parent widget.
Returns:
QAction: The created action.
"""
icon = QIcon(icon_path)
action = QAction(icon, text, parent)
action.triggered.connect(callback)
action.setEnabled(enabled_flag)
if status_tip is not None:
action.setStatusTip(status_tip)
if whats_this is not None:
action.setWhatsThis(whats_this)
if add_to_menu:
self.iface.addPluginToMenu(self.MAIN_NAME, action)
if add_to_toolbar:
self.toolbar.addAction(action)
self.actions.append(action)
return action
def initGui(self) -> None:
"""
Initializes the GUI components, adding actions to the interface.
"""
components = ["config", "map", "place", "routes", "terms"]
for component_name in components:
icon_path = os.path.join(
self.plugin_directory, f"ui/{component_name}/{component_name}.png"
)
self.add_action(
icon_path=icon_path,
text=component_name.capitalize(),
callback=getattr(self, f"show_{component_name}"),
parent=self.main_window,
)
def unload(self) -> None:
"""
Cleans up the plugin interface by removing actions and toolbar.
"""
for action in self.actions:
self.iface.removePluginMenu(self.MAIN_NAME, action)
self.iface.removeToolBarIcon(action)
del self.toolbar
def show_config(self) -> None:
"""
Displays the configuration dialog window.
"""
self.config.setWindowFlags(Qt.WindowStaysOnTopHint) # type: ignore
self.config.show()
def show_map(self) -> None:
"""
Displays the map dialog window.
"""
self.map.setWindowFlags(Qt.WindowStaysOnTopHint) # type: ignore
self.map.show()
def show_place(self) -> None:
"""
Displays the place dialog window.
"""
self.place.setWindowFlags(Qt.WindowStaysOnTopHint) # type: ignore
self.place.show()
def show_routes(self) -> None:
"""
Displays the routes dialog window.
"""
self.routes.setWindowFlags(Qt.WindowStaysOnTopHint) # type: ignore
self.routes.show()
def show_terms(self) -> None:
"""
Opens the service terms URL in the default web browser.
"""
self.terms.open_service_terms_url()
ui/map/map.ui
UIファイルです。Qt Designerで作成されたダイアログで、ラベル、コンボボックス、ボタンを定義しています。
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>358</width>
<height>166</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>240</width>
<height>0</height>
</size>
</property>
<property name="windowTitle">
<string>Map</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="main_label">
<property name="text">
<string><html><head/><body><p><span style=" font-size:18pt;">Map</span></p></body></html></string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string/>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="map_label">
<property name="text">
<string>Map Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="map_comboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="button_add">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_cancel">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
ui/map/map.py
UI処理です。UIコンポーネントの読み込みや設定オプションの表示を行います。
import os
from PyQt5.QtWidgets import QDialog, QMessageBox
from qgis.PyQt import uic
from ...utils.configuration_handler import ConfigurationHandler
from ...functions.map import MapFunctions
class MapUi(QDialog):
"""
A dialog for managing map configurations and adding vector tile layers to a
QGIS project.
"""
UI_PATH = os.path.join(os.path.dirname(__file__), "map.ui")
KEY_MAP = "map_value"
def __init__(self) -> None:
"""
Initializes the Map dialog, loads UI components, and populates the map options.
"""
super().__init__()
self.ui = uic.loadUi(self.UI_PATH, self)
self.button_add.clicked.connect(self._add)
self.button_cancel.clicked.connect(self._cancel)
self.map = MapFunctions()
self.configuration_handler = ConfigurationHandler()
self._populate_map_options()
def _populate_map_options(self) -> None:
"""
Populates the map options dropdown with available configurations.
"""
map = self.configuration_handler.get_setting(self.KEY_MAP)
self.map_comboBox.addItem(map)
def _add(self) -> None:
"""
Adds the selected vector tile layer to the QGIS project and closes the dialog.
"""
try:
self.map.add_vector_tile_layer()
self.close()
except Exception as e:
QMessageBox.critical(
self, "Error", f"Failed to add vector tile layer: {str(e)}"
)
def _cancel(self) -> None:
"""
Cancels the operation and closes the dialog without making changes.
"""
self.close()
utils/click_handler.py
地図のクリック処理です。地図のクリック位置の座標を取得し、指定したUIに反映します。
from typing import Any
from qgis.gui import QgsMapTool, QgsMapCanvas, QgsMapMouseEvent
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsProject,
QgsCoordinateTransform,
QgsPointXY,
)
class MapClickCoordinateUpdater(QgsMapTool):
"""
A tool for updating UI fields with geographic coordinates based on map clicks.
"""
WGS84_CRS = "EPSG:4326"
PLACE_LONGITUDE = "lon_lineEdit"
PLACE_LATITUDE = "lat_lineEdit"
ST_ROUTES_LONGITUDE = "st_lon_lineEdit"
ST_ROUTES_LATITUDE = "st_lat_lineEdit"
ED_ROUTES_LONGITUDE = "ed_lon_lineEdit"
ED_ROUTES_LATITUDE = "ed_lat_lineEdit"
def __init__(self, canvas: QgsMapCanvas, active_ui: Any, active_type: str) -> None:
"""
Initializes the MapClickCoordinateUpdater with a map canvas, UI references,
and the type of coordinates to update.
"""
super().__init__(canvas)
self.active_ui = active_ui
self.active_type = active_type
def canvasPressEvent(self, e: QgsMapMouseEvent) -> None:
"""
Processes mouse press events on the map canvas, converting the click location
to WGS84 coordinates and updating the UI.
"""
map_point = self.toMapCoordinates(e.pos())
wgs84_point = self.transform_to_wgs84(map_point)
self.update_ui(wgs84_point)
def update_ui(self, wgs84_point: QgsPointXY) -> None:
"""
Dynamically updates UI fields designated for longitude and latitude with
new coordinates from map interactions.
"""
field_mapping = {
"st_routes": (self.ST_ROUTES_LONGITUDE, self.ST_ROUTES_LATITUDE),
"ed_routes": (self.ED_ROUTES_LONGITUDE, self.ED_ROUTES_LATITUDE),
"place": (self.PLACE_LONGITUDE, self.PLACE_LATITUDE),
}
if self.active_type in field_mapping:
lon_field, lat_field = field_mapping[self.active_type]
self.set_text_fields(lon_field, lat_field, wgs84_point)
def set_text_fields(
self, lon_field: str, lat_field: str, wgs84_point: QgsPointXY
) -> None:
"""
Helper method to set the text of UI fields designated for longitude and
latitude.
"""
getattr(self.active_ui, lon_field).setText(str(wgs84_point.x()))
getattr(self.active_ui, lat_field).setText(str(wgs84_point.y()))
def transform_to_wgs84(self, map_point: QgsPointXY) -> QgsPointXY:
"""
Converts map coordinates to the WGS84 coordinate system, ensuring global
standardization of the location data.
Args:
map_point (QgsPointXY): A point in the current map's coordinate system
that needs to be standardized.
Returns:
QgsPointXY: The transformed point in WGS84 coordinates, suitable for
global mapping applications.
"""
canvas_crs = QgsProject.instance().crs()
wgs84_crs = QgsCoordinateReferenceSystem(self.WGS84_CRS)
transform = QgsCoordinateTransform(canvas_crs, wgs84_crs, QgsProject.instance())
return transform.transform(map_point)
functions/routes.py
ルーティング機能です。取得したルートデータを使用して、QGIS上にラインレイヤを作成します。
from typing import Dict, Tuple, Any
from PyQt5.QtCore import QVariant
from PyQt5.QtGui import QColor
from qgis.core import (
QgsProject,
QgsVectorLayer,
QgsFields,
QgsField,
QgsPointXY,
QgsFeature,
QgsGeometry,
QgsSimpleLineSymbolLayer,
QgsSymbol,
QgsSingleSymbolRenderer,
)
from ..utils.configuration_handler import ConfigurationHandler
from ..utils.external_api_handler import ExternalApiHandler
class RoutesFunctions:
"""
Manages the calculation and visualization of routes between two points on a map.
"""
KEY_REGION = "region_value"
KEY_ROUTES = "routes_value"
KEY_APIKEY = "apikey_value"
WGS84_CRS = "EPSG:4326"
LAYER_TYPE = "LineString"
FIELD_DISTANCE = "Distance"
FIELD_DURATION = "DurationSec"
LINE_COLOR = QColor(255, 0, 0)
LINE_WIDTH = 2.0
def __init__(self) -> None:
"""
Initializes the RoutesFunctions class with configuration and API handlers.
"""
self.configuration_handler = ConfigurationHandler()
self.api_handler = ExternalApiHandler()
def get_configuration_settings(self) -> Tuple[str, str, str]:
"""
Fetches necessary configuration settings from the settings manager.
Returns:
Tuple[str, str, str]: A tuple containing the region,
route calculator name, and API key.
"""
region = self.configuration_handler.get_setting(self.KEY_REGION)
routes = self.configuration_handler.get_setting(self.KEY_ROUTES)
apikey = self.configuration_handler.get_setting(self.KEY_APIKEY)
return region, routes, apikey
def calculate_route(
self, st_lon: float, st_lat: float, ed_lon: float, ed_lat: float
) -> Dict[str, Any]:
"""
Calculates a route from start to end coordinates using an external API.
Args:
st_lon (float): Longitude of the start position.
st_lat (float): Latitude of the start position.
ed_lon (float): Longitude of the end position.
ed_lat (float): Latitude of the end position.
Returns:
A dictionary containing the calculated route data.
"""
region, routes, apikey = self.get_configuration_settings()
routes_url = (
f"https://routes.geo.{region}.amazonaws.com/routes/v0/calculators/"
f"{routes}/calculate/route?key={apikey}"
)
data = {
"DeparturePosition": [st_lon, st_lat],
"DestinationPosition": [ed_lon, ed_lat],
"IncludeLegGeometry": "true",
}
result = self.api_handler.send_json_post_request(routes_url, data)
if result is None:
raise ValueError("Failed to receive a valid response from the API.")
return result
def add_line_layer(self, data: Dict[str, Any]) -> None:
"""
Adds a line layer to the QGIS project based on route data provided.
Args:
data (Dict): Route data including the route legs and geometry.
"""
routes = self.configuration_handler.get_setting(self.KEY_ROUTES)
layer = QgsVectorLayer(
f"{self.LAYER_TYPE}?crs={self.WGS84_CRS}", routes, "memory"
)
self.setup_layer(layer, data)
def setup_layer(self, layer: QgsVectorLayer, data: Dict[str, Any]) -> None:
"""
Configures the given layer with attributes, features,
and styling based on route data.
Args:
layer (QgsVectorLayer): The vector layer to be configured.
data (Dict): Route data used to populate the layer.
"""
self.add_attributes(layer)
self.add_features(layer, data)
self.apply_layer_style(layer)
layer.triggerRepaint()
QgsProject.instance().addMapLayer(layer)
def add_attributes(self, layer: QgsVectorLayer) -> None:
"""
Adds necessary fields to the vector layer.
Args:
layer (QgsVectorLayer): The layer to which fields are added.
"""
fields = QgsFields()
fields.append(QgsField(self.FIELD_DISTANCE, QVariant.Double))
fields.append(QgsField(self.FIELD_DURATION, QVariant.Int))
layer.dataProvider().addAttributes(fields)
layer.updateFields()
def add_features(self, layer: QgsVectorLayer, data: Dict[str, Any]) -> None:
"""
Adds features to the layer based on the route data.
Args:
layer (QgsVectorLayer): The layer to which features are added.
data (Dict): The route data containing legs and geometry.
"""
features = []
for leg in data["Legs"]:
line_points = [
QgsPointXY(coord[0], coord[1])
for coord in leg["Geometry"]["LineString"]
]
geometry = QgsGeometry.fromPolylineXY(line_points)
feature = QgsFeature(layer.fields())
feature.setGeometry(geometry)
feature.setAttributes([leg["Distance"], leg["DurationSeconds"]])
features.append(feature)
layer.dataProvider().addFeatures(features)
def apply_layer_style(self, layer: QgsVectorLayer) -> None:
"""
Applies styling to the layer to visually differentiate it.
Args:
layer (QgsVectorLayer): The layer to be styled.
"""
symbol_layer = QgsSimpleLineSymbolLayer()
symbol_layer.setColor(self.LINE_COLOR)
symbol_layer.setWidth(self.LINE_WIDTH)
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.changeSymbolLayer(0, symbol_layer)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
利用規約
Amazon Location Serviceにはデータ利用について利用規約があります。「82. Amazon Location Serviceプレビュー」の項目を確認し、自己責任でご利用ください。
HEREをプロバイダとして使用する場合、基本的な利用規約に加えて、次のことを行うことはできません。
- ジオコード化および逆ジオコード化の結果を含む日本のロケーションデータを保管またはキャッシュすること。
- 別の第三者プロバイダからのマップの上にHEREのルートを重ねること、またはHEREのマップ上に他の第三者プロバイダからのルートを重ねること。
Discussion