Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Add Feature Collection" button, buildout menu tooling #231

Open
wants to merge 49 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a97cdfc
Fix icons, refactor, add toolbar
alukach Jan 28, 2025
de23929
In progress
alukach Feb 7, 2025
758fe67
Refactor to prefer functions over dataclasses
alukach Feb 7, 2025
da0b867
Trim down code
alukach Feb 7, 2025
02e1acc
Rename inputs
alukach Feb 7, 2025
c02e385
Add type
alukach Feb 7, 2025
53e5612
Merge branch 'master' into feature/menu-buildout
alukach Feb 7, 2025
adc1693
Merge branch 'master' into feature/menu-buildout
alukach Feb 10, 2025
92a2b94
Merge branch 'master' into feature/menu-buildout
alukach Feb 10, 2025
ee75ce3
In progress
alukach Feb 11, 2025
750e261
In progress
alukach Feb 11, 2025
aff56a7
Buildout types for utils
alukach Feb 11, 2025
ec1b30c
In progress
alukach Feb 11, 2025
e8b18ce
Refactor menu setup
alukach Feb 12, 2025
6d1ed60
Support nullable date widget
alukach Feb 12, 2025
7672e08
Display error to user
alukach Feb 12, 2025
ae2f80b
Improve help text
alukach Feb 12, 2025
46beec7
Return dialog
alukach Feb 12, 2025
b53e905
Rename func
alukach Feb 12, 2025
0ab93c2
Cleanup comments
alukach Feb 12, 2025
de24761
Simplify flag
alukach Feb 12, 2025
e2ccb15
Add beginning of tests
alukach Feb 12, 2025
6e80ff6
Refactor get_values to support subclassed types
alukach Feb 12, 2025
73bed0a
add TODO
alukach Feb 12, 2025
2904eb1
Build parser for QgsDateEdit
alukach Feb 12, 2025
802986d
Add docs
alukach Feb 12, 2025
3b6119a
Cleanup
alukach Feb 12, 2025
9624c3f
Simplify
alukach Feb 13, 2025
03d5758
Support QgsExtentGroupBox
alukach Feb 13, 2025
728a082
Update tests
alukach Feb 13, 2025
8fe6a4a
Rework form calls with helper to filter args
alukach Feb 13, 2025
d40bc9e
Expand tests
alukach Feb 13, 2025
f5b74b9
Set default display format on custom date edit widget
alukach Feb 13, 2025
ef857c7
Add types for utils (#228)
alukach Feb 13, 2025
4a334b8
Rm unnecessary change
alukach Feb 13, 2025
19b2e1e
Merge branch 'master' into feature/menu-buildout
alukach Feb 13, 2025
57d6881
Cleanup docstring
alukach Feb 13, 2025
bb046af
Add notes
alukach Feb 13, 2025
ca7d52c
Rename 'use_util' to 'as_vector' and update tooltip for feature colle…
alukach Feb 13, 2025
34d9236
Refactor translation handling in the plugin by introducing a utility …
alukach Feb 13, 2025
e9d9779
Move DefaultNullQgsDateEdit function to utils and simplify form handling
alukach Feb 13, 2025
864a1e5
Rename forms module to add_feature_collection
alukach Feb 13, 2025
6b2a2f9
Rm button comments
alukach Feb 13, 2025
9fe2ba2
Mv form to form dir
alukach Feb 13, 2025
77b48f2
Merge remote-tracking branch 'origin/master' into feature/menu-buildout
alukach Feb 14, 2025
d606826
Add failing test
alukach Feb 15, 2025
3e76ba0
Clear layers between tests
alukach Feb 15, 2025
c57a16c
Fix layer name in UI test assertion
alukach Feb 15, 2025
58d486e
Fix typo in USGS
zacdezgeo Feb 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions ee_plugin/ee_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import ee

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


PLUGIN_DIR = os.path.dirname(__file__)
Expand Down Expand Up @@ -88,8 +89,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)
alukach marked this conversation as resolved.
Show resolved Hide resolved

def initGui(self):
"""Initialize the plugin GUI."""
Expand All @@ -112,36 +112,54 @@ def initGui(self):
parent=self.iface.mainWindow(),
triggered=self._run_cmd_set_cloud_project,
)
add_fc_button = QtWidgets.QAction(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I'm wondering if when we should implement the sub-menus to avoid having a long list of functions at the top level. We can punt beyond this PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if when we should implement the sub-menus to ...

Not sure what you're saying here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, have an "EE Tools" or something like at the top level and list the specific forms available under this sub-menu. In a similar way to how other menus work in QGIS:
image

text=self.tr("Add Feature Collection"),
parent=self.iface.mainWindow(),
triggered=lambda: forms.add_feature_collection_form(self.iface),
)

