Example #1
0
class ShapeTool(QgsMapTool):
    #signal emitted when the mouse is clicked. This indicates that the tool finished its job
    toolFinished = pyqtSignal()
    def __init__(self, canvas, geometryType, param, type, color = QColor( 254, 178, 76, 63 )):
        """
        Constructor
        """
        QgsMapTool.__init__(self, canvas)
        self.canvas = canvas
        self.active = False
        self.geometryType = self.tr(geometryType)
        self.param=param
        self.type=type       
        self.cursor=None
        self.rubberBand = QgsRubberBand(self.canvas, QgsWkbTypes.PolygonGeometry)     
        self.setColor(color)
        self.reset()
        self.rotAngle = 0
        self.currentCentroid = None
        self.rotate = False
    
    def setColor(self, mFillColor):
        """
        Adjusting the color to create the rubber band
        """
    
        self.rubberBand.setColor(mFillColor)
        self.rubberBand.setWidth(1)
    
    def reset(self):
        """
        Resetting the rubber band
        """
        self.startPoint = self.endPoint = None
        self.isEmittingPoint = False
        try:
            self.rubberBand.reset(QgsWkbTypes.PolygonGeometry)
        except:
            pass

    def rotateRect(self, centroid, e):
        """
        Calculates the angle for the rotation.
        """
        item_position = self.canvas.mapToGlobal(e.pos())
        c = self.toCanvasCoordinates(centroid)
        c = self.canvas.mapToGlobal(c)
        rotAngle = pi - atan2(
            item_position.y() - c.y(), item_position.x() - c.x())
        return rotAngle

    def canvasPressEvent(self, e):
        """
        When the canvas is pressed the tool finishes its job
        """
        # enforce mouse restoring if clicked right after rotation 
        QApplication.restoreOverrideCursor()
        self.canvas.unsetMapTool(self)
        self.toolFinished.emit()

    def _baseDistanceInMeters(self):
        """
        Calculates the distance in meters of 2 points 1 unit map away on
        current canvas CRS.
        :return: (float) distance in meters between two points 1 map unit apart
                 from each other.
        """
        source_crs = self.canvas.mapSettings().destinationCrs()
        dest_crs = QgsCoordinateReferenceSystem(3857)
        tr = QgsCoordinateTransform(
            source_crs, dest_crs, QgsCoordinateTransformContext())
        p1t = QgsGeometry().fromPointXY(QgsPointXY(1, 0))
        p1t.transform(tr)
        p2t = QgsGeometry().fromPointXY(QgsPointXY(0, 0))
        p2t.transform(tr)
        return QgsDistanceArea().measureLine(p1t.asPoint(), p2t.asPoint())

    def getAdjustedSize(self, size):
        """
        If map unit is not metric, the figure to be drawn needs to have its
        size adjusted. This is necessary because input parameters are designed
        to be meters on tool's GUI.
        :param size: (float) tool's radius/length reference size in meters.
        :return: (float)  
        """
        source_crs = self.canvas.mapSettings().destinationCrs()
        if source_crs.mapUnits() != QgsUnitTypes.DistanceMeters:
            return size / self._baseDistanceInMeters()
        return size

    def canvasMoveEvent(self, e):
        """
        Deals with mouse move event to update the rubber band position in the canvas
        """
        ctrlIsHeld = QApplication.keyboardModifiers() == Qt2.ControlModifier
        if e.button() != None and not ctrlIsHeld:
            if self.rotate:
                # change rotate status
                self.rotate = False
            QApplication.restoreOverrideCursor()
            self.endPoint = self.toMapCoordinates( e.pos() )
        elif e.button() != None and ctrlIsHeld \
            and self.geometryType == self.tr(u"Square"):
            # calculate angle between mouse and last rubberband centroid before holding control
            self.rotAngle = self.rotateRect(self.currentCentroid, e)
            if not self.rotate:
                # only override mouse if it is not overriden already
                QApplication.setOverrideCursor(QCursor(Qt2.BlankCursor))
                self.rotate = True
        if self.geometryType == self.tr(u"Circle"):
                self.showCircle(self.endPoint)
        elif self.geometryType == self.tr(u"Square"):
            self.showRect(self.endPoint, sqrt(self.param)/2, self.rotAngle)
    
    def showCircle(self, startPoint):
        """
        Draws a circle in the canvas
        """
        nPoints = 50
        x = startPoint.x()
        y = startPoint.y()
        if self.type == self.tr('distance'):
            r = self.getAdjustedSize(self.param)
            self.rubberBand.reset(QgsWkbTypes.PolygonGeometry)
            for itheta in range(nPoints+1):
                theta = itheta*(2.0*pi/nPoints)
                self.rubberBand.addPoint(QgsPointXY(x+r*cos(theta), y+r*sin(theta)))
            self.rubberBand.show()
        else:
            r = self.getAdjustedSize(sqrt(self.param/pi))
            self.rubberBand.reset(QgsWkbTypes.PolygonGeometry)
            for itheta in range(nPoints+1):
                theta = itheta*(2.0*pi/nPoints)
                self.rubberBand.addPoint(QgsPointXY(x+r*cos(theta), y+r*sin(theta)))
            self.rubberBand.show()

    def showRect(self, startPoint, param, rotAngle=0):
        """
        Draws a rectangle in the canvas
        """  
        self.rubberBand.reset(QgsWkbTypes.PolygonGeometry)
        x = startPoint.x() # center point x
        y = startPoint.y() # center point y
        # rotation angle is always applied in reference to center point
        # to avoid unnecessary calculations
        c = cos(rotAngle)
        s = sin(rotAngle)
        # translating coordinate system to rubberband centroid
        param = self.getAdjustedSize(param)
        for posx, posy in ((-1, -1), (-1, 1), (1, 1), (1, -1)):
            px = posx * param
            py = posy * param
            pnt = QgsPointXY(px * c - py * s + x, py * c + px * s + y)
            self.rubberBand.addPoint(pnt, False)
        self.rubberBand.setVisible(True)
        self.rubberBand.updateRect()
        self.rubberBand.update()
        self.rubberBand.show()
        self.currentCentroid = startPoint
        
    def deactivate(self):
        """
        Deactivates the tool and hides the rubber band
        """
        self.rubberBand.hide()
        QgsMapTool.deactivate(self)
        # restore mouse in case tool is disabled right after rotation
        QApplication.restoreOverrideCursor()
        
    def activate(self):
        """
        Activates the tool
        """
        QgsMapTool.activate(self)
