1

I am trying to create a display where you have a matplotlib canvas in the background, and an overlapping widget in the foreground displaying some arbitrary information about the plotted data. This in itself I have basically achieved, however, I have trouble with the alignment.

I would like the overlapping widget (in the example below the QGroupBox) to be aligned with the lower left corner of the axes, and also respond to whenever the window size is changed. The problem is that I don't know how I can change the relative position of the two overlapping widgets correctly.

I found this answer (below called method 1), which uses QAlignment, but once that is set, the QGroupBox seems irresponsive to any kind of positional changes. Maybe it is possible to add margins and change them dynamically?

The other method I found is this one (below called method 2), which uses absolute positioning, and thus doesn't change with resizing the window. Maybe this one makes more sense? But then there is some transformation and signal handling necessary to reposition the QGroupBox every time the window resizes. But somehow I didn't manage to get the transformation right.

Lastly, I also found this, dealing with anchors, but I have no idea how they work, and if they are even a thing in regular PyQt5.

import sys
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import \
    FigureCanvasQTAgg as FigureCanvas
import matplotlib.patheffects as PathEffects

from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtWidgets import QDialog, QApplication, QGridLayout, QGroupBox, \
    QLabel, QLineEdit


class MainWindow(QDialog):
    def __init__(self):
        super().__init__()

        # this is just for context

        self.fig, self.ax = plt.subplots()
        self.canvas = FigureCanvas(self.fig)
        self.data = np.random.uniform(0, 1, (50, 2))
        self.artists = []
        for point in self.data:
            artist = self.ax.plot(*point, 'o', c='orange')[0]
            artist.set_pickradius = 5
            self.artists.append(artist)
        self.zoom_factor = 1.2
        self.x_press = 0
        self.y_press = 0
        self.last_artist = None
        self.cid_motion = self.canvas.mpl_connect(
            'motion_notify_event', self.motion_event
        )
        self.cid_button = self.canvas.mpl_connect(
            'button_press_event', self.pan_press
        )
        self.cid_zoom = self.canvas.mpl_connect('scroll_event', self.zoom)

        self.mainLayout = QGridLayout(self)
        self.mainLayout.addWidget(self.canvas, 0, 0)
        self.setLayout(self.mainLayout)
        self.statsBox = QGroupBox('Stats:')
        self.statsLayout = QGridLayout(self.statsBox)
        self.posLabel = QLabel('Pos:')
        self.statsLayout.addWidget(self.posLabel, 0, 0)
        self.posEdit = QLineEdit()
        self.posEdit.setReadOnly(True)
        self.posEdit.setAlignment(Qt.AlignHCenter)
        self.statsLayout.addWidget(self.posEdit, 0, 1)

        # here is what's interesting

        # method 1
        # self.mainLayout.addWidget(
        #     self.statsBox, 0, 0, Qt.AlignRight | Qt.AlignBottom
        # )

        # method 2
        self.statsBox.setParent(self)
        self.statsBox.setFixedSize(self.statsBox.sizeHint())
        self.position_statsBox()

    def resizeEvent(self, a0):
        super().resizeEvent(a0)
        self.position_statsBox()

    def position_statsBox(self):
        x, y = self.ax.get_xlim()[1], self.ax.get_ylim()[0]
        pos = QPoint(*self.ax.transData.transform((x, y)))
        self.statsBox.move(pos)

    # below here is just for context again

    def motion_event(self, event):
        if event.inaxes == self.ax and event.button == 1:
            self.pan_move(event)
        else:
            self.hover(event)

    def pan_press(self, event):
        if event.inaxes == self.ax and event.button == 1:
            self.x_press = event.xdata
            self.y_press = event.ydata

    def pan_move(self, event):
        xdata = event.xdata
        ydata = event.ydata
        cur_xlim = self.ax.get_xlim()
        cur_ylim = self.ax.get_ylim()
        dx = xdata - self.x_press
        dy = ydata - self.y_press
        new_xlim = [cur_xlim[0] - dx, cur_xlim[1] - dx]
        new_ylim = [cur_ylim[0] - dy, cur_ylim[1] - dy]

        self.ax.set_xlim(new_xlim)
        self.ax.set_ylim(new_ylim)
        self.canvas.draw_idle()

    def zoom(self, event):
        if event.inaxes == self.ax:
            xdata, ydata = event.xdata, event.ydata
            xlim = self.ax.get_xlim()
            ylim = self.ax.get_ylim()
            x_left = xdata - xlim[0]
            x_right = xlim[1] - xdata
            y_bottom = ydata - ylim[0]
            y_top = ylim[1] - ydata
            scale_factor = np.power(self.zoom_factor, -event.step)
            new_xlim = xdata-x_left*scale_factor, xdata+x_right*scale_factor
            new_ylim = ydata-y_bottom*scale_factor, ydata+y_top*scale_factor
            self.ax.set_xlim(new_xlim)
            self.ax.set_ylim(new_ylim)
            self.canvas.draw_idle()

    def hover(self, event):
        ind = 0
        cont = None
        while ind in range(len(self.artists)) and not cont:
            artist = self.artists[ind]
            cont, _ = artist.contains(event)
            if cont and artist is not self.last_artist:
                if self.last_artist is not None:
                    self.last_artist.set_path_effects(
                        [PathEffects.Normal()]
                    )
                    self.last_artist = None
                artist.set_path_effects(
                    [PathEffects.withStroke(
                        linewidth=7, foreground="c", alpha=0.4
                    )]
                )
                self.last_artist = artist
                x, y = artist.get_data()
                pos = f'({x[0]:.2f}, {y[0]:.2f})'
                self.posEdit.setText(pos)
            ind += 1

        if not cont and self.last_artist:
            self.last_artist.set_path_effects([PathEffects.Normal()])
            self.last_artist = None
            self.posEdit.clear()

        self.canvas.draw_idle()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    GUI = MainWindow()
    GUI.show()
    sys.exit(app.exec_())
