0

I am having diffculties to move the text "Rank" exactly one line above the first label and by not using guesswork as I have different chart types with variable sizes, widths and also paddings between the labels and bars.

enter image description here

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from pylab import rcParams

rcParams['figure.figsize'] = 8, 6
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df = pd.DataFrame.from_records(zip(np.arange(1,30)))
df.plot.barh(width=0.8,ax=ax,legend=False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.tick_params(left=False, bottom=False)
ax.tick_params(axis='y', which='major', pad=36)
ax.set_title("Rankings")
ax.text(-5,30,"Rank")

plt.show()

Using transData.transform didn't get me any further. The problem seems to be that ax.text() with the position params of (0,0) aligns with the start of the bars and not the yticklabels which I need, so getting the exact position of yticklabels relative to the axis would be helpful.

CodeTrek
  • 435
  • 1
  • 3
  • 10

2 Answers2

2

The following approach creates an offset_copy transform, using "axes coordinates". The top left corner of the main plot is at position 0, 1 in axes coordinates. The ticks have a "pad" (between label and tick mark) and a "padding" (length of the tick mark), both measured in "points".

The text can be right aligned, just as the ticks. With "bottom" as vertical alignment, it will be just above the main plot. If that distance is too low, you could try ax.text(0, 1.01, ...) to have it a bit higher.

import matplotlib.pyplot as plt
from matplotlib.transforms import offset_copy
import pandas as pd
import numpy as np
from matplotlib import rcParams

rcParams['figure.figsize'] = 8, 6
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df = pd.DataFrame.from_records(zip(np.arange(1, 30)))
df.plot.barh(width=0.8, ax=ax, legend=False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.tick_params(left=False, bottom=False)
ax.tick_params(axis='y', which='major', pad=36)
ax.set_title("Rankings")

tick = ax.yaxis.get_major_ticks()[-1] # get information of one of the ticks
padding = tick.get_pad() + tick.get_tick_padding()
trans_offset = offset_copy(ax.transAxes, fig=fig, x=-padding, y=0, units='points')
ax.text(0, 1, "Rank", ha='right', va='bottom', transform=trans_offset)
# optionally also use tick.label.get_fontproperties()

plt.tight_layout()
plt.show()

aligning a text on top of the y tick labels

JohanC
  • 71,591
  • 8
  • 33
  • 66
  • I really like that one and more than mine :P Where did you learn offset_copy? - Never head of that. Did you do some advanced mpl workshop? ;) – CodeTrek Jan 04 '23 at 22:33
  • I just came across `offset_copy` trying to answer a different [Stackoverflow question](https://stackoverflow.com/questions/74997386/offset-parallel-overlapping-lines). As a matter of fact, `offset_copy` isn't strictly needed here, as `ax.annotate` also has an option to integrate an offset. – JohanC Jan 04 '23 at 22:40
  • Ahh ok, I wish there would be an advanced course or so that teaches all this tricks in a bundle. It's amazing what you can do but most of my knowledge is taken from the docs and bits and pieces scrambled all over the web. – CodeTrek Jan 04 '23 at 22:42
0

I've answered my own question while Johan was had posted his one - which is pretty good and what I wanted. However, I post mine anyways as it uses an entirely different approach. Here I add a "ghost" row into the dataframe and label it appropriately which solves the problem:

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from pylab import rcParams

rcParams['figure.figsize'] = 8, 6
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df = pd.DataFrame.from_records(zip(np.arange(1,30)),columns=["val"])

#add a temporary header
new_row = pd.DataFrame({"val":0}, index=[0])
df = pd.concat([df[:],new_row]).reset_index(drop = True)

df.plot.barh(width=0.8,ax=ax,legend=False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.tick_params(left=False, bottom=False)
ax.tick_params(axis='y', which='major', pad=36)
ax.set_title("Rankings")

# Set the top label to "Rank"
yticklabels = [t for t in ax.get_yticklabels()]
yticklabels[-1]="Rank"

# Left align all labels
[t.set_ha("left") for t in ax.get_yticklabels()]
ax.set_yticklabels(yticklabels)

# delete the top bar effectively by setting it's height to 0
ax.patches[-1].set_height(0)

plt.show()

enter image description here

Perhaps the advantage is that it is always a constant distance above the top label, but with the disadvantage that this is a bit "patchy" in the most literal sense to transform your dataframe for this task.

CodeTrek
  • 435
  • 1
  • 3
  • 10
  • You wrote you *"have different chart types"*. This approach mainly works for horizontal bar plots. A bit annoying can also be that the top bar, although made invisible, still takes up space inside the plot. – JohanC Jan 04 '23 at 22:37
  • 1
    Indeed, good point. With a vertical column chart plot this will need to be patched again but for a quick fix as a reference for horizontal charts I'll keep this answer here and accept your answer as the ultimate solution. – CodeTrek Jan 04 '23 at 22:39