5

I'm trying to have a rotated text in matplotlib. unfortunately the rotation seems to be in the display coordinate system, and not in the data coordinate system. that is:

import numpy as np
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_axes([0.15, 0.1, 0.8, 0.8])
t = np.arange(0.0, 1.0, 0.01)
line, = ax.plot(t, t, color='blue', lw=2)
ax.text (0.51,0.51,"test label", rotation=45)
plt.show()

will give a line that will be in a 45 deg in the data coordinate system, but the accompanied text will be in a 45 deg in the display coordinate system. I'd like to have the text and data to be aligned even when resizing the figure. I saw here that I can transform the rotation, but this will works only as long as the plot is not resized. I tried writing ax.text (0.51,0.51,"test label", transform=ax.transData, rotation=45), but it seems to be the default anyway, and doesn't help for the rotation

Is there a way to have the rotation in the data coordinate system ?

EDIT:

I'm interested in being able to resize the figure after I draw it - this is because I usually draw something and then play with the figure before saving it

nopede11
  • 297
  • 3
  • 7

3 Answers3

9

You may use the following class to create the text along the line. Instead of an angle it takes two points (p and pa) as input. The connection between those two points define the angle in data coordinates. If pa is not given, the connecting line between p and xy (the text coordinate) is used.
The angle is then updated automatically such that the text is always oriented along the line. This even works with logarithmic scales.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.text as mtext
import matplotlib.transforms as mtransforms


class RotationAwareAnnotation(mtext.Annotation):
    def __init__(self, s, xy, p, pa=None, ax=None, **kwargs):
        self.ax = ax or plt.gca()
        self.p = p
        if not pa:
            self.pa = xy
        self.calc_angle_data()
        kwargs.update(rotation_mode=kwargs.get("rotation_mode", "anchor"))
        mtext.Annotation.__init__(self, s, xy, **kwargs)
        self.set_transform(mtransforms.IdentityTransform())
        if 'clip_on' in kwargs:
            self.set_clip_path(self.ax.patch)
        self.ax._add_text(self)

    def calc_angle_data(self):
        ang = np.arctan2(self.p[1]-self.pa[1], self.p[0]-self.pa[0])
        self.angle_data = np.rad2deg(ang)

    def _get_rotation(self):
        return self.ax.transData.transform_angles(np.array((self.angle_data,)), 
                            np.array([self.pa[0], self.pa[1]]).reshape((1, 2)))[0]

    def _set_rotation(self, rotation):
        pass

    _rotation = property(_get_rotation, _set_rotation)

Example usage:

fig, ax = plt.subplots()
t = np.arange(0.0, 1.0, 0.01)
line, = ax.plot(t, t, color='blue', lw=2)

ra = RotationAwareAnnotation("test label", xy=(.5,.5), p=(.6,.6), ax=ax,
                             xytext=(2,-1), textcoords="offset points", va="top")

plt.show()

enter image description here

Alternative for edge-cases

The above may fail in certain cases of text along a vertical line or on scales with highly dissimilar x- and y- units (example here). In that case, the following would be better suited. It calculates the angle in screen coordinates, instead of relying on an angle transformation.

class RotationAwareAnnotation2(mtext.Annotation):
    def __init__(self, s, xy, p, pa=None, ax=None, **kwargs):
        self.ax = ax or plt.gca()
        self.p = p
        if not pa:
            self.pa = xy
        kwargs.update(rotation_mode=kwargs.get("rotation_mode", "anchor"))
        mtext.Annotation.__init__(self, s, xy, **kwargs)
        self.set_transform(mtransforms.IdentityTransform())
        if 'clip_on' in kwargs:
            self.set_clip_path(self.ax.patch)
        self.ax._add_text(self)

    def calc_angle(self):
        p = self.ax.transData.transform_point(self.p)
        pa = self.ax.transData.transform_point(self.pa)
        ang = np.arctan2(p[1]-pa[1], p[0]-pa[0])
        return np.rad2deg(ang)

    def _get_rotation(self):
        return self.calc_angle()

    def _set_rotation(self, rotation):
        pass

    _rotation = property(_get_rotation, _set_rotation)

For usual cases, both result in the same output. I'm not sure if the second class has any drawbacks, so I'll leave both in here, choose whichever you seem more suitable.

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
2

