DEV Community

Cover image for How to Create a Beautiful Polar Histogram With Python and Matplotlib
Oscar Leo
Oscar Leo

Posted on

How to Create a Beautiful Polar Histogram With Python and Matplotlib

Hi, and welcome to this Python + Matplotlib tutorial, where I will show you how to create the beautiful polar histogram you see above.

Polar histograms are great when you have too many values for a standard bar chart. The circular shape where each bar gets thinner towards the middle allows us to cram more information into the same area.

I’m using data from the World Happiness Report and information about income levels from the World Bank.

Screenshot by the author

You can find the code and data I’m using in this GitHub repository.

Let’s get started.


Step 1: Preparations

Let's start with a few preperations.

Importing libraries

We only need standard Python libraries familiar to everyone. PIL is not mandatory, but it’s my preferred choice for handling images which we do when adding flags.



import math
import numpy as np
import pandas as pd

import seaborn as sns
import matplotlib.pyplot as plt

from PIL import Image
from matplotlib.lines import Line2D
from matplotlib.patches import Wedge
from matplotlib.offsetbox import OffsetImage, AnnotationBbox


Enter fullscreen mode Exit fullscreen mode

The only thing that stands out is a few specific Matplotlib imports at the end. I’ll cover those components later in the tutorial.

I use pandas to load the data.



df = pd.read_csv("./hapiness_report_2022.csv", index_col=None)
df = df.sort_values("score").reset_index(drop=True)


Enter fullscreen mode Exit fullscreen mode

Seaborn style settings

Next, I use Seaborn to create a base style by defining the background, text color, and font.



font_family = "PT Mono"
background_color = "#F8F1F1"
text_color = "#040303"

sns.set_style({
    "axes.facecolor": background_color,
    "figure.facecolor": background_color,
    "font.family": font_family,
    "text.color": text_color,
})


Enter fullscreen mode Exit fullscreen mode

There are several more parameters for set_style, but these four are the only ones I need in this tutorial.

I use websites such as Colorhunt and Coolors to create beautiful color palettes.

Global settings

I’m also adding a few global settings to control the general look. The first four define the range, size, and width of the wedges in the histogram.



START_ANGLE = 100 # At what angle to start drawing the first wedge
END_ANGLE = 450 # At what angle to finish drawing the last wedge
SIZE = (END_ANGLE - START_ANGLE) / len(df) # The size of each wedge
PAD = 0.2 * SIZE # The padding between wedges

INNER_PADDING = 2 * df.score.min()
LIMIT = (INNER_PADDING + df.score.max()) * 1.3 # Limit of the axes


Enter fullscreen mode Exit fullscreen mode

Inner padding creates distance between the origo and the start of each wedge. It opens a space in the middle of the graph where I can add a title.

Boilerplate code

As a software engineer, I strive to write reusable code, and it’s the same when I’m working on data visualizations.

That’s why I always start by creating a few lines of boilerplate code that I can extend with reusable functions.



fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(30, 30))
ax.set(xlim=(-LIMIT, LIMIT), ylim=(-LIMIT, LIMIT))

for i, row in df.iterrows():
    bar_length = row.score
    name = row.country
    length = bar_length + INNER_PADDING
    start = 100 + i*SIZE + PAD
    end = 100 + (i+1)*SIZE
    angle = (end + start) / 2

    # Create variables here

    # Add wedge functions here

# Add general functions here

plt.axis("off")
plt.tight_layout()
plt.show()


Enter fullscreen mode Exit fullscreen mode

For the rest of the tutorial, I will create and add functions and variables under one of the three comments.


Step 2: Drawing wedges

To get more power over the visuals in Matplotlib, it helps to use the underlying components rather than the built-in graph functions.

Drawing a wedge

For example, instead of using plt.pie() to create a pie chart, you can use plt.patches.Wedge() to draw the individual pieces.

That’s why I created the following function, which draws a wedge based on angles, length, bar length, and color.



def draw_wedge(ax, start_angle, end_angle, length, bar_length, color):
    ax.add_artist(
        Wedge((0, 0),
            length, start_angle, end_angle,
            color=color, width=bar_length
        )
    )


Enter fullscreen mode Exit fullscreen mode

In the boilerplate code, I add draw_wedge() under the “Add functions here” comment as below.



bar_length = row.score
length = bar_length # + INNER_PADDING
start = 100 + i*SIZE + PAD
end = 100 + (i+1)*SIZE
.
.
.

# Add functions here    
draw_wedge(ax, start, end, length, bar_length, "#000")


Enter fullscreen mode Exit fullscreen mode

I use row.score to define bar_length so that the visible part of the bars has an accurate size relation to each other.
For now, I’ve removed the INNER_PADDING to show you what it does.

When I run the code, I get the following figure.

Initial chart with wedges

As you can see, we have a long way to go until we get something similar to the polar histogram that you saw at the beginning, but at least we’ve managed to draw the wedges.

We get a lot of visual artefacts close to the middle, so let’s uncomment INNER_PADDING.

