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

Commit 43444fc

Browse filesBrowse files
Implement Axes.label_lines
Co-authored-by: Robert Augustynowicz <robert.augustynowicz@mail.utoronto.ca> Closes: #12939
1 parent d1f07d3 commit 43444fc
Copy full SHA for 43444fc

File tree

Expand file treeCollapse file tree

3 files changed

+152
-0
lines changed
Filter options
Expand file treeCollapse file tree

3 files changed

+152
-0
lines changed

‎lib/matplotlib/axes/_axes.py

Copy file name to clipboardExpand all lines: lib/matplotlib/axes/_axes.py
+145Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,151 @@ def text(self, x, y, s, fontdict=None, **kwargs):
753753
self._add_text(t)
754754
return t
755755

756+
def label_lines(self, *args, **kwargs):
757+
handles, labels, extra_args, kwargs = mlegend._parse_legend_args(
758+
[self],
759+
*args,
760+
**kwargs)
761+
if len(extra_args):
762+
raise TypeError(
763+
'label_lines only accepts two nonkeyword arguments')
764+
765+
self._line_labels = []
766+
767+
# TODO(omarchehab98): Should we avoid using a Line2D method from inside
768+
# the Axes class?
769+
# If it's okay, we should check if handle is an
770+
# instance of Line2D.
771+
def get_last_data(handle):
772+
last_x = handle.get_xdata()[-1]
773+
last_y = handle.get_ydata()[-1]
774+
return last_x, last_y
775+
# Get last data point for each handle
776+
xys = list(map(get_last_data, handles))
777+
778+
# Find the largest last x value
779+
data_maxx = max(map(lambda point: point[0], xys))
780+
# TODO(omarchehab98): We do eventually sort `xys` by `y`.
781+
# If we reorder things we can do these two
782+
# computations in constant time.
783+
# Rather than linear time.
784+
# Find the smallest last y value
785+
data_miny = min(map(lambda point: point[1], xys))
786+
# Find the largest last y value
787+
data_maxy = max(map(lambda point: point[1], xys))
788+
789+
# Get the figure width and height in pixels
790+
fig_dpi_transform = self.figure.dpi_scale_trans.inverted()
791+
fig_bbox = self.get_window_extent().transformed(fig_dpi_transform)
792+
fig_width_px = fig_bbox.width * self.figure.dpi
793+
fig_height_px = fig_bbox.height * self.figure.dpi
794+
795+
# Get the figure width and height in points
796+
fig_minx, fig_maxx = self.get_xbound()
797+
fig_miny, fig_maxy = self.get_ybound()
798+
fig_width_pt = abs(fig_maxx - fig_minx)
799+
fig_height_pt = abs(fig_maxy - fig_miny)
800+
801+
# Space to the left of the text converted from pixels
802+
margin_left = 8 * (fig_width_pt / fig_width_px)
803+
# Space to the top and bottom of the text converted from pixels
804+
margin_vertical = 2 * (fig_height_pt / fig_height_px)
805+
806+
text_fontsize = 10
807+
# Height of the text converted from pixels
808+
text_height = text_fontsize * (fig_height_pt / fig_height_px)
809+
810+
# Height of each bucket
811+
bucket_height = text_height + 2 * margin_vertical
812+
# Total number of buckets
813+
buckets_total = 2 * int((fig_maxy - fig_miny - text_height) /
814+
bucket_height)
815+
# Bit array to track which buckets are used
816+
buckets_map = 0
817+
818+
def get_bucket_index(y):
819+
return int((y - fig_miny) / bucket_height)
820+
821+
# How many text SHOULD be in this bucket
822+
bucket_densities = [0] * buckets_total
823+
for xy in xys:
824+
data_x, data_y = xy
825+
bucket = get_bucket_index(data_y)
826+
bucket_densities[bucket] += 1
827+
828+
def by_y(args):
829+
xy = args[2]
830+
x, y = xy
831+
return y
832+
833+
bucket_offset = None
834+
prev_ideal_bucket = -1
835+
# Iterate over all the handles, labels, and xy data sorted by y asc
836+
for handle, label, xy in sorted(zip(handles, labels, xys), key=by_y):
837+
data_x, data_y = xy
838+
839+
ideal_bucket = get_bucket_index(data_y)
840+
if ideal_bucket != prev_ideal_bucket:
841+
# If a bucket is dense, then we want to center the text around
842+
# the bucket. SSo, find out how many empty buckets there are
843+
# below the current bucket
844+
bucket_density = bucket_densities[ideal_bucket]
845+
ideal_offset = bucket_density // 2
846+
empty_buckets_below = 0
847+
for i in range(ideal_bucket + 1,
848+
ideal_bucket - ideal_offset + 1,
849+
-1):
850+
# If reached bottom or bucket is not empty
851+
if i == 0 or buckets_map & (1 << i) == 1:
852+
break
853+
empty_buckets_below += 1
854+
bucket_offset = -min(ideal_offset, empty_buckets_below)
855+
prev_ideal_bucket = ideal_bucket
856+
857+
bucket = ideal_bucket + bucket_offset
858+
text_x, text_y = None, None
859+
860+
# Find the nearest empty bucket
861+
for index in range(buckets_total):
862+
# 0 <= bucket_index <= buckets_total
863+
bucket_index = max(0, min(bucket + index, buckets_total))
864+
bucket_mask = 1 << bucket_index
865+
# If this bucket is empty
866+
if buckets_map & bucket_mask == 0:
867+
# Mark this bucket as used
868+
buckets_map |= bucket_mask
869+
text_x = data_maxx + margin_left
870+
text_y = bucket_index * bucket_height + fig_miny
871+
break
872+
873+
# If a bucket was found for the text
874+
if text_x is not None and text_y is not None:
875+
text_color = handle.get_color()
876+
line_label = self.annotate(label, (data_maxx, data_y),
877+
xytext=(text_x, text_y),
878+
fontsize=text_fontsize,
879+
color=text_color,
880+
annotation_clip=True)
881+
self._line_labels.append(line_label)
882+
883+
# A bucket is not necessarily aligned with the data point
884+
# If space allows, center the text around the data point
885+
for line_label in self._line_labels:
886+
datax, datay = line_label.xy
887+
targety = datay - text_height / 2
888+
textx, texty = line_label.get_position()
889+
direction = 1 if targety > texty else -1
890+
bucket_mask = 1 << (get_bucket_index(targety) + direction)
891+
if (get_bucket_index(targety) == get_bucket_index(texty) and
892+
buckets_map & bucket_mask == 0):
893+
line_label.set_position((textx, targety))
894+
895+
def reset_label_lines(self, *args, **kwargs):
896+
for label in self._line_labels:
897+
label.remove()
898+
self._line_labels = []
899+
self.label_lines(*args, **kwargs)
900+
756901
@cbook._rename_parameter("3.3", "s", "text")
757902
@docstring.dedent_interpd
758903
def annotate(self, text, xy, *args, **kwargs):

‎lib/matplotlib/pyplot.py

Copy file name to clipboardExpand all lines: lib/matplotlib/pyplot.py
+6Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2559,6 +2559,12 @@ def imshow(
25592559
return __ret
25602560

25612561

2562+
# Autogenerated by boilerplate.py. Do not edit as changes will be lost.
2563+
@_copy_docstring_and_deprecators(Axes.label_lines)
2564+
def label_lines(*args, **kwargs):
2565+
return gca().label_lines(*args, **kwargs)
2566+
2567+
25622568
# Autogenerated by boilerplate.py. Do not edit as changes will be lost.
25632569
@_copy_docstring_and_deprecators(Axes.legend)
25642570
def legend(*args, **kwargs):

‎tools/boilerplate.py

Copy file name to clipboardExpand all lines: tools/boilerplate.py
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ def boilerplate_gen():
227227
'hist2d',
228228
'hlines',
229229
'imshow',
230+
'label_lines',
230231
'legend',
231232
'locator_params',
232233
'loglog',

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.