How to get an interactive custom legend, stop lines being interactive beyond axis boundaries

Hi, I am trying to create interactive plots via matplotlib and have run into some issues I cannot find a solution for. Maybe somebody can point me into the right direction?

The aim is to toggle visibility for a group of lines using entries in a custom legend (based on matplotlib.org/tutorials/intermediate/legend_guide.html )

The problem I run into is two-fold:

  1. that the patches in the legend do not selectively catch any mouse-events. So somehow the interactivity that is built-in with any Line2D created from the data set (like in https://matplotlib.org/examples/event_handling/legend_picking.html ) is not achieved with the legend patches. (The legend itself can be turned into a button that toggles line visibility, but the same logic does not seem to apply for the individual Patches added to a custom legend). Am I missing something, in the sense that I need to build a separate class for these so that these become responsive?

  2. Then, also related to mouse-events, but now the opposite: how not to catch them: When a subsection of the data is drawn (say by setting xlim and ylim to 80%) the lines continue beyond the panel boundaries and they are still interactive. This creates a confusing using-interface and ideally you would like to prevent this.
    In this respect, too: the axes themselves still catch mouse-events that are picked up by the lines; thus the same mouse-event is registered by various Artists. How can that be controlled (i.e. that the mouse-click intended for the line is not picked up by the underlying axes)?

I tried using ‘clip’ and z-order settings to see whether that would help but it did not. Below an example showing the above issues.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
#  beyond_axes.py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Patch
from matplotlib.lines import Line2D
from matplotlib.axes import Axes
from matplotlib.transforms import Bbox
import matplotlib as mpl
from matplotlib.legend import Legend
#
def plot_set():
    """plot a figure"""
    ## prepare data for plotting
    lim=100 #20*5
    df=pd.DataFrame({
                    'a0':np.arange(lim)*3, 
                    'a1':np.arange(lim)*0.8, 
                    'a2':np.arange(lim)*2,  
                    'c0':np.arange(lim)*0.5,
                    'b3':np.arange(lim)*6.6, 
                    'd1':np.arange(lim)*1.8 ,                  
                    'b1':np.arange(lim)*0.2,
                    'c1':np.arange(lim)*1.2,   
                    'b2':np.arange(lim)*2.5,
                    'd2':np.arange(lim)*4 
                    })
    ## prepare for plotting
    lowalph = 0.3 # built up the plot so intensity is measure for overlap
    highalph = 0.9 # on click show whole plot
    lowlw = 1.5
    highlw = 4
    print(df.columns)
    # set up figure
    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10, 5))   
    # try to keep interactivity within the panel (ax)
    print(ax.bbox)
    ax.set_clip_box(Bbox.from_bounds(0, 0, 10.0, 5.0)) # ax.axis())# gives 0,1,0,1
    ax.set_clip_on(True) #does not make a difference
    print(ax.get_clip_on())
    print(ax.get_clip_box())
    ax.set_picker(True) # comment this out and lines toggle inside the axes as well, not only when clicked outside
    # plot various groups
    def plotdf(df=None, xlim_=[0,80], ylim_=[0,80]):        
        df[['a0','a1','a2']].plot(ax=ax, legend=False, label='A', gid='A', color='b', alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=5)
        df[['b1','b2','b3']].plot(ax=ax, legend=False, label='B', gid='B', color='orange', alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=5)
        df[['c0','c1','d1','d2']].plot(ax=ax, legend=False, label='CD', gid='CD',color='g', alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=5)
    #make custom legend
    a_patch  = Patch(color='blue',   zorder=6, alpha=lowalph, picker=True, label='A' ) # get it on top of; legend has z-order 5; 
    b_patch  = Patch(color='orange', zorder=6, alpha=lowalph, picker=True, label='B' ) # but z-order change does not help to catch
    cd_patch = Patch(color='green',  zorder=6, alpha=lowalph, picker=True, label='CD') # clicks (being part of legend takes over?)
    leg=fig.legend(handles = [a_patch,b_patch,cd_patch], fontsize=('small'), ncol=1 ,loc='upper right', bbox_to_anchor=(0.8, 0.4))
    leg.set_draggable(True)
    plotdf(df) 
    a_patch.set_zorder(7) # also no effect
    print("Legend z-order:",leg.get_zorder())
    print("A-patch z-order:",a_patch.get_zorder())
    print("B-patch z-order:",b_patch.get_zorder())
    # set up interactivity
    # matplotlib.org/3.2.2/users/event_handling.html
    def onpick(pevent): #PickEvent 
        if isinstance(pevent.artist, Patch):  # not picked up; legend takes all clicks
            print("Patch") 
        elif isinstance(pevent.artist, Legend):
            print("Legend!")
            for line in ax.get_lines():
                if line.get_gid() =='B':
                    print("Toggle:", line.get_gid())
                    line.set_visible(False) if line.get_visible()==True else line.set_visible(True)        
        elif isinstance(pevent.artist, Line2D): # also picked outwith axes area
            thisline = pevent.artist 
            lab = mpl.artist.getp(thisline,"label")
            print("Line width toggled for",lab)
            lineon = highlw if thisline.get_lw() == lowlw else lowlw
            linealph = highalph if thisline.get_alpha() == lowalph else lowalph
            thisline.set_lw(lineon)
            thisline.set_alpha(linealph)
        elif isinstance(pevent.artist,Axes): # to turn off line if clicked besides it 
            print('Axes')
            for line in ax.get_lines():
                if line.get_alpha() == highalph:
                    line.set_alpha(lowalph) 
                if line.get_lw() == highlw:
                    line.set_lw(lowlw) 
        fig.canvas.draw_idle()
    #show the figure        
    fig.canvas.mpl_connect('pick_event', onpick) 
    plt.show()    

