diff --git a/doc/users/legend_guide.rst b/doc/users/legend_guide.rst index 7afcf0bd1cb6..91c507c28d61 100644 --- a/doc/users/legend_guide.rst +++ b/doc/users/legend_guide.rst @@ -28,39 +28,43 @@ For example, *ax.legend()* is equivalent to:: The :meth:`~matplotlib.axes.Axes.get_legend_handles_labels` method returns a tuple of two lists, i.e., list of artists and list of labels (python string). However, it does not return all of its child -artists. It returns all artists in *ax.lines* and *ax.patches* and -some artists in *ax.collection* which are instance of +artists. It returns artists that are currently supported by matplotlib. + +For matplotlib v1.0 and before, the supported artists are as follows. + + * :class:`~matplotlib.lines.Line2D` + * :class:`~matplotlib.patches.Patch` + * :class:`~matplotlib.collections.LineCollection` + * :class:`~matplotlib.collections.RegularPolyCollection` + +And, :meth:`~matplotlib.axes.Axes.get_legend_handles_labels` returns +all artists in *ax.lines*, *ax.patches* and +artists in *ax.collection* which are instance of :class:`~matplotlib.collections.LineCollection` or :class:`~matplotlib.collections.RegularPolyCollection`. The label attributes (returned by get_label() method) of collected artists are used as text labels. If label attribute is empty string or starts with -"_", that artist will be ignored. +"_", those artists will be ignored. - * Note that not all kind of artists are supported by the legend. The - following is the list of artists that are currently supported. +Therefore, plots drawn by some *pyplot* commands are not supported by +legend. For example, :func:`~matplotlib.pyplot.fill_between` creates +:class:`~matplotlib.collections.PolyCollection` that is not +supported. Also support is limted for some commands that creat +multiple artists. For example, :func:`~matplotlib.pyplot.errorbar` +creates multiples :class:`~matplotlib.lines.Line2D` instances. - * :class:`~matplotlib.lines.Line2D` - * :class:`~matplotlib.patches.Patch` - * :class:`~matplotlib.collections.LineCollection` - * :class:`~matplotlib.collections.RegularPolyCollection` +Unfortunately, there is no easy workaround when you need legend for an +artist not supported by matplotlib (You may use one of the supported +artist as a proxy. See below) - Unfortunately, there is no easy workaround when you need legend for - an artist not in the above list (You may use one of the supported - artist as a proxy. See below), or customize it beyond what is - supported by :class:`matplotlib.legend.Legend`. +In newer version of matplotlib (v1.1 and later), the matplotlib +internals are revised to support - * Remember that some *pyplot* commands return artist not supported by - legend, e.g., :func:`~matplotlib.pyplot.fill_between` returns - :class:`~matplotlib.collections.PolyCollection` that is not - supported. Or some return multiple artists. For example, - :func:`~matplotlib.pyplot.plot` returns list of - :class:`~matplotlib.lines.Line2D` instances, and - :func:`~matplotlib.pyplot.errorbar` returns a length 3 tuple of - :class:`~matplotlib.lines.Line2D` instances. + * complex plots that creates multiple artists (e.g., bar, errorbar, etc) + * custom legend handles - * The legend does not care about the axes that given artists belongs, - i.e., the artists may belong to other axes or even none. +See below for details of new functionality. Adjusting the Order of Legend items @@ -180,3 +184,132 @@ legend. .. plot:: users/plotting/examples/simple_legend02.py :include-source: + + +Legend of Complex Plots +======================= + +In matplotlib v1.1 (FIXME when released) and later, the legend is +improved to support more plot commands and ease the customization. + +Artist Container +---------------- + +The Artist Container is simple class (derived from tuple) that +contains multiple artists. This is introduced primarily to support +legends for complex plot commands that create multiple artists. + +Axes instances now have a "containers" attribute (which is a list, and +this is only intended to be used for generating a legend). The items +in this attribute are also returned by +:meth:`~matplotlib.axes.Axes.get_legend_handles_labels`. + +For example, "bar" command creates a series of Rectangle +patches. Previously, it returned a list of these patches. With the +current change, it creates a container object of these rectangle +patches (and these patches are added to Axes.patches attribute as +before) and return it instead. As the container class is derived from +a tuple, it should be backward-compatible. Furthermore, the container +object is added to the Axes.containers attributes so that legend +command can properly create a legend for the bar. Thus, you may do :: + + b1 = bar([0, 1, 2], [0.2, 0.3, 0.1], width=0.4, + label="Bar 1", align="center") + legend() + +or :: + + b1 = bar([0, 1, 2], [0.2, 0.3, 0.1], width=0.4, align="center") + legend([b1], ["Bar 1"]) + + +At this time of writing, however, "bar" and "errorbar" are only +supported (hopefully the list will increase). Here is an example. + +.. plot:: mpl_examples/pylab_examples/legend_demo4.py + +Legend Handler +-------------- + +One of the change is that drawing of legend handles is delegated to +legend handlers. For example, :class:`~matplotlib.lines.Line2D` +instances are handled by +:class:`~matplotlib.legend_handler.HandlerLine2D`. The mapping +between the artists and their corresponding handlers are defined in a +handler_map of the legend. The handler_map is a dictionary of +key-handler pair, where key can be an artist instance or its +class. And the handler is a Handler instance. + +Let's consider the following sample code, :: + + legend([p_1, p_2,..., p_i, ...], ["Test 1", "Test 2", ..., "Test i",...]) + +For each *p_i*, matplotlib + + 1. check if *p_i* itself is in the handler_map + 2. if not, iterate over type(p_i).mro() until a matching key is found in the handler_map + + +Unless specified, the defaul handler_map is used. Below is a partial +list of key-handler pairs included in the default handler map. + + * Line2D : legend_handler.HandlerLine2D() + * Patch : legend_handler.HandlerPatch() + * LineCollection : legend_handler.HandlerLineCollection() + * ... + + +The legend command takes an optional argument of "handler_map". When +provided, the default handler map will be updated (using dict.update +method) with the provided one. :: + + p1, = plot(x, "ro", label="test1") + p2, = plot(y, "b+", ms=10, label="test2") + + my_handler = HandlerLine2D(numpoints=1) + + legend(handler_map={Line2D:my_handler}) + +The above example will use *my_handler* for any Line2D +instances (p1 and p2). :: + + legend(handler_map={p1:HandlerLine2D(numpoints=1)}) + +In the above example, only *p1* will be handled by *my_handler*, while +others will be handled by default handlers. + +The curent default handler_map has handlers for errobar and bar +plots. Also, it includes an entry for ˇ°tupleˇ± which is mapped to +*HandlerTuple*. It simply overplots all the handles for items in the +given tuple. For example, + + +.. plot:: + :include-source: + + z = np.random.randn(10) + + p1a, = plt.plot(z, "ro", ms=10, mfc="r", mew=2, mec="r") # red filled circle + p1b, = plt.plot(z[:5], "w+", ms=10, mec="w", mew=2) # white cross + + plt.legend([p1a, (p1a, p1b)], ["Attr A", "Attr A+B"]) + + + +Implement a Custom Handler +-------------------------- + +Handler can be any callable object with following signature. :: + + def __call__(self, legend, orig_handle, + fontsize, + handlebox): + +Where *legend* is the legend itself, *orig_handle* is the original +plot (*p_i* in the above example), *fontsize* is the fontsize in +pixles, and *handlebox* is a OffsetBox instance. Within the call, you +create relevant artists (using relevant properties from the *legend* +and/or *orig_handle*) and add them into the handlebox. The artists +needs to be scaled according to the fontsize (note that the size is in +pixel, i.e., this is dpi-scaled value). See legend_handler.py for more +details. diff --git a/examples/pylab_examples/legend_demo4.py b/examples/pylab_examples/legend_demo4.py new file mode 100644 index 000000000000..a6a5e958506a --- /dev/null +++ b/examples/pylab_examples/legend_demo4.py @@ -0,0 +1,22 @@ +import matplotlib.pyplot as plt + +ax = plt.subplot(111) + +b1 = ax.bar([0, 1, 2], [0.2, 0.3, 0.1], width=0.4, + label="Bar 1", align="center") + +b2 = ax.bar([0.5, 1.5, 2.5], [0.3, 0.2, 0.2], color="red", width=0.4, + label="Bar 2", align="center") + +err1 = ax.errorbar([0, 1, 2], [2, 3, 1], xerr=0.4, fmt="s", + label="test 1") +err2 = ax.errorbar([0, 1, 2], [3, 2, 4], yerr=0.3, fmt="o", + label="test 2") +err3 = ax.errorbar([0, 1, 2], [1, 1, 3], xerr=0.4, yerr=0.3, fmt="^", + label="test 3") + +# legend +leg1 = plt.legend(loc=1) + +plt.show() + diff --git a/examples/pylab_examples/legend_demo_custom_handler.py b/examples/pylab_examples/legend_demo_custom_handler.py new file mode 100644 index 000000000000..4d5e9e59def3 --- /dev/null +++ b/examples/pylab_examples/legend_demo_custom_handler.py @@ -0,0 +1,30 @@ +import matplotlib.pyplot as plt +import numpy as np + +ax = plt.subplot(111, aspect=1) +x, y = np.random.randn(2, 20) +#[1.1, 2, 2.8], [1.1, 2, 1.8] +l1, = ax.plot(x,y, "k+", mew=3, ms=12) +l2, = ax.plot(x,y, "w+", mew=1, ms=10) + +import matplotlib.patches as mpatches +c = mpatches.Circle((0, 0), 1, fc="g", ec="r", lw=3) +ax.add_patch(c) + + + +from matplotlib.legend_handler import HandlerPatch + +def make_legend_ellipse(legend, orig_handle, + xdescent, ydescent, + width, height, fontsize): + p = mpatches.Ellipse(xy=(0.5*width-0.5*xdescent, 0.5*height-0.5*ydescent), + width = width+xdescent, height=(height+ydescent)) + + return p + +plt.legend([c, (l1, l2)], ["Label 1", "Label 2"], + handler_map={mpatches.Circle:HandlerPatch(patch_func=make_legend_ellipse), + }) + +plt.show() diff --git a/lib/matplotlib/axes.py b/lib/matplotlib/axes.py index f47eaa447896..c049aba3c16c 100644 --- a/lib/matplotlib/axes.py +++ b/lib/matplotlib/axes.py @@ -34,6 +34,7 @@ import matplotlib.transforms as mtransforms import matplotlib.tri as mtri +from matplotlib.container import BarContainer, ErrorbarContainer iterable = cbook.iterable is_string_like = cbook.is_string_like @@ -868,6 +869,7 @@ def cla(self): self._current_image = None # strictly for pyplot via _sci, _gci self.legend_ = None self.collections = [] # collection.Collection instances + self.containers = [] # self.grid(self._gridOn) props = font_manager.FontProperties(size=rcParams['axes.titlesize']) @@ -1494,6 +1496,21 @@ def add_table(self, tab): tab._remove_method = lambda h: self.tables.remove(h) return tab + def add_container(self, container): + ''' + Add a :class:`~matplotlib.container.Container` instance + to the axes. + + Returns the collection. + ''' + label = container.get_label() + if not label: + container.set_label('_container%d'%len(self.containers)) + self.containers.append(container) + container.set_remove_method(lambda h: self.containers.remove(container)) + return container + + def relim(self): """ Recompute the data limits based on current artists. @@ -4182,20 +4199,32 @@ def xcorr(self, x, y, normed=True, detrend=mlab.detrend_none, return lags, c, a, b - def _get_legend_handles(self): + def _get_legend_handles(self, legend_handler_map=None): "return artists that will be used as handles for legend" - handles = self.lines[:] - handles.extend(self.patches) - handles.extend([c for c in self.collections - if isinstance(c, mcoll.LineCollection)]) - handles.extend([c for c in self.collections - if isinstance(c, mcoll.RegularPolyCollection)]) - handles.extend([c for c in self.collections - if isinstance(c, mcoll.CircleCollection)]) + handles_original = self.lines + self.patches + \ + self.collections + self.containers + + # collections + 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) + + #collection_types = [cls for cls in legend_map_keys \ + # if issubclass(cls, mcoll.Collection)] + + handles = [] + for h in handles_original: + if h.get_label() == "_nolegend_": #.startswith('_'): + continue + if mlegend.Legend.get_legend_handler(handler_map, h): + handles.append(h) + return handles - def get_legend_handles_labels(self): + def get_legend_handles_labels(self, legend_handler_map=None): """ return handles and labels for legend @@ -4208,10 +4237,10 @@ def get_legend_handles_labels(self): handles = [] labels = [] - for handle in self._get_legend_handles(): + for handle in self._get_legend_handles(legend_handler_map): label = handle.get_label() - if (label is not None and - label != '' and not label.startswith('_')): + #if (label is not None and label != '' and not label.startswith('_')): + if label and not label.startswith('_'): handles.append(handle) labels.append(label) @@ -4359,6 +4388,9 @@ def legend(self, *args, **kwargs): columnspacing the spacing between columns ================ ================================================================== + .. Note:: Not all kinds of artist are supported by the legend command. + See LINK (FIXME) for details. + **Example:** @@ -4400,7 +4432,10 @@ def legend(self, *args, **kwargs): raise TypeError('Invalid arguments to legend') - handles = cbook.flatten(handles) + # Why do we need to call "flatten" here? -JJL + # handles = cbook.flatten(handles) + + self.legend_ = mlegend.Legend(self, handles, labels, **kwargs) return self.legend_ @@ -4661,9 +4696,8 @@ def make_iterable(x): facecolor=c, edgecolor=e, linewidth=lw, - label=label + label='_nolegend_' ) - label = '_nolegend_' r.update(kwargs) r.get_path()._interpolation_steps = 100 #print r.get_label(), label, 'label' in kwargs @@ -4684,10 +4718,14 @@ def make_iterable(x): x = [l+w for l,w in zip(left, width)] y = [b+0.5*h for b,h in zip(bottom, height)] - self.errorbar( - x, y, - yerr=yerr, xerr=xerr, - fmt=None, **error_kw) + if "label" not in error_kw: + error_kw["label"] = '_nolegend_' + + errorbar = self.errorbar(x, y, + yerr=yerr, xerr=xerr, + fmt=None, **error_kw) + else: + errorbar = None self.hold(holdstate) # restore previous hold state @@ -4707,7 +4745,11 @@ def make_iterable(x): ymin = max(ymin*0.9, 1e-100) self.dataLim.intervaly = (ymin, ymax) self.autoscale_view() - return patches + + bar_container = BarContainer(patches, errorbar, label=label) + self.add_container(bar_container) + + return bar_container @docstring.dedent_interpd def barh(self, bottom, width, height=0.8, left=None, **kwargs): @@ -5109,6 +5151,8 @@ def errorbar(self, x, y, yerr=None, xerr=None, holdstate = self._hold self._hold = True + label = kwargs.pop("label", None) + # make sure all the args are iterable; use lists not arrays to # preserve units if not iterable(x): @@ -5128,7 +5172,7 @@ def errorbar(self, x, y, yerr=None, xerr=None, l0 = None if barsabove and fmt is not None: - l0, = self.plot(x,y,fmt,**kwargs) + l0, = self.plot(x,y,fmt,label="_nolegend_", **kwargs) barcols = [] caplines = [] @@ -5281,7 +5325,14 @@ def xywhere(xs, ys, mask): self.autoscale_view() self._hold = holdstate - return (l0, caplines, barcols) + + errorbar_container = ErrorbarContainer((l0, tuple(caplines), tuple(barcols)), + has_xerr=(xerr is not None), + has_yerr=(yerr is not None), + label=label) + self.containers.append(errorbar_container) + + return errorbar_container # (l0, caplines, barcols) def boxplot(self, x, notch=0, sym='b+', vert=1, whis=1.5, positions=None, widths=None, patch_artist=False, diff --git a/lib/matplotlib/container.py b/lib/matplotlib/container.py new file mode 100644 index 000000000000..0993fa236d51 --- /dev/null +++ b/lib/matplotlib/container.py @@ -0,0 +1,103 @@ +import matplotlib.cbook as cbook + +class Container(tuple): + """ + Base class for containers. + """ + + def __repr__(self): + return "" % (len(self)) + + def __new__(cls, *kl, **kwargs): + return tuple.__new__(cls, kl[0]) + + def __init__(self, kl, label=None): + + self.eventson = False # fire events only if eventson + self._oid = 0 # an observer id + self._propobservers = {} # a dict from oids to funcs + + self._remove_method = None + + self.set_label(label) + + def set_remove_method(self, f): + self._remove_method = f + + def remove(self): + for c in self: + c.remove() + + if self._remove_method: + self._remove_method() + + def get_label(self): + """ + Get the label used for this artist in the legend. + """ + return self._label + + def set_label(self, s): + """ + Set the label to *s* for auto legend. + + ACCEPTS: any string + """ + self._label = s + self.pchanged() + + def add_callback(self, func): + """ + Adds a callback function that will be called whenever one of + the :class:`Artist`'s properties changes. + + Returns an *id* that is useful for removing the callback with + :meth:`remove_callback` later. + """ + oid = self._oid + self._propobservers[oid] = func + self._oid += 1 + return oid + + def remove_callback(self, oid): + """ + Remove a callback based on its *id*. + + .. seealso:: + + :meth:`add_callback` + For adding callbacks + + """ + try: del self._propobservers[oid] + except KeyError: pass + + def pchanged(self): + """ + Fire an event when property changed, calling all of the + registered callbacks. + """ + for oid, func in self._propobservers.items(): + func(self) + + def get_children(self): + return list(cbook.flatten(self)) + + +class BarContainer(Container): + + def __init__(self, patches, errorbar=None, **kwargs): + self.patches = patches + self.errorbar = errorbar + Container.__init__(self, patches, **kwargs) + + +class ErrorbarContainer(Container): + + def __init__(self, lines, has_xerr=False, has_yerr=False, **kwargs): + self.lines = lines + self.has_xerr = has_xerr + self.has_yerr = has_yerr + Container.__init__(self, lines, **kwargs) + + diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 57ab2c4328c3..4e111c79c662 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -973,6 +973,8 @@ def legend(self, handles, labels, *args, **kwargs): columnspacing the spacing between columns ================ ================================================================== + .. Note:: Not all kinds of artist are supported by the legend. + See LINK (FIXME) for details. **Example:** diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 9a838fd8242a..3ab0599fac0c 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -1,24 +1,15 @@ """ -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 axis legends) - '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, - -Return value is a sequence of text, line instances that make -up the legend +The legend module defines the Legend class, which is responsible for +drawing legends associated with axes and/or figures. + +The Legend class can be considered as a container of legend handles +and legend texts. Creation of corresponding legend handles from the +plot elements in the axes or figures (e.g., lines, patches, etc.) are +specified by the handler map, which defines the mapping between the +plot elements and the legend handlers to be used (the default legend +handlers are defined in the matplotlib.legend_handler module). Note +that not all kinds of artist are supported by the legend yet (See LINK +(FIXME) for details). """ from __future__ import division import warnings @@ -37,6 +28,9 @@ from matplotlib.offsetbox import HPacker, VPacker, TextArea, DrawingArea, DraggableOffsetBox +from matplotlib.container import ErrorbarContainer, BarContainer +import legend_handler + class DraggableLegend(DraggableOffsetBox): def __init__(self, legend, use_blit=False, update="loc"): @@ -67,7 +61,7 @@ def finalize_offset(self): self._update_bbox_to_anchor(loc_in_canvas) else: raise RuntimeError("update parameter '%s' is not supported." % self.update) - + def _update_loc(self, loc_in_canvas): bbox = self.legend.get_bbox_to_anchor() @@ -76,7 +70,7 @@ def _update_loc(self, loc_in_canvas): if bbox.width ==0 or bbox.height ==0: self.legend.set_bbox_to_anchor(None) bbox = self.legend.get_bbox_to_anchor() - + _bbox_transform = BboxTransformFrom(bbox) self.legend._loc = tuple(_bbox_transform.transform_point(loc_in_canvas)) @@ -112,8 +106,6 @@ class Legend(Artist): loc can be a tuple of the noramilzed coordinate values with respect its parent. - Return value is a sequence of text, line instances that make - up the legend """ @@ -167,10 +159,11 @@ 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 = True, # draw frame + handler_map = None, ): """ - *parent* : the artist that contains the legend - - *handles* : a list of artists (lines, patches) to add to the legend + - *handles* : a list of artists (lines, patches) to be added to the legend - *labels* : a list of strings to label the legend Optional keyword arguments: @@ -200,7 +193,7 @@ def __init__(self, parent, handles, labels, ================ ================================================================== -The pad and spacing parameters are measure in font-size units. E.g., +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. @@ -236,6 +229,9 @@ def __init__(self, parent, handles, labels, self.legendHandles = [] self._legend_title_box = None + + self._handler_map = handler_map + localdict = locals() for name in propnames: @@ -470,6 +466,88 @@ def _approx_text_height(self, renderer=None): return renderer.points_to_pixels(self._fontsize) + # _default_handler_map defines the default mapping between plot + # elements and the legend handlers. + + _default_handler_map = { + 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(), + } + + # (get|set|update)_default_handler_maps are public interfaces to + # modify the defalut 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._handler_map: + hm = default_handler_map.copy() + hm.update(self._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_hanler_map* and return the associated value. + Otherwised, it checks for each of the classes in its + method-resolution-order. If no matching key is found, it + returns None. + """ + legend_handler_keys = legend_handler_map.keys() + if orig_handle in legend_handler_keys: + handler = legend_handler_map[orig_handle] + else: + + for handle_type in type(orig_handle).mro(): + if handle_type in legend_handler_map: + handler = legend_handler_map[handle_type] + break + else: + handler = None + + return handler + + def _init_legend_box(self, handles, labels): """ Initiallize the legend_box. The legend_box is an instance of @@ -516,154 +594,37 @@ def _init_legend_box(self, handles, labels): # manually set their transform to the self.get_transform(). - for handle, lab in zip(handles, labels): - if isinstance(handle, RegularPolyCollection) or \ - isinstance(handle, CircleCollection): - npoints = self.scatterpoints - else: - npoints = self.numpoints - if npoints > 1: - # we put some pad here to compensate the size of the - # marker - xdata = np.linspace(0.3*fontsize, - (self.handlelength-0.3)*fontsize, - npoints) - xdata_marker = xdata - elif npoints == 1: - xdata = np.linspace(0, self.handlelength*fontsize, 2) - xdata_marker = [0.5*self.handlelength*fontsize] - - if isinstance(handle, Line2D): - ydata = ((height-descent)/2.)*np.ones(xdata.shape, float) - legline = Line2D(xdata, ydata) - - legline.update_from(handle) - self._set_artist_props(legline) # after update - legline.set_clip_box(None) - legline.set_clip_path(None) - legline.set_drawstyle('default') - legline.set_marker('None') - - handle_list.append(legline) - - legline_marker = Line2D(xdata_marker, ydata[:len(xdata_marker)]) - legline_marker.update_from(handle) - self._set_artist_props(legline_marker) - legline_marker.set_clip_box(None) - legline_marker.set_clip_path(None) - legline_marker.set_linestyle('None') - if self.markerscale !=1: - newsz = legline_marker.get_markersize()*self.markerscale - legline_marker.set_markersize(newsz) - # we don't want to add this to the return list because - # the texts and handles are assumed to be in one-to-one - # correpondence. - legline._legmarker = legline_marker - - elif isinstance(handle, Patch): - p = Rectangle(xy=(0., 0.), - width = self.handlelength*fontsize, - height=(height-descent), - ) - p.update_from(handle) - self._set_artist_props(p) - p.set_clip_box(None) - p.set_clip_path(None) - handle_list.append(p) - elif isinstance(handle, LineCollection): - ydata = ((height-descent)/2.)*np.ones(xdata.shape, float) - legline = Line2D(xdata, ydata) - self._set_artist_props(legline) - legline.set_clip_box(None) - legline.set_clip_path(None) - lw = handle.get_linewidth()[0] - dashes = handle.get_dashes()[0] - color = handle.get_colors()[0] - legline.set_color(color) - legline.set_linewidth(lw) - if dashes[0] is not None: # dashed line - legline.set_dashes(dashes[1]) - handle_list.append(legline) - - elif isinstance(handle, RegularPolyCollection): - - #ydata = self._scatteryoffsets - ydata = height*self._scatteryoffsets - - size_max, size_min = max(handle.get_sizes())*self.markerscale**2,\ - min(handle.get_sizes())*self.markerscale**2 - if self.scatterpoints < 4: - sizes = [.5*(size_max+size_min), size_max, - size_min] - else: - sizes = (size_max-size_min)*np.linspace(0,1,self.scatterpoints)+size_min - - p = type(handle)(handle.get_numsides(), - rotation=handle.get_rotation(), - sizes=sizes, - offsets=zip(xdata_marker,ydata), - transOffset=self.get_transform(), - ) - - p.update_from(handle) - p.set_figure(self.figure) - p.set_clip_box(None) - p.set_clip_path(None) - handle_list.append(p) - - elif isinstance(handle, CircleCollection): - - ydata = height*self._scatteryoffsets - - size_max, size_min = max(handle.get_sizes())*self.markerscale**2,\ - min(handle.get_sizes())*self.markerscale**2 - if self.scatterpoints < 4: - sizes = [.5*(size_max+size_min), size_max, - size_min] - else: - sizes = (size_max-size_min)*np.linspace(0,1,self.scatterpoints)+size_min - - p = type(handle)(sizes, - offsets=zip(xdata_marker,ydata), - transOffset=self.get_transform(), - ) - - p.update_from(handle) - p.set_figure(self.figure) - p.set_clip_box(None) - p.set_clip_path(None) - handle_list.append(p) - else: - handle_type = type(handle) - warnings.warn("Legend does not support %s\nUse proxy artist instead.\n\nhttp://matplotlib.sourceforge.net/users/legend_guide.html#using-proxy-artist\n" % (str(handle_type),)) - handle_list.append(None) + legend_handler_map = self.get_legend_handler_map() + + for orig_handle, lab in zip(handles, labels): + + handler = self.get_legend_handler(legend_handler_map, orig_handle) + if handler is None: + warnings.warn("Legend does not support %s\nUse proxy artist instead.\n\nhttp://matplotlib.sourceforge.net/users/legend_guide.html#using-proxy-artist\n" % (str(orig_handle),)) + handle_list.append(None) + continue + textbox = TextArea(lab, textprops=label_prop, + multilinebaseline=True, minimumdescent=True) + text_list.append(textbox._text) - handle = handle_list[-1] - if handle is not None: # handle is None is the artist is not supproted - textbox = TextArea(lab, textprops=label_prop, - multilinebaseline=True, minimumdescent=True) - text_list.append(textbox._text) + labelboxes.append(textbox) - labelboxes.append(textbox) + handlebox = DrawingArea(width=self.handlelength*fontsize, + height=height, + xdescent=0., ydescent=descent) - handlebox = DrawingArea(width=self.handlelength*fontsize, - height=height, - xdescent=0., ydescent=descent) + handle = handler(self, orig_handle, \ + #xdescent, ydescent, width, height, + fontsize, + handlebox) + handle_list.append(handle) - handlebox.add_artist(handle) - # special case for collection instances - if isinstance(handle, RegularPolyCollection) or \ - isinstance(handle, CircleCollection): - handle._transOffset = handlebox.get_transform() - handle.set_transform(None) - if hasattr(handle, "_legmarker"): - handlebox.add_artist(handle._legmarker) - handleboxes.append(handlebox) + handleboxes.append(handlebox) if len(handleboxes) > 0: diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py new file mode 100644 index 000000000000..3556b48a22d1 --- /dev/null +++ b/lib/matplotlib/legend_handler.py @@ -0,0 +1,493 @@ +""" +This module defines default legend handlers. + +Legend handlers are expected to be a callable object with a following +signature. :: + + legend_handler(legend, orig_handle, fontsize, handlebox) + +Where *legend* is the legend itself, *orig_handle* is the original +plot, *fontsize* is the fontsize in pixles, and *handlebox* is a +OffsetBox instance. Within the call, you should create relevant +artists (using relevant properties from the *legend* and/or +*orig_handle*) and add them into the handlebox. The artists needs to +be scaled according to the fontsize (note that the size is in pixel, +i.e., this is dpi-scaled value). + +This module includes definition of several legend handler classes +derived from the base class (HandlerBase) with a following method. + + def __call__(self, legend, orig_handle, + fontsize, + handlebox): + + +""" + +import numpy as np + +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle +import matplotlib.collections as mcoll +# from matplotlib.collections import LineCollection, RegularPolyCollection, \ +# CircleCollection + +def update_from_first_child(tgt, src): + tgt.update_from(src.get_children()[0]) + + +class HandlerBase(object): + """ + A Base class for default legend handlers. + + The derived classes are meant to override *create_artists* method, which + has a following signatture.:: + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + The overriden method needs to create artists of the given + transform that fits in the given dimension (xdescent, ydescemt, + width, height) that are scaled by fontsize if necessary. + + """ + def __init__(self, xpad=0., ypad=0., update_func=None): + self._xpad, self._ypad = xpad, ypad + self._update_prop_func = update_func + + def _update_prop(self, legend_handle, orig_handle): + if self._update_prop_func is None: + self._default_update_prop(legend_handle, orig_handle) + else: + self._update_prop_func(legend_handle, orig_handle) + + def _default_update_prop(self, legend_handle, orig_handle): + legend_handle.update_from(orig_handle) + + + def update_prop(self, legend_handle, orig_handle, legend): + + self._update_prop(legend_handle, orig_handle) + + legend._set_artist_props(legend_handle) + legend_handle.set_clip_box(None) + legend_handle.set_clip_path(None) + + # make usre that transform is not set since they will be set + # when added to an handlerbox. + legend_handle._transformSet = False + + def adjust_drawing_area(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + ): + xdescent = xdescent-self._xpad*fontsize + ydescent = ydescent-self._ypad*fontsize + width = width-self._xpad*fontsize + height = height-self._ypad*fontsize + return xdescent, ydescent, width, height + + def __call__(self, legend, orig_handle, + fontsize, + handlebox): + """ + x, y, w, h in display coordinate w/ default dpi (72) + fontsize in points + """ + + width, height, xdescent, ydescent = handlebox.width, \ + handlebox.height, \ + handlebox.xdescent, \ + handlebox.ydescent + + xdescent, ydescent, width, height = \ + self.adjust_drawing_area(legend, orig_handle, + xdescent, ydescent, width, height, + fontsize) + + a_list = 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 a_list: + handlebox.add_artist(a) + + # we only return the first artist + return a_list[0] + + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + raise NotImplementedError('Derived must override') + + +class HandlerLine2D(HandlerBase): + """ + Handler for Line2D instances + """ + def __init__(self, marker_pad=0.3, numpoints=None, **kw): + HandlerBase.__init__(self, **kw) + self._marker_pad = marker_pad + self._numpoints = numpoints + + def get_numpoints(self, legend): + if self._numpoints is None: + return legend.numpoints + else: + return self._numpoints + + def get_xdata(self, legend, xdescent, ydescent, width, height, fontsize): + numpoints = self.get_numpoints(legend) + + if numpoints > 1: + # we put some pad here to compensate the size of the + # marker + xdata = np.linspace(-xdescent+self._marker_pad*fontsize, + width-self._marker_pad*fontsize, + numpoints) + xdata_marker = xdata + elif numpoints == 1: + xdata = np.linspace(-xdescent, width, 2) + xdata_marker = [0.5*width-0.5*xdescent] + + return xdata, xdata_marker + + + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, + width, height, fontsize) + + ydata = ((height-ydescent)/2.)*np.ones(xdata.shape, float) + legline = Line2D(xdata, ydata) + + self.update_prop(legline, orig_handle, legend) + #legline.update_from(orig_handle) + #legend._set_artist_props(legline) # after update + #legline.set_clip_box(None) + #legline.set_clip_path(None) + legline.set_drawstyle('default') + legline.set_marker('None') + + + legline_marker = Line2D(xdata_marker, ydata[:len(xdata_marker)]) + self.update_prop(legline_marker, orig_handle, legend) + #legline_marker.update_from(orig_handle) + #legend._set_artist_props(legline_marker) + #legline_marker.set_clip_box(None) + #legline_marker.set_clip_path(None) + legline_marker.set_linestyle('None') + if legend.markerscale !=1: + newsz = legline_marker.get_markersize()*legend.markerscale + legline_marker.set_markersize(newsz) + # we don't want to add this to the return list because + # the texts and handles are assumed to be in one-to-one + # correpondence. + legline._legmarker = legline_marker + + return [legline, legline_marker] + + + +class HandlerPatch(HandlerBase): + """ + Handler for Patches + """ + def __init__(self, patch_func=None, **kw): + HandlerBase.__init__(self, **kw) + + self._patch_func = patch_func + + def _create_patch(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize): + if self._patch_func is None: + p = Rectangle(xy=(-xdescent, -ydescent), + width = width+xdescent, height=(height+ydescent)) + else: + p = self._patch_func(legend=legend, orig_handle=orig_handle, + xdescent=xdescent, ydescent=ydescent, + width=width, height=height, fontsize=fontsize) + + return p + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, trans): + + p = self._create_patch(legend, orig_handle, + xdescent, ydescent, width, height, fontsize) + + self.update_prop(p, orig_handle, legend) + + return [p] + + + +class HandlerLineCollection(HandlerLine2D): + """ + Handler for LineCollections + """ + + def get_numpoints(self, legend): + if self._numpoints is None: + return legend.scatterpoints + else: + return self._numpoints + + def _default_update_prop(self, legend_handle, orig_handle): + lw = orig_handle.get_linewidth()[0] + dashes = orig_handle.get_dashes()[0] + color = orig_handle.get_colors()[0] + legend_handle.set_color(color) + legend_handle.set_linewidth(lw) + if dashes[0] is not None: # dashed line + legend_handle.set_dashes(dashes[1]) + + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, trans): + + xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, + width, height, fontsize) + ydata = ((height-ydescent)/2.)*np.ones(xdata.shape, float) + legline = Line2D(xdata, ydata) + + self.update_prop(legline, orig_handle, legend) + + return [legline] + + + +class HandlerRegularPolyCollection(HandlerLine2D): + """ + Handler for RegularPolyCollections. + """ + def __init__(self, scatteryoffsets=None, sizes=None, **kw): + HandlerLine2D.__init__(self, **kw) + + self._scatteryoffsets = scatteryoffsets + self._sizes = sizes + + def get_numpoints(self, legend): + if self._numpoints is None: + return legend.scatterpoints + else: + return self._numpoints + + def get_ydata(self, legend, xdescent, ydescent, width, height, fontsize): + if self._scatteryoffsets is None: + ydata = height*legend._scatteryoffsets + else: + ydata = height*np.asarray(self._scatteryoffsets) + + return ydata + + def get_sizes(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize): + if self._sizes is None: + size_max = max(orig_handle.get_sizes())*legend.markerscale**2 + size_min = min(orig_handle.get_sizes())*legend.markerscale**2 + + numpoints = self.get_numpoints(legend) + if numpoints < 4: + sizes = [.5*(size_max+size_min), size_max, + size_min] + else: + sizes = (size_max-size_min)*np.linspace(0,1,numpoints)+size_min + else: + sizes = self._sizes #[:legend.scatterpoints] + + return sizes + + def update_prop(self, legend_handle, orig_handle, legend): + + self._update_prop(legend_handle, orig_handle) + + legend_handle.set_figure(legend.figure) + #legend._set_artist_props(legend_handle) + legend_handle.set_clip_box(None) + legend_handle.set_clip_path(None) + + def create_collection(self, orig_handle, sizes, offsets, transOffset): + p = type(orig_handle)(orig_handle.get_numsides(), + rotation=orig_handle.get_rotation(), + sizes=sizes, + offsets=offsets, + transOffset=transOffset, + ) + return p + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + + xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, + width, height, fontsize) + + + ydata = self.get_ydata(legend, xdescent, ydescent, + width, height, fontsize) + + sizes = self.get_sizes(legend, orig_handle, xdescent, ydescent, + width, height, fontsize) + + p = self.create_collection(orig_handle, sizes, + offsets=zip(xdata_marker,ydata), + transOffset=trans) + + self.update_prop(p, orig_handle, legend) + + p._transOffset = trans + p.set_transform(None) + + return [p] + + +class HandlerCircleCollection(HandlerRegularPolyCollection): + """ + Handler for CircleCollections + """ + def create_collection(self, orig_handle, sizes, offsets, transOffset): + p = type(orig_handle)(sizes, + offsets=offsets, + transOffset=transOffset, + ) + return p + + +class HandlerErrorbar(HandlerLine2D): + """ + Handler for Errorbars + """ + def __init__(self, xerr_size=0.5, yerr_size=None, + marker_pad=0.3, numpoints=None, **kw): + + self._xerr_size = xerr_size + self._yerr_size = yerr_size + + HandlerLine2D.__init__(self, marker_pad=marker_pad, numpoints=numpoints, + **kw) + + def get_err_size(self, legend, xdescent, ydescent, width, height, fontsize): + xerr_size = self._xerr_size*fontsize + + if self._yerr_size is None: + yerr_size = xerr_size + else: + yerr_size = self._yerr_size*fontsize + + return xerr_size, yerr_size + + + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + plotlines, caplines, barlinecols = orig_handle + + xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, + width, height, fontsize) + + ydata = ((height-ydescent)/2.)*np.ones(xdata.shape, float) + legline = Line2D(xdata, ydata) + + self.update_prop(legline, plotlines, legend) + legline.set_drawstyle('default') + legline.set_marker('None') + + + xdata_marker = np.asarray(xdata_marker) + ydata_marker = np.asarray(ydata[:len(xdata_marker)]) + + xerr_size, yerr_size = self.get_err_size(legend, xdescent, ydescent, + width, height, fontsize) + + + legline_marker = Line2D(xdata_marker, ydata_marker) + self.update_prop(legline_marker, plotlines, legend) + legline_marker.set_linestyle('None') + if legend.markerscale !=1: + newsz = legline_marker.get_markersize()*legend.markerscale + legline_marker.set_markersize(newsz) + + + handle_barlinecols = [] + 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)] + coll = mcoll.LineCollection(verts) + self.update_prop(coll, barlinecols[0], legend) + handle_barlinecols.append(coll) + + if caplines: + capline_left = Line2D(xdata_marker-xerr_size, ydata_marker) + capline_right = Line2D(xdata_marker+xerr_size, ydata_marker) + self.update_prop(capline_left, caplines[0], legend) + self.update_prop(capline_right, caplines[0], legend) + capline_left.set_marker("|") + capline_right.set_marker("|") + + handle_caplines.append(capline_left) + 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)] + coll = mcoll.LineCollection(verts) + self.update_prop(coll, barlinecols[0], legend) + handle_barlinecols.append(coll) + + if caplines: + capline_left = Line2D(xdata_marker, ydata_marker-yerr_size) + capline_right = Line2D(xdata_marker, ydata_marker+yerr_size) + self.update_prop(capline_left, caplines[0], legend) + self.update_prop(capline_right, caplines[0], legend) + capline_left.set_marker("_") + capline_right.set_marker("_") + + handle_caplines.append(capline_left) + handle_caplines.append(capline_right) + + artists = [] + artists.extend(handle_barlinecols) + artists.extend(handle_caplines) + artists.append(legline_marker) + artists.append(legline) + + return artists + + + +class HandlerTuple(HandlerBase): + """ + Handler for Tuple + """ + def __init__(self, **kwargs): + HandlerBase.__init__(self, **kwargs) + + #self._handle_list = handle_list + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + handler_map = legend.get_legend_handler_map() + a_list = [] + for handle1 in orig_handle: + handler = legend.get_legend_handler(handler_map, handle1) + _a_list = handler.create_artists(legend, handle1, + xdescent, ydescent, width, height, + fontsize, + trans) + a_list.extend(_a_list) + + return a_list diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 9bdf5bf4b975..c1f197d7b614 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -491,7 +491,8 @@ def get_extent(self, renderer): def add_artist(self, a): 'Add any :class:`~matplotlib.artist.Artist` to the container box' self._children.append(a) - a.set_transform(self.get_transform()) + if not a.is_transform_set(): + a.set_transform(self.get_transform()) def draw(self, renderer):