Search code examples
pythonmatplotlibpyqtpyside2

How to interact with a matplotlib plot using double clicked events (e.g. adding marker, canceling previous action)?


I am learning how to use click events to trigger specific actions in matplotlib.

In my reproductible example, I would like:

  • task1 : drag marker at a different position when clicking/dragging them [this is done]
  • task2 : add markers in a plot when double clicking on it with the left mouse button
  • task3 : cancel the previous click action (add marker or drag marker) when double clicking with the right mouse button

Task1

These posts Matplotlib draggable data marker and Matplotlib drag overlapping points interactively were very useful to implement the draggable data and I think it is working well in my example.

Task2

I think in the code below I should be close to implementing the 'add marker' events but something is not right when I update the data of the artist as the new markers don't appear on the plot.

Task3

I don't know what is the best way to implement this one... I think the best way would be to always keep in memory one copy of the plot before a click event is triggered and restore this copy if a double click event (with the right mouse button) is triggered after a click event (= adding marker or dragging the marker somewhere else)

I am using the script below:

import sys
from PySide2.QtWidgets import QApplication
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg
from matplotlib.backends.backend_qt4agg import FigureManagerQT
import numpy as np


class MyFigureCanvas(FigureCanvasQTAgg):
    def __init__(self):
        super(MyFigureCanvas, self).__init__(Figure())
        # init class attributes:
        self.background = None
        self.draggable = None
        self.msize = 6
        # plot some data:
        x = np.random.rand(25)
        self.ax = self.figure.add_subplot(111)
        (self.markers,) = self.ax.plot(x, marker="o", ms=self.msize)
        # define event connections:
        self.mpl_connect("motion_notify_event", self.on_motion)
        self.mpl_connect("button_press_event", self.on_click)
        self.mpl_connect("button_release_event", self.on_release)

    def on_click(self, event):
        if event.dblclick:
            if event.button == 1:  # add a marker on the line plotted
                # get mouse cursor coordinates in pixels:
                x, y = event.x, event.y
                # get markers xy coordinate in pixels:
                xydata = self.ax.transData.transform(self.markers.get_xydata())
                xdata, ydata = xydata.T

                # update the data of the artist:
                self.markers.set_xdata(xdata)
                self.markers.set_ydata(ydata)
                self.ax.draw_artist(self.markers)
                self.update()

                print(f"{event.button} - coords: x: {x} / y: {y} ")
            elif event.button == 3:  # cancel previous action
                print(f"Double clicked event - {str(event.button)}")

        if event.button == 1:  # 2 is for middle mouse button
            # get mouse cursor coordinates in pixels:
            x = event.x
            y = event.y
            # get markers xy coordinate in pixels:
            xydata = self.ax.transData.transform(self.markers.get_xydata())
            xdata, ydata = xydata.T
            # compute the linear distance between the markers and the cursor:
            r = ((xdata - x) ** 2 + (ydata - y) ** 2) ** 0.5
            if np.min(r) < self.msize:
                # save figure background:
                self.markers.set_visible(False)
                self.draw()
                self.background = self.copy_from_bbox(self.ax.bbox)
                self.markers.set_visible(True)
                self.ax.draw_artist(self.markers)
                self.update()
                # store index of draggable marker:
                self.draggable = np.argmin(r)
            else:
                self.draggable = None

    def on_motion(self, event):
        if self.draggable is not None:
            if event.xdata and event.ydata:
                # get markers coordinate in data units:
                xdata, ydata = self.markers.get_data()
                # change the coordinate of the marker that is
                # being dragged to the ones of the mouse cursor:
                xdata[self.draggable] = event.xdata
                ydata[self.draggable] = event.ydata
                # update the data of the artist:
                self.markers.set_xdata(xdata)
                self.markers.set_ydata(ydata)
                # update the plot:
                self.restore_region(self.background)
                self.ax.draw_artist(self.markers)
                self.update()

    def on_release(self, event):
        self.draggable = None


if __name__ == "__main__":

    app = QApplication(sys.argv)

    canvas = MyFigureCanvas()
    manager = FigureManagerQT(canvas, 1)
    manager.show()

    sys.exit(app.exec_())


