diff --git a/DataPlotly/core/plot_types/icons/scatter3d.svg b/DataPlotly/core/plot_types/icons/scatter3d.svg new file mode 100644 index 00000000..b3237c6f --- /dev/null +++ b/DataPlotly/core/plot_types/icons/scatter3d.svg @@ -0,0 +1,2727 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DataPlotly/processing/dataplotly_generic_plot.py b/DataPlotly/processing/dataplotly_generic_plot.py index fbc4ab19..a180f71b 100644 --- a/DataPlotly/processing/dataplotly_generic_plot.py +++ b/DataPlotly/processing/dataplotly_generic_plot.py @@ -52,6 +52,7 @@ class DataPlotlyProcessingPlot(QgsProcessingAlgorithm): INPUT = "INPUT" XEXPRESSION = "XEXPRESSION" YEXPRESSION = "YEXPRESSION" + ZEXPRESSION = "ZEXPRESSION" OFFLINE = "OFFLINE" COLOR = "COLOR" SIZE = "SIZE" @@ -117,6 +118,11 @@ def create_parameter_dictionary(self, plot_type): self.tr("Y Field"), parentLayerParameterName=self.INPUT, ) + z_field = QgsProcessingParameterExpression( + self.ZEXPRESSION, + self.tr("Z Field"), + parentLayerParameterName=self.INPUT, + ) offline_param = QgsProcessingParameterBoolean( self.OFFLINE, self.tr("Complete offline usage"), defaultValue=False @@ -125,7 +131,7 @@ def create_parameter_dictionary(self, plot_type): output_html = QgsProcessingParameterFileDestination( self.OUTPUT_HTML_FILE, - self.tr("Scatter Plot"), + self.tr(f"{self.plot_type.title()} Plot"), self.tr("HTML files (*.html)"), ) @@ -195,17 +201,19 @@ def create_parameter_dictionary(self, plot_type): plot_types_dict[plot_type].append(input_layer) plot_types_dict[plot_type].append(x_field) plot_types_dict[plot_type].append(y_field) + if plot_type in ("scatter_3d",): + plot_types_dict[plot_type].append(z_field) + plot_types_dict[plot_type].append(color_param) + if plot_type in ("scatter",): + plot_types_dict[plot_type].append(size_param) plot_types_dict[plot_type].append(offline_param) plot_types_dict[plot_type].append(output_html) plot_types_dict[plot_type].append(output_json) - plot_types_dict[plot_type].append(color_param) - plot_types_dict[plot_type].append(facet_row) - plot_types_dict[plot_type].append(facet_col) - plot_types_dict[plot_type].append(show_legend) + if self.plot_type in ('scatter', 'bar'): + plot_types_dict[plot_type].append(facet_row) + plot_types_dict[plot_type].append(facet_col) + plot_types_dict[plot_type].append(show_legend) - # add the parameter depending on the plot type - if plot_type in ("scatter"): - plot_types_dict[plot_type].append(size_param) return plot_types_dict[plot_type] @@ -276,6 +284,14 @@ def processAlgorithm(self, parameters, context, feedback): y_expression.prepare(expressionContext) raise QgsProcessingException(y_expression.parserErrorString()) + if parameters.get(self.ZEXPRESSION): + z_expression = self.parameterAsString(parameters, self.ZEXPRESSION, context) + z_expression = QgsExpression(z_expression) + + if z_expression.hasParserError(): + z_expression.prepare(expressionContext) + raise QgsProcessingException(z_expression.parserErrorString()) + size = self.parameterAsDouble(parameters, self.SIZE, context) size_property = None if QgsProcessingParameters.isDynamic(parameters, "SIZE"): @@ -301,7 +317,7 @@ def processAlgorithm(self, parameters, context, feedback): raise QgsProcessingException(facet_col_expression.parserErrorString()) offline = self.parameterAsBool(parameters, self.OFFLINE, context) - if offline is not True: + if not offline: offline = "cdn" show_legend = self.parameterAsBool(parameters, self.SHOW_LEGEND, context) @@ -316,7 +332,10 @@ def processAlgorithm(self, parameters, context, feedback): ) # start building the object to create the pandas dataframe - colnames = ["x", "y", "customdata"] + colnames = ["x", "y"] + if self.plot_type in ('scatter_3d',): + colnames.append('z') + colnames.append("customdata") data = [] request = QgsFeatureRequest() @@ -337,6 +356,11 @@ def processAlgorithm(self, parameters, context, feedback): tl.append(x_val) tl.append(y_val) + + if self.plot_type in ('scatter_3d',): + z_val = z_expression.evaluate(expressionContext) + tl.append(z_val) + tl.append(ids) if facet_row: @@ -380,24 +404,29 @@ def processAlgorithm(self, parameters, context, feedback): plot_params = { "x": "x", "y": "y", - "color": "color" if color_property else None, - "facet_row": "facet_row" if facet_row else None, - "facet_col": "facet_col" if facet_col else None, + "color": "color" if color_property else None } # initialize the updating dictionary (different depending on the plot parameters) fig_update_params = defaultdict(dict) # only if scatter - if self.plot_type in ('scatter'): + if self.plot_type in ('scatter',): if size_property: plot_params["size"] = "size" else: fig_update_params[self.plot_type]["marker_size"] = size + if self.plot_type in ('scatter_3d',): + plot_params['z'] = 'z' + if color_property is None: fig_update_params[self.plot_type]["marker_color"] = color.name() + if self.plot_type in ('scatter', 'bar'): + plot_params["facet_row"] = "facet_row" if facet_row else None + plot_params["facet_col"] = "facet_col" if facet_col else None + # call the methods to create and update the figure fig = self.create_plot(self.plot_type, df, plot_params) fig = self.update_plot(fig, fig_update_params) diff --git a/DataPlotly/processing/dataplotly_provider.py b/DataPlotly/processing/dataplotly_provider.py index fa9f8221..8d6055e2 100644 --- a/DataPlotly/processing/dataplotly_provider.py +++ b/DataPlotly/processing/dataplotly_provider.py @@ -26,12 +26,13 @@ # 🐼 from DataPlotly.processing.dataplotly_scatterplot import DataPlotlyProcessingScatterPlot from DataPlotly.processing.dataplotly_barplot import DataPlotlyProcessingBarPlot + from DataPlotly.processing.dataplotly_scatter3d import DataPlotlyProcessingScatter3D WITH_PANDAS = True except ImportError: WITH_PANDAS = False QgsMessageLog.logMessage( "Pandas has not been found. The processing algorithm will not be loaded. " - "Please install qgis-full or qgis standalone", "DataPlotly", Qgis.Warning) + "Please install qgis-full or qgis standalone", "DataPlotly", Qgis.MessageLevel.Warning) class DataPlotlyProvider(QgsProcessingProvider): @@ -89,3 +90,4 @@ def loadAlgorithms(self): if WITH_PANDAS: self.addAlgorithm(DataPlotlyProcessingScatterPlot()) self.addAlgorithm(DataPlotlyProcessingBarPlot()) + self.addAlgorithm(DataPlotlyProcessingScatter3D()) diff --git a/DataPlotly/processing/dataplotly_scatter3d.py b/DataPlotly/processing/dataplotly_scatter3d.py new file mode 100644 index 00000000..6b2e54f0 --- /dev/null +++ b/DataPlotly/processing/dataplotly_scatter3d.py @@ -0,0 +1,64 @@ +""" +/*************************************************************************** + DataPlotly + A QGIS plugin + D3 Plots for QGIS + ------------------- + begin : 2024-10-29 + git sha : $Format:%H$ + copyright : (C) 2024 by matteo ghetta + email : matteo.ghetta@gmail.com + ***************************************************************************/ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +""" + +import os + +from DataPlotly.processing.dataplotly_generic_plot import DataPlotlyProcessingPlot +from qgis.PyQt.QtGui import QIcon + + +class DataPlotlyProcessingScatter3D(DataPlotlyProcessingPlot): + """ + Create a bar with DataPlotly plugin + """ + + def __init__(self): + super().__init__(plot_type="scatter_3d") + + def name(self): + return "scatter3d" + + def displayName(self): + return "Scatter 3D Plot" + + def icon(self): + return QIcon( + os.path.join( + os.path.dirname(__file__), + "..", + "core", + "plot_types", + "icons", + "scatter3d.svg", + ) + ) + + def createInstance(self): + return DataPlotlyProcessingScatter3D() + + def initAlgorithm(self, config=None): + + # create the parameters list + parameters = self.create_parameter_dictionary(self.plot_type) + + # loop and fill the parameters + for param in parameters: + self.addParameter(param) diff --git a/requirements/tests.txt b/requirements/tests.txt index 24a25c26..96cb4c13 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -11,3 +11,4 @@ pytest-qgis @ git+https://github.com/3liz/pytest-qgis.git@555b184158a58f6922e0df semver==3.0.4 tomli==2.3.0 ; python_full_version < '3.11' typing-extensions==4.15.0 ; python_full_version < '3.11' +pandas \ No newline at end of file diff --git a/tests/test_processing.py b/tests/test_processing.py index d25e6e44..f5c9bf63 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -20,6 +20,8 @@ def decode_array_1d(spec: dict) -> array.array: + if isinstance(spec, list): + return spec binary = base64.decodebytes(spec["bdata"].encode()) match spec["dtype"]: case "i2": @@ -47,10 +49,8 @@ def reportError(self, msg: str, fatalError: bool = False): context = QgsProcessingContext() context.setTemporaryFolder(str(output_dir)) - return - result = processing.run( - "DataPlotly:dataplotly_scatterplot", + "DataPlotly:scatterplot", { "INPUT": vl, "XEXPRESSION": '"so4"', @@ -58,8 +58,51 @@ def reportError(self, msg: str, fatalError: bool = False): "SIZE": 10, "COLOR": QColor(142, 186, 217), "FACET_COL": "", + "OFFLINE": False, + "SHOW_LEGEND": True, + "FACET_ROW": "", + "OUTPUT_HTML_FILE": "TEMPORARY_OUTPUT", + "OUTPUT_JSON_FILE": "TEMPORARY_OUTPUT", + }, + context=context, + feedback=Feedback(), + ) + + with open(result["OUTPUT_JSON_FILE"]) as f: + result_dict = json.load(f) + + x = decode_array_1d(result_dict["data"][0]["x"]) + assert x == [98, 88, 267, 329, 319, 137, 350, 151, 203] + + y = decode_array_1d(result_dict["data"][0]["y"]) + assert y == [81.87, 22.26, 74.16, 35.05, 46.64, 126.73, 116.44, 108.25, 110.45] + +def test_barplot_figure(data: Path, output_dir: Path): + """Test for the Processing scatterplot""" + from qgis import processing + + class Feedback(QgsProcessingFeedback): + def reportError(self, msg: str, fatalError: bool = False): + print("\n::test_barplot_figure::error", msg) + + layer_path = data.joinpath("test_layer.shp") + + vl = QgsVectorLayer(str(layer_path), "test_layer", "ogr") + + context = QgsProcessingContext() + context.setTemporaryFolder(str(output_dir)) + + result = processing.run( + "DataPlotly:barplot", + { + "INPUT": vl, + "XEXPRESSION": '"profo"', + "YEXPRESSION": '"ca"', + "COLOR": QColor(142, 186, 217), + "FACET_COL": "", "FACET_ROW": "", "OFFLINE": False, + "SHOW_LEGEND": True, "OUTPUT_HTML_FILE": "TEMPORARY_OUTPUT", "OUTPUT_JSON_FILE": "TEMPORARY_OUTPUT", }, @@ -71,7 +114,7 @@ def reportError(self, msg: str, fatalError: bool = False): result_dict = json.load(f) x = decode_array_1d(result_dict["data"][0]["x"]) - assert x.tolist() == [98, 88, 267, 329, 319, 137, 350, 151, 203] + assert x == ['s', 'd', 's', 's', 'd', 's', 'd', 's', 'd'] y = decode_array_1d(result_dict["data"][0]["y"]) - assert y.tolist() == [81.87, 22.26, 74.16, 35.05, 46.64, 126.73, 116.44, 108.25, 110.45] + assert y == [81.87, 22.26, 74.16, 35.05, 46.64, 126.73, 116.44, 108.25, 110.45]