Example #2
0
class DiscoveryPlugin:
    def __init__(self, _iface):
        # Save reference to the QGIS interface
        self.iface = _iface
        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)

        # Variables to facilitate delayed queries and database connection management
        self.db_timer = QTimer()
        self.line_edit_timer = QTimer()
        self.line_edit_timer.setSingleShot(True)
        self.line_edit_timer.timeout.connect(self.reset_line_edit_after_move)
        self.next_query_time = None
        self.last_query_time = time.time()
        self.db_conn = None
        self.search_delay = 0.5  # s
        self.query_sql = ''
        self.query_text = ''
        self.query_dict = {}
        self.db_idle_time = 60.0  # s
        self.display_time = 5000  # ms
        self.bar_info_time = 30  # s

        self.search_results = []
        self.tool_bar = None
        self.search_line_edit = None
        self.completer = None
        self.conn_info = {}

        self.marker = QgsVertexMarker(iface.mapCanvas())
        self.marker.setIconSize(15)
        self.marker.setPenWidth(2)
        self.marker.setColor(QColor(226, 27, 28))  #51,160,44))
        self.marker.setZValue(11)
        self.marker.setVisible(False)
        self.marker2 = QgsVertexMarker(iface.mapCanvas())
        self.marker2.setIconSize(16)
        self.marker2.setPenWidth(4)
        self.marker2.setColor(QColor(255, 255, 255, 200))
        self.marker2.setZValue(10)
        self.marker2.setVisible(False)
        self.is_displayed = False

        self.rubber_band = QgsRubberBand(iface.mapCanvas(), False)
        self.rubber_band.setVisible(False)
        self.rubber_band.setWidth(3)
        self.rubber_band.setStrokeColor(QColor(226, 27, 28))
        self.rubber_band.setFillColor(QColor(226, 27, 28, 63))

    def initGui(self):

        # Create a new toolbar
        self.tool_bar = self.iface.addToolBar('Discovery')
        self.tool_bar.setObjectName('Discovery_Plugin')

        # Create action that will start plugin configuration
        self.action_config = QAction(
            QIcon(os.path.join(self.plugin_dir, "discovery_logo.png")),
            u"Configure Discovery", self.tool_bar)
        self.action_config.triggered.connect(self.show_config_dialog)
        self.tool_bar.addAction(self.action_config)

        # Add combobox for configs
        self.config_combo = QComboBox()
        settings = QgsSettings()
        settings.beginGroup("/Discovery")
        config_list = settings.value("config_list")

        if config_list:
            for conf in config_list:
                self.config_combo.addItem(conf)
        elif settings.childGroups():
            # support for prev version
            key = "Config1"
            config_list = []
            config_list.append(key)
            settings.setValue("config_list", config_list)
            self.config_combo.addItem(key)

            settings.setValue(key + "data_type", settings.value("data_type"))
            settings.setValue(key + "file", settings.value("file"))
            settings.setValue(key + "connection", settings.value("connection"))
            settings.setValue(key + "schema", settings.value("schema"))
            settings.setValue(key + "table", settings.value("table"))
            settings.setValue(key + "search_column",
                              settings.value("search_column"))
            settings.setValue(key + "echo_search_column",
                              settings.value("echo_search_column"))
            settings.setValue(key + "display_columns",
                              settings.value("display_columns"))
            settings.setValue(key + "geom_column",
                              settings.value("geom_column"))
            settings.setValue(key + "scale_expr", settings.value("scale_expr"))
            settings.setValue(key + "bbox_expr", settings.value("bbox_expr"))

            delete_config_from_settings("", settings)
        self.tool_bar.addWidget(self.config_combo)

        # Add search edit box
        self.search_line_edit = QgsFilterLineEdit()
        self.search_line_edit.setPlaceholderText('Search for...')
        self.search_line_edit.setMaximumWidth(768)
        self.tool_bar.addWidget(self.search_line_edit)

        self.config_combo.currentIndexChanged.connect(
            self.change_configuration)

        # Set up the completer
        self.completer = QCompleter([])  # Initialise with en empty list
        self.completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.completer.setMaxVisibleItems(1000)
        self.completer.setModelSorting(
            QCompleter.UnsortedModel)  # Sorting done in PostGIS
        self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion
                                         )  # Show all fetched possibilities
        self.completer.activated[QModelIndex].connect(self.on_result_selected)
        self.completer.highlighted[QModelIndex].connect(
            self.on_result_highlighted)
        self.search_line_edit.setCompleter(self.completer)

        # Connect any signals
        self.search_line_edit.textEdited.connect(self.on_search_text_changed)

        # Search results
        self.search_results = []

        # Set up a timer to periodically perform db queries as required
        self.db_timer.timeout.connect(self.do_db_operations)
        self.db_timer.start(100)

        # Read config
        self.read_config(config_list[0] if config_list else "")

        self.locator_filter = locator_filter.DiscoveryLocatorFilter(self)
        self.iface.registerLocatorFilter(self.locator_filter)

        # Debug
        # import pydevd; pydevd.settrace('localhost', port=5678)

    def unload(self):
        # Stop timer
        self.db_timer.stop()
        # Disconnect any signals
        self.db_timer.timeout.disconnect(self.do_db_operations)
        self.completer.highlighted[QModelIndex].disconnect(
            self.on_result_highlighted)
        self.completer.activated[QModelIndex].disconnect(
            self.on_result_selected)
        self.search_line_edit.textEdited.disconnect(
            self.on_search_text_changed)
        # Remove the new toolbar
        self.tool_bar.clear()  # Clear all actions
        self.iface.mainWindow().removeToolBar(self.tool_bar)

        self.iface.deregisterLocatorFilter(self.locator_filter)
        self.locator_filter = None

    def clear_suggestions(self):
        model = self.completer.model()
        model.setStringList([])

    def on_search_text_changed(self, new_search_text):
        """
        This function is called whenever the user modified the search text

        1. Open a database connection
        2. Make the query
        3. Update the QStringListModel with these results
        4. Store the other details in self.search_results
        """

        self.query_text = new_search_text

        if len(new_search_text) < 3:
            # Clear any previous suggestions in case the user is 'backspacing'
            self.clear_suggestions()
            return

        if self.data_type == "postgres":
            query_text, query_dict = dbutils.get_search_sql(
                new_search_text, self.postgisgeomcolumn,
                self.postgissearchcolumn, self.echosearchcolumn,
                self.postgisdisplaycolumn, self.extra_expr_columns,
                self.postgisschema, self.postgistable)
            self.schedule_search(query_text, query_dict)

        elif self.data_type == "gpkg":
            query_text = (new_search_text, self.postgissearchcolumn,
                          self.echosearchcolumn,
                          self.postgisdisplaycolumn.split(","),
                          self.extra_expr_columns, self.layer)
            self.schedule_search(query_text, None)

        elif self.data_type == "mssql":
            query_text = mssql_utils.get_search_sql(
                new_search_text, self.postgisgeomcolumn,
                self.postgissearchcolumn, self.echosearchcolumn,
                self.postgisdisplaycolumn, self.extra_expr_columns,
                self.postgisschema, self.postgistable)
            self.schedule_search(query_text, None)

    def do_db_operations(self):
        if self.next_query_time is not None and self.next_query_time < time.time(
        ):
            # It's time to run a query
            self.next_query_time = None  # Prevent this query from being repeated
            self.last_query_time = time.time()
            self.perform_search()
        else:
            # We're not performing a query, close the db connection if it's been open for > 60s
            if time.time() > self.last_query_time + self.db_idle_time:
                self.db_conn = None

    def perform_search(self):
        db = self.get_db()
        self.search_results = []
        suggestions = []
        if self.data_type == "postgres":
            cur = db.cursor()
            try:
                cur.execute(self.query_sql, self.query_dict)
            except psycopg2.Error as e:
                err_info = "Failed to execute the search query. Please, check your settings. Error message:\n\n"
                err_info += f"{e.pgerror}"
                QMessageBox.critical(None, "Discovery", err_info)
                return
            result_set = cur.fetchall()
        elif self.data_type == "mssql":
            result_set = mssql_utils.execute(db, self.query_sql)
        elif self.data_type == "gpkg":
            result_set = gpkg_utils.search_gpkg(*self.query_sql)

        for row in result_set:
            geom, epsg, suggestion_text = row[0], row[1], row[2]
            extra_data = {}
            for idx, extra_col in enumerate(self.extra_expr_columns):
                extra_data[extra_col] = row[3 + idx]
            self.search_results.append(
                (geom, epsg, suggestion_text, extra_data))
            suggestions.append(suggestion_text)
        model = self.completer.model()
        model.setStringList(suggestions)
        self.completer.complete()

    def schedule_search(self, query_text, query_dict):
        # Update the search text and the time after which the query should be executed
        self.query_sql = query_text
        self.query_dict = query_dict
        self.next_query_time = time.time() + self.search_delay

    def show_bar_info(self, info_text):
        """Optional show info bar message with selected result information"""
        self.iface.messageBar().clearWidgets()
        if self.bar_info_time:
            self.iface.messageBar().pushMessage("Discovery",
                                                info_text,
                                                level=Qgis.Info,
                                                duration=self.bar_info_time)

    def on_result_selected(self, result_index):
        # What to do when the user makes a selection
        self.select_result(self.search_results[result_index.row()])

    def select_result(self, result_data):
        geometry_text, src_epsg, suggestion_text, extra_data = result_data
        location_geom = QgsGeometry.fromWkt(geometry_text)
        canvas = self.iface.mapCanvas()
        dst_srid = canvas.mapSettings().destinationCrs().authid()
        transform = QgsCoordinateTransform(
            QgsCoordinateReferenceSystem(src_epsg),
            QgsCoordinateReferenceSystem(dst_srid),
            canvas.mapSettings().transformContext())
        # Ensure the geometry from the DB is reprojected to the same SRID as the map canvas
        location_geom.transform(transform)
        location_centroid = location_geom.centroid().asPoint()

        # show temporary marker
        if location_geom.type() == QgsWkbTypes.PointGeometry:
            self.show_marker(location_centroid)
        elif location_geom.type() == QgsWkbTypes.LineGeometry or \
            location_geom.type() == QgsWkbTypes.PolygonGeometry:
            self.show_line_rubber_band(location_geom)
        else:
            #unsupported geometry type
            pass

        # Adjust map canvas extent
        zoom_method = 'Move and Zoom'
        if zoom_method == 'Move and Zoom':
            # with higher priority try to use exact bounding box to zoom to features (if provided)
            bbox_str = eval_expression(self.bbox_expr, extra_data)
            rect = bbox_str_to_rectangle(bbox_str)
            if rect is not None:
                # transform the rectangle in case of OTF projection
                rect = transform.transformBoundingBox(rect)
            else:
                # bbox is not available - so let's just use defined scale
                # compute target scale. If the result is 2000 this means the target scale is 1:2000
                rect = location_geom.boundingBox()
                if rect.isEmpty():
                    scale_denom = eval_expression(self.scale_expr,
                                                  extra_data,
                                                  default=2000.)
                    rect = canvas.mapSettings().extent()
                    rect.scale(scale_denom / canvas.scale(), location_centroid)
                else:
                    # enlarge geom bbox to have some margin
                    rect.scale(1.2)
            canvas.setExtent(rect)
        elif zoom_method == 'Move':
            current_extent = QgsGeometry.fromRect(
                self.iface.mapCanvas().extent())
            dx = location_centroid.x() - location_centroid.x()
            dy = location_centroid.y() - location_centroid.y()
            current_extent.translate(dx, dy)
            canvas.setExtent(current_extent.boundingBox())
        canvas.refresh()
        self.line_edit_timer.start(0)
        if self.info_to_clipboard:
            QApplication.clipboard().setText(suggestion_text)
            suggestion_text += ' (copied to clipboard)'
        self.show_bar_info(suggestion_text)

    def on_result_highlighted(self, result_idx):
        self.line_edit_timer.start(0)

    def reset_line_edit_after_move(self):
        self.search_line_edit.setText(self.query_text)

    def get_db(self):
        # Create a new new connection if required
        if self.db_conn is None:
            if self.data_type == "postgres":
                self.db_conn = dbutils.get_connection(self.conn_info)
            elif self.data_type == "mssql":
                self.db_conn = mssql_utils.get_mssql_conn(self.conn_info)
        return self.db_conn

    def change_configuration(self):
        self.search_line_edit.setText("")
        self.line_edit_timer.start(0)
        self.read_config(self.config_combo.currentText())

    def read_config(self, key=""):
        # the following code reads the configuration file which setups the plugin to search in the correct database,
        # table and method

        settings = QgsSettings()
        settings.beginGroup("/Discovery")

        connection = settings.value(key + "connection", "", type=str)
        self.data_type = settings.value(key + "data_type", "", type=str)
        self.file = settings.value(key + "file", "", type=str)
        self.postgisschema = settings.value(key + "schema", "", type=str)
        self.postgistable = settings.value(key + "table", "", type=str)
        self.postgissearchcolumn = settings.value(key + "search_column",
                                                  "",
                                                  type=str)
        self.echosearchcolumn = settings.value(key + "echo_search_column",
                                               True,
                                               type=bool)
        self.postgisdisplaycolumn = settings.value(key + "display_columns",
                                                   "",
                                                   type=str)
        self.postgisgeomcolumn = settings.value(key + "geom_column",
                                                "",
                                                type=str)
        if settings.value("marker_time_enabled", True, type=bool):
            self.display_time = settings.value("marker_time", 5000, type=int)
        else:
            self.display_time = -1
        if settings.value("bar_info_time_enabled", True, type=bool):
            self.bar_info_time = settings.value("bar_info_time", 30, type=int)
        else:
            self.bar_info_time = 0
        self.info_to_clipboard = settings.value("info_to_clipboard",
                                                True,
                                                type=bool)

        scale_expr = settings.value(key + "scale_expr", "", type=str)
        bbox_expr = settings.value(key + "bbox_expr", "", type=str)

        if self.is_displayed:
            self.hide_marker()
            self.hide_rubber_band()
            self.is_displayed = False

        self.make_enabled(False)  # assume the config is invalid first

        self.db_conn = None
        if self.data_type == "postgres":
            self.conn_info = dbutils.get_postgres_conn_info(connection)
            self.layer = None

            if len(connection) == 0 or len(self.postgisschema) == 0 or len(self.postgistable) == 0 or \
                    len(self.postgissearchcolumn) == 0 or len(self.postgisgeomcolumn) == 0:
                return

            if len(self.conn_info) == 0:
                iface.messageBar().pushMessage(
                    "Discovery",
                    "The database connection '%s' does not exist!" %
                    connection,
                    level=Qgis.Critical)
                return
        if self.data_type == "mssql":
            self.conn_info = mssql_utils.get_mssql_conn_info(connection)
            self.layer = None

            if len(connection) == 0 or len(self.postgisschema) == 0 or len(self.postgistable) == 0 or \
                    len(self.postgissearchcolumn) == 0 or len(self.postgisgeomcolumn) == 0:
                return

            if len(self.conn_info) == 0:
                iface.messageBar().pushMessage(
                    "Discovery",
                    "The database connection '%s' does not exist!" %
                    connection,
                    level=Qgis.Critical)
                return
        elif self.data_type == "gpkg":
            self.layer = QgsVectorLayer(
                self.file + '|layername=' + self.postgistable,
                self.postgistable, 'ogr')
            self.conn_info = None
        self.extra_expr_columns = []
        self.scale_expr = None
        self.bbox_expr = None

        self.make_enabled(True)

        # optional scale expression when zooming in to results
        if len(scale_expr) != 0:
            expr = QgsExpression(scale_expr)
            if expr.hasParserError():
                iface.messageBar().pushMessage("Discovery",
                                               "Invalid scale expression: " +
                                               expr.parserErrorString(),
                                               level=Qgis.Warning)
            else:
                self.scale_expr = scale_expr
                self.extra_expr_columns += expr.referencedColumns()

        # optional bbox expression when zooming in to results
        if len(bbox_expr) != 0:
            expr = QgsExpression(bbox_expr)
            if expr.hasParserError():
                iface.messageBar().pushMessage("Discovery",
                                               "Invalid bbox expression: " +
                                               expr.parserErrorString(),
                                               level=Qgis.Warning)
            else:
                self.bbox_expr = bbox_expr
                self.extra_expr_columns += expr.referencedColumns()

    def show_config_dialog(self):
        dlg = config_dialog.ConfigDialog()
        if (self.config_combo.currentIndex() >= 0):
            dlg.configOptions.setCurrentIndex(self.config_combo.currentIndex())

        if dlg.exec_():
            dlg.write_config()
            self.config_combo.clear()
            for key in [
                    dlg.configOptions.itemText(i)
                    for i in range(dlg.configOptions.count())
            ]:
                self.config_combo.addItem(key)

            self.config_combo.setCurrentIndex(dlg.configOptions.currentIndex())
            self.change_configuration()

    def make_enabled(self, enabled):
        self.search_line_edit.setEnabled(enabled)
        self.search_line_edit.setPlaceholderText(
            "Search for..."
            if enabled else "Search disabled: check configuration")

    def show_marker(self, point):
        for m in [self.marker, self.marker2]:
            m.setCenter(point)
            m.setOpacity(1.0)
            m.setVisible(True)
        if self.display_time == -1:
            self.is_displayed = True
        else:
            QTimer.singleShot(self.display_time, self.hide_marker)

    def hide_marker(self):
        opacity = self.marker.opacity()
        if opacity > 0.:
            # produce a fade out effect
            opacity -= 0.1
            self.marker.setOpacity(opacity)
            self.marker2.setOpacity(opacity)
            QTimer.singleShot(100, self.hide_marker)
        else:
            self.marker.setVisible(False)
            self.marker2.setVisible(False)

    def show_line_rubber_band(self, geom):
        self.rubber_band.reset(geom.type())
        self.rubber_band.setToGeometry(geom, None)
        self.rubber_band.setVisible(True)
        self.rubber_band.setOpacity(1.0)
        self.rubber_band.show()
        if self.display_time == -1:
            self.is_displayed = True
        else:
            QTimer.singleShot(self.display_time, self.hide_rubber_band)
        pass

    def hide_rubber_band(self):
        opacity = self.rubber_band.opacity()
        if opacity > 0.:
            # produce a fade out effect
            opacity -= 0.1
            self.rubber_band.setOpacity(opacity)
            QTimer.singleShot(100, self.hide_rubber_band)
        else:
            self.rubber_band.setVisible(False)
            self.rubber_band.hide()
