Matplotlib Legends: Artists & Handlers#

Hey all, got some matplotlib for you this week. I wanted to start touching on some more advanced ideas about it and decided to demonstrate a question I answered on Stack Overflow not long ago.

The question asked about custom legend artists- essentially asking “How can I change the style of the artists matplotlib presents in a given legend.” While the longest way to do this is to construct a Legend manually, thankfully matplotlib provides an escape hatch in the form of the handler_map argument.

What is a Legend Handler?#

A legend handler is an object that handles the drawing of a proxy Artist (an Artist that is similar to the one drawn on the Axes) onto a Legend. To do this, it simply applies some transformations and performs drawing on a Legend object instead of on an Axes.

Before I go too far into a Legend Handler, we should visit the handler_map argument of a Legend. The handler_map is a dictionary that provides mappings of Artists → Handlers, meaning when a Legend encounters a specific Artist (Line2D (commonly lines on a line plot), Patch (commonly bars from a barplot), PathCollection (commonly scatter plot markers)), then it will use the corresponding Handler to draw that Artist on the Legend.

matplotlib has default pairings, which is why the Legend works quite well without needing to supply the handle_map. You can also view these by calling the classmethod Legend.get_default_handler_map().

from pprint import pprint
from matplotlib.legend import Legend

pprint(
    Legend.get_default_handler_map()
)
{<class 'tuple'>: <matplotlib.legend_handler.HandlerTuple object at 0x7f22fabbef50>,
 <class 'matplotlib.collections.PathCollection'>: <matplotlib.legend_handler.HandlerPathCollection object at 0x7f22fabbefb0>,
 <class 'matplotlib.lines.Line2D'>: <matplotlib.legend_handler.HandlerLine2D object at 0x7f22fabbed10>,
 <class 'matplotlib.patches.Patch'>: <matplotlib.legend_handler.HandlerPatch object at 0x7f22fabbed70>,
 <class 'matplotlib.patches.StepPatch'>: <matplotlib.legend_handler.HandlerStepPatch object at 0x7f22fabbedd0>,
 <class 'matplotlib.collections.PolyCollection'>: <matplotlib.legend_handler.HandlerPolyCollection object at 0x7f22fabbefe0>,
 <class 'matplotlib.collections.RegularPolyCollection'>: <matplotlib.legend_handler.HandlerRegularPolyCollection object at 0x7f22fabbee90>,
 <class 'matplotlib.collections.LineCollection'>: <matplotlib.legend_handler.HandlerLineCollection object at 0x7f22fabbee30>,
 <class 'matplotlib.collections.CircleCollection'>: <matplotlib.legend_handler.HandlerCircleCollection object at 0x7f22fabbeec0>,
 <class 'matplotlib.container.BarContainer'>: <matplotlib.legend_handler.HandlerPatch object at 0x7f22fabbeef0>,
 <class 'matplotlib.container.ErrorbarContainer'>: <matplotlib.legend_handler.HandlerErrorbar object at 0x7f22fabbecb0>,
 <class 'matplotlib.container.StemContainer'>: <matplotlib.legend_handler.HandlerStem object at 0x7f22fabbe200>}

When to use a custom Handler?#

There are various times you’ll want to use your own Handler. However usecases will typically fall into one of two categories:

  1. The default handler matplotlib provides doesn’t look how I want it to, and the exposed .legend arguments don’t provide enough control to fix it.

  2. matplotlib does not have an entry for the type of Artist I want in my legend.

In regards to the former, I should also mention that matplotlib provides various subclasses of HandlerBase objects to provide some explicit control over the drawing of legend handles. However, I don’t find these nearly as useful as a fully customized Handler. It’s a little more boilerplate, but you gain full control over how each Artist is drawn on your legend.

In the question I answered on Stack Overflow, the question focused on the former of the two cases I laid out above. An individual was drawing Wedges on their plot, and wanted the Legend to also have a Wedge in the same shape as the original Artist.

Unfortunately the Wedge inherits from matplotlib.patches.Patch which is mapped to a generic Patch (rectangle) Handler

from matplotlib.patches import Wedge

handler_map = Legend.get_default_handler_map()
artist = Wedge((0, 0), 0, 0, 0)
print(
    Legend.get_legend_handler(handler_map, artist)
)
<matplotlib.legend_handler.HandlerPatch object at 0x7f22fabbed70>

The Default HandlerPatch#

