Skip to content

Commit 249bb58

Browse files
committed
Implement xtick and ytick rotation_mode
1 parent 0439b37 commit 249bb58

File tree

9 files changed

+158
-36
lines changed

9 files changed

+158
-36
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 automatically adjusts the
6+
alignment of rotated tick labels. This applies to tick labels on all four
7+
sides of the plot (bottom, top, left, right), reducing the need for manual
8+
adjustments when rotating labels.
9+
10+
.. plot::
11+
:include-source: true
12+
:alt: Example of rotated xtick and ytick labels.
13+
14+
import matplotlib.pyplot as plt
15+
16+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
17+
18+
s = range(5)
19+
ax1.set_xticks(s)
20+
ax1.set_xticklabels(['label'] * 5, rotation=-45, rotation_mode='xtick')
21+
ax1.set_yticks(s)
22+
ax1.set_yticklabels(['label'] * 5, rotation=45, rotation_mode='ytick')
23+
ax2.set_xticks(s)
24+
ax2.set_xticklabels(['label'] * 5, rotation=-45, rotation_mode='xtick')
25+
ax2.xaxis.tick_top()
26+
ax2.set_yticks(s)
27+
ax2.set_yticklabels(['label'] * 5, rotation=45, rotation_mode='ytick')
28+
ax2.yaxis.tick_right()
29+
30+
plt.tight_layout()
31+
plt.show()

galleries/examples/images_contours_and_fields/image_annotated_heatmap.py

Lines changed: 2 additions & 2 deletions
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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,19 @@
1717

1818
fig, axs = plt.subplots(2, 2, layout='constrained')
1919

20-
ax = axs[0][0]
21-
ax.plot(np.arange(0, 1e6, 1000))
22-
ax.set_title('Title0 0')
23-
ax.set_ylabel('YLabel0 0')
24-
25-
ax = axs[0][1]
26-
ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1))
27-
ax.set_title('Title0 1')
28-
ax.xaxis.tick_top()
29-
ax.tick_params(axis='x', rotation=55)
30-
31-
3220
for i in range(2):
33-
ax = axs[1][i]
34-
ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1))
35-
ax.set_ylabel('YLabel1 %d' % i)
36-
ax.set_xlabel('XLabel1 %d' % i)
37-
if i == 0:
38-
ax.tick_params(axis='x', rotation=55)
21+
for j in range(2):
22+
ax = axs[i][j]
23+
ax.plot(np.arange(1., 0., -0.1) * 1000., np.arange(1., 0., -0.1))
24+
ax.set_title(f'Title {i} {j}')
25+
ax.set_xlabel(f'XLabel {i} {j}')
26+
ax.set_ylabel(f'YLabel {i} {j}')
27+
if (i == 0 and j == 1) or (i == 1 and j == 0):
28+
if i == 0 and j == 1:
29+
ax.xaxis.tick_top()
30+
ax.set_xticks(np.linspace(0, 1000, 5))
31+
ax.set_xticklabels([250 * n for n in range(5)])
32+
ax.xaxis.set_tick_params(rotation=55, rotation_mode='xtick')
3933

4034
fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels()
4135
fig.align_titles()

lib/matplotlib/axis.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ def _apply_params(self, **kwargs):
346346
if k in _gridline_param_names}
347347
self.gridline.set(**grid_kw)
348348

349+
if 'rotation_mode' in kwargs:
350+
rotation_mode = kwargs.pop('rotation_mode')
351+
self.label1.set_rotation_mode(rotation_mode)
352+
self.label2.set_rotation_mode(rotation_mode)
353+
349354
def update_position(self, loc):
350355
"""Set the location of tick in data coords with scalar *loc*."""
351356
raise NotImplementedError('Derived must override')
@@ -1043,12 +1048,11 @@ def get_tick_params(self, which='major'):
10431048
_api.check_in_list(['major', 'minor'], which=which)
10441049
if which == 'major':
10451050
return self._translate_tick_params(
1046-
self._major_tick_kw, reverse=True
1047-
)
1051+
self._major_tick_kw, reverse=True)
10481052
return self._translate_tick_params(self._minor_tick_kw, reverse=True)
10491053