mapf
  • 1,906
  • 1
  • 14
  • 40

1 Answers1

2

The solution is to set the QGroupBox as a child of the canvas and change the position using the position of the axis bbox:

import sys
import numpy as np

from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib.patheffects as PathEffects

from PyQt5.QtCore import Qt, QPoint, QTimer
from PyQt5.QtWidgets import (
    QDialog,
    QApplication,
    QGridLayout,
    QGroupBox,
    QLabel,
    QLineEdit,
)


class MainWindow(QDialog):
    def __init__(self):
        super().__init__()

        # this is just for context

        self.fig = Figure()
        self.canvas = FigureCanvas(self.fig)
        self.ax = self.canvas.figure.subplots()
        self.data = np.random.uniform(0, 1, (50, 2))
        self.artists = []
        for point in self.data:
            artist = self.ax.plot(*point, "o", c="orange")[0]
            artist.set_pickradius = 5
            self.artists.append(artist)
        self.zoom_factor = 1.2
        self.x_press = 0
        self.y_press = 0
        self.last_artist = None
        self.cid_motion = self.canvas.mpl_connect(
            "motion_notify_event", self.motion_event
        )
        self.cid_button = self.canvas.mpl_connect("button_press_event", self.pan_press)
        self.cid_zoom = self.canvas.mpl_connect("scroll_event", self.zoom)

        self.mainLayout = QGridLayout(self)
        self.mainLayout.addWidget(self.canvas, 0, 0)

        self.statsBox = QGroupBox("Stats:", self.canvas)
        self.statsLayout = QGridLayout(self.statsBox)
        self.posLabel = QLabel("Pos:")
        self.statsLayout.addWidget(self.posLabel, 0, 0)
        self.posEdit = QLineEdit()
        self.posEdit.setReadOnly(True)
        self.posEdit.setAlignment(Qt.AlignHCenter)
        self.statsLayout.addWidget(self.posEdit, 0, 1)
        self.statsBox.setFixedSize(self.statsBox.sizeHint())

        self.position_statsBox()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.position_statsBox()

    def position_statsBox(self):
        x0, y0, x1, y1 = self.ax.bbox.extents
        p = QPoint(int(x0), int(y1))
        p -= QPoint(0, self.statsBox.height())
        p += QPoint(0, 6)  # FIXME
        self.statsBox.move(p)

    def motion_event(self, event):
        if event.inaxes == self.ax and event.button == 1:
            self.pan_move(event)
        else:
            self.hover(event)

    def pan_press(self, event):
        if event.inaxes == self.ax and event.button == 1:
            self.x_press = event.xdata
            self.y_press = event.ydata

    def pan_move(self, event):
        xdata = event.xdata
        ydata = event.ydata
        cur_xlim = self.ax.get_xlim()
        cur_ylim = self.ax.get_ylim()
        dx = xdata - self.x_press
        dy = ydata - self.y_press
        new_xlim = [cur_xlim[0] - dx, cur_xlim[1] - dx]
        new_ylim = [cur_ylim[0] - dy, cur_ylim[1] - dy]

        self.ax.set_xlim(new_xlim)
        self.ax.set_ylim(new_ylim)
        self.canvas.draw_idle()

    def zoom(self, event):
        if event.inaxes == self.ax:
            xdata, ydata = event.xdata, event.ydata
            xlim = self.ax.get_xlim()
            ylim = self.ax.get_ylim()
            x_left = xdata - xlim[0]
            x_right = xlim[1] - xdata
            y_bottom = ydata - ylim[0]
            y_top = ylim[1] - ydata
            scale_factor = np.power(self.zoom_factor, -event.step)
            new_xlim = xdata - x_left * scale_factor, xdata + x_right * scale_factor
            new_ylim = ydata - y_bottom * scale_factor, ydata + y_top * scale_factor
            self.ax.set_xlim(new_xlim)
            self.ax.set_ylim(new_ylim)
            self.canvas.draw_idle()

    def hover(self, event):
        ind = 0
        cont = None
        while ind in range(len(self.artists)) and not cont:
            artist = self.artists[ind]
            cont, _ = artist.contains(event)
            if cont and artist is not self.last_artist:
                if self.last_artist is not None:
                    self.last_artist.set_path_effects([PathEffects.Normal()])
                    self.last_artist = None
                artist.set_path_effects(
                    [PathEffects.withStroke(linewidth=7, foreground="c", alpha=0.4)]
                )
                self.last_artist = artist
                x, y = artist.get_data()
                pos = f"({x[0]:.2f}, {y[0]:.2f})"
                self.posEdit.setText(pos)
            ind += 1

        if not cont and self.last_artist:
            self.last_artist.set_path_effects([PathEffects.Normal()])
            self.last_artist = None
            self.posEdit.clear()

        self.canvas.draw_idle()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    GUI = MainWindow()
    GUI.show()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Oooh, that's smart. Didn't think of that. Thank you! What is the `# FIXME` refering to? – mapf Feb 18 '21 at 21:10
  • @mapf It is that I had to use that line to correct the vertical displacement (at least in my test I saw that), since that "6" is a value that does not arise from any method but from experimental trial and error, I think that it should be sought from where it arises that "6" (maybe in another environment not "6" that would cause the problem I wanted to correct) – eyllanesc Feb 18 '21 at 21:13
  • Thanks for the explanation. Sorry didn't see your answer before commenting again. But yeah, it seems weird. In my case the displacement gets slightly larger with enlarging the window. Nothing that can't befixed with a bit of magic though. Still wonder where it's coming from. – mapf Feb 18 '21 at 21:26
  • Ok, I experimented a bit and found that `p = QPoint(x0, y1*1.015 - self.statsBox.height())` fits almost perfectly for any size. Still don't know what's causing it though. Btw. using `tight_layout`, `constrained_layout` or `subplots_adjust` completely sets it off as well and so requires different magic numbers. I don't really understand that either. – mapf Feb 18 '21 at 21:54