How to display 3 bar charts next to each other in one graph using Altair

Viewed 470

I'm trying to replicate this chart but I am having a hard time placing all 3 charts I created into one graph. I've so far been able to create 3 individual bar charts with the appropriate data and color but was not successful with layering.

Chart I am replicating: starwars

This is the code I am using to create each chart. It's essentially the same code repeated 3 separate times to create each individual chart with the name of each chart labeled 'seen_movies_top', 'seen_movies_middle', and 'seen_movies_bottom'. I feel like I'm overdoing it here and that there is an easier way to go about this but I'm happy to have been able to at least create each individual chart. Now just to get them on the same graph..

# fix the labels a bit so will create a mapping to the full names
episodes = ['EI', 'EII', 'EIII', 'EIV', 'EV', 'EVI']
names = {
    'EI' : 'The Phantom Meanance', 'EII' : 'Attack of the clones', 'EIII' : 'Revenge of the Sith', 
    'EIV': 'A New Hope', 'EV': 'The Empire Strikes Back', 'EVI' : 'The Return of the Jedi'
}

# going to use this order to sort, so names_l will now have our sort order
names_l = [names[ep] for ep in episodes]

print("sort order: ",names_l)

seen_every = seen_at_least_one.dropna(subset=['seen_EI','seen_EII','seen_EIII','seen_EIV','seen_EV','seen_EVI'])

# only use those people who have seen at least one movie, let's get the people, toss NAs
# and get the total count

# find people who have at least on of the columns (seen_*) not NaN
seen_at_least_one = sw.dropna(subset=['seen_' + ep for ep in episodes],how='all')
total = len(seen_every)

seen_every = seen_at_least_one.dropna(subset=['seen_EI','seen_EII','seen_EIII','seen_EIV','seen_EV','seen_EVI'])

print("total who have seen at least one: ", total)

total_rank = len(seen_every)

# calculating the percents and generating a new data frame
percs_seen_top3 = []

# looping over each column and calculating the number of people who have seen the movie
# specifically, filter out the people who are *NaN* for a specific episode (e.g., ep_EII), count them
# and divide by the percent

for rank_ep in ['rank_' + ep for ep in episodes]:
    #my_value_count = seen_every[rank_ep].value_counts()
    perc_seen_top3 = (seen_every[rank_ep].value_counts()['1'] + seen_every[rank_ep].value_counts()['2'])/ total_rank 
    percs_seen_top3.append(perc_seen_top3)

# creating tuples--pairing names with percents--using "zip" and then making a dataframe
tuples_top = list(zip([names[ep] for ep in episodes],percs_seen_top3))
seen_per_df_top = pd.DataFrame(tuples_top, columns = ['Name', 'Percentage'])

bars_top = alt.Chart(seen_per_df_top).mark_bar(size=20).encode(
    # encode x as the percent, and hide the axis
    x=alt.X(
        'Percentage',
        axis=None),
    y=alt.Y(
        # encode y using the name, use the movie name to label the axis, sort using the names_l
        'Name:N',
         axis=alt.Axis(tickCount=5, title=''),
         # we give the sorting order to avoid alphabetical order
         sort=names_l
    )
)

text_top = bars_top.mark_text(
    align='left',
    baseline='middle',
    dx=3  # Nudges text to right so it doesn't appear on top of the bar
).encode(
    # we'll use the percentage as the text
    text=alt.Text('Percentage:Q',format='.0%')
)


seen_movies_top = (text_top + bars_top).configure_mark(
    color='#008fd5'
).configure_view(
    # we don't want a stroke around the bars
    strokeWidth=0
).configure_scale(
    # add some padding
    bandPaddingInner=0.2
).properties(
    # set the dimensions of the visualization
    width=500,
    height=180
).properties(
    # add a title
    title={
    "text":["How People Rate the 'Star Wars' Movies"],
    "subtitle":["How often each film was rated in the top, middle and bottom third (by 471 respondents who have seen all six films)"]}
).configure_title(
    # customize title and sub-title
    fontSize=30, align='left',anchor ='start', fontWeight='bold', subtitleFontWeight='lighter'
)

seen_movies_top 

percs_seen_middle3 = []

