Skip to Content
The HIC Learning Exchange begins July 13, 2026. View the agenda

Visualization Guide

A library of styled, presentation-ready chart templates for health reporting, available in Python (Matplotlib) and R (ggplot2). Each entry is complete and self-contained: expand Show the code, pick your language, and everything needed to reproduce the output below it is there — imports, data, styling, and layout. Swap the sample dataframe for your own and the styling carries over. Chart types and styling follow the HIC house style for charts. The Python source notebook is analysis/spatial-analysis/Bar-Plots.ipynb.

The datasets in these examples are illustrative placeholders used only to demonstrate layout and styling. They contain no real health, patient, or facility data.

Horizontal bar

Horizontal bar with a highlighted category

Ranks named categories and draws the eye to one row with a single accent color, keeping the rest as neutral context.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default. " "Install it and re-run for the exact look.") # Data HIGHLIGHT = "Spain" COLOR_DEFAULT = "#1a4a7a" COLOR_HIGHLIGHT = "#5fc4c0" BG_COLOR = "#dce8f0" df = pd.DataFrame({ "country": ["Germany", "Spain", "Austria", "France", "Luxembourg", "Portugal", "Britain", "Denmark", "Sweden"], "consultations_per_person": [8.2, 7.5, 6.8, 6.7, 6.3, 5.8, 5.1, 4.7, 2.9], }) df["highlight"] = df["country"] == HIGHLIGHT df["color"] = df["highlight"].map({True: COLOR_HIGHLIGHT, False: COLOR_DEFAULT}) df["y_pos"] = range(len(df) - 1, -1, -1) # top-to-bottom order print(df.to_string(index=False)) fig, ax = plt.subplots(figsize=(7, 5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) # Bars ax.barh(df["y_pos"], df["consultations_per_person"], color=df["color"], height=0.6) # Axes ax.set_xlim(0, 8.5) ax.set_xticks([0, 2, 4, 6, 8]) ax.xaxis.set_tick_params(labelsize=9, colors="#555555") ax.xaxis.tick_top() ax.set_yticks(df["y_pos"]) ax.set_yticklabels( [f"$\\bf{{{c}}}$" if c == HIGHLIGHT else c for c in df["country"]], fontsize=10 ) ax.tick_params(axis="y", length=0) # Grid & spines ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) ax.tick_params(axis="x", length=0) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.055, 0.016, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.965, "Salud", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.930, "Doctors' consultations per person", fontsize=9, va="top", color="#333333", transform=fig.transFigure) fig.text(0.02, 0.905, "Selected countries, 2009", fontsize=8.5, va="top", color="#555555", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: Health Management Information System", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.79, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) # rect=[left, bottom, right, top] — lower 'top' pushes the plot area down plt.tight_layout(rect=[0, 0.03, 1, 0.78]) plt.show()
charts/doctors_consultations.png

Horizontal bar, two series

Compares two measures per category (for example two periods or two indicators) with paired bars.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data df = pd.DataFrame({ "country": ["Rubavu", "Rusizi", "Bugesera", "Nyabihu", "Musanze", "Burera", "Rulindo", "Gasabo", "Nyamasheke"], "middle_class": [80, 72, 60, 58, 55, 50, 62, 50, 48], "low_income": [68, 63, 50, 62, 38, 42, 55, 48, 42], }) # Reverse so Chile appears on top df = df.iloc[::-1].reset_index(drop=True) print(df.to_string(index=False)) # Colors COLOR_MIDDLE = "#1a4a7a" COLOR_LOW = "#5fc4c0" BG_COLOR = "#dce8f0" # Layout n = len(df) y = np.arange(n) height = 0.35 # bar height for each trace fig, ax = plt.subplots(figsize=(7, 5.5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) # Bars ax.barh(y + height / 2, df["middle_class"], height=height, color=COLOR_MIDDLE, label="Middle class") ax.barh(y - height / 2, df["low_income"], height=height, color=COLOR_LOW, label="Low income") # Axes ax.set_xlim(0, 85) ax.set_xticks([0, 20, 40, 60, 80]) ax.xaxis.set_tick_params(labelsize=9, colors="#555555") ax.xaxis.tick_top() ax.set_yticks(y) ax.set_yticklabels(df["country"], fontsize=10, fontweight="bold") ax.tick_params(axis="y", length=0) # Grid & spines ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) ax.tick_params(axis="x", length=0) # Legend legend = ax.legend( loc="lower right", fontsize=9, frameon=False, handlelength=1.2, handleheight=0.9, handletextpad=0.5, ) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.965), 0.045, 0.016, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.955, "Defenders of democracy", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.915, "Respondents saying that honest elections held regularly\n" "with a choice of at least two political parties are very\n" "important, 2007, %", fontsize=8.5, va="top", color="#444444", transform=fig.transFigure, linespacing=1.5) # Footer fig.text(0.02, 0.02, "Source: Pew Global Attitudes Survey", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.72, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.03, 1, 0.78]) plt.show()
charts/defenders_of_democracy.png

