Search code examples
pythonmatplotlibplotlegend

Extend best positioning also outside the plot


I am trying to plot several data which, in some cases, occupies the entire plot.

The default option, from version 2, should be 'best', which tries to find the best position to place the legend inside the plot. Is there a way to extend the option to be able to place the legend outside the plot if the space is insufficient?

Otherwise, is there an option for matplotlib (without taking the max of all the series and add a manual padding) to automatically add an ylim padding and give space to the legend and be placed inside the plot?

The main idea is to avoid manual tweaking of the plots, having several plots to be created automatically.

A simple MWE is in the following:

%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import scipy as sc
import matplotlib.pyplot as plt
plt.close('all')

x = sc.linspace(0, 1, 50)
y = sc.array([sc.ones(50)*0.5, x, x**2, (1-x), (1-x**2)]).T
fig = plt.figure('Fig')
ax = fig.add_subplot(111)
lines = ax.plot(x, y)
leg = ax.legend([lines[0], lines[1], lines[2], lines[3], lines[4]],
                [r'$\mathrm{line} = 0.5$', r'$\mathrm{line} = x$', r'$\mathrm{line} = x^2$',
                 r'$\mathrm{line} = 1-x$',r'$\mathrm{line} = 1-x^2$'], ncol=2)
fig.tight_layout()

Plot


Solution

  • There is no automatic way to place the legend at "the best" position outside the axes.

    inside the plot

    You may decide to always leave enough space inside the axes, such that the legend doesn't overlap with anything. To this end you can use ax.margins. e.g.

    ax.margins(y=0.25)
    

    will produce 25% margin on both ends of the y axis, enough space to host the legend if it has 3 columns.

    enter image description here

    You may then decide to always use the same location, e.g. loc="upper center" for a consistent result among all plots. The drawback of this is that it depends on figure size and that it adds a (potentially undesired) margin at the other end of the axis as well. If you can live with that margin, a way to automatically determine the needed margin would be the following:

    import numpy as np
    import matplotlib.pyplot as plt
    import matplotlib.transforms
    
    x = np.linspace(0, 1, 50)
    y = np.array([np.ones(50)*0.5, x, x**2, (1-x), (1-x**2)]).T
    fig = plt.figure('Fig')
    ax = fig.add_subplot(111)
    lines = ax.plot(x, y)
    
    def legend_adjust(legend, ax=None ):
        if ax == None: ax  =plt.gca()
        ax.figure.canvas.draw()
        bbox = legend.get_window_extent().transformed(ax.transAxes.inverted() )
        print bbox.height
        ax.margins(y = 2.*bbox.height)
        
    leg = plt.legend(handles=[lines[0], lines[1], lines[2], lines[3], lines[4]],
           labels= [r'$\mathrm{line} = 0.5$', r'$\mathrm{line} = x$', r'$\mathrm{line} = x^2$',
                     r'$\mathrm{line} = 1-x$',r'$\mathrm{line} = 1-x^2$'], loc="upper center", 
                      ncol=2)
    legend_adjust(leg)
    plt.show()
    

    If setting the limits is fine with you, you may also adapt the limits themselves:

    import numpy as np
    import matplotlib.pyplot as plt
    import matplotlib.transforms
    
    x = np.linspace(0, 1, 50)
    y = np.array([np.ones(50)*0.5, x, x**2, (1-x), (1-x**2)]).T
    fig = plt.figure('Fig')
    ax = fig.add_subplot(111)
    lines = ax.plot(x, y)
    
    def legend_adjust(legend, ax=None, pad=0.05 ):
        if ax == None: ax  =plt.gca()
        ax.figure.canvas.draw()
        bbox = legend.get_window_extent().transformed(ax.transAxes.inverted() )
        ymin, ymax = ax.get_ylim()
        ax.set_ylim(ymin, ymax+(ymax-ymin)*(1.+pad-bbox.y0))
        
    
        
    leg = plt.legend(handles=[lines[0], lines[1], lines[2], lines[3], lines[4]],
           labels= [r'$\mathrm{line} = 0.5$', r'$\mathrm{line} = x$', r'$\mathrm{line} = x^2$',
                     r'$\mathrm{line} = 1-x$',r'$\mathrm{line} = 1-x^2$'], loc="upper center", 
                      ncol=2)
    legend_adjust(leg)
    plt.show()
    

    enter image description here

    out of the plot

    Otherwise you may decide to always put the legend out of the plot. Some techniques are collected in this answer.

    Of special interest may be to place the legend outside the figure without changing the figuresize, as detailed in this question: Creating figure with exact size and no padding (and legend outside the axes)

    Adapting it to this case would look like:

    import numpy as np
    import matplotlib.pyplot as plt
    import matplotlib.transforms
    
    x = np.linspace(0, 1, 50)
    y = np.array([np.ones(50)*0.5, x, x**2, (1-x), (1-x**2)]).T
    fig = plt.figure('Fig')
    ax = fig.add_subplot(111)
    lines = ax.plot(x, y)
    
    def legend(ax=None, x0=1,y0=1, direction = "v", padpoints = 3,**kwargs):
        if ax == None: ax  =plt.gca()
        otrans = ax.figure.transFigure
        t = ax.legend(bbox_to_anchor=(x0,y0), loc=1, bbox_transform=otrans,**kwargs)
        plt.tight_layout()
        ax.figure.canvas.draw()
        plt.tight_layout()
        ppar = [0,-padpoints/72.] if direction == "v" else [-padpoints/72.,0] 
        trans2=matplotlib.transforms.ScaledTranslation(ppar[0],ppar[1],fig.dpi_scale_trans)+\
                 ax.figure.transFigure.inverted() 
        tbox = t.get_window_extent().transformed(trans2 )
        bbox = ax.get_position()
        if direction=="v":
            ax.set_position([bbox.x0, bbox.y0,bbox.width, tbox.y0-bbox.y0]) 
        else:
            ax.set_position([bbox.x0, bbox.y0,tbox.x0-bbox.x0, bbox.height]) 
    
    legend(handles=[lines[0], lines[1], lines[2], lines[3], lines[4]],
           labels= [r'$\mathrm{line} = 0.5$', r'$\mathrm{line} = x$', r'$\mathrm{line} = x^2$',
                     r'$\mathrm{line} = 1-x$',r'$\mathrm{line} = 1-x^2$'], 
                     y0=0.8, direction="h", borderaxespad=0.2)
    
    plt.show()
    

    enter image description here