Metro Manila Road Safety Dashboard
Build an interactive crash analytics dashboard for Metro Manila.
Introduction
30 Second Summary
Every day, millions of commuters navigate Metro Manila's roads without knowing which corridors are deadliest. In 2024, a total of 29,199 crashes were recorded across just 7 major highways, and fatal incidents grew more frequent compared to the prior year.
In this project, you will use Streamlit to build a live, interactive road safety dashboard that transforms ~957,000 traffic accident records into an explorable heatmap and time-series charts. You will deploy it for free on Streamlit Community Cloud so anyone can open it in their browser.
What You'll Build
Picture sending someone a link and watching them instantly discover that EDSA and C5 are Metro Manila's most crash-prone corridors, just by hovering over a glowing heatmap in their browser.
By the end of this project, you'll have:
- A live heatmap where you filter by crash severity, year range, and time of day to reveal exactly which corridors across Metro Manila's 17 cities are deadliest.
- Time-series charts and KPI cards that update as you adjust filters, showing when crashes peak by hour and how severity trends have shifted across 12 years of data.
- A publicly deployed dashboard on Streamlit Community Cloud that anyone can access in their browser for $0.
- Secret Mission: A ranked city comparison feature that groups crashes by Metro Manila's 17 cities and lets users toggle between total crashes and fatality rate.
Are there any prerequisites?
This project assumes you are comfortable writing basic Python (variables, functions, loops) and have used SQL at an introductory level. No prior experience with Streamlit or dashboard building is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Project Environment
Before you can analyze any crash data, you need a clean workspace with the right tools installed. A dedicated project folder and an isolated Python environment ensure your dashboard code stays organized and reproducible.
In this step, you'll create a virtual environment and install four core libraries: Streamlit for the web app framework, pandas for data manipulation, Plotly for interactive charts, and pydeck for the heatmap.
In this step, get ready to:
- Create a project folder with a Python virtual environment.
- Install Streamlit, pandas, Plotly, and pydeck.
- Verify Streamlit runs in the browser.
Create your project folder and virtual environment
Every Python project benefits from its own folder and virtual environment. The virtual environment keeps your dashboard's libraries separate from other projects on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir metro-manila-dashboard
cd metro-manila-dashboard
- Confirm you're inside the new folder by running:
pwd
You should see a path ending in metro-manila-dashboard.
- Create a Python virtual environment and activate it by running the commands for your operating system:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Libraries you install here won't conflict with other projects or your system Python.
Don't see (venv) in your prompt?
Make sure you ran the activate command (not just the python -m venv venv part). On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again.
help me activate my Python virtual environment
- Install the four libraries your dashboard needs by running this command:
pip install streamlit==1.61.0 pandas==2.2.3 plotly==6.1.2 pydeck==0.9.1
After a minute or so, you'll see Successfully installed followed by the list of packages. This confirms all four libraries are ready.
Why pin exact versions?
Pinning versions (like streamlit==1.61.0) guarantees everyone running your project gets the same behavior. Without pins, a future library update could break your dashboard.
Seeing a permission error or timeout?
Make sure (venv) appears in your prompt before running pip install. If the install times out, check your internet connection and try again.
help me fix my pip install error
Create the project file structure
Your project needs three things: an app.py file for your dashboard code, a data/ folder for the dataset, and a requirements.txt that records your dependencies.
- Create the data/ folder and empty app.py file by running the commands for your operating system:
macOS
mkdir data
touch app.py
Windows
mkdir data
New-Item app.py
- Open VS Code to your project folder. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop and select the metro-manila-dashboard folder, then click Open.
You should see app.py and the data folder in the VS Code Explorer sidebar on the left.
- Create the requirements.txt file. In VS Code's Explorer sidebar, click the New File icon (the page icon with a plus sign).
- Name the file requirements.txt and press Enter.
- Paste the following content into requirements.txt:
streamlit==1.61.0
pandas==2.2.3
plotly==6.1.2
pydeck==0.9.1
What does requirements.txt do?
This file lists every library your project depends on, with exact versions. Anyone who clones your project can run pip install -r requirements.txt to recreate your environment. You'll also need this file for deployment later.
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm your project files by switching back to your terminal and running:
ls
You should see app.py, data, requirements.txt, and venv in the output.
Missing a file in the output?
Make sure you're still inside the metro-manila-dashboard folder. Run pwd to check your current directory. If requirements.txt is missing, make sure you saved the file in VS Code (not just typed the content).
help me find my missing project files
✔️ Awesome, I've got everything!
Great. Double check you've saved requirements.txt and let's move on.
ⓧ I'd like to double check the full code
Your requirements.txt should contain exactly this:
streamlit==1.61.0
pandas==2.2.3
plotly==6.1.2
pydeck==0.9.1
Your app.py should be empty for now. The data/ folder should also be empty.
Verify Streamlit is working
Time to confirm that Streamlit is properly installed and can launch a web app from your terminal.
- Before you run the next command, predict what you think will happen. Will it print output to the terminal, open something in your browser, or both?
- Test your Streamlit installation by running this command:
streamlit hello
A new tab opens in your browser showing the Streamlit Hello demo app. Your terminal also shows a local URL (http://localhost:8501). The demo showcases interactive charts, maps, and widgets that Streamlit can render.
What just happened?
Streamlit started a local web server on port 8501 and opened your browser to display a built-in demo app. This is the same mechanism your dashboard will use. You write Python, Streamlit serves it as an interactive web page.
The server keeps running until you stop it. Press Ctrl+C in your terminal to stop it when you're ready.
Seeing 'streamlit' is not recognized?
This means your virtual environment is not active or Streamlit was not installed into it. Make sure (venv) appears in your terminal prompt. If it does not, re-run the activate command from the first substep.
If activation is working but the command still fails, try the long-form version:
python -m streamlit hello
help me get streamlit hello to work
- Once you've explored the demo, press Ctrl+C in your terminal to stop the Streamlit server.
Your environment is locked and loaded. Next up, you'll download the Metro Manila crash dataset and get your first look at nearly a million accident records.
Load and Explore the Raw Data
Your environment is ready and Streamlit is running. Now it is time to feed it real data.
The dataset has nearly a million records with coordinates in raw form. Before building any visualizations, you need to see what you are working with, understand the structure, and identify what needs cleaning. This step produces your first visible output: a data summary displayed in the browser.
In this step, get ready to:
- Download the Metro Manila Traffic Accident dataset and load it into pandas.
- Display the dataset shape, columns, and a sample in the Streamlit app.
- Explore column types, null counts, and classification value distributions.
Download the dataset from GitHub
The Metro Manila Traffic Accident dataset lives on GitHub as a set of CSV files covering 2005 to Q1 2017. You will download these files and place them in your project's data/ folder.
- Open this link in your browser to visit the dataset repository:
- https://github.com/teknomo/MetroManilaTrafficAccidentData
The repository contains three CSV files you need to download.
- Click on MetroManilaSpatialAccidentData.csv in the file list.
- Click the Download raw file button (the download icon near the top right of the file view).
- Save it into your project's data/ folder.
- Go back to the repository and repeat for MetroManilaSpatialAccidentData2.csv.
- Repeat once more for MetroManilaAccidentDataTimeClassification.csv.
What are these files?
The two spatial files contain latitude and longitude coordinates for ~957,000 traffic accidents. The time/classification file contains the timestamp (in Excel date-time format) and severity code for each record. A shared Key field connects them.
- Confirm the files are in place by running:
ls data/
You should see all three CSV files listed in the output.
Don't see three files?
Make sure you saved the files directly into the data/ folder inside your project directory. If your browser saved them to Downloads by default, move them into data/ now.
help me find where my downloaded CSV files ended up
Set up the app and load the CSV files
With the data in place, you will write the first version of app.py that configures the Streamlit page and loads all CSVs into a single pandas DataFrame.
- Open app.py in VS Code and add the following imports and page configuration:
import streamlit as st
import pandas as pd
import plotly.express as px
import pydeck as pdk
from pathlib import Path
# Configure the Streamlit page layout and title
st.set_page_config(page_title="Metro Manila Road Safety Dashboard", layout="wide")
st.title("Metro Manila Road Safety Dashboard")
st.caption("Analyzing ~957,000 traffic accidents (2005 - Q1 2017)")
# Point to the folder holding our CSV files
DATA_DIR = Path("data")
What does this code do?
st.set_page_config sets the browser tab title and switches to a wide layout so charts have room to breathe.
st.title and st.caption render the dashboard heading and subtitle directly in the browser. DATA_DIR stores the path to your data folder so you can reference it cleanly later.
- Save app.py and run it to confirm the page title appears:
streamlit run app.py
Your browser opens to localhost:8501 showing the title "Metro Manila Road Safety Dashboard" and the caption below it.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. You should see its name in parentheses in your terminal prompt. If not, activate it again before running the command.
help me fix a ModuleNotFoundError when running streamlit
Now add the code that reads all CSV files from data/ and merges them into one DataFrame.
- Below the DATA_DIR line in app.py, add the following code to load and combine all CSV files:
# Find all CSV files in data/ and load them into one DataFrame
spatial_files = sorted(DATA_DIR.glob("*.csv"))
frames = []
for f in spatial_files:
df = pd.read_csv(f)
frames.append(df)
raw = pd.concat(frames, ignore_index=True)
# Normalize column names to lowercase
raw.columns = [c.strip().lower() for c in raw.columns]
# Rename the 'key' column to 'id' for clarity
if "key" in raw.columns:
raw = raw.rename(columns={"key": "id"})
# Display basic info about the loaded dataset
st.write("Shape:", raw.shape)
st.write("Columns:", raw.columns.tolist())
st.dataframe(raw.head(20))
What does this code do?
DATA_DIR.glob("*.csv") finds every CSV file in the data/ folder. The loop reads each one into a DataFrame and appends it to a list.
pd.concat merges all the DataFrames into a single raw DataFrame with a fresh index. Normalizing column names to lowercase prevents case-mismatch bugs later. Finally, st.dataframe renders an interactive table in the browser showing the first 20 rows.
- Save app.py.
Your browser auto-refreshes. You should see the shape displayed (approximately 957,000 rows), a list of column names, and an interactive data table showing the first 20 rows.
Seeing an EmptyDataError or zero rows?
This means DATA_DIR.glob("*.csv") found no files. Double-check that your CSV files are saved directly inside the data/ folder (not in a subfolder) and that they have the .csv extension.
help me debug why my CSVs are not loading
Display column types and classification counts
You can see the data, but you do not yet know the types of each column or how the severity classifications are distributed. Adding this information reveals the scope of cleaning needed.
- Below the st.dataframe(raw.head(20)) line in app.py, add the following code to show column types, nulls, and classification metrics:
# Show data types and missing values
st.write("Column types:", raw.dtypes)
st.write("Null counts:", raw.isnull().sum())
# Display classification value counts as metric cards
if "classification" in raw.columns:
for code, count in raw["classification"].value_counts().items():
st.metric(f"Classification {code}", f"{count:,}")
What does this code do?
raw.dtypes shows each column's data type. This reveals that the time column is stored as a float (Excel date-time format), not a proper datetime.
raw.isnull().sum() counts missing values per column. st.metric renders large, prominent cards showing how many records fall into each classification code (1 = Damage to Property, 2 = Non-Fatal Injury, 3 = Fatal).
- Save app.py.
Your browser refreshes again. Below the data table, you now see column types, null counts, and metric cards showing the count for each classification code.
What data quality issues can you spot?
Look at the null counts. Some rows are missing latitude or longitude values. The time column is a raw float instead of a human-readable date. The classification field uses numeric codes (1, 2, 3) that do not tell you the severity at a glance.
These are the issues you will fix in the next step when you clean the data and extract proper datetime features.
Before you move on, predict what you expect to see when you look at the full running app.
- Refresh your browser at localhost:8501 and confirm you see: the dataset shape (~957,000 rows), column names, an interactive data table, column types, null counts, and three classification metric cards.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and your Streamlit app is displaying all the data exploration output.
ⓧ I'd like to double check the full code
Here is the complete app.py at the end of this step. Make sure yours matches:
import streamlit as st
import pandas as pd
import plotly.express as px
import pydeck as pdk
from pathlib import Path
st.set_page_config(page_title="Metro Manila Road Safety Dashboard", layout="wide")
st.title("Metro Manila Road Safety Dashboard")
st.caption("Analyzing ~957,000 traffic accidents (2005 - Q1 2017)")
DATA_DIR = Path("data")
spatial_files = sorted(DATA_DIR.glob("*.csv"))
frames = []
for f in spatial_files:
df = pd.read_csv(f)
frames.append(df)
raw = pd.concat(frames, ignore_index=True)
raw.columns = [c.strip().lower() for c in raw.columns]
if "key" in raw.columns:
raw = raw.rename(columns={"key": "id"})
st.write("Shape:", raw.shape)
st.write("Columns:", raw.columns.tolist())
st.dataframe(raw.head(20))
st.write("Column types:", raw.dtypes)
st.write("Null counts:", raw.isnull().sum())
if "classification" in raw.columns:
for code, count in raw["classification"].value_counts().items():
st.metric(f"Classification {code}", f"{count:,}")
You have loaded nearly a million traffic accident records into a live dashboard and surfaced the data quality issues that need fixing. Next up, you will clean the data by converting timestamps to real dates, filtering coordinates to Metro Manila's bounds, and mapping classification codes to human-readable labels.
Clean the Data and Add Time Features
In the last step you loaded the raw dataset and saw what you're working with. The numbers are there, but the timestamps are stored as Excel serial numbers (days since 1899) and the coordinates include points that fall outside Metro Manila entirely.
If you tried to plot this data right now, you'd get points in the ocean and meaningless time axes. In this step, you'll transform the raw numbers into usable datetime and location fields so the dashboard can actually make sense of them.
In this step, get ready to:
- Filter out records with missing or out-of-bounds coordinates.
- Convert Excel serial date-time values to proper Python datetimes with hour, day, month, and year columns.
- Map numeric classification codes to human-readable severity labels and cache the result.
Replace the exploration code with a cleaning function
The exploratory display code from Step 2 did its job. Now you'll replace it with a proper cleaning function that filters coordinates and normalizes columns.
- In app.py, select and delete everything below the line that says DATA_DIR = Path("data").
- Paste the following code directly below that line:
# Map codes to severity labels, define Metro Manila boundaries
CLASSIFICATION_MAP = {1: "Damage to Property", 2: "Non-Fatal Injury", 3: "Fatal"}
METRO_MANILA_BOUNDS = {
"lat_min": 14.35,
"lat_max": 14.78,
"lon_min": 120.90,
"lon_max": 121.15,
}
@st.cache_data
def load_and_clean_data():
# Load and combine all CSV files
spatial_files = sorted(DATA_DIR.glob("*.csv"))
frames = []
for f in spatial_files:
df = pd.read_csv(f)
frames.append(df)
raw = pd.concat(frames, ignore_index=True)
raw.columns = [c.strip().lower() for c in raw.columns]
if "key" in raw.columns:
raw = raw.rename(columns={"key": "id"})
# Remove rows with missing or out-of-bounds coordinates
raw = raw.dropna(subset=["latitude", "longitude"])
raw = raw[
(raw["latitude"] >= METRO_MANILA_BOUNDS["lat_min"])
& (raw["latitude"] <= METRO_MANILA_BOUNDS["lat_max"])
& (raw["longitude"] >= METRO_MANILA_BOUNDS["lon_min"])
& (raw["longitude"] <= METRO_MANILA_BOUNDS["lon_max"])
]
What does this code do?
CLASSIFICATION_MAP defines the lookup from numeric codes (1, 2, 3) to severity labels you'll apply later. METRO_MANILA_BOUNDS defines the latitude/longitude box around Metro Manila's 17 cities.
The function starts with @st.cache_data so Streamlit caches the cleaned result. On future reruns, it skips the expensive reload. Inside, it loads the CSVs (same logic from before), then drops any rows missing coordinates and filters to only points that fall inside Metro Manila.
Convert timestamps and map severity labels
The dataset stores timestamps as Excel serial numbers. Each value represents the number of days since December 30, 1899. You need to convert these into proper Python datetimes so you can filter by hour, day, and year.
- Still in app.py, paste the following code directly below the coordinate filter (after the closing ] bracket):
# Convert Excel serial date-time to Python datetime columns
if "time" in raw.columns:
excel_epoch = pd.Timestamp("1899-12-30")
raw["datetime"] = excel_epoch + pd.to_timedelta(raw["time"], unit="D")
raw["hour"] = raw["datetime"].dt.hour
raw["day_of_week"] = raw["datetime"].dt.day_name()
raw["year"] = raw["datetime"].dt.year
raw["month"] = raw["datetime"].dt.month
# Map classification codes to human-readable severity labels
if "classification" in raw.columns:
raw["severity"] = raw["classification"].map(CLASSIFICATION_MAP)
return raw
data = load_and_clean_data()
st.subheader("Data Quality")
st.write(f"Cleaned row count: {len(data):,}")
st.dataframe(data[["datetime", "hour", "day_of_week", "year", "month", "severity", "latitude", "longitude"]].head(20))
What does this code do?
excel_epoch = pd.Timestamp("1899-12-30") sets the base date that Excel counts from. Adding the serial number as days gives you a real datetime. The code then extracts hour, day_of_week, year, and month as separate columns for filtering later.
The classification mapping turns codes 1, 2, 3 into "Damage to Property", "Non-Fatal Injury", and "Fatal". Finally, data = load_and_clean_data() calls the function and the display section shows you the cleaned row count plus a preview of the new columns.
- Save app.py.
Before you run this, think about what you expect to see. The raw data had about 957,000 rows. After dropping rows with missing coordinates and filtering to Metro Manila bounds, do you think the count will drop significantly or stay about the same?
- Run your app by executing this command:
streamlit run app.py
Your browser shows a "Data Quality" section with the cleaned row count and a table showing the new columns: datetime, hour, day_of_week, year, month, and severity with human-readable values like "Fatal" and "Monday".
Seeing an error or no data?
- If you see IndentationError, make sure all lines inside load_and_clean_data() are indented with 4 spaces. The if blocks need 8 spaces for their bodies.
- If the row count shows 0, check that METRO_MANILA_BOUNDS uses the exact values shown above. A typo in the latitude or longitude bounds will filter out all records.
- If you see KeyError: 'latitude', confirm you kept the raw.columns = [c.strip().lower() for c in raw.columns] line before the dropna call.
Still stuck? Help me debug my data cleaning function in app.py
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and your browser shows the cleaned data table.
ⓧ I'd like to double check the full code
Your app.py should have these sections in order. Scroll through your file and confirm each is present:
- Imports (streamlit, pandas, plotly.express, pydeck, Path) at the top.
- Page config, title, and caption lines.
- DATA_DIR = Path("data") line.
- CLASSIFICATION_MAP dictionary and METRO_MANILA_BOUNDS dictionary.
- The @st.cache_data decorator followed by def load_and_clean_data(): containing the CSV loading, column normalization, coordinate filtering, datetime conversion, and classification mapping.
- data = load_and_clean_data() call followed by the Data Quality display (subheader, row count, dataframe preview).
Your raw data is now clean, structured, and cached. The timestamps make sense, the coordinates are guaranteed to be in Metro Manila, and each record has a readable severity label. Next up, you'll put this data on a map and let users filter it interactively.
Build the Interactive Map and Filters
You now have a cleaned dataset with nearly a million geo-located crash records. But a data table that large tells you nothing at a glance.
A heatmap instantly reveals which corridors are deadliest. In this step, you will add sidebar filters for severity, year, and time of day, then render a pydeck heatmap that lights up the most dangerous stretches of Metro Manila.
In this step, get ready to:
- Add sidebar filter widgets for severity, year range, and hour of day.
- Display KPI metric cards that react to the active filters.
- Render a pydeck heatmap showing crash hotspots across Metro Manila.
Add sidebar filter widgets
Filters let users narrow ~957,000 records to just the slice they care about. Streamlit's sidebar keeps controls out of the main content area so the map and charts stay front and center.
- In app.py, find and delete the Data Quality section at the bottom of the file (the three lines starting with st.subheader("Data Quality")).
- Find this code:
st.subheader("Data Quality")
st.write(f"Cleaned row count: {len(data):,}")
st.dataframe(data[["datetime", "hour", "day_of_week", "year", "month", "severity", "latitude", "longitude"]].head(20))
- Delete those three lines. You will replace them with the sidebar filters now.
- Add the following sidebar filter code at the bottom of app.py (right after the data = load_and_clean_data() line):
st.sidebar.header("Filters")
# Get unique severity values for the multiselect options
severity_options = data["severity"].dropna().unique().tolist()
selected_severity = st.sidebar.multiselect(
"Crash Severity", severity_options, default=severity_options
)
# Year range slider based on actual data bounds
year_min = int(data["year"].min())
year_max = int(data["year"].max())
selected_years = st.sidebar.slider("Year Range", year_min, year_max, (year_min, year_max))
# Hour of day slider for time-based filtering
selected_hours = st.sidebar.slider("Hour of Day", 0, 23, (0, 23))
What does this code do?
- st.sidebar.header("Filters") creates a labeled section in the left sidebar panel.
- st.sidebar.multiselect shows a dropdown where users can select one or more severity levels. Setting default=severity_options means all options start selected.
- st.sidebar.slider with a tuple as the third argument creates a range slider. The year slider bounds come from the actual data, and the hour slider spans 0 to 23.
- Save app.py.
- Switch to the terminal running your Streamlit app. The page should auto-reload.
- You should see a sidebar on the left with three filter controls: a multiselect dropdown, a year range slider, and an hour of day slider.
Don't see the sidebar?
- Make sure you deleted the old Data Quality section. A leftover st.subheader("Data Quality") line can cause confusion but should not block the sidebar.
- Click the arrow icon in the top left of the app to expand the sidebar if it is collapsed.
- Check that the new code is placed after data = load_and_clean_data(). The widgets rely on data being available.
help me debug my Streamlit sidebar not appearing
Filter the data and display KPI metric cards
The sidebar widgets now capture user selections. You need to apply those selections to the dataframe and show the resulting totals as bold, scannable numbers at the top of the page.
- Add the following code below the sidebar slider code you just wrote in app.py:
# Apply all three filters to produce a focused subset
filtered = data[
(data["severity"].isin(selected_severity))
& (data["year"] >= selected_years[0])
& (data["year"] <= selected_years[1])
& (data["hour"] >= selected_hours[0])
& (data["hour"] <= selected_hours[1])
]
# Display KPI cards in a three-column layout
col1, col2, col3 = st.columns(3)
col1.metric("Total Incidents", f"{len(filtered):,}")
col2.metric("Fatal", f"{(filtered['severity'] == 'Fatal').sum():,}")
col3.metric("Non-Fatal Injuries", f"{(filtered['severity'] == 'Non-Fatal Injury').sum():,}")
What does this code do?
- The filtered dataframe uses boolean indexing with & to combine all three filter conditions. Only rows matching every active filter survive.
- st.columns(3) creates a three-column layout. Each column's .metric() renders a large number with a label above it, giving users an instant summary.
- The :, format specifier adds comma separators to large numbers for readability.
- Save app.py and check your browser.
- You should see three metric cards at the top of the main area: Total Incidents, Fatal, and Non-Fatal Injuries. Try deselecting a severity option in the sidebar. The numbers should update instantly.
Metric cards showing zero?
- Check the multiselect widget in the sidebar. If you accidentally cleared all options, selected_severity is an empty list, so no rows pass the filter. Re-select at least one option.
- Make sure the filter code references selected_years[0] and selected_years[1] (tuple unpacking), not the whole tuple.
help me fix my Streamlit metric cards showing zero or unexpected values
Render the pydeck heatmap
The filters and KPIs tell users what they are looking at. Now you will show them where crashes happen using a heatmap that turns coordinate density into glowing color intensity on a dark map.
- Add the following code below the metric cards in app.py:
st.subheader("Crash Hotspot Map")
# Configure the heatmap layer with filtered coordinates
layer = pdk.Layer(
"HeatmapLayer",
data=filtered[["latitude", "longitude"]],
get_position=["longitude", "latitude"],
radiusPixels=30,
intensity=1,
threshold=0.3,
)
# Center the map on Metro Manila
view_state = pdk.ViewState(
latitude=14.55,
longitude=121.03,
zoom=10.5,
pitch=0,
)
# Render with a dark basemap so heat colors pop
st.pydeck_chart(pdk.Deck(layers=[layer], initial_view_state=view_state, map_style="dark"))
What does this code do?
- pdk.Layer("HeatmapLayer", ...) creates a deck.gl heatmap that aggregates point density into color intensity. get_position=["longitude", "latitude"] tells the layer which dataframe columns hold the coordinates (longitude first, then latitude).
- radiusPixels=30 controls how wide each point's heat contribution spreads. threshold=0.3 sets the minimum opacity before a pixel is rendered, keeping low-density areas clean.
- pdk.ViewState centers the camera on Metro Manila at coordinates 14.55, 121.03 with zoom level 10.5, which frames all 17 cities.
- map_style="dark" sets a dark basemap so the warm heat colors stand out clearly against the background.
- Save app.py.
Before you check the browser, what do you expect to see on the map? Think about where Metro Manila's busiest roads are.
- Check your browser. You should see a dark map of Metro Manila with bright colored regions where crashes cluster. The EDSA and C5 corridors should light up as the most prominent hotspots.
- Try adjusting the hour slider to cover only nighttime hours (18 to 23). The heatmap should shift to show different hotspot patterns.
Map is blank or not rendering?
- Pydeck requires an active internet connection to load the basemap tiles. Check that you are online.
- Make sure get_position=["longitude", "latitude"] lists longitude first. Swapping the order places points in the wrong location.
- Verify that filtered has rows. If all sidebar selections are cleared, the dataframe is empty and no points render.
help me debug why my pydeck heatmap is not rendering in Streamlit
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that all three sections (sidebar filters, KPI cards, heatmap) are visible in your browser.
ⓧ I'd like to double check the full code
Your app.py is now getting long. Scroll through and confirm it has these sections in order:
- Imports and page config (st.set_page_config, st.title, st.caption).
- Constants (DATA_DIR, CLASSIFICATION_MAP, METRO_MANILA_BOUNDS).
- The load_and_clean_data() function with @st.cache_data.
- data = load_and_clean_data() call.
- Sidebar filters (st.sidebar.header, multiselect, two sliders).
- Filtered dataframe and three metric cards.
- Crash Hotspot Map subheader with pydeck layer, view state, and st.pydeck_chart call.
Make sure there is no leftover Data Quality section. The old st.subheader("Data Quality"), st.write, and st.dataframe lines should be gone.
Your dashboard now tells a visual story. Users can filter nearly a million crash records by severity, year, and time of day, then instantly see where the deadliest corridors are. Next, you will add time-series charts that reveal when crashes peak and deploy the whole thing to the web.
Add Time-Series Charts and Deploy
Your heatmap reveals where Metro Manila's crashes cluster. But it says nothing about when they happen.
Time-based charts complete the story: peak hours, yearly trends, and severity proportions. Once those are in place, you will deploy the dashboard to Streamlit Community Cloud so anyone with a browser can explore it for free.
In this step, get ready to:
- Add three Plotly charts (hourly bar, yearly line, severity pie) that react to sidebar filters.
- Push your project to a public GitHub repository.
- Deploy to Streamlit Community Cloud so the dashboard is live at a public URL.
Add a bar chart of crashes by hour of day
Commuters need to know which hours are most dangerous. A bar chart grouped by hour makes the pattern instantly obvious.
- In app.py, scroll to the very bottom of the file (after the st.pydeck_chart line).
- Add the hourly bar chart by pasting the following code at the end of the file:
st.subheader("Crashes by Hour of Day")
# Group filtered data by hour and count crashes
hourly = filtered.groupby("hour").size().reset_index(name="count")
fig_hour = px.bar(hourly, x="hour", y="count", labels={"hour": "Hour", "count": "Crashes"})
# Show every hour label (0-23) on the x-axis
fig_hour.update_layout(xaxis=dict(dtick=1))
st.plotly_chart(fig_hour, use_container_width=True)
What does this code do?
The filtered.groupby("hour").size() counts how many crashes fall in each hour of the day, respecting whatever filters the user has active in the sidebar.
The dtick=1 setting ensures every hour (0 through 23) gets its own bar label instead of Plotly skipping some.
- Save app.py.
- Return to your browser at localhost:8501. The app should auto-refresh. Scroll below the map and you should see a bar chart with 24 bars, one per hour.
Don't see the bar chart?
- Make sure you pasted the code after the st.pydeck_chart(...) call, not inside it.
- Check that app.py is saved. Unsaved changes will not trigger a browser refresh.
- If you see a red error banner in the app, look for indentation or typo issues in the new lines.
Help me debug why my hourly bar chart is not showing up in Streamlit.
Add a line chart of crashes by year
A line chart shows whether road safety is improving or declining over the dataset's 12-year span.
- Below the hourly chart code you just added, paste the following to create a yearly trend line:
st.subheader("Crashes by Year")
# Group filtered data by year and count crashes
yearly = filtered.groupby("year").size().reset_index(name="count")
fig_year = px.line(yearly, x="year", y="count", markers=True, labels={"year": "Year", "count": "Crashes"})
st.plotly_chart(fig_year, use_container_width=True)
What does this code do?
This groups filtered crashes by year and draws a line chart. The markers=True argument adds a dot at each year's data point, making values easy to read even when lines are close together.
- Save app.py and check the browser.
- Below the bar chart, you should now see a line chart spanning the years in your dataset. Try changing the year-range slider in the sidebar and watch the chart update.
Line chart looks flat or empty?
- Make sure the year-range slider in the sidebar is set wide enough to include multiple years.
- Confirm you are grouping by filtered and not data.
Help me debug why my yearly line chart is empty or flat.
Add a severity breakdown pie chart
A pie chart instantly communicates the proportion of fatal vs non-fatal vs property-damage crashes under the current filter.
- Below the yearly chart code, add the severity pie chart:
st.subheader("Severity Breakdown")
# Count occurrences of each severity label
severity_counts = filtered["severity"].value_counts().reset_index()
severity_counts.columns = ["Severity", "Count"]
fig_sev = px.pie(severity_counts, names="Severity", values="Count")
st.plotly_chart(fig_sev, use_container_width=True)
What does this code do?
The value_counts() method counts how many rows belong to each severity label. px.pie turns that into a chart where each slice represents one classification: Damage to Property, Non-Fatal Injury, or Fatal.
- Save app.py and refresh your browser.
- You should now see all three charts stacked below the heatmap: the hourly bar chart, the yearly line chart, and the severity pie chart.
- Try toggling severity types off in the sidebar multiselect. All three charts and the KPI cards should update simultaneously.
Charts not reacting to filters?
All three charts use the filtered DataFrame. If the charts do not respond to sidebar changes, double-check you are referencing filtered (not data) in each groupby call.
Help me figure out why my Plotly charts are not updating when I change the sidebar filters.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and all three charts render below the heatmap.
ⓧ I'd like to double check the full code
Your app.py is now too long to show in one block. Scroll up through the file and confirm you have these sections in order from top to bottom:
- Imports and page config at the top.
- Constants (DATA_DIR, CLASSIFICATION_MAP, METRO_MANILA_BOUNDS).
- The load_and_clean_data() function with the @st.cache_data decorator.
- Sidebar filters (severity, year range, hour of day).
- The filtered DataFrame and KPI metric cards.
- The pydeck heatmap section.
- "Crashes by Hour of Day" bar chart.
- "Crashes by Year" line chart.
- "Severity Breakdown" pie chart at the very end.
Push your project to GitHub
Streamlit Community Cloud deploys directly from a GitHub repository. You need to push your project there before you can deploy.
- Check whether Git is installed by running this command in your terminal:
git --version
✔️ I see a version number
Git is ready. Continue to the next instruction below.
ⓧ Command not found
You need to install Git first.
- Download the official installer from git-scm.com/downloads and run it.
- Accept the default options during installation.
- Close and reopen your terminal, then run git --version again to confirm it works.
- Create a .gitignore file so the virtual environment folder does not get pushed. Run:
echo venv/ > .gitignore
- Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit: road safety dashboard"
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all files (app.py, requirements.txt, the data/ CSVs, and .gitignore) for commit.
- git commit saves a snapshot of your project with a descriptive message.
- You should see output listing the files that were committed.
- Open github.com in your browser. If you do not have an account, create one now (it is free).
- Click the + icon in the top-right corner and select New repository.
- Name the repository metro-manila-road-safety (or any name you prefer).
- Set visibility to Public.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
GitHub shows a setup page with the HTTPS URL for your new repository. Copy that URL.
- Back in your terminal, connect your local repo to GitHub and push by running:
git branch -M main
git remote add origin [[REPO_URL="https://github.com/your-username/metro-manila-road-safety.git"]]
git push -u origin main
What does this do?
This renames your default branch to main, links your local repository to the GitHub remote, and uploads all your committed files.
- Refresh your GitHub repository page in the browser. You should see app.py, requirements.txt, and the data/ folder listed.
Push was rejected or asks for credentials?
- If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS pushes. Generate a Personal Access Token at github.com/settings/tokens and use that token as your password.
- If the push says "remote already exists", run git remote set-url origin <your-url> to fix the URL, then push again.
Help me push my Streamlit project to GitHub.
Deploy to Streamlit Community Cloud
Your code is on GitHub. Now you will connect it to Streamlit Community Cloud so it becomes a live web app anyone can visit.
- Open share.streamlit.io in your browser.
- Sign in with your GitHub account. If this is your first time, authorize Streamlit to access your repositories when prompted.
- In the upper-right corner of your workspace, click Create app.
- When asked "Do you already have an app?" click Yup, I have an app.
- Fill in your repository name, branch main, and main file path app.py.
- Click Advanced settings.
- In the Python version dropdown, select 3.12.
- Click Save.
- Click Deploy.
Why set Python to 3.12?
Streamlit Community Cloud defaults to version 3.12. Explicitly selecting it ensures your app does not accidentally run on a newer version that could cause wheel-building failures with your pinned dependencies.
The deployment takes a few minutes. You can watch the logs on the right side of the screen as it installs your dependencies and boots the app.
Before you open your deployed URL, predict: do you think the heatmap, charts, and filters will all work on the cloud the same way they do locally?
- Once the deployment finishes, click on your app URL. It will look something like your-username-metro-manila-road-safety-app-xxxxx.streamlit.app.
- Verify the dashboard loads with the heatmap, all three charts, and the sidebar filters all working.
Your dashboard is now live. Anyone with the URL can explore Metro Manila's crash data without installing anything.
Deployment failed or app won't load?
- Check the deployment logs for the specific error. The most common issue is a missing dependency in requirements.txt. Make sure it contains streamlit==1.61.0, pandas==2.2.3, plotly==6.1.2, and pydeck==0.9.1.
- If you see a "No module named" error, push an updated requirements.txt to GitHub. Community Cloud will auto-redeploy.
- If the data files appear missing, confirm your data/ folder with the CSV files is committed to the repo (check GitHub).
Help me debug a failed Streamlit Community Cloud deployment.
Your dashboard is deployed and publicly accessible. Next up, you will tackle the Secret Mission and add a city comparison ranking to make the dashboard even more useful for stakeholders.
Secret mission
Top Dangerous Roads Ranking
Your heatmap shows crash patterns visually, but stakeholders and journalists want a ranked list they can cite. Can you build a city comparison feature that reveals which of Metro Manila's 17 cities is most dangerous, and let users toggle between total crashes and fatality rate?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free services, so there are no ongoing costs.
Resources you used:
- A Streamlit Community Cloud app (free, stays running indefinitely for public apps).
- A public GitHub repository with your code and data.
- A local project folder on your Desktop containing app.py, the data/ folder, requirements.txt, and your virtual environment.
Keep everything running
No action needed. Choose this if you want your dashboard to stay live and publicly accessible.
- Your Streamlit Community Cloud app costs nothing and stays deployed indefinitely.
- Your GitHub repository remains public so others can view or fork your code.
- Push any changes to your GitHub repo and the app auto-redeploys.
Pause - I'll come back to this later
Since both the app and repository are free, there is nothing to pause for cost reasons. If you want to free up local disk space while keeping the deployed app intact:
- Delete the local project folder from your Desktop. Your code is safe on GitHub.
- When you want to resume, clone the repository again and recreate your virtual environment.
Delete - I don't want to use this again
Remove all project resources completely. Follow each item below.
- Delete your Streamlit Community Cloud app: go to share.streamlit.io, find your app in your workspace, click the app's overflow menu (three dots), and select Delete.
- Delete the GitHub repository: open the repository page on GitHub, click Settings, scroll to the bottom, and click Delete this repository in the Danger Zone section.
- Delete the local project folder. On macOS or Linux, run the following command in your terminal (replace the folder name with your actual project folder):
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
Nice Work!
Nice Work!
Nice work! You just built and deployed a full interactive road safety dashboard analyzing nearly a million Metro Manila traffic accident records.
You've learned how to:
- Clean and transform raw geospatial data into analysis-ready format using pandas, including converting Excel date-time fields and filtering coordinates to a bounding box.
- Build interactive visualizations with a pydeck heatmap revealing crash hotspot corridors and Plotly time-series charts showing when crashes peak.
- Deploy a live, publicly accessible web app on Streamlit Community Cloud for $0, turning a local Python script into a shareable tool anyone can explore in their browser.
- Secret Mission: Added a ranked city comparison feature showing which of Metro Manila's 17 cities are most dangerous by total crashes or fatality rate.
Ready to quiz yourself?