-
-
Notifications
You must be signed in to change notification settings - Fork 117
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add "Add Feature Collection" button, buildout menu tooling (#231)
- Loading branch information
Showing
10 changed files
with
571 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.