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 numpy as np
import pandas as pd
from matplotlib.lines import Line2D
from itertools import cycle
color_dict = {"Norway": "#2B314D", "Denmark": "#A54836", "Sweden": "#5375D4" }
xy_ticklabel_color, grid_color ='#929EA7',"#929EA7"
data = {
"year": [2004, 2022, 2004, 2022, 2004, 2022],
"countries" : ["Sweden", "Sweden", "Denmark", "Denmark", "Norway", "Norway"],
"sites": [13,15,4,10,5,8]
}
df= pd.DataFrame(data)
df = df.sort_values([ 'countries', 'year'], ascending=True ).reset_index(drop=True)
#map the colors of a dict to a dataframe
df['color']= df.countries.map(color_dict)
df
| year | countries | sites | color | |
|---|---|---|---|---|
| 0 | 2004 | Denmark | 4 | #A54836 |
| 1 | 2022 | Denmark | 10 | #A54836 |
| 2 | 2004 | Norway | 5 | #2B314D |
| 3 | 2022 | Norway | 8 | #2B314D |
| 4 | 2004 | Sweden | 13 | #5375D4 |
| 5 | 2022 | Sweden | 15 | #5375D4 |
max_sites = df.sites.max()+1
face_colors = ["w"]*3 + df.color.unique().tolist()
color_cycle = cycle(face_colors) # infinite loop through the list
face_colors
['w', 'w', 'w', '#A54836', '#2B314D', '#5375D4']
fig, ax = plt.subplots(figsize=(5,7), facecolor = "#FFFFFF",subplot_kw=dict(polar=True) )
ax.set_theta_direction(-1)
ax.set_theta_zero_location("N")
for year, group in df.groupby("year"):
for i, row in enumerate(group.itertuples()):
ax.scatter(
(2 *np.pi)/ max_sites *row.sites,
1,
s=100, lw= 2,
fc=next(color_cycle), ec=row.color, zorder =1, clip_on=False,
)
one_widget_angle= 360/max_sites
ax.set(thetamin=0, thetamax=360-one_widget_angle)
ax.set_theta_offset(np.deg2rad(90-one_widget_angle/2)) #rotate 0 position
ax.set_rmax(1)
fig
line_params = {'color':grid_color, 'lw':1, 'zorder':0, 'clip_on': False,}
ax.axvline( 0, 0.9, 1.1, **line_params )
ax.axvline( (2 *np.pi)/max_sites * (max_sites-1) , 0.9, 1.1, **line_params )
fig
Add the radial labels ans rotate them:
thetatick_locs = np.arange(0,360,360/max_sites)
thetatick_labels = range(0,max_sites,1)
ax.set_thetagrids(
thetatick_locs,
thetatick_labels ,
fontsize=10, zorder =0,color = "#929EA7"
)
for angle, label in zip(thetatick_locs, ax.get_xticklabels()):
if angle <= 180:
label.set_rotation(-angle)
else:
label.set_rotation(360 - angle)
fig
import numpy as np
from matplotlib.font_manager import FontProperties
def curved_label(ax, text, p1, p2, rad=0.5, offset=12, fontsize=10, color="black"):
# Coordinate conversion and Bezier setup.
P1, P2 = map(lambda p: np.array(ax.transData.transform(p)), (p1, p2))
diff = P2 - P1
control = (P1 + P2) / 2 + rad * np.array([diff[1], -diff[0]])
# Pre-calculate the Bezier curve and tangents.
ts = np.linspace(0, 1, 500)
u = 1 - ts
points = u[:, None]**2 * P1 + 2*u[:, None]*ts[:, None]*control + ts[:, None]**2 * P2
tangents = 2*u[:, None]*(control - P1) + 2*ts[:, None]*(P2 - control)
# Calculate the arc length for character spacing.
cumulative = np.r_[0, np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1))]
total_len = cumulative[-1]
# Measure the width of each character.
renderer = ax.figure.canvas.get_renderer()
font = FontProperties(size=fontsize)
widths = np.array([renderer.get_text_width_height_descent(c, font, ismath=False)[0] for c in text])
text_len = widths.sum()
if not text_len:
return
# Scale the text to fit within 80% of the curve.
usable_len = min(text_len, total_len * 0.8)
widths *= usable_len / text_len
# Determine whether the text needs to be flipped.
angle_mid = np.degrees(np.arctan2(*tangents[len(ts)//2][::-1]))
flipped = not -90 <= angle_mid <= 90
chars = text[::-1] if flipped else text
# Calculate centered character positions along the curve.
distances = total_len / 2 - usable_len / 2 + np.cumsum(widths) - widths / 2
# Prepare the chord midpoint and inverse coordinate transform.
chord_mid = (P1 + P2) / 2
inv = ax.transData.inverted()
# Place each character along the curve.
for char, dist in zip(chars, distances):
t = np.interp(dist, cumulative, ts)
# Interpolate the character position and tangent.
pos = np.array([np.interp(t, ts, points[:, i]) for i in range(2)])
tan = np.array([np.interp(t, ts, tangents[:, i]) for i in range(2)])
# Calculate the normal vector to the curve.
normal = np.array([-tan[1], tan[0]])
normal /= np.linalg.norm(normal)
# Ensure the text is on the convex side of the curve.
if np.dot(normal, pos - chord_mid) < 0:
normal *= -1
# Calculate the character rotation and keep it upright.
angle = np.degrees(np.arctan2(tan[1], tan[0]))
if flipped:
angle += 180
angle = (angle + 180) % 360 - 180
# Offset the character away from the curve.
theta, radius = inv.transform(pos + normal * offset)
# Draw the character.
ax.text(theta, radius, char, ha="center", va="center", fontsize=fontsize, color=color,
rotation=angle, rotation_mode="anchor")
rad = 0.5
for country, group in df.groupby("countries", sort=False):
x_arr = ( 2 * np.pi / max_sites * group["sites"].iloc[1])
x_end = ( 2 * np.pi / max_sites * group["sites"].iloc[0])
y_arr = y_end = 0.95
color = group.color.iloc[0]
# Arrow
ax.annotate(
"",
xy=(x_arr, y_arr),
xytext=(x_end, y_end),
arrowprops=dict(
arrowstyle="->", connectionstyle=f"arc3,rad={rad}", color=color, linewidth=2, linestyle="-",
),
)
# Curved label
curved_label( ax, country, p1=(x_end, y_end), p2=(x_arr, y_arr), rad=rad, offset=12, fontsize=10, color=color,)
fig
#add legend
color_legend = ["w","#838B93"]
marker_edge_color = ["#838B93","#838B93"]
lines = [Line2D([0], [0], color=c, marker='o',linestyle='',markeredgecolor=ec, markersize=10,) for c, ec in zip(color_legend, marker_edge_color)]
fig.legend(
lines,
df.year.unique(),
bbox_to_anchor=(0.5, 0), loc="lower center", ncols = 2, frameon=False, fontsize= 10 )
fig
ax.spines[['start','end']].set_color('w')
ax.spines[['polar']].set_color(grid_color)
for k, spine in ax.spines.items(): #ax.spines to the back
spine.set_zorder(0)
ax.set_yticklabels([])
ax.grid(False )
fig