Better horizontal bar charts with plotly

I often find that horizontal bar charts are a great way of visualising comparisons between different categories. They're easy to understand and make, and provide a really simple way of displaying data.

But I've found the default way of labelling them often doesn't make sense. Labels for the bars are usually presented to the left of the axis, and often have to be crammed together, split across multiple lines or truncated to make them readable.

Some charting tools can produce a much easier to read version, where the labels for the bars appear on a separate line to the bars themselves. This gives the data itself more room to breathe and makes the labels less cramped.

Here's an example from DataWrapper using some of their sample data. First using their default settings:

Bar chart showing labels to the left of the vertical axis

And then with the "labels on separate line" options selected:

Bar chart showing labels above each of the bars

Implementing in plotly

I've been using Plotly for charting a lot recently, particularly in conjunction with Dash for making rich interactive dashboards. It's a very powerful combination.

I built 360Insights using Dash, and plotly's charts powers the charts on NGO Explorer. I wanted to record how I created the charts with labels above the bars in NGO Explorer. You can see some examples of these on a country page.

Bar chart from NGO Explorer showing labels above each of the bars

Making subplots

The only way I could find to accomplish the same effect in plotly was to use subplots. These essentially split the figure into a number of different charts. What we're going to do is use a different subplot for each chart, using the make_subplots function. Start by importing the make_subplots function.

from plotly.subplots import make_subplots

Add our data to the categories variable.

categories = [
    {"name": "Musée du Louvre, Paris", "value": 10200000},
    {"name": "National Museum of China, Beijing", "value": 8610092},
    {"name": "Metropolitan Museum of Art, New York City", "value": 6953927},
    {"name": "Vatican Museums, Vatican City", "value": 6756186},
    {"name": "Tate Modern, London", "value": 5868562},
    {"name": "British Museum, London", "value": 5820000},
    {"name": "National Gallery, London", "value": 5735831},
    {"name": "National Gallery of Art, Washington D.C.", "value": 4404212},
    {"name": "State Hermitage Museum, Saint Petersburg", "value": 4220000},
    {"name": "Victoria and Albert Museum, London", "value": 3967566},
]

Create as many subplots as there are categories. For each subplot we'll set the title to the name of the museum using the subplot_titles parameter. We also set shared_xaxes=True to ensure that all the bars are still in proportion to each other, and print_grid=False to make it seamless between the plots (otherwise each would be in a box.

The vertical_spacing parameter is important, but can be tricky to get right. I've found that 0.45 divided by the number of categories gives a good spacing between them, but you may need to play around with it.

I've also removed the background colour from the subplots, and set the width of the plots.

subplots = make_subplots(
    rows=len(categories),
    cols=1,
    subplot_titles=[x["name"] for x in categories],
    shared_xaxes=True,
    print_grid=False,
    vertical_spacing=(0.45 / len(categories)),
)
_ = subplots['layout'].update(
    width=550,
    plot_bgcolor='#fff',
)

Next we go through each category and add it to the relevant subplot, using the add_trace function.

This functions takes a dictionary with the values and settings for the bar (notice that the y and x values are wrapped in lists because plotly would normally expect a list of values here).

We'll set the marker so they're all the same colour, and add a text label for the amount to the inside of the bar.

for k, x in enumerate(categories):
    subplots.add_trace(dict(
        type='bar',
        orientation='h',
        y=[x["name"]],
        x=[x["value"]],
        text=["{:,.0f}".format(x["value"])],
        hoverinfo='text',
        textposition='auto',
        marker=dict(
            color="#7030a0",
        ),
    ), k+1, 1)

After adding traces

This gives us subplots that are much closer to what we're looking for, but they look a bit of a mess as there's still quite a bit of the layout we need to adjust.

First we remove the legend on the right hand side and make sure the titles (stored by plotly as annotations) are aligned to the left hand side, and have smaller font sizes).

subplots['layout'].update(
    showlegend=False,
)
for x in subplots["layout"]['annotations']:
    x['x'] = 0
    x['xanchor'] = 'left'
    x['align'] = 'left'
    x['font'] = dict(
        size=12,
    )

After removing annotations and images

Next we remove all the axes to give a clean look to the chart. If you wanted, you could keep the x-axis to help see the values.

for axis in subplots['layout']:
    if axis.startswith('yaxis') or axis.startswith('xaxis'):
        subplots['layout'][axis]['visible'] = False

Finally we remove the margins at the left hand side, and calculate the height so that the categories fit nicely.

subplots['layout']['margin'] = {
    'l': 0,
    'r': 0,
    't': 20,
    'b': 1,
}
height_calc = 45 * len(categories)
height_calc = max([height_calc, 350])
subplots['layout']['height'] = height_calc
subplots['layout']['width'] = height_calc
subplots

The finished product

I think the final result is a lot clearer than the default chart for the data. I've put together all of this together in a function to help making these types of charts easier.

You can play around with the settings, either as part of make_subplots, the individual bars (add_trace) or the layout (make changes to subplots['layout'].

from plotly.subplots import make_subplots

def horizontal_bar_labels(categories):
    subplots = make_subplots(
        rows=len(categories),
        cols=1,
        subplot_titles=[x["name"] for x in categories],
        shared_xaxes=True,
        print_grid=False,
        vertical_spacing=(0.45 / len(categories)),
    )
    subplots['layout'].update(
        width=550,
        plot_bgcolor='#fff',
    )

    # add bars for the categories
    for k, x in enumerate(categories):
        subplots.add_trace(dict(
            type='bar',
            orientation='h',
            y=[x["name"]],
            x=[x["value"]],
            text=["{:,.0f}".format(x["value"])],
            hoverinfo='text',
            textposition='auto',
            marker=dict(
                color="#7030a0",
            ),
        ), k+1, 1)

    # update the layout
    subplots['layout'].update(
        showlegend=False,
    )
    for x in subplots["layout"]['annotations']:
        x['x'] = 0
        x['xanchor'] = 'left'
        x['align'] = 'left'
        x['font'] = dict(
            size=12,
        )

    # hide the axes
    for axis in subplots['layout']:
        if axis.startswith('yaxis') or axis.startswith('xaxis'):
            subplots['layout'][axis]['visible'] = False

    # update the margins and size
    subplots['layout']['margin'] = {
        'l': 0,
        'r': 0,
        't': 20,
        'b': 1,
    }
    height_calc = 45 * len(categories)
    height_calc = max([height_calc, 350])
    subplots['layout']['height'] = height_calc
    subplots['layout']['width'] = height_calc

    return subplots

A note on log axes

If you're using a log axis, as some of the charts on do, then the implementation above may not work due to the different way that log ranges are calculated.

Adding something similar to this on the function above should help (where log_axis is a boolean variable):

for x in hb_plot["layout"]:
    if x.startswith("xaxis"):
        if log_axis:
            hb_plot["layout"][x]["type"] = "log"
            hb_plot["layout"][x]["range"] = [1, int(math.log10(max_value)) + 1]
        else:
            hb_plot["layout"][x]["range"] = [0, max_value * 1.1]

Updated 2021-11-25

Thanks to Chris Caudill for pointing out a missing part of the function - this is now included. I've also added the section at the end on log axes.