1

How can I create an arrow in Matplotlib whose end (head) can be dragged by a mouse?

Edit: Inspired by Mathematica, a possible implementation is to create a "locator", then draw an arrow to it. The locator (instead of the arrow) is draggable; when it is moved, redraw the arrow to the locator.

Corresponding Mathematica Code (see the doc for Locator):

Manipulate[
 Graphics[Line[{{0, 0}, p}], PlotRange -> 2], {{p, {1, 1}}, Locator}]

Purpose of such an object: I want to create an interactive figure to illustrate how change of basis (in 2D) affects the components of a vector along the basis vectors (will be used in a math course). Therefore, I would like to implement the two basis vectors as arrows with draggable end (the starting point is fixed at the origin). They are changed by dragging the arrow end (head).

I am quite unfamiliar with event handling in Matplotlib. I tried reading the related docs but is still confused. Detailed explanation in the answer is welcomed.

  • [This](https://stackoverflow.com/questions/50439506/dragging-points-in-matplotlib-interactive-plot) and [this post](https://stackoverflow.com/questions/28001655/draggable-line-with-draggable-points) seem to tackle something similar. – JohanC Aug 24 '20 at 18:57
  • I think the first is more starter-friendly. The second one uses Qt, which only increases my confusion ... :( – Zhengyuan Yue Aug 24 '20 at 23:36

2 Answers2

1

Here is an example of two moveable vectors. As we only want to pick and move the end points, two invisible points are added only for the purpose of moving the end points.

This is just a simple example. The motion_notify_callback can be extended updating many more elements.

Hopefully the names of the functions help enough to figure out what is happening.

from matplotlib import pyplot as plt
from matplotlib.backend_bases import MouseButton
from matplotlib.ticker import MultipleLocator

fig, ax = plt.subplots(figsize=(10, 10))

picked_artist = None
vector_x2 = ax.annotate('', (0, 0), (2, 1), ha="left", va="center",
                        arrowprops=dict(arrowstyle='<|-', fc="k", ec="k", shrinkA=0, shrinkB=0))
vector_y2 = ax.annotate('', (0, 0), (-1, 2), ha="left", va="center",
                        arrowprops=dict(arrowstyle='<|-', fc="k", ec="k", shrinkA=0, shrinkB=0))
point_x2, = ax.plot(2, 1, '.', color='none', picker=True)
point_y2, = ax.plot(-1, 2, '.', color='none', picker=True)

def pick_callback(event):
    'called when an element is picked'
    global picked_artist
    if event.mouseevent.button == MouseButton.LEFT:
        picked_artist = event.artist

def button_release_callback(event):
    'called when a mouse button is released'
    global picked_artist
    if event.button == MouseButton.LEFT:
        picked_artist = None

def motion_notify_callback(event):
    'called when the mouse moves'
    global picked_artist
    if picked_artist is not None and event.inaxes is not None and event.button == MouseButton.LEFT:
        picked_artist.set_data([event.xdata], [event.ydata])
        if picked_artist == point_x2:
            vector_x2.set_position([event.xdata, event.ydata])
        elif picked_artist == point_y2:
            vector_y2.set_position([event.xdata, event.ydata])
        fig.canvas.draw_idle()

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.xaxis.set_major_locator(MultipleLocator(1))
ax.xaxis.set_minor_locator(MultipleLocator(0.5))
ax.yaxis.set_major_locator(MultipleLocator(1))
ax.yaxis.set_minor_locator(MultipleLocator(0.5))

ax.spines['left'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_position('zero')
ax.spines['top'].set_color('none')

ax.grid(True, which='major', linestyle='-', lw=1)
ax.grid(True, which='minor', linestyle=':', lw=0.5)

fig.canvas.mpl_connect('button_release_event', button_release_callback)
fig.canvas.mpl_connect('pick_event', pick_callback)
fig.canvas.mpl_connect('motion_notify_event', motion_notify_callback)

plt.show()

starting plot

JohanC
  • 71,591
  • 8
  • 33
  • 66
0

Based on the code of @JohanC, I now directly manipulate FancyArrow instead of ax.annotate. The update method is to remove the arrow and redraw it. More efficient update methods are welcome.

from matplotlib import pyplot as plt
from matplotlib.backend_bases import MouseButton
from matplotlib.ticker import MultipleLocator
from matplotlib.patches import FancyArrow
import numpy as np

fig, ax = plt.subplots(figsize=(8,8))
text_kw = dict(fontsize=14)
picked_artist = None

ax.set_xlim(-5, 5)
ax.set_ylim(-5, 5)
ax.set_aspect(1)
ax.xaxis.set_major_locator(MultipleLocator(1))
ax.xaxis.set_minor_locator(MultipleLocator(0.5))
ax.yaxis.set_major_locator(MultipleLocator(1))
ax.yaxis.set_minor_locator(MultipleLocator(0.5))

ax.spines['left'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_position('zero')
ax.spines['top'].set_color('none')
ax.tick_params(left=False, bottom=False, labelsize=14)

ax.grid(True, which='major', linestyle='-', lw=1)
ax.grid(True, which='minor', linestyle=':', lw=0.5)

# locator of vectors
init_v = np.array([3,2])
## the point indicator can be made invisible by setting `color='none'`
ptv, = ax.plot(*init_v, '+', color='blue', ms=10, picker=20)
# draw vector
arrow_kw = {'color': 'blue', 'head_width': 0.2, 'head_length': 0.3, 'width': 0.03, 'length_includes_head': True}
v = FancyArrow(0, 0, *init_v, **arrow_kw)
ax.add_artist(v)

def on_pick(event):
    'called when an element is picked'
    global picked_artist
    if event.mouseevent.button == MouseButton.LEFT:
        picked_artist = event.artist

def on_button_release(event):
    'called when a mouse button is released'
    global picked_artist
    if event.button == MouseButton.LEFT:
        picked_artist = None

def on_motion_notify(event):
    'called when the mouse moves'
    global picked_artist
    global init_v, v
    if picked_artist is not None and event.inaxes is not None and event.button == MouseButton.LEFT:
        picked_artist.set_data([event.xdata], [event.ydata])
        if picked_artist == ptv:
            # redraw arrow v
            init_v = np.array([event.xdata, event.ydata])
            v.remove()
            v = FancyArrow(0, 0, *init_v, **arrow_kw)
            ax.add_artist(v)
        fig.canvas.draw_idle()

fig.canvas.mpl_connect('button_release_event', on_button_release)
fig.canvas.mpl_connect('pick_event', on_pick)
fig.canvas.mpl_connect('motion_notify_event', on_motion_notify)

plt.show()