diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 9094dc5a1bf2..a8fd08d0b03b 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -244,12 +244,23 @@ def _update_bbox_to_anchor(self, loc_in_canvas): Default is ``None``, which will take the value from :rc:`legend.labelspacing`. +scalehandlebox : None or bool + Control whether to scale each individual handlebox up to fit it's handle. + Default is ``None``, which will take the value from + :rc:`legend.scalehandlebox`. + handlelength : float or None The length of the legend handles. Measured in font-size units. Default is ``None``, which will take the value from :rc:`legend.handlelength`. +handleheight : float or None + The height of the legend handles. + Measured in font-size units. + Default is ``None``, which will take the value from + :rc:`legend.handleheight`. + handletextpad : float or None The pad between the legend handle and text. Measured in font-size units. @@ -317,6 +328,7 @@ def __init__(self, parent, handles, labels, borderpad=None, # the whitespace inside the legend border labelspacing=None, # the vertical space between the legend # entries + scalehandlebox=None, # scale handlebox to fit handle handlelength=None, # the length of the legend handles handleheight=None, # the height of the legend handles handletextpad=None, # the pad between the legend handle @@ -404,9 +416,9 @@ def __init__(self, parent, handles, labels, locals_view = locals() for name in ["numpoints", "markerscale", "shadow", "columnspacing", - "scatterpoints", "handleheight", 'borderpad', - 'labelspacing', 'handlelength', 'handletextpad', - 'borderaxespad']: + "scatterpoints", "scalehandlebox", "handleheight", + 'handlelength', 'borderpad', 'labelspacing', + 'handletextpad', 'borderaxespad']: if locals_view[name] is None: value = rcParams["legend." + name] else: @@ -746,6 +758,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): 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 + width = self.handlelength * fontsize # 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. @@ -773,7 +786,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): textbox = TextArea(lab, textprops=label_prop, multilinebaseline=True, minimumdescent=True) - handlebox = DrawingArea(width=self.handlelength * fontsize, + handlebox = DrawingArea(width=width, height=height, xdescent=0., ydescent=descent) diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 84405f2cbbc4..22f2b62a0e98 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -84,6 +84,58 @@ def adjust_drawing_area(self, legend, orig_handle, height = height - self._ypad * fontsize return xdescent, ydescent, width, height + def _scale_dimensions(self, legend, handlebox, orig_handle): + ''' + Scales up handlebox dimensions to fit orig_handle + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle + handlebox: :class:`matplotlib.offsetbox.DrawingArea` + The drawing area that is to be scaled + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + width = max(handlebox.width, self.handle_width(legend, orig_handle)) + height = max(handlebox.height, self.handle_height(legend, orig_handle)) + handlebox.set_width(width) + handlebox.set_height(height) + + def handle_width(self, legend, orig_handle): + ''' + Overriden in children classes if orig_handle could be larger + than the default DrawingArea height, returns the height of orig_handle. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + return -1 + + def handle_height(self, legend, orig_handle): + ''' + Overriden in children classes if orig_handle could be larger + than the default DrawingArea height, returns the height of orig_handle. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + return -1 + def legend_artist(self, legend, orig_handle, fontsize, handlebox): """ @@ -105,6 +157,8 @@ def legend_artist(self, legend, orig_handle, be added to this handlebox inside this method. """ + if legend.scalehandlebox: + self._scale_dimensions(legend, handlebox, orig_handle) xdescent, ydescent, width, height = self.adjust_drawing_area( legend, orig_handle, handlebox.xdescent, handlebox.ydescent, @@ -113,7 +167,6 @@ def legend_artist(self, legend, orig_handle, artists = self.create_artists(legend, orig_handle, xdescent, ydescent, width, height, fontsize, handlebox.get_transform()) - # create_artists will return a list of artists. for a in artists: handlebox.add_artist(a) @@ -224,6 +277,54 @@ def __init__(self, marker_pad=0.3, numpoints=None, **kw): HandlerNpoints.__init__(self, marker_pad=marker_pad, numpoints=numpoints, **kw) + def handle_width(self, legend, orig_handle): + ''' + If *orig_handle* contains a marker, returns markersize multiplied by + legend.marker_scale, if the marker is not significantly + larger in width, do nothing. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + + marker = orig_handle.get_marker() + marker_size = orig_handle.get_markersize() + if marker and marker_size > 0: + if legend.markerscale != 1: + marker_size = marker_size * legend.markerscale + return marker_size + return -1 + + def handle_height(self, legend, orig_handle): + ''' + If *orig_handle* contains a marker, returns markersize multiplied by + legend.marker_scale, if the marker is not significantly + larger in height, do nothing. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + + marker = orig_handle.get_marker() + marker_size = orig_handle.get_markersize() + if marker and marker_size > 0: + if legend.markerscale != 1: + marker_size = marker_size * legend.markerscale + return marker_size + return -1 + def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): @@ -305,6 +406,12 @@ class HandlerLineCollection(HandlerLine2D): """ Handler for `.LineCollection` instances. """ + def handle_width(self, legend, orig_handle): + return -1 + + def handle_height(self, legend, orig_handle): + return -1 + def get_numpoints(self, legend): if self._numpoints is None: return legend.scatterpoints @@ -437,6 +544,12 @@ class HandlerErrorbar(HandlerLine2D): """ Handler for Errorbars. """ + def handle_width(self, legend, orig_handle): + return -1 + + def handle_height(self, legend, orig_handle): + return -1 + def __init__(self, xerr_size=0.5, yerr_size=None, marker_pad=0.3, numpoints=None, **kw): @@ -499,8 +612,8 @@ def create_artists(self, legend, orig_handle, handle_caplines = [] if orig_handle.has_xerr: - verts = [ ((x - xerr_size, y), (x + xerr_size, y)) - for x, y in zip(xdata_marker, ydata_marker)] + verts = [((x - xerr_size, y), (x + xerr_size, y)) + for x, y in zip(xdata_marker, ydata_marker)] coll = mcoll.LineCollection(verts) self.update_prop(coll, barlinecols[0], legend) handle_barlinecols.append(coll) @@ -517,8 +630,8 @@ def create_artists(self, legend, orig_handle, handle_caplines.append(capline_right) if orig_handle.has_yerr: - verts = [ ((x, y - yerr_size), (x, y + yerr_size)) - for x, y in zip(xdata_marker, ydata_marker)] + verts = [((x, y - yerr_size), (x, y + yerr_size)) + for x, y in zip(xdata_marker, ydata_marker)] coll = mcoll.LineCollection(verts) self.update_prop(coll, barlinecols[0], legend) handle_barlinecols.append(coll) @@ -652,6 +765,48 @@ def __init__(self, ndivide=1, pad=None, **kwargs): self._pad = pad HandlerBase.__init__(self, **kwargs) + def handle_width(self, legend, orig_handle): + ''' + Returns width of largest handle in *orig_handle* tuple. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + handler_map = legend.get_legend_handler_map() + largest_width = -1 + for handle1 in orig_handle: + handler = legend.get_legend_handler(handler_map, handle1) + largest_width = max(largest_width, + handler.handle_width(legend, handle1)) + return largest_width + + def handle_height(self, legend, orig_handle): + ''' + Returns width of largest handle in *orig_handle* tuple. + + Parameters + ---------- + legend : :class:`matplotlib.legend.Legend` instance + The legend that will contain the orig_handle. + orig_handle : :class:`matplotlib.artist.Artist` or similar + The object that the output width and height must be large + enough to fit. + + ''' + handler_map = legend.get_legend_handler_map() + largest_height = -1 + for handle1 in orig_handle: + handler = legend.get_legend_handler(handler_map, handle1) + largest_height = max(largest_height, + handler.handle_height(legend, handle1)) + return largest_height + def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index e17ffcc24bbf..08ea80354f79 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1247,6 +1247,8 @@ def _validate_linestyle(ls): 'legend.borderpad': [0.4, validate_float], # units are fontsize # the vertical space between the legend entries 'legend.labelspacing': [0.5, validate_float], + # whether or not to scale handlebox to fit handle + 'legend.scalehandlebox': [True, validate_bool], # the length of the legend lines 'legend.handlelength': [2., validate_float], # the length of the legend lines diff --git a/lib/matplotlib/tests/baseline_images/test_cycles/marker_cycle.png b/lib/matplotlib/tests/baseline_images/test_cycles/marker_cycle.png index 160b68b8bf3a..b65edfa13351 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_cycles/marker_cycle.png and b/lib/matplotlib/tests/baseline_images/test_cycles/marker_cycle.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_legend/legend_large_marker_in_tuple.png b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_marker_in_tuple.png new file mode 100644 index 000000000000..cd2e2ebccb8c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_marker_in_tuple.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markers.png b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markers.png new file mode 100644 index 000000000000..0cfda41585f4 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markers.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markerscale.png b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markerscale.png new file mode 100644 index 000000000000..82a9dc135b6c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_legend/legend_large_markerscale.png differ diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 830fe798c44c..a016d9b49d0d 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -488,3 +488,38 @@ def test_legend_title_empty(): leg = ax.legend() assert leg.get_title().get_text() == "" assert leg.get_title().get_visible() is False + + +@image_comparison(baseline_images=['legend_large_markers'], + extensions=['png'], style='mpl20') +def test_large_markers(): + # test that legend scales to fit markers with large markersize + plt.figure() + a, = plt.plot([1], [1], 's', markersize=40.) + b, = plt.plot([2], [1], '*', markersize=40.) + plt.xticks([], []) + plt.yticks([], []) + plt.legend([a, b], ["big square", "big star"], loc=2, numpoints=1) + + +@image_comparison(baseline_images=['legend_large_markerscale'], + extensions=['png'], style='mpl20') +def test_large_markerscale(): + # test that legend scales to fit markers with large markerscale + plt.figure() + a, = plt.plot([1], [1], 's') + plt.xticks([], []) + plt.yticks([], []) + plt.legend([a], ["big markerscale"], markerscale=20., loc=2, numpoints=1) + + +@image_comparison(baseline_images=['legend_large_marker_in_tuple'], + extensions=['png'], style='mpl20') +def test_large_marker_in_tuple(): + # test that legend scales to fit large markers in tuple + plt.figure() + a, = plt.plot([1], [1], "ro", c="green", markersize=15) + b, = plt.plot([1], [1], "w+", c="red", markeredgewidth=3, markersize=40) + plt.xticks([], []) + plt.yticks([], []) + plt.legend([(a, b)], ["a and b"], loc=2, numpoints=1) diff --git a/matplotlibrc.template b/matplotlibrc.template index 0b99a98ade91..8196c74174cc 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -416,6 +416,7 @@ backend : $TEMPLATE_BACKEND ## Dimensions as fraction of fontsize: #legend.borderpad : 0.4 ## border whitespace #legend.labelspacing : 0.5 ## the vertical space between the legend entries +#legend.scalehandlebox : True ## whether or not to scale handlebox to fit handle #legend.handlelength : 2.0 ## the length of the legend lines #legend.handleheight : 0.7 ## the height of the legend handle #legend.handletextpad : 0.8 ## the space between the legend line and legend text