How do I make multiple span selectors work on the same axis?

As the title suggests, I am trying to put multiple SpanSelector widgets on the same axis. In the test code below (adapted from this example), I am using a multi-plot figure with the qt backend, to zoom in on the two regions selected by the span selectors. I want to make sure that the two widgets can function independently. I have set non-overlapping initial spans for them both when the figure is generated. I also need to make sure that ignore_event_outside is set to True for both span selectors, so that trying to move/resize one does not re-draw the other span selector at that location. I thought this is would be enough to make sure the two widgets function independently and there is no ambiguity in which object should respond to a given event.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import SpanSelector

# Fixing random state for reproducibility
np.random.seed(19680801)

mosaic = """AA
            BC"""

fig = plt.figure(figsize=(8, 6))
ax_dict = fig.subplot_mosaic(mosaic)

(ax0,ax1,ax2) = list(ax_dict.values())

x = np.arange(0.0, 5.0, 0.01)
y = np.sin(2 * np.pi * x) + 0.5 * np.random.randn(len(x))

ax0.plot(x, y, color='black')
ax0.set_ylim(-2, 2)
ax0.set_title('Press left mouse button and drag '
              'to select a region in the top graph')

line1, = ax1.plot([], [], color='dodgerblue')
line2, = ax2.plot([], [], color='coral')

def onselect1(xmin, xmax):
    indmin, indmax = np.searchsorted(x, (xmin, xmax))
    indmax = min(len(x) - 1, indmax)

    region_x = x[indmin:indmax]
    region_y = y[indmin:indmax]

    if len(region_x) >= 2:
        line1.set_data(region_x, region_y)
        ax1.set_xlim(region_x[0], region_x[-1])
        ax1.set_ylim(region_y.min(), region_y.max())
        fig.canvas.draw_idle()

def onselect2(xmin, xmax):
    indmin, indmax = np.searchsorted(x, (xmin, xmax))
    indmax = min(len(x) - 1, indmax)

    region_x = x[indmin:indmax]
    region_y = y[indmin:indmax]

    if len(region_x) >= 2:
        line2.set_data(region_x, region_y)
        ax2.set_xlim(region_x[0], region_x[-1])
        ax2.set_ylim(region_y.min(), region_y.max())
        fig.canvas.draw_idle()

span1 = SpanSelector(
    ax0,
    onselect1,
    "horizontal",
    useblit=True,
    props=dict(alpha=0.5, facecolor="dodgerblue"),
    interactive=True,
    drag_from_anywhere=True,
    ignore_event_outside=True
)

span2 = SpanSelector(
    ax0,
    onselect2,
    "horizontal",
    useblit=True,
    props=dict(alpha=0.5, facecolor="coral"),
    interactive=True,
    drag_from_anywhere=True,
    ignore_event_outside=True
)
# Set useblit=True on most backends for enhanced performance.

# Set default values
xmin1, xmax1 = 1.4, 2.3
span1.extents = (xmin1, xmax1)
span1.onselect(xmin1, xmax1)

xmin2, xmax2 = 2.5, 3.1
span2.extents = (xmin2, xmax2)
span2.onselect(xmin2, xmax2)

plt.show()

However, whenever I try to interact with the two widgets, things don’t go as planned. As seen in the GIF below, when I first click on one of the span selectors (red in this example) after the figure is generated, and move/resize it, the other widget (blue in this example) responds to it, even though ignore_event_outside is set to True. Subsequent interactions seem to work almost fine but still look strange. In this example, moving/resizing the blue span selector makes the red span selector “blink” off until the mouse button is released. Please note that the opposite does not happen. Also note that the cursor does not change (to :left_right_arrow:) when I hover over the handles for the blue span selector - I have click on the handle before the cursor shape changes. Again, the red span selector is not affected in the same way.

2022-11-04 13-34-38

I know I can try to assign different mouse buttons to the two span selectors in this case, but I eventually plan to generate 3-4 span selectors on the same figure and I would simply run out of mouse buttons.

Additional info:
OS: Windows 10
Python version: 3.9.13
Matplotlib version: 3.5.3
Jupyter Notebook server version: 6.4.12
IPython version: 8.4.0

N.B.- Link to Stackoverflow post for this issue.

