Skip to content

[ENH]: Better positioning of rotated tick labels #28951

Closed

Description

@timhoffm

Problem

Tick labels are currently centered, also with rotation:

plt.bar(range(3), [1, 3, 2], tick_label=['very long tick labels'] * 3)
plt.tick_params(rotation=30)

image

The rotated labels do not "point" to the ticks anymore, which can make it difficult to see which label belongs to which tick - up to being outright misleading, e.g.

N = 20
plt.bar(range(N), range(N), tick_label=[f'tick label {i}' for i in range(N)])
plt.tick_params(rotation=45)

image

A common workaround is to adapt the alignment of the tick labels (e.g. demonstated in this example). The solution is not satisfying for two reasons:

  • The canonical way to adjust tick styles is tick_params, but we don't have alignment in there yet (and that has reasons, see No support for horizontal alignment in tick_params?  #13774). Therefore people have to resort to setting the alignment on the individual tick instances, which has the major disadvantage that it does not work for dynamically changing plots because tick instances are volatile.
  • When rotating ticks, I expect that it "just works" and I don't have to additionally fine tune the result.

Proposed solution

A new rotation algorithm that rotates the ticks appropriately. We can include this as a new rotation_mode type (see Text.set_rotation_mode).

Essentially, the labels should "point" towards their ticks. Note that this results in a very particular alignment: -90°, 0° and 90° want a centered alignment, whereas diagonal tick labels want a left/right alignment.

One can get approximately good results by tuning the alignment depending on the angle.

Example code (color coding for alignment: black: 'center', green: 'right', red: 'left')
import matplotlib.pyplot as plt
import numpy as np


def ha_for_angle(angle):
    if angle < -90 or angle > 90:
        raise ValueError('Todo: not thought about reasonable values for upside-down labels')
    if angle < -60:
        return 'center'
    if angle < -5:
        return 'left'
    if angle <= 5:
        return 'center'
    if angle <= 60:
        return 'right'
    if angle <= 90:
        return 'center'
   

fig, ax = plt.subplots(figsize=(18, 1))
angles = np.linspace(90, -90, 37)
N = len(angles)
ax.set_xticks(range(N), ['label']*N)
ax.set_xlim(-0.5, N -0.5)
tls = ax.xaxis.get_majorticklabels()
for ticklabel, angle in zip(tls, angles):
    ha = ha_for_angle(angle)
    color = {'left': 'r', 'center': 'k', 'right': 'g'}[ha]
    ticklabel.set(rotation=angle, ha=ha, color=color)

image

While one could try and come up with mathematical more accurate text position methods, this simple trick gives reasonably good results.

Concrete suggestion: Introduce rotation modes "xtick", "ytick" (exact names t.b.d.). "xtick" ignores the horizontal alignment setting and instead chooses a suitable one depending on the angle as above. Likewise "ytick" chooses a suitable vertical alignment.

Depending on how strong we feel about backward compatibility, we can either make these rotation modes the default for ticks. Or we are defensive and only add it to tick_params for now, so that one could write ax.xaxis.tick_params(rotation=45, rotation_mode='xtick').

Activity

  1. story645 commented on Oct 7, 2024

    @story645
    Member

    We can include this as a new rotation_mode type (see Text.set_rotation_mode).

    I think it fits w/ the current rotation mode b/c this is about setting the anchor relative to the tick position?
    Like here, you'd want the same point on the last letter of the respective label to be the anchor point (rather than the text center):
    374314440-8932be5c-c65e-4cd3-ba66-290aa2ab1306

    I could be misunderstanding your suggestion,but I think the anchor point should be the same for all labels b/c anything else will look wildly inconsistent?

  2. timhoffm commented on Oct 7, 2024

    @timhoffm
    MemberAuthor

    I think you are misunderstanding. The desired position of the anchor depends on the rotation. For the positive roations it's the last letter, for negative rotations it would be the first letter, and for 0 rotation it would be at the center of the string. This is not achievable with the current rotation modes (at least not without manually adapting the alignment - but that's exactly the point).

    I think the anchor point should be the same for all labels b/c anything else will look wildly inconsistent?

    All labels will typically have the same rotation angle, and thus nothing looks inconsistent, even though the used anchor depends on the position. The example plot with the variing angles shown above in the proposal is an artificial plot. It only serves to demonstate that the label can "point" towards the label for all angles if we adapt alignment depending on the angle. Each label is a sample angle that you could imagine to be used for all tick labels of an axes - I just didn't want to make 36 plots to illustrate that point.

  3. kc9323 commented on Oct 8, 2024

    @kc9323
    Contributor

    This proposal sounds great! Would it be alright if I worked on this and opened a draft PR? I'd love to help implement this.

  4. timhoffm commented on Oct 9, 2024

    @timhoffm
    MemberAuthor

    @kyracho you are very welcome!

    I suggest to start with an implementation for the xticks (yticks are analogous but likely need their own code path). You can take my example from above as the basis. You may further tune the angle boundaries where to switch switching alignment, and you should expand to full 360 degrees.

    To give you a head start: The algorithmic implementation of the rotation mode is here:

    rotation_mode = self.get_rotation_mode()
    if rotation_mode != "anchor":
    # compute the text location in display coords and the offsets
    # necessary to align the bbox with that location
    if halign == 'center':
    offsetx = (xmin + xmax) / 2
    elif halign == 'right':
    offsetx = xmax
    else:
    offsetx = xmin
    if valign == 'center':
    offsety = (ymin + ymax) / 2
    elif valign == 'top':
    offsety = ymax
    elif valign == 'baseline':
    offsety = ymin + descent
    elif valign == 'center_baseline':
    offsety = ymin + height - baseline / 2.0
    else:
    offsety = ymin
    else:
    xmin1, ymin1 = corners_horiz[0]
    xmax1, ymax1 = corners_horiz[2]
    if halign == 'center':
    offsetx = (xmin1 + xmax1) / 2.0
    elif halign == 'right':
    offsetx = xmax1
    else:
    offsetx = xmin1
    if valign == 'center':
    offsety = (ymin1 + ymax1) / 2.0

    Feel free to ask questions along the way as they arise.

  5. added this to the v3.11.0 milestone on Feb 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions