Search code examples
pythonqtchartspyside6

Qt callout example with more than one y axis


I have a QChart from the Callout example of PySide6. Now I have rewritten some of the code for my project, but when I hover over a `QLineSeries' the callout appears higher or lower than where I am actually pointing. Here is some code:

The Callout class (Almost the same as in the example)

class Callout(QGraphicsItem):

    def __init__(self, chart):
        QGraphicsItem.__init__(self, chart)
        self.chart = chart
        self._text = ""
        self._textRect = QRectF()
        self._anchor = QPointF()
        self._font = QFont()
        self._rect = QRectF()


    def boundingRect(self):
        anchor = self.mapFromParent(self.chart.mapToPosition(self._anchor))
        rect = QRectF()
        rect.setLeft(min(self._rect.left(), anchor.x()))
        rect.setRight(max(self._rect.right(), anchor.x()))
        rect.setTop(min(self._rect.top(), anchor.y()))
        rect.setBottom(max(self._rect.bottom(), anchor.y()))

        return rect

    def paint(self, painter, option, widget):
        path = QPainterPath()
        path.addRoundedRect(self._rect, 5, 5)
        anchor = self.mapFromParent(self.chart.mapToPosition(self._anchor))
        if not self._rect.contains(anchor) and not self._anchor.isNull():
            point1 = QPointF()
            point2 = QPointF()

            # establish the position of the anchor point in relation to _rect
            above = anchor.y() <= self._rect.top()
            above_center = (anchor.y() > self._rect.top() and
                anchor.y() <= self._rect.center().y())
            below_center = (anchor.y() > self._rect.center().y() and
                anchor.y() <= self._rect.bottom())
            below = anchor.y() > self._rect.bottom()

            on_left = anchor.x() <= self._rect.left()
            left_of_center = (anchor.x() > self._rect.left() and
                anchor.x() <= self._rect.center().x())
            right_of_center = (anchor.x() > self._rect.center().x() and
                anchor.x() <= self._rect.right())
            on_right = anchor.x() > self._rect.right()

            # get the nearest _rect corner.
            x = (on_right + right_of_center) * self._rect.width()
            y = (below + below_center) * self._rect.height()
            corner_case = ((above and on_left) or (above and on_right) or
                (below and on_left) or (below and on_right))
            vertical = abs(anchor.x() - x) > abs(anchor.y() - y)

            x1 = (x + left_of_center * 10 - right_of_center * 20 + corner_case *
                int(not vertical) * (on_left * 10 - on_right * 20))
            y1 = (y + above_center * 10 - below_center * 20 + corner_case *
                vertical * (above * 10 - below * 20))
            point1.setX(x1)
            point1.setY(y1)

            x2 = (x + left_of_center * 20 - right_of_center * 10 + corner_case *
                int(not vertical) * (on_left * 20 - on_right * 10))
            y2 = (y + above_center * 20 - below_center * 10 + corner_case *
                vertical * (above * 20 - below * 10))
            point2.setX(x2)
            point2.setY(y2)

            path.moveTo(point1)
            path.lineTo(anchor)
            path.lineTo(point2)
            path = path.simplified()

        painter.setBrush(QColor(255, 255, 255))
        painter.drawPath(path)
        painter.drawText(self._textRect, self._text)

    def mousePressEvent(self, event):
        if event.button() == Qt.RightButton:
            self.removecallout()
        event.setAccepted(True)

    def mouseMoveEvent(self, event):
        if event.buttons() & Qt.LeftButton:
            self.setPos(self.mapToParent(
                event.pos() - event.buttonDownPos(Qt.LeftButton)))
            event.setAccepted(True)
        else:
            event.setAccepted(False)

    def set_text(self, text):
        self._text = text
        metrics = QFontMetrics(self._font)
        self._textRect = QRectF(metrics.boundingRect(
            QRect(0.0, 0.0, 150.0, 150.0), Qt.AlignLeft, self._text))
        self._textRect.translate(5, 5)
        self.prepareGeometryChange()
        self._rect = self._textRect.adjusted(-5, -5, 5, 5)

    def set_anchor(self, point):
        self._anchor = QPointF(point)

    def update_geometry(self):
        self.prepareGeometryChange()
        self.setPos(self.chart.mapToPosition(
            self._anchor) + QPointF(10, -50))

    def removecallout(self):
        self.hide()

The Class which produces the chart:

class CreateChart(QChartView):
    def __init__(self, data):
        super().__init__()

        self.serieses = self.getserieses(data)

        i = 0

        self.chart = QChart()
        self.buddy = None
        self.chart.legend().setVisible(False)
        xaxis = QValueAxis()
        xaxis.setTitleText("Time")
        self.chart.addAxis(xaxis, Qt.AlignBottom)
        for key in self.serieses.keys():
            if i >= 100:
                break
            else:
                self.serieses[key].setName(key)
                self.chart.addSeries(self.serieses[key])
                self.serieses[key].hovered.connect(self.tooltip)   #The connections for the temporary callout of the coordinates
                self.serieses[key].clicked.connect(self.keep_callout)  #The connection for the permanent callout of the coordinates
                axis = QValueAxis()
                axis.setTitleText(key)
                axis.setTitleBrush(self.serieses[key].color())
                self.chart.addAxis(axis, Qt.AlignLeft if ((i % 2) == 0) else Qt.AlignRight)
                self.serieses[key].attachAxis(axis)
                self.serieses[key].attachAxis(xaxis)
                i += 1
        print("Finished Loading")
        
        self.chart.legend().setMarkerShape(QLegend.MarkerShapeFromSeries)

        self.chart_view = super()
        self.chart_view.setRenderHint(QPainter.Antialiasing)
        self.chart_view.setChart(self.chart)
        #self.chart_view.setMaximumWidth(300)

        #QGraphicsView.RubberbandDrag = Selecting an area which can be retrieved by **Your QChartView**.rubberBandRect()
        self.chart_view.setDragMode(QGraphicsView.RubberBandDrag)
        self.chart_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)       #Don't need these, as they don't move the Graph. but the whole window
        self.chart_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)     # ^
        self.chart.setFocusPolicy(Qt.NoFocus)

        self._tooltip = Callout(self.chart)
        self._callouts = []

        


        self.setMouseTracking(True)     #Must be on

        
    
    def tooltip(self, point, state):
        #point = self.Mouse
        if self._tooltip == 0:
            self._tooltip = Callout(self._chart)

        if state:
            x = point.x()
            y = point.y()
            self._tooltip.set_text(f"X: {x:.1f} \nY: {y:.1f} ")
            self._tooltip.set_anchor(point)
            self._tooltip.setZValue(11)
            self._tooltip.update_geometry()
            self._tooltip.show()
        else:
            self._tooltip.hide()

    def keep_callout(self):
        self._callouts.append(self._tooltip)
        self._tooltip = Callout(self.chart)

Now when I execute this the callouts appear perfect on one series, but on all the others the callouts appear above or below where my mouse is actually located, as the callout gets drawn on a different axis than the series is


Solution

  • Showing tooltip in a Qt chart with multiple y axes

    Answer to this question could be found here in C++, answered by eyllanesc.

    Here is a PySide6 version of the answer.

    """PySide6 port of the Callout example from Qt v5.x"""
    
    import sys
    from PySide6.QtWidgets import (QApplication, QGraphicsScene,
                                   QGraphicsView, QGraphicsSimpleTextItem, QGraphicsItem)
    from PySide6.QtCore import Qt, QPointF, QRectF, QRect
    from PySide6.QtCharts import QChart, QChartView, QLineSeries, QSplineSeries, QValueAxis
    from PySide6.QtGui import QPainter, QFont, QFontMetrics, QPainterPath, QColor
    
    
    class Callout(QGraphicsItem):
    
        def __init__(self, chart, series):
            QGraphicsItem.__init__(self, chart)
            self._chart = chart
            self._series = series
            self._text = ""
            self._textRect = QRectF()
            self._anchor = QPointF()
            self._font = QFont()
            self._rect = QRectF()
    
        def boundingRect(self):
            anchor = self.mapFromParent(
                self._chart.mapToPosition(self._anchor, self._series))
            rect = QRectF()
            rect.setLeft(min(self._rect.left(), anchor.x()))
            rect.setRight(max(self._rect.right(), anchor.x()))
            rect.setTop(min(self._rect.top(), anchor.y()))
            rect.setBottom(max(self._rect.bottom(), anchor.y()))
    
            return rect
    
        def paint(self, painter, option, widget):
            path = QPainterPath()
            path.addRoundedRect(self._rect, 5, 5)
            anchor = self.mapFromParent(
                self._chart.mapToPosition(self._anchor, self._series))
            if not self._rect.contains(anchor) and not self._anchor.isNull():
                point1 = QPointF()
                point2 = QPointF()
    
                # establish the position of the anchor point in relation to _rect
                above = anchor.y() <= self._rect.top()
                above_center = (anchor.y() > self._rect.top() and
                                anchor.y() <= self._rect.center().y())
                below_center = (anchor.y() > self._rect.center().y() and
                                anchor.y() <= self._rect.bottom())
                below = anchor.y() > self._rect.bottom()
    
                on_left = anchor.x() <= self._rect.left()
                left_of_center = (anchor.x() > self._rect.left() and
                                  anchor.x() <= self._rect.center().x())
                right_of_center = (anchor.x() > self._rect.center().x() and
                                   anchor.x() <= self._rect.right())
                on_right = anchor.x() > self._rect.right()
    
                # get the nearest _rect corner.
                x = (on_right + right_of_center) * self._rect.width()
                y = (below + below_center) * self._rect.height()
                corner_case = ((above and on_left) or (above and on_right) or
                               (below and on_left) or (below and on_right))
                vertical = abs(anchor.x() - x) > abs(anchor.y() - y)
    
                x1 = (x + left_of_center * 10 - right_of_center * 20 + corner_case *
                      int(not vertical) * (on_left * 10 - on_right * 20))
                y1 = (y + above_center * 10 - below_center * 20 + corner_case *
                      vertical * (above * 10 - below * 20))
                point1.setX(x1)
                point1.setY(y1)
    
                x2 = (x + left_of_center * 20 - right_of_center * 10 + corner_case *
                      int(not vertical) * (on_left * 20 - on_right * 10))
                y2 = (y + above_center * 20 - below_center * 10 + corner_case *
                      vertical * (above * 20 - below * 10))
                point2.setX(x2)
                point2.setY(y2)
    
                path.moveTo(point1)
                path.lineTo(anchor)
                path.lineTo(point2)
                path = path.simplified()
    
            painter.setBrush(QColor(255, 255, 255))
            painter.drawPath(path)
            painter.drawText(self._textRect, self._text)
    
        def mousePressEvent(self, event):
            event.setAccepted(True)
    
        def mouseMoveEvent(self, event):
            if event.buttons() & Qt.LeftButton:
                self.setPos(self.mapToParent(
                    event.pos() - event.buttonDownPos(Qt.LeftButton)))
                event.setAccepted(True)
            else:
                event.setAccepted(False)
    
        def set_text(self, text):
            self._text = text
            metrics = QFontMetrics(self._font)
            self._textRect = QRectF(metrics.boundingRect(
                QRect(0.0, 0.0, 150.0, 150.0), Qt.AlignLeft, self._text))
            self._textRect.translate(5, 5)
            self.prepareGeometryChange()
            self._rect = self._textRect.adjusted(-5, -5, 5, 5)
    
        def set_anchor(self, point):
            self._anchor = QPointF(point)
    
        def update_geometry(self):
            self.prepareGeometryChange()
            self.setPos(self._chart.mapToPosition(
                self._anchor, self._series) + QPointF(10, -50))
    
        def setSeries(self, series):
            self._series = series
    
    
    class View(QChartView):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setScene(QGraphicsScene(self))
    
            self.setDragMode(QGraphicsView.RubberBandDrag)
            self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
    
            # Chart
            self._chart = QChart()
            self._chart.setMinimumSize(640, 480)
            self._chart.setTitle("Hover the line to show callout. Click the line "
                                 "to make it stay")
            self._chart.legend().hide()
    
            self.series = QLineSeries()
            self.series.append(1, 3)
            self.series.append(4, 5)
            self.series.append(5, 4.5)
            self.series.append(7, 1)
            self.series.append(11, 2)
            self._chart.addSeries(self.series)
    
            self.series2 = QSplineSeries()
            self.series2.append(1.6, 1.4)
            self.series2.append(2.4, 3.5)
            self.series2.append(3.7, 2.5)
            self.series2.append(7, 4)
            self.series2.append(10, 2)
    
            self._chart.addSeries(self.series2)
    
            xaxis = QValueAxis()
            xaxis.setTitleText("X")
            self._chart.addAxis(xaxis, Qt.AlignBottom)
            yaxis = QValueAxis()
            yaxis.setTitleText("YR")
            self._chart.addAxis(yaxis, Qt.AlignRight)
            y2axis = QValueAxis()
            y2axis.setTitleText("YL")
            self._chart.addAxis(y2axis, Qt.AlignLeft)
    
            self.series.attachAxis(xaxis)
            self.series.attachAxis(y2axis)
    
            self.series2.attachAxis(xaxis)
            self.series2.attachAxis(yaxis)
    
            self._chart.setAcceptHoverEvents(True)
    
            self.setRenderHint(QPainter.Antialiasing)
            self.scene().addItem(self._chart)
    
            self._coordX = QGraphicsSimpleTextItem(self._chart)
            self._coordX.setPos(
                self._chart.size().width() / 2 - 50, self._chart.size().height())
            self._coordX.setText("X: ")
            self._coordY = QGraphicsSimpleTextItem(self._chart)
            self._coordY.setPos(
                self._chart.size().width() / 2 + 50, self._chart.size().height())
            self._coordY.setText("Y: ")
    
            self._callouts = []
            self._tooltip = Callout(self._chart, self.series)
    
            self.series.clicked.connect(self.keep_callout)
            self.series.hovered.connect(self.tooltip)
    
            self.series2.clicked.connect(self.keep_callout)
            self.series2.hovered.connect(self.tooltip)
    
            self.setMouseTracking(True)
    
        def resizeEvent(self, event):
            if self.scene():
                self.scene().setSceneRect(QRectF(QPointF(0, 0), event.size()))
                self._chart.resize(event.size())
                self._coordX.setPos(
                    self._chart.size().width() / 2 - 50,
                    self._chart.size().height() - 20)
                self._coordY.setPos(
                    self._chart.size().width() / 2 + 50,
                    self._chart.size().height() - 20)
                for callout in self._callouts:
                    callout.update_geometry()
            QGraphicsView.resizeEvent(self, event)
    
        def mouseMoveEvent(self, event):
            pos = self._chart.mapToValue(event.pos())
            x = pos.x()
            y = pos.y()
            self._coordX.setText(f"X: {x:.2f}")
            self._coordY.setText(f"Y: {y:.2f}")
            QGraphicsView.mouseMoveEvent(self, event)
    
        def keep_callout(self):
            series = self.sender()
            self._callouts.append(self._tooltip)
            self._tooltip = Callout(self._chart, series)
    
        def tooltip(self, point, state):
            series = self.sender()
            if self._tooltip == 0:
                self._tooltip = Callout(self._chart, series)
    
            if state:
                x = point.x()
                y = point.y()
                self._tooltip.setSeries(series)
                self._tooltip.set_text(f"X: {x:.2f} \nY: {y:.2f} ")
                self._tooltip.set_anchor(point)
                self._tooltip.setZValue(11)
                self._tooltip.update_geometry()
                self._tooltip.show()
            else:
                self._tooltip.hide()
    
    
    if __name__ == "__main__":
        app = QApplication(sys.argv)
        v = View()
        v.show()
        sys.exit(app.exec())
    

    enter image description here