Here’s what we get.

Chart with inner padding

Much better.

Adding color

Next, I have a simple color function that decides the color for each wedge based on the income level of that country.



def color(income_group):
    if income_group == "High income":
        return "#468FA8"
    elif income_group == "Lower middle income":
        return "#E5625E"
    elif income_group == "Upper middle income":
        return "#62466B"
    elif income_group == "Low income":
        return "#6B0F1A"
    else:
        return "#909090"


Enter fullscreen mode Exit fullscreen mode

I use that function as input to the draw_wedge function.



# Add functions here    
draw_wedge(ax, start, end, length, bar_length, color(row.income))


Enter fullscreen mode Exit fullscreen mode

Here’s the result.

Initial chart with wedges and color

With INNER_PADDING and color() there are no strange artifacts left. It’s time to add information that explains what we’re looking at.


Step 3: Adding labels

Let’s add labels for each bar in the polar histogram. I want each bar to display the country’s flag, name, and happiness score.

Defining the positions

When you add flags and text to a chart in Matplotlib, you need to calculate the correct positions.

That’s often tricky, especially when you have an unusual shape like we have in the polar histogram.

The function below takes the length of a wedge and its angle to calculate a position. Padding pushes the position away from the bar to add some visual space.



def get_xy_with_padding(length, angle, padding):
    x = math.cos(math.radians(angle)) * (length + padding)
    y = math.sin(math.radians(angle)) * (length + padding)
    return x, y


Enter fullscreen mode Exit fullscreen mode

We can use this function for both flags and text.

Adding flags

For flags, I’m using these rounded ones from FlatIcon.

They require a license, so, unfortunately, I can’t share them, but you can find similar flags in other places.

Here’s my function to add a flag to the graph. It takes the position, the country’s name (which corresponds to the name of the correct file), zoom, and rotation.



def add_flag(ax, x, y, name, zoom, rotation):
    flag = Image.open("<location>/{}.png".format(name.lower()))
    flag = flag.rotate(rotation if rotation > 270 else rotation - 180)
    im = OffsetImage(flag, zoom=zoom, interpolation="lanczos", resample=True, visible=True)

    ax.add_artist(AnnotationBbox(
        im, (x, y), frameon=False,
        xycoords="data",
    ))


Enter fullscreen mode Exit fullscreen mode

I change how the flag rotates if the angle exceeds 270 degrees. That happens when we start adding bars on the right part of the chart. At that point, the flag is to the left of the text, and changing the rotation makes reading more natural.

Now, we can calculate the angle, use get_xy_with_padding() and put flags on the chart.



bar_length = row.score
length = bar_length + INNER_PADDING
start = START_ANGLE + i*SIZE + PAD
end = START_ANGLE + (i+1)*SIZE

# Add variables here
angle = (end + start) / 2
flag_zoom = 0.004 * length
flag_x, flag_y = get_xy_with_padding(length, angle, 0.1 * length)

# Add functions here
...
add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)


Enter fullscreen mode Exit fullscreen mode

The flag_zoom parameters decide the size of the flag and depend on the score. If a country has a low score, there’s less room for a flag, and we need to make it a bit smaller.

Polar histogram with flags

Fantastic.

Adding country names and scores

To add the name and score of the country, I’ve written the following function.

As with the flags, I change the rotation if the angle exceeds 270 degrees. Otherwise, the text would be upside down.



def add_text(ax, x, y, country, score, angle):
    if angle < 270:
        text = "{} ({})".format(country, score)
        ax.text(x, y, text, fontsize=13, rotation=angle-180, ha="right", va="center", rotation_mode="anchor")
    else:
        text = "({}) {}".format(score, country)
        ax.text(x, y, text, fontsize=13, rotation=angle, ha="left", va="center", rotation_mode="anchor")


Enter fullscreen mode Exit fullscreen mode

We calculate the position of the text in the same way as we did with the flags.

The only difference is that we add more padding since we want it further from the wedges.



bar_length = row.score
length = bar_length + INNER_PADDING
start = START_ANGLE + i*SIZE + PAD
end = START_ANGLE + (i+1)*SIZE

# Add variables here
angle = (end + start) / 2
flag_zoom = 0.004 * length
flag_x, flag_y = get_xy_with_padding(length, angle, 0.1 * length)
text_x, text_y = get_xy_with_padding(length, angle, 16*flag_zoom)

# Add functions here
...
add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)
add_text(ax, text_x, text_y, row.country, bar_length, angle)


Enter fullscreen mode Exit fullscreen mode

Now we have the following graph, and it’s starting to look much better.

Polar histogram with flags and text

Now it’s time to tell the users what they are looking at.


Step 4: Adding information

We have added all the data. It’s time to make the chart readable by adding helpful information and guidance.

Drawing reference lines

An excellent type of visual helper is reference lines; they work just as well here as with standard bar charts.

The idea is to draw a line at a specific score, which indirectly helps us compare different countries.

