Generate the data and import packages¶
First, we need to create the data. I'll start by defining it as a dictionary and then convert it into a pandas DataFrame, since pandas is commonly used in many projects for data manipulation.
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
import pandas as pd
from svgpathtools import svg2paths
from svgpath2mpl import parse_path
color_dict = {"Dummy":"w","Norway": "#2B314D", "Denmark": "#A54836", "Sweden": "#5375D4", }
xy_ticklabel_color, grand_totals_color, grid_color, datalabels_color ='#848490',"#101628", "#D3D9DB", "#FFFFFF"
data = {
"year": [2004, 2022, 2004, 2022, 2004, 2022],
"countries" : [ "Denmark", "Denmark", "Norway", "Norway","Sweden", "Sweden",],
"sites": [4,10,5,8,13,15]
}
df= pd.DataFrame(data)
df['year_lbl'] ="'"+df['year'].astype(str).str[-2:].astype(str)
df['sub_total'] = df.groupby('year')['sites'].transform('sum')
#custom sort
sort_order_dict = {"Denmark":3, "Sweden":4, "Norway":2, 2004:4, 2022:5}
df = df.sort_values(by=['year','countries',], key=lambda x: x.map(sort_order_dict))
#map the colors of a dict to a dataframe
df['color']= df.countries.map(color_dict)
df['pct_total'] = df.sites / df['sub_total']
df
| year | countries | sites | year_lbl | sub_total | color | pct_total | |
|---|---|---|---|---|---|---|---|
| 2 | 2004 | Norway | 5 | '04 | 22 | #2B314D | 0.227273 |
| 0 | 2004 | Denmark | 4 | '04 | 22 | #A54836 | 0.181818 |
| 4 | 2004 | Sweden | 13 | '04 | 22 | #5375D4 | 0.590909 |
| 3 | 2022 | Norway | 8 | '22 | 33 | #2B314D | 0.242424 |
| 1 | 2022 | Denmark | 10 | '22 | 33 | #A54836 | 0.303030 |
| 5 | 2022 | Sweden | 15 | '22 | 33 | #5375D4 | 0.454545 |
icon_path, attributes = svg2paths('../flags/Unesco_World_Heritage_logo_notext_transparent.svg')
#matplotlib path object of the icon
icon_marker = parse_path(attributes[0]['d'])
icon_marker.vertices -= icon_marker.vertices.mean(axis=0)
icon_marker = icon_marker.transformed(mpl.transforms.Affine2D().rotate_deg(180))
icon_marker = icon_marker.transformed(mpl.transforms.Affine2D().scale(-1,1))
This chart is made of stacked bars to represent the pies and the lines inside the bars. We need to define its start and end points:
radius_lines = [[.7,.55], [0.8, 0.95]]
radius_bars = [.5, .75]
max_sites = df.sites.max()
Plot the chart¶
Define the properties of the plot first.
fig, ax = plt.subplots(figsize=(6, 6),subplot_kw=dict(polar=True))
ax.set_theta_direction(-1)
ax.set_theta_zero_location("N")
one_widget_angle= 360/max_sites
theta_offset = np.deg2rad(90 - one_widget_angle / 2)
ax.set_theta_offset(theta_offset) #rotate 0 position
new_end_angle = 360 - one_widget_angle
ax.set( ylim = [0, 1.2], thetamin=0, thetamax=new_end_angle)
bar_height = 0.25
print(new_end_angle)
336.0
Add the stacked bars (pie wedges) and the vertical bars.
offset_bars = 0.09 #dont start the lines at zero
for i, (year, group) in enumerate(df.groupby("year", sort = False)):
r_start, r_end = radius_lines[i]
#add the stacked bars
group["angle"] = df["pct_total"] * np.deg2rad(new_end_angle)
group['theta_start'] = group["angle"].cumsum().shift(fill_value=0)
for row in group.itertuples():
ax.bar(
x=row.theta_start + row.angle / 2, # center the bar
height= bar_height,
width=row.angle,
bottom=radius_bars[i],
color="w",
edgecolor=grid_color,
linewidth=1,
zorder =0
)
#add the lines
bar_color= list(np.repeat(group['color'],group['sites']))
angles_lines = np.linspace(0, np.deg2rad(new_end_angle ), group['sub_total'].iloc[0], endpoint=False) + offset_bars
lines = ax.plot(
[angles_lines, angles_lines],
[r_start, r_end ],
lw=6,
zorder = 2
)
for i, line in enumerate(lines):
line.set_color(bar_color[i])
ax.set_axis_off()
fig
To sdd the heritage symbol, I will place a new axes inside our existing plot because if we put it on the existig axes it will be cut off due to the cut off we added to place the year labels:
#add axes in the same position
ax_symbol = fig.add_axes(ax.get_position(), polar=True, label="overlay", frameon=False)
ax_symbol.set_axis_off()
ax_symbol.set( ylim = [0, 1.2], thetamin=0, thetamax=360)
ax_symbol.set_theta_direction(-1)
ax_symbol.set_theta_zero_location("N")
ax_symbol.scatter(
0,
0,
s=4000,
marker=icon_marker,
color = grid_color
)
fig
Add the missing wedges:
radius_bars.append(1)
theta = np.linspace(np.deg2rad(360 - one_widget_angle/2), np.deg2rad(360 + one_widget_angle/2), 100) # 390° == 30°
for r in radius_bars:
ax_symbol.plot(
theta,
np.full_like(theta, r),
color=grid_color,
lw=1
)
fig
Add the year labels:
offset = bar_height /2
r_text = np.array(radius_bars[:-1]) + offset
for i, r_txt in enumerate(r_text):
ax.text(
-0.2,
r_txt,
df.year_lbl.unique()[i],
color = xy_ticklabel_color,
va= "center",
ha= "center"
)
fig
Add the curved text:
country_labels = df[df.year_lbl == "'22"]
# custom sort
sort_order_dict = {"Denmark": 2, "Sweden": 3, "Norway": 1}
country_labels = country_labels.sort_values( by=["countries",], key=lambda x: x.map(sort_order_dict),)
country_labels
start_angle_deg = country_labels["pct_total"] * np.deg2rad(new_end_angle ) # starting point for the label
theta_start = start_angle_deg .cumsum().shift(fill_value=0) + start_angle_deg/2 # mid point for the labels
angle_per_char = .04 # degrees per character
text_radie= 1.15 # radius
for ang, text, color in zip(theta_start, country_labels.countries, country_labels.color):
cnt_text = len(text)
arc_span_deg = cnt_text * angle_per_char # total length of the word (number of letters * width)
if ang <1:
rotation_angle = ang - 45
text_angles = np.linspace(ang, ang - arc_span_deg, cnt_text)
elif ang <3:
rotation_angle = ang - 135
text_angles = np.linspace(ang, ang - arc_span_deg, cnt_text)
elif ang <5:
rotation_angle = ang + 90
text_angles = np.linspace(ang, ang - arc_span_deg, cnt_text)
for char, angle_deg in zip(text, reversed(text_angles)):
ax.text(
angle_deg,
text_radie,
char,
rotation = rotation_angle,
rotation_mode = "anchor",
fontsize = 8,
color = color,
)
fig