I tried this out and wow that is indeed pretty broken. I think craziness of the first interaction is due to this line (in _on_move) not triggering due to _selection_complete being initialized as False: matplotlib/widgets.py at b9c5152fa497abb30dfe68c6a386dbd2515b1ce6 · matplotlib/matplotlib · GitHub

So you can work around the first half of your issue with the following:

span1._selection_complete = True
span2._selection_complete = True

though long term this should be fixed in matplotlib.

There’s a race condition between the two spanselector’s set_cursor methods: matplotlib/widgets.py at b9c5152fa497abb30dfe68c6a386dbd2515b1ce6 · matplotlib/matplotlib · GitHub

so whichever one was made second will always be in control of the cursor.

So you can work around the first half of your issue with the following:

span1._selection_complete = True
span2._selection_complete = True

Thanks for identifying the possible source of the issue! I tried adding the two lines at various places in my code but it doesn’t seem to solve the first problem. I added the lines at the very end (before plt.show()), right before setting the extents, and (separately) right after defining each span selector. Unfortunately, none of them seems to do the trick. :frowning:

I should add that I am working on Jupyter Notebook.

In jupyterlab (with ipympl backend) the following fixes the initial craziness for me:

%matplotlib ipympl
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import SpanSelector

# Fixing random state for reproducibility
np.random.seed(19680801)

mosaic = """AA
            BC"""

fig = plt.figure(figsize=(8, 6))
ax_dict = fig.subplot_mosaic(mosaic)

(ax0,ax1,ax2) = list(ax_dict.values())

x = np.arange(0.0, 5.0, 0.01)
y = np.sin(2 * np.pi * x) + 0.5 * np.random.randn(len(x))

ax0.plot(x, y, color='black')
ax0.set_ylim(-2, 2)
ax0.set_title('Press left mouse button and drag '
              'to select a region in the top graph')

line1, = ax1.plot([], [], color='dodgerblue')
line2, = ax2.plot([], [], color='coral')

def onselect1(xmin, xmax):
    indmin, indmax = np.searchsorted(x, (xmin, xmax))
    indmax = min(len(x) - 1, indmax)

    region_x = x[indmin:indmax]
    region_y = y[indmin:indmax]

    if len(region_x) >= 2:
        line1.set_data(region_x, region_y)
        ax1.set_xlim(region_x[0], region_x[-1])
        ax1.set_ylim(region_y.min(), region_y.max())
        fig.canvas.draw_idle()

def onselect2(xmin, xmax):
    indmin, indmax = np.searchsorted(x, (xmin, xmax))
    indmax = min(len(x) - 1, indmax)

    region_x = x[indmin:indmax]
    region_y = y[indmin:indmax]

    if len(region_x) >= 2:
        line2.set_data(region_x, region_y)
        ax2.set_xlim(region_x[0], region_x[-1])
        ax2.set_ylim(region_y.min(), region_y.max())
        fig.canvas.draw_idle()

span1 = SpanSelector(
    ax0,
    onselect1,
    "horizontal",
    useblit=True,
    props=dict(alpha=0.5, facecolor="dodgerblue"),
    interactive=True,
    drag_from_anywhere=True,
    ignore_event_outside=True,
    grab_range=1,
)

span2 = SpanSelector(
    ax0,
    onselect2,
    "horizontal",
    useblit=True,
    props=dict(alpha=0.5, facecolor="coral"),
    interactive=True,
    drag_from_anywhere=True,
    ignore_event_outside=True,
    grab_range=1,
)
span1._selection_completed = True
span2._selection_completed = True
# Set useblit=True on most backends for enhanced performance.

# Set default values
xmin1, xmax1 = 1.4, 2.3
span1.extents = (xmin1, xmax1)
span1.onselect(xmin1, xmax1)

xmin2, xmax2 = 2.5, 3.1
span2.extents = (xmin2, xmax2)
span2.onselect(xmin2, xmax2)

plt.show()

Peek 2022-11-04 14-53

1 Like

Ok, that finally solved it! I must have missed something in my previous attempt. Tested on qt and notebook backends on Jupyter Notebook and this fix works! I can live with the other issues.

Thank you!

@ianhi Is there something we should fix in Matplotlib here?

@tacaswell probably - If multiple span selectors are allowed then they need to not compete like this, and if the decision is instead to not have this be allowed then an error should be raised.

1 Like