Grouped horizontal bar with sub-categories

Breaks each category into grouped sub-category bars with group labels.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import matplotlib.text as mtext import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data: two forecast vintages (June / September) per district and year df = pd.DataFrame({ "country": ["Burera", "Burera", "Nyabihu", "Nyabihu", "Rulindo", "Rulindo", "Gasabo", "Gasabo", "Musanze", "Musanze"], "year": [2011, 2012] * 5, "june": [3.0, 1.8, 2.1, 1.4, 2.0, 1.5, 0.8, 1.1, 1.0, 0.3], "september": [2.7, 1.3, 1.7, 1.0, 1.6, 1.1, 0.7, 0.3, 0.6, 0.1], }) print(df.to_string(index=False)) # Colors COLOR_JUNE = "#1a4a7a" COLOR_SEP = "#5fc4c0" BG_COLOR = "#dce8f0" # Build y positions # Layout (bottom→top): Italy_2012, Italy_2011, gap, Spain_2012, Spain_2011 … # Each year-row occupies 2 bar slots stacked: june on top, sep below. # june centre = y_row + BAR_H/2 # sep centre = y_row - BAR_H/2 BAR_H = 0.30 # height of each individual bar ROW_STEP = BAR_H * 2 + 0.05 # vertical space per year-row GROUP_GAP = 0.30 # extra gap between country groups countries = df["country"].unique() # original order: Germany … Italy positions = [] y = 0 # Build bottom-up: last country first, within each country 2012 first for country in reversed(countries): sub = df[df["country"] == country].sort_values("year", ascending=False) # 2012, 2011 # Place 2012 at bottom of group, 2011 above it for _, row in sub.iterrows(): positions.append({ "country": country, "year": int(row["year"]), "june": row["june"], "sep": row["september"], "y": y }) y += ROW_STEP y += GROUP_GAP pos_df = pd.DataFrame(positions) # After building bottom-up: lower y = 2012, higher y = 2011 ✓ # Plot fig, ax = plt.subplots(figsize=(6, 6.5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) for _, r in pos_df.iterrows(): # June on top (+BAR_H/2), September below (-BAR_H/2) ax.barh(r["y"] + BAR_H / 2, r["june"], height=BAR_H, color=COLOR_JUNE, align="center") ax.barh(r["y"] - BAR_H / 2, r["sep"], height=BAR_H, color=COLOR_SEP, align="center") # Y-axis: year labels only, country drawn via ax.text ax.set_yticks(pos_df["y"]) ax.set_yticklabels([str(r["year"]) for _, r in pos_df.iterrows()], fontsize=9, color="#555555") ax.tick_params(axis="y", length=0, pad=2) # Draw country name centred between its two year rows for country in countries: rows = pos_df[pos_df["country"] == country] mid_y = rows["y"].mean() ax.text(-0.18, mid_y, country, ha="right", va="center", fontsize=9, fontweight="bold", transform=ax.get_yaxis_transform()) # X-axis ax.set_xlim(0, 3.3) ax.set_xticks([0, 1, 2, 3]) ax.xaxis.set_tick_params(labelsize=9, colors="#555555") ax.xaxis.tick_top() ax.tick_params(axis="x", length=0) # Grid & spines ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Legend from matplotlib.patches import Patch legend_elements = [Patch(facecolor=COLOR_JUNE, label="June"), Patch(facecolor=COLOR_SEP, label="September")] ax.legend(handles=legend_elements, loc="lower right", fontsize=8, frameon=False, title="Forecasts made in:", title_fontsize=8) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.045, 0.013, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Autumn fall", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.936, "GDP, % increase on previous year", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: HMIS", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.79, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.03, 1, 0.80]) plt.show()
charts/autumn_fall_gdp.png

Ranked horizontal bar with emphasis

