1

Aim

I would like to simultaneously:

1) Select multiple artists on a matplotlib canvas by window-select.

By that I mean that if I hold down the mouse button, drag the mouse, and release, all artists in the rectangle defined by the x,y locations of the button press and the button release are picked.

2) Drag multiple selected artists by then pressing the mouse down over one of the selected artists and moving the mouse, and releasing.

This is exactly the same behaviour as one would expect in a normal file browser.

Previous attempts and remaining problems

To that end, I have started to write two classes, WindowSelect and Draggable, which are shown below.

WindowSelect implements the logic for (1) apart from the fact that I don't know how to manually trigger the picker function, as it is referred to in the matplotlib documentation. Instead I call a place holder function func.

Draggable implements the logic for (2) apart from the fact that a single artist is picked at a time (shamelessly appropriated from another indicated SO answer). The matplotlib documentation indicates that the picking of multiple artists simultaneously should be possible (last example on the web page). However, if I, for example, set the tolerance for the picker very high, only a single artist appears to get selected / can then be dragged around on the canvas, so I am unsure how the code needs to change to accommodate multiple artists.

Code

import numpy as np
import matplotlib.pyplot as plt; plt.ion()
import matplotlib.patches as patches


class WindowSelect(object):

    def __init__(self, artists):
        self.artists = artists
        self.canvases = set(artist.figure.canvas for artist in self.artists)

        for canvas in self.canvases:
            canvas.mpl_connect('button_press_event', self.on_press)
            canvas.mpl_connect('button_release_event', self.on_release)

        self.currently_dragging = False

    def on_press(self, event):
        if not self.currently_dragging:
            self.x0 = event.xdata
            self.y0 = event.ydata
            self.currently_dragging = True

    def on_release(self, event):
        if self.currently_dragging:
            self.x1 = event.xdata
            self.y1 = event.ydata

            for artist in self.artists:
                if self.is_inside_rect(*artist.center):
                    self.func(artist)

            for canvas in self.canvases:
                canvas.draw()

            self.currently_dragging = False

    def is_inside_rect(self, x, y):
        xlim = np.sort([self.x0, self.x1])
        ylim = np.sort([self.y0, self.y1])
        if (xlim[0]<=x) and (x<xlim[1]) and (ylim[0]<=y) and (y<ylim[1]):
            return True
        else:
            return False

    def func(self, artist):
        artist.set_color('k') # <- just an example operation; would like to pick artist instead


class Draggable(object):
    """
    https://stackoverflow.com/questions/21654008/matplotlib-drag-overlapping-points-interactively
    """
    def __init__(self, artists, tolerance=5):
        for artist in artists:
            artist.set_picker(tolerance)
        self.artists = artists
        self.currently_dragging = False
        self.current_artist = None
        self.offset = (0, 0)

        for canvas in set(artist.figure.canvas for artist in self.artists):
            canvas.mpl_connect('button_press_event', self.on_press)
            canvas.mpl_connect('button_release_event', self.on_release)
            canvas.mpl_connect('pick_event', self.on_pick)
            canvas.mpl_connect('motion_notify_event', self.on_motion)

    def on_press(self, event):
        self.currently_dragging = True

    def on_release(self, event):
        self.currently_dragging = False
        self.current_artist = None

    def on_pick(self, event):
        if self.current_artist is None:
            self.current_artist = event.artist
            x0, y0 = event.artist.center
            x1, y1 = event.mouseevent.xdata, event.mouseevent.ydata
            self.offset = (x0 - x1), (y0 - y1)

    def on_motion(self, event):
        if not self.currently_dragging:
            return
        if self.current_artist is None:
            return
        dx, dy = self.offset
        self.current_artist.center = event.xdata + dx, event.ydata + dy
        self.current_artist.figure.canvas.draw()


