""" 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 "" 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()