A longer ranked list where one bar carries the story.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data df = pd.DataFrame({ "sector": ["Internet", "Software", "Tech hardware", "IT services", "Health care", "Total", "Leisure", "Chemicals", "Oil, gas and fuels", "Metals and mining", "Property", "Energy equipment"], "change": [57, 34, 20, 16, 10, 4, -3, -5, -18, -22, -28, -58], "bold": [False, False, False, False, False, True, False, False, False, False, False, False], }) # Reverse so Internet is on top df = df.iloc[::-1].reset_index(drop=True) print(df.to_string(index=False)) # Colors COLOR_BAR = "#1a4a7a" COLOR_ZERO = "#c0392b" BG_COLOR = "#dce8f0" # Plot fig, ax = plt.subplots(figsize=(6, 6.5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) y = np.arange(len(df)) ax.barh(y, df["change"], color=COLOR_BAR, height=0.6, zorder=2) # Zero line (red) ax.axvline(x=0, color=COLOR_ZERO, linewidth=1.5, zorder=3) # Axes ax.set_xlim(-65, 65) ax.set_xticks([-60, -40, -20, 0, 20, 40, 60]) ax.xaxis.set_tick_params(labelsize=9, colors="#555555") ax.xaxis.tick_top() ax.tick_params(axis="x", length=0) ax.set_yticks(y) ax.set_yticklabels(df["sector"], fontsize=10) ax.tick_params(axis="y", length=0) # Bold "Total" label for label, (_, row) in zip(ax.get_yticklabels(), df.iterrows()): if row["bold"]: label.set_fontweight("bold") # Grid & spines ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.045, 0.013, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Another dotcom boom", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.935, "Change in worldwide capital spending*, %", fontsize=9, va="top", color="#444444", transform=fig.transFigure) fig.text(0.02, 0.912, "2014-17 forecast", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: Goldman Sachs", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.68, 0.02, "*In dollar terms", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.03, 1, 0.78]) plt.show()
charts/dotcom_boom_capex.png

Vertical column

Vertical column chart

Ordered categories or a short time series reading left to right.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data df = pd.DataFrame({ "year": ["2004","06","08","10","11","12","13","14","15*"], "gdp": [ 11.7, 12.6, 11.2, 10.0, 11.2, 10.9, 9.7, 10.3, 8.7], "estimate": [ False, False, False, False, False, False, False, False, True], }) # Colors COLOR_DEFAULT = "#1a4a7a" COLOR_ESTIMATE = "#5fc4c0" BG_COLOR = "#dce8f0" df["color"] = df["estimate"].map({False: COLOR_DEFAULT, True: COLOR_ESTIMATE}) print(df.to_string(index=False)) # Plot fig, ax = plt.subplots(figsize=(5, 5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) x = range(len(df)) ax.bar(x, df["gdp"], color=df["color"], width=0.75, zorder=2) # Axes ax.set_xlim(-0.5, len(df) - 0.5) ax.set_ylim(0, 14) ax.set_yticks([0, 2, 4, 6, 8, 10, 12, 14]) ax.yaxis.set_tick_params(labelsize=9, colors="#555555") ax.yaxis.tick_right() # y-axis on the right ax.tick_params(axis="y", length=0) ax.set_xticks(list(x)) ax.set_xticklabels(df["year"], fontsize=9, color="#555555") ax.tick_params(axis="x", length=0) # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.055, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "African lion", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.935, "Ethiopia's GDP, % change on a year earlier", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: IMF", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.02, 0.055, "*Estimate", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.79, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.06, 1, 0.80]) plt.show()
charts/african_lion_gdp.png

Vertical column chart, alternate styling

