A Single stacked bar chart in Matplotlib

Somewhat recently I wanted to generate a chart that had just a single bar in it, but with different segments on the bar to denote the amount of time spent in different parts of an overall process. This is variant of the nested horizontal bar chart I wrote about last time.

The particular things I was after in this chart were:

  • I didn’t want a y-axis label — there’s only one bar in this case (albeit sub-divided).  So having a label seems redundant.
  • I did still want a larger shadow bar to show overall time — sometimes I might have segments that don’t quite add up to the total amount of time.  In my scenario that was okay but I still want to see that overall time.
  • I wanted to put the legend somewhere convenient — in this case I opted to spread the items across the top of the chart.
  • I also wanted to minimize the vertical size of the chart since I only had one bar in it.

Sample Data

To start with let’s say we have this as our sample data set.

segment_values = [
 {'value': 12, 'label': 'A', 'color': '#FF0000'},
 {'value': 8, 'label': 'B', 'color': '#00FF00'},
 {'value': 5, 'label': 'C', 'color': '#0000FF'},
 {'value': 5, 'label': 'D', 'color': '#33A6CC'},
 {'value': 16, 'label': 'E', 'color': '#A82279'}
 ]

Adding the bars

As in the previous post when embedding bars within bars, we add each segment using the same vertical offset (y_pos) but a different horizontal position.

left_pos = 0
for idx in range(len(segment_values)):
 segdata = segment_values[idx]
 seglabel = segdata['label']
 segval = segdata['value']
 segcol = segdata['color']

chart_ax.barh(y_pos, [segval], width, align='center', color=segcol, label=seglabel, left=left_pos, edgecolor=['black', 'black'], linewidth=0.5)
 left_pos += segval

The one problem though is that our single bar will take up all the vertical space in the chart, which just doesn’t look good:

To prevent that we need to trick Matplotlib just a bit.  The way we do that is to add a bar with a zero length.

chart_ax.barh(y_pos, 0, 1.0, align='center', color='white', ecolor='black', label=None)

Omitting the y-axis labels.

If we set up our xlabel and title but don’t do anything about the y axis labels then we’ll get something that looks like this:

To omit that we just set y_ticks.

chart_ax.set_yticks([1])

Adding the legend

Normally adding in a legend is pretty trivial. Getting the legend in this particular format — spread across the top — is a little more involved. We need to set up a set of anchor values.  But what are anchor values?

The anchor values in this example is a tuple of floating point values ranging from 0 to 1.0. The actual parameters are:

(x0, y0, width, height)

# Set up the legend so it is arranged across the top of the chart.
anchor_vals = (0.01, 0.6, 0.95, 0.2)
plt.legend(bbox_to_anchor=anchor_vals, 
          loc=4, 
          ncol=4, 
          mode="expand", 
          borderaxespad=0.0)

There is a good StackOverflow discussion about the 4-tuple anchor values. The Matplotlib legend location docs are also pretty good and authoritative legend options.  In practice it probably still takes a bit of playing around to get a feel for how this is working.

Summary

The resulting chart served my limits. The are limits to the number of items that can comfortably fit in this kind of chart though. If you are going to have the legend identify every segment then that is going to continue to use more room of course.  But in addition I found that you have to pay attention to color selection when adding more segments.

The resulting sample code in full is shown below.

import matplotlib.pyplot as plt

# Set the vertical dimension to be smaller.. 
# 3.5 seems to work after a bit of experimenting.
plt.rcParams["figure.figsize"] = [10, 3.5]
fig, chart_ax = plt.subplots()
plt.rcdefaults()

# Sample Data
# -------------------

segment_values = [ {'value': 12, 'label': 'A', 'color': '#FF0000'},
 {'value': 8, 'label': 'B', 'color': '#00FF00'},
 {'value': 5, 'label': 'C', 'color': '#0000FF'},
 {'value': 5, 'label': 'D', 'color': '#33A6CC'},
 {'value': 16, 'label': 'E', 'color': '#A82279'}
 ]

# Sum up the value total.
outer_bar_length = 0
for segitem in segment_values:
 outer_bar_length += segitem['value']
outer_bar_label = 'Total Time'

# In this case we expect only 1 item in the entries list.
y_pos = [0]
width = 0.05

# Set the 'empty' bar .. this is here to coerce Matplotlib
# to keep the size of the bar smaller on our actual data.
# Otherwise the bar will use all available space.

chart_ax.barh(y_pos, 0, 1.0, align='center', color='white', ecolor='black', label=None)

# Is there an 'outer' or container bar?
if outer_bar_length != -1:
 chart_ax.barh(y_pos, outer_bar_length, 0.12,
 align='center', color='#D9DCDE', label=outer_bar_label, left=0)


# Now go through and add in the actual segments of data.
left_pos = 0
for idx in range(len(segment_values)):
 segdata = segment_values[idx]
 seglabel = segdata['label']
 segval = segdata['value']
 segcol = segdata['color']

chart_ax.barh(y_pos, [segval], width, align='center', color=segcol, label=seglabel, left=left_pos, edgecolor=['black', 'black'], linewidth=0.5)
 left_pos += segval

chart_ax.set_yticks([1])
chart_ax.invert_yaxis()
chart_ax.set_xlabel('Time')
chart_ax.set_title('Single Stacked Bar Chart')
plt.tight_layout()

# Set up the legend so it is arranged across the top of the chart.
anchor_vals = (0.01, 0.6, 0.95, 0.2)
plt.legend(bbox_to_anchor=anchor_vals, loc=4, ncol=4, mode="expand", borderaxespad=0.0)

plt.show()