diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 84817c6d6bba..2f903d64f370 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -307,6 +307,8 @@ Axis Labels, title, and legend Axes.get_xlabel Axes.set_ylabel Axes.get_ylabel + Axes.set_xlabel_legend + Axes.set_ylabel_legend Axes.set_title Axes.get_title diff --git a/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst b/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst new file mode 100644 index 000000000000..d16c9f704ac6 --- /dev/null +++ b/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst @@ -0,0 +1,7 @@ +Support for axis label legends +------------------------------ + +Legends can now be placed next to axis labels, enabling more +space-efficient and intuitive ``twinx()`` plots. Such legends are created +using ``plt.xlabel_legend()`` and ``plt.ylabel_legend()``, which each +accept one artist handle as understood by ``plt.legend()``. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 702b6a0db813..78cdbb9c155b 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -214,6 +214,21 @@ def set_xlabel(self, xlabel, fontdict=None, labelpad=None, **kwargs): self.xaxis.labelpad = labelpad return self.xaxis.set_label_text(xlabel, fontdict, **kwargs) + def set_xlabel_legend(self, handle, **kwargs): + """ + Place a legend next to the axis label. + + Parameters + ---------- + handle: `.Artist` + An artist (e.g. lines, patches) to be shown as legend. + + **kwargs: `.LegendConfig` properties + Additional properties to control legend appearance. + """ + label = self.xaxis.get_label() + label.set_legend_handle(handle, **kwargs) + def get_ylabel(self): """ Get the ylabel text string. @@ -248,6 +263,36 @@ def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): self.yaxis.labelpad = labelpad return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) + def set_ylabel_legend(self, handle, **kwargs): + """ + Place a legend next to the axis label. + + Parameters + ---------- + handle: `.Artist` + An artist (e.g. lines, patches) to be shown as legend. + + **kwargs: `.LegendConfig` properties + Additional properties to control legend appearance. + + Examples + -------- + Plot two lines on different axes with legends placed next to the + axis labels:: + + artist, = plt.plot([0, 1, 2], [0, 1, 2], "b-") + plt.ylabel('Density') + plt.ylabel_legend(artist) + + plt.twinx() + artist, = plt.plot([0, 1, 2], [0, 3, 2], "r-") + plt.ylabel('Temperature') + plt.ylabel_legend(artist) + plt.show() + """ + label = self.yaxis.get_label() + label.set_legend_handle(handle, **kwargs) + def get_legend_handles_labels(self, legend_handler_map=None): """ Return handles and labels for legend diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index a52bd54ab0d5..352e50aeb10f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -11,6 +11,7 @@ import matplotlib.artist as martist import matplotlib.cbook as cbook import matplotlib.font_manager as font_manager +import matplotlib.legend as mlegend import matplotlib.lines as mlines import matplotlib.scale as mscale import matplotlib.text as mtext @@ -691,7 +692,7 @@ def __init__(self, axes, pickradius=15): self._autolabelpos = True self._smart_bounds = False # Deprecated in 3.2 - self.label = mtext.Text( + self.label = mlegend.TextWithLegend( np.nan, np.nan, fontproperties=font_manager.FontProperties( size=rcParams['axes.labelsize'], diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 8a6dc90f6d7f..e662ed052887 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -36,6 +36,7 @@ from matplotlib.collections import (LineCollection, RegularPolyCollection, CircleCollection, PathCollection, PolyCollection) +from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox from matplotlib.transforms import BboxTransformTo, BboxTransformFrom @@ -175,24 +176,6 @@ def _update_bbox_to_anchor(self, loc_in_canvas): 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 : int, default: :rc:`legend.numpoints` - The number of marker points in the legend when creating a legend - entry for a `.Line2D` (line). - -scatterpoints : int, default: :rc:`legend.scatterpoints` - The number of marker points in the legend when creating - a legend entry for a `.PathCollection` (scatter plot). - -scatteryoffsets : iterable of floats, default: ``[0.375, 0.5, 0.3125]`` - 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]``. - -markerscale : float, default: :rc:`legend.markerscale` - The relative size of legend markers compared with the originally - drawn ones. - markerfirst : bool, default: True 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. @@ -236,32 +219,309 @@ def _update_bbox_to_anchor(self, loc_in_canvas): title_fontsize: str or None The fontsize of the legend's title. Default is the default fontsize. -borderpad : float, default: :rc:`legend.borderpad` - The fractional whitespace inside the legend border, in font-size units. - labelspacing : float, default: :rc:`legend.labelspacing` The vertical space between the legend entries, in font-size units. -handlelength : float, default: :rc:`legend.handlelength` - The length of the legend handles, in font-size units. - -handletextpad : float, default: :rc:`legend.handletextpad` - The pad between the legend handle and text, in font-size units. - borderaxespad : float, default: :rc:`legend.borderaxespad` The pad between the axes and legend border, in font-size units. columnspacing : float, default: :rc:`legend.columnspacing` The spacing between columns, in font-size units. +""") + +docstring.interpd.update(_legend_config_kw_doc=""" +numpoints : int, default: :rc:`legend.numpoints` + The number of marker points in the legend when creating a legend + entry for a `.Line2D` (line). + +scatterpoints : int, default: :rc:`legend.scatterpoints` + The number of marker points in the legend when creating + a legend entry for a `.PathCollection` (scatter plot). + +scatteryoffsets : iterable of floats, default: ``[0.375, 0.5, 0.3125]`` + 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]``. + +markerscale : float, default: :rc:`legend.markerscale` + The relative size of legend markers compared with the originally + drawn ones. + +borderpad : float, default: :rc:`legend.borderpad` + The fractional whitespace inside the legend border, in font-size units. + +handlelength : float, default: :rc:`legend.handlelength` + The length of the legend handles, in font-size units. + +handletextpad : float, default: :rc:`legend.handletextpad` + The pad between the legend handle and text, in font-size units. 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`. + found at :func:`matplotlib.legend.LegendConfig.get_legend_handler_map`. """) -class Legend(Artist): +class LegendConfig(object): + """ + Shared elements of regular legends and axis-label legends. + """ + @docstring.dedent_interpd + def __init__(self, + parent, + numpoints=None, # the number of points in the legend line + markerscale=None, # the relative size of legend markers + # vs. original + scatterpoints=None, # number of scatter points + scatteryoffsets=None, + + # spacing & pad defined as a fraction of the font-size + borderpad=None, # the whitespace inside the legend border + handlelength=None, # the length of the legend handles + handleheight=None, # the height of the legend handles + handletextpad=None, # the pad between the legend handle + # and text + handler_map=None, + ): + """ + Parameters + ---------- + %(_legend_config_kw_doc)s + """ + + self.parent = parent + #: A dictionary with the extra handler mappings for this Legend + #: instance. + self._custom_handler_map = handler_map + + locals_view = locals() + for name in ['numpoints', 'markerscale', 'scatterpoints', 'borderpad', + 'handleheight', 'handlelength', 'handletextpad']: + if locals_view[name] is None: + value = rcParams["legend." + name] + else: + value = locals_view[name] + setattr(self, name, value) + del locals_view + + if self.numpoints <= 0: + raise ValueError("numpoints must be > 0; it was %d" % numpoints) + + # introduce y-offset for handles of the scatter plot + if scatteryoffsets is None: + self._scatteryoffsets = np.array([3. / 8., 4. / 8., 2.5 / 8.]) + else: + self._scatteryoffsets = np.asarray(scatteryoffsets) + reps = self.scatterpoints // len(self._scatteryoffsets) + 1 + self._scatteryoffsets = np.tile(self._scatteryoffsets, + reps)[:self.scatterpoints] + + def _approx_box_height(self, fontsize, renderer=None): + """ + Return the approximate height and descent of the DrawingArea + holding the legend handle. + """ + if renderer is None: + size = fontsize + else: + size = renderer.points_to_pixels(fontsize) + + # The approximate height and descent of text. These values are + # only used for plotting the legend handle. + descent = 0.35 * size * (self.handleheight - 0.7) + # 0.35 and 0.7 are just heuristic numbers and may need to be improved. + height = size * self.handleheight - descent + # each handle needs to be drawn inside a box of (x, y, w, h) = + # (0, -descent, width, height). And their coordinates should + # be given in the display coordinates. + return descent, height + + # _default_handler_map defines the default mapping between plot + # elements and the legend handlers. + + _default_handler_map = { + StemContainer: legend_handler.HandlerStem(), + ErrorbarContainer: legend_handler.HandlerErrorbar(), + Line2D: legend_handler.HandlerLine2D(), + Patch: legend_handler.HandlerPatch(), + LineCollection: legend_handler.HandlerLineCollection(), + RegularPolyCollection: legend_handler.HandlerRegularPolyCollection(), + CircleCollection: legend_handler.HandlerCircleCollection(), + BarContainer: legend_handler.HandlerPatch( + update_func=legend_handler.update_from_first_child), + tuple: legend_handler.HandlerTuple(), + PathCollection: legend_handler.HandlerPathCollection(), + PolyCollection: legend_handler.HandlerPolyCollection() + } + + # (get|set|update)_default_handler_maps are public interfaces to + # modify the default handler map. + + @classmethod + def get_default_handler_map(cls): + """ + A class method that returns the default handler map. + """ + return cls._default_handler_map + + @classmethod + def set_default_handler_map(cls, handler_map): + """ + A class method to set the default handler map. + """ + cls._default_handler_map = handler_map + + @classmethod + def update_default_handler_map(cls, handler_map): + """ + A class method to update the default handler map. + """ + cls._default_handler_map.update(handler_map) + + def get_legend_handler_map(self): + """ + Return the handler map. + """ + + default_handler_map = self.get_default_handler_map() + + if self._custom_handler_map: + hm = default_handler_map.copy() + hm.update(self._custom_handler_map) + return hm + else: + return default_handler_map + + @staticmethod + def get_legend_handler(legend_handler_map, orig_handle): + """ + Return a legend handler from *legend_handler_map* that + corresponds to *orig_handler*. + + *legend_handler_map* should be a dictionary object (that is + returned by the get_legend_handler_map method). + + It first checks if the *orig_handle* itself is a key in the + *legend_handler_map* and return the associated value. + Otherwise, it checks for each of the classes in its + method-resolution-order. If no matching key is found, it + returns ``None``. + """ + try: + return legend_handler_map[orig_handle] + except (TypeError, KeyError): # TypeError if unhashable. + pass + for handle_type in type(orig_handle).mro(): + try: + return legend_handler_map[handle_type] + except KeyError: + pass + return None + + def _warn_unsupported_artist(self, handle): + cbook._warn_external( + "Legend does not support {!r} instances.\nA proxy artist " + "may be used instead.\nSee: " + "http://matplotlib.org/users/legend_guide.html" + "#creating-artists-specifically-for-adding-to-the-legend-" + "aka-proxy-artists".format(handle)) + + def _set_artist_props(self, a): + self.parent._set_artist_props(a) + + +class TextWithLegend(Text): + """ + Place a legend symbol next to a text. + """ + def __init__(self, *args, **kwargs): + """ + Valid keyword arguments are: + + %(Text)s + """ + Text.__init__(self, *args, **kwargs) + self.legend_config = None + self.legend = None + + def _get_layout_with_legend(self, renderer): + if self.legend is None: + bbox, info, descent = Text._get_layout(self, renderer, 0) + return bbox, info, descent, 0 + legend_extent = self.legend.get_extent(renderer) + legend_width, legend_height, _, _ = legend_extent + padding = self.legend_config.handletextpad * self.get_size() + rotation = self.get_rotation() + if rotation == 0: + line_indent = legend_width + padding + bbox, info, descent = Text._get_layout(self, renderer, line_indent) + line, (w, h, d), x, y = info[0] + legend_offset = ( + x - line_indent, + y + (h-d - legend_height) / 2 + ) + elif rotation == 90: + line_indent = legend_height + padding + bbox, info, descent = Text._get_layout(self, renderer, line_indent) + line, (w, h, d), x, y = info[0] + legend_offset = ( + x - (h-d + legend_width) / 2, + y - line_indent + ) + return bbox, info, descent, legend_offset + + def _get_layout(self, renderer, firstline_indent=0): + bbox, info, descent, _ = self._get_layout_with_legend(renderer) + return bbox, info, descent + + def draw(self, renderer): + Text.draw(self, renderer) + if self.legend is not None: + bbox, info, _, offset = self._get_layout_with_legend(renderer) + x, y = offset + trans = self.get_transform() + posx, posy = self.get_unitless_position() + posx, posy = trans.transform((posx, posy)) + self.legend.set_offset((x + posx, y + posy)) + self.legend.draw(renderer) + + def set_legend_handle(self, handle, **kwargs): + """Initialize DrawingArea and legend artist""" + rotation = self.get_rotation() + if rotation not in [0, 90]: + cbook._warn_external("Legend symbols are only supported " + "for non-rotated texts and texts rotated by 90°.") + return + rotate = rotation == 90 + config = LegendConfig(self.figure, **kwargs) + self.legend_config = config + fontsize = self.get_fontsize() + descent, height = config._approx_box_height(fontsize) + + legend_handler_map = config.get_legend_handler_map() + handler = config.get_legend_handler(legend_handler_map, handle) + if handler is None: + config._warn_unsupported_artist(handle) + self.legend_config = None + self.legend = None + return + if rotate: + box_width, box_height = height, config.handlelength * fontsize + xdescent, ydescent = descent, 0 + else: + box_width, box_height = config.handlelength * fontsize, height + xdescent, ydescent = 0, descent + + self.legend = DrawingArea(width=box_width, height=box_height, + xdescent=xdescent, ydescent=ydescent) + # Create the artist for the legend which represents the + # original artist/handle. + handler.legend_artist(config, handle, fontsize, self.legend, rotate) + + +class Legend(Artist, LegendConfig): """ Place a legend on the axes at location loc. @@ -287,24 +547,14 @@ def __str__(self): @docstring.dedent_interpd def __init__(self, parent, handles, labels, loc=None, - numpoints=None, # the number of points in the legend line - markerscale=None, # the relative size of legend markers - # vs. original markerfirst=True, # controls ordering (left-to-right) of # legend marker and label - scatterpoints=None, # number of scatter points - scatteryoffsets=None, prop=None, # properties for the legend texts fontsize=None, # keyword to set font size directly # spacing & pad defined as a fraction of the font-size - borderpad=None, # the whitespace inside the legend border labelspacing=None, # the vertical space between the legend # entries - handlelength=None, # the length of the legend handles - handleheight=None, # the height of the legend handles - handletextpad=None, # the pad between the legend handle - # and text borderaxespad=None, # the pad between the axes and legend # border columnspacing=None, # spacing between columns @@ -325,7 +575,7 @@ def __init__(self, parent, handles, labels, bbox_to_anchor=None, # bbox that the legend will be anchored. bbox_transform=None, # transform for the bbox frameon=None, # draw frame - handler_map=None, + **kwargs ): """ Parameters @@ -345,6 +595,8 @@ def __init__(self, parent, handles, labels, ---------------- %(_legend_kw_doc)s + %(_legend_config_kw_doc)s + Notes ----- Users can specify any arbitrary location for the legend using the @@ -361,6 +613,7 @@ def __init__(self, parent, handles, labels, from matplotlib.figure import Figure Artist.__init__(self) + LegendConfig.__init__(self, parent, **kwargs) if prop is None: if fontsize is not None: @@ -380,14 +633,8 @@ def __init__(self, parent, handles, labels, self.legendHandles = [] self._legend_title_box = None - #: A dictionary with the extra handler mappings for this Legend - #: instance. - self._custom_handler_map = handler_map - locals_view = locals() - for name in ["numpoints", "markerscale", "shadow", "columnspacing", - "scatterpoints", "handleheight", 'borderpad', - 'labelspacing', 'handlelength', 'handletextpad', + for name in ['shadow', 'columnspacing', 'labelspacing', 'borderaxespad']: if locals_view[name] is None: value = rcParams["legend." + name] @@ -412,18 +659,6 @@ def __init__(self, parent, handles, labels, ncol = 1 self._ncol = ncol - if self.numpoints <= 0: - raise ValueError("numpoints must be > 0; it was %d" % numpoints) - - # introduce y-offset for handles of the scatter plot - if scatteryoffsets is None: - self._scatteryoffsets = np.array([3. / 8., 4. / 8., 2.5 / 8.]) - else: - self._scatteryoffsets = np.asarray(scatteryoffsets) - reps = self.scatterpoints // len(self._scatteryoffsets) + 1 - self._scatteryoffsets = np.tile(self._scatteryoffsets, - reps)[:self.scatterpoints] - # _legend_box is an OffsetBox instance that contains all # legend items and will be initialized from _init_legend_box() # method. @@ -613,98 +848,6 @@ def draw(self, renderer): renderer.close_group('legend') self.stale = False - def _approx_text_height(self, renderer=None): - """ - Return the approximate height of the text. This is used to place - the legend handle. - """ - if renderer is None: - return self._fontsize - else: - return renderer.points_to_pixels(self._fontsize) - - # _default_handler_map defines the default mapping between plot - # elements and the legend handlers. - - _default_handler_map = { - StemContainer: legend_handler.HandlerStem(), - ErrorbarContainer: legend_handler.HandlerErrorbar(), - Line2D: legend_handler.HandlerLine2D(), - Patch: legend_handler.HandlerPatch(), - LineCollection: legend_handler.HandlerLineCollection(), - RegularPolyCollection: legend_handler.HandlerRegularPolyCollection(), - CircleCollection: legend_handler.HandlerCircleCollection(), - BarContainer: legend_handler.HandlerPatch( - update_func=legend_handler.update_from_first_child), - tuple: legend_handler.HandlerTuple(), - PathCollection: legend_handler.HandlerPathCollection(), - PolyCollection: legend_handler.HandlerPolyCollection() - } - - # (get|set|update)_default_handler_maps are public interfaces to - # modify the default handler map. - - @classmethod - def get_default_handler_map(cls): - """ - A class method that returns the default handler map. - """ - return cls._default_handler_map - - @classmethod - def set_default_handler_map(cls, handler_map): - """ - A class method to set the default handler map. - """ - cls._default_handler_map = handler_map - - @classmethod - def update_default_handler_map(cls, handler_map): - """ - A class method to update the default handler map. - """ - cls._default_handler_map.update(handler_map) - - def get_legend_handler_map(self): - """ - Return the handler map. - """ - - default_handler_map = self.get_default_handler_map() - - if self._custom_handler_map: - hm = default_handler_map.copy() - hm.update(self._custom_handler_map) - return hm - else: - return default_handler_map - - @staticmethod - def get_legend_handler(legend_handler_map, orig_handle): - """ - Return a legend handler from *legend_handler_map* that - corresponds to *orig_handler*. - - *legend_handler_map* should be a dictionary object (that is - returned by the get_legend_handler_map method). - - It first checks if the *orig_handle* itself is a key in the - *legend_handler_map* and return the associated value. - Otherwise, it checks for each of the classes in its - method-resolution-order. If no matching key is found, it - returns ``None``. - """ - try: - return legend_handler_map[orig_handle] - except (TypeError, KeyError): # TypeError if unhashable. - pass - for handle_type in type(orig_handle).mro(): - try: - return legend_handler_map[handle_type] - except KeyError: - pass - return None - def _init_legend_box(self, handles, labels, markerfirst=True): """ Initialize the legend_box. The legend_box is an instance of @@ -732,14 +875,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): fontproperties=self.prop, ) - # The approximate height and descent of text. These values are - # only used for plotting the legend handle. - descent = 0.35 * self._approx_text_height() * (self.handleheight - 0.7) - # 0.35 and 0.7 are just heuristic numbers and may need to be improved. - height = self._approx_text_height() * self.handleheight - descent - # each handle needs to be drawn inside a box of (x, y, w, h) = - # (0, -descent, width, height). And their coordinates should - # be given in the display coordinates. + descent, height = self._approx_box_height(self._fontsize) # The transformation of each handle will be automatically set # to self.get_transform(). If the artist does not use its @@ -750,12 +886,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): for orig_handle, lab in zip(handles, labels): handler = self.get_legend_handler(legend_handler_map, orig_handle) if handler is None: - cbook._warn_external( - "Legend does not support {!r} instances.\nA proxy artist " - "may be used instead.\nSee: " - "http://matplotlib.org/users/legend_guide.html" - "#creating-artists-specifically-for-adding-to-the-legend-" - "aka-proxy-artists".format(orig_handle)) + self._warn_unsupported_artist(orig_handle) # We don't have a handle for this artist, so we just defer # to None. handle_list.append(None) diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 051c97da0d0b..a29412f3cab2 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -30,6 +30,7 @@ def legend_artist(self, legend, orig_handle, fontsize, handlebox) from matplotlib.lines import Line2D from matplotlib.patches import Rectangle +from matplotlib.transforms import Affine2D import matplotlib.collections as mcoll import matplotlib.colors as mcolors @@ -87,7 +88,7 @@ def adjust_drawing_area(self, legend, orig_handle, return xdescent, ydescent, width, height def legend_artist(self, legend, orig_handle, - fontsize, handlebox): + fontsize, handlebox, rotate=False): """ Return the artist that this HandlerBase generates for the given original artist/handle. @@ -112,9 +113,16 @@ def legend_artist(self, legend, orig_handle, handlebox.xdescent, handlebox.ydescent, handlebox.width, handlebox.height, fontsize) + transform = handlebox.get_transform() + if rotate: + point = (width - xdescent) / 2 + rotate_transform = Affine2D().rotate_deg_around(point, point, 90) + transform = rotate_transform + transform + width, height = height, width + xdescent, ydescent = ydescent, xdescent artists = self.create_artists(legend, orig_handle, xdescent, ydescent, width, height, - fontsize, handlebox.get_transform()) + fontsize, transform) # create_artists will return a list of artists. for a in artists: @@ -407,6 +415,12 @@ def create_artists(self, legend, orig_handle, self.update_prop(p, orig_handle, legend) p._transOffset = trans + if trans.is_affine: + # trans has only been applied to offset so far. + # Manually apply rotation and scaling to p + m = trans.get_matrix().copy() + m[:2, 2] = 0 + p.set_transform(Affine2D(m)) return [p] diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 508f11cbd286..3a1a4d04b4ad 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2854,6 +2854,18 @@ def ylabel(ylabel, fontdict=None, labelpad=None, **kwargs): ylabel, fontdict=fontdict, labelpad=labelpad, **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@docstring.copy(Axes.set_xlabel_legend) +def xlabel_legend(handle, **kwargs): + return gca().set_xlabel_legend(handle, **kwargs) + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@docstring.copy(Axes.set_ylabel_legend) +def ylabel_legend(handle, **kwargs): + return gca().set_ylabel_legend(handle, **kwargs) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @docstring.copy(Axes.set_xscale) def xscale(value, **kwargs): diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png b/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png new file mode 100644 index 000000000000..0bf5921433d5 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index d65906fdcf92..e13536f8bd76 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -45,6 +45,26 @@ def test_get_labels(): assert ax.get_ylabel() == 'y label' +@image_comparison(['label_legend.png'], style='mpl20') +def test_label_legends(): + fig, ax1 = plt.subplots() + + line1, _, _ = ax1.bar([0, 1, 2], [0, 1, 2], color='gray') + ax1.set_xlabel('X label 1') + ax1.set_ylabel('A very long Y label which requires\nmultiple lines') + ax1.set_ylabel_legend(line1) + + ax2 = ax1.twinx() + line2, = ax2.plot([0, 1, 2], [20, 10, 0], color='red') + ax2.set_ylabel('Y label 2') + ax2.set_ylabel_legend(line2) + + ax3 = ax2.twiny() + line3, = ax3.plot([100, 200, 300], [20, 5, 10], marker='D') + ax3.set_xlabel('X label 2') + ax3.set_xlabel_legend(line3) + + @image_comparison(['acorr.png'], style='mpl20') def test_acorr(): # Remove this line when this test image is regenerated. diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 3fd248bcc586..8795bf01e996 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -77,8 +77,8 @@ def _get_textbox(text, renderer): _, parts, d = text._get_layout(renderer) - for t, wh, x, y in parts: - w, h = wh + for t, whd, x, y in parts: + w, h, _ = whd xt1, yt1 = tr.transform((x, y)) yt1 -= d @@ -269,7 +269,7 @@ def update_from(self, other): self._linespacing = other._linespacing self.stale = True - def _get_layout(self, renderer): + def _get_layout(self, renderer, firstline_indent=0): """ return the extent (bbox) of the text together with multiple-alignment information. Note that it returns an extent @@ -284,6 +284,7 @@ def _get_layout(self, renderer): ws = [] hs = [] + ds = [] xs = [] ys = [] @@ -307,8 +308,8 @@ def _get_layout(self, renderer): h = max(h, lp_h) d = max(d, lp_d) - ws.append(w) hs.append(h) + ds.append(d) # Metrics of the last line that are needed later: baseline = (h - d) - thisy @@ -316,11 +317,16 @@ def _get_layout(self, renderer): if i == 0: # position at baseline thisy = -(h - d) + # reserve some space for the legend symbol + w += firstline_indent + thisx = firstline_indent else: # put baseline a good distance from bottom of previous line thisy -= max(min_dy, (h - d) * self._linespacing) + thisx = 0.0 - xs.append(thisx) # == 0. + ws.append(w) + xs.append(thisx) ys.append(thisy) thisy -= d @@ -422,7 +428,9 @@ def _get_layout(self, renderer): # now rotate the positions around the first (x, y) position xys = M.transform(offset_layout) - (offsetx, offsety) - ret = bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent + ret = (bbox, + list(zip(lines, zip(ws, hs, ds), *xys.T)), + descent) self._cached[key] = ret return ret @@ -707,7 +715,7 @@ def draw(self, renderer): angle = textobj.get_rotation() - for line, wh, x, y in info: + for line, whd, x, y in info: mtext = textobj if len(info) == 1 else None x = x + posx diff --git a/tools/boilerplate.py b/tools/boilerplate.py index cafb850b44af..fc409f478f9c 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -260,6 +260,8 @@ def boilerplate_gen(): 'title:set_title', 'xlabel:set_xlabel', 'ylabel:set_ylabel', + 'xlabel_legend:set_xlabel_legend', + 'ylabel_legend:set_ylabel_legend', 'xscale:set_xscale', 'yscale:set_yscale', ) diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index ec1861cfe6cf..1f46ef7a5886 100644 --- a/tutorials/intermediate/legend_guide.py +++ b/tutorials/intermediate/legend_guide.py @@ -165,7 +165,7 @@ # appropriate :class:`~matplotlib.legend_handler.HandlerBase` subclass. # The choice of handler subclass is determined by the following rules: # -# 1. Update :func:`~matplotlib.legend.Legend.get_legend_handler_map` +# 1. Update :func:`~matplotlib.legend.LegendConfig.get_legend_handler_map` # with the value in the ``handler_map`` keyword. # 2. Check if the ``handle`` is in the newly created ``handler_map``. # 3. Check if the type of ``handle`` is in the newly created @@ -174,7 +174,7 @@ # created ``handler_map``. # # For completeness, this logic is mostly implemented in -# :func:`~matplotlib.legend.Legend.get_legend_handler`. +# :func:`~matplotlib.legend.LegendConfig.get_legend_handler`. # # All of this flexibility means that we have the necessary hooks to implement # custom handlers for our own type of legend key.