def demo(TestClass):

    fig, ax = plt.subplots(1,1)
    xlim = [-5, 5]
    ylim = [-5, 5]
    ax.set(xlim=xlim, ylim=ylim)

    circles = [patches.Circle((3.0, 3.0), 0.5, fc='r', alpha=1.0),
               patches.Circle((0.0, 0.0), 0.5, fc='b', alpha=1.0),
               patches.Circle((0.0, 3.0), 0.5, fc='g', alpha=1.0)]

    for circle in circles:
        ax.add_patch(circle)

    return TestClass(circles)


if __name__ == '__main__':

    out1 = demo(Draggable)
    out2 = demo(WindowSelect)
Paul Brodersen
  • 11,221
  • 21
  • 38
  • Well, you have two different sets of objects interacting with two different canvases, so you'll need a single canvas if you expect these to interact. The `on_pick` event only moves a single artist, so you'll need to modify it to move all artists that have been selected. – user2699 Nov 14 '17 at 22:17
  • Thanks for you comment. I am well aware that the desired class implementing both parts should be hooked up to a single canvas. As I mentioned above, both subroutines still have their own problems (even before combining them), so I thought that it might be better to separate them into two self-contained classes for the time being. I would be interested to hear/see your suggestion for an implementation of `on_click` that deals with multiple artists. – Paul Brodersen Nov 15 '17 at 00:57

2 Answers2

2

It's difficult to keep the selection and dragging separate. This is because the respective events need to perform different tasks depending on whether you are currently selecting or dragging. At each point in time you would hence need to know which action is currently being performed and that is best done inside a single class, where you might have two different flags, self.currently_selecting and self.currently_dragging. You would then also need a list of selected artists, self.selected_artists, which can be dragged when required.

I would get rid of the pick_event because it is hard to distinguish from the button_press_event anyways (they would be triggered simultaneously and its not deterministic which comes first). Instead a single button_press_event can handle all the logic; i.e. find out if click happens on an artist, if so, start dragging, else start selecting.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches


class WindowSelect(object):

    def __init__(self, artists):
        self.artists = artists
        self.colors = [a.get_facecolor() for a in self.artists]
        # assume all artists are in the same figure, otherwise selection is meaningless
        self.fig = self.artists[0].figure
        self.ax = self.artists[0].axes

        self.fig.canvas.mpl_connect('button_press_event', self.on_press)
        self.fig.canvas.mpl_connect('button_release_event', self.on_release)
        self.fig.canvas.mpl_connect('motion_notify_event', self.on_motion)

        self.currently_selecting = False
        self.currently_dragging = False
        self.selected_artists = []
        self.offset = np.zeros((1,2))
        self.rect = plt.Rectangle((0,0),1,1, linestyle="--",
                                  edgecolor="crimson", fill=False)
        self.ax.add_patch(self.rect)
        self.rect.set_visible(False)

    def on_press(self, event):
        # is the press over some artist
        isonartist = False
        for artist in self.artists:
            if artist.contains(event)[0]:
                isonartist = artist
        self.x0 = event.xdata
        self.y0 = event.ydata
        if isonartist:
            # add clicked artist to selection
            self.select_artist(isonartist)
            # start dragging
            self.currently_dragging = True
            ac = np.array([a.center for a in self.selected_artists])
            ec = np.array([event.xdata, event.ydata])
            self.offset = ac - ec
        else:
            #start selecting
            self.currently_selecting = True
            self.deseclect_artists()

    def on_release(self, event):
        if self.currently_selecting:

            for artist in self.artists:
                if self.is_inside_rect(*artist.center):
                    self.select_artist(artist)
            self.fig.canvas.draw_idle()
            self.currently_selecting = False
            self.rect.set_visible(False)

        elif self.currently_dragging:
            self.currently_dragging = False


    def on_motion(self, event):
        if self.currently_dragging:
            newcenters = np.array([event.xdata, event.ydata])+self.offset
            for i, artist in enumerate(self.selected_artists):
                artist.center = newcenters[i]
            self.fig.canvas.draw_idle()
        elif self.currently_selecting:
            self.x1 = event.xdata
            self.y1 = event.ydata
            #add rectangle for selection here
            self.selector_on()
            self.fig.canvas.draw_idle()

    def is_inside_rect(self, x, y):
        xlim = np.sort([self.x0, self.x1])
        ylim = np.sort([self.y0, self.y1])
        if (xlim[0]<=x) and (x<xlim[1]) and (ylim[0]<=y) and (y<ylim[1]):
            return True
        else:
            return False

    def select_artist(self, artist):
        artist.set_color('k')
        if artist not in self.selected_artists:
            self.selected_artists.append(artist)

    def deseclect_artists(self):
        for artist,color in zip(self.artists, self.colors):
            artist.set_color(color)
        self.selected_artists = []

    def selector_on(self):
        self.rect.set_visible(True)
        xlim = np.sort([self.x0, self.x1])
        ylim = np.sort([self.y0, self.y1])
        self.rect.set_xy((xlim[0],ylim[0] ) )
        self.rect.set_width(np.diff(xlim))
        self.rect.set_height(np.diff(ylim))


