|
|
|
|
|
|
| |
| |
|
|
| import os |
| import re |
| import ast |
| import numpy as np |
| import pandas as pd |
| import streamlit as st |
| import plotly.express as px |
|
|
| |
| from sentence_transformers import SentenceTransformer |
| from transformers import pipeline |
| from sklearn.metrics.pairwise import cosine_similarity |
| from sklearn.feature_extraction.text import TfidfVectorizer |
|
|
| os.environ["TOKENIZERS_PARALLELISM"] = "false" |
|
|
| |
| |
| |
| st.set_page_config( |
| page_title="U of I Legislation Impact Dashboard", |
| page_icon="📊", |
| layout="wide", |
| initial_sidebar_state="collapsed", |
| ) |
|
|
| |
| |
| |
| ILLINI_BLUE = "#13294B" |
| ILLINI_ORANGE = "#FF552E" |
| ILLINI_ALT_BLUE = "#1E3877" |
| ILLINI_LIGHT = "#E8EDF5" |
| TEXT_DARK = "#0B1220" |
|
|
| |
| |
| |
| st.markdown( |
| f""" |
| <style> |
| .block-container {{ |
| padding-top: 2.0rem !important; |
| padding-bottom: 1.0rem !important; |
| }} |
| header[data-testid="stHeader"] {{ |
| height: 0.25rem; |
| }} |
| .main {{ |
| background: linear-gradient(180deg, #FFFFFF 0%, {ILLINI_LIGHT} 100%); |
| }} |
| .uofi-banner {{ |
| margin-top: 0.65rem; |
| background: {ILLINI_BLUE}; |
| color: white; |
| padding: 20px 22px; |
| border-radius: 14px; |
| font-weight: 950; |
| font-size: clamp(24px, 2.8vw, 34px); |
| letter-spacing: 0.2px; |
| margin-bottom: 8px; |
| box-shadow: 0 8px 22px rgba(19,41,75,0.18); |
| white-space: normal !important; |
| overflow: visible !important; |
| line-height: 1.18; |
| word-break: break-word; |
| }} |
| .uofi-sub {{ |
| margin-top: 8px; |
| font-size: 13px; |
| opacity: 0.92; |
| font-weight: 600; |
| white-space: normal !important; |
| overflow: visible !important; |
| }} |
| /* KPI cards */ |
| .kpi-card {{ |
| background: white; |
| border-radius: 14px; |
| padding: 12px 12px; |
| border: 1px solid rgba(19,41,75,0.10); |
| box-shadow: 0 8px 18px rgba(19,41,75,0.08); |
| min-height: 84px; |
| }} |
| .kpi-title {{ |
| font-size: 13px; |
| color: rgba(11,18,32,0.72); |
| font-weight: 900; |
| }} |
| .kpi-value {{ |
| font-size: 26px; |
| font-weight: 950; |
| color: {ILLINI_BLUE}; |
| margin-top: 2px; |
| }} |
| .kpi-foot {{ |
| font-size: 12px; |
| color: rgba(11,18,32,0.60); |
| margin-top: 2px; |
| }} |
| .kpi-accent {{ |
| color: {ILLINI_ORANGE}; |
| font-weight: 950; |
| }} |
| /* Section titles */ |
| .section-title {{ |
| font-size: 15px; |
| font-weight: 950; |
| color: white; |
| background: {ILLINI_BLUE}; |
| padding: 8px 10px; |
| border-radius: 12px; |
| margin: 4px 0 8px 0; |
| box-shadow: 0 8px 18px rgba(19,41,75,0.10); |
| }} |
| /* Panels */ |
| .panel {{ |
| background: white; |
| border-radius: 14px; |
| padding: 10px; |
| border: 1px solid rgba(19,41,75,0.10); |
| box-shadow: 0 8px 18px rgba(19,41,75,0.08); |
| }} |
| .stVerticalBlock {{ |
| gap: 0.28rem !important; |
| }} |
| div[data-testid="stDataFrame"] * {{ |
| white-space: normal !important; |
| }} |
| /* Full width download button without deprecated args */ |
| div[data-testid="stDownloadButton"] > button {{ |
| width: 100% !important; |
| }} |
| </style> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| st.markdown( |
| f""" |
| <style> |
| |
| /* ============================ |
| FILTER ROW: bigger labels + bold |
| ============================ */ |
| |
| /* Make ALL widget labels bigger + bold */ |
| div[data-testid="stWidgetLabel"] > label {{ |
| font-size: 16px !important; |
| font-weight: 900 !important; |
| color: {ILLINI_BLUE} !important; |
| }} |
| |
| /* Increase spacing between the 3 filter columns */ |
| div[data-testid="column"] {{ |
| padding-left: 8px !important; |
| padding-right: 8px !important; |
| }} |
| |
| /* Make the Year range slider values (2019 / 2026) more readable */ |
| div[data-testid="stSlider"] {{ |
| font-size: 14px !important; |
| font-weight: 700 !important; |
| }} |
| |
| /* Make selectbox + multiselect text slightly bigger */ |
| div[data-baseweb="select"] * {{ |
| font-size: 15px !important; |
| font-weight: 700 !important; |
| }} |
| |
| /* Multi-select chips ("Pending", "Enacted") bolder */ |
| span[data-baseweb="tag"] {{ |
| font-weight: 900 !important; |
| font-size: 14px !important; |
| }} |
| |
| /* Reduce the weird extra top whitespace around widgets */ |
| section[data-testid="stSidebar"] {{ |
| display: none !important; |
| }} |
| |
| </style> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
|
|
| |
| |
| |
| def safe_col(df, candidates): |
| for c in candidates: |
| if c in df.columns: |
| return c |
| return None |
|
|
| def chamber_from_bill_id(bid): |
| if pd.isna(bid): |
| return np.nan |
| s = str(bid).strip() |
| parts = s.split() |
| if len(parts) >= 2: |
| return {"S": "Senate", "H": "House"}.get(parts[1], np.nan) |
| return np.nan |
|
|
| def party_from_author(author): |
| if pd.isna(author): |
| return np.nan |
| m = re.search(r"\((D|R)\)\s*$", str(author).strip(), flags=re.I) |
| if not m: |
| return np.nan |
| return m.group(1).upper() |
|
|
| def to_dt(series): |
| return pd.to_datetime(series, errors="coerce") |
|
|
| def style_plotly(fig): |
| fig.update_layout( |
| template="plotly_white", |
| font=dict(color=TEXT_DARK, size=11), |
| margin=dict(l=10, r=10, t=46, b=10), |
| legend=dict( |
| orientation="h", |
| yanchor="bottom", |
| y=1.02, |
| xanchor="right", |
| x=1, |
| font=dict(size=10), |
| ), |
| title=dict(font=dict(size=14)), |
| ) |
| return fig |
|
|
| def clean_text(x): |
| if pd.isna(x): |
| return "" |
| return re.sub(r"\s+", " ", str(x)).strip() |
|
|
| def parse_listish(x): |
| if pd.isna(x): |
| return [] |
| if isinstance(x, list): |
| return [str(t).strip() for t in x if str(t).strip()] |
| s = str(x).strip() |
| if not s: |
| return [] |
| try: |
| v = ast.literal_eval(s) |
| if isinstance(v, list): |
| return [str(t).strip() for t in v if str(t).strip()] |
| except Exception: |
| pass |
| return [t.strip() for t in s.split(",") if t.strip()] |
|
|
| def enforce_two_sentences(text: str) -> str: |
| text = (text or "").strip() |
| sents = re.split(r"(?<=[.!?])\s+", text) |
| sents = [s.strip() for s in sents if s.strip()] |
| return " ".join(sents[:2]).strip() |
|
|
| |
| |
| |
| @st.cache_data(show_spinner=False) |
| def load_data(): |
| candidates = [ |
| "illinois_legislation_VIZ_READY.csv", |
| "/mnt/data/illinois_legislation_VIZ_READY.csv", |
| "illinois_postsecondary_legislation.csv", |
| "/mnt/data/illinois_postsecondary_legislation.csv", |
| ] |
| path = None |
| for p in candidates: |
| if os.path.exists(p): |
| path = p |
| break |
| if path is None: |
| raise FileNotFoundError("Could not find the viz-ready CSV in the app directory.") |
| df_ = pd.read_csv(path) |
| return df_, os.path.basename(path) |
|
|
| raw, filename = load_data() |
| df = raw.copy() |
|
|
| |
| |
| |
| if "year" not in df.columns: |
| st.error("Missing required column: year") |
| st.stop() |
|
|
| df["year"] = pd.to_numeric(df["year"], errors="coerce") |
| df = df[df["year"].between(2019, 2026, inclusive="both")].copy() |
|
|
| |
| if "chamber" not in df.columns: |
| if "bill_id" in df.columns: |
| df["chamber"] = df["bill_id"].apply(chamber_from_bill_id) |
| else: |
| df["chamber"] = np.nan |
|
|
| |
| date_col = safe_col(df, ["last_action_date_parsed", "last_action_date_dt", "last_action_date_clean", "last_action_date"]) |
| if date_col is None: |
| st.error("Missing last action date column (last_action_date*)") |
| st.stop() |
|
|
| if "last_action_date_parsed" not in df.columns: |
| df["last_action_date_parsed"] = to_dt(df[date_col]) |
| else: |
| df["last_action_date_parsed"] = to_dt(df["last_action_date_parsed"]) |
|
|
| NOW = pd.Timestamp("2026-01-23") |
| df["days_since_last_action"] = (NOW - df["last_action_date_parsed"]).dt.days |
| df["is_recent_90d"] = df["days_since_last_action"].between(0, 90, inclusive="both") |
|
|
| |
| status_stage_col = safe_col(df, ["status_stage", "status_label"]) |
| if status_stage_col is None: |
| if "status" in df.columns: |
| s = df["status"].fillna("").astype(str).str.lower() |
| df["status_stage"] = np.where( |
| s.str.contains("enacted|signed|public act"), "Enacted", |
| np.where(s.str.contains("pending"), "Pending", np.nan), |
| ) |
| status_stage_col = "status_stage" |
| else: |
| df["status_stage"] = np.nan |
| status_stage_col = "status_stage" |
|
|
| df[status_stage_col] = df[status_stage_col].astype(str).str.strip() |
| df[status_stage_col] = df[status_stage_col].replace({"pending": "Pending", "enacted": "Enacted"}) |
|
|
| |
| party_col = safe_col(df, ["primary_author_party"]) |
| if party_col is None: |
| a_col = safe_col(df, ["author_clean", "author"]) |
| if a_col: |
| df["primary_author_party"] = df[a_col].apply(party_from_author) |
| party_col = "primary_author_party" |
| else: |
| df["primary_author_party"] = np.nan |
| party_col = "primary_author_party" |
|
|
| |
| title_bucket_col = safe_col(df, ["title_std_bucket", "title_standardized", "title_nlp", "title_bucket"]) |
| if title_bucket_col is None: |
| df["title_std_bucket"] = df["title"].fillna("").astype(str) if "title" in df.columns else "No Title" |
| title_bucket_col = "title_std_bucket" |
|
|
| |
| policy_col = safe_col(df, ["policy_area_bucket"]) |
| if policy_col is None: |
| df["policy_area_bucket"] = "Not Available" |
| policy_col = "policy(override)" |
|
|
| |
| stake_col = safe_col(df, ["stakeholder_group", "stakeholder_group_bucket", "stakeholder_bucket", "stakeholders_bucket", "stakeholder"]) |
| if stake_col is None: |
| df["stakeholder_group"] = "Not Available" |
| stake_col = "stakeholder_group" |
|
|
| benef_col = safe_col(df, ["beneficiary_type", "beneficiary_bucket", "beneficiary_category", "intended_beneficiaries_standardized_final", "intended_beneficiaries_bucket"]) |
| if benef_col is None: |
| df["beneficiary_type"] = "Not Available" |
| benef_col = "beneficiary_type" |
|
|
| |
| if "bill_age_days" not in df.columns: |
| df["bill_age_days"] = df["days_since_last_action"] |
|
|
| if "action_recency_bucket" not in df.columns: |
| bins = [-1, 30, 90, 180, 365, 999999] |
| labels = ["0–30d", "31–90d", "91–180d", "181–365d", "365d+"] |
| df["action_recency_bucket"] = pd.cut(df["days_since_last_action"].fillna(999999), bins=bins, labels=labels) |
|
|
| for c in ["status_step", "pending_committee_name", "pending_chamber"]: |
| if c not in df.columns: |
| df[c] = "" |
|
|
| if "author_party_combo" not in df.columns: |
| df["author_party_combo"] = df[party_col].fillna("").astype(str) |
|
|
| if "sponsor_count" not in df.columns: |
| df["sponsor_count"] = np.nan |
|
|
| |
| |
| |
| st.markdown("<div style='height:6px'></div>", unsafe_allow_html=True) |
|
|
| st.markdown( |
| """ |
| <div class="uofi-banner"> |
| U of I Legislation Impact Dashboard |
| <div class="uofi-sub"> |
| 2019–2026 • Trends → Party share → Executive themes → Stakeholders → Beneficiaries → Impact view → Watchlist → Policy Domain Summary at a Glance |
| </div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| |
| |
| st.markdown( |
| f""" |
| <div class="panel"> |
| <div style="font-weight:900; color:{ILLINI_BLUE}; margin-bottom:6px;"> Data Source Link </div> |
| <div style="color:{TEXT_DARK}; font-size:13px; line-height:1.35;"> |
| This dashboard is made using the NCSL Postsecondary Legislation Database. |
| <br/> |
| <a href="https://www.ncsl.org/education/postsecondary-legislation-database" target="_blank" |
| style="color:{ILLINI_ORANGE}; font-weight:900; text-decoration:none;"> |
| Open NCSL Postsecondary Legislation Database ↗ |
| </a> |
| </div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| st.markdown("<div style='height:28px'></div>", unsafe_allow_html=True) |
|
|
| |
| |
| |
| f1, f2, f3 = st.columns([2.0, 2.0, 2.0], gap="large") |
|
|
| with f1: |
| years = sorted([int(y) for y in df["year"].dropna().unique()]) |
| ymin, ymax = (min(years), max(years)) if years else (2019, 2026) |
| year_range = st.slider("Year range", 2019, 2026, (max(2019, ymin), min(2026, ymax))) |
|
|
| with f2: |
| chamber_opts = ["All"] + [c for c in ["House", "Senate"] if c in df["chamber"].dropna().unique().tolist()] |
| sel_chamber = st.selectbox("Chamber", chamber_opts, index=0) |
|
|
| with f3: |
| status_opts = ["Pending", "Enacted"] |
| sel_status = st.multiselect("Status", options=status_opts, default=status_opts) |
|
|
| st.caption(f"Dashboard dataset: {filename}") |
|
|
| |
| f = df.copy() |
| f = f[f["year"].between(year_range[0], year_range[1], inclusive="both")] |
| if sel_chamber != "All": |
| f = f[f["chamber"] == sel_chamber] |
| f = f[f[status_stage_col].isin(sel_status)].copy() |
|
|
| |
| |
| |
| total_bills = len(f) |
| recent_90 = int(f["is_recent_90d"].fillna(False).sum()) |
| enacted_ct = int((f[status_stage_col] == "Enacted").sum()) |
| pending_ct = int((f[status_stage_col] == "Pending").sum()) |
| enact_rate = (enacted_ct / (enacted_ct + pending_ct)) if (enacted_ct + pending_ct) > 0 else 0.0 |
| stuck_pending_365 = int(((f[status_stage_col] == "Pending") & (f["days_since_last_action"] > 365)).sum()) |
|
|
| k1, k2, k3, k4 = st.columns(4, gap="small") |
|
|
| with k1: |
| st.markdown( |
| f""" |
| <div class="kpi-card"> |
| <div class="kpi-title">Bills in View</div> |
| <div class="kpi-value">{total_bills:,}</div> |
| <div class="kpi-foot">Filtered cohort</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| with k2: |
| st.markdown( |
| f""" |
| <div class="kpi-card"> |
| <div class="kpi-title">Recent (≤ 90 Days)</div> |
| <div class="kpi-value"><span class="kpi-accent">{recent_90:,}</span></div> |
| <div class="kpi-foot">Latest movement / momentum</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| with k3: |
| st.markdown( |
| f""" |
| <div class="kpi-card"> |
| <div class="kpi-title">Enactment Rate</div> |
| <div class="kpi-value">{enact_rate*100:,.1f}%</div> |
| <div class="kpi-foot">{enacted_ct:,} enacted vs {pending_ct:,} pending</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| with k4: |
| st.markdown( |
| f""" |
| <div class="kpi-card"> |
| <div class="kpi-title">Stuck at Pending Stage (> 365 Days)</div> |
| <div class="kpi-value">{stuck_pending_365:,}</div> |
| <div class="kpi-foot">Aging bills needing attention</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| |
| |
| c1, c2 = st.columns([1.55, 1.0], gap="small") |
|
|
| with c1: |
| st.markdown('<div class="section-title">Trend: Bills Over Time (Monthly)</div>', unsafe_allow_html=True) |
|
|
| ts = ( |
| f.dropna(subset=["last_action_date_parsed"]) |
| .assign(ym=lambda x: x["last_action_date_parsed"].dt.to_period("M").astype(str)) |
| .groupby("ym") |
| .size() |
| .reset_index(name="bills") |
| ) |
|
|
| if ts.empty: |
| st.info("No dated bills found for the selected filters.") |
| else: |
| ts["ym_dt"] = pd.to_datetime(ts["ym"], errors="coerce") |
| ts = ts.sort_values("ym_dt") |
|
|
| fig = px.line( |
| ts, |
| x="ym", |
| y="bills", |
| markers=True, |
| title="Bills per month (by last action date)", |
| color_discrete_sequence=[ILLINI_ORANGE], |
| ) |
| fig = style_plotly(fig) |
| fig.update_xaxes(title="", tickangle=0) |
| fig.update_yaxes(title="Bills") |
| st.plotly_chart(fig, width="stretch") |
|
|
| with c2: |
| st.markdown('<div class="section-title">Democrat vs Republican Share</div>', unsafe_allow_html=True) |
|
|
| p = f[party_col].fillna("").astype(str).str.upper() |
| p = p[p.isin(["D", "R"])] |
|
|
| if p.empty: |
| st.info("Party share not available for this filtered view.") |
| else: |
| pie_df = p.value_counts().reset_index() |
| pie_df.columns = ["party", "count"] |
| fig = px.pie( |
| pie_df, |
| names="party", |
| values="count", |
| hole=0.58, |
| title="Primary author party (D vs R)", |
| color="party", |
| color_discrete_map={"D": ILLINI_BLUE, "R": ILLINI_ORANGE}, |
| ) |
| fig = style_plotly(fig) |
| fig.update_traces(textposition="inside", textinfo="percent+label", textfont=dict(size=11)) |
| st.plotly_chart(fig, width="stretch") |
|
|
| |
| |
| |
| r2a, r2b = st.columns([1.05, 1.2], gap="small") |
|
|
| with r2b: |
| st.markdown('<div class="section-title">Executive Themes: Policy Areas</div>', unsafe_allow_html=True) |
|
|
| policy_counts = ( |
| f[policy_col].astype(str).str.strip().replace({"": np.nan}).dropna() |
| .value_counts().head(9).reset_index() |
| ) |
| policy_counts.columns = ["policy_area_bucket", "bills"] |
|
|
| if policy_counts.empty: |
| st.info("Policy areas not available for this filtered view.") |
| else: |
| fig1 = px.treemap( |
| policy_counts, |
| path=["policy_area_bucket"], |
| values="bills", |
| title="Policy area concentration", |
| color_discrete_sequence=[ILLINI_BLUE, ILLINI_ALT_BLUE, ILLINI_ORANGE], |
| ) |
| fig1 = style_plotly(fig1) |
| st.plotly_chart(fig1, width="stretch") |
|
|
| with r2a: |
| st.markdown('<div class="section-title">Stakeholder Themes: Who is affected?</div>', unsafe_allow_html=True) |
|
|
| stake_counts = ( |
| f[stake_col].astype(str).str.strip().replace({"": np.nan}).dropna() |
| .value_counts().head(5).reset_index() |
| ) |
| stake_counts.columns = ["stakeholder_group", "bills"] |
|
|
| if stake_counts.empty: |
| st.info("Stakeholder themes not available for this filtered view.") |
| else: |
| fig7 = px.pie( |
| stake_counts, |
| names="stakeholder_group", |
| values="bills", |
| title="Stakeholder share (pie)", |
| color_discrete_sequence=[ILLINI_ALT_BLUE, ILLINI_BLUE, ILLINI_ORANGE, ILLINI_ALT_BLUE, ILLINI_BLUE], |
| ) |
| fig7 = style_plotly(fig7) |
| fig7.update_layout(showlegend=False) |
| fig7.update_traces(textposition="inside", textinfo="percent+label", textfont=dict(size=12)) |
| st.plotly_chart(fig7, width="stretch") |
|
|
| |
| |
| |
| r3a, r3b = st.columns([1.0, 1.55], gap="small") |
|
|
| with r3a: |
| st.markdown('<div class="section-title">Beneficiary Themes: Who benefits?</div>', unsafe_allow_html=True) |
|
|
| benef_counts = ( |
| f[benef_col].astype(str).str.strip().replace({"": np.nan}).dropna() |
| .value_counts().head(8).reset_index() |
| ) |
| benef_counts.columns = ["beneficiary_type", "bills"] |
|
|
| if benef_counts.empty: |
| st.info("Beneficiary themes not available for this filtered view.") |
| else: |
| fig8 = px.bar( |
| benef_counts.sort_values("bills", ascending=True), |
| x="bills", y="beneficiary_type", |
| orientation="h", |
| title="Beneficiary types", |
| color_discrete_sequence=[ILLINI_ALT_BLUE], |
| ) |
| fig8 = style_plotly(fig8) |
| fig8.update_yaxes(title="", automargin=True) |
| fig8.update_xaxes(title="Bills") |
| st.plotly_chart(fig8, width="stretch") |
|
|
| with r3b: |
| st.markdown('<div class="section-title">Impact View: Pending vs Enacted by Policy Area</div>', unsafe_allow_html=True) |
|
|
| top_policy = ( |
| f[policy_col].astype(str).str.strip().replace({"": np.nan}).dropna() |
| .value_counts().head(12).index.tolist() |
| ) |
|
|
| stage_policy = ( |
| f[f[policy_col].isin(top_policy)] |
| .groupby([policy_col, status_stage_col]) |
| .size() |
| .reset_index(name="bills") |
| ) |
|
|
| if stage_policy.empty: |
| st.info("Not enough data to build the impact view for this filtered view.") |
| else: |
| order = ( |
| stage_policy.groupby(policy_col)["bills"].sum() |
| .sort_values(ascending=False) |
| .index.tolist() |
| ) |
|
|
| fig_u1 = px.bar( |
| stage_policy, |
| x=policy_col, |
| y="bills", |
| color=status_stage_col, |
| barmode="stack", |
| category_orders={policy_col: order}, |
| title="Status composition by policy area", |
| color_discrete_map={"Pending": ILLINI_ORANGE, "Enacted": ILLINI_BLUE}, |
| ) |
| fig_u1 = style_plotly(fig_u1) |
| fig_u1.update_xaxes(title="", tickangle=25) |
| fig_u1.update_yaxes(title="Bills") |
| st.plotly_chart(fig_u1, width="stretch") |
|
|
| |
| |
| |
| st.markdown('<div class="section-title">Watchlist: Most Recent Pending Bills</div>', unsafe_allow_html=True) |
|
|
| watch = f[f[status_stage_col] == "Pending"].copy() |
| watch["last_action_date_parsed"] = pd.to_datetime(watch["last_action_date_parsed"], errors="coerce") |
| watch = watch.dropna(subset=["last_action_date_parsed"]).sort_values("last_action_date_parsed", ascending=False) |
|
|
| watch_cols = [ |
| "bill_id","year","chamber", |
| policy_col, |
| "status", "status_step", |
| "last_action_date_parsed", |
| "primary_author_party", |
| |
| ] |
| watch_cols = [c for c in watch_cols if c in watch.columns] |
|
|
| col_cfg_watch = {} |
| if "summary" in watch_cols: |
| col_cfg_watch["summary"] = st.column_config.TextColumn("summary", width="large") |
| if title_bucket_col in watch_cols: |
| col_cfg_watch[title_bucket_col] = st.column_config.TextColumn(title_bucket_col, width="large") |
|
|
| st.dataframe( |
| watch.head(15)[watch_cols], |
| width="stretch", |
| hide_index=True, |
| column_config=col_cfg_watch, |
| height=380, |
| ) |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
|
|
| |
| |
| |
| st.markdown('<div class="section-title">Policy Domain Summary at a Glance</div>', unsafe_allow_html=True) |
| st.markdown("<div style='height:20px;'></div>", unsafe_allow_html=True) |
| st.caption("Select a policy area domain to view a brief 2-sentence summary.") |
|
|
| POLICY_DOMAIN_SUMMARY = { |
| "Admissions & Enrollment": ( |
| "This domain includes legislation related to admission requirements, transfer policies, enrollment rules, and institutional access pathways. " |
| "It covers processes that affect student entry, eligibility standards, and campus-level enrollment administration." |
| ), |
| "Appropriations & Budget": ( |
| "This domain covers legislation affecting higher education funding, appropriations, budget structures, and fiscal mandates. " |
| "It includes items connected to state allocations, compliance costs, program funding, and financial reporting requirements." |
| ), |
| "Athletics & NIL": ( |
| "This domain includes legislation related to intercollegiate athletics governance, student-athlete participation, and NIL policies. " |
| "It involves compliance requirements, athlete protections, disclosures, and institutional responsibilities." |
| ), |
| "Campus Safety & Title IX": ( |
| "This domain includes legislation tied to campus safety policies, Title IX requirements, Clery reporting, and student conduct procedures. " |
| "It covers institutional reporting expectations, investigations, training standards, and procedural compliance." |
| ), |
| "Data/Reporting/Accountability": ( |
| "This domain includes legislation involving institutional reporting, data collection standards, accountability measures, and audit requirements. " |
| "It relates to performance metrics, public disclosures, compliance reporting, and system-wide data governance considerations." |
| ), |
| "Dual Credit & College Readiness": ( |
| "This domain includes legislation related to dual credit programs, college readiness initiatives, and partnerships with K–12 districts. " |
| "It covers articulation policies, credit transfer alignment, eligibility requirements, and academic pathway structures." |
| ), |
| "Financial Aid & Scholarships": ( |
| "This domain includes legislation tied to financial aid eligibility, scholarship programs, award administration, and student affordability. " |
| "It addresses funding mechanisms, qualifying criteria, program rules, and aid-related reporting requirements." |
| ), |
| "Governance & Oversight": ( |
| "This domain includes legislation affecting institutional governance structures, oversight authority, and board-related processes. " |
| "It involves decision-making frameworks, administrative authority, compliance monitoring, and policy control responsibilities." |
| ), |
| "Mental Health & Wellness": ( |
| "This domain includes legislation related to student mental health services, wellness resources, and mandated program initiatives. " |
| "It covers service capacity, staffing requirements, program reporting, and institutional support frameworks." |
| ), |
| "Other Postsecondary Policy": ( |
| "This domain groups postsecondary legislation that does not fit cleanly into the other standard categories. " |
| "It includes mixed policy areas spanning compliance, student programs, operations, or administrative requirements." |
| ), |
| "Student Rights & Protections": ( |
| "This domain includes legislation affecting student rights, protections, grievance processes, and academic policy standards. " |
| "It covers nondiscrimination policy, procedural safeguards, enforcement expectations, and campus-level student support rules." |
| ), |
| "Tax Credits & Deductions": ( |
| "This domain includes legislation related to tax credits, deductions, and financial incentives connected to education costs. " |
| "It influences affordability mechanisms, eligibility definitions, and financial guidance associated with postsecondary participation." |
| ), |
| "Tuition & Fees": ( |
| "This domain includes legislation affecting tuition structures, mandatory fees, program charges, and cost-setting constraints. " |
| "It includes provisions related to fee caps, tuition regulation, and campus revenue planning requirements." |
| ), |
| "Workforce & Career Readiness": ( |
| "This domain includes legislation tied to workforce pipelines, credentialing programs, internships, and employer partnerships. " |
| "It connects higher education programs to statewide workforce priorities, training requirements, and outcomes tracking." |
| ), |
| } |
|
|
| |
| policy_options = sorted( |
| f[policy_col].dropna().astype(str).str.strip().replace({"": np.nan}).dropna().unique().tolist() |
| ) |
|
|
| if not policy_options: |
| st.info("No policy areas available in the current filtered view.") |
| else: |
| sel_policy = st.selectbox("Policy area domain", policy_options, index=0) |
|
|
| |
| summary = POLICY_DOMAIN_SUMMARY.get( |
| sel_policy, |
| "No 2-sentence summary is available for this policy area yet." |
| ) |
| st.success(summary) |
|
|
| st.markdown("---") |
| st.caption("Download the filtered dataset used to build this dashboard:") |
|
|
|
|
| st.download_button( |
| "⬇️ Download filtered dashboard data (CSV)", |
| data=f.to_csv(index=False).encode("utf-8"), |
| file_name="uofi_legislation_filtered_2019_2026.csv", |
| mime="text/csv", |
| ) |
|
|
| st.caption("Love Data Week 2026 • University of Illinois System • Streamlit (HF Spaces)") |
|
|