Creating circular plots based on the concepts of a bar chart, using matplotlib

Mapping a bar chart to a circular plot, with variations on circular plotting.

Context

Circular (polar) charts look quite different from bar charts but are conceptually similar.

Here I'm showing how the code in matplotlib for a standard bar chart can be related directly to code creating a circular plot, requiring only small changes.

I then look at some variations on circular plots, which can be achieved with generally small amounts of additional code.

Sample data

This is a very simple dataset I use for various demos containing 7 days' worth of sleep data for 4 cats. In this demo I am just going to use the data for Luna, so you could replace this with a simple list of numbers.


hours_sleep_by_day = {
'Luna': [11, 10, 13, 18, 16, 8, 19],
'Bella': [14, 15, 18, 18, 12, 12, 18],
'Charlie': [16, 8, 16, 17, 9, 13, 15],
'Jasper': [13, 10, 12, 19, 9, 9, 10]
}

Plots comparison and code

I'll cover some variations and adjustments to the circular plot below, so here's the 'basic' bar chart and circular equivalent.

The method is very similar for both. The main difference is that the plot is declared as polar in the call to ax = plt.subplot(1, 1, 1, polar=True). The bar widths are calculated in radians and the x-values similarly are the position on the circle in radians (so that if there were 3 values they would be at the radian equivalent of 0 degrees, 120 degrees and 240 degrees).

Mapping bar chart to circular plot

  • x-values:
    • Bar chart - numeric or categories.
    • Circular plot - numeric (number of radians for position on the circle).
  • height: No change.
  • width:
    • Bar chart - proportion of 1 (e.g. 0.8 for 80%) where 1 fully occupies the space.
    • Circular plot - number of radians.
  • x ticks:
    • Bar chart - can plot 'categories' directly.
    • Circular plot - need to plot numbers and then need to add labels as the x-ticks.
  • y ticks: No change when using default positioning. However due to the way the y ticks are placed "inside" the circle and cannot sit outside the axis in the way they do with a standard bar chart, may be some cosmetic adjustments needed to make it more readable.
  • line width, color, alpha etc: No change.
  • grid: No change. The same grid will just be 'reshaped' to plot in polar coordinates.

Variations on circular plots

Here's some variations on the basic circular plot... I won't repeat all the code every time here (but they are listed in full in the Jupyter notebook at the end) - in general they are relatively small pieces that should be clear where to insert them.

Change starting position (e.g. put the first element at the top)

The method set_theta_offset() takes a number of radians and 'rotates' the zero position accordingly. In the example below I've moved it by 90 degrees (and passed this as the radians conversion) to put the first value (Monday) at the top.

The movement is in the direction of the plot, so if the plot is in its default anti-clockwise orientation (see below) the movement will also be anti-clockwise. A negative movement such as -90 would reverse this.


ax = plt.subplot(1, 1, 1, polar=True)
ax.set_theta_offset(math.radians(90))

Plot clockwise instead of anti-clockwise (counter-clockwise)

The method set_theta_direction() can be used with a -1 argument to plot the values in a clockwise direction rather than the default anti-clockwise.


ax = plt.subplot(1, 1, 1, polar=True)
ax.set_theta_direction(-1)

An alternative method of course is to reverse the list of values and plot in the default direction!

Don't occupy the full circle (e.g. plot as a semicircle or 75%)

By default, the bar is aligned to the 'center' of the data point and we want to keep it that way so the labels are centered with the bars. But this results in half of the first and last bar being chopped off, as they are now "outside" the segment! So to adjust this, the available space of (e.g.) 180 degrees needs to allow enough space for the additional bar (half of the first one and half of the last).

This works easiest if using full width 'bars'. It could be adjusted to use bars of less than 100% width without cutting them off, but the arithmetic for that gets more involved -- I'll cover that in a future post and link back to it here.

I set the "origin" of the partial circle, and the total degrees it contains (270 in this case as I wanted a plot that takes 3/4ths of the circle).


partial_circle_origin = 0
partial_circle_size_degrees = 270

The calculation for the segment size now has to split the available space (270 degrees here) over the bars, so each individual segment is accordingly smaller. Then the calculation for the x-values follows, with an adjustment in case the origin is anything other than 0.


segment_size_radians = math.radians(partial_circle_size_degrees / (len(days)))
x_values = [(segment_size_radians * i) + segment_size_radians/2 + math.radians(partial_circle_origin) for i in range(len(days))]

Lastly I use set_thetamin() and set_thetamax() methods, to draw only the required portion of the circle. Unlike the other thetas, these ones take an argument in degrees rather than radians!


ax.set_thetamin(partial_circle_origin)
ax.set_thetamax(partial_circle_origin+partial_circle_size_degrees)

This will result in a plot as below. This can be combined with the other methods on this page to start the circle from the top, leave space in the centre (see below), etc.

Leave space in the center of the plot

These methods shift the "bottom" of the bars, leaving space in the centre of the plot.

First way: set the y-limit explicitly (to values outside the range of the data) using set_ylim().

In my case, the range is 0 to 20, so I could put, for example, -10 as the y-minimum. Of course, in a real scenario these should be derived programmatically from the data rather than hard coded!


ax.set_ylim([-10, 20])

Combined with explicitly setting the y-labels (as above) for the desired range:


plt.yticks(range(0,21,5))

Second way: use the set_rorigin() method

Again, the argument to pass will depend on the underlying data.


ax.set_rorigin(-10)

This produces a plot very similar to the above, but the subtle difference is that the "bottom" axis is now explicitly at the 0 value, which has moved outwards.

Populate the plot from the "outside in"

We can make use of the invert_yaxis() method to set the origin as the 'outside' of the circle. (The equivalent for a standard bar chart would be the 0 value being at the top of the chart instead of the bottom.)


ax.invert_yaxis()

This might result in a strange looking chart since the 'top' part of the bars will now converge in the centre of the circle ...

... so it is probably best to leave some extra space, again making use of set_ylim (calculated from the data).

In my case I selected 25 as the 'max' value given that the actual max value of my data is 20.

Since the y-axis is inverted, the min and max values are also switched here.


ax.set_ylim([25, 0])

Jupyter notebooks

The first Jupyter notebook covers the basic bar and circular chart comparison.

The second notebook covers the variations on circular plots.

They can be run individually as they both contain the test data and dependencies.