1050-
@staticmethod
1051-
def _translate_tick_params(kw, reverse=False):
1054+
@classmethod
1055+
def _translate_tick_params(cls, kw, reverse=False):
10521056
"""
10531057
Translate the kwargs supported by `.Axis.set_tick_params` to kwargs
10541058
supported by `.Tick._apply_params`.
@@ -1072,7 +1076,7 @@ def _translate_tick_params(kw, reverse=False):
10721076
'tick1On', 'tick2On', 'label1On', 'label2On',
10731077
'length', 'direction', 'left', 'bottom', 'right', 'top',
10741078
'labelleft', 'labelbottom', 'labelright', 'labeltop',
1075-
'labelrotation',
1079+
'labelrotation', 'rotation_mode',
10761080
*_gridline_param_names]
10771081

10781082
keymap = {
@@ -1089,11 +1093,21 @@ def _translate_tick_params(kw, reverse=False):
10891093
'labelright': 'label2On',
10901094
'labeltop': 'label2On',
10911095
}
1096+
is_x_axis = cls.axis_name == 'x'
10921097
if reverse:
1093-
kwtrans = {
1094-
oldkey: kw_.pop(newkey)
1095-
for oldkey, newkey in keymap.items() if newkey in kw_
1096-
}
1098+
kwtrans = {}
1099+
for oldkey, newkey in keymap.items():
1100+
if newkey in kw_:
1101+
if is_x_axis and newkey == 'label1On':
1102+
kwtrans['labelbottom'] = kw_.pop(newkey)
1103+
elif is_x_axis and newkey == 'tick1On':
1104+
kwtrans['bottom'] = kw_.pop(newkey)
1105+
elif is_x_axis and newkey == 'label2On':
1106+
kwtrans['labeltop'] = kw_.pop(newkey)
1107+
elif is_x_axis and newkey == 'tick2On':
1108+
kwtrans['top'] = kw_.pop(newkey)
1109+
else:
1110+
kwtrans[oldkey] = kw_.pop(newkey)
10971111
else:
10981112
kwtrans = {
10991113
newkey: kw_.pop(oldkey)

lib/matplotlib/tests/test_axis.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import numpy as np
2-
32
import matplotlib.pyplot as plt
4-
from matplotlib.axis import XTick
3+
from matplotlib.axis import XTick, XAxis
54

65

76
def test_tick_labelcolor_array():
@@ -29,3 +28,15 @@ def test_axis_not_in_layout():
2928
# Positions should not be affected by overlapping 100 label
3029
assert ax1_left.get_position().bounds == ax2_left.get_position().bounds
3130
assert ax1_right.get_position().bounds == ax2_right.get_position().bounds
31+
32+
33+
def test__translate_tick_params():
34+
fig, ax = plt.subplots()
35+
xaxis = XAxis(ax)
36+
kw = {'label1On': 'dummy_string_1', 'label2On': 'dummy_string_2',
37+
'tick1On': 'dummy_string_3', 'tick2On': 'dummy_string_4'}
38+
result = xaxis._translate_tick_params(kw, reverse=True)
39+
assert result['labelbottom'] == 'dummy_string_1'
40+
assert result['labeltop'] == 'dummy_string_2'
41+
assert result['bottom'] == 'dummy_string_3'
42+
assert result['top'] == 'dummy_string_4'

lib/matplotlib/tests/test_text.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,3 +1135,42 @@ 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=['text_xtick_ytick_rotation_modes'],
1157+
remove_text=False, extensions=['png'], style='mpl20')
1158+
def test_xtick_ytick_rotation_modes():
1159+
def set_ticks(ax, angles):
1160+
ax.set_xticks(np.arange(10))
1161+
ax.set_yticks(np.arange(10))
1162+
ax.set_xticklabels(['L'] * 10)
1163+
ax.set_yticklabels(['L'] * 10)
1164+
ax.xaxis.set_tick_params(rotation_mode='xtick', labelsize=7)
1165+
ax.yaxis.set_tick_params(rotation_mode='ytick', labelsize=7)
1166+
for label, angle in zip(ax.get_xticklabels(), angles):
1167+
label.set_rotation(angle)
1168+
for label, angle in zip(ax.get_yticklabels(), angles):
1169+
label.set_rotation(angle)
1170+
angles = np.linspace(0, 360, 10)
1171+
fig, axs = plt.subplots(1, 2, figsize=(5, 2.5))
1172+
set_ticks(axs[0], angles)
1173+
axs[1].xaxis.tick_top()
1174+
axs[1].yaxis.tick_right()
1175+
set_ticks(axs[1], angles)
1176+
plt.tight_layout()

lib/matplotlib/text.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,16 @@ def set_rotation_mode(self, m):
301301
302302
Parameters
303303
----------
304-
m : {None, 'default', 'anchor'}
304+
m : {None, 'default', 'anchor', 'xtick', 'ytick'}
305305
If ``"default"``, the text will be first rotated, then aligned according
306-
to their horizontal and vertical alignments. If ``"anchor"``, then
307-
alignment occurs before rotation. Passing ``None`` will set the rotation
308-
mode to ``"default"``.
306+
to their horizontal and vertical alignments. If ``"anchor"``, ``"xtick"``
307+
or ``"ytick", then alignment occurs before rotation. Passing ``None`` will
308+
set the rotation mode to ``"default"``.
309309
"""
310310
if m is None:
311311
m = "default"
312312
else:
313-
_api.check_in_list(("anchor", "default"), rotation_mode=m)
313+
_api.check_in_list(("anchor", "default", "xtick", "ytick"), rotation_mode=m)
314314
self._rotation_mode = m
315315
self.stale = True
316316

@@ -454,6 +454,11 @@ def _get_layout(self, renderer):
454454

455455
rotation_mode = self.get_rotation_mode()
456456
if rotation_mode != "anchor":
457+
angle = self.get_rotation()
458+
if rotation_mode == 'xtick':
459+
halign = self._ha_for_angle(angle)
460+
elif rotation_mode == 'ytick':
461+
valign = self._va_for_angle(angle)
457462
# compute the text location in display coords and the offsets
458463
# necessary to align the bbox with that location
459464
if halign == 'center':
@@ -1380,6 +1385,32 @@ def set_fontname(self, fontname):
13801385
"""
13811386
self.set_fontfamily(fontname)
13821387

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

13841415
class OffsetFrom:
13851416
"""Callable helper class for working with `Annotation`."""

lib/matplotlib/text.pyi

Lines changed: 2 additions & 0 deletions
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)