New solution rotation_mode="anchor"
There is actually an argument rotation_mode
to matplotlib.text.Text
, which steers exactly the requested functionality. The default is rotation_mode="default"
which recreates the unwanted behaviour from the question, while rotation_mode="anchor"
anchors the point of revolution according to the text itself and not its bounding box.
ax.text(x,y,'test', rotation = deg, rotation_mode="anchor")
Also see the demo_text_rotation_mode example.
With this, the example from the question can be created easily without the need to subclass Text
.
from matplotlib import pyplot as plt
import numpy as np
fig, axes = plt.subplots(3,3, figsize=(10,10),dpi=100)
aligns = [ (va,ha) for va in ('top', 'center', 'bottom')
for ha in ('left', 'center', 'right')]
xys = [[i,j] for j in np.linspace(0.9,0.1,5) for i in np.linspace(0.1,0.9,5)]
degs = np.linspace(0,360,25)
for ax, align in zip(axes.reshape(-1), aligns):
ax.set_xlim([-0.1,1.1])
ax.set_ylim([-0.1,1.1])
for deg,xy in zip(degs,xys):
x,y = xy
ax.plot(x,y,'r.')
text = ax.text(x,y,'test',
rotation = deg,
rotation_mode="anchor", ### <--- this is the key
va = align[0],
ha = align[1],
bbox=dict(facecolor='none', edgecolor='blue', pad=0.0),
)
ax.set_title('alignment = {}'.format(align))
fig.tight_layout()
plt.show()
old solution, subclassing Text
In case one is still interested, the solution given by @ThomasKühn is of course working fine, but has some drawbacks when text is used in a non-cartesian system, because it calculates the offset needed in Data coordinates.
The following would be a version of the code which offsets the text in display coordinates by using a transformation, which is temporarily attached while drawing the text. It can therefore also be used e.g. in polar plots.
from matplotlib import pyplot as plt
from matplotlib import patches, text
import matplotlib.transforms
import numpy as np
class TextTrueAlign(text.Text):
"""
A Text object that always aligns relative to the text, not
to the bounding box; also when the text is rotated.
"""
def __init__(self, x, y, text, **kwargs):
super(TextTrueAlign, self).__init__(x,y,text, **kwargs)
self.__Ha = self.get_ha()
self.__Va = self.get_va()
def draw(self, renderer, *args, **kwargs):
"""
Overload of the Text.draw() function
"""
trans = self.get_transform()
offset = self.update_position()
# while drawing, set a transform which is offset
self.set_transform(trans + offset)
super(TextTrueAlign, self).draw(renderer, *args, **kwargs)
# reset to original transform
self.set_transform(trans)
def update_position(self):
"""
As the (center/center) alignment always aligns to the center of the
text, even upon rotation, we make use of this here. The algorithm
first computes the (x,y) offset for the un-rotated text between
centered alignment and the alignment requested by the user. This offset
is then rotated by the given rotation angle.
Finally a translation of the negative offset is returned.
"""
#resetting to the original state:
rotation = self.get_rotation()
self.set_rotation(0)
self.set_va(self.__Va)
self.set_ha(self.__Ha)
##from https://stackoverflow.com/questions/5320205/matplotlib-text-dimensions
##getting the current renderer, so that
##get_window_extent() works
renderer = self.axes.figure.canvas.get_renderer()
##computing the bounding box for the un-rotated text
##aligned as requested by the user
bbox1 = self.get_window_extent(renderer=renderer)
##re-aligning text to (center,center) as here rotations
##do what is intuitively expected
self.set_va('center')
self.set_ha('center')
##computing the bounding box for the un-rotated text
##aligned to (center,center)
bbox2 = self.get_window_extent(renderer=renderer)
##computing the difference vector between the two alignments
dr = np.array(bbox2.get_points()[0]-bbox1.get_points()[0])
##computing the rotation matrix, which also accounts for
##the aspect ratio of the figure, to stretch squeeze
##dimensions as needed
rad = np.deg2rad(rotation)
rot_mat = np.array([
[np.cos(rad), np.sin(rad)],
[-np.sin(rad), np.cos(rad)]
])
##computing the offset vector
drp = np.dot(dr,rot_mat)
# transform to translate by the negative offset vector
offset = matplotlib.transforms.Affine2D().translate(-drp[0],-drp[1])
##setting rotation value back to the one requested by the user
self.set_rotation(rotation)
return offset
if __name__ == '__main__':
fig, axes = plt.subplots(3,3, figsize=(10,10),dpi=100)
aligns = [ (va,ha) for va in ('top', 'center', 'bottom')
for ha in ('left', 'center', 'right')]
xys = [[i,j] for j in np.linspace(0.9,0.1,5) for i in np.linspace(0.1,0.9,5)]
degs = np.linspace(0,360,25)
for ax, align in zip(axes.reshape(-1), aligns):
ax.set_xlim([-0.1,1.1])
ax.set_ylim([-0.1,1.1])
for deg,xy in zip(degs,xys):
x,y = xy
ax.plot(x,y,'r.')
text = TextTrueAlign(
x = x,
y = y,
text='test',
axes = ax,
rotation = deg,
va = align[0],
ha = align[1],
bbox=dict(facecolor='none', edgecolor='blue', pad=0.0),
)
ax.add_artist(text)
ax.set_title('alignment = {}'.format(align))
fig.tight_layout()
plt.show()