def main(args):
    plot_set()
    return 0

if __name__ == '__main__':
    import sys
    sys.exit(main(sys.argv))
1 Like

For the first question, the issue is that the Artists actually drawn in the legend are not the objects you passed in, but artists the legend machinery derived from those artists. Once you have the artist you need to access the leg.legendHandles attribute and set up the picker on them. See https://stackoverflow.com/questions/19470104/python-matplotlib-errorbar-legend-picking for more details.

For the second question, I can reproduce that with

fig, ax = plt.subplots()
ln, = ax.plot([1, 1, 1, 1, 1])
ln.set_picker(True)
fig.canvas.mpl_connect('pick_event', lambda *args, **kwargs: print(args, kwargs))
ax.set_xlim([2, 3])
plt.show()

which is definitely a bug!

Thanks so much for the pointer! A HandlerPatch linking the bits was sufficient.

#import matplotlib.legend_handler
from matplotlib.legend_handler import HandlerPatch
..
handlermap = {
        a_patch: HandlerPatch(),
        b_patch: HandlerPatch(),
        cd_patch:HandlerPatch(),
    }
        
    leg = fig.legend(handler_map=handlermap, handles=patchlist, fontsize=('small'), ncol=1 ,loc='upper right', bbox_to_anchor=(0.8, 0.4))
..

The lines were found for toggling using their ‘gid’. This is awesome :wink:
Also the ‘bug’ can be ‘cured’: make sure that the mouse event used for picking is inside the frame:

def onpick(pevent): #PickEvent 
        if pevent.mouseevent.inaxes != ax:
            return
   ..

