Skip to content

Implement xtick and ytick rotation modes #28968

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

Merged
merged 1 commit into from
Jan 31, 2025
Merged
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
28 changes: 28 additions & 0 deletions doc/users/next_whats_new/xtick_ytick_rotation_modes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
``xtick`` and ``ytick`` rotation modes
--------------------------------------

A new feature has been added for handling rotation of xtick and ytick
labels more intuitively. The new `rotation modes <matplotlib.text.Text.set_rotation_mode>`
"xtick" and "ytick" automatically adjust the alignment of rotated tick labels,
so that the text points towards their anchor point, i.e. ticks. This works for
all four sides of the plot (bottom, top, left, right), reducing the need for
manual adjustments when rotating labels.

.. plot::
:include-source: true
:alt: Example of rotated xtick and ytick labels.

import matplotlib.pyplot as plt

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3.5), layout='constrained')

pos = range(5)
labels = ['label'] * 5
ax1.set_xticks(pos, labels, rotation=-45, rotation_mode='xtick')
ax1.set_yticks(pos, labels, rotation=45, rotation_mode='ytick')
ax2.xaxis.tick_top()
ax2.set_xticks(pos, labels, rotation=-45, rotation_mode='xtick')
ax2.yaxis.tick_right()
ax2.set_yticks(pos, labels, rotation=45, rotation_mode='ytick')

plt.show()
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@

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

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

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

# Let the horizontal axes labeling appear on top.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1))
ax.set_title('Title0 1')
ax.xaxis.tick_top()
ax.tick_params(axis='x', rotation=55)
ax.set_xticks(ax.get_xticks())
ax.tick_params(axis='x', rotation=55, rotation_mode='xtick')


for i in range(2):
Expand All @@ -35,7 +36,8 @@
ax.set_ylabel('YLabel1 %d' % i)
ax.set_xlabel('XLabel1 %d' % i)
if i == 0:
ax.tick_params(axis='x', rotation=55)
ax.set_xticks(ax.get_xticks())
ax.tick_params(axis='x', rotation=55, rotation_mode='xtick')

fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels()
fig.align_titles()
Expand Down
4 changes: 2 additions & 2 deletions lib/matplotlib/axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def __init__(
# grid(color=(1, 1, 1, 0.5), alpha=rcParams['grid.alpha'])
# so the that the rcParams default would override color alpha.
grid_alpha = mpl.rcParams["grid.alpha"]
grid_kw = {k[5:]: v for k, v in kwargs.items()}
grid_kw = {k[5:]: v for k, v in kwargs.items() if k != "rotation_mode"}

self.tick1line = mlines.Line2D(
[], [],
Expand Down Expand Up @@ -1078,7 +1078,7 @@ def _translate_tick_params(kw, reverse=False):
'tick1On', 'tick2On', 'label1On', 'label2On',
'length', 'direction', 'left', 'bottom', 'right', 'top',
'labelleft', 'labelbottom', 'labelright', 'labeltop',
'labelrotation',
'labelrotation', 'rotation_mode',
*_gridline_param_names]

keymap = {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,3 +1135,58 @@ def test_font_wrap():
plt.text(3, 4, t, family='monospace', ha='right', wrap=True)
plt.text(-1, 0, t, fontsize=14, style='italic', ha='left', rotation=-15,
wrap=True)


def test_ha_for_angle():
text_instance = Text()
angles = np.arange(0, 360.1, 0.1)
for angle in angles:
alignment = text_instance._ha_for_angle(angle)
assert alignment in ['center', 'left', 'right']


def test_va_for_angle():
text_instance = Text()
angles = np.arange(0, 360.1, 0.1)
for angle in angles:
alignment = text_instance._va_for_angle(angle)
assert alignment in ['center', 'top', 'baseline']


@image_comparison(baseline_images=['xtick_rotation_mode'],
remove_text=False, extensions=['png'], style='mpl20')
def test_xtick_rotation_mode():
fig, ax = plt.subplots(figsize=(12, 1))
ax.set_yticks([])
ax2 = ax.twiny()

ax.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")
ax2.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")

angles = np.linspace(0, 360, 37)

for tick, angle in zip(ax.get_xticklabels(), angles):
tick.set_rotation(angle)
for tick, angle in zip(ax2.get_xticklabels(), angles):
tick.set_rotation(angle)

plt.subplots_adjust(left=0.01, right=0.99, top=.6, bottom=.4)


@image_comparison(baseline_images=['ytick_rotation_mode'],
remove_text=False, extensions=['png'], style='mpl20')
def test_ytick_rotation_mode():
fig, ax = plt.subplots(figsize=(1, 12))
ax.set_xticks([])
ax2 = ax.twinx()

ax.set_yticks(range(37), ['foo'] * 37, rotation_mode="ytick")
ax2.set_yticks(range(37), ['foo'] * 37, rotation_mode='ytick')

angles = np.linspace(0, 360, 37)
for tick, angle in zip(ax.get_yticklabels(), angles):
tick.set_rotation(angle)
for tick, angle in zip(ax2.get_yticklabels(), angles):
tick.set_rotation(angle)

plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
42 changes: 38 additions & 4 deletions lib/matplotlib/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,19 @@ def set_rotation_mode(self, m):

Parameters
----------
m : {None, 'default', 'anchor'}
m : {None, 'default', 'anchor', 'xtick', 'ytick'}
If ``"default"``, the text will be first rotated, then aligned according
to their horizontal and vertical alignments. If ``"anchor"``, then
alignment occurs before rotation. Passing ``None`` will set the rotation
mode to ``"default"``.
alignment occurs before rotation. "xtick" and "ytick" adjust the
horizontal/vertical alignment so that the text is visually pointing
towards its anchor point. This is primarily used for rotated tick
labels and positions them nicely towards their ticks. Passing
``None`` will set the rotation mode to ``"default"``.
"""
if m is None:
m = "default"
else:
_api.check_in_list(("anchor", "default"), rotation_mode=m)
_api.check_in_list(("anchor", "default", "xtick", "ytick"), rotation_mode=m)
self._rotation_mode = m
self.stale = True

Expand Down Expand Up @@ -454,6 +457,11 @@ def _get_layout(self, renderer):

rotation_mode = self.get_rotation_mode()
if rotation_mode != "anchor":
angle = self.get_rotation()
if rotation_mode == 'xtick':
halign = self._ha_for_angle(angle)
elif rotation_mode == 'ytick':
valign = self._va_for_angle(angle)
# compute the text location in display coords and the offsets
# necessary to align the bbox with that location
if halign == 'center':
Expand Down Expand Up @@ -1380,6 +1388,32 @@ def set_fontname(self, fontname):
"""
self.set_fontfamily(fontname)

def _ha_for_angle(self, angle):
"""
Determines horizontal alignment ('ha') for rotation_mode "xtick" based on
the angle of rotation in degrees and the vertical alignment.
"""
anchor_at_bottom = self.get_verticalalignment() == 'bottom'
if (angle <= 10 or 85 <= angle <= 95 or 350 <= angle or
170 <= angle <= 190 or 265 <= angle <= 275):
return 'center'
elif 10 < angle < 85 or 190 < angle < 265:
return 'left' if anchor_at_bottom else 'right'
return 'right' if anchor_at_bottom else 'left'

def _va_for_angle(self, angle):
"""
Determines vertical alignment ('va') for rotation_mode "ytick" based on
the angle of rotation in degrees and the horizontal alignment.
"""
anchor_at_left = self.get_horizontalalignment() == 'left'
if (angle <= 10 or 350 <= angle or 170 <= angle <= 190
or 80 <= angle <= 100 or 260 <= angle <= 280):
return 'center'
elif 190 < angle < 260 or 10 < angle < 80:
return 'baseline' if anchor_at_left else 'top'
return 'top' if anchor_at_left else 'baseline'


class OffsetFrom:
"""Callable helper class for working with `Annotation`."""
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/text.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ class Text(Artist):
def set_fontname(self, fontname: str | Iterable[str]) -> None: ...
def get_antialiased(self) -> bool: ...
def set_antialiased(self, antialiased: bool) -> None: ...
def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ...
def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ...

class OffsetFrom:
def __init__(
Expand Down
Loading