Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Implement line labels feature #17035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
Loading
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions 12 doc/users/next_whats_new/2020-04-06-label_lines.rst
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 5 additions & 20 deletions 25 examples/showcase/bachelors_degrees_by_gender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions 44 examples/text_labels_and_annotations/label_lines.py
Original file line number Diff line number Diff line change
@@ -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
173 changes: 173 additions & 0 deletions 173 lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this? Why is this more complicated than putting the text at the end of each line?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our main goal with this implementation is to ensure readability for the user at minimal cost for them. The given examples such as Bachelor's degrees by gender were created by individually placing text objects and using offsets so that they do not overlap. Therefore to try and automatically format the texts in such a way that there is no overlap, we decided to create this bitmap system for spacing rather than simply placing a text at the end of each line. This need for automatic spacing is exemplified by the the same graph from the feature request, where it lacks the offsets.
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jklymak basically, if you place the text at the end of each line. In some cases the text will overlap, consider the bachelor's degree example posted by my colleague above.

Copy link
Contributor Author

@omarchehab98 omarchehab98 Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jklymak for the explanation you requested:

We are placing annotations in "buckets". To do so, we sort the last data points by Y ascending and use a boolean array to keep track of which buckets have been used. If a bucket has been used, we try to place the text in the next empty bucket.

We also consider the data point density of each bucket, if there are many data points in a bucket the annotations should be centered around the bucket rather than be placed bottom-up starting from the center (due to our iteration order because of the sort)

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):
Expand Down
1 change: 1 addition & 0 deletions 1 lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions 7 lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions 6 lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.