Here’s my function to draw reference lines. I’m reusing the draw_wedge() function to draw a wedge from 0 to 360 degrees.



def draw_reference_line(ax, point, size, padding, fontsize=18):
    draw_wedge(ax, 0, 360, point+padding+size/2, size, background_color)
    ax.text(-0.6, padding + point, point, va="center", rotation=1, fontsize=fontsize)


Enter fullscreen mode Exit fullscreen mode

I run the function once for each score to draw multiple reference lines.



# Add general functions here
draw_reference_line(ax, 2.0, 0.05, INNER_PADDING)
draw_reference_line(ax, 4.0, 0.05, INNER_PADDING)
draw_reference_line(ax, 6.0, 0.05, INNER_PADDING)


Enter fullscreen mode Exit fullscreen mode

Here’s the result.

Polar histogram with reference lines

It makes a significant difference.

Adding a title

The purpose of the gap in the center of the graph is to create a natural place for a title. Having the title in the center is unusual and can immediately capture a viewer’s interest.

The code for adding the title is standard Matplotlib functionality.



# Add general functions here
...
plt.title(
  "World Happiness Report 2022".replace(" ", "\n"), 
  x=0.5, y=0.5, va="center", ha="center", 
  fontsize=64, linespacing=1.5
)


Enter fullscreen mode Exit fullscreen mode

Here’s what it looks like.

Polar histogram with title

It’s getting close, but we still have one more thing to do.

Adding a legend

There’s no way for the viewer to understand what the colors mean, but we can fix that by adding a legend.

To add a legend, I’ve created the following function that takes the labels to add, their colors, and a title.



def add_legend(labels, colors, title):
    lines = [
        Line2D([], [], marker='o', markersize=24, linewidth=0, color=c) 
        for c in colors
    ]

    plt.legend(
        lines, labels,
        fontsize=18, loc="upper left", alignment="left",
        borderpad=1.3, edgecolor="#E4C9C9", labelspacing=1,
        facecolor="#F1E4E4", framealpha=1, borderaxespad=1,
        title=title, title_fontsize=20,
    )


Enter fullscreen mode Exit fullscreen mode

I add the function under “Add general functions here” and run it together with everything else.



# Add general functions here
...

add_legend(
    labels=["High income", "Upper middle income", "Lower middle income", "Low income", "Unknown"],
    colors=["#468FA8", "#62466B", "#E5625E", "#6B0F1A", "#909090"],
    title="Income level according to the World Bank\n"
)


Enter fullscreen mode Exit fullscreen mode

The final result looks like this.

Polar histogram by the author

That’s it. We have recreated the beautiful polar histogram you saw at the top.

Your entire main block of code should now look like this.



fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(30, 30))
ax.set(xlim=(-LIMIT, LIMIT), ylim=(-LIMIT, LIMIT))

for i, row in df.iterrows():
    bar_length = row.score
    length = bar_length + INNER_PADDING
    start = START_ANGLE + i*SIZE + PAD
    end = START_ANGLE + (i+1)*SIZE
    angle = (end + start) / 2

    # Add variables here
    flag_zoom = 0.004 * length
    flag_x, flag_y = get_xy_with_padding(length, angle, 8*flag_zoom)
    text_x, text_y = get_xy_with_padding(length, angle, 16*flag_zoom)

    # Add functions here
    draw_wedge(ax, start, end, length, bar_length, color(row.income))
    add_flag(ax, flag_x, flag_y, row.country, flag_zoom, angle)
    add_text(ax, text_x, text_y, row.country, bar_length, angle)

ax.text(1-LIMIT, LIMIT-2, "+ main title", fontsize=58)

# Add general functions here
draw_reference_line(ax, 2.0, 0.06, INNER_PADDING)
draw_reference_line(ax, 4.0, 0.06, INNER_PADDING)
draw_reference_line(ax, 6.0, 0.06, INNER_PADDING)
plt.title("World Happiness Report 2022".replace(" ", "\n"), x=0.5, y=0.5, va="center", ha="center", fontsize=64, linespacing=1.5)

add_legend(
    labels=["High income", "Upper middle income", "Lower middle income", "Low income", "Unknown"],
    colors=["#468FA8", "#62466B", "#E5625E", "#6B0F1A", "#909090"],
    title="Income level according to the World Bank\n"
)

plt.axis("off")
plt.tight_layout()
plt.show()


Enter fullscreen mode Exit fullscreen mode

That’s it for this tutorial; congratulations on reaching the end.


Conclusion

Today, we learned to create a beautiful polar histogram using Matplotlib and Python.

Polar histograms are surprisingly easy to create, allowing us to cram more information into a single chart.

I used the World Happiness Report in this tutorial, but you can change it to another inspiring dataset.

I hope you learned a few techniques to help you bring your chart ideas to life.

Top comments (2)

Collapse
 
baptistsec profile image
William Baptist

What an excellent, thorough article! I love the attention to design detail and how you build up the program.

Collapse
 
oscarleo profile image
Oscar Leo

Thank you! That means a lot :D