About

This plot is a combination of multiple choropleth maps, with a lollipop plot as a legend. It shows the share of explained happiness of different factors across Europe.

The chart was made by Joseph B. Thanks to him for accepting sharing his work here!

Let's see what the final picture will look like:

preview

Libraries

First, you need to load a whole bunch of libraries:

# data
import pandas as pd
import geopandas as gpd

# plotting
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap

# annotations
import matplotlib.patches as patches
from matplotlib.patches import FancyArrowPatch
from highlight_text import ax_text, fig_text
import matplotlib.patheffects as path_effects

Dataset

For this reproduction, we're going to retrieve the data directly from the gallery's Github repo. This means we just need to give the right url as an argument to pandas' read_csv() function to retrieve the data.

The dataset contains one row per country and its associated value. The dataset has been transformed a bit, but you can find the original data here.

Note: the geometry column is used to create the shape of the countries on the map.

# Open the dataset from Github
url = "https://raw.githubusercontent.com/holtzy/the-python-graph-gallery/master/static/data/WHRreport2024.csv"
url = '../../static/data/WHRreport2024.csv'
df = pd.read_csv(url)

# open and merge with geo data
world = gpd.read_file(
    "../../static/data/ne_110m_admin_0_countries/ne_110m_admin_0_countries.shp"
)
europe = world[world['CONTINENT'] == 'Europe']
data = europe.merge(
    df, how='left',
    left_on='NAME',
    right_on='Country name'
)
data = data[['geometry']+df.columns.to_list()]
data.dropna(inplace=True)
data.head(3)
geometry Country name Ladder score upperwhisker lowerwhisker Dystopia + residual Log GDP per capita Social support Healthy life expectancy Freedom to make life choices Generosity Perceptions of corruption share_Dystopia + residual share_Log GDP per capita share_Social support share_Healthy life expectancy share_Freedom to make life choices share_Generosity share_Perceptions of corruption
1 MULTIPOLYGON (((15.14282 79.67431, 15.52255 80... Norway 7.302 7.389 7.215 1.586 1.952 1.517 0.704 0.835 0.224 0.484 21.720077 26.732402 20.775130 9.641194 11.435223 3.067653 6.628321
2 MULTIPOLYGON (((-51.65780 4.15623, -52.24934 3... France 6.609 6.685 6.533 1.672 1.818 1.348 0.727 0.650 0.112 0.281 25.298835 27.507944 20.396429 11.000151 9.835073 1.694659 4.251778
3 POLYGON ((11.02737 58.85615, 11.46827 59.43239... Sweden 7.344 7.422 7.267 1.658 1.878 1.501 0.724 0.838 0.221 0.524 22.576253 25.571895 20.438453 9.858388 11.410675 3.009259 7.135076

Initiate the maps

We start by creating a figure with 3 rows and 2 columns.

Then, we add the maps, with default colors on the last 4 axes.

def plot_map_on_ax(column, ax):
    """
    Add a map on a given axis
    """
    data.plot(
        column=column,
        edgecolor='black', linewidth=0.4,
        ax=ax
    )
    ax.set_xlim(-13.8, 40)
    ax.set_ylim(32, 72)
    ax.axis('off')


# initialize the figure
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 10))
axs = axs.flatten()

# list that we use to display maps,
# with empty values for 2 first axes
columns = [
    '', '',
    'share_Generosity',
    'share_Perceptions of corruption',
    'share_Freedom to make life choices',
    'share_Social support'
]
for i, (ax, column) in enumerate(zip(axs, columns)):

    # skip first two axes (on top of the maps)
    if i in [0, 1]:
        continue

    # add map on the current axe
    plot_map_on_ax(column=column, ax=ax)

# display chart
plt.tight_layout()
plt.show()

Custom colors

For this chart, we define a colors dictionnary that contains column names as keys, and a list of 2 colors as values. We do so in order to then create colormaps (or cmap) based on those colors, thanks to the create_gradient_colormap() function, defined below.

We also use the set_facecolor() to change background color of the figure and the lollipop axe. The first axe does not need it since it will be completly removed in the next step and will have the figure color as well.

def create_gradient_colormap(colors):
    """
    Based on 2 color input, create a colormap of it.
    """
    cmap = LinearSegmentedColormap.from_list("custom_gradient", colors, N=256)
    return cmap


def plot_map_on_ax(column, ax, cmap):
    """
    Add a map on a given axis.
    """
    data.plot(
        column=column,
        cmap=cmap,
        edgecolor='black', linewidth=0.4,
        ax=ax
    )
    ax.set_xlim(-13.8, 40)
    ax.set_ylim(32, 72)
    ax.axis('off')


# colors for the chart
colors = {
    'share_Generosity': ['#c9ada7', '#4a4e69'],
    'share_Perceptions of corruption': ['#fcbf49', '#d62828'],
    'share_Freedom to make life choices': ['#90e0ef', '#0077b6'],
    'share_Social support': ['#80ed99', '#38a3a5'],
}
background_col = '#22333b'

# initialize the figure
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 10))
axs = axs.flatten()

# set background color
fig.set_facecolor(background_col)
axs[1].set_facecolor(background_col)

# list that we use to display maps,
# with empty values for 2 first axes
columns = [
    '', '',
    'share_Generosity',
    'share_Perceptions of corruption',
    'share_Freedom to make life choices',
    'share_Social support'
]
for i, (ax, column) in enumerate(zip(axs, columns)):

    # skip first two axes (on top of the maps)
    if i in [0, 1]:
        continue

    # create a colormap based on colors
    cmap = create_gradient_colormap(colors[column])

    # add map on the current axe
    plot_map_on_ax(column=column, ax=ax, cmap=cmap)

# display chart
plt.tight_layout()
plt.show()

Add lollipop plot

Making a lollipop plot can be a bit tricky in matplotlib, especially when having points on both side.

For this, we use the scatter() function for the points and the hlines() function for the horizontal lines.

The positions are defined thanks to the min_max_df, defined in the code below. It contains one row per column (so 4 in total) and a column for the minimum value and one for the maximum.

def create_gradient_colormap(colors):
    """
    Based on 2 color input, create a colormap of it.
    """
    cmap = LinearSegmentedColormap.from_list("custom_gradient", colors, N=256)
    return cmap


def plot_map_on_ax(column, ax, cmap):
    """
    Add a map on a given axis.
    """
    data.plot(
        column=column,
        cmap=cmap,
        edgecolor='black', linewidth=0.4,
        ax=ax
    )
    ax.set_xlim(-13.8, 40)
    ax.set_ylim(32, 72)
    ax.axis('off')


# colors for the chart
colors = {
    'share_Generosity': ['#c9ada7', '#4a4e69'],
    'share_Perceptions of corruption': ['#fcbf49', '#d62828'],
    'share_Freedom to make life choices': ['#90e0ef', '#0077b6'],
    'share_Social support': ['#80ed99', '#38a3a5'],
}
background_col = '#22333b'

# initialize the figure
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 10))
axs = axs.flatten()

# set background color
fig.set_facecolor(background_col)
axs[1].set_facecolor(background_col)

# list that we use to display maps,
# with empty values for 2 first axes
columns = [
    '', '',
    'share_Generosity',
    'share_Perceptions of corruption',
    'share_Freedom to make life choices',
    'share_Social support'
]
for i, (ax, column) in enumerate(zip(axs, columns)):

    # skip first two axes (on top of the maps)
    if i in [0, 1]:
        continue

    # create a colormap based on colors
    cmap = create_gradient_colormap(colors[column])

    # add map on the current axe
    plot_map_on_ax(column=column, ax=ax, cmap=cmap)

# Lollipop plot
min_max_df = data[columns[2:]].agg(['min', 'max']).T
for i, col in enumerate(columns[2:]):

    # colors
    min_color = colors[col][0]
    max_color = colors[col][1]

    # filter on current column
    subset = min_max_df.iloc[i].T

    # add data points of lollipop
    axs[1].scatter(subset['min'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=min_color)
    axs[1].scatter(subset['max'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=max_color)

# horizontal lines of lollipop
axs[1].hlines(
    y=range(4),
    xmin=min_max_df['min'], xmax=min_max_df['max'],
    color='white', linewidth=0.8, zorder=1
)

# custom lollipop axis features
axs[1].spines[['right', 'top', 'left']].set_visible(False)
axs[1].set_xticks([0, 10, 20, 30, 40])
axs[1].spines['bottom'].set_color('white')
axs[1].tick_params(axis='x', colors='white')
axs[1].set_yticks([])
axs[1].set_ylim(-1, 6)
axs[1].set_xlim(-3, 33)

# display chart
plt.tight_layout()
plt.show()

Title and description

The title (and the description) are displayed using the ax_text() function from the highlight_text package. For a dedicated explanation of how it works, check this post.

def create_gradient_colormap(colors):
    """
    Based on 2 color input, create a colormap of it.
    """
    cmap = LinearSegmentedColormap.from_list("custom_gradient", colors, N=256)
    return cmap

def plot_map_on_ax(column, ax, cmap):
    """
    Add a map on a given axis.
    """
    data.plot(
        column=column,
        cmap=cmap,
        edgecolor='black', linewidth=0.4,
        ax=ax
    )
    ax.set_xlim(-13.8, 40)
    ax.set_ylim(32, 72)
    ax.axis('off')

# colors for the chart
colors = {
    'share_Generosity': ['#c9ada7', '#4a4e69'],
    'share_Perceptions of corruption': ['#fcbf49', '#d62828'],
    'share_Freedom to make life choices': ['#90e0ef', '#0077b6'],
    'share_Social support': ['#80ed99', '#38a3a5'],
}
background_col = '#22333b'

# initialize the figure
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 10))
axs = axs.flatten()

# set background color
fig.set_facecolor(background_col)
axs[1].set_facecolor(background_col)

# list that we use to display maps,
# with empty values for 2 first axes
columns = [
    '', '',
    'share_Generosity',
    'share_Perceptions of corruption',
    'share_Freedom to make life choices',
    'share_Social support'
]
for i, (ax, column) in enumerate(zip(axs, columns)):

    # skip first two axes (on top of the maps)
    if i in [0, 1]:
        continue

    # create a colormap based on colors
    cmap = create_gradient_colormap(colors[column])

    # add map on the current axe
    plot_map_on_ax(column=column, ax=ax, cmap=cmap)

# Lollipop plot
min_max_df = data[columns[2:]].agg(['min', 'max']).T
for i, col in enumerate(columns[2:]):

    # colors
    min_color = colors[col][0]
    max_color = colors[col][1]

    # filter on current column
    subset = min_max_df.iloc[i].T

    # add data points of lollipop
    axs[1].scatter(subset['min'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=min_color)
    axs[1].scatter(subset['max'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=max_color)

# horizontal lines of lollipop
axs[1].hlines(
    y=range(4),
    xmin=min_max_df['min'], xmax=min_max_df['max'],
    color='white', linewidth=0.8, zorder=1
)

# custom lollipop axis features
axs[1].spines[['right', 'top', 'left']].set_visible(False)
axs[1].set_xticks([0, 10, 20, 30, 40])
axs[1].spines['bottom'].set_color('white')
axs[1].tick_params(axis='x', colors='white')
axs[1].set_yticks([])
axs[1].set_ylim(-1, 6)
axs[1].set_xlim(-3, 33)

# remove top left axis
axs[0].set_axis_off()

# title and credit
text = """
<What determines happiness>
<in Europe? Well, it depends>


<Share, in %, of happiness explained by different>
<factors across Europe. For each country, the>
<darker the color is, the more the factor explains>
<happiness in this country.>
"""
ax_text(
    -0.02, 0.6,
    text,
    ha='left', va='center',
    fontsize=15,
    color='black',
    highlight_textprops=[
        {'fontweight': 'bold',
         'color': 'white'},
        {'fontweight': 'bold',
         'color': 'white'},

        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11}
    ],
    ax=axs[0]
)

# display chart
plt.tight_layout()
plt.show()

Annotations

Similar to the title, the annotations are displayed using the ax_text() and fig_text() function from the highlight_text package.

The arrows are displayed using FancyArrowPatch() function from matplotlib. We define a draw_arrow() function in order to make the code a bit easier to read when adding arrows. It just needs tail and head positions to add arrows on the figure.

def create_gradient_colormap(colors):
    """
    Based on 2 color input, create a colormap of it.
    """
    cmap = LinearSegmentedColormap.from_list("custom_gradient", colors, N=256)
    return cmap

def draw_arrow(tail_position, head_position, fig, invert=False):
    """
    Draw a curve arrow at given tail/head position, on a figure.
    """
    kw = dict(
        arrowstyle="Simple, tail_width=0.5, head_width=4, head_length=8", color="white")
    if invert:
        connectionstyle = "arc3,rad=-.5"
    else:
        connectionstyle = "arc3,rad=.5"
    a = FancyArrowPatch(tail_position, head_position,
                        connectionstyle=connectionstyle,
                        transform=fig.transFigure,
                        **kw)
    fig.patches.append(a)

def plot_map_on_ax(column, ax, cmap):
    """
    Add a map on a given axis.
    """
    data.plot(
        column=column,
        cmap=cmap,
        edgecolor='black', linewidth=0.4,
        ax=ax
    )
    ax.set_xlim(-13.8, 40)
    ax.set_ylim(32, 72)
    ax.axis('off')

def path_effect_stroke(**kwargs):
    return [path_effects.Stroke(**kwargs), path_effects.Normal()]
pe = path_effect_stroke(linewidth=1, foreground="black")

# colors for the chart
colors = {
    'share_Generosity': ['#c9ada7', '#4a4e69'],
    'share_Perceptions of corruption': ['#fcbf49', '#d62828'],
    'share_Freedom to make life choices': ['#90e0ef', '#0077b6'],
    'share_Social support': ['#80ed99', '#38a3a5'],
}
background_col = '#22333b'

# initialize the figure
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 10))
axs = axs.flatten()

# set background color
fig.set_facecolor(background_col)
axs[1].set_facecolor(background_col)

# list that we use to display maps,
# with empty values for 2 first axes
columns = [
    '', '',
    'share_Generosity',
    'share_Perceptions of corruption',
    'share_Freedom to make life choices',
    'share_Social support'
]

# annotation positions on the lollipop
annotations_pos = [
    '', '',
    [8, 0],
    [9, 1],
    [15, 2],
    [3, 3]
]

# iterate over of the 6 axes AND column names
for i, (ax, column) in enumerate(zip(axs, columns)):

    # skip first two axes (on top of the maps)
    if i in [0, 1]:
        continue

    # create a colormap based on colors
    cmap = create_gradient_colormap(colors[column])

    # add map on the current axe
    plot_map_on_ax(column=column, ax=ax, cmap=cmap)

    # annotations below each map
    ax_text(
        -15, 33, # fixed position for each map
        '<'+column[6:]+'>',
        ha='left', va='center',
        fontsize=9, fontweight='bold',
        color=cmap(0.5),
        highlight_textprops=[
            {"path_effects": pe}
        ], ax=ax
    )

    # annotations on lollipop
    x, y = annotations_pos[i]
    ax_text(
        x, y,
        '<'+column[6:]+'>',
        ha='left', va='center',
        fontweight='bold',
        fontsize=10,
        color=cmap(0.5),
        highlight_textprops=[
            {"path_effects": pe}
        ], ax=axs[1]
    )

# Lollipop plot
min_max_df = data[columns[2:]].agg(['min', 'max']).T
for i, col in enumerate(columns[2:]):

    # colors
    min_color = colors[col][0]
    max_color = colors[col][1]

    # filter on current column
    subset = min_max_df.iloc[i].T

    # add data points of lollipop
    axs[1].scatter(subset['min'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=min_color)
    axs[1].scatter(subset['max'], i, zorder=2, s=160, edgecolor='black', linewidth=0.5, color=max_color)

# horizontal lines of lollipop
axs[1].hlines(
    y=range(4),
    xmin=min_max_df['min'], xmax=min_max_df['max'],
    color='white', linewidth=0.8, zorder=1
)

# custom lollipop axis features
axs[1].spines[['right', 'top', 'left']].set_visible(False)
axs[1].set_xticks([0, 10, 20, 30, 40])
axs[1].spines['bottom'].set_color('white')
axs[1].tick_params(axis='x', colors='white')
axs[1].set_yticks([])
axs[1].set_ylim(-1, 6)
axs[1].set_xlim(-3, 33)

# remove top left axis
axs[0].set_axis_off()

# title and credit
text = """
<What determines happiness>
<in Europe? Well, it depends>


<Share, in %, of happiness explained by different>
<factors across Europe. For each country, the>
<darker the color is, the more the factor explains>
<happiness in this country.>
"""
ax_text(
    -0.02, 0.6,
    text,
    ha='left', va='center',
    fontsize=15,
    color='black',
    highlight_textprops=[
        {'fontweight': 'bold',
         'color': 'white'},
        {'fontweight': 'bold',
         'color': 'white'},

        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11},
        {'color': 'darkgrey',
         'fontsize': 11}
    ],
    ax=axs[0]
)

# credit
text = """
<Design:> Joseph Barbier
<Data:> World Happiness Report 2024
"""
ax_text(
    -17.6, 25, text,
    ha='left', va='center',
    fontsize=6, color='white',
    highlight_textprops=[
        {'fontweight': 'bold'},
        {'fontweight': 'bold'},
    ], ax=axs[4]
)

# reduce size and change position of lollipop axe
axs[1].set_position([0.56, 0.68, 0.2, 0.1])

# legend arrows
draw_arrow((0.7, 0.92), (0.76, 0.87), fig=fig, invert=True)
draw_arrow((0.82, 0.92), (0.862, 0.87), fig=fig, invert=True)
ax_text(
    6, 4.7, 'Minimum',
    fontsize=8, color='white',
    ax=axs[1]
)
ax_text(
    16.7, 4.8, 'Maximum',
    fontsize=8, color='white',
    ax=axs[1]
)

plt.tight_layout()
fig.savefig('../../static/graph/web-multiple-maps.png', dpi=300, bbox_inches='tight')
plt.show()

Going further

This article explains how to reproduce a multiple choropleth maps with a lollipop plot for the legend.

You might be interested in

Contact & Edit


👋 This document is a work by Yan Holtz. You can contribute on github, send me a feedback on twitter or subscribe to the newsletter to know when new examples are published! 🔥

This page is just a jupyter notebook, you can edit it here. Please help me making this website better 🙏!