def demo():

    fig, ax = plt.subplots(1,1)
    xlim = [-5, 5]
    ylim = [-5, 5]
    ax.set(xlim=xlim, ylim=ylim)

    circles = [patches.Circle((3.0, 3.0), 0.5, fc='r', alpha=1.0),
               patches.Circle((0.0, 0.0), 0.5, fc='b', alpha=1.0),
               patches.Circle((0.0, 3.0), 0.5, fc='g', alpha=1.0)]

    for circle in circles:
        ax.add_patch(circle)

    w = WindowSelect(circles)
    plt.show()

if __name__ == '__main__':
    demo()

enter image description here

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • I thought this question might peak your interest. ;-) As always, you came through with a fantastic answer -- my thanks. You may want to initialise the rectangle "corners" (`x0, y0, x1, x2`) in `__init__`. I got an `AttributeError` when on my first try I clumsily clicked in the blank space and then didn't actually select anything (a short click seems to be able to trigger a `button_release_event` before the `button_press_event` is registered). That's a user error, of course, but a little robustness can't hurt (even when it does take away from the elegance of the code). – Paul Brodersen Nov 15 '17 at 17:50
  • If you want you can edit the answer to include what you think is missing. In a real world scenario you would probably check if clicks happen inside the axes, `if event.inaxes == self.ax:`. Or you can check if `event.xdata` is `None` before using it. – ImportanceOfBeingErnest Nov 15 '17 at 21:56
  • 1
    Hey Ernest, in case you ever wonder what people do with your code: I used it to refine a little module that I made for plotting networks. You can now move nodes around interactively and thus tweak the network layout. Code is [here](https://github.com/paulbrodersen/netgraph). – Paul Brodersen Nov 24 '17 at 10:24
0

The only modification your second class needs is to track and move multiple artists at once. Modifying the following functions will allow for this.

def on_pick(self, event):
    if self.current_artist is None:
        self.current_artist = event.artist
        ecoord = array([event.mouseevent.xdata, event.mouseevent.ydata])
        self.offsets = [a.center for a in self.artists] - ecoord

def on_motion(self, event):
    if not self.currently_dragging:
        return
    if self.current_artist is None:
        return

    center = event.xdata, event.ydata
    for artist, delta in zip(self.artists, self.offsets):
        artist.center = center + delta
    self.artists[0].figure.canvas.draw()
user2699
  • 2,927
  • 14
  • 31
  • I think this would throw an error and even when not, you would drag all artists at once not only the selected ones. – ImportanceOfBeingErnest Nov 15 '17 at 15:23
  • It meant to replace the function definitions in the `Draggable` class, so it won't throw an error. You're right that it will drag all `artists`- the logic from both classes needs to be combined as @Paul stated. – user2699 Nov 15 '17 at 15:35
  • @ImportanceOfBeingErnest, `eecord` is a numpy array so it broadcasts the operation to each list element. If you still have doubts, run the code. – user2699 Nov 15 '17 at 17:43