Search code examples
pyqtpyqtgraph

Custom pyqtgraph item composed of a PlotCurveItem and a ScatterPlotItem


I want to display a real-time graph displaying a few curves. I would like some mouse interaction:

  1. When hovering over a curve, it gets highlighted and the value of the closest point is displayed in the tooltip
  2. The point whose value is showed in the tooltip is highlighted

I have been able to handle this hoverable curve behaviour using a combination of:

  1. A PlotCurveItem that displays the curve for all data
  2. A ScatterPlotItem with opacity of 0 to detect hovered points for all data and display their value in a tooltip
  3. A second ScatterPlotItem for displaying the hovered data point

(Feel free to give some tips on the methodology, I am new to PyQt and pyqtgraph). Now I want to have several of these hoverable curves, so ideally I would like to create a class that encompasses this behaviour.

How do I do that? What class do I need to extend?

Here's some code I wrote for illustration:

import pyqtgraph as pg
from pyqtgraph import QtCore, QtGui
from PyQt5.QtWidgets import QApplication, QMainWindow

import numpy as np



class HoverableCurveItem(pg.PlotCurveItem):
    sigCurveHovered = QtCore.Signal(object, object)
    sigCurveNotHovered = QtCore.Signal(object, object)

    def __init__(self, hoverable=True, *args, **kwargs):
        super(HoverableCurveItem, self).__init__(*args, **kwargs)
        self.hoverable = hoverable
        self.setAcceptHoverEvents(True)

    def hoverEvent(self, ev):
        if self.hoverable:
            if self.mouseShape().contains(ev.pos()):
                self.sigCurveHovered.emit(self, ev)
            else:
                self.sigCurveNotHovered.emit(self, ev)


class MainWindow(QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        print('he')
        self.view = pg.GraphicsLayoutWidget()
        self.setCentralWidget(self.view)
        self.makeplot()

    def makeplot(self):
        x = list(range(10))
        y = [np.random.randint(10) for _ in  x]
        self.data = y
        plot = self.view.addPlot()
        self.plotitem = HoverableCurveItem(x, y, pen=pg.mkPen('w', width=10))
        self.scatter = pg.ScatterPlotItem(pen=pg.mkPen('g', width=25))
        self.scatter.setOpacity(0.0)
        self.scatter.setData(x, y, hoverable=True, tip=lambda x, y, data: f"{y}°C" )
        self.plotitem.setClickable(True, width=10)
        self.plotitem.sigCurveHovered.connect(self.hovered)
        self.plotitem.sigCurveNotHovered.connect(self.leaveHovered)
        self.hovered = pg.ScatterPlotItem(pen=pg.mkPen('g', width=5)) 
        self.hovered.setOpacity(0.)
        plot.addItem(self.plotitem)
        plot.addItem(self.scatter)
        plot.addItem(self.hovered)
        self.plot = plot

    def hovered(self, item, event):
        x = int(np.round(event.pos()[0]))
        y = self.data[int(np.round(x))]
        self.plotitem.setToolTip(f"{x}: {y}")
        self.plotitem.setPen(pg.mkPen('b', width=10))
        self.hovered.setData([x], [y])
        self.hovered.setOpacity(1)

    def leaveHovered(self):
        self.plotitem.setPen(pg.mkPen('w', width=10))
        self.point.setOpacity(0)



if __name__ == '__main__':
    import sys

    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec())

Solution

  • Your implementation is acceptable, but too convoluted and not very modular.

    Most importantly, the scatter items should be part of the curve, not separate entities, since they share the same points.

    A more appropriate approach should set the scatter items as child items of the curve, then the hover event would eventually make them visible depending on the current point.

    class HoverableCurveItem(pg.PlotCurveItem):
        def __init__(self, hoverable=True, *args, **kwargs):
            super(HoverableCurveItem, self).__init__(*args, **kwargs)
            self.basePen = self.opts['pen']
            self.hoverPen = pg.mkPen('b', width=10)
            self.hoverable = hoverable
            self.setAcceptHoverEvents(True)
            self.hoverItem = pg.ScatterPlotItem(pen=pg.mkPen('g', width=5))
            self.hoverItem.setParentItem(self)
            self.hoverItem.setData(self.xData, self.yData)
            self.hoverItem.setVisible(False)
    
        def hoverEvent(self, ev):
            if self.hoverable:
                if self.mouseShape().contains(ev.pos()):
                    self.setPen(self.hoverPen)
                    x = int(round(ev.pos()[0]))
                    y = self.yData[x]
                    self.setToolTip("{}: {}".format(x, y))
                    self.hoverItem.setPointsVisible([x == i for i in self.xData])
                    self.hoverItem.setVisible(True)
                else:
                    self.setPen(self.basePen)
                    self.setToolTip('')
                    self.hoverItem.setVisible(False)
    

    As an unrelated note, be aware that you made a very important mistake: in your MainWindow class you defined a hovered function, but then you actually overwrite that attribute to create the scatter item, which you also named hovered.

    Luckily, that issue didn't affect your program in its current state because you've been connecting the self.hovered function before creating the self.hovered item, but if you had connected the function after that point, you'd have got a fatal error, since self.hovered wouldn't refer to a callable anymore.

    Always consider very carefully all the names you use (including functions), so that you can avoid mistakes like these, which are very annoying and often difficult to track down. Ask yourself if something is (an instance or a data container, so it should have a noun) or it does (a function, so it is a verb).