The same column pattern with a different palette and emphasis.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import matplotlib.patches as mpatches import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data df = pd.DataFrame({ "period": ["20 years", "10 years", "5 years"], "equities": [8.5, 7.5, 8.5], "all_property": [9.5, 8.0, 13.5], "rural": [14.5, 13.5, 13.0], "forestry": [11.0, 18.5, 20.5], }) print(df.to_string(index=False)) # Colors COLORS = { "equities": "#1a4a7a", "all_property": "#5fc4c0", "rural": "#c9a84c", "forestry": "#2d7a4a", } LABELS = { "equities": "Equities", "all_property": "All property", "rural": "Rural property", "forestry": "Forestry", } BG_COLOR = "#dce8f0" keys = list(COLORS.keys()) n_groups = len(df) n_bars = len(keys) width = 0.18 # width of each bar group_w = width * n_bars + 0.08 # total group width incl. gap x = np.arange(n_groups) # Plot fig, ax = plt.subplots(figsize=(6, 5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) for i, key in enumerate(keys): offsets = x - (n_bars - 1) * width / 2 + i * width ax.bar(offsets, df[key], width=width, color=COLORS[key], label=LABELS[key], zorder=2) # Axes ax.set_xlim(-0.5, n_groups - 0.5) ax.set_ylim(0, 22) ax.set_yticks([0, 5, 10, 15, 20]) ax.yaxis.set_tick_params(labelsize=9, colors="#555555") ax.yaxis.tick_right() ax.tick_params(axis="y", length=0) ax.set_xticks(x) ax.set_xticklabels(df["period"], fontsize=9, color="#555555") ax.tick_params(axis="x", length=0) # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Legend handles = [mpatches.Patch(facecolor=COLORS[k], label=LABELS[k]) for k in keys] ax.legend(handles=handles, ncol=2, fontsize=8, frameon=False, loc="upper left", bbox_to_anchor=(0, 1.02), handlelength=1.0, handleheight=0.9, columnspacing=1.0) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.055, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "The mighty jungle", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.935, "Britain, annualised rate of return\non investment*, %", fontsize=9, va="top", color="#444444", transform=fig.transFigure, linespacing=1.5) # Footer fig.text(0.02, 0.02, "Sources: MSCI; JP Morgan", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.75, 0.02, "*To 2014", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.79, 0.055, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.06, 1, 0.75]) plt.show()
charts/mighty_jungle_returns.png

Vertical column chart with a highlighted bar

A column chart that highlights a single period or category.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import matplotlib.patches as mpatches import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT print(f"Using font: {FONT}") else: print("Gill Sans not found – using matplotlib default.") # Data # Approximate net private capital inflows/outflows in $bn, 1994–2011 # Values estimated from the chart image years = [ 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011 ] values = [ -4, -5, -23, -18, -8, -18, -25, -15, -8, -7, -35, -30, 40, 80, -130, -50, -35, 5 ] # Two-tone: teal for positive inflows, darker teal for outflows (negative) # The chart uses a medium teal for most bars and a lighter teal for the # 2006-2008 positive bars — we'll use a single accent for positive values COLOR_NEG = "#3a9aa0" # muted teal (outflows) COLOR_POS = "#1a6a7a" # darker teal (inflows) COLOR_ZERO = "#c0392b" # red zero line BG_COLOR = "#dce8f0" # light blue-grey house background bar_colors = [COLOR_POS if v >= 0 else COLOR_NEG for v in values] # Plot fig, ax = plt.subplots(figsize=(5, 5.5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) x = np.arange(len(years)) ax.bar(x, values, color=bar_colors, width=0.75, zorder=2) # Zero / baseline (red) ax.axhline(y=0, color=COLOR_ZERO, linewidth=1.5, zorder=3) # Axes ax.set_ylim(-160, 120) ax.set_yticks([100, 50, 0, -50, -100, -150]) ax.yaxis.set_tick_params(labelsize=9, colors="#555555") ax.yaxis.tick_right() ax.tick_params(axis="y", length=0) # X-axis: show subset of year labels to match original label_map = { 1994: "1994", 1996: "96", 1998: "98", 2000: "2000", 2002: "02", 2004: "04", 2006: "06", 2008: "08", 2011: "11*" } xtick_positions = [i for i, yr in enumerate(years) if yr in label_map] xtick_labels = [label_map[years[i]] for i in xtick_positions] ax.set_xticks(xtick_positions) ax.set_xticklabels(xtick_labels, fontsize=8.5, color="#555555") ax.tick_params(axis="x", length=0) ax.xaxis.tick_bottom() # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Red badge (house style) fig.add_artist(plt.Rectangle( (0.02, 0.975), 0.045, 0.013, transform=fig.transFigure, color="#c0392b", clip_on=False )) # Title block fig.text(0.02, 0.968, "The money drain", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.935, "Net private capital inflows/outflows, $bn", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: Central Bank of Russia", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.55, 0.02, "*To June 30th", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.04, 1, 0.80]) plt.show()
charts/money_drain.png

Stacked column chart

