Programming Web Apps with Streamlit
Introductory guide
By Carlos A. Toruño P.
May 25, 2023
Streamlit is an open-source web framework for python. In recent years, it has experienced an increase in popularity due to its simplicity, very low learning-curve, and its focus on data science and machine learning. In this post, I’ll be showing you a step-by-step guide of how I programmed this ROLI Map Generator app that takes the data published by the World Justice Project and produces customized Choropleth Maps with it.
Note: The ROLI Map Generator might look different to what you see in this post. In this tutorial, I’m portraying the alpha version of the app as of March, 2023.
The basics
Before jumping into the coding of this app. We need to understand how does Streamlit works.
First. Streamlits renders a HTML version of your app from a python script. Therefore, you need a basic or intermediate understanding of python. Prior experience with HTML, CSS or JavaScript is not necessary. However, knowing about these languages will help a lot in the process.
Second. You can visualize your app in your browser and all the changes you are applying as soon as you save the python script you are working on. More on this in a moment.
Third. Streamlit’s architecture works in a very unique way. If something changes while the app is running, Streamlit will re-run the entire script from top to bottom. If you are familiar with the concepts of reactivity and laziness applied by other web frameworks such as R Shiny… I’m sorry to break this to you but you better unlearn those concepts while you program a Streamlit app. What does this means? Basically, if the user inputs a new word in a text box or clicks on a different button, Streamlit will re-run the entire script. This might seem a bit troublesome at first; however, you can use cache decorators to avoid re-running very expensive computational tasks again and again.
Fourth. User interactions with the app are massively simplified through widgets. Because of this, you really don’t need to worry about the front-end of the app as long as the logical sequence of your python script is correct.
Fifth. Other web frameworks require you to split your code between server and User Interface (UI). Streamlit, does not require you to do this. Nevertheless, I still try/prefer to organize my code this way. You will notice it in a moment.
During the remaining part of this post, I will be explaining the different aspects of the programming on the go. If you want to review some of these aspects or go slowly with the basics of Streamlit, I would strongly suggest you to check the Streamlit Documentation online. I personally find it very helpful and easy to go through.
Installing Streamlit
In order to work with Streamlit you will need to install the library in your local machine. For this, install Streamlit using the PIP package manager by opening your terminal and writing the following line of code:
pip install streamlit
IMPORTANT NOTE: As flagged in the documentation, it is recommended to install and run streamlit from its own virtual environment. In my case, I use Anaconda to manage my virtual environments. If you are not familiar with virtual environments, I would strongly suggest you to watch this video:
Once that you have Streamlit in its own virtual environment and you have already installed the library inside this environment, you can start creating your app.
IMPORTANT NOTE: I will be displaying ONLY small chunks of code in order to facilitate the explanation of the overall programming. If you feel the need to see the big picture and where these small chunks are located in the final script, I suggest you to open the final version of the python script in a separate tab of your browser and review it while you go through this post. The script can be found in this GitHub repository
Writing a proper introduction
As I mentioned before, the first thing you need to do in order to start building your app, is to create a python script. We are gonna call this file app.py
. The first thing we are going to do is to import the Streamlit library and write a title and a proper introduction. We do this as follows:
# Importing the libraries
import streamlit as st
# Writing a title for the app
st.title("ROLI Map Generator")
# Writing an introductory paragraph
st.write(
"""
This is an interactive app designed to display and generate
Choropleth Maps using the WJP's Rule of Law Index scores
as data inputs. This app is still under deevelopment.
Therefore, customization is limited at the moment. However,
the data presented in this app is up-to-date according to
the latest datasets published by the World Justice Project
in its website.
"""
)
We can make use of the st.title()
and st.write()
API references in order to write the text that we want to display as title and in the introductory paragraph, respectively. By now, you must be asking yourself, but how does this look?
Running your Streamlit app locally
In order to preview your app in your browser, you need to run the app from your terminal. For this, open your terminal and run the following lines of code:
# Activating my streamlit virtual environment (I use Anaconda)
conda activate streamlit
# Changing the working directory to where your app is located
cd PATH_TO_APP
# Running the app
streamlit run app.py
Once that you hit ENTER after running the streamlit, a new tab will open in your default browser showing you what you have worked so far. The very simple app we have until now should look a lot like this:
Loading the data
The first thing that we need in order for our app to work properly, is to load the data that we will be using. However, as it was mentioned before, Streamlit runs the entire script from top to bottom every time that the user interacts with the app. If the data is heavy and takes some time to load, it would be troublesome to have it loaded every time that the user interacts with the app. Therefore, it is strongly suggested to load the data into the app by using cache decorators.
Cache decorators allow you to use the memory cache to store information separately so that future calls or requests to that specific information are served faster. Streamlit provides a cache decorator designed specifically for loading data called: st.cache_data
. We make use of this decorator by placing it before a callable function that loads the data:
import streamlit as st
import pandas as pd
import geopandas as gpd
# Cache of data
@st.cache_data
def load_data():
# Loading data
boundaries = gpd.read_file("Data/data4app.geojson")
roli_data = pd.read_excel("Data/ROLI_data.xlsx")
roli_data["year"] = roli_data["year"].apply(str)
data = {"boundaries" : boundaries,
"roli" : roli_data}
return data
master_data = load_data()
As you can observe, we first place a decorator before defining a function that loads two different data sets and returns a dictionary containing both tables. In this way, every time the app calls the load_data()
function, it will return the object stored in the cache instead of reading and loading the data using the pandas/geopandas libraries. This process saves a huge amount of time and allows the app to run smoothly.
Going back to the data tables that we have loaded, the first data frame in the list is a geojson file that contains data related to country boundaries and its geospacial geometries. I will be using the World Bank Official Boundaries. This data set, without the geometries, looks as follows:
master_data["boundaries"].head(10)
FIELD1 | TYPE | WB_A3 | REGION_UN | SUBREGION | REGION_WB | WB_NAME |
---|---|---|---|---|---|---|
0 | Country | ADO | Europe | Southern Europe | Europe & Central Asia | Andorra |
1 | Country | AFG | Asia | Southern Asia | South Asia | Afghanistan |
2 | Country | AGO | Africa | Middle Africa | Sub-Saharan Africa | Angola |
3 | Country | ALB | Europe | Southern Europe | Europe & Central Asia | Albania |
4 | Country | ARE | Asia | Western Asia | Middle East & North Africa | United Arab Emirates |
5 | Country | ARG | Americas | South America | Latin America & Caribbean | Argentina |
6 | Country | ARM | Asia | Western Asia | Europe & Central Asia | Armenia |
7 | Country | ATG | Americas | Caribbean | Latin America & Caribbean | Antigua and Barbuda |
8 | Country | AUS | Oceania | Australia and New Zealand | East Asia & Pacific | Australia |
9 | Country | AUT | Europe | Western Europe | Europe & Central Asia | Austria |
10 | Country | AZE | Asia | Western Asia | Europe & Central Asia | Azerbaijan |
On the other hand, our second table contains information about countries and their achieved scores in the Rule of Law Index (ROLI). This data can be downloaded from the official website of the World Justice Project, and after a few twitches, it would look like this:
master_data["roli"].head(10)
country | year | code | region | roli |
Afghanistan | 2014 | AFG | South Asia | 0.34 |
Afghanistan | 2015 | AFG | South Asia | 0.35 |
Afghanistan | 2016 | AFG | South Asia | 0.35 |
Afghanistan | 2017-2018 | AFG | South Asia | 0.34 |
Afghanistan | 2019 | AFG | South Asia | 0.35 |
Afghanistan | 2020 | AFG | South Asia | 0.36 |
Afghanistan | 2021 | AFG | South Asia | 0.35 |
Afghanistan | 2022 | AFG | South Asia | 0.33 |
Albania | 2012-2013 | ALB | Eastern Europe & Central Asia | 0.49 |
Albania | 2014 | ALB | Eastern Europe & Central Asia | 0.49 |
Albania | 2015 | ALB | Eastern Europe & Central Asia | 0.52 |
Creating containers
Let’s assume, for now, that we just want to generate world maps. However, we will allow the user to decide on a few other aspects. More specifically, the user will decide on which score and from which yearly wave display, but also on the color palette to use. Having defined this, we will build our app into a three-steps interaction:
- Step 1: Select the scores to be displayed in your map
- Step 2: Customize the color palette
- Step 3: Draw your map
We will visually represent this using Streamlit containers. A container allows you to place elements inside it in order to keep the resulting HTML (and your app) tidy and organized. We create these containers as follows:
# Creating a container for Step 1
data_container = st.container()
# Creating a container for Step 2
customization = st.container()
# Creating a container for Step 3
saving = st.container()
Once that we have created the containers, we can proceed to fill them using the with
statement from python:
# Creating a container for Step 1
data_container = st.container()
with data_container:
# Data Container Title
st.markdown("<h4>Step 1: Select the scores to be displayed in your map</h4>",
unsafe_allow_html = True)
st.markdown("""---""")
# Creating a container for Step 2
customization = st.container()
with customization:
# Data Cistomization Container Title
st.markdown("<h4>Step 2: Customize your map</h4>",
unsafe_allow_html = True)
st.markdown("""---""")
# Creating a container for Step 3
saving = st.container()
with saving:
#Saving Options Title
st.markdown("<h4>Step 3: Draw your map</h4>",
unsafe_allow_html = True)
# Execution button
submit_button = st.button(label = "Display")
As you can observe I use the st.markdown()
method from Streamlit to write some headers and also horizontal lines that will separate the containers. The unsafe_allow_html == True
argument allows you to leave HTML tags to be read by Streamlit when rendering the text. Also, I created an execution button at the end of the last container using the st.button()
method which will allows the user to run the generator once that all the previous steps have been filled. At this stage, the execution button doesn’t do anything when clicked but we will program the actions soon. After these additions, your app should look like this:
Now that we have the containers, we can proceed to add some input widgets. These widgets are pre-programmed elements that Streamlit puts at your disposition in order to facilitate the interaction of the user with the app so you don’t have to program everything from scratch.
To start, I will be adding two dropdown lists within the Step 1 container. Then, I will add a numeric input and a color picker within Step 2. However, you should know that there are other widgets created by Streamlit and multiple others created by the community. You can check some of them in the widgets documentation and on the Streamlit Extras website.
Setting up the input widgets
Lets start with the dropdown lists we are going to place in the Step 1 container. The first widget will allow the user to select which variable does he wants to use and display in the map. The second widget will allow the user to select from which yearly wave get the scores of the previously selected variable. For this, I will be using st.selectbox
.
First, wee need to create a dictionary or a list of the available options that will be displayed to the user. I will get this options directly from the data. However, given that the variable names are not self-explained, I have created a list call variable_labels
which provide more informative labels for the user to read and select a variable (column) from the data frame. Because the available years are self-explained and there is no need to add labels to them, I define their available options through a list, instead of a dictionary.
available_variables = dict(zip(master_data["roli"].iloc[:, 4:].columns.tolist(),
variable_labels))
available_years = sorted(master_data["roli"]["year"].unique().tolist(),
reverse = True)
Once that we have defined the available options, we proceed to set up the two dropdown lists as follows:
target_variable = st.selectbox("Select a variable from the following list:",
list(available_variables.keys()),
format_func=lambda x: available_variables[x])
target_year = st.selectbox("Select which year do you want to display from the following list:",
available_years)
The variable and year selected by the user will be stored in two different objects called target_variable
and target_year
, respectively. Once that we program our back-end actions, we can call these objects making use of these names.
Now, lets define the input widgets that we are going to place in the Step 2 container. We first need a numeric input that will allow the user to select how many color breaks should the color palette have. For this specific example, I will allow the user to have the freedom to choose between 2 and 5 color breaks for the continuous color palette. From a practical perspective, we do this by making use of st.number_input
. Then, we will need to dynamically set a number of color pickers equal to the number of color breaks selected by the user. A color picker can be defined using st.color_picker
.
# Defining default colors
default_colors = [["#B49A67"],
["#B49A67", "#001A23"],
["#98473E", "#B49A67", "#001A23"],
["#98473E", "#B49A67", "#395E66", "#001A23"],
["#98473E", "#B49A67", "#7A9E7E", "#395E66", "#001A23"]]
# We first define an empty list were we will store the colors
color_breaks = []
# Dropdown menu for number of color breaks
ncolors = st.number_input("Select number of color breaks", 2, 5, 4)
# Dynamic Color Pickers
st.markdown("<b>Select or write down the color codes for your legend</b>:",
unsafe_allow_html = True)
cols = st.columns(ncolors)
for i, x in enumerate(cols):
input_value = x.color_picker(f"Break #{i+1}:",
default_colors[ncolors-1][i],
key = f"break{i}")
x.write(str(input_value))
color_breaks.append(input_value)
As you can observe the number of color pickers are dynamically generated by setting them up inside a undefined number of columns that is, at the same time, dynamically generated by the number of color breaks selected.
IMPORTANT NOTE: Don’t forget to place all these widgets inside each container by making use of the with
statement. Just as we did with the panel headers above.
At the end, we should end up with a nice and tidy list containing the color codes selected by the user. We have named this list color_breaks
and we will be calling it in the back-end actions. If you re-run your app using the terminal, your app should look a lot like this:
Programming the execution
Now that we have a nice looking version of the app, we need to program the execution button and what does the generator do behind curtains. We outline this process by following these steps:
-
We filter the ROLI data using the targeted year selected by the user.
-
We merge the filtered ROLI data with the geographical information of the country boundaries. At this step, it is fundamental that the country codes of both data frames are identical.
-
We define the color palette using the color breaks and color codes defined by the user. We need to make use of the
LinearSegmentedColormap
method from matplotlib. -
We draw the map using matplotlib
-
We define an button which will allow the user to save the generated map as a SVG file in their local machine.
import io
import matplotlib.pyplot as plt
import matplotlib.colors as colors
# Server
if submit_button:
# Filtering ROLI Data
filtered_roli = master_data["roli"][master_data["roli"]['year'] == target_year]
# Merge the two DataFrames on a common column
data4map = master_data["boundaries"].merge(filtered_roli,
left_on = "WB_A3",
right_on = "code",
how = "left")
# Parameters for missing values
missing_kwds = {
"color" : "#EBEBEB",
"edgecolor": "white",
"label" : "Missing values"
}
# Create a custom colormap
colors_list = color_breaks
cmap_name = "default_cmap"
cmap = (colors
.LinearSegmentedColormap
.from_list(cmap_name, colors_list))
# Drawing map with matplotlib
fig, ax = plt.subplots(1,
figsize = (25, 16),
dpi = 100)
data4map.plot(
column = target_variable,
cmap = cmap,
linewidth = 0.5,
ax = ax,
edgecolor = "white",
legend = True,
missing_kwds = missing_kwds
)
ax.axis("off")
st.pyplot(fig)
# Export image as SVG file
svg_file = io.StringIO()
plt.savefig(svg_file,
format = "svg")
st.download_button(label = "Save",
data = svg_file.getvalue(),
file_name = "choropleth_map.svg")
Having programmed these steps, everytime the user hits the Display button, a nice looking world map will be generated by the app:
Customizing the app using style sheets
You can further customize the looks of your app by creating a
CCS file. This Cascading Style Sheet will allow you to define the visual aesthetics of the HTML elements of your app. I will do it by creating a file named styles.css
in the app root directory and I will define some styles:
.jtext {
text-align: justify;
text-justify: auto;
color: #393B3B;
}
.vdesc {
text-align: justify;
text-justify: auto;
color: #393B3B;
font-size: 14px;
}
#MainMenu {
visibility: hidden;
}
#roli-map-generator{
text-align: center;
}
footer{
visibility: hidden;
}
div .streamlit-expanderHeader p{
text-size-adjust: 100%;
font-size: calc(0.875rem + .3vw);
font-family: "Source Sans Pro", sans-serif;
font-weight: 600;
color: rgb(76, 76, 82);
line-height: 1.1;
box-sizing: border-box;
position: relative;
flex: 1 1 0%;
}
In order for these styles to be applied to your final rendered app, you will have to call them at the very beginning of your python script by writting the following lines of code:
with open("styles.css") as stl:
st.markdown(f"<style>{stl.read()}</style>",
unsafe_allow_html=True)
And now, you will have a fully functional app that generates Choropleth Maps for you. You can see the current version of the app in this link.
- Posted on:
- May 25, 2023
- Length:
- 15 minute read, 3055 words