diff --git a/doc/users/next_whats_new/2020-04-06-label_lines.rst b/doc/users/next_whats_new/2020-04-06-label_lines.rst new file mode 100644 index 000000000000..fc07b61abc8f --- /dev/null +++ b/doc/users/next_whats_new/2020-04-06-label_lines.rst @@ -0,0 +1,12 @@ +New `~.axes.Axes.label_lines` method +------------------------------------ + +A new `~.axes.Axes.label_lines` method has been added to label the end of lines on an axes. +Previously, the user had to go through the hassle of positioning each label individually +like the Bachelors degrees by gender example. + +https://matplotlib.org/gallery/showcase/bachelors_degrees_by_gender.html + +Now, to achieve the same effect, a user can simply call + + ax.label_lines() diff --git a/examples/showcase/bachelors_degrees_by_gender.py b/examples/showcase/bachelors_degrees_by_gender.py index 3f4c805241c7..ffb92c0850e8 100644 --- a/examples/showcase/bachelors_degrees_by_gender.py +++ b/examples/showcase/bachelors_degrees_by_gender.py @@ -75,30 +75,12 @@ 'Math and Statistics', 'Architecture', 'Physical Sciences', 'Computer Science', 'Engineering'] -y_offsets = {'Foreign Languages': 0.5, 'English': -0.5, - 'Communications\nand Journalism': 0.75, - 'Art and Performance': -0.25, 'Agriculture': 1.25, - 'Social Sciences and History': 0.25, 'Business': -0.75, - 'Math and Statistics': 0.75, 'Architecture': -0.75, - 'Computer Science': 0.75, 'Engineering': -0.25} - for column in majors: # Plot each line separately with its own color. column_rec_name = column.replace('\n', '_').replace(' ', '_') - + column_label = column.replace('\n', ' ').replace(' ', ' ') line, = ax.plot('Year', column_rec_name, data=gender_degree_data, - lw=2.5) - - # Add a text label to the right end of every line. Most of the code below - # is adding specific offsets y position because some labels overlapped. - y_pos = gender_degree_data[column_rec_name][-1] - 0.5 - - if column in y_offsets: - y_pos += y_offsets[column] - - # Again, make sure that all labels are large enough to be easily read - # by the viewer. - ax.text(2011.5, y_pos, column, fontsize=14, color=line.get_color()) + lw=2.5, label=column_label) # Make the title big enough so it spans the entire plot, but don't make it # so big that it requires two lines to show. @@ -108,6 +90,9 @@ fig.suptitle("Percentage of Bachelor's degrees conferred to women in " "the U.S.A. by major (1970-2011)", fontsize=18, ha="center") +# Call the the label lines feature to add appropriate labels for each line +plt.label_lines() + # Finally, save the figure as a PNG. # You can also save it as a PDF, JPEG, etc. # Just change the file extension in this call. diff --git a/examples/text_labels_and_annotations/label_lines.py b/examples/text_labels_and_annotations/label_lines.py new file mode 100644 index 000000000000..bdb1461dee58 --- /dev/null +++ b/examples/text_labels_and_annotations/label_lines.py @@ -0,0 +1,44 @@ +""" +==================================== +Line labels using pre-defined labels +==================================== + +Defining line labels with plots. +""" + + +import numpy as np +import matplotlib.pyplot as plt + +# Make some fake data. +a = b = np.arange(0, 3, .02) +c = np.exp(a) +d = c[::-1] + +# Create plots with pre-defined labels. +fig, ax = plt.subplots() +ax.spines['top'].set_visible(False) +ax.spines['right'].set_visible(False) +ax.plot(a, c, 'k--', label='Model length') +ax.plot(a, d, 'k:', label='Data length') +ax.plot(a, c + d, 'k', label='Total message length') + +ax.label_lines() + +plt.show() + +############################################################################# +# +# ------------ +# +# References +# """""""""" +# +# The use of the following functions, methods, classes and modules is shown +# in this example: + +#import matplotlib +#matplotlib.axes.Axes.plot +#matplotlib.pyplot.plot +#matplotlib.axes.Axes.label_lines +#matplotlib.pyplot.label_lines diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 35fd2b0ce890..0763680e4f9c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -767,6 +767,179 @@ def text(self, x, y, s, fontdict=None, **kwargs): self._add_text(t) return t + def label_lines(self, *args, **kwargs): + """ + Place labels at the end of lines on the chart. + + Call signatures:: + + label_lines() + label_lines(labels) + label_lines(handles, labels) + + The call signatures correspond to three different ways of how to use + this method. + + The simplest way to use line_labels is without parameters. After the + lines are created with their labels, the user can call this function + which automatically applies the stored labels by the corresponding + lines. This would be most effectively used after the complete creation + of the line chart. + + The second way to call this method is by specifying the first parameter + which is labels. In doing so you would be able to name the lines if + they lack names, or rename them as needed. This process occurs in the + order in which the lines were added to the chart. + + The Final way to use this method is specifying both the handles and the + labels. In doing so you can specify names for select lines leaving the + rest blank. This would be useful when users would want to specify + select pieces of data to monitor when data clumps occur. + + Parameters + ---------- + handles : sequence of `.Artist`, optional + A list of Artists (lines) to be added to the line labels. + Use this together with *labels*, if you need full control on what + is shown in the line labels and the automatic mechanism described + above is not sufficient. + + The length of handles and labels should be the same in this + case. If they are not, they are truncated to the smaller length. + + labels : list of str, optional + A list of labels to show next to the artists. + Use this together with *handles*, if you need full control on what + is shown in the line labels and the automatic mechanism described + above is not sufficient. + + Returns + ------- + None + + Notes + ----- + Only line handles are supported by this method. + + Examples + -------- + .. plot:: gallery/text_labels_and_annotations/label_lines.py + """ + handles, labels, extra_args, kwargs = mlegend._parse_legend_args( + [self], + *args, + **kwargs) + if len(extra_args): + raise TypeError( + 'label_lines only accepts two nonkeyword arguments') + + self._line_labels = [] + + def get_last_data_point(handle): + last_x = handle.get_xdata()[-1] + last_y = handle.get_ydata()[-1] + return last_x, last_y + + xys = np.array([get_last_data_point(x) for x in handles]) + + data_maxx, data_maxy = np.max(xys, axis=0) + data_miny = np.min(xys, axis=0)[1] + + fig_dpi_transform = self.figure.dpi_scale_trans.inverted() + fig_bbox = self.get_window_extent().transformed(fig_dpi_transform) + fig_width_px = fig_bbox.width * self.figure.dpi + fig_height_px = fig_bbox.height * self.figure.dpi + + fig_minx, fig_maxx = self.get_xbound() + fig_miny, fig_maxy = self.get_ybound() + fig_width_pt = abs(fig_maxx - fig_minx) + fig_height_pt = abs(fig_maxy - fig_miny) + + margin_left = 8 * (fig_width_pt / fig_width_px) + margin_vertical = 2 * (fig_height_pt / fig_height_px) + + text_fontsize = 10 + text_height = text_fontsize * (fig_height_pt / fig_height_px) + + bucket_height = text_height + 2 * margin_vertical + buckets_total = 1 + int((fig_maxy - fig_miny - text_height) / + bucket_height) + buckets_map = 0 + + def get_bucket_index(y): + return int((y - fig_miny) / bucket_height) + + bucket_densities = [0] * buckets_total + for xy in xys: + data_x, data_y = xy + ideal_bucket = get_bucket_index(data_y) + if ideal_bucket >= 0 and ideal_bucket < buckets_total: + bucket_densities[ideal_bucket] += 1 + + def in_viewport(args): + xy = args[2] + x, y = xy + return fig_minx < x < fig_maxx and fig_miny < y < fig_maxy + + def by_y(args): + xy = args[2] + x, y = xy + return y + + bucket_offset = None + prev_ideal_bucket = -1 + for handle, label, xy in sorted(filter(in_viewport, + zip(handles, labels, xys)), + key=by_y): + data_x, data_y = xy + + ideal_bucket = get_bucket_index(data_y) + if (ideal_bucket != prev_ideal_bucket and + ideal_bucket < len(bucket_densities)): + bucket_density = bucket_densities[ideal_bucket] + ideal_offset = bucket_density // 2 + empty_buckets_below = 0 + for i in range(ideal_bucket + 1, + ideal_bucket - ideal_offset + 1, + -1): + if i == 0 or buckets_map & (1 << i) == 1: + break + empty_buckets_below += 1 + bucket_offset = -min(ideal_offset, empty_buckets_below) + prev_ideal_bucket = ideal_bucket + + bucket = ideal_bucket + bucket_offset + text_x, text_y = None, None + + for index in range(buckets_total): + bucket_index = max(0, min(bucket + index, buckets_total)) + bucket_mask = 1 << bucket_index + if buckets_map & bucket_mask == 0: + buckets_map |= bucket_mask + text_x = data_maxx + margin_left + text_y = bucket_index * bucket_height + fig_miny + break + + if text_x is not None and text_y is not None: + text_color = handle.get_color() + y_axes = self.transLimits.transform((text_x, text_y))[1] + line_label = self.annotate(label, (data_maxx, data_y), + textcoords='axes fraction', + xytext=(1, y_axes), + fontsize=text_fontsize, + color=text_color, + annotation_clip=True) + self._line_labels.append(line_label) + + def has_label_lines(self): + return self._line_labels is not None + + def refresh_label_lines(self, *args, **kwargs): + for label in self._line_labels: + label.remove() + self._line_labels = None + self.label_lines(*args, **kwargs) + @cbook._rename_parameter("3.3", "s", "text") @docstring.dedent_interpd def annotate(self, text, xy, *args, **kwargs): diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index b61264f82d48..41b5f5d8f926 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -567,6 +567,7 @@ def __init__(self, fig, rect, self._layoutbox = None self._poslayoutbox = None + self._line_labels = None def __getstate__(self): # The renderer should be re-created by the figure, and then cached at diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index cf9453959853..d60a1d27f55d 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3056,6 +3056,11 @@ def draw(self): # inline call to self.canvas.draw_idle(). def _draw(self): for a in self.canvas.figure.get_axes(): + # TODO(henryhu123): Is this the right place do recompute the line + # labels? + if a.has_label_lines(): + a.refresh_label_lines() + xaxis = getattr(a, 'xaxis', None) yaxis = getattr(a, 'yaxis', None) locators = [] @@ -3086,6 +3091,8 @@ def _update_view(self): # Restore both the original and modified positions ax._set_position(pos_orig, 'original') ax._set_position(pos_active, 'active') + if ax.has_label_lines(): + ax.refresh_label_lines() self.canvas.draw_idle() def save_figure(self, *args): diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 4aa2f8023e13..8c00ee95a69a 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2559,6 +2559,12 @@ def imshow( return __ret +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.label_lines) +def label_lines(*args, **kwargs): + return gca().label_lines(*args, **kwargs) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.legend) def legend(*args, **kwargs): diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.pdf b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.pdf new file mode 100644 index 000000000000..df6ffe3ae08c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.png b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.png new file mode 100644 index 000000000000..d67e8c7d4ade Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_default.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.pdf b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.pdf new file mode 100644 index 000000000000..6b811ee32211 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.png b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.png new file mode 100644 index 000000000000..7b530efc6c30 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_different_x_endpoint.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.pdf b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.pdf new file mode 100644 index 000000000000..15010bc62cd8 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.png b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.png new file mode 100644 index 000000000000..da37c2b6cfa0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_lines_single_endpoint.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 28312db83181..c6bbe759d5e6 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6117,3 +6117,79 @@ def test_invisible_axes(): assert fig.canvas.inaxes((200, 200)) is not None ax.set_visible(False) assert fig.canvas.inaxes((200, 200)) is None + + +@check_figures_equal(extensions=["png"]) +def test_polar_interpolation_steps_constant_r(fig_test, fig_ref): + # Check that an extra half-turn doesn't make any difference -- modulo + # antialiasing, which we disable here. + p1 = (fig_test.add_subplot(121, projection="polar") + .bar([0], [1], 3*np.pi, edgecolor="none")) + p2 = (fig_test.add_subplot(122, projection="polar") + .bar([0], [1], -3*np.pi, edgecolor="none")) + p3 = (fig_ref.add_subplot(121, projection="polar") + .bar([0], [1], 2*np.pi, edgecolor="none")) + p4 = (fig_ref.add_subplot(122, projection="polar") + .bar([0], [1], -2*np.pi, edgecolor="none")) + for p in [p1, p2, p3, p4]: + plt.setp(p, antialiased=False) + + +@check_figures_equal(extensions=["png"]) +def test_polar_interpolation_steps_variable_r(fig_test, fig_ref): + l, = fig_test.add_subplot(projection="polar").plot([0, np.pi/2], [1, 2]) + l.get_path()._interpolation_steps = 100 + fig_ref.add_subplot(projection="polar").plot( + np.linspace(0, np.pi/2, 101), np.linspace(1, 2, 101)) + + +@image_comparison(['label_lines_default'], style="default") +def label_lines_default(): + fig, ax = plt.subplots(figsize=(8, 8)) + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.get_xaxis().tick_bottom() + ax.get_yaxis().tick_left() + plt.plot([0, 2], [-1, 1], label="y=x-1") + plt.plot([0, 2], [0, 2], label="y=x") + plt.plot([0, 2], [1, 3], label="y=x+1") + plt.plot([0, 2], [2, 4], label="y=x+2") + plt.xticks(range(0, 3)) + ax.label_lines() + + +@image_comparison(['label_lines_single_endpoint'], style="default") +def label_lines_single_endpoint(): + fig, ax = plt.subplots(figsize=(8, 8)) + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.get_xaxis().tick_bottom() + ax.get_yaxis().tick_left() + plt.plot([0, 2], [0, 2], label="Label 1") + plt.plot([0, 2], [1, 2], label="Label 2") + plt.plot([0, 2], [2, 2], label="Label 3") + plt.plot([0, 2], [3, 2], label="Label 4") + plt.plot([0, 2], [4, 2], label="Label 5") + plt.xticks(range(0, 3)) + ax.label_lines() + + +@image_comparison(['label_lines_different_x_endpoint'], style="default") +def label_lines_different_x_endpoint(): + fig, ax = plt.subplots(figsize=(8, 8)) + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.get_xaxis().tick_bottom() + ax.get_yaxis().tick_left() + plt.plot([0, 1], [2.5, 2.5], label="Label 8") + plt.plot([0, 1], [1.5, 1.5], label="Label 7") + plt.plot([1, 2], [4, 4], label="Label 5") + plt.plot([1, 2], [2, 2], label="Label 3") + plt.plot([0, 1], [4.5, 4.5], label="Label 10") + plt.plot([1, 2], [0, 0], label="Label 1") + plt.plot([1, 2], [1, 1], label="Label 2") + plt.plot([0, 1], [3.5, 3.5], label="Label 9") + plt.plot([1, 2], [3, 3], label="Label 4") + plt.plot([0, 1], [0.5, 0.5], label="Label 6") + plt.xticks(range(0, 3)) + ax.label_lines() diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 7b1e5919565a..143fe54e1ffe 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -227,6 +227,7 @@ def boilerplate_gen(): 'hist2d', 'hlines', 'imshow', + 'label_lines', 'legend', 'locator_params', 'loglog',