As I mentioned before, the HandlerPatch only draws rectangles. So if we draw a Wedge onto an Axes and attempt to create a legend, we should expect to see the proxy Artist to be a simple rectangle with the same aesthetic features as the Wedge

from matplotlib.pyplot import subplots, show
from matplotlib.patches import Wedge
from matplotlib import rc

rc('font', size=18)    

colors = ['#e41a1c','#377eb8','#4daf4a','#984ea3']
theta2 = [90, 180, 270, 360]
r = .25
fig, ax = subplots(figsize=(4, 4))
# Axes has no ax.wedge method
artist = Wedge((.5, .5), r=.25, theta1=0, theta2=270, label='My Wedge')
ax.add_artist(artist)
ax.legend()
ax.tick_params(
    which='both', labelbottom=False, labelleft=False,
    bottom=False, left=False
)

show()
../_images/61b2bbbe9718e082d7cfe43b385823a7bcb4533cc2d43acc25bdc7a124fcca48.png

So how do we create an entry in our legend that looks just like the artist drawn on our Axes? Well, we create a Handler class that has a legend_artist method, and do the drawing there! I’ll avoid boring you with too many details, as they can already be found in the matplotlib documentation. Let’s take a look at how to implement this on our own!

A Custom WedgeHandler#

There’s not much else to say as far as implementing the handler. I commented the code to help readers parse through it.

from matplotlib.pyplot import subplots, show
from matplotlib.patches import Wedge
from matplotlib.cm import get_cmap
from matplotlib import rc

class WedgeHandler:
    def legend_artist(self, legend, orig_handle, fontsize, handlebox):
        x0, y0 = handlebox.xdescent, handlebox.ydescent
        width, height = handlebox.width, handlebox.height
        r = min(width, height)
        
        handle = Wedge(
            center=(x0 + width / 2, y0 + height / 2),      # centers handle in handlebox
            r=r,                                           # ensures radius fits in handlebox
            width=r * (orig_handle.width / orig_handle.r), # preserves original radius/width ratio
            theta1=orig_handle.theta1,                     # copies the following parameters
            theta2=orig_handle.theta2,
            color=orig_handle.get_facecolor(),
            transform=handlebox.get_transform(),           # use handlebox coordinate system
        )
        
        # Add this new Artist to the handlebox
        #   the handlebox is the drawing area for a given
        #   entry in the legend
        handlebox.add_artist(handle)
        return handle

rc('font', size=18)    

# need to manually supply colors as add_artist 
#    does not advance the Axes.prop_cycle
theta2 = [90, 180, 270, 360]
colors = get_cmap('tab10').colors
r = .25

fig, ax = subplots(figsize=(8, 8))

for i, (color, t2) in enumerate(zip(colors, theta2)):
    y, x = divmod(i, 2)
    wedge = Wedge(
        (x, y), r=r, theta1=0, theta2=t2,
        width=0.25, color=color, label=f'category{i+1}'
    )
    ax.add_artist(wedge)
    
    # Wedge Artists do NOT automatically update data limits
    #   use this in combination with `Axes.autoscale_view`
    #   so that we don't need to manually set x/y limits
    ax.update_datalim([
        (x + r, y + r), (x - r, y - r), 
        (x - r, y + r), (x + r, y - r),
    ])
    

legend = ax.legend(
    title='Default Handler', loc='upper left',
    bbox_to_anchor=(1.01, 1),
)
# Preserve original legend on plot, or else next call to
#    ax.legend will replace the previous one
ax.add_artist(legend)

ax.legend(
    title='Custom Handler', loc='upper left',
    bbox_to_anchor=(1.01, 0.5), handler_map={Wedge: WedgeHandler()},
    labelspacing=1
)
ax.set_title('Custom Legend Handles', size='large')
ax.autoscale_view()
ax.tick_params(
    which='both', labelbottom=False, labelleft=False,
    bottom=False, left=False
)
ax.invert_yaxis()

show()
/tmp/ipykernel_909365/495570177.py:33: MatplotlibDeprecationWarning: The get_cmap function was deprecated in Matplotlib 3.7 and will be removed two minor releases later. Use ``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap(obj)`` instead.
  colors = get_cmap('tab10').colors
../_images/7d57f0d6430c373d0cdc4787cebeed2d5f86b9619163b29df3b1508813f824e6.png

Wrap Up#

And there you have it! A brief tutorial on how to take even more control over your matplotlib plots. I hope you enjoyed this article and use it to make it pretty in matplotlib. Talk to you all next week!