That underlying Artists will also pick the mousevent (see the print messages:
whenever you pick a legend patch you also pick the legend (or a line if that happens to be below the legend) is something I have to get used to; it can be quite helpful, say when mutually overlapping lines need to be all picked in one go and is for that reason possibly expected behaviour.

Thanks again for Matplotlib; it’s great so much is possible from within one framework.

below the whole thing together

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
#  beyond_axes.py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from matplotlib.lines import Line2D
import matplotlib as mpl
from matplotlib.legend import Legend
#import matplotlib.legend_handler
from matplotlib.legend_handler import HandlerPatch
#
def plot_set():
    """plot a figure"""
    ## prepare data for plotting
    lim=100 #20*5
    df=pd.DataFrame({
                    'a0':np.arange(lim)*3, 
                    'a1':np.arange(lim)*0.8, 
                    'a2':np.arange(lim)*2,  
                    'c0':np.arange(lim)*0.5,
                    'b3':np.arange(lim)*6.6, 
                    'd1':np.arange(lim)*1.8 ,                  
                    'b1':np.arange(lim)*0.2,
                    'c1':np.arange(lim)*1.2,   
                    'b2':np.arange(lim)*2.5,
                    'd2':np.arange(lim)*4 
                    })
    ## prepare for plotting
    lowleg   = 0.2 
    highleg  = 0.5
    lowalph  = 0.5  # when picked
    highalph = 0.8
    lowlw    = 1.5
    highlw   = 4    # when picked
    print(df.columns)
    # set up figure
    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10, 5))   

    # plot various groups
    alist  = ['a0','a1','a2']
    blist  = ['b1','b2','b3']
    cdlist = ['c0','c1','d1','d2']
    cola, colb, colcd = 'b', 'orange', 'g'
    
    def plotdf(df=None, xlim_=[0,80], ylim_=[0,80]):        
        df[alist].plot( ax=ax, legend=False, label='A',  gid='A', color=cola,  alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=True)
        df[blist].plot( ax=ax, legend=False, label='B',  gid='B', color=colb,  alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=True)
        df[cdlist].plot(ax=ax, legend=False, label='CD', gid='CD',color=colcd, alpha=lowalph,  xlim=xlim_, ylim=ylim_, lw=lowlw,  picker=True)

    #make custom legend
    a_patch  = Patch(color='blue',   label='A' )
    b_patch  = Patch(color='orange', label='B' )
    cd_patch = Patch(color='green',  label='CD')
    patchlist = [a_patch,b_patch,cd_patch]
    
    handlermap = {
        a_patch: HandlerPatch(),
        b_patch: HandlerPatch(),
        cd_patch:HandlerPatch(),
    }
        
    leg = fig.legend(handler_map=handlermap, handles=patchlist, fontsize=('small'), ncol=1 ,loc='upper right', bbox_to_anchor=(0.8, 0.4))
 
    for legpatch in leg.legendHandles:
        legpatch.set_picker(True)
        legpatch.set_alpha(highleg) 
    
    leg.set_draggable(True)
    plotdf(df) 
    
    # set up interactivity
    def toggle_patch(patch):    
        pgid = patch.get_label()
        palph = patch.get_alpha()
        alphon = lowleg if palph == highleg else highleg
        patch.set_alpha(alphon)
        for line in ax.get_lines():
            if line.get_gid() == pgid:
                print("Toggle:", line.get_gid())
                vis = not line.get_visible()
                line.set_visible(vis)
    
    # matplotlib.org/3.2.2/users/event_handling.html
    def onpick(pevent): #PickEvent 
        if pevent.mouseevent.inaxes != ax:  # make sure mouse click happened inside panel
            return 
        if isinstance(pevent.artist, Patch):
            print("Patch")
            legpatch = pevent.artist
            toggle_patch(legpatch)
        elif isinstance(pevent.artist, Legend):
            print("Legend!")
        elif isinstance(pevent.artist, Line2D):
            thisline = pevent.artist 
            if not thisline.get_visible():
                return
            lab = mpl.artist.getp(thisline,"label")
            print("Line width toggled for",lab)
            lineon = highlw if thisline.get_lw() == lowlw else lowlw
            linealph = highalph if thisline.get_alpha() == lowalph else lowalph
            thisline.set_lw(lineon)
            thisline.set_alpha(linealph)
        
        fig.canvas.draw_idle()
    #show the figure        
    fig.canvas.mpl_connect('pick_event', onpick) 
    plt.show()
    

def main(args):
    plot_set()
    return 0

if __name__ == '__main__':
    import sys
    sys.exit(main(sys.argv))
1 Like