for rank_ep in ['rank_' + ep for ep in episodes]:
    #my_value_count = seen_every[rank_ep].value_counts()
    perc_seen_middle3 = (seen_every[rank_ep].value_counts()['3'] + seen_every[rank_ep].value_counts()['4'])/ total_rank 
    percs_seen_middle3.append(perc_seen_middle3)
    
tuples_middle = list(zip([names[ep] for ep in episodes],percs_seen_middle3))
seen_per_df_middle = pd.DataFrame(tuples_middle, columns = ['Name', 'Percentage'])

# ok, time to make the chart... let's make a bar chart (use mark_bar)
bars_middle = alt.Chart(seen_per_df_middle).mark_bar(size=20).encode(
    # encode x as the percent, and hide the axis
    x=alt.X(
        'Percentage',
        axis=None),
    y=alt.Y(
        # encode y using the name, use the movie name to label the axis, sort using the names_l
        'Name:N',
         axis=alt.Axis(tickCount=5, title=''),
         # we give the sorting order to avoid alphabetical order
         sort=names_l
    )
)

# at this point we don't really have a great plot (it's missing the annotations, titles, etc.)
bars_middle

text_middle = bars_middle.mark_text(
    align='left',
    baseline='middle',
    dx=3  # Nudges text to right so it doesn't appear on top of the bar
).encode(
    # we'll use the percentage as the text
    text=alt.Text('Percentage:Q',format='.0%')
)

seen_movies_middle = (text_middle + bars_middle).configure_mark(
    # we don't love the blue
    color='#69a14f'
).configure_view(
    # we don't want a stroke around the bars
    strokeWidth=0
).configure_scale(
    # add some padding
    bandPaddingInner=0.2
).properties(
    # set the dimensions of the visualization
    width=500,
    height=180
).properties(
    # add a title
    title={
    "text":["How People Rate the 'Star Wars' Movies"],
    "subtitle":["How often each film was rated in the top, middle and bottom third (by 471 respondents who have seen all six films)"]}
).configure_title(
    # customize title and sub-title
    fontSize=30, align='left',anchor ='start', fontWeight='bold', subtitleFontWeight='lighter'
)

seen_movies_middle 

percs_seen_bottom3 = []

for rank_ep in ['rank_' + ep for ep in episodes]:
    #my_value_count = seen_every[rank_ep].value_counts()
    perc_seen_bottom3 = (seen_every[rank_ep].value_counts()['5'] + seen_every[rank_ep].value_counts()['6'])/ total_rank 
    percs_seen_bottom3.append(perc_seen_bottom3)  

tuples_bottom = list(zip([names[ep] for ep in episodes],percs_seen_bottom3))
seen_per_df_bottom = pd.DataFrame(tuples_bottom, columns = ['Name', 'Percentage'])


# ok, time to make the chart... let's make a bar chart (use mark_bar)
bars_bottom = alt.Chart(seen_per_df_bottom).mark_bar(size=20).encode(
    # encode x as the percent, and hide the axis
    x=alt.X(
        'Percentage',
        axis=None),
    y=alt.Y(
        # encode y using the name, use the movie name to label the axis, sort using the names_l
        'Name:N',
         axis=alt.Axis(tickCount=5, title=''),
         # we give the sorting order to avoid alphabetical order
         sort=names_l
    )
)

# at this point we don't really have a great plot (it's missing the annotations, titles, etc.)
bars_bottom

text_bottom = bars_bottom.mark_text(
    align='left',
    baseline='middle',
    dx=3  # Nudges text to right so it doesn't appear on top of the bar
).encode(
    # we'll use the percentage as the text
    text=alt.Text('Percentage:Q',format='.0%')
)


seen_movies_bottom = (text_bottom + bars_bottom).configure_mark(
    # we don't love the blue
    color='#fd3a4a'
).configure_view(
    # we don't want a stroke around the bars
    strokeWidth=0
).configure_scale(
    # add some padding
    bandPaddingInner=0.2
).properties(
    # set the dimensions of the visualization
    width=500,
    height=180
).properties(
    # add a title
    title={
    "text":["How People Rate the 'Star Wars' Movies"],
    "subtitle":["How often each film was rated in the top, middle and bottom third (by 471 respondents who have seen all six films)"]}
).configure_title(
    # customize title and sub-title
    fontSize=30, align='left',anchor ='start', fontWeight='bold', subtitleFontWeight='lighter'
)

