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 f1b540b

Browse filesBrowse files
RobertAugustynowiczomarchehab98henryhu123DennisTismenkoJinming-Zhang
committed
Implement Axes.label_lines
Co-authored-by: Robert Augustynowicz <robert.augustynowicz@mail.utoronto.ca> Co-authored-by: Omar Chehab <omarchehab98@gmail.com> Co-authored-by: Jinyang Hu <jinyang.hu@mail.utoronto.ca> Co-authored-by: Dennis Tismenko <dtismenkodeveloper@gmail.com> Co-authored-by: Jinming Zhang <jinming.zhang@mail.utoronto.ca> Closes: #12939
1 parent d1f07d3 commit f1b540b
Copy full SHA for f1b540b

File tree

5 files changed

+172
-0
lines changed
Filter options

5 files changed

+172
-0
lines changed

‎lib/matplotlib/axes/_axes.py

Copy file name to clipboardExpand all lines: lib/matplotlib/axes/_axes.py
+158Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,164 @@ 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 = 1 + 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+
if bucket >= 0 and bucket < buckets_total:
827+
bucket_densities[bucket] += 1
828+
829+
def in_viewport(args):
830+
xy = args[2]
831+
x, y = xy
832+
return fig_minx < x < fig_maxx and fig_miny < y < fig_maxy
833+
834+
def by_y(args):
835+
xy = args[2]
836+
x, y = xy
837+
return y
838+
839+
bucket_offset = None
840+
prev_ideal_bucket = -1
841+
# Iterate over all the handles, labels, and xy data sorted by y asc
842+
for handle, label, xy in sorted(filter(in_viewport,
843+
zip(handles, labels, xys)),
844+
key=by_y):
845+
data_x, data_y = xy
846+
847+
ideal_bucket = get_bucket_index(data_y)
848+
if ideal_bucket != prev_ideal_bucket:
849+
# If a bucket is dense, then we want to center the text around
850+
# the bucket. SSo, find out how many empty buckets there are
851+
# below the current bucket
852+
bucket_density = bucket_densities[ideal_bucket]
853+
ideal_offset = bucket_density // 2
854+
empty_buckets_below = 0
855+
for i in range(ideal_bucket + 1,
856+
ideal_bucket - ideal_offset + 1,
857+
-1):
858+
# If reached bottom or bucket is not empty
859+
if i == 0 or buckets_map & (1 << i) == 1:
860+
break
861+
empty_buckets_below += 1
862+
bucket_offset = -min(ideal_offset, empty_buckets_below)
863+
prev_ideal_bucket = ideal_bucket
864+
865+
bucket = ideal_bucket + bucket_offset
866+
text_x, text_y = None, None
867+
868+
# Find the nearest empty bucket
869+
for index in range(buckets_total):
870+
# 0 <= bucket_index <= buckets_total
871+
bucket_index = max(0, min(bucket + index, buckets_total))
872+
bucket_mask = 1 << bucket_index
873+
# If this bucket is empty
874+
if buckets_map & bucket_mask == 0:
875+
# Mark this bucket as used
876+
buckets_map |= bucket_mask
877+
text_x = data_maxx + margin_left
878+
text_y = bucket_index * bucket_height + fig_miny
879+
break
880+
881+
# If a bucket was found for the text
882+
if text_x is not None and text_y is not None:
883+
text_color = handle.get_color()
884+
line_label = self.annotate(label, (data_maxx, data_y),
885+
xytext=(text_x, text_y),
886+
fontsize=text_fontsize,
887+
color=text_color,
888+
annotation_clip=True)
889+
self._line_labels.append(line_label)
890+
891+
# A bucket is not necessarily aligned with the data point
892+
# If space allows, center the text around the data point
893+
# TODO(omarchehab98): group fuzzing
894+
# for line_label in self._line_labels:
895+
# datax, datay = line_label.xy
896+
# targety = datay - text_height / 2
897+
# textx, texty = line_label.get_position()
898+
# direction = 1 if targety > texty else -1
899+
# target_bucket = get_bucket_index(targety) + direction
900+
# if (get_bucket_index(targety) == get_bucket_index(texty) and
901+
# (target_bucket == -1 or
902+
# buckets_map & (1 << target_bucket) == 0)):
903+
# line_label.set_position((textx, targety))
904+
905+
def has_label_lines(self):
906+
return self._line_labels is not None
907+
908+
def refresh_label_lines(self, *args, **kwargs):
909+
for label in self._line_labels:
910+
label.remove()
911+
self._line_labels = None
912+
self.label_lines(*args, **kwargs)
913+
756914
@cbook._rename_parameter("3.3", "s", "text")
757915
@docstring.dedent_interpd
758916
def annotate(self, text, xy, *args, **kwargs):

‎lib/matplotlib/axes/_base.py

Copy file name to clipboardExpand all lines: lib/matplotlib/axes/_base.py
+2Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ def __init__(self, fig, rect,
514514

515515
self._layoutbox = None
516516
self._poslayoutbox = None
517+
518+
self._line_labels = None
517519

518520
def __getstate__(self):
519521
# The renderer should be re-created by the figure, and then cached at

‎lib/matplotlib/backend_bases.py

Copy file name to clipboardExpand all lines: lib/matplotlib/backend_bases.py
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3058,6 +3058,11 @@ def release_zoom(self, event):
30583058
def draw(self):
30593059
"""Redraw the canvases, update the locators."""
30603060
for a in self.canvas.figure.get_axes():
3061+
# TODO(henryhu123): Is this the right place do recompute the line
3062+
# labels?
3063+
if a.has_label_lines():
3064+
a.refresh_label_lines()
3065+
30613066
xaxis = getattr(a, 'xaxis', None)
30623067
yaxis = getattr(a, 'yaxis', None)
30633068
locators = []

‎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.