From 2b6927de47dd61d2e75f4c99132a2e4f30aa7748 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Sun, 22 Oct 2017 21:35:58 -0700 Subject: [PATCH] Allow kwarg handles and labels figure.legend Allow kwarg handles and labels figure.legend Small refactor of check for labels so it is inside legend.Legend attempt to refactor legend; not passing tests small typo Moved kwarg documentation out of child functions and into legend. Fixed documentation passthroughs so they work properly Fixed documentation passthroughs so they work properly Remerge master, small changes Fixed tests Remove repeated labels if same linestyle Remove repeated labels if same linestyle Removed ability of third positional argument for axes.legend Deprecate third psotionsal argument for fig.legend Allow parasite axes to call legend(): parasite_simple.py Fixed doc Fixed doc Added twinx test for legend merge fix merge fix Fixed legend handling of handles-only input; added error message Fixed legend handling of handles-only input for py27 Fixed small doc change MNT: do not special-case loc as positional arg Fixed small doc change Fix PEP8 and test error Fix PEP8 and test error --- lib/matplotlib/axes/_axes.py | 95 +--- lib/matplotlib/figure.py | 231 ++++---- lib/matplotlib/legend.py | 552 +++++++++++++++++-- lib/matplotlib/tests/test_legend.py | 121 +++- lib/mpl_toolkits/axes_grid1/parasite_axes.py | 2 +- 5 files changed, 753 insertions(+), 248 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index e32b734a455d..5f005eb56406 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -252,27 +252,6 @@ def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): self.yaxis.labelpad = labelpad return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) - def _get_legend_handles(self, legend_handler_map=None): - """ - Return a generator of artists that can be used as handles in - a legend. - - """ - handles_original = (self.lines + self.patches + - self.collections + self.containers) - handler_map = mlegend.Legend.get_default_handler_map() - - if legend_handler_map is not None: - handler_map = handler_map.copy() - handler_map.update(legend_handler_map) - - has_handler = mlegend.Legend.get_legend_handler - - for handle in handles_original: - label = handle.get_label() - if label != '_nolegend_' and has_handler(handler_map, handle): - yield handle - def get_legend_handles_labels(self, legend_handler_map=None): """ Return handles and labels for legend @@ -283,16 +262,13 @@ def get_legend_handles_labels(self, legend_handler_map=None): ax.legend(h, l) """ - handles = [] - labels = [] - for handle in self._get_legend_handles(legend_handler_map): - label = handle.get_label() - if label and not label.startswith('_'): - handles.append(handle) - labels.append(label) + # pass through to legend. + handles, labels = mlegend._get_legend_handles_labels([self], + legend_handler_map) return handles, labels + @docstring.dedent_interpd def legend(self, *args, **kwargs): """ Places a legend on the axes. @@ -328,6 +304,7 @@ def legend(self, *args, **kwargs): Parameters ---------- + loc : int or string or pair of floats, default: 'upper right' The location of the legend. Possible codes are: @@ -497,6 +474,11 @@ def legend(self, *args, **kwargs): handler. This `handler_map` updates the default handler map found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + Returns + ------- + + :class:`matplotlib.legend.Legend` instance + Notes ----- @@ -509,57 +491,12 @@ def legend(self, *args, **kwargs): .. plot:: gallery/api/legend.py """ - handlers = kwargs.get('handler_map', {}) or {} - - # Support handles and labels being passed as keywords. - handles = kwargs.pop('handles', None) - labels = kwargs.pop('labels', None) - - if (handles is not None or labels is not None) and len(args): - warnings.warn("You have mixed positional and keyword " - "arguments, some input will be " - "discarded.") - - # if got both handles and labels as kwargs, make same length - if handles and labels: - handles, labels = zip(*zip(handles, labels)) - - elif handles is not None and labels is None: - labels = [handle.get_label() for handle in handles] - for label, handle in zip(labels[:], handles[:]): - if label.startswith('_'): - warnings.warn('The handle {!r} has a label of {!r} which ' - 'cannot be automatically added to the ' - 'legend.'.format(handle, label)) - labels.remove(label) - handles.remove(handle) - - elif labels is not None and handles is None: - # Get as many handles as there are labels. - handles = [handle for handle, label - in zip(self._get_legend_handles(handlers), labels)] - - # No arguments - automatically detect labels and handles. - elif len(args) == 0: - handles, labels = self.get_legend_handles_labels(handlers) - if not handles: - return None - - # One argument. User defined labels - automatic handle detection. - elif len(args) == 1: - labels, = args - # Get as many handles as there are labels. - handles = [handle for handle, label - in zip(self._get_legend_handles(handlers), labels)] - - # Two arguments: - # * user defined handles and labels - elif len(args) == 2: - handles, labels = args - - else: - raise TypeError('Invalid arguments to legend.') - + handles, labels, extra_args, kwargs = mlegend._parse_legend_args( + [self], + *args, + **kwargs) + if len(extra_args): + raise TypeError('legend only accepts two non-keyword arguments') self.legend_ = mlegend.Legend(self, handles, labels, **kwargs) self.legend_._remove_method = lambda h: setattr(self, 'legend_', None) return self.legend_ diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index eb13d2e5f6dd..21173289e4c9 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -39,7 +39,7 @@ from matplotlib.axes import Axes, SubplotBase, subplot_class_factory from matplotlib.blocking_input import BlockingMouseInput, BlockingKeyMouseInput from matplotlib.gridspec import GridSpec -from matplotlib.legend import Legend +import matplotlib.legend as mlegend from matplotlib.patches import Rectangle from matplotlib.projections import (get_projection_names, process_projection_requirements) @@ -1315,6 +1315,7 @@ def draw_artist(self, a): def get_axes(self): return self.axes + @docstring.dedent_interpd def legend(self, *args, **kwargs): """ Place a legend on the figure. @@ -1327,16 +1328,24 @@ def legend(self, *args, **kwargs): legend( (line1, line2, line3), ('label1', 'label2', 'label3'), - 'upper right') + loc='upper right') + + These can also be specified by keyword:: + + legend(handles=(line1, line2, line3), + labels=('label1', 'label2', 'label3'), + loc='upper right') Parameters ---------- - loc : string or integer + + loc : int or string or pair of floats, default: 'upper right' The location of the legend. Possible codes are: =============== ============= Location String Location Code =============== ============= + 'best' 0 'upper right' 1 'upper left' 2 'lower left' 3 @@ -1349,27 +1358,57 @@ def legend(self, *args, **kwargs): 'center' 10 =============== ============= - *loc* can also be an (x,y) tuple in figure coords, which specifies - the lower left of the legend box. In figure coords (0,0) is the - bottom left of the figure, and (1,1) is the top right. - prop : None or FontProperties or dict - A :class:`matplotlib.font_manager.FontProperties` instance. If - *prop* is a dictionary, a new instance will be created with *prop*. - If *None*, use rc settings. + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). - numpoints : integer - The number of points in the legend line, default is 4 + bbox_to_anchor : `~.BboxBase` or pair of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). - scatterpoints : integer - The number of points in the legend line, default is 4 + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: - scatteryoffsets : list of floats - A list of yoffsets for scatter symbols in legend + loc='upper right', bbox_to_anchor=(0.5, 0.5) - markerscale : None or scalar - The relative size of legend markers vs. original. If *None*, use rc - settings. + ncol : integer + The number of columns that the legend has. Default is 1. + + prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + + fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + + numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + + scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + + scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + + markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. markerfirst : bool If *True*, legend marker is placed to the left of the legend label. @@ -1379,79 +1418,96 @@ def legend(self, *args, **kwargs): frameon : None or bool Control whether the legend should be drawn on a patch (frame). - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.frameon`` :data:`rcParam`. fancybox : None or bool - If *True*, draw a frame with a round fancybox. If *None*, use rc - settings. + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. shadow : None or bool - If *True*, draw a shadow behind legend. If *None*, use rc settings. + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. framealpha : None or float Control the alpha transparency of the legend's background. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. facecolor : None or "inherit" or a color spec Control the legend's background color. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.facecolor`` :data:`rcParam`. If ``"inherit"``, it will take the ``axes.facecolor`` :data:`rcParam`. edgecolor : None or "inherit" or a color spec Control the legend's background patch edge color. - Default is *None* which will take the value from the + Default is ``None`` which will take the value from the ``legend.edgecolor`` :data:`rcParam`. If ``"inherit"``, it will take the ``axes.edgecolor`` :data:`rcParam`. - ncol : integer - Number of columns. Default is 1. + mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). - mode : "expand" or None - If mode is "expand", the legend will be horizontally expanded - to fill the axes area (or *bbox_to_anchor*) + bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. - title : string - The legend title + title : str or None + The legend's title. Default is no title (``None``). borderpad : float or None - The fractional whitespace inside the legend border, measured in - font-size units. - Default is *None* which will take the value from the + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.borderpad`` :data:`rcParam`. labelspacing : float or None - The vertical space between the legend entries, measured in - font-size units. - Default is *None* which will take the value from the + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.labelspacing`` :data:`rcParam`. handlelength : float or None - The length of the legend handles, measured in font-size units. - Default is *None* which will take the value from the + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.handlelength`` :data:`rcParam`. handletextpad : float or None - The padding between the legend handle and text, measured in - font-size units. - Default is *None* which will take the value from the + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.handletextpad`` :data:`rcParam`. borderaxespad : float or None - The padding between the axes and legend border, measured in - font-size units. - Default is *None* which will take the value from the + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.borderaxespad`` :data:`rcParam`. columnspacing : float or None - The spacing between columns, measured in font-size units. - Default is *None* which will take the value from the + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the ``legend.columnspacing`` :data:`rcParam`. + handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + Returns ------- :class:`matplotlib.legend.Legend` instance @@ -1462,67 +1518,22 @@ def legend(self, *args, **kwargs): :ref:`sphx_glr_tutorials_intermediate_legend_guide.py` for details. """ - # If no arguments given, collect up all the artists on the figure - if len(args) == 0: - handles = [] - labels = [] - - def in_handles(h, l): - # Method to check if we already have a given handle and label. - # Consider two handles to be the same if they share a label, - # color, facecolor, and edgecolor. - - # Loop through each handle and label already collected - for f_h, f_l in zip(handles, labels): - if f_l != l: - continue - if type(f_h) != type(h): - continue - try: - if f_h.get_color() != h.get_color(): - continue - except AttributeError: - pass - try: - if f_h.get_facecolor() != h.get_facecolor(): - continue - except AttributeError: - pass - try: - if f_h.get_edgecolor() != h.get_edgecolor(): - continue - except AttributeError: - pass - return True - return False - - for ax in self.axes: - ax_handles, ax_labels = ax.get_legend_handles_labels() - for h, l in zip(ax_handles, ax_labels): - if not in_handles(h, l): - handles.append(h) - labels.append(l) - if len(handles) == 0: - warnings.warn("No labeled objects found. " - "Use label='...' kwarg on individual plots.") - return None - - elif len(args) == 2: - # LINES, LABELS - handles, labels = args - - elif len(args) == 3: - # LINES, LABELS, LOC - handles, labels, loc = args - kwargs['loc'] = loc - - else: - raise TypeError('Invalid number of arguments passed to legend. ' - 'Please specify either 0 args, 2 args ' - '(artist handles, figure labels) or 3 args ' - '(artist handles, figure labels, legend location)') - - l = Legend(self, handles, labels, **kwargs) + handles, labels, extra_args, kwargs = mlegend._parse_legend_args( + self.axes, + *args, + **kwargs) + # check for third arg + if len(extra_args): + # cbook.warn_deprecated( + # "2.1", + # "Figure.legend will accept no more than two " + # "positional arguments in the future. Use " + # "'fig.legend(handles, labels, loc=location)' " + # "instead.") + # kwargs['loc'] = extra_args[0] + # extra_args = extra_args[1:] + pass + l = mlegend.Legend(self, handles, labels, *extra_args, **kwargs) self.legends.append(l) l._remove_method = lambda h: self.legends.remove(h) self.stale = True diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 214d110fd8a1..269f93f345dd 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -28,11 +28,13 @@ import six from six.moves import xrange +import logging import warnings import numpy as np from matplotlib import rcParams +from matplotlib import docstring from matplotlib.artist import Artist, allow_rasterization from matplotlib.cbook import silent_list, is_hashable from matplotlib.font_manager import FontProperties @@ -105,29 +107,187 @@ def _update_bbox_to_anchor(self, loc_in_canvas): self.legend.set_bbox_to_anchor(loc_in_bbox) +_legend_kw_doc = ''' +loc : int or string or pair of floats, default: 'upper right' + The location of the legend. Possible codes are: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + + + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). + +bbox_to_anchor : :class:`matplotlib.transforms.BboxBase` instance \ +or tuple of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). + + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: + + loc='upper right', bbox_to_anchor=(0.5, 0.5) + +ncol : integer + The number of columns that the legend has. Default is 1. + +prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + +fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + +numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + +scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + +scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + +markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. + +markerfirst : bool + If *True*, legend marker is placed to the left of the legend label. + If *False*, legend marker is placed to the right of the legend + label. + Default is *True*. + +frameon : None or bool + Control whether the legend should be drawn on a patch (frame). + Default is ``None`` which will take the value from the + ``legend.frameon`` :data:`rcParam`. + +fancybox : None or bool + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. + +shadow : None or bool + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. + +framealpha : None or float + Control the alpha transparency of the legend's background. + Default is ``None`` which will take the value from the + ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. + +facecolor : None or "inherit" or a color spec + Control the legend's background color. + Default is ``None`` which will take the value from the + ``legend.facecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.facecolor`` + :data:`rcParam`. + +edgecolor : None or "inherit" or a color spec + Control the legend's background patch edge color. + Default is ``None`` which will take the value from the + ``legend.edgecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.edgecolor`` + :data:`rcParam`. + +mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). + +bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. + +title : str or None + The legend's title. Default is no title (``None``). + +borderpad : float or None + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderpad`` :data:`rcParam`. + +labelspacing : float or None + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.labelspacing`` :data:`rcParam`. + +handlelength : float or None + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handlelength`` :data:`rcParam`. + +handletextpad : float or None + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handletextpad`` :data:`rcParam`. + +borderaxespad : float or None + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderaxespad`` :data:`rcParam`. + +columnspacing : float or None + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.columnspacing`` :data:`rcParam`. + +handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + +''' +docstring.interpd.update(_legend_kw_doc=_legend_kw_doc) + + class Legend(Artist): """ Place a legend on the axes at location loc. Labels are a sequence of strings and loc can be a string or an integer specifying the legend location - The location codes are:: - - 'best' : 0, (only implemented for axes legends) - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4, - 'right' : 5, (same as 'center right', for back-compatibility) - 'center left' : 6, - 'center right' : 7, - 'lower center' : 8, - 'upper center' : 9, - 'center' : 10, - - loc can be a tuple of the normalized coordinate values with - respect its parent. - """ codes = {'best': 0, # only implemented for axes legends 'upper right': 1, @@ -147,6 +307,7 @@ class Legend(Artist): def __str__(self): return "Legend" + @docstring.dedent_interpd def __init__(self, parent, handles, labels, loc=None, numpoints=None, # the number of points in the legend line @@ -195,42 +356,180 @@ def __init__(self, parent, handles, labels, legend - *labels*: a list of strings to label the legend - Optional keyword arguments: - - ================ ==================================================== - Keyword Description - ================ ==================================================== - loc Location code string, or tuple (see below). - prop the font property - fontsize the font size (used only if prop is not specified) - markerscale the relative size of legend markers vs. original - markerfirst If True (default), marker is to left of the label. - numpoints the number of points in the legend for line - scatterpoints the number of points in the legend for scatter plot - scatteryoffsets a list of yoffsets for scatter symbols in legend - frameon If True, draw the legend on a patch (frame). - fancybox If True, draw the frame with a round fancybox. - shadow If True, draw a shadow behind legend. - framealpha Transparency of the frame. - edgecolor Frame edgecolor. - facecolor Frame facecolor. - ncol number of columns - borderpad the fractional whitespace inside the legend border - labelspacing the vertical space between the legend entries - handlelength the length of the legend handles - handleheight the height of the legend handles - handletextpad the pad between the legend handle and text - borderaxespad the pad between the axes and legend border - columnspacing the spacing between columns - title the legend title - bbox_to_anchor the bbox that the legend will be anchored. - bbox_transform the transform for the bbox. transAxes if None. - ================ ==================================================== - - - The pad and spacing parameters are measured in font-size units. e.g., - a fontsize of 10 points and a handlelength=5 implies a handlelength of - 50 points. Values from rcParams will be used if None. + Parameters + ---------- + + loc : int or string or pair of floats, default: 'upper right' + The location of the legend. Possible codes are: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + + + Alternatively can be a 2-tuple giving ``x, y`` of the lower-left + corner of the legend in axes coordinates (in which case + ``bbox_to_anchor`` will be ignored). + + bbox_to_anchor : `~.BboxBase` or pair of floats + Specify any arbitrary location for the legend in `bbox_transform` + coordinates (default Axes coordinates). + + For example, to put the legend's upper right hand corner in the + center of the axes the following keywords can be used:: + + loc='upper right', bbox_to_anchor=(0.5, 0.5) + + ncol : integer + The number of columns that the legend has. Default is 1. + + prop : None or :class:`matplotlib.font_manager.FontProperties` or dict + The font properties of the legend. If None (default), the current + :data:`matplotlib.rcParams` will be used. + + fontsize : int or float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'} + Controls the font size of the legend. If the value is numeric the + size will be the absolute font size in points. String values are + relative to the current default font size. This argument is only + used if `prop` is not specified. + + numpoints : None or int + The number of marker points in the legend when creating a legend + entry for a line/:class:`matplotlib.lines.Line2D`. + Default is ``None`` which will take the value from the + ``legend.numpoints`` :data:`rcParam`. + + scatterpoints : None or int + The number of marker points in the legend when creating a legend + entry for a scatter plot/ + :class:`matplotlib.collections.PathCollection`. + Default is ``None`` which will take the value from the + ``legend.scatterpoints`` :data:`rcParam`. + + scatteryoffsets : iterable of floats + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``. + + markerscale : None or int or float + The relative size of legend markers compared with the originally + drawn ones. Default is ``None`` which will take the value from + the ``legend.markerscale`` :data:`rcParam `. + + markerfirst : bool + If *True*, legend marker is placed to the left of the legend label. + If *False*, legend marker is placed to the right of the legend + label. + Default is *True*. + + frameon : None or bool + Control whether the legend should be drawn on a patch (frame). + Default is ``None`` which will take the value from the + ``legend.frameon`` :data:`rcParam`. + + fancybox : None or bool + Control whether round edges should be enabled around + the :class:`~matplotlib.patches.FancyBboxPatch` which + makes up the legend's background. + Default is ``None`` which will take the value from the + ``legend.fancybox`` :data:`rcParam`. + + shadow : None or bool + Control whether to draw a shadow behind the legend. + Default is ``None`` which will take the value from the + ``legend.shadow`` :data:`rcParam`. + + framealpha : None or float + Control the alpha transparency of the legend's background. + Default is ``None`` which will take the value from the + ``legend.framealpha`` :data:`rcParam`. + If shadow is activated and framealpha is ``None`` the + default value is being ignored. + + facecolor : None or "inherit" or a color spec + Control the legend's background color. + Default is ``None`` which will take the value from the + ``legend.facecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.facecolor`` + :data:`rcParam`. + + edgecolor : None or "inherit" or a color spec + Control the legend's background patch edge color. + Default is ``None`` which will take the value from the + ``legend.edgecolor`` :data:`rcParam`. + If ``"inherit"``, it will take the ``axes.edgecolor`` + :data:`rcParam`. + + mode : {"expand", None} + If `mode` is set to ``"expand"`` the legend will be horizontally + expanded to fill the axes area (or `bbox_to_anchor` if defines + the legend's size). + + bbox_transform : None or :class:`matplotlib.transforms.Transform` + The transform for the bounding box (`bbox_to_anchor`). For a value + of ``None`` (default) the Axes' + :data:`~matplotlib.axes.Axes.transAxes` transform will be used. + + title : str or None + The legend's title. Default is no title (``None``). + + borderpad : float or None + The fractional whitespace inside the legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderpad`` :data:`rcParam`. + + labelspacing : float or None + The vertical space between the legend entries. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.labelspacing`` :data:`rcParam`. + + handlelength : float or None + The length of the legend handles. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handlelength`` :data:`rcParam`. + + handletextpad : float or None + The pad between the legend handle and text. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.handletextpad`` :data:`rcParam`. + + borderaxespad : float or None + The pad between the axes and legend border. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.borderaxespad`` :data:`rcParam`. + + columnspacing : float or None + The spacing between columns. + Measured in font-size units. + Default is ``None`` which will take the value from the + ``legend.columnspacing`` :data:`rcParam`. + + handler_map : dict or None + The custom dictionary mapping instances or types to a legend + handler. This `handler_map` updates the default handler map + found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + + Notes + ----- Users can specify any arbitrary location for the legend using the *bbox_to_anchor* keyword argument. bbox_to_anchor can be an instance @@ -280,6 +579,15 @@ def __init__(self, parent, handles, labels, value = locals_view[name] setattr(self, name, value) del locals_view + # trim handles and labels if illegal label... + for label, handle in zip(labels[:], handles[:]): + if (isinstance(label, six.string_types) + and label.startswith('_')): + warnings.warn('The handle {!r} has a label of {!r} which ' + 'cannot be automatically added to the ' + 'legend.'.format(handle, label)) + labels.remove(label) + handles.remove(handle) handles = list(handles) if len(handles) < 2: @@ -974,3 +1282,141 @@ def draggable(self, state=None, use_blit=False, update="loc"): self._draggable = None return self._draggable + + +# Helper functions to parse legend arguments for both `figure.legend` and +# `axes.legend`: +def _get_legend_handles(axs, legend_handler_map=None): + """ + Return a generator of artists that can be used as handles in + a legend. + + """ + handles_original = [] + for ax in axs: + handles_original += (ax.lines + ax.patches + + ax.collections + ax.containers) + # support parasite axes: + if hasattr(ax, 'parasites'): + for axx in ax.parasites: + handles_original += (axx.lines + axx.patches + + axx.collections + axx.containers) + + handler_map = Legend.get_default_handler_map() + + if legend_handler_map is not None: + handler_map = handler_map.copy() + handler_map.update(legend_handler_map) + + has_handler = Legend.get_legend_handler + + for handle in handles_original: + label = handle.get_label() + if label != '_nolegend_' and has_handler(handler_map, handle): + yield handle + + +def _get_legend_handles_labels(axs, legend_handler_map=None): + """ + Return handles and labels for legend, internal method. + + """ + handles = [] + labels = [] + + def _in_handles(h, l): + # Method to check if we already have a given handle and label. + # Consider two handles to be the same if they share a label, + # color, facecolor, and edgecolor. + + # Loop through each handle and label already collected + for f_h, f_l in zip(handles, labels): + if f_l != l: + continue + if type(f_h) != type(h): + continue + try: + if f_h.get_color() != h.get_color(): + continue + except AttributeError: + pass + try: + if f_h.get_facecolor() != h.get_facecolor(): + continue + except AttributeError: + pass + try: + if f_h.get_edgecolor() != h.get_edgecolor(): + continue + except AttributeError: + pass + return True + return False + + for handle in _get_legend_handles(axs, legend_handler_map): + label = handle.get_label() + if (label + and not label.startswith('_') + and not _in_handles(handle, label)): + handles.append(handle) + labels.append(label) + return handles, labels + + +def _parse_legend_args(axs, *args, **kwargs): + """ + Get the handles and labels from the calls to either ``figure.legend`` + or ``axes.legend``. + + ``axs`` is a list of axes (to get legend artists from) + """ + log = logging.getLogger(__name__) + + handlers = kwargs.get('handler_map', {}) or {} + + # Support handles and labels being passed as keywords. + handles = kwargs.pop('handles', None) + labels = kwargs.pop('labels', None) + + extra_args = () + + if (handles is not None or labels is not None) and len(args): + warnings.warn("You have mixed positional and keyword " + "arguments, some input may be " + "discarded.") + + # if got both handles and labels as kwargs, make same length + if handles and labels: + handles, labels = zip(*zip(handles, labels)) + + elif handles is not None and labels is None: + labels = [handle.get_label() for handle in handles] + + elif labels is not None and handles is None: + # Get as many handles as there are labels. + handles = [handle for handle, label + in zip(_get_legend_handles(axs, handlers), labels)] + + # No arguments - automatically detect labels and handles. + elif len(args) == 0: + handles, labels = _get_legend_handles_labels(axs, handlers) + if not handles: + log.warning('No handles with labels found to put in legend.') + + # One argument. User defined labels - automatic handle detection. + elif len(args) == 1: + labels, = args + # Get as many handles as there are labels. + handles = [handle for handle, label + in zip(_get_legend_handles(axs, handlers), labels)] + + # Two arguments: + # * user defined handles and labels + elif len(args) >= 2: + handles, labels = args[:2] + extra_args = args[2:] + + else: + raise TypeError('Invalid arguments to legend.') + + return handles, labels, extra_args, kwargs diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 221acdaa9306..da9b072aeef0 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -7,6 +7,8 @@ except ImportError: import mock import numpy as np +import pytest + from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt @@ -14,6 +16,36 @@ import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections from matplotlib.legend_handler import HandlerTuple +import inspect + + +# test that docstrigs are the same +def get_docstring_section(func, section): + """ extract a section from the docstring of a function """ + ll = inspect.getdoc(func) + lines = ll.splitlines() + insec = False + st = '' + for ind in range(len(lines)): + if lines[ind][:len(section)] == section and lines[ind+1][:3] == '---': + insec = True + ind = ind+1 + if insec: + if len(lines[ind + 1]) > 3 and lines[ind + 1][0:3] == '---': + insec = False + break + else: + st += lines[ind] + '\n' + return st + + +def test_legend_kwdocstrings(): + stleg = get_docstring_section(mpl.legend.Legend.__init__, 'Parameters') + stax = get_docstring_section(mpl.axes.Axes.legend, 'Parameters') + stfig = get_docstring_section(mpl.figure.Figure.legend, 'Parameters') + assert stleg == stax + assert stfig == stax + assert stleg == stfig @image_comparison(baseline_images=['legend_auto1'], remove_text=True) @@ -233,13 +265,19 @@ def test_legend_label_args(self): plt.legend(['foobar']) Legend.assert_called_with(plt.gca(), lines, ['foobar']) + def test_legend_three_args(self): + lines = plt.plot(range(10), label='hello world') + with mock.patch('matplotlib.legend.Legend') as Legend: + plt.legend(lines, ['foobar'], loc='right') + Legend.assert_called_with(plt.gca(), lines, ['foobar'], loc='right') + def test_legend_handler_map(self): lines = plt.plot(range(10), label='hello world') - with mock.patch('matplotlib.axes.Axes.' - 'get_legend_handles_labels') as handles_labels: + with mock.patch('matplotlib.legend.' + '_get_legend_handles_labels') as handles_labels: handles_labels.return_value = lines, ['hello world'] plt.legend(handler_map={'1': 2}) - handles_labels.assert_called_with({'1': 2}) + handles_labels.assert_called_with([plt.gca()], {'1': 2}) def test_kwargs(self): fig, ax = plt.subplots(1, 1) @@ -247,7 +285,7 @@ def test_kwargs(self): lns, = ax.plot(th, np.sin(th), label='sin', lw=5) lnc, = ax.plot(th, np.cos(th), label='cos', lw=5) with mock.patch('matplotlib.legend.Legend') as Legend: - ax.legend(handles=(lnc, lns), labels=('a', 'b')) + ax.legend(labels=('a', 'b'), handles=(lnc, lns)) Legend.assert_called_with(ax, (lnc, lns), ('a', 'b')) def test_warn_args_kwargs(self): @@ -259,7 +297,80 @@ def test_warn_args_kwargs(self): ax.legend((lnc, lns), labels=('a', 'b')) warn.assert_called_with("You have mixed positional and keyword " - "arguments, some input will be " + "arguments, some input may be " + "discarded.") + + def test_parasite(self): + from mpl_toolkits.axes_grid1 import host_subplot + + host = host_subplot(111) + par = host.twinx() + + p1, = host.plot([0, 1, 2], [0, 1, 2], label="Density") + p2, = par.plot([0, 1, 2], [0, 3, 2], label="Temperature") + + with mock.patch('matplotlib.legend.Legend') as Legend: + leg = plt.legend() + Legend.assert_called_with(host, [p1, p2], + ['Density', 'Temperature']) + + +class TestLegendFigureFunction(object): + # Tests the legend function for figure + def test_legend_handle_label(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(lines, ['hello world']) + Legend.assert_called_with(fig, lines, ['hello world']) + + def test_legend_no_args(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10), label='hello world') + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend() + Legend.assert_called_with(fig, lines, ['hello world']) + + def test_legend_label_arg(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(['foobar']) + Legend.assert_called_with(fig, lines, ['foobar']) + + def test_legend_label_three_args(self): + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(lines, ['foobar'], 'right') + Legend.assert_called_with(fig, lines, ['foobar'], 'right') + + def test_legend_label_three_args_pluskw(self): + # test that third argument and loc= called together give + # Exception + fig, ax = plt.subplots() + lines = ax.plot(range(10)) + with pytest.raises(Exception): + fig.legend(lines, ['foobar'], 'right', loc='left') + + def test_legend_kw_args(self): + fig, axs = plt.subplots(1, 2) + lines = axs[0].plot(range(10)) + lines2 = axs[1].plot(np.arange(10) * 2.) + with mock.patch('matplotlib.legend.Legend') as Legend: + fig.legend(loc='right', labels=('a', 'b'), + handles=(lines, lines2)) + Legend.assert_called_with(fig, (lines, lines2), ('a', 'b'), + loc='right') + + def test_warn_args_kwargs(self): + fig, axs = plt.subplots(1, 2) + lines = axs[0].plot(range(10)) + lines2 = axs[1].plot(np.arange(10) * 2.) + with mock.patch('warnings.warn') as warn: + fig.legend((lines, lines2), labels=('a', 'b')) + warn.assert_called_with("You have mixed positional and keyword " + "arguments, some input may be " "discarded.") diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index 086806b182e3..5d347af1da4c 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -258,8 +258,8 @@ def get_aux_axes(self, tr, viewlim_mode="equal", axes_class=None): ax2._remove_method = lambda h: self.parasites.remove(h) return ax2 - def _get_legend_handles(self, legend_handler_map=None): + # don't use this! Axes_get_legend_handles = self._get_base_axes_attr("_get_legend_handles") all_handles = list(Axes_get_legend_handles(self, legend_handler_map))