class GraphWidget(QWidget):
    def __init__(
        self,
        parent=None,
        ts_datasources=None,
        parameter_config=[],
        name="",
        geometry_type=QgsWkbTypes.Point,
    ):
        super().__init__(parent)

        self.name = name
        self.ts_datasources = ts_datasources
        self.parent = parent
        self.geometry_type = geometry_type

        self.setup_ui()

        self.model = LocationTimeseriesModel(
            ts_datasources=self.ts_datasources)
        self.graph_plot.set_location_model(self.model)
        self.graph_plot.set_ds_model(self.ts_datasources)
        self.location_timeseries_table.setModel(self.model)

        # set listeners
        self.parameter_combo_box.currentIndexChanged.connect(
            self.parameter_change)
        self.remove_timeseries_button.clicked.connect(
            self.remove_objects_table)

        # init parameter selection
        self.set_parameter_list(parameter_config)

        if self.geometry_type == QgsWkbTypes.Point:
            self.marker = QgsVertexMarker(self.parent.iface.mapCanvas())
        else:
            self.marker = QgsRubberBand(self.parent.iface.mapCanvas())
            self.marker.setColor(Qt.red)
            self.marker.setWidth(2)

    def set_parameter_list(self, parameter_config):

        # reset
        nr_old_parameters = self.parameter_combo_box.count()

        self.parameters = dict([(p["name"], p) for p in parameter_config])

        self.parameter_combo_box.insertItems(
            0, [p["name"] for p in parameter_config])

        # todo: find best matching parameter based on previous selection
        if nr_old_parameters > 0:
            self.parameter_combo_box.setCurrentIndex(0)

        nr_parameters_tot = self.parameter_combo_box.count()
        for i in reversed(
                list(
                    range(nr_parameters_tot - nr_old_parameters,
                          nr_parameters_tot))):
            self.parameter_combo_box.removeItem(i)

        # self.graph_plot.set_parameter(self.current_parameter)

    def on_close(self):
        """
        unloading widget and remove all required stuff
        :return:
        """
        self.parameter_combo_box.currentIndexChanged.disconnect(
            self.parameter_change)
        self.remove_timeseries_button.clicked.disconnect(
            self.remove_objects_table)

    def closeEvent(self, event):
        """
        overwrite of QDockWidget class to emit signal
        :param event: QEvent
        """
        self.on_close()
        event.accept()

    def highlight_feature(self, obj_id, obj_type):

        pass
        # todo: selection generated errors and crash of Qgis. Implement method
        # with QgsRubberband and/ or QgsVertexMarker
        transform = QgsCoordinateTransform(
            QgsCoordinateReferenceSystem(4326),
            QgsProject.instance().crs(),
            QgsProject.instance(),
        )

        layers = self.parent.iface.mapCanvas().layers()
        for lyr in layers:
            # Clear other layers
            # lyr.removeSelection()
            if lyr.name() == obj_type:
                # query layer for object
                filt = u'"id" = {0}'.format(obj_id)
                request = QgsFeatureRequest().setFilterExpression(filt)
                features = lyr.getFeatures(request)
                for feature in features:
                    if self.geometry_type == QgsWkbTypes.Point:
                        geom = feature.geometry()
                        geom.transform(transform)
                        self.marker.setCenter(geom.asPoint())
                        self.marker.setVisible(True)
                    else:
                        self.marker.setToGeometry(feature.geometry(), lyr)

    def unhighlight_all_features(self):
        """Remove the highlights from all layers"""

        if self.geometry_type == QgsWkbTypes.Point:
            self.marker.setVisible(False)
        else:
            self.marker.reset()
        pass
        # todo: selection generated errors and crash of Qgis. Implement method
        # with QgsRubberband and/ or QgsVertexMarker

    def setup_ui(self):
        """
        Create Qt widgets and elements
        """

        self.setObjectName(self.name)

        self.hLayout = QHBoxLayout(self)
        self.hLayout.setObjectName("hLayout")

        # add graphplot
        self.graph_plot = GraphPlot(self)
        sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        sizePolicy.setHorizontalStretch(1)
        sizePolicy.setVerticalStretch(1)
        sizePolicy.setHeightForWidth(
            self.graph_plot.sizePolicy().hasHeightForWidth())
        self.graph_plot.setSizePolicy(sizePolicy)
        self.graph_plot.setMinimumSize(QSize(250, 250))
        self.hLayout.addWidget(self.graph_plot)

        # add layout for timeseries table and other controls
        self.vLayoutTable = QVBoxLayout(self)
        self.hLayout.addLayout(self.vLayoutTable)

        # add combobox for parameter selection
        self.parameter_combo_box = QComboBox(self)
        self.vLayoutTable.addWidget(self.parameter_combo_box)

        # add timeseries table
        self.location_timeseries_table = LocationTimeseriesTable(self)
        self.location_timeseries_table.hoverEnterRow.connect(
            self.highlight_feature)
        self.location_timeseries_table.hoverExitAllRows.connect(
            self.unhighlight_all_features)
        sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        sizePolicy.setHeightForWidth(
            self.location_timeseries_table.sizePolicy().hasHeightForWidth())
        self.location_timeseries_table.setSizePolicy(sizePolicy)
        self.location_timeseries_table.setMinimumSize(QSize(250, 0))
        self.vLayoutTable.addWidget(self.location_timeseries_table)

        # add buttons below table
        self.hLayoutButtons = QHBoxLayout(self)
        self.vLayoutTable.addLayout(self.hLayoutButtons)

        self.remove_timeseries_button = QPushButton(self)
        sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        sizePolicy.setHeightForWidth(
            self.remove_timeseries_button.sizePolicy().hasHeightForWidth())
        self.remove_timeseries_button.setSizePolicy(sizePolicy)
        self.remove_timeseries_button.setObjectName("remove_timeseries_button")
        self.hLayoutButtons.addWidget(self.remove_timeseries_button)
        self.hLayoutButtons.addItem(
            QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))

        self.retranslateUi()

    def retranslateUi(self):
        """
        set translated widget text
        """
        self.remove_timeseries_button.setText("Delete")

    def parameter_change(self, nr):
        """
        set current selected parameter and trigger refresh of graphs
        :param nr: nr of selected option of combobox
        :return:
        """
        self.current_parameter = self.parameters[
            self.parameter_combo_box.currentText()]
        self.graph_plot.set_parameter(self.current_parameter)

    def get_feature_index(self, layer, feature):
        """
        get the id of the selected id feature
        :param layer: selected Qgis layer to be added
        :param feature: selected Qgis feature to be added
        :return: idx (integer)
        We can't do ``feature.id()``, so we have to pick something that we
        have agreed on. For now we have hardcoded the 'id' field as the
        default, but that doesn't mean it's always the case in the future
        when more layers are added!
        """
        idx = feature.id()
        if layer.dataProvider().name() in PROVIDERS_WITHOUT_PRIMARY_KEY:
            idx = feature["id"]
        return idx

    def get_object_name(self, layer, feature):
        """
        get the object_name (display_name / type)  of the selected id feature
        :param layer: selected Qgis layer to be added
        :param feature: selected Qgis feature to be added
        :return: object_name (string)
        To get a object_name we use the following logic:
        - get the '*display_name*' column if available;
        - if not: get the 'type' column if available;
        - if not: object_name = 'N/A'
        """
        object_name = None
        for column_nr, field in enumerate(layer.fields()):
            if "display_name" in field.name():
                object_name = feature[column_nr]
        if object_name is None:
            for column_nr, field in enumerate(layer.fields()):
                if field.name() == "type":
                    object_name = feature[column_nr]
                    break
                else:
                    object_name = "N/A"
                    logger.warning(
                        "Layer has no 'display_name', it's probably a result "
                        "layer, but putting a placeholder object name just "
                        "for safety.")
        return object_name

    def get_new_items(self, layer, features, filename, existing_items):
        """
        get a list of new items (that have been selected by user) to be added
        to graph (if they do not already exist in the graph items
        :param layer: selected Qgis layer to be added
        :param features: selected Qgis features to be added
        :param filename: selected Qgis features to be added
        :param existing_items: selected Qgis features to be added
        :return: new_items (list)
        """
        new_items = []
        for feature in features:
            new_idx = self.get_feature_index(layer, feature)
            new_object_name = self.get_object_name(layer, feature)
            # check if object not already exist
            if (layer.name() + "_" + str(new_idx)) not in existing_items:
                item = {
                    "object_type": layer.name(),
                    "object_id": new_idx,
                    "object_name": new_object_name,
                    "file_path": filename,
                }
                new_items.append(item)
        return new_items

    def add_objects(self, layer, features):
        """
        :param layer: layer of features
        :param features: Qgis layer features to be added
        :return: boolean: new objects are added
        """

        # Get the active database as URI, conn_info is something like:
        # u"dbname='/home/jackieleng/git/threedi-turtle/var/models/
        # DS_152_1D_totaal_bergingsbak/results/
        # DS_152_1D_totaal_bergingsbak_result.sqlite'"

        if layer.name() not in ("flowlines", "nodes", "pumplines"):
            msg = """Please select results from either the 'flowlines', 'nodes' or
            'pumplines' layer."""
            messagebar_message("Info", msg, level=0, duration=5)
            return

        conn_info = QgsDataSourceUri(
            layer.dataProvider().dataSourceUri()).connectionInfo()
        try:
            filename = conn_info.split("'")[1]
        except IndexError:
            raise RuntimeError(
                "Active database (%s) doesn't look like an sqlite filename" %
                conn_info)

        # get attribute information from selected layers
        existing_items = [
            "%s_%s" % (item.object_type.value, str(item.object_id.value))
            for item in self.model.rows
        ]
        items = self.get_new_items(layer, features, filename, existing_items)

        if len(items) > 20:
            msg = ("%i new objects selected. Adding those to the plot can "
                   "take a while. Do you want to continue?" % len(items))
            reply = QMessageBox.question(self, "Add objects", msg,
                                         QMessageBox.Yes, QMessageBox.No)

            if reply == QMessageBox.No:
                return False

        self.model.insertRows(items)
        msg = "%i new objects added to plot " % len(items)
        skipped_items = len(features) - len(items)
        if skipped_items > 0:
            msg += "(skipped %s already present objects)" % skipped_items

        statusbar_message(msg)
        return True

    def remove_objects_table(self):
        """
        removes selected objects from table
        :return:
        """
        selection_model = self.location_timeseries_table.selectionModel()
        # get unique rows in selected fields
        rows = set(
            [index.row() for index in selection_model.selectedIndexes()])
        for row in reversed(sorted(rows)):
            self.model.removeRows(row, 1)