seen_movies_bottom

top

middle

bottom

1 Answers

I don't usually use altair, so I did a lot of research and created this, so the code may not be consistent. The output you are expecting is where the annotation text color is not black. In my experience, this could not be solved. Also, since the goal is not to format the data, I created sample data for the graph and created the graph.

import pandas as pd
import numpy as np
import io
import altair as alt
from altair import datum

data = '''
episode name "Top third" "Middle third" "Bottom third"
1 "The Phantom Menace" 0.16 0.37 0.46
2 "Attack of the Clones" 0.14 0.29 0.57
3 "Revenge of the Sith" 0.13 0.40 0.47
4 "A New Hope" 0.50 0.31 0.19
5 "The Empire Strikes Back" 0.64 0.22 0.14
6 "Return of the Jedi" 0.43 0.41 0.17
'''

df = pd.read_csv(io.StringIO(data), delim_whitespace=True)
df = df.set_index(['episode','name']).stack().to_frame(name='percentage').reset_index()
df.columns = ['episode','name', 'rank', 'percentage']
episode name rank percentage
0 1 The Phantom Menace Top third 0.16
1 1 The Phantom Menace Middle third 0.37
2 1 The Phantom Menace Bottom third 0.46
3 2 Attack of the Clones Top third 0.14
4 2 Attack of the Clones Middle third 0.29
5 2 Attack of the Clones Bottom third 0.57
6 3 Revenge of the Sith Top third 0.13
7 3 Revenge of the Sith Middle third 0.4
8 3 Revenge of the Sith Bottom third 0.47
9 4 A New Hope Top third 0.5
10 4 A New Hope Middle third 0.31
11 4 A New Hope Bottom third 0.19
12 5 The Empire Strikes Back Top third 0.64
13 5 The Empire Strikes Back Middle third 0.22
14 5 The Empire Strikes Back Bottom third 0.14
15 6 Return of the Jedi Top third 0.43
16 6 Return of the Jedi Middle third 0.41
17 6 Return of the Jedi Bottom third 0.17
domain = ['Top third','Middle third','Bottom third']
range_ = ['green', 'blue', 'red']

bar1 = alt.Chart(df,title=domain\[0\]).mark_bar().encode(
    alt.X('percentage:Q', axis=None, title=domain\[0\]),
    alt.Y('name:O', sort=df.name.unique(), title=''),
    color=alt.Color('rank:N', legend=None, scale=alt.Scale(domain=domain, range=range_)),
).transform_filter(
    (datum.rank == 'Top third')
).properties(
    width=50
)

text1 = bar1.mark_text(
    align='left',
    baseline='middle',
    dx=3
).encode(
    text=alt.Text('percentage:Q', format='.0%')
)

bar2 = alt.Chart(df,title=domain\[1\]).mark_bar().encode(
    alt.X('percentage:Q', axis=None),
    alt.Y('name:O', axis=None),
    color=alt.Color('rank:N', legend=None, scale=alt.Scale(domain=domain, range=range_)),
).transform_filter(
    (datum.rank == 'Middle third')
).properties(
    width=50
)

text2 = bar2.mark_text(
    align='left',
    baseline='middle',
    dx=3
).encode(
    text=alt.Text('percentage:Q', format='.0%')
)

bar3 = alt.Chart(df,title=domain\[2\]).mark_bar().encode(
    alt.X('percentage:Q', axis=None),
    alt.Y('name:O', axis=None),
    color=alt.Color('rank:N', legend=None, scale=alt.Scale(domain=domain, range=range_)),
).transform_filter(
    (datum.rank == 'Bottom third')
).properties(
    width=50
)

text3 = bar3.mark_text(
    align='left',
    baseline='middle',
    dx=3
).encode(
    text=alt.Text('percentage:Q', format='.0%')
)

alt.hconcat(
    bar1+text1 ,bar2+text2, bar3+text3,
    title=alt.TitleParams(
        text="How People Rate the 'Star Wars' Movies",
        subtitle=\["How often each film was rated in the top, middle and bottom third ",
                  "(by 471 respondents who have seen all six films)"\],)
).configure_axis(
    grid=False,
).configure_view(
    strokeWidth=0
).configure(
    background='#dcdcdc'
)

enter image description here

Related