Ok, starting off with code similar to your example:

%pylab inline
import numpy as np
fig = plt.figure()
ax = fig.add_axes([0.15, 0.1, 0.8, 0.8])
t = np.arange(0.0, 1.0, 0.01)
line, = ax.plot(t, t, color='blue', lw=2)
ax.text(0.51,0.51,"test label", rotation=45)
plt.show()

enter image description here

As you indicated, the text label is not rotated properly to be parallel with the line.

The dissociation in coordinate systems for the text object rotation relative to the line has been explained at this link as you indicated. The solution is to transform the text rotation angle from the plot to the screen coordinate system, and let's see if resizing the plot causes issues as you suggest:

for fig_size in [(3.0,3.0),(9.0,3.0),(3.0,9.0)]: #use different sizes, in inches
    fig2 = plt.figure(figsize=fig_size)
    ax = fig2.add_axes([0.15, 0.1, 0.8, 0.8])
    text_plot_location = np.array([0.51,0.51]) #I'm using the same location for plotting text as you did above
    trans_angle = gca().transData.transform_angles(np.array((45,)),text_plot_location.reshape((1,2)))[0]
    line, = ax.plot(t, t, color='blue', lw=2)
    ax.text(0.51,0.51,"test label", rotation=trans_angle)
    plt.show()

enter image description here enter image description here enter image description here

Looks good to me, even with resizing. Now, if you make the line longer and the axis limits longer, then of course you'd have to adjust the text drawing to occur at the new center of the plot.

treddy
  • 2,771
  • 2
  • 18
  • 31
  • 1
    I think I need to edit the question: your answer works as long as I don't change the figure after drawing it. my problem is that usually after drawing the figure I manually play with it before saving – nopede11 Nov 27 '13 at 15:14
0

In case anyone is interested, below is a modification I made to ImportanceOfBeingErnest's code. This version will take an offset value and calculate the x,y coordinates to offset that number of points perpendicular to the angle that it calculated.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.text as mtext
import matplotlib.transforms as mtransforms


class RotationAwareAnnotation(mtext.Annotation):
    def __init__(self, s, xy, p, pa=None, perpendicular_offset=None, ax=None, **kwargs):
        self.ax = ax or plt.gca()
        self.p = p
        self.xy = xy
        if not pa:
            self.pa = xy
        else:
            self.pa = pa
        self.calc_angle_data()
        self.perpendicular_offset = perpendicular_offset
        kwargs.update(rotation_mode=kwargs.get("rotation_mode", "anchor"))
        xytext = self.get_offset_coordinates()
        mtext.Annotation.__init__(self, s, xy, xytext=xytext, **kwargs)  # Pass xy and xytext
        self.set_transform(mtransforms.IdentityTransform())
        if 'clip_on' in kwargs:
            self.set_clip_path(self.ax.patch)
        self.ax._add_text(self)

    def calc_angle_data(self):
        ang = np.arctan2(self.p[1] - self.pa[1], self.p[0] - self.pa[0])
        self.angle_data = np.rad2deg(ang)

    def _get_rotation(self):
        return self.ax.transData.transform_angles(
            np.array((self.angle_data,)), np.array([self.pa[0], self.pa[1]]).reshape((1, 2))
        )[0]

    def _set_rotation(self, rotation):
        pass

    _rotation = property(_get_rotation, _set_rotation)

    def get_offset_coordinates(self):
        angle_rad = np.deg2rad(self.angle_data + 90)  # Calculate the perpendicular angle
        dx = self.perpendicular_offset * np.cos(angle_rad)  # Calculate the x offset
        dy = self.perpendicular_offset * np.sin(angle_rad)  # Calculate the y offset
        return (self.xy[0] + dx, self.xy[1] + dy)  # Return the offset coordinates


fig, ax = plt.subplots(figsize=(8, 8))
t = np.arange(0.0, 1.0, 0.01)
line, = ax.plot(t, t, color='blue', lw=2)
ax.set_yscale('log')

ra = RotationAwareAnnotation(
    "test label",
    xy=(.5, .5),
    p=(.8, .8),
    ax=ax,
    perpendicular_offset=10,
    textcoords="offset points", 
    va="bottom",
    ha="center"
)

plt.show()