Skip to content

Commit 39c1d50

Browse files
authored
Merge pull request #28968 from kyracho/positioning_for_rotated_labels
Implement xtick and ytick rotation modes
2 parents 5714a18 + 98778bb commit 39c1d50

File tree

9 files changed

+131
-10
lines changed

9 files changed

+131
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
``xtick`` and ``ytick`` rotation modes
2+
--------------------------------------
3+
4+
A new feature has been added for handling rotation of xtick and ytick
5+
labels more intuitively. The new `rotation modes <matplotlib.text.Text.set_rotation_mode>`
6+
"xtick" and "ytick" automatically adjust the alignment of rotated tick labels,
7+
so that the text points towards their anchor point, i.e. ticks. This works for
8+
all four sides of the plot (bottom, top, left, right), reducing the need for
9+
manual adjustments when rotating labels.
10+
11+
.. plot::
12+
:include-source: true
13+
:alt: Example of rotated xtick and ytick labels.
14+
15+
import matplotlib.pyplot as plt
16+
17+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3.5), layout='constrained')
18+
19+
pos = range(5)
20+
labels = ['label'] * 5
21+
ax1.set_xticks(pos, labels, rotation=-45, rotation_mode='xtick')
22+
ax1.set_yticks(pos, labels, rotation=45, rotation_mode='ytick')
23+
ax2.xaxis.tick_top()
24+
ax2.set_xticks(pos, labels, rotation=-45, rotation_mode='xtick')
25+
ax2.yaxis.tick_right()
26+
ax2.set_yticks(pos, labels, rotation=45, rotation_mode='ytick')
27+
28+
plt.show()

galleries/examples/images_contours_and_fields/image_annotated_heatmap.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464

6565
# Show all ticks and label them with the respective list entries
6666
ax.set_xticks(range(len(farmers)), labels=farmers,
67-
rotation=45, ha="right", rotation_mode="anchor")
67+
rotation=45, rotation_mode="xtick")
6868
ax.set_yticks(range(len(vegetables)), labels=vegetables)
6969

7070
# Loop over data dimensions and create text annotations.
@@ -135,7 +135,7 @@ def heatmap(data, row_labels, col_labels, ax=None,
135135

136136
# Show all ticks and label them with the respective list entries.
137137
ax.set_xticks(range(data.shape[1]), labels=col_labels,
138-
rotation=-30, ha="right", rotation_mode="anchor")
138+
rotation=-30, rotation_mode="xtick")
139139
ax.set_yticks(range(data.shape[0]), labels=row_labels)
140140

141141
# Let the horizontal axes labeling appear on top.

galleries/examples/subplots_axes_and_figures/align_labels_demo.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1))
2727
ax.set_title('Title0 1')
2828
ax.xaxis.tick_top()
29-
ax.tick_params(axis='x', rotation=55)
29+
ax.set_xticks(ax.get_xticks())
30+
ax.tick_params(axis='x', rotation=55, rotation_mode='xtick')
3031

3132

3233
for i in range(2):
@@ -35,7 +36,8 @@
3536
ax.set_ylabel('YLabel1 %d' % i)
3637
ax.set_xlabel('XLabel1 %d' % i)
3738
if i == 0:
38-
ax.tick_params(axis='x', rotation=55)
39+
ax.set_xticks(ax.get_xticks())
40+
ax.tick_params(axis='x', rotation=55, rotation_mode='xtick')
3941

4042
fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels()
4143
fig.align_titles()

lib/matplotlib/axis.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def __init__(
134134
# grid(color=(1, 1, 1, 0.5), alpha=rcParams['grid.alpha'])
135135
# so the that the rcParams default would override color alpha.
136136
grid_alpha = mpl.rcParams["grid.alpha"]
137-
grid_kw = {k[5:]: v for k, v in kwargs.items()}
137+
grid_kw = {k[5:]: v for k, v in kwargs.items() if k != "rotation_mode"}
138138

139139
self.tick1line = mlines.Line2D(
140140
[], [],
@@ -1050,7 +1050,7 @@ def _translate_tick_params(cls, kw, reverse=False):
10501050
'tick1On', 'tick2On', 'label1On', 'label2On',
10511051
'length', 'direction', 'left', 'bottom', 'right', 'top',
10521052
'labelleft', 'labelbottom', 'labelright', 'labeltop',
1053-
'labelrotation',
1053+
'labelrotation', 'rotation_mode',
10541054
*_gridline_param_names]
10551055

10561056
keymap = {

lib/matplotlib/tests/test_text.py

+55
Original file line numberDiff line numberDiff line change
@@ -1135,3 +1135,58 @@ def test_font_wrap():
11351135
plt.text(3, 4, t, family='monospace', ha='right', wrap=True)
11361136
plt.text(-1, 0, t, fontsize=14, style='italic', ha='left', rotation=-15,
11371137
wrap=True)
1138+
1139+
1140+
def test_ha_for_angle():
1141+
text_instance = Text()
1142+
angles = np.arange(0, 360.1, 0.1)
1143+
for angle in angles:
1144+
alignment = text_instance._ha_for_angle(angle)
1145+
assert alignment in ['center', 'left', 'right']
1146+
1147+
1148+
def test_va_for_angle():
1149+
text_instance = Text()
1150+
angles = np.arange(0, 360.1, 0.1)
1151+
for angle in angles:
1152+
alignment = text_instance._va_for_angle(angle)
1153+
assert alignment in ['center', 'top', 'baseline']
1154+
1155+
1156+
@image_comparison(baseline_images=['xtick_rotation_mode'],
1157+
remove_text=False, extensions=['png'], style='mpl20')
1158+
def test_xtick_rotation_mode():
1159+
fig, ax = plt.subplots(figsize=(12, 1))
1160+
ax.set_yticks([])
1161+
ax2 = ax.twiny()
1162+
1163+
ax.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")
1164+
ax2.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")
1165+
1166+
angles = np.linspace(0, 360, 37)
1167+
1168+
for tick, angle in zip(ax.get_xticklabels(), angles):
1169+
tick.set_rotation(angle)
1170+
for tick, angle in zip(ax2.get_xticklabels(), angles):
1171+
tick.set_rotation(angle)
1172+
1173+
plt.subplots_adjust(left=0.01, right=0.99, top=.6, bottom=.4)
1174+
1175+
1176+
@image_comparison(baseline_images=['ytick_rotation_mode'],
1177+
remove_text=False, extensions=['png'], style='mpl20')
1178+
def test_ytick_rotation_mode():
1179+
fig, ax = plt.subplots(figsize=(1, 12))
1180+
ax.set_xticks([])
1181+
ax2 = ax.twinx()
1182+
1183+
ax.set_yticks(range(37), ['foo'] * 37, rotation_mode="ytick")
1184+
ax2.set_yticks(range(37), ['foo'] * 37, rotation_mode='ytick')
1185+
1186+
angles = np.linspace(0, 360, 37)
1187+
for tick, angle in zip(ax.get_yticklabels(), angles):
1188+
tick.set_rotation(angle)
1189+
for tick, angle in zip(ax2.get_yticklabels(), angles):
1190+
tick.set_rotation(angle)
1191+
1192+
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)

lib/matplotlib/text.py

+38-4
Original file line numberDiff line numberDiff line change
@@ -300,16 +300,19 @@ def set_rotation_mode(self, m):
300300
301301
Parameters
302302
----------
303-
m : {None, 'default', 'anchor'}
303+
m : {None, 'default', 'anchor', 'xtick', 'ytick'}
304304
If ``"default"``, the text will be first rotated, then aligned according
305305
to their horizontal and vertical alignments. If ``"anchor"``, then
306-
alignment occurs before rotation. Passing ``None`` will set the rotation
307-
mode to ``"default"``.
306+
alignment occurs before rotation. "xtick" and "ytick" adjust the
307+
horizontal/vertical alignment so that the text is visually pointing
308+
towards its anchor point. This is primarily used for rotated tick
309+
labels and positions them nicely towards their ticks. Passing
310+
``None`` will set the rotation mode to ``"default"``.
308311
"""
309312
if m is None:
310313
m = "default"
311314
else:
312-
_api.check_in_list(("anchor", "default"), rotation_mode=m)
315+
_api.check_in_list(("anchor", "default", "xtick", "ytick"), rotation_mode=m)
313316
self._rotation_mode = m
314317
self.stale = True
315318

@@ -453,6 +456,11 @@ def _get_layout(self, renderer):
453456

454457
rotation_mode = self.get_rotation_mode()
455458
if rotation_mode != "anchor":
459+
angle = self.get_rotation()
460+
if rotation_mode == 'xtick':
461+
halign = self._ha_for_angle(angle)
462+
elif rotation_mode == 'ytick':
463+
valign = self._va_for_angle(angle)
456464
# compute the text location in display coords and the offsets
457465
# necessary to align the bbox with that location
458466
if halign == 'center':
@@ -1376,6 +1384,32 @@ def set_fontname(self, fontname):
13761384
"""
13771385
self.set_fontfamily(fontname)
13781386

1387+
def _ha_for_angle(self, angle):
1388+
"""
1389+
Determines horizontal alignment ('ha') for rotation_mode "xtick" based on
1390+
the angle of rotation in degrees and the vertical alignment.
1391+
"""
1392+
anchor_at_bottom = self.get_verticalalignment() == 'bottom'
1393+
if (angle <= 10 or 85 <= angle <= 95 or 350 <= angle or
1394+
170 <= angle <= 190 or 265 <= angle <= 275):
1395+
return 'center'
1396+
elif 10 < angle < 85 or 190 < angle < 265:
1397+
return 'left' if anchor_at_bottom else 'right'
1398+
return 'right' if anchor_at_bottom else 'left'
1399+
1400+
def _va_for_angle(self, angle):
1401+
"""
1402+
Determines vertical alignment ('va') for rotation_mode "ytick" based on
1403+
the angle of rotation in degrees and the horizontal alignment.
1404+
"""
1405+
anchor_at_left = self.get_horizontalalignment() == 'left'
1406+
if (angle <= 10 or 350 <= angle or 170 <= angle <= 190
1407+
or 80 <= angle <= 100 or 260 <= angle <= 280):
1408+
return 'center'
1409+
elif 190 < angle < 260 or 10 < angle < 80:
1410+
return 'baseline' if anchor_at_left else 'top'
1411+
return 'top' if anchor_at_left else 'baseline'
1412+
13791413

13801414
class OffsetFrom:
13811415
"""Callable helper class for working with `Annotation`."""

lib/matplotlib/text.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ class Text(Artist):
106106
def set_fontname(self, fontname: str | Iterable[str]) -> None: ...
107107
def get_antialiased(self) -> bool: ...
108108
def set_antialiased(self, antialiased: bool) -> None: ...
109+
def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ...
110+
def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ...
109111

110112
class OffsetFrom:
111113
def __init__(

0 commit comments

Comments
 (0)