KWHLC / src /streamlit_app.py
edwardyanguoi238's picture
Update src/streamlit_app.py
b2490e5 verified
Raw
History Blame Contribute Delete
9.81 kB
"""
Streamlit UI for the Bernalillo County Urban Opportunity Index,
designed for legal aid staff and community members in Bernalillo County.
Run with:
streamlit run ui.py
"""
import os
import geopandas as gpd
import pandas as pd
import streamlit as st
import folium
from streamlit_folium import st_folium
from bern_uoi_pipeline import build_bern_uoi, ACS_YEAR, OUTPUT_DIR
DATA_GPKG = os.path.join(OUTPUT_DIR, "bern_uoi_tracts.gpkg")
DATA_LAYER = "tracts"
# =========================
# DATA LOADING
# =========================
@st.cache_data(show_spinner=True)
def load_data() -> gpd.GeoDataFrame:
"""
Load Bernalillo UOI data from GeoPackage if present.
If key fields (like eviction_resilience_score) are missing, rebuild.
"""
if os.path.exists(DATA_GPKG):
gdf = gpd.read_file(DATA_GPKG, layer=DATA_LAYER)
print(f"Loaded existing {DATA_GPKG}")
# If this is an older file without eviction columns, rebuild
if "eviction_resilience_score" not in gdf.columns:
print("Old data detected → rebuilding Bernalillo UOI dataset...")
gdf = build_bern_uoi(refresh=True)
else:
gdf = build_bern_uoi(refresh=True)
return gdf
# =========================
# MAP BUILDER
# =========================
def make_choropleth(gdf: gpd.GeoDataFrame, color_col: str, legend_name: str) -> folium.Map:
"""
Build a simple choropleth map for the chosen indicator.
Values are clipped to the 5th–95th percentile range so that
a few extreme tracts do not distort the color scale.
"""
# Map center = mean of tract centroids
centroids = gdf.geometry.centroid
center_lat = centroids.y.mean()
center_lon = centroids.x.mean()
# Copy and create a clipped plotting column
gdf_plot = gdf.copy()
# Use to_numeric to handle strings like "<NA>"
series = pd.to_numeric(gdf_plot[color_col], errors="coerce")
if series.notna().sum() > 0:
q_low = series.quantile(0.05)
q_high = series.quantile(0.95)
gdf_plot["_plot_val"] = series.clip(lower=q_low, upper=q_high)
else:
gdf_plot["_plot_val"] = series
m = folium.Map(
location=[center_lat, center_lon],
zoom_start=11,
tiles="CartoDB positron",
)
folium.Choropleth(
geo_data=gdf_plot.to_json(),
data=gdf_plot,
columns=["GEOID", "_plot_val"],
key_on="feature.properties.GEOID",
fill_color="YlGnBu",
fill_opacity=0.8,
line_opacity=0.3,
nan_fill_opacity=0.15,
legend_name=legend_name,
).add_to(m)
# Hover tooltip uses the original (unclipped) values
folium.GeoJson(
gdf,
style_function=lambda x: {"fillOpacity": 0, "color": "#444", "weight": 0.4},
tooltip=folium.features.GeoJsonTooltip(
fields=["NAME", color_col],
aliases=["Tract:", legend_name + ":"],
localize=True,
),
).add_to(m)
folium.LayerControl().add_to(m)
return m
# =========================
# APP LAYOUT
# =========================
def main():
st.set_page_config(
page_title="Bernalillo County Urban Opportunity Index",
layout="wide",
)
st.title("Bernalillo County Opportunity Map")
st.markdown(
"""
This map shows how different parts of Bernalillo County compare on everyday basics like:
- **Internet at home**
- **Housing and rent**
- **Health coverage**
- **Poverty and income**
- **Disability and education**
- **Eviction risk**
It’s meant to help **community members and legal aid staff** see where needs are greatest and
where people may need extra support.
"""
)
gdf = load_data()
# ----- SIDEBAR CONTROLS -----
st.sidebar.header("Pick what to see")
group = st.sidebar.radio(
"What do you want to look at?",
(
"Overall opportunity",
"Internet access",
"Housing costs",
"Health coverage",
"Money & poverty",
"Income",
"Disability",
"Education",
"Eviction risk",
),
)
value_type = st.sidebar.radio(
"How should the numbers be shown?",
(
"Opportunity score (0–1, higher = better)",
"Original value (as collected)",
),
)
# ----- COLUMN SELECTION -----
if group == "Overall opportunity":
col_norm = "uoi_score"
col_raw = "uoi_score" # same numbers, just labeled differently
label = "Overall opportunity score (0–1)"
elif group == "Internet access":
col_norm = "norm_broadband"
col_raw = "pct_broadband"
label = "Households with home internet (%)"
elif group == "Housing costs":
col_norm = "norm_rent_burdened"
col_raw = "pct_rent_burdened"
label = "Households with high housing costs (%)"
elif group == "Health coverage":
col_norm = "norm_uninsured"
col_raw = "pct_uninsured"
label = "People without health insurance (%)"
elif group == "Money & poverty":
col_norm = "norm_poverty"
col_raw = "pct_poverty"
label = "People living below the poverty line (%)"
elif group == "Income":
col_norm = "norm_income"
col_raw = "median_hh_income"
label = "Median household income (dollars)"
elif group == "Disability":
col_norm = "norm_disability"
col_raw = "pct_disability"
label = "People living with a disability (%)"
elif group == "Education":
col_norm = "norm_hs_or_higher"
col_raw = "pct_hs_or_higher"
label = "Adults with high school or higher (%)"
else: # Eviction risk
# “Opportunity score” view: resilience (higher = safer)
# “Original value” view: risk (higher = more pressure)
col_norm = "eviction_resilience_score"
col_raw = "eviction_risk_score"
label = "Eviction risk score (0–1, higher = more risk)"
if value_type.startswith("Opportunity"):
color_col = col_norm
legend_name = f"{group} (score 0–1)"
else:
color_col = col_raw
legend_name = label
# ----- SAFETY CHECKS -----
if color_col not in gdf.columns:
st.warning(
f"The column **{color_col}** is not available in the current data. "
"This may happen if the file is from an older version of the pipeline. "
"Try restarting after deleting old files in the `outputs/` folder, or pick a different topic."
)
return
plot_gdf = gdf[~gdf[color_col].isna()].copy()
if plot_gdf.empty:
st.warning(
"No data are available yet for this topic. "
"Try picking a different item in the sidebar."
)
return
# ----- MAIN LAYOUT: MAP + TABLE -----
col_map, col_table = st.columns([2, 1], gap="large")
with col_map:
st.subheader("Map")
m = make_choropleth(plot_gdf, color_col=color_col, legend_name=legend_name)
st_folium(m, width="100%", height=600)
with col_table:
st.subheader("Neighborhood list")
df_table = plot_gdf[["GEOID", "NAME", color_col]].copy()
df_table = df_table.sort_values(color_col, ascending=False).reset_index(drop=True)
df_table.index = df_table.index + 1
df_table.rename(
columns={
"NAME": "Tract name",
color_col: legend_name,
},
inplace=True,
)
st.dataframe(
df_table,
use_container_width=True,
hide_index=False,
)
st.caption(
"If you pick **Opportunity score**, higher numbers mean better access and lower hardship. "
"If you pick **Original value**, you’ll see the raw numbers (percentages, dollar amounts, or distances) "
"for each neighborhood."
)
# ----- FOOTER / METHODS BLURB -----
with st.expander("How this map was made", expanded=False):
st.markdown(
"""
**Where the data come from**
- Public data from the U.S. Census Bureau’s American Community Survey ({})
- We look at each small area (“census tract”) in Bernalillo County.
**What we measure**
- **Internet access:** share of households that have any kind of home internet subscription
- **Housing costs:** share of households paying 30% or more of their income on housing
- **Health coverage:** share of people who do *not* have health insurance
- **Poverty:** share of people living below the federal poverty line
- **Income:** typical (median) household income in dollars
- **Disability:** share of people living with a disability
- **Education:** share of adults (25+) with a high school diploma, GED, or higher
- **Eviction risk (proxy):** a 0–1 score built from rent burden and poverty
**How the score works**
- For each measure, we put neighborhoods on the same 0–1 scale so they can be compared.
- Areas with **better internet**, **lower housing burden**, **more people insured**, **less poverty**,
**higher incomes**, **fewer disability-related barriers**, and **higher education levels** get **higher scores**.
- The overall opportunity score is the average of all of these pieces.
In short: **higher scores = more opportunity and easier access to basics.**
""".format(ACS_YEAR)
)
if __name__ == "__main__":
main()