Skip to content

Commit

Permalink
Add "Add Feature Collection" button, buildout menu tooling (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
alukach authored Feb 24, 2025
1 parent 08e7bb8 commit 9196a2f
Show file tree
Hide file tree
Showing 10 changed files with 571 additions and 25 deletions.
71 changes: 46 additions & 25 deletions ee_plugin/ee_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import ee

from . import provider, config, ee_auth, utils
from .ui.forms import add_feature_collection
from .ui import menus


PLUGIN_DIR = os.path.dirname(__file__)
Expand Down Expand Up @@ -92,8 +94,7 @@ def tr(self, message):
:returns: Translated version of message.
:rtype: QString
"""
# noinspection PyTypeChecker,PyArgumentList,PyCallByClass
return QCoreApplication.translate("GoogleEarthEngine", message)
return utils.translate(message)

def initGui(self):
"""Initialize the plugin GUI."""
Expand All @@ -116,36 +117,56 @@ def initGui(self):
parent=self.iface.mainWindow(),
triggered=self._run_cmd_set_cloud_project,
)
add_fc_button = QtWidgets.QAction(
text=self.tr("Add Feature Collection"),
parent=self.iface.mainWindow(),
triggered=lambda: add_feature_collection.form(
self.iface,
accepted=add_feature_collection.callback,
),
)

# Build plugin menu
# Initialize plugin menu
plugin_menu = cast(QtWidgets.QMenu, self.iface.pluginMenu())
ee_menu = plugin_menu.addMenu(
self.menu = plugin_menu.addMenu(
icon("earth-engine.svg"),
self.tr("&Google Earth Engine"),
)
self.menu = ee_menu
ee_menu.addAction(ee_user_guide_action)
ee_menu.addSeparator()
ee_menu.addAction(sign_in_action)
ee_menu.addAction(self.set_cloud_project_action)

# Build toolbar
toolButton = QtWidgets.QToolButton()
toolButton.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonIconOnly
# Qt.ToolButtonStyle.ToolButtonTextBesideIcon

# Initialize toolbar menu
self.toolButton = QtWidgets.QToolButton()
self.toolButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.toolButton.setPopupMode(
QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup
)
toolButton.setPopupMode(
QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup
self.toolButton.setMenu(QtWidgets.QMenu())
self.toolButton.setDefaultAction(
QtWidgets.QAction(
icon=icon("earth-engine.svg"),
text=f'<strong>{self.tr("Google Earth Engine")}</strong>',
parent=self.iface.mainWindow(),
)
)
toolButton.setMenu(QtWidgets.QMenu())
toolButton.setDefaultAction(ee_user_guide_action)
toolButton.menu().addAction(ee_user_guide_action)
toolButton.menu().addSeparator()
toolButton.menu().addAction(sign_in_action)
toolButton.menu().addAction(self.set_cloud_project_action)
self.iface.pluginToolBar().addWidget(toolButton)
self.toolButton = toolButton
self.iface.pluginToolBar().addWidget(self.toolButton)

# Populate menus
for m in (self.menu, self.toolButton.menu()):
menus.populate_menu(
menu=m,
items=[
menus.Action(action=ee_user_guide_action),
menus.Separator(),
menus.Action(action=sign_in_action),
menus.Action(action=self.set_cloud_project_action),
menus.Separator(),
menus.SubMenu(
label=self.tr("Add Layer"),
subitems=[
menus.Action(action=add_fc_button),
],
),
],
)

# Register signal to initialize EE layers on project load
self.iface.projectRead.connect(self._updateLayers)
Expand Down
Empty file added ee_plugin/ui/forms/__init__.py
Empty file.
165 changes: 165 additions & 0 deletions ee_plugin/ui/forms/add_feature_collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from typing import Optional, Callable
from qgis import gui
from qgis.PyQt import QtWidgets
import ee

from .. import widgets, utils as ui_utils
from ... import Map, utils
from ...utils import translate as _


def form(
iface: gui.QgisInterface,
accepted: Optional[Callable] = None,
**dialog_kwargs,
) -> QtWidgets.QDialog:
"""Add a GEE Feature Collection to the map."""
dialog = widgets.build_vbox_dialog(
windowTitle=_("Add Feature Collection"),
widgets=[
widgets.build_form_group_box(
title=_("Source"),
rows=[
(
QtWidgets.QLabel(
toolTip=_("The Earth Engine Feature Collection ID."),
text="<br />".join(
[
_("Feature Collection ID"),
"e.g. <code>USGS/WBD/2017/HUC06</code>",
]
),
),
QtWidgets.QLineEdit(
objectName="feature_collection_id",
),
),
(
QtWidgets.QLabel(
_("Retain as a vector layer"),
toolTip=_(
"Store as a vector layer rather than WMS Raster layer."
),
whatsThis=_(
"Attempt to retain the layer as a vector layer, running "
"the risk of encountering Earth Engine API limitations if "
"the layer is large. Otherwise, the layer will be added as "
"a WMS raster layer."
),
),
QtWidgets.QCheckBox(
objectName="as_vector",
),
),
],
),
widgets.build_form_group_box(
title=_("Filter by Properties"),
collapsable=True,
collapsed=True,
rows=[
(
_("Name"),
QtWidgets.QLineEdit(objectName="filter_name"),
),
(
_("Value"),
QtWidgets.QLineEdit(objectName="filter_value"),
),
],
),
widgets.build_form_group_box(
title=_("Filter by Dates"),
collapsable=True,
collapsed=True,
rows=[
(
"Start",
widgets.DefaultNullQgsDateEdit(objectName="start_date"),
),
(
"End",
widgets.DefaultNullQgsDateEdit(objectName="end_date"),
),
],
),
gui.QgsExtentGroupBox(
objectName="extent",
title=_("Filter by Coordinates"),
collapsed=True,
),
widgets.build_form_group_box(
title=_("Visualization"),
collapsable=True,
collapsed=True,
rows=[
(
_("Color"),
gui.QgsColorButton(objectName="viz_color_hex"),
),
],
),
],
parent=iface.mainWindow(),
**dialog_kwargs,
)

# If a callback function passed, call it with the values from the dialog
if accepted:
dialog.accepted.connect(
lambda: ui_utils.call_func_with_values(accepted, dialog)
)
return dialog


def callback(
feature_collection_id: str,
filter_name: str,
filter_value: str,
start_date: Optional[str],
end_date: Optional[str],
extent: Optional[tuple[float, float, float, float]],
viz_color_hex: str,
as_vector: bool,
):
"""
Loads and optionally filters a FeatureCollection, then adds it to the map.
Args:
feature_collection_id (str): The Earth Engine FeatureCollection ID.
filter_name (str, optional): Name of the attribute to filter on.
filter_value (str, optional): Value of the attribute to match.
start_date (str, optional): Start date (YYYY-MM-DD) for filtering (must have a date property in your FC).
end_date (str, optional): End date (YYYY-MM-DD) for filtering (must have a date property in your FC).
extent (ee.Geometry, optional): Geometry to filter (or clip) the FeatureCollection.
viz_color_hex (str, optional): Hex color code for styling the features.
Returns:
ee.FeatureCollection: The filtered FeatureCollection.
"""

fc = ee.FeatureCollection(feature_collection_id)

if filter_name and filter_value:
fc = fc.filter(ee.Filter.eq(filter_name, filter_value))

if start_date and end_date:
fc = fc.filter(ee.Filter.date(ee.Date(start_date), ee.Date(end_date)))

if extent:
fc = fc.filterBounds(ee.Geometry.Rectangle(extent))

# 6. Add to map
layer_name = f"FC: {feature_collection_id}"
if as_vector:
try:
utils.add_ee_vector_layer(fc, layer_name)
except ee.ee_exception.EEException as e:
Map.get_iface().messageBar().pushMessage(
"Error",
f"Failed to load the Feature Collection: {e}",
level=gui.Qgis.Critical,
)
else:
Map.addLayer(fc, {"palette": viz_color_hex}, layer_name)
return fc
44 changes: 44 additions & 0 deletions ee_plugin/ui/menus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from dataclasses import dataclass, field
from typing import Optional, List, Callable
from PyQt5.QtWidgets import QMenu, QAction


MenuItem = Callable[[QMenu], None]


class Separator:
"""A separator in a menu."""

def __call__(self, menu: QMenu):
"""Render the separator in the given QMenu."""
menu.addSeparator()


@dataclass
class SubMenu:
"""A submenu in a menu."""

label: Optional[str] = None
subitems: Optional[List[MenuItem]] = field(default_factory=list)

def __call__(self, menu: QMenu):
"""Render the submenu in the given QMenu."""
sub_menu = menu.addMenu(self.label or "")
populate_menu(menu=sub_menu, items=self.subitems)


@dataclass
class Action:
"""An action in a menu."""

action: Optional[QAction] = None

def __call__(self, menu: QMenu):
"""Render the action in the given QMenu."""
menu.addAction(self.action)


def populate_menu(*, menu: QMenu, items: List[MenuItem]):
"""Populate a QMenu with the given list of MenuItem objects."""
for item in items:
item(menu)
18 changes: 18 additions & 0 deletions ee_plugin/ui/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import inspect
from typing import Callable

from qgis.PyQt.QtWidgets import QDialog
from .widget_parsers import get_dialog_values


def call_func_with_values(func: Callable, dialog: QDialog):
"""
Call a function with values from a dialog. Prior to the call, the function signature
is inspected and used to filter out any values from the dialog that are not expected
by the function.
"""
func_signature = inspect.signature(func)
func_kwargs = set(func_signature.parameters.keys())
dialog_values = get_dialog_values(dialog)
kwargs = {k: v for k, v in dialog_values.items() if k in func_kwargs}
return func(**kwargs)
59 changes: 59 additions & 0 deletions ee_plugin/ui/widget_parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from typing import Optional

from qgis.PyQt.QtWidgets import QCheckBox, QDateEdit, QDialog, QLineEdit
from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject
from qgis.gui import QgsColorButton, QgsDateEdit, QgsExtentGroupBox


def qgs_extent_to_bbox(
w: QgsExtentGroupBox,
) -> Optional[tuple[float, float, float, float]]:
"""
Convert a QgsRectangle in a given CRS to an EPSG:4326 bounding box, formatted as
(xmin, ymin, xmax, ymax).
"""
extent = w.outputExtent()
if extent.area() == float("inf"):
return None

source_crs = w.outputCrs()
target_crs = QgsCoordinateReferenceSystem("EPSG:4326")

extent_transformed = QgsCoordinateTransform(
source_crs, target_crs, QgsProject.instance()
).transformBoundingBox(extent)

return (
extent_transformed.xMinimum(),
extent_transformed.yMinimum(),
extent_transformed.xMaximum(),
extent_transformed.yMaximum(),
)


def get_dialog_values(dialog: QDialog) -> dict:
"""
Return a dictionary of all widget values from dialog.
Note that the response dictionary may contain keys that were not explicitely set as
object names in the widgets. This is due to the fact that some widgets are composites
of multiple child widgets. The child widgets are parsed and stored in the response
but it is the value of the parent widget that should be used in the application.
"""
# NOTE: To support more widgets, the widget class must be registered here with a
# parser. These parsers are read in order, so more specific widgets should be listed
# last as their results will overwrite more general widgets.
parsers = {
QLineEdit: lambda w: w.text(),
QDateEdit: lambda w: w.date().toString("yyyy-MM-dd"),
QgsDateEdit: lambda w: None if w.isNull() else w.findChild(QLineEdit).text(),
QCheckBox: lambda w: w.isChecked(),
QgsColorButton: lambda w: w.color().name(),
QgsExtentGroupBox: qgs_extent_to_bbox,
}
values = {}
for cls, formatter in parsers.items():
for widget in dialog.findChildren(cls):
values[widget.objectName()] = formatter(widget)

return values
Loading

0 comments on commit 9196a2f

Please sign in to comment.