Shows how a total splits into parts over time. The house style caps stacked charts at four categories with the standard palette — swap in other colors beyond that, and use grey for “Other”.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT # Data df = pd.DataFrame({ "year": np.arange(2008, 2018), "paid_search": [22, 25, 29, 34, 40, 47, 55, 64, 74, 85], "display": [10, 11, 13, 15, 18, 21, 25, 30, 36, 43], "classified": [8, 8, 9, 10, 11, 12, 13, 14, 15, 16], "online_video": [2, 3, 4, 6, 8, 11, 15, 20, 27, 35], }) # Colors (house stack order: darkest at the bottom) COLORS = { "paid_search": "#1a4a7a", "display": "#5fc4c0", "classified": "#c9a84c", "online_video": "#2d7a4a", } LABELS = { "paid_search": "Paid search", "display": "Display", "classified": "Classified", "online_video": "Online video", } BG_COLOR = "#dce8f0" print(df.to_string(index=False)) fig, ax = plt.subplots(figsize=(6, 4.8)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) # Forecast panel behind the last two columns ax.axvspan(2015.5, 2017.6, color="#c3d6e4", zorder=1) ax.text(2016.55, 196, "FORECAST", fontsize=7.5, color="#555555", ha="center", va="top") # Stacked bars bottom = np.zeros(len(df)) for key, color in COLORS.items(): ax.bar(df["year"], df[key], bottom=bottom, color=color, width=0.75, label=LABELS[key], zorder=2) bottom += np.asarray(df[key], dtype=float) # Axes ax.set_xlim(2007.4, 2017.6) ax.set_xticks([2008, 2010, 2012, 2014, 2016, 2017]) ax.set_xticklabels(["2008", "10", "12", "14", "16", "17"]) ax.set_ylim(0, 210) ax.set_yticks([0, 50, 100, 150, 200]) ax.yaxis.tick_right() ax.xaxis.set_tick_params(labelsize=9, colors="#555555", length=0) ax.yaxis.set_tick_params(labelsize=9, colors="#555555", length=0) # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Legend ax.legend(loc="upper left", frameon=False, fontsize=8, handlelength=1.0, handleheight=0.9, labelspacing=0.4) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.05, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Getting sociable", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.933, "Global internet-advertising spending, $bn", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: ZenithOptimedia", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.72, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.05, 1, 0.82]) plt.show()
charts/getting_sociable_adspend.png

Line

Line chart, two series

The default for time series. The house palette orders line colors dark blue, then teal, then gold — up to six series, though four is a practical ceiling.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT # Data — yields over the 13 years following each period start years = np.arange(1, 14) japan = np.array([8.0, 7.2, 6.1, 5.2, 4.4, 3.5, 3.0, 2.4, 1.9, 1.7, 1.5, 1.4, 1.3]) us = np.array([6.6, 6.0, 5.2, 4.6, 4.2, 4.4, 4.7, 4.2, 3.6, 3.2, 2.6, 2.2, 2.0]) # Colors COLOR_JAPAN = "#1a4a7a" COLOR_US = "#5fc4c0" BG_COLOR = "#dce8f0" fig, ax = plt.subplots(figsize=(6, 4.8)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) ax.plot(years, japan, color=COLOR_JAPAN, linewidth=2.2, solid_capstyle="round", label="January 1989 (Japan)", zorder=3) ax.plot(years, us, color=COLOR_US, linewidth=2.2, solid_capstyle="round", label="January 1999 (US)", zorder=3) # Axes ax.set_xlim(0.6, 13.4) ax.set_xticks([1, 5, 10, 13]) ax.set_xlabel("Years since start", fontsize=9, color="#555555") ax.set_ylim(0, 8.4) ax.set_yticks([0, 2, 4, 6, 8]) ax.yaxis.tick_right() ax.xaxis.set_tick_params(labelsize=9, colors="#555555", length=0) ax.yaxis.set_tick_params(labelsize=9, colors="#555555", length=0) # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Legend under the title block fig.text(0.02, 0.885, "Period beginning:", fontsize=8.5, va="top", color="#444444", transform=fig.transFigure) fig.legend(loc="upper left", bbox_to_anchor=(0.02, 0.87), frameon=False, fontsize=8.5, ncol=2, handlelength=1.4, columnspacing=1.2) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.05, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Where Tokyo leads", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.933, "Ten-year government-bond yields, %", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: Thomson Reuters", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.72, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.05, 1, 0.76]) plt.show()
charts/where_tokyo_leads.png

Stacked area chart

