diff --git a/src/cplus_plugin/api/layer_tasks.py b/src/cplus_plugin/api/layer_tasks.py index 6d142bfe..e3e0d821 100644 --- a/src/cplus_plugin/api/layer_tasks.py +++ b/src/cplus_plugin/api/layer_tasks.py @@ -20,6 +20,7 @@ QgsFileDownloader, QgsCoordinateTransform, QgsCoordinateReferenceSystem, + QgsVectorLayer, ) from qgis.PyQt import QtCore @@ -38,8 +39,15 @@ compress_raster, get_layer_type, convert_size, + transform_extent, +) +from .request import ( + CplusApiPooling, + CplusApiRequest, + CplusApiRequestError, + CplusApiUrl, + JOB_COMPLETED_STATUS, ) -from .request import CplusApiPooling, CplusApiRequest, CplusApiUrl, JOB_COMPLETED_STATUS from ..definitions.defaults import DEFAULT_CRS_ID @@ -744,7 +752,6 @@ class CalculateNatureBaseZonalStatsTask(QgsTask): """ status_message_changed = QtCore.pyqtSignal(str) - progress_changed = QtCore.pyqtSignal(float) results_ready = QtCore.pyqtSignal(object) task_finished = QtCore.pyqtSignal(bool) @@ -770,23 +777,76 @@ def _get_bbox_for_request(self) -> str: return str(self.bbox) - # Otherwise get saved scenario extent - extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None) + # Otherwise get saved extents from settings + clip_to_studyarea = settings_manager.get_value( + Settings.CLIP_TO_STUDYAREA, default=False, setting_type=bool + ) + if clip_to_studyarea: + # From vector layer + study_area_path = settings_manager.get_value( + Settings.STUDYAREA_PATH, default="", setting_type=str + ) + if not study_area_path or not os.path.exists(study_area_path): + log("Path for determining layer extent is invalid.", info=False) + return "" + + aoi_layer = QgsVectorLayer(study_area_path, "AOI Layer") + if not aoi_layer.isValid(): + log("AOI layer is invalid.", info=False) + return "" + + source_crs = aoi_layer.crs() + if not source_crs: + log("CRS of AOI layer is undefined.", info=False) + return "" + + aoi_extent = aoi_layer.extent() + if not aoi_extent: + log("Extent of AOI layer is undefined.", info=False) + return "" + + # Reproject extent if required + destination_crs = QgsCoordinateReferenceSystem("EPSG:4326") + if source_crs != destination_crs: + aoi_extent = transform_extent(aoi_extent, source_crs, destination_crs) + + extent = [ + aoi_extent.xMinimum(), + aoi_extent.yMinimum(), + aoi_extent.xMaximum(), + aoi_extent.yMaximum(), + ] + else: + # From explicit extent definition + settings_extent = settings_manager.get_value( + Settings.SCENARIO_EXTENT, default=None + ) + # Ensure in minX, minY, maxX, maxY format + extent = [ + float(settings_extent[0]), + float(settings_extent[2]), + float(settings_extent[1]), + float(settings_extent[3]), + ] + if not extent or len(extent) < 4: raise ValueError("Scenario extent is not defined or invalid.") - return f"{float(extent[0])},{float(extent[2])},{float(extent[1])},{float(extent[3])}" + return f"{extent[0]},{extent[1]},{extent[2]},{extent[3]}" def run(self) -> bool: """Initiate the zonal statistics calculation and poll until completed. + Use `poll_once` method for non-blocking iterations. + :returns: True if the calculation process succeeded or False it if failed. :rtype: bool """ try: bbox_str = self._get_bbox_for_request() + log(f"BBOX for Zonal stats request: {bbox_str}") except Exception as e: log(f"Invalid bbox: {e}") return False @@ -827,12 +887,32 @@ def run(self) -> bool: f"Zonal statistics calculation started, task id: {task_uuid}" ) - pooling = self.request.fetch_zonal_statistics_progress(task_uuid) + polling = self.request.fetch_zonal_statistics_progress(task_uuid) # Repeatedly poll until final status + status = True try: - while not self.isCanceled(): - response = pooling.results() or {} + while True: + if self.isCanceled(): + polling.cancelled = True + status = False + break + + try: + # Use poll once which is non-blocking + response = polling.poll_once() or {} + except CplusApiRequestError as ex: + log(f"Polling error: {ex}", info=False) + status = False + break + except Exception as ex: + log( + f"Error while polling zonal statistics progress: {ex}", + info=False, + ) + time.sleep(self.polling_interval) + continue + status_str = response.get("status") progress = response.get("progress", 0.0) try: @@ -845,18 +925,20 @@ def run(self) -> bool: progress_value = 0.0 self.setProgress(int(progress_value)) - self.progress_changed.emit(progress_value) self.set_status_message( f"Zonal statistics: {status_str} ({progress_value:.1f}%)" ) + if status_str in CplusApiPooling.FINAL_STATUS_LIST: + if status_str != JOB_COMPLETED_STATUS: + status = False self.result = response break - # Pooling.results already sleeps between attempts, but keep guard + # Sleep between poll iterations so that we can control the frequency time.sleep(self.polling_interval) - return True + return status except Exception as ex: log( f"Error while polling zonal statistics progress: {ex}", @@ -865,7 +947,7 @@ def run(self) -> bool: return False def finished(self, result: bool): - """Emit signals and optionally persist results in settings.""" + """Emit signals and persist results in settings.""" if result and self.result: results = self.result.get("results", []) self.results_ready.emit(results) @@ -875,7 +957,10 @@ def finished(self, result: bool): "Empty result set for zonal statistics calculation of Naturebase layers.", info=False, ) - result_info = ResultInfo(results, self.result.get("finished_at", "")) + sorted_results = [] + if results: + sorted_results = sorted(results, key=lambda d: d["layer_name"]) + result_info = ResultInfo(sorted_results, self.result.get("finished_at", "")) settings_manager.save_nature_base_zonal_stats(result_info) else: self.set_status_message("Zonal statistics task failed or was cancelled") diff --git a/src/cplus_plugin/api/request.py b/src/cplus_plugin/api/request.py index 654eb2e0..ffd74ef2 100644 --- a/src/cplus_plugin/api/request.py +++ b/src/cplus_plugin/api/request.py @@ -129,6 +129,37 @@ def __call_api(self) -> typing.Tuple[dict, int]: return self.context.get(self.url) return self.context.post(self.url, self.data) + def poll_once(self) -> dict: + """Perform a single API call to the network resource + and returns the response dict. + + This does not sleep or recurse. It increments the + retry counter and enforces cancellation / timeout rules. + Use this from external loops to control the + loop frequency and update dependencies + after each response. + + :returns: Dictionary containing the response details. + :rtype: dict + """ + if self.cancelled: + return {"status": JOB_CANCELLED_STATUS} + + if self.limit != -1 and self.current_repeat >= self.limit: + raise CplusApiRequestError("Request Timeout when fetching status!") + + self.current_repeat += 1 + + response, status_code = self.__call_api() + if status_code != 200: + error_detail = response.get("detail", "Unknown Error!") + raise CplusApiRequestError(f"{status_code} - {error_detail}") + + if self.on_response_fetched: + self.on_response_fetched(response) + + return response + def results(self) -> dict: """Fetch the results from API every X seconds and stop when status is in the final status list. diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 373ad581..595d3f9a 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -275,6 +275,7 @@ class Settings(enum.Enum): # Naturebase mean zonal statistics NATURE_BASE_MEAN_ZONAL_STATS = "nature_base_zonal_stats/mean" + AUTO_REFRESH_NATURE_BASE_ZONAL_STATS = "nature_base_zonal_stats/auto_refresh" # Constant Rasters Dialog CONSTANT_RASTERS_DIALOG_ACTIVITY_TYPE = ( diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index 130bd17a..c5587e54 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -50,6 +50,10 @@ RESTORE_CARBON_IMPACT_HEADER = "C.I. (Restore)" TOTAL_CARBON_IMPACT_HEADER = "Total C.I." +# Naturebase carbon impact table headers +LAYER_NAME_HEADER = "Layer Name" +CARBON_IMPACT_PER_HA_HEADER = "tCO2e/ha" + ICON_PATH = ":/plugins/cplus_plugin/icon.svg" REPORT_SETTINGS_ICON_PATH = str( os.path.normpath( diff --git a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py index e1d37ebc..16efa6e3 100644 --- a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py +++ b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py @@ -110,7 +110,6 @@ def __init__(self, parent=None, ncs_pathway=None, excluded_names=None): # Naturebase carbon impact reference carbon_impact_info = settings_manager.get_nature_base_zonal_stats() if carbon_impact_info: - # Manage page self.cbo_naturebase_carbon_mng.addItem("") self.cbo_naturebase_carbon_rst.addItem("") for impact in carbon_impact_info.result_collection: diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 1d8dbeaf..dbedfaed 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -57,7 +57,7 @@ from .progress_dialog import OnlineProgressDialog, ReportProgressDialog, ProgressDialog from ..trends_earth import auth from ..api.scenario_task_api_client import ScenarioAnalysisTaskApiClient -from ..api.layer_tasks import FetchDefaultLayerTask +from ..api.layer_tasks import calculate_zonal_stats_task, FetchDefaultLayerTask from ..api.scenario_history_tasks import ( FetchScenarioHistoryTask, FetchScenarioOutputTask, @@ -404,6 +404,9 @@ def prepare_input(self): self.scenario_name.textChanged.connect(self.save_scenario) self.scenario_description.textChanged.connect(self.save_scenario) self.extent_box.extentChanged.connect(self.save_scenario) + self.extent_box.extentChanged.connect( + lambda s: self.update_naturebase_carbon_impact() + ) icon_pixmap = QtGui.QPixmap(ICON_PATH) self.icon_la.setPixmap(icon_pixmap) @@ -553,6 +556,9 @@ def on_aoi_source_changed(self, button_id: int, toggled: bool): self.save_scenario() + # Check and fetch carbon impact for the current extent + self.update_naturebase_carbon_impact() + def _on_studyarea_file_changed(self): """Slot raised to when the area of interest is selected from a local file system.""" data_dir = settings_manager.get_value(Settings.LAST_DATA_DIR, "") @@ -580,6 +586,9 @@ def _on_studyarea_file_changed(self): self.save_scenario() + # Check and fetch carbon impact for the current extent + self.update_naturebase_carbon_impact() + def _on_studyarea_layer_changed(self, layer): """Slot raised to when the area of interest is selected from a map layers.""" if layer is not None: @@ -590,6 +599,9 @@ def _on_studyarea_layer_changed(self, layer): self.save_scenario() + # Check and fetch carbon impact for the current extent + self.update_naturebase_carbon_impact() + def can_clip_to_studyarea(self) -> bool: """Return true if clipping layers by study area is selected""" clip_to_studyarea = False @@ -601,6 +613,18 @@ def can_clip_to_studyarea(self) -> bool: clip_to_studyarea = True return clip_to_studyarea + def update_naturebase_carbon_impact(self): + """Fetch the naturebase zonal stats based on the current extent.""" + auto_refresh = settings_manager.get_value( + Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS, + default=False, + setting_type=bool, + ) + if not auto_refresh: + return + + _ = calculate_zonal_stats_task() + def get_studyarea_path(self) -> str: """Return the path of the study area diff --git a/src/cplus_plugin/gui/settings/carbon_options.py b/src/cplus_plugin/gui/settings/carbon_options.py index 0970f35f..45effd32 100644 --- a/src/cplus_plugin/gui/settings/carbon_options.py +++ b/src/cplus_plugin/gui/settings/carbon_options.py @@ -10,31 +10,40 @@ from qgis.gui import QgsFileWidget, QgsMessageBar, QgsOptionsPageWidget from qgis.gui import QgsOptionsWidgetFactory from qgis.PyQt import uic -from qgis.PyQt import QtCore +from qgis.PyQt import QtCore, sip from qgis.PyQt.QtGui import ( QIcon, - QShowEvent, QPixmap, + QShowEvent, + QStandardItem, + QStandardItemModel, ) -from qgis.PyQt.QtWidgets import QButtonGroup, QWidget +from qgis.PyQt.QtWidgets import QButtonGroup, QHeaderView, QWidget from ...api.base import ApiRequestStatus from ...api.carbon import ( start_irrecoverable_carbon_download, get_downloader_task, ) +from ...api.layer_tasks import calculate_zonal_stats_task from ...conf import ( settings_manager, Settings, ) -from ...definitions.constants import CPLUS_OPTIONS_KEY, CARBON_OPTIONS_KEY +from ...definitions.constants import ( + CPLUS_OPTIONS_KEY, + CARBON_OPTIONS_KEY, + LAYER_NAME_ATTRIBUTE, + MEAN_VALUE_ATTRIBUTE, +) from ...definitions.defaults import ( + CARBON_IMPACT_PER_HA_HEADER, OPTIONS_TITLE, CARBON_OPTIONS_TITLE, CARBON_SETTINGS_ICON_PATH, - MAX_CARBON_IMPACT_MANAGE, + LAYER_NAME_HEADER, ) from ...models.base import DataSourceType from ...utils import FileUtils, tr @@ -45,6 +54,45 @@ ) +class NaturebaseCarbonImpactModel(QStandardItemModel): + """Model for displaying carbon impact values in a table view.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setColumnCount(2) + self.setHorizontalHeaderLabels([LAYER_NAME_HEADER, CARBON_IMPACT_PER_HA_HEADER]) + + def _readonly_item(self, text: str = "") -> QStandardItem: + """Helper to create a non-editable QStandardItem with + given display text. + """ + item = QStandardItem(text) + item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + + return item + + def add_row(self, layer_name: str, carbon_impact: float): + """Adds a row with the layer details to the model. + + :param layer_name: Name of the layer. + :type layer_name: str + + :param carbon_impact: Value of the carbon impact. + :type carbon_impact: float + """ + name_item = self._readonly_item(str(layer_name)) + carbon_item = self._readonly_item(str(carbon_impact)) + carbon_item.setData(carbon_impact, QtCore.Qt.UserRole) + + self.appendRow([name_item, carbon_item]) + + def remove_all_rows(self) -> None: + """Remove all rows from the model while preserving the column headers.""" + row_count = self.rowCount() + if row_count > 0: + self.removeRows(0, row_count) + + class CarbonSettingsWidget(QgsOptionsPageWidget, Ui_CarbonSettingsWidget): """Carbon settings widget.""" @@ -114,8 +162,37 @@ def __init__(self, parent=None): self.cbo_biomass.setFilters(qgis.core.QgsMapLayerProxyModel.Filter.RasterLayer) # Naturebase carbon impact - # Temp disable until functionality is complete - self.gb_carbon_management.setVisible(False) + self.zonal_stats_task = None + self._carbon_impact_model = NaturebaseCarbonImpactModel() + self.tv_naturebase_carbon_impact.setModel(self._carbon_impact_model) + self.tv_naturebase_carbon_impact.setSortingEnabled(True) + + header = self.tv_naturebase_carbon_impact.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + header.setSectionsClickable(True) + header.setSortIndicatorShown(True) + self.load_carbon_impact() + self.tv_naturebase_carbon_impact.sortByColumn(0, QtCore.Qt.AscendingOrder) + + self.btn_reload_carbon_impact.clicked.connect( + self._on_reload_naturebase_carbon_impact + ) + + def load_carbon_impact(self): + """Load carbon impact info based on the latest values in settings.""" + self._carbon_impact_model.remove_all_rows() + carbon_impact_info = settings_manager.get_nature_base_zonal_stats() + if carbon_impact_info: + for impact in carbon_impact_info.result_collection: + layer_name = impact.get(LAYER_NAME_ATTRIBUTE) + mean_value = impact.get(MEAN_VALUE_ATTRIBUTE) or 0.0 + self._carbon_impact_model.add_row(layer_name, mean_value) + + updated_date_str = ( + f'

