Text alignment *within* bounding box - python

The alignment of a text box can be specified with the horizontalalignment (ha) and verticalalignment (va) arguments, e.g.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8,5))
plt.subplots_adjust(right=0.5)
txt = "Test:\nthis is some text\ninside a bounding box."
fig.text(0.7, 0.5, txt, ha='left', va='center')
Which produces:
Is there anyway to keep the same bounding-box (bbox) alignment, while changing the alignment of the text within that bounding-box? e.g. for the text to be centered in the bounding-box.
(Obviously in this situation I could just replace the bounding-box, but in more complicated cases I'd like to change the text alignment independently.)

The exact bbox depends on the renderer of your specific backend. The following example preserves the x position of the text bbox. It is a bit trickier to exactly preserve both x and y:
import matplotlib
import matplotlib.pyplot as plt
def get_bbox(txt):
renderer = matplotlib.backend_bases.RendererBase()
return txt.get_window_extent(renderer)
fig, ax = plt.subplots(figsize=(8,5))
plt.subplots_adjust(right=0.5)
txt = "Test:\nthis is some text\ninside a bounding box."
text_inst = fig.text(0.7, 0.5, txt, ha='left', va='center')
bbox = get_bbox(text_inst)
bbox_fig = bbox.transformed(fig.transFigure.inverted())
print "original bbox (figure system)\t:", bbox.transformed(fig.transFigure.inverted())
# adjust horizontal alignment
text_inst.set_ha('right')
bbox_new = get_bbox(text_inst)
bbox_new_fig = bbox_new.transformed(fig.transFigure.inverted())
print "aligned bbox\t\t\t:", bbox_new_fig
# shift back manually
offset = bbox_fig.x0 - bbox_new_fig.x0
text_inst.set_x(bbox_fig.x0 + offset)
bbox_shifted = get_bbox(text_inst)
print "shifted bbox\t\t\t:", bbox_shifted.transformed(fig.transFigure.inverted())
plt.show()

Related

How to measure a text element in matplotlib

I need to lay out a table full of text boxes using matplotlib. It should be obvious how to do this: create a gridspec for the table members, fill in each element of the grid, take the maximum heights and widths of the elements in the grid, change the appropriate height and widths of the grid columns and rows. Easy peasy, right?
Wrong.
Everything works except the measurements of the items themselves. Matplotlib consistently returns the wrong size for each item. I believe that I have been able to track this down to not even being able to measure the size of a text path correctly:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatch
import matplotlib.text as mtext
import matplotlib.path as mpath
import matplotlib.patches as mpatches
fig, ax = plt.subplots(1, 1)
ax.set_axis_off()
text = '!?' * 16
size=36
## Buildand measure hidden text path
text_path=mtext.TextPath(
(0.0, 0.0),
text,
prop={'size' : size}
)
vertices = text_path.vertices
code = text_path.codes
min_x, min_y = np.min(
text_path.vertices[text_path.codes != mpath.Path.CLOSEPOLY], axis=0)
max_x, max_y = np.max(
text_path.vertices[text_path.codes != mpath.Path.CLOSEPOLY], axis=0)
## Transform measurement to graph units
transData = ax.transData.inverted()
((local_min_x, local_min_y),
(local_max_x, local_max_y)) = transData.transform(
((min_x, min_y), (max_x, max_y)))
## Draw a box which should enclose the path
x_offset = (local_max_x - local_max_y) / 2
y_offset = (local_max_y - local_min_y) / 2
local_min_x = 0.5 - x_offset
local_min_y = 0.5 - y_offset
local_max_x = 0.5 + x_offset
local_max_y = 0.5 + y_offset
path_data = [
(mpath.Path.MOVETO, (local_min_x, local_min_y)),
(mpath.Path.LINETO, (local_max_x, local_min_y)),
(mpath.Path.LINETO, (local_max_x, local_max_y)),
(mpath.Path.LINETO, (local_min_x, local_max_y)),
(mpath.Path.LINETO, (local_min_x, local_min_y)),
(mpath.Path.CLOSEPOLY, (local_min_x, local_min_y)),
]
codes, verts = zip(*path_data)
path = mpath.Path(verts, codes)
patch = mpatches.PathPatch(
path,
facecolor='white',
edgecolor='red',
linewidth=3)
ax.add_patch(patch)
## Draw the text itself
item_textbox = ax.text(
0.5, 0.5,
text,
bbox=dict(boxstyle='square',
fc='white',
ec='white',
alpha=0.0),
transform=ax.transAxes,
size=size,
horizontalalignment="center",
verticalalignment="center",
alpha=1.0)
plt.show()
Run this under Python 3.8
Expect: the red box to be the exact height and width of the text
Observe: the red box is the right height, but is most definitely not the right width.
There doesn't seem to be any way to do this directly, but there's a way to do it indirectly: instead of using a text box, use TextPath, transform it to Axis coordinates, and then use the differences between min and max on each coordinate. (See https://matplotlib.org/stable/gallery/text_labels_and_annotations/demo_text_path.html#sphx-glr-gallery-text-labels-and-annotations-demo-text-path-py for a sample implementation. This implementation has a significant bug -- it uses vertices and codes directly, which break in the case of a clipped text path.)

How to make a multi-column text annotation in matplotlib?

matplotlib's legend() method has an ncol parameter to lay out the content in multiple columns.
I want to do the same thing in a text annotation.
I can pass multi-line text string (i.e., one that contains \ns) to annotate(), but how can I array the content in two columns?
use tab characters? They don't seem to do anything
use two separate text annotations? But I want a round border (bbox) around the two columns
use something like ncol? It can wrap columns according to the number of columns I've asked for
I couln't find an nice way to do this so I wrote a function that gets the jobs done. Try it out and see if it does what you need.
def place_column_text(ax, text, xy, wrap_n, shift, bbox=False, **kwargs):
""" Creates a text annotation with the text in columns.
The text columns are provided by a list of strings.
A surrounding box can be added via bbox=True parameter.
If so, FancyBboxPatch kwargs can be specified.
The width of the column can be specified by wrap_n,
the shift parameter determines how far apart the columns are.
The axes are specified by the ax parameter.
Requires:
import textwrap
import matplotlib.patches as mpatches
"""
# place the individual text boxes, with a bbox to extract details from later
x,y = xy
n = 0
text_boxes = []
for i in text:
text = textwrap.fill(i, wrap_n)
box = ax.text(x = x + n, y = y, s=text, va='top', ha='left',
bbox=dict(alpha=0, boxstyle='square,pad=0'))
text_boxes.append(box)
n += shift
if bbox == True: # draw surrounding box
# extract box data
plt.draw() # so we can extract real bbox data
# first let's calulate the height of the largest bbox
heights=[]
for box in text_boxes:
heights.append(box.get_bbox_patch().get_extents().transformed(ax.transData.inverted()).bounds[3])
max_height=max(heights)
# then calculate the furthest x value of the last bbox
end_x = text_boxes[-1].get_window_extent().transformed(ax.transData.inverted()).xmax
# draw final
width = end_x - x
fancypatch_y = y - max_height
rect = mpatches.FancyBboxPatch(xy=(x,fancypatch_y), width=width, height=max_height, **kwargs)
ax.add_patch(rect)
Here is it in use:
import matplotlib.patches as mpatches
import textwrap
fig, ax = plt.subplots(2,2,sharex=True, sharey=True,figsize=(16,12))
fig.subplots_adjust(hspace=0.05, wspace=0.05)
ax1, ax2, ax3, ax4 = ax.flatten()
for a in ax.flatten():
a.plot(range(0,20),range(0,20), alpha=0)
# the text to be split into columns and annotated
col_text = ['Colum 1 text is this sentence here.',
'The text for column two is going to be longer',
'Column 3 is the third column.',
'And last but not least we have column 4. In fact this text is the longest.']
# use the function to place the text
place_column_text(ax=ax1,text=col_text, xy=(1,10), wrap_n=10, shift=4.2)
place_column_text(ax=ax2,text=col_text, xy=(0,19), wrap_n=17, bbox=True, shift=5, ec='red', fc='w', boxstyle='square')
place_column_text(ax=ax3,text=col_text, xy=(2,18), wrap_n=6, bbox=True, shift=2.7, ec='blue', fc = 'blue' , alpha=0.3, boxstyle='round,pad=1')
place_column_text(ax=ax4,text=col_text, xy=(3,12), wrap_n=10, bbox=True, shift=3, ec='red', fc='w', boxstyle='circle, pad=3')
plt.show()
Result:

Conditionally moving the position of a single data label in a pie chart

The following sample code will generate the donut chart I'll use as my example:
import matplotlib.pyplot as plt
%matplotlib inline
# Following should supposedly set the font correctly:
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Muli'] + plt.rcParams['font.sans-serif']
plt.rcParams['font.weight'] = 'extra bold'
size_of_groups=[12,11,30,0.3]
colors = ['#a1daaa','#bbbbb4','#444511','#1afff2']
import matplotlib as mpl
mpl.rcParams['text.color'] = '#273859'
# Create a pieplot
my_pie,texts,_ = plt.pie(size_of_groups,radius = 1.2,colors=colors,autopct="%.1f%%",
textprops = {'color':'w',
'size':15 #, 'weight':"extra bold"
}, pctdistance=0.75, labeldistance=0.7) #pctdistance and labeldistance change label positions.
labels=['High','Low','Normal','NA']
plt.legend(my_pie,labels,loc='lower center',ncol=2,bbox_to_anchor=(0.5, -0.2))
plt.setp(my_pie, width=0.6, edgecolor='white')
fig1 = plt.gcf()
fig1.show()
The above outputs this:
Mostly, this is great. Finally I got a nice looking donut chart!
But there is just one last thing to finesse - when the portion of the donut chart is very small (like the 0.6%), I need the labels to be moved out of the chart, and possibly colored black instead.
I managed to do something similar for bar charts using plt.text, but I don't think that will be feasible with pie charts at all. I figure someone has definitely solved a similar problem before, but I can't readily fine any decent solutions.
Here is a way to move all percent-texts for patches smaller than some given amount (5 degrees in the code example). Note that this will also fail when there would be multiple small pieces close to each other.
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
size_of_groups = [12, 11, 30, 0.3]
colors = ['#a1daaa', '#bbbbb4', '#444511', '#1afff2']
my_pie, texts, pct_txts = plt.pie(size_of_groups, radius=1.2, colors=colors, autopct="%.1f%%",
textprops={'color': 'w', 'size': 15}, pctdistance=0.75,
labeldistance=0.7)
labels = ['High', 'Low', 'Normal', 'NA']
plt.legend(my_pie, labels, loc='lower center', ncol=2, bbox_to_anchor=(0.5, -0.2))
plt.setp(my_pie, width=0.6, edgecolor='white')
for patch, txt in zip(my_pie, pct_txts):
if (patch.theta2 - patch.theta1) <= 5:
# the angle at which the text is normally located
angle = (patch.theta2 + patch.theta1) / 2.
# new distance to the pie center
x = patch.r * 1.2 * np.cos(angle * np.pi / 180)
y = patch.r * 1.2 * np.sin(angle * np.pi / 180)
# move text to new position
txt.set_position((x, y))
txt.set_color('black')
plt.tight_layout()
plt.show()
I attempted a solution by tweaking the solution of ImportanceOfBeingErnest on a different problem given here. For some reason, the percentage sign is not being displayed in my system but you can figure that out
rad = 1.2 # Define a radius variable for later use
my_pie, texts, autotexts = plt.pie(size_of_groups, radius=rad, colors=colors, autopct="%.1f%%",
pctdistance=0.75, labeldistance=0.7, textprops={'color':'white', 'size':20})
# Rest of the code
cx, cy = 0, 0 # Center of the pie chart
for t in autotexts:
x, y = t.get_position()
text = t.get_text()
if float(text.strip('%')) < 1: # Here 1 is the target threshold percentage
angle = np.arctan2(y-cy, x-cx)
xt, yt = 1.1*rad*np.cos(angle)+cx, 1.1*rad*np.sin(angle)+cy
t.set_color("k")
t.set_position((xt,yt))

Add a legend for an animation (of Artists) in matplotlib

I have made an animation from a set of images like this (10 snapshots):
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import time
infile = open ('out.txt')
frame_counter = 0
N_p = 100
N_step = 10
N_line = N_p*N_step
for s in xrange(N_step):
x, y = [], []
for i in xrange(N_p):
data = infile.readline()
raw = data.split()
x.append(float(raw[0]))
y.append(float(raw[1]))
xnp = np.array(x)
ynp = np.array(y)
fig = plt.figure(0)
ax = fig.add_subplot(111, aspect='equal')
for x, y in zip(xnp, ynp):
cir = Circle(xy = (x, y), radius = 1)
cir.set_facecolor('red')
ax.add_artist(cir)
cir.set_clip_box(ax.bbox)
ax.set_xlim(-10, 150)
ax.set_ylim(-10, 150)
fig.savefig("step.%04d.png" % frame_counter)
ax.remove()
frame_counter +=1
Now I want to add a legend to each image showing the time step.
For doing this I must set legends to each of these 10 images. The problem is that I have tested different things like ax.set_label , cir.set_label, ...
and I get errors like this:
UserWarning: No labelled objects found. Use label='...' kwarg on individual plots
According to this error I must add label to my individual plots, but since this is a plot of Artists, I don't know how I can do this.
If for whatever reason you need a legend, you can show your Circle as the handle and use some text as the label.
ax.legend(handles=[cir], labels=["{}".format(frame_counter)])
If you don't really need a legend, you can just use some text to place inside the axes.
ax.text(.8,.8, "{}".format(frame_counter), transform=ax.transAxes)

Align legend rows in matplotlib

I am doing a plot with matplotlib and creating a legend for this (see code below). I want the legends rows be aligned horizontally such that the relations > and < are aligned. Trying to adapt this and this code of similar problems, i got stuck.
I understand the basic idea: use \makebox[width][alignment]{math expression before aligment}<math expression after alignment as label, such that the space used by that epsilon-expression always uses the same space and is aligned to the right, hence there is free space to the left.
But the \hfill-methods used in the links only work if there is text before it the hfill, or if the alignment is standard (left). The solution must be quite near and any help would be appreciated.
This is how the text of the legend should look like
import numpy
from matplotlib import pyplot as plt
plt.rc('text', usetex=True) # needed for interpeting tex strings, but changes appearence of axis-tick labels also
fig = plt.figure(1,figsize=(12.0, 8.0))
plt.ion()
# does not align the '<', '<' and '>' in the legend
# plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{$\varepsilon_i$}$< -\xi$')
# plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{$|\varepsilon_i|$}$< \xi$')
# plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{$\varepsilon_i$}$ > \xi$')
# \hfill doesnt change anything
# plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{\hfill$\varepsilon_i$}$< -\xi$')
# plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{\hfill$|\varepsilon_i|$}$< \xi$')
# plt.plot(numpy.random.rand(10), label=r'\makebox[24cm][r]{\hfill$\varepsilon_i$}$ > \xi$')
# the relations are aligned, but i do not want to plot the 'bla' for this
plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{bla\hfill$\varepsilon_i$}$< -\xi$')
plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{bla\hfill$|\varepsilon_i|$}$< \xi$')
plt.plot(numpy.random.rand(10), label=r'\makebox[2cm][r]{bla\hfill$\varepsilon_i$}$ > \xi$')
plt.legend(loc='upper right')
plt.show()
Here's a solution where LaTeX perfectly alignes math, but the user has to take the pain to position it inside the legend. The idea is to
draw legend box in a given position with a placeholder
put an amsmath's array into it manually
Here's the code:
#!/usr/bin/python3
from numpy import arange
import matplotlib
from matplotlib import pyplot as plt
custom_preamble = {
"text.usetex": True,
"text.latex.preamble": [
r"\usepackage{amsmath}", # for the array macros
],
}
matplotlib.rcParams.update(custom_preamble)
x = arange(5)
y = arange(5)
fig = plt.figure()
ax = fig.add_subplot(111)
l1, = ax.plot(x, y)
l2, = ax.plot(x * 2, y)
l3, = ax.plot(x * 3, y)
leg = ax.legend(
[l1, l2, l3],
["", "", ""],
bbox_to_anchor = (0.98, 0.25),
handletextpad = 4, # space between lines and text -- used here as a placeholder
labelspacing = 0.1, # space between lines in a legend
)
leg.set_zorder(1)
ax.text(0.955, 0.21,
r"\begin{array}{rcl}"
r" \varepsilon_i & < & -\xi"
r"\\ |\varepsilon_i| & < & \xi"
r"\\ \varepsilon_i & > & \xi"
r"\end{array}",
transform = ax.transAxes,
horizontalalignment = 'right',
verticalalignment = 'top',
zorder = 5,
)
fig.savefig("mwe.png")
Result:
You might want to compile it twice: on the first compilation it might give You error, but all other tries would go fine.
As to a space between < sign in a legend -- it might be reduced with say:
ax.text(0.94, 0.21,
r"\begin{array}{r#{}c#{}l}"
r" \varepsilon_i \,\,& < &\,\, -\xi"
r"\\ |\varepsilon_i| \,\,& < &\,\, \xi"
r"\\ \varepsilon_i \,\,& > &\,\, \xi"
r"\end{array}",
(everything else the same). This gives:

Categories