Shows a total and its composition at once. Label the bands directly on the chart instead of using a legend.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT # Data years = np.arange(2002, 2012) china = np.array([0.3, 0.4, 0.6, 0.8, 1.1, 1.5, 1.9, 2.4, 2.9, 3.2]) other = np.array([0.9, 1.0, 1.2, 1.5, 1.8, 2.2, 2.4, 2.6, 3.0, 3.3]) # Colors COLOR_CHINA = "#1a4a7a" COLOR_OTHER = "#5fc4c0" BG_COLOR = "#dce8f0" fig, ax = plt.subplots(figsize=(5.5, 4.8)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) ax.stackplot(years, china, other, colors=[COLOR_CHINA, COLOR_OTHER], zorder=2) # In-chart labels instead of a legend ax.text(2009.6, 1.2, "China", color="white", fontsize=9, fontweight="bold", ha="center", zorder=3) ax.text(2009.2, 4.3, "Other\ndeveloping\ncountries", color="#0e3f4a", fontsize=9, fontweight="bold", ha="center", linespacing=1.3, zorder=3) # Axes ax.set_xlim(2002, 2011) ax.set_xticks([2002, 2004, 2006, 2008, 2010, 2011]) ax.set_xticklabels(["2002", "04", "06", "08", "10*", "11*"]) ax.set_ylim(0, 6.6) ax.set_yticks([0, 2, 4, 6]) ax.yaxis.tick_right() ax.xaxis.set_tick_params(labelsize=9, colors="#555555", length=0) ax.yaxis.set_tick_params(labelsize=9, colors="#555555", length=0) # Grid & spines ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.05, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Foreign hoards", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.933, "Developing-country currency reserves\n$trn", fontsize=9, va="top", color="#444444", transform=fig.transFigure, linespacing=1.4) # Footer fig.text(0.02, 0.02, "Source: IMF", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.78, 0.02, "*Forecast", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.05, 1, 0.80]) plt.show()
charts/foreign_hoards_reserves.png

Scatter

Annotated scatter plot

Relates two continuous variables, labelling points directly instead of using a legend.

Show the code

