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.patches as patches
import matplotlib.path as mpath
import numpy as np
import pandas as pd
code_dict = {"Norway": "NO", "Denmark": "DK", "Sweden": "SE", }
color_dict = { "Norway": "#2B314D", "Denmark": "#A54836", "Sweden": "#5375D4"}
data_labels = "#7C8091"
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([ 'year', 'sites'], ascending=True ).reset_index(drop=True)
df['pct_change'] = df.groupby('countries', sort=False)['sites'].apply(
lambda x: x.pct_change()).to_numpy()*-1
df['ctry_code'] = df.countries.map(code_dict)
df
| year | countries | sites | pct_change | ctry_code | |
|---|---|---|---|---|---|
| 0 | 2004 | Denmark | 4 | NaN | DK |
| 1 | 2004 | Norway | 5 | -1.500000 | NO |
| 2 | 2004 | Sweden | 13 | NaN | SE |
| 3 | 2022 | Norway | 8 | -0.600000 | NO |
| 4 | 2022 | Denmark | 10 | NaN | DK |
| 5 | 2022 | Sweden | 15 | -0.153846 | SE |
Plot the chart¶
fig, ax = plt.subplots(figsize=(8, 5))
gap = 5
dir_lines = [-1,1]
bars = []
for j, (year, group) in enumerate(df.groupby("year", sort = False)):
bottom = 5 if year == 2004 else 0
for _, row in group.iterrows():
height = row['sites']
country = row['countries']
bar = ax.bar(
year,
height,
bottom=bottom,
width=0.3,
color=color_dict[country],
)
bottom += height + gap # Add site height + gap for next segment
bars.append(bar)
#add vertical lines
ax.axvline(
year + 0.15 * dir_lines[j],
0,
45,
color = data_labels,
lw =1
)
#add year labels
ax.text(
year + 0.15 * dir_lines[j],
55,
year,
color = data_labels,
size = 12,
ha = "center",
va = "center",
clip_on = False
)
Add the data labels:
Create a function for plotting a beizer.¶
To create a bezier area, we need the following points between the bars:
To simplify the creation and add the gradient we will use the function below that requires the following points:
- x0: the x-coordinate of the starting position of the left bar
- x1: the x-coordinate of the starting position of the right bar
- y0: the y-coordinate of the left edge of both bars
- y1: the y-coordinate of the right edge of both bars
- height0: the height of the left horizontal bar
- height1: the height of the right horizontal bar
def draw_horizontal_beizer(ax, x0, x1, y0, y1, height0, height1, facecolor, edgecolor, alpha, text, offset_text):
"""
Draws a smooth cubic bezier curve between two horizontal bars, and fills the area between the curve and the bars.
Parameters:
----------
ax : The Axes object where the Bézier curve and area will be drawn.
x0 : float, the x-coordinate of the starting position of the left bar.
x1 : float, the x-coordinate of the starting position of the right bar.
y0 : float, the y-coordinate of the bottom edge of both bars.
y1 : float, the y-coordinate of the bottom edge of both bars.
height0 : float, the height of the left horizontal bar.
height1 : float the height of the right horizontal bar.
facecolor : str, the color of the area.
edgecolor : str, the color of the area's edge.
alpha : float, the transparency of the Bézier curve and area.
offset_text : str, pffset the x coodinate for the text.
text : str, add text at the center of the beizer aera.
"""
Path = mpath.Path
# Define main points
top_left = (x0, y0 + height0)
top_right = (x1, y1 + height1)
bottom_right = (x1, y1)
bottom_left = (x0, y0)
curve_offset = 4
# Top curve control points (horizontal Bézier)
ctrl1_top = (x0 + curve_offset, y0 + height0)
ctrl2_top = (x1 - curve_offset, y1 + height1)
# Bottom curve control points (mirror or match top)
ctrl1_bottom = (x1 - curve_offset, y1 - curve_offset)
ctrl2_bottom = (x0 + curve_offset, y0 + curve_offset)
# Define path
vertices = [
top_left,
ctrl1_top,
ctrl2_top,
top_right,
bottom_right,
ctrl1_bottom,
ctrl2_bottom,
bottom_left,
top_left # CLOSEPOLY requires repeating first point
]
codes = [
Path.MOVETO, # top_left
Path.CURVE4,
Path.CURVE4,
Path.CURVE4, # top_right
Path.LINETO, # bottom_right (start new subpath)
Path.CURVE4,
Path.CURVE4,
Path.CURVE4, # bottom_left
Path.CLOSEPOLY # close the full loop
]
path = Path(vertices, codes)
patch = patches.PathPatch(path, facecolor=facecolor, edgecolor=edgecolor, alpha = alpha)
ax.add_patch(patch)
######
#add text at the center of the area
###########
# Find the bounding box of the Bézier path
xs = [p[0] for p in vertices]
ys = [p[1] for p in vertices]
# Calculate the center of the bounding box
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
center_x = (x_min + x_max) / 2
center_y = (y_min + y_max) / 2
# Add text in the center of the bounding box
if text:
ax.text(center_x + offset_text, center_y, text, color="w", fontsize=12, ha="center", va="center")
for i, (bar1, bar2) in enumerate(zip([bars[0], bars[1], bars[2]], [bars[4], bars[3], bars[5]])):
color = color_dict[df.countries.unique()[i]]
ctry_code = df.ctry_code.unique()[i]
print(i, country)
# coordinates for fisrt bar
x0 = bar1[0].get_x() + bar1[0].get_width() # Right edge of Bar 1
y0 = bar1[0].get_y()
height0 = bar1[0].get_height()
# for second bar
x1 = bar2[0].get_x() # Left edge of Bar 2
y1 = bar2[0].get_y()
height1 = bar2[0].get_height()
#add the beizer
draw_horizontal_beizer(ax, x0, x1, y0, y1, height0, height1, color, color, 0.9, ctry_code, 0)
fig
0 Sweden 1 Sweden 2 Sweden
Add the labels and the styling:
dir_labels = np.repeat([-1,1],3)
for k, (bars_, row) in enumerate(zip(ax.patches, df.itertuples())):
ax.text(
bars_.get_x() + 1 * dir_labels[k],
bars_.get_y() + bars_.get_height() / 2,
row.sites,
color = data_labels,
size = 12
)
ax.set_axis_off()
ax.set(ylim = (-2, 50))
fig