# 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(

# Initialize toolbar menu
self.toolButton = QtWidgets.QToolButton()
self.toolButton.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonIconOnly
# Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
toolButton.setPopupMode(
QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup
self.toolButton.setPopupMode(
# QtWidgets.QToolButton.ToolButtonPopupMode.DelayedPopup # Button is only for triggering action
alukach marked this conversation as resolved.
Show resolved Hide resolved
QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup # Button is only for opening dropdown menu
# QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup # Button is split into action and dropdown menu
)
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)

# Add actions to the menu
for action in [
ee_user_guide_action,
None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is none used for some whitespace or something? Wondering if we should extract this to another function that potentially takes a list of actions, and figures out the formatting. With the sub-menu logic, we could also maybe use a dictionary that maps to the sub-menu/level desired? Again, we can refactor after this PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is something that I was unsure about. If you look below, you'll see:

                if action:
                    menu.addAction(action)
                else:
                    menu.addSeparator()

So basically, None represents a separator. That feels like an awkward convention but wasn't sure what would be better. I also am unsure that we want to keep the plugin menu and toolbar menu completely in sync as we are doing now.

sign_in_action,
self.set_cloud_project_action,
None,
add_fc_button,
]:
for menu in [self.menu, self.toolButton.menu()]:
if action:
menu.addAction(action)
else:
menu.addSeparator()

# Register signal to initialize EE layers on project load
self.iface.projectRead.connect(self._updateLayers)
Expand Down
184 changes: 184 additions & 0 deletions ee_plugin/ui/forms.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I'm wondering if we would like to have a single file per form to avoid them being to clustered in the future. Is listing all these files under the ui folder a good idea? That's the approach I took in #229.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could also be under a new python package called forms in terms of project structure 🤷

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think separating the forms and possibly their handler into separate files seems like a good idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zacdezgeo what do you think about 864a1e5?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great! Probably a nit, but can we add the form prefix to any form to help differentiate from the other UI files and have the forms listed next to each other when we add more?

Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from typing import Optional, Callable
from qgis import gui
from qgis.PyQt import QtWidgets, QtCore
import ee

from .utils import (
build_form_group_box,
build_vbox_dialog,
call_func_with_values,
)

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


def DefaultNullQgsDateEdit(
*, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs
) -> gui.QgsDateEdit:
"""Build a QgsDateEdit widget, with null default capability."""
# NOTE: Specifying a displayFormat guarantees that the date will be formatted as
# expected across different runtime environments.
d = gui.QgsDateEdit(**kwargs, displayFormat=displayFormat)
# NOTE: It would be great to remove this helper and just use the built-in QgsDateEdit
# class but at this time it's not clear how to make a DateEdit widget that initializes
# with a null value. This is a workaround.
if date is None:
d.clear()
else:
d.setDate(date)
return d


def add_feature_collection_form(
iface: gui.QgisInterface,
accepted: Optional[Callable] = None,
**dialog_kwargs,
) -> QtWidgets.QDialog:
"""Add a GEE Feature Collection to the map."""
dialog = build_vbox_dialog(
windowTitle=_("Add Feature Collection"),
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",
),
),
],
),
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"),
),
],
),
build_form_group_box(
title=_("Filter by Dates"),
collapsable=True,
collapsed=True,
rows=[
(
"Start",
DefaultNullQgsDateEdit(objectName="start_date"),
),
(
"End",
DefaultNullQgsDateEdit(objectName="end_date"),
),
],
),
gui.QgsExtentGroupBox(
objectName="extent",
title=_("Filter by Coordinates"),
collapsed=True,
),
build_form_group_box(
title=_("Visualization"),
collapsable=True,
collapsed=True,
rows=[
(
_("Color"),
gui.QgsColorButton(objectName="viz_color_hex"),
),
],
),
],
parent=iface.mainWindow(),
**dialog_kwargs,
)

if accepted:
dialog.accepted.connect(lambda: call_func_with_values(accepted, dialog))
return dialog


def add_feature_collection(
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
Loading