import matplotlib matplotlib.rcParams['text.usetex'] = False import matplotlib.pyplot as plt import matplotlib.font_manager as fm import matplotlib.patches as mpatches import numpy as np available = {f.name for f in fm.fontManager.ttflist} FONT = next((f for f in ["Gill Sans","Gill Sans MT","URW Classico","Trebuchet MS"] if f in available), None) if FONT: plt.rcParams["font.family"] = FONT C_MW = "#8b3030" # Midwest — dark red (highlighted) C_OTHER = "#3bbfbf" # All other — teal C_GRAY = "#9e9e9e" # Large cities — grey BG = "#dce8f0" RED_LINE= "#c0392b" RED_BAR = "#c0392b" np.random.seed(42) def make_counties(n, health_mu, health_sd, swing_mu, swing_sd, size_mu, size_sd): """Simulate county-level data. Replace with real arrays when available.""" health = np.random.normal(health_mu, health_sd, n) swing = np.random.normal(swing_mu, swing_sd, n) size = np.abs(np.random.normal(size_mu, size_sd, n)) + 10 return health, swing, size # Other regions (teal) — spread across full x-range, mixed swing h_ot, s_ot, sz_ot = make_counties(600, 48, 8, -2, 16, 20, 18) # Midwest (dark red) — clustered at lower health, stronger R swing h_mw, s_mw, sz_mw = make_counties(450, 38, 5, 16, 10, 18, 14) # Large cities (grey) — few, larger bubbles, less partisan swing h_gr = np.array([35, 45, 52, 58, 40, 30 ]) s_gr = np.array([-5, -8, -12, -2, -15, -3 ]) sz_gr = np.array([180, 220, 300, 250, 160, 140 ]) # Named/highlighted counties — white-outlined dots with leader lines named = { "Jefferson, OH": (32, 42), "Knox, OH": (43, 32), } # Figure fig, ax = plt.subplots(figsize=(7.4, 5.6)) fig.patch.set_facecolor(BG) ax.set_facecolor(BG) fig.subplots_adjust(left=0.11, right=0.76, top=0.77, bottom=0.18) # Layer order: other (bottom) → grey cities → Midwest (top) ax.scatter(h_ot, s_ot, s=sz_ot, color=C_OTHER, alpha=0.55, linewidths=0, zorder=2) ax.scatter(h_gr, s_gr, s=sz_gr, color=C_GRAY, alpha=0.65, linewidths=0.5, edgecolors='white', zorder=3) ax.scatter(h_mw, s_mw, s=sz_mw, color=C_MW, alpha=0.60, linewidths=0, zorder=4) # Named counties — white-filled dot with dark outline + leader line + label for name, (x, y) in named.items(): ax.scatter(x, y, s=38, color='white', linewidths=1.2, edgecolors=C_MW, zorder=6) ox = 1.2 if "Knox" in name else 1.0 ax.annotate(name, xy=(x, y), xytext=(x + ox, y + 3.5), fontsize=8, color="#1a1a1a", zorder=7, arrowprops=dict(arrowstyle="-", color="#555555", lw=0.7)) # Region label inside cluster ax.text(37, 14, "Midwest", fontsize=13, fontweight="bold", color="black", alpha=0.85, zorder=5, style='italic') # Zero reference line ax.axhline(0, color=RED_LINE, linewidth=1.0, zorder=2) # Axes ax.set_xlim(18, 72) ax.set_ylim(-55, 55) ax.set_xticks([20, 30, 40, 50, 60, 70]) ax.set_yticks([-50, -25, 0, 25, 50]) ax.xaxis.set_tick_params(labelsize=8.5, colors="#555555", length=0) ax.yaxis.set_tick_params(labelsize=8.5, colors="#555555", length=0) ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.yaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for sp in ax.spines.values(): sp.set_visible(False) # Axis labels ax.set_xlabel("Index of county health metrics*", fontsize=9, color="#444444", labelpad=22) ax.text(0.02, -0.13, "◄ WORSE HEALTH", fontsize=7.5, color="#555555", transform=ax.transAxes, va='top') ax.text(0.98, -0.13, "BETTER HEALTH ►", fontsize=7.5, color="#555555", ha='right', transform=ax.transAxes, va='top') ax.text(-0.10, 0.75, "MORE REPUBLICAN", fontsize=7.5, color="#555555", transform=ax.transAxes, va='center', ha='center', rotation=90) ax.text(-0.10, 0.25, "LESS REPUBLICAN", fontsize=7.5, color="#555555", transform=ax.transAxes, va='center', ha='center', rotation=90) # Right-side y-axis description fig.text(0.77, 0.77, "Change in Republican margin over\nDemocrats, 2012-16, % points", fontsize=7.8, color="#444444", va="top", ha="left", linespacing=1.4, transform=fig.transFigure) # Bubble size legend lx, ly = 0.790, 0.62 circ = mpatches.Circle((lx + 0.025, ly - 0.018), 0.026, transform=fig.transFigure, fill=False, edgecolor="#555555", linewidth=0.9, clip_on=False) fig.add_artist(circ) fig.text(lx + 0.060, ly, "Voting\neligible\npopulation", fontsize=7.5, color="#1a1a1a", va="center", transform=fig.transFigure, linespacing=1.3) # Red badge + Title fig.add_artist(plt.Rectangle((0.06, 0.942), 0.038, 0.008, transform=fig.transFigure, color=RED_BAR, clip_on=False)) fig.text(0.06, 0.938, "Vitality and the vote", fontsize=11, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.06, 0.910, "United States, health metrics against swing to Donald Trump", fontsize=8.5, va="top", color="#444444", transform=fig.transFigure) fig.text(0.06, 0.890, "By county", fontsize=8, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.06, 0.022, "Sources: Atlas of US Presidential Elections; Census Bureau;\n" "IPUMS, University of Minnesota; Institute for Health Metrics\n" "and Evaluation; Health Intelligence Center", fontsize=7, color="#666666", transform=fig.transFigure, linespacing=1.4) fig.text(0.52, 0.022, "*Weighted index of obesity, diabetes,\nheavy drinking, physical exercise and\nlife expectancy, 2010-12", fontsize=7, color="#666666", transform=fig.transFigure, linespacing=1.4) print("Saved: vitality_vote_midwest.png")
charts/vitality_vote_midwest.png

Thermometer

Dot-range chart

