7

I want to create a radial bar chart. I have the following Python3 code:

lObjectsALLcnts = [1, 1, 1, 2, 2, 3, 5, 14, 15, 20, 32, 33, 51, 1, 1, 2, 2, 3, 3, 3, 3, 3, 4, 6, 7, 7, 10, 10, 14, 14, 14, 17, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 5, 5, 6, 14, 14, 27, 27, 1, 1, 2, 3, 4, 4, 5]`

lObjectsALLlbls = ['DuctPipe', 'Column', 'Protrusion', 'Tree', 'Pole', 'Bar', 'Undefined', 'EarthingConductor', 'Grooves', 'UtilityPipe', 'Cables', 'RainPipe', 'Moulding', 'Intrusion', 'PowerPlug', 'UtilityBox', 'Balcony', 'Lighting', 'Lock', 'Doorbell', 'Alarm', 'LetterBox', 'Grate', 'Undefined', 'CableBox', 'Canopy', 'Vent', 'PowerBox', 'UtilityHole', 'Recess', 'Protrusion', 'Shutter', 'Handrail', 'Lock', 'Mirror', 'SecuritySpike', 'Bench', 'Intrusion', 'Picture', 'Showcase', 'Camera', 'Undefined', 'Stair', 'Protrusion', 'Alarm', 'Graffiti', 'Lighting', 'Ornaments', 'SecurityBar', 'Grate', 'Vent', 'Lighting', 'UtilityHole', 'Intrusion', 'Undefined', 'Protrusion']

iN = len(lObjectsALLcnts)
arrCnts = np.array(lObjectsALLcnts)

theta=np.arange(0,2*np.pi,2*np.pi/iN)
width = (2*np.pi)/iN *0.9

fig = plt.figure(figsize=(8, 8))
ax = fig.add_axes([0.1, 0.1, 0.75, 0.75], polar=True)
bars = ax.bar(theta, arrCnts, width=width, bottom=50)
ax.set_xticks(theta)
plt.axis('off')

which creates the following image:

radialbartchart_nolabels

After creating this I would like to add labels, but I'm having a bit of troubles finding the right coordinates. The labels should be rotated along the directions of the bars.

The best I've come up with is adding the following code:

rotations = [np.degrees(i) for i in theta]
for i in rotations: i = int(i)
for x, bar, rotation, label in zip(theta, bars, rotations, lObjectsALLlbls):
     height = bar.get_height() + 50
     ax.text(x + bar.get_width()/2, height, label, ha='center', va='bottom', rotation=rotation)

which creates the following:

radialbarchart_wlabels

Can some help me with finding the right coordinates for the labels? I've been looking in to answers like Adding value labels on a matplotlib bar chart and translating it to the polar bar chart. But with no success.

Thanks in advance,

A long time reader on StackOverflow, but for the first time I couldn't find an answer.

eyllanesc
  • 235,170
  • 19
  • 170
  • 241

1 Answers1

6

The problem you run into is that the text bounding box is expanded to host the complete rotated text, but that box itself is still defined in cartesian coordinates. The picture below shows two texts with horizontalalignment "left" and vertical alignment "bottom"; the problem is that the rotated text has its bounding box edge much further away from the text.

enter image description here

What you want is rather to have the text rotate about an edge point of its own surrounding as below.

enter image description here

This can be achieved using the rotation_mode="anchor" argument to matplotlib.text.Text, which steers exactly the above functionality.

ax.text(..., rotation_mode="anchor")

In this example:

from matplotlib import pyplot as plt
import numpy as np

lObjectsALLcnts = [1, 1, 1, 2, 2, 3, 5, 14, 15, 20, 32, 33, 51, 1, 1, 2, 2, 3, 3, 3, 3, 
                   3, 4, 6, 7, 7, 10, 10, 14, 14, 14, 17, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 
                   5, 5, 6, 14, 14, 27, 27, 1, 1, 2, 3, 4, 4, 5]

lObjectsALLlbls = ['DuctPipe', 'Column', 'Protrusion', 'Tree', 'Pole', 'Bar', 'Undefined', 
                   'EarthingConductor', 'Grooves', 'UtilityPipe', 'Cables', 'RainPipe', 'Moulding', 
                   'Intrusion', 'PowerPlug', 'UtilityBox', 'Balcony', 'Lighting', 'Lock', 'Doorbell', 
                   'Alarm', 'LetterBox', 'Grate', 'Undefined', 'CableBox', 'Canopy', 'Vent', 'PowerBox', 
                   'UtilityHole', 'Recess', 'Protrusion', 'Shutter', 'Handrail', 'Lock', 'Mirror', 
                   'SecuritySpike', 'Bench', 'Intrusion', 'Picture', 'Showcase', 'Camera', 
                   'Undefined', 'Stair', 'Protrusion', 'Alarm', 'Graffiti', 'Lighting', 'Ornaments', 
                   'SecurityBar', 
                   'Grate', 'Vent', 'Lighting', 'UtilityHole', 'Intrusion', 'Undefined', 'Protrusion']

iN = len(lObjectsALLcnts)
arrCnts = np.array(lObjectsALLcnts)

theta=np.arange(0,2*np.pi,2*np.pi/iN)
width = (2*np.pi)/iN *0.9
bottom = 50

fig = plt.figure(figsize=(8,8))
ax = fig.add_axes([0.1, 0.1, 0.75, 0.75], polar=True)
bars = ax.bar(theta, arrCnts, width=width, bottom=bottom)

plt.axis('off')

rotations = np.rad2deg(theta)
for x, bar, rotation, label in zip(theta, bars, rotations, lObjectsALLlbls):
    lab = ax.text(x,bottom+bar.get_height() , label, 
             ha='left', va='center', rotation=rotation, rotation_mode="anchor",)   
plt.show()

enter image description here

Note that this uses the given 50 units of bottom spacing. You may increase this number a bit to have more spacing between bars and text.


The below initial version of this answer is somehow outdated. I will keep it here for reference.

The problem you run into is that the text bounding box is expanded to host the complete rotated text, but that box itself is still defined in cartesian coordinates. The picture below shows two texts with horizontalalignment "left" and vertical alignment "bottom"; the problem is that the rotated text has its bounding box edge much further away from the text.

enter image description here

An easy solution may be to define the horizontal and vertical alignment as "center", such the pivot of the text stays the same independent of its rotation.

enter image description here

The problem would then be to get a good estimate for the distance between the center of the text and the bar's top.

One could take half the number of letters in the text and multiply it with some factor. This would need to be found by trial and error.

bottom = 50
rotations = np.rad2deg(theta)
y0,y1 = ax.get_ylim()

for x, bar, rotation, label in zip(theta, bars, rotations, lObjectsALLlbls):
     offset = (bottom+bar.get_height())/(y1-y0)
     h =offset + len(label)/2.*0.032
     lab = ax.text(x, h, label, transform=ax.get_xaxis_transform(), 
             ha='center', va='center')
     lab.set_rotation(rotation)

You could also try to find out how large the rendered text really is and use this information to find out the coordinates,

bottom = 50
rotations = np.rad2deg(theta)
y0,y1 = ax.get_ylim()

for x, bar, rotation, label in zip(theta, bars, rotations, lObjectsALLlbls):
     offset = (bottom+bar.get_height())/(y1-y0)
     lab = ax.text(0, 0, label, transform=None, 
             ha='center', va='center')
     renderer = ax.figure.canvas.get_renderer()
     bbox = lab.get_window_extent(renderer=renderer)
     invb = ax.transData.inverted().transform([[0,0],[bbox.width,0] ])
     lab.set_position((x,offset+(invb[1][0]-invb[0][0])/2.*2.7 ) )
     lab.set_transform(ax.get_xaxis_transform())
     lab.set_rotation(rotation)

enter image description here

Complete code for reproduction:

import numpy as np
import matplotlib.pyplot as plt

lObjectsALLcnts = [1, 1, 1, 2, 2, 3, 5, 14, 15, 20, 32, 33, 51, 1, 1, 2, 2, 3, 3, 3, 3, 
               3, 4, 6, 7, 7, 10, 10, 14, 14, 14, 17, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 
               5, 5, 6, 14, 14, 27, 27, 1, 1, 2, 3, 4, 4, 5]

lObjectsALLlbls = ['DuctPipe', 'Column', 'Protrusion', 'Tree', 'Pole', 'Bar', 'Undefined', 
               'EarthingConductor', 'Grooves', 'UtilityPipe', 'Cables', 'RainPipe', 'Moulding', 
               'Intrusion', 'PowerPlug', 'UtilityBox', 'Balcony', 'Lighting', 'Lock', 'Doorbell', 
               'Alarm', 'LetterBox', 'Grate', 'Undefined', 'CableBox', 'Canopy', 'Vent', 'PowerBox', 
               'UtilityHole', 'Recess', 'Protrusion', 'Shutter', 'Handrail', 'Lock', 'Mirror', 
               'SecuritySpike', 'Bench', 'Intrusion', 'Picture', 'Showcase', 'Camera', 
               'Undefined', 'Stair', 'Protrusion', 'Alarm', 'Graffiti', 'Lighting', 'Ornaments', 
               'SecurityBar', 
               'Grate', 'Vent', 'Lighting', 'UtilityHole', 'Intrusion', 'Undefined', 'Protrusion']

iN = len(lObjectsALLcnts)
arrCnts = np.array(lObjectsALLcnts)

theta=np.arange(0,2*np.pi,2*np.pi/iN)
width = (2*np.pi)/iN *0.9
bottom = 50

fig = plt.figure(figsize=(8,8))
ax = fig.add_axes([0.1, 0.1, 0.75, 0.75], polar=True)
bars = ax.bar(theta, arrCnts, width=width, bottom=bottom)

plt.axis('off')

rotations = np.rad2deg(theta)
y0,y1 = ax.get_ylim()

for x, bar, rotation, label in zip(theta, bars, rotations, lObjectsALLlbls):
 offset = (bottom+bar.get_height())/(y1-y0)
 lab = ax.text(0, 0, label, transform=None, 
         ha='center', va='center')
 renderer = ax.figure.canvas.get_renderer()
 bbox = lab.get_window_extent(renderer=renderer)
 invb = ax.transData.inverted().transform([[0,0],[bbox.width,0] ])
 lab.set_position((x,offset+(invb[1][0]-invb[0][0])/2.*2.7 ) )
 lab.set_transform(ax.get_xaxis_transform())
 lab.set_rotation(rotation)

 
plt.show()

Unfortunately there is again some strange factor 2.7 involved. Even more unfornate is that in this case I have absolutely no idea why it must be there. But the result may still be good enough to work with.

One could also use a solution from ths question: Align arbitrarily rotated text annotations relative to the text, not the bounding box

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • I updated this answer with a better and simpler solution. – ImportanceOfBeingErnest Oct 23 '17 at 15:01
  • 1
    Since my reputation doesn't let me to up vote: thank you for the clarity of your solution. Adding a 1.25 to the bottom does the trick for the spacing between the bar and text, like you suggested. I also added a -0.01 to x, since on the top left the labels are a bit too much on the side of the bars. To be completely just, in your updated solution you can remove the 'y0,y1 = ax.get_ylim()', 'fig.canvas.draw()' and 'offset = (bottom+bar.get_height())/(y1-y0)' lines. They are leftover of your initial solution. – Stijn Van der Linden Oct 23 '17 at 17:00
  • Yep, well spotted, I removed them. – ImportanceOfBeingErnest Oct 23 '17 at 17:34