Solution

  • I have done the following changes in your code:

    Task 2

    The reason the new marker did not appear in your plot, is that you used event.x, event.y instead of event.xdata, event.ydata (it gets the coordinates automatically instead of converting pixels to coordinates). I appended the new point to the coordinates of the old markers and updated the plot.

    Task 3

    I created a new instance variable self.memory which contains the coordinates of markers. self.memory updates before dragging markers or adding new markers with the self.save_to_memory() function. The self.undo() function uses self.memory to undo the last change. I also replaced the line if event.button == 1: with an elif, because on double click, it accesses both if statements.

    import sys
    from PySide2.QtWidgets import QApplication
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg
    from matplotlib.backends.backend_qt4agg import FigureManagerQT
    import numpy as np
    
    
    class MyFigureCanvas(FigureCanvasQTAgg):
        def __init__(self):
            super(MyFigureCanvas, self).__init__(Figure())
    
            # init class attributes:
            self.background = None
            self.draggable = None
            self.msize = 6
    
            # plot some data:
            x = np.random.rand(25)
            self.ax = self.figure.add_subplot(111)
            (self.markers,) = self.ax.plot(x, marker="o", ms=self.msize)
    
            self.memory = self.markers.get_xydata()
    
            # define event connections:
            self.mpl_connect("motion_notify_event", self.on_motion)
            self.mpl_connect("button_press_event", self.on_click)
            self.mpl_connect("button_release_event", self.on_release)
    
        def save_to_memory(self):
            self.memory = self.markers.get_xydata()
    
        def undo(self):
            self.markers.set_xdata(self.memory[:, 0])
            self.markers.set_ydata(self.memory[:, 1])
            self.draw()
            self.ax.draw_artist(self.markers)
            self.update()
    
        def on_click(self, event):
            if event.dblclick:
                if event.button == 1:  # add a marker on the line plotted
                    self.save_to_memory()
                    # get mouse cursor coordinates in pixels:
                    x, y = event.xdata, event.ydata
    
                    # update the data of the artist:
                    old_xy = self.markers.get_xydata()
                    old_xy = np.vstack((old_xy, np.array([[x, y]])))
                    self.markers.set_xdata(old_xy[:, 0])
                    self.markers.set_ydata(old_xy[:, 1])
                    self.ax.draw_artist(self.markers)
                    self.update()
    
                    print(f"{event.button} - coords: x: {x} / y: {y} ")
                elif event.button == 3:  # cancel previous action
                    print(f"Double clicked event - {str(event.button)}")
                    self.undo()
    
            elif event.button == 1:  # 2 is for middle mouse button
                # get mouse cursor coordinates in pixels:
                x = event.x
                y = event.y
                # print(f"{event.button} - coords: x: {x} / y: {y} ")
                # get markers xy coordinate in pixels:
                xydata = self.ax.transData.transform(self.markers.get_xydata())
                xdata, ydata = xydata.T
                # compute the linear distance between the markers and the cursor:
                r = ((xdata - x) ** 2 + (ydata - y) ** 2) ** 0.5
                if np.min(r) < self.msize:
                    self.save_to_memory()
                    # save figure background:
                    self.markers.set_visible(False)
                    self.draw()
                    self.background = self.copy_from_bbox(self.ax.bbox)
                    self.markers.set_visible(True)
                    self.ax.draw_artist(self.markers)
                    self.update()
                    # store index of draggable marker:
                    self.draggable = np.argmin(r)
                else:
                    self.draggable = None
    
        def on_motion(self, event):
            if self.draggable is not None:
                if event.xdata and event.ydata:
                    # get markers coordinate in data units:
                    xdata, ydata = self.markers.get_data()
                    # change the coordinate of the marker that is
                    # being dragged to the ones of the mouse cursor:
                    xdata[self.draggable] = event.xdata
                    ydata[self.draggable] = event.ydata
                    # update the data of the artist:
                    self.markers.set_xdata(xdata)
                    self.markers.set_ydata(ydata)
                    # update the plot:
                    self.restore_region(self.background)
                    self.ax.draw_artist(self.markers)
                    self.update()
    
        def on_release(self, event):
            self.draggable = None
    
    
    if __name__ == "__main__":
    
        app = QApplication(sys.argv)
    
        canvas = MyFigureCanvas()
        manager = FigureManagerQT(canvas, 1)
        manager.show()
    
        sys.exit(app.exec_())
    

    Edit:

    In the following code I added the functionality for multiple undos. The simple solution would be making self.memory a list, but it would be wasteful of sources. So I gave up the self.memory idea and created a variable self.history which is a list that keeps record of every change that takes place. self.history_depth is the capability of self.history to keep record of the last n changes. self.append_to_history adds history steps to self.history. I also added the functions self.update_markers, self.move_marker, self.append_marker and self.remove_marker for shake of readability and maintainability.

    class MyFigureCanvas(FigureCanvasQTAgg):
        def __init__(self):
            super(MyFigureCanvas, self).__init__(Figure())
    
            # init class attributes:
            self.background = None
            self.draggable = None
            self.msize = 6
    
            # plot some data:
            x = np.random.rand(25)
            self.ax = self.figure.add_subplot(111)
            (self.markers,) = self.ax.plot(x, marker="o", ms=self.msize)
    
            self.history = []
            self.history_depth = 10
    
            # define event connections:
            self.mpl_connect("motion_notify_event", self.on_motion)
            self.mpl_connect("button_press_event", self.on_click)
            self.mpl_connect("button_release_event", self.on_release)
    
        def append_to_history(self, type, index, x=None, y=None):
            if index < 0:
                index += self.markers.get_xydata().shape[0]
            if type == "move":
                self.history.append({"type": "move",
                                     "index": index,
                                     "x": x,
                                     "y": y})
            elif type == "append":
                self.history.append({"type": "append",
                                     "index": index + 1})
            if len(self.history) > self.history_depth:
                del self.history[0]
    
        def undo(self):
            if len(self.history) > 0:
                last_move = self.history[-1]
                if last_move["type"] == "move":
                    self.move_marker(last_move["index"], last_move["x"], last_move["y"])
                elif last_move["type"] == "append":
                    self.remove_marker(last_move["index"])
                del self.history[-1]
    
        def update_markers(self, x_data, y_data):
            self.markers.set_xdata(x_data)
            self.markers.set_ydata(y_data)
            self.draw()
            self.ax.draw_artist(self.markers)
            self.update()
    
        def move_marker(self, i, x, y):
            xdata, ydata = self.markers.get_data()
            xdata[i], ydata[i] = x, y
            self.update_markers(xdata, ydata)
    
        def append_marker(self, x, y):
            new_xy = np.vstack((self.markers.get_xydata(), np.array([[x, y]])))
            self.update_markers(new_xy[:, 0], new_xy[:, 1])
    
        def remove_marker(self, i):
            new_xy = np.delete(self.markers.get_xydata(), i, axis=0)
            self.update_markers(new_xy[:, 0], new_xy[:, 1])
    
        def on_click(self, event):
            if event.dblclick:
                if event.button == 1:
                    self.append_to_history("append", -1)
                    self.append_marker(event.xdata, event.ydata)
                elif event.button == 3:
                    self.undo()
    
            # Single Click
            elif event.button == 1:
                x, y = event.x, event.y
                xydata = self.ax.transData.transform(self.markers.get_xydata())
                xdata, ydata = xydata.T
                r = ((xdata - x) ** 2 + (ydata - y) ** 2) ** 0.5
                if np.min(r) < self.msize:
                    index = np.where(r == r.min())[0][0]
                    x_i, y_i = self.markers.get_xydata()[index]
                    self.append_to_history("move", index, x_i, y_i)
                    self.markers.set_visible(False)
                    self.draw()
                    self.background = self.copy_from_bbox(self.ax.bbox)
                    self.markers.set_visible(True)
                    self.ax.draw_artist(self.markers)
                    self.update()
                    self.draggable = np.argmin(r)
                else:
                    self.draggable = None
    
        def on_motion(self, event):
            if self.draggable is not None:
                if event.xdata and event.ydata:
                    self.move_marker(self.draggable, event.xdata, event.ydata)
    
        def on_release(self, event):
            self.draggable = None