Compares two values per category — two periods, two scenarios — with dot terminals joined by a range line. The house style recommends dot terminals when plain thermometer lines become unclear.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm import pandas as pd import numpy as np # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT # Data df = pd.DataFrame({ "district": ["Gasabo", "Kicukiro", "Nyarugenge", "Musanze", "Rubavu", "Huye", "Rusizi", "Nyagatare", "Burera", "Rwamagana"], "june": [310, 285, 300, 240, 255, 220, 205, 190, 175, 160], "september": [385, 350, 330, 310, 290, 270, 240, 250, 210, 205], }) # Colors COLOR_JUNE = "#5fc4c0" COLOR_SEP = "#1a4a7a" COLOR_RANGE = "#9bb8c8" BG_COLOR = "#dce8f0" print(df.to_string(index=False)) fig, ax = plt.subplots(figsize=(6, 5)) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) # Range lines with dot terminals y = np.arange(len(df))[::-1] ax.hlines(y, df["june"], df["september"], color=COLOR_RANGE, linewidth=1.6, zorder=2) ax.scatter(df["june"], y, s=42, color=COLOR_JUNE, zorder=3, label="June") ax.scatter(df["september"], y, s=42, color=COLOR_SEP, zorder=3, label="September") # Axes ax.set_xlim(0, 420) ax.set_xticks([0, 100, 200, 300, 400]) ax.xaxis.tick_top() ax.xaxis.set_tick_params(labelsize=9, colors="#555555", length=0) ax.set_yticks(y) ax.set_yticklabels(df["district"], fontsize=10) ax.tick_params(axis="y", length=0) # Grid & spines ax.xaxis.grid(True, color="white", linewidth=0.8, zorder=0) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_visible(False) # Legend ax.legend(loc="lower right", frameon=False, fontsize=8.5, handletextpad=0.3, borderaxespad=0.2) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.05, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Catching up", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.933, "Outpatient consultations per 1,000 population*", fontsize=9, va="top", color="#444444", transform=fig.transFigure) # Footer fig.text(0.02, 0.02, "Source: HMIS", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.68, 0.02, "*Illustrative data", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0, 0.04, 1, 0.82]) plt.show()
charts/thermometer_consultations.png

Pie / doughnut

Doughnut chart

Use pies and doughnuts with caution: beyond four categories, prefer a stacked bar. Keep at most six slices, order them clockwise from 12 o’clock, and use grey for “Other”.

Show the code

import matplotlib.pyplot as plt import matplotlib.font_manager as fm # Font setup available = {f.name for f in fm.fontManager.ttflist} FONT = next( (f for f in ["Gill Sans", "Gill Sans MT", "URW Classico", "Trebuchet MS"] if f in available), None ) if FONT: plt.rcParams["font.family"] = FONT # Data labels = ["Jaguar Land Rover", "Tata Steel", "Tata Consultancy Services", "Tata Chemicals", "Taj (hotels)", "Others"] values = [19.0, 19.4, 4.9, 0.5, 0.4, 1.2] # Colors — standard order, grey last for "Others" colors = ["#1a4a7a", "#5fc4c0", "#c9a84c", "#2d7a4a", "#8b3030", "#b8c4cc"] BG_COLOR = "#dce8f0" fig, ax = plt.subplots(figsize=(6.5, 4.4)) fig.patch.set_facecolor(BG_COLOR) # Doughnut: a pie with a wedge width < 1 wedges, _ = ax.pie(values, colors=colors, startangle=90, counterclock=False, wedgeprops={"width": 0.42, "edgecolor": BG_COLOR, "linewidth": 1}) ax.text(0, 0, "Total\n45.4", ha="center", va="center", fontsize=11, fontweight="bold", color="#1a1a1a", linespacing=1.2) # Color-keyed labels with values, house style legend_labels = [f"{l} {v}" for l, v in zip(labels, values)] ax.legend(wedges, legend_labels, loc="center left", bbox_to_anchor=(1.0, 0.5), frameon=False, fontsize=8.5, labelspacing=0.8, handlelength=0.8, handleheight=1.1) # Red badge fig.add_artist(plt.Rectangle((0.02, 0.975), 0.045, 0.014, transform=fig.transFigure, color="#c0392b", clip_on=False)) # Title block fig.text(0.02, 0.968, "Naan bigger", fontsize=12, fontweight="bold", va="top", color="#1a1a1a", transform=fig.transFigure) fig.text(0.02, 0.933, "Tata's workforce in Britain, September 2011\n'000", fontsize=9, va="top", color="#444444", transform=fig.transFigure, linespacing=1.4) # Footer fig.text(0.02, 0.02, "Source: Tata", fontsize=8, color="#666666", transform=fig.transFigure) fig.text(0.72, 0.02, "Health Intelligence Center", fontsize=8, color="#666666", transform=fig.transFigure) plt.tight_layout(rect=[0.02, 0.05, 0.72, 0.80]) plt.show()
charts/naan_bigger_workforce.png
Last updated on