' + f'{self.tr("Last updated")}: {carbon_impact_info.to_local_time()}

' + ) + self.lbl_last_updated_carbon_impact.setText(updated_date_str) def apply(self) -> None: """This is called on OK click in the QGIS options panel.""" @@ -128,9 +205,6 @@ def save_settings(self) -> None: Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, self.fw_irrecoverable_carbon.filePath(), ) - settings_manager.set_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, self.txt_ic_url.text() - ) settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, self.fw_save_online_file.filePath(), @@ -156,6 +230,12 @@ def save_settings(self) -> None: self.fw_biomass.filePath(), ) + # Carbon impact + settings_manager.set_value( + Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS, + self.cb_auto_refresh_carbon_impact.isChecked(), + ) + def load_settings(self): """Loads the settings and displays it in the UI.""" # Irrecoverable carbon @@ -175,11 +255,6 @@ def load_settings(self): ) # Online config - self.txt_ic_url.setText( - settings_manager.get_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="" - ) - ) self.fw_save_online_file.setFilePath( settings_manager.get_value( Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, default="" @@ -207,7 +282,13 @@ def load_settings(self): settings_manager.get_value(Settings.STORED_CARBON_BIOMASS_PATH, default="") ) - # Carbon impact - manage + # Carbon impact + auto_refresh = settings_manager.get_value( + Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS, + default=False, + setting_type=bool, + ) + self.cb_auto_refresh_carbon_impact.setChecked(auto_refresh) def showEvent(self, event: QShowEvent) -> None: """Show event being called. This will display the plugin settings. @@ -282,7 +363,9 @@ def validate_irrecoverable_carbon_url(self) -> bool: well-formed. :rtype: bool """ - dataset_url = self.txt_ic_url.text() + dataset_url = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="", setting_type=str + ) if not dataset_url: self.message_bar.pushWarning( tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined") @@ -419,6 +502,75 @@ def _on_biomass_layer_changed(self, layer: qgis.core.QgsMapLayer): if layer is not None: self.fw_biomass.setFilePath(layer.source()) + def _on_reload_naturebase_carbon_impact(self): + """Slot raised to initiate the fetching of Naturebase zonal stats.""" + # Disconnect any existing zonal stats receivers + if self.zonal_stats_task and not sip.isdeleted(self.zonal_stats_task): + self.zonal_stats_task.statusChanged.disconnect( + lambda s: self.reload_zonal_stats_task_status() + ) + self.zonal_stats_task.taskCompleted.disconnect( + self._on_zonal_stats_complete_or_error + ) + self.zonal_stats_task.taskTerminated.disconnect( + self._on_zonal_stats_complete_or_error + ) + + self.zonal_stats_task = calculate_zonal_stats_task() + + # Reconnect signals + if self.zonal_stats_task: + self.zonal_stats_task.statusChanged.connect( + lambda s: self.reload_zonal_stats_task_status() + ) + self.zonal_stats_task.progressChanged.connect( + lambda s: self.reload_zonal_stats_task_status() + ) + self.zonal_stats_task.taskCompleted.connect( + self._on_zonal_stats_complete_or_error + ) + self.zonal_stats_task.taskTerminated.connect( + self._on_zonal_stats_complete_or_error + ) + + self.btn_reload_carbon_impact.setEnabled(False) + self.tv_naturebase_carbon_impact.setEnabled(False) + + # Update the latest status + self.reload_zonal_stats_task_status() + + def _on_zonal_stats_complete_or_error(self): + """Re-enable controls and refresh table view if applicable.""" + self.btn_reload_carbon_impact.setEnabled(True) + self.tv_naturebase_carbon_impact.setEnabled(True) + if self.zonal_stats_task.status() == qgis.core.QgsTask.TaskStatus.Complete: + self.load_carbon_impact() + + def reload_zonal_stats_task_status(self): + """Update icon and description of zonal stats task.""" + icon_path = "" + description = "" + if self.zonal_stats_task: + status = self.zonal_stats_task.status() + if status == qgis.core.QgsTask.TaskStatus.OnHold: + icon_path = FileUtils.get_icon_path("mIndicatorTemporal.svg") + description = self.tr("Not started") + elif status == qgis.core.QgsTask.TaskStatus.Queued: + icon_path = FileUtils.get_icon_path("mIndicatorTemporal.svg") + description = self.tr("Queued") + elif status == qgis.core.QgsTask.TaskStatus.Running: + icon_path = FileUtils.get_icon_path("progress-indicator.svg") + description = f"{self.tr('Running')} ({int(self.zonal_stats_task.progress())}%)..." + elif status == qgis.core.QgsTask.TaskStatus.Complete: + icon_path = FileUtils.get_icon_path("mIconSuccess.svg") + description = self.tr("Completed") + elif status == qgis.core.QgsTask.TaskStatus.Terminated: + icon_path = FileUtils.get_icon_path("mIconWarning.svg") + description = self.tr("Terminated") + + self.lbl_carbon_impact_status_icon.svg_path = icon_path + self.lbl_carbon_impact_status_description.setText(description) + class CarbonOptionsFactory(QgsOptionsWidgetFactory): """Factory for defining CPLUS carbon settings.""" diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index 24831418..6d211b06 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -736,4 +736,7 @@ def to_local_time(self) -> str: if not updated_date_time.isValid(): return "" - return QLocale.system().toString(updated_date_time, QLocale.LongFormat) + updated_date_time.setTimeSpec(Qt.UTC) + local_date_time = updated_date_time.toLocalTime() + + return QLocale.system().toString(local_date_time, QLocale.LongFormat) diff --git a/src/cplus_plugin/ui/carbon_settings.ui b/src/cplus_plugin/ui/carbon_settings.ui index c8383f9e..fb451644 100644 --- a/src/cplus_plugin/ui/carbon_settings.ui +++ b/src/cplus_plugin/ui/carbon_settings.ui @@ -6,7 +6,7 @@ 0 0 - 555 + 579 726 @@ -120,44 +120,31 @@ 5 - - + + + + Initiate new download or refesh previous download in the background + - URL + Start download - + - Do not include the bbox PARAM, it will be automatically appended based on the current scenario extent - - - Specify the URL to fetch the dataset in the CI server + Specify the local path for saving the downloaded file - + - Save as + Save file as - - - - - - - Initiate new download or refesh previous download in the background - - - Start download - - - - + 4 @@ -228,7 +215,7 @@ - false + true @@ -246,53 +233,129 @@ Naturebase carbon impact - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SelectRows + + + + 0 - + + + + + 0 + 0 + + + + Auto-refresh on extents changed in Step 1 + + + + + + + <html><head/><body><p><span style=" font-style:italic;">(more network-resource intensive)</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + - - - - - 0 - 0 - - - - Manually reload carbon impact values for Naturebase layers - - - Reload + + + + Qt::Horizontal - + - <html><head/><body><p>Mean values fetched from online Naturebase layers are shown below:</p></body></html> + <html><head/><body><p>Mean values fetched from the online Naturebase layers are shown below:</p></body></html> - - - - Auto-refresh on extents changed in Step 1 (network-resource intensive) + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + false + - + <html><head/><body><p><span style=" color:#6a6a6a;">Last updated:</span></p></body></html> + + + + + + + 0 + 0 + + + + Manually reload carbon impact values for Naturebase layers + + + Reload + + + + + + + + 16 + 16 + + + + + 24 + 24 + + + + + + + + + + + + + + + +