| import streamlit as st |
| import pandas as pd |
| import altair as alt |
| import json |
| import urllib.request |
|
|
| st.set_page_config(page_title="Crimes in Chicago 2026", page_icon="🚨", layout="wide") |
| st.title("Crimes in Chicago - 2026") |
| st.markdown("**Authors: Xinyi Chen, Zhongyin Wang** - Group 6") |
| st.markdown("---") |
| st.markdown( |
| """ |
| ## What Is This About? |
| Every day, hundreds of crime incidents are reported across Chicago's 77 community areas. |
| But where do they happen? At what time? And does poverty play a role? |
| This interactive article walks you through 2026 Chicago crime data drawn directly from |
| the [Chicago Data Portal](https://data.cityofchicago.org/) to help you explore the |
| geography, timing, and social context of crime in one of America's largest cities. |
| The dataset records every reported crime incident in 2026, including the exact location, |
| date and time, crime type, and the police district that handled it. Each row is one |
| reported incident. We also include community-level socioeconomic data to examine the |
| relationship between poverty and crime rates across Chicago's neighborhoods. |
| """ |
| ) |
|
|
| |
| |
| |
| @st.cache_data(show_spinner="Loading local Chicago crime data...") |
| def load_crime_data(): |
| """Robust loading for Hugging Face Spaces (handles path issues).""" |
| import os |
|
|
| try: |
| |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| file_path = os.path.join(BASE_DIR, "Crimes_-_2026_20260417.csv") |
|
|
| |
| df = pd.read_csv(file_path) |
|
|
| |
| df.columns = [c.lower().replace(" ", "_") for c in df.columns] |
|
|
| except FileNotFoundError: |
| st.error("❌ CSV file not found. Check filename and path.") |
| return pd.DataFrame() |
| except Exception as e: |
| st.error(f"❌ Failed to read CSV: {e}") |
| return pd.DataFrame() |
|
|
| |
| df["date"] = pd.to_datetime(df["date"], errors="coerce") |
|
|
| for col in ["latitude", "longitude"]: |
| df[col] = pd.to_numeric(df.get(col, pd.Series(dtype=float)), errors="coerce") |
|
|
| df = df.dropna(subset=["date"]) |
|
|
| df["Date_Only"] = df["date"].dt.floor("d") |
| df["Hour"] = df["date"].dt.hour |
| df["weekday"] = df["date"].dt.day_name().str[:3] |
|
|
| df["Primary Type"] = ( |
| df["primary_type"].str.upper() |
| if "primary_type" in df.columns else "UNKNOWN" |
| ) |
|
|
| if "district" in df.columns: |
| df["District_Str"] = ( |
| pd.to_numeric(df["district"], errors="coerce") |
| .fillna(-1).astype(int).astype(str) |
| ) |
| df["District"] = df["District_Str"] |
| else: |
| df["District_Str"] = df["District"] = "-1" |
|
|
| if "community_area" not in df.columns: |
| df["community_area"] = None |
|
|
| def get_period(h): |
| if 6 < h <= 12: |
| return "Morning (6am-12pm)" |
| elif 12 < h <= 18: |
| return "Afternoon (12pm-6pm)" |
| elif 18 < h <= 24: |
| return "Evening (6pm-12am)" |
| else: |
| return "Late Night (12am-6am)" |
|
|
| df["Period"] = df["Hour"].apply(get_period) |
|
|
| return df |
|
|
| @st.cache_data(show_spinner="Loading socioeconomic data...") |
| def load_socio(): |
| try: |
| df = pd.read_json("https://data.cityofchicago.org/resource/kn9c-c2s2.json") |
| df = df.dropna(subset=["ca"]) |
| df["ca"] = df["ca"].astype(float).astype(int).astype(str) |
| df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce") |
| return df |
| except Exception as e: |
| st.warning(f"Could not load socioeconomic data: {e}") |
| return pd.DataFrame(columns=["ca", "community_area_name", "poverty_rate"]) |
|
|
| @st.cache_data(show_spinner="Loading boundaries...") |
| def load_geojson(url): |
| try: |
| with urllib.request.urlopen(url) as r: |
| return json.loads(r.read()) |
| except Exception as e: |
| st.warning(f"Could not load GeoJSON: {e}") |
| return {"features": []} |
|
|
| district_geojson = load_geojson("https://data.cityofchicago.org/resource/24zt-jpfn.geojson") |
| community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8jzy.geojson") |
|
|
| df = load_crime_data() |
| df_socio = load_socio() |
| districts = alt.Data(values=district_geojson["features"]) |
| communities = alt.Data(values=community_geojson["features"]) |
|
|
| if df.empty: |
| st.error("Crime data could not be loaded.") |
| st.stop() |
|
|
| df_geo = df.dropna(subset=["latitude", "longitude"]).copy() |
| st.info(f"Loaded **{len(df):,}** crime records for 2026 ({len(df_geo):,} with coordinates).") |
|
|
| |
| |
| |
| st.markdown("---") |
| st.header("Interactive Crime Dashboard") |
| st.markdown( |
| """ |
| This dashboard lets you explore Chicago crime data across three linked views. |
| **Drag a box on the map** to select a geographic area, or **click a district boundary** |
| to highlight it — both actions filter the bar chart on the right and the timeline below. |
| You can also **click a crime category** in the bar chart to drill into its temporal trend. |
| |
| *(Note: If the map points look like a strict grid, it is because the Chicago Police Department |
| anonymizes crime locations to the nearest block level, aligning perfectly with Chicago's grid street system!)* |
| """ |
| ) |
|
|
| brush = alt.selection_interval(name="brush") |
| click_type = alt.selection_point(fields=["Primary Type"], name="click_type") |
| click_dist = alt.selection_point(fields=["District_Str"], name="click_dist") |
| MAP_SAMPLE = 5000 |
| df_map_sample = df_geo.sample(min(MAP_SAMPLE, len(df_geo)), random_state=42) |
|
|
| background = ( |
| alt.Chart(districts) |
| .mark_geoshape(stroke="black", strokeWidth=0.6) |
| .transform_calculate(District_Str="datum.properties.dist_num") |
| .encode( |
| color=alt.condition(click_dist, alt.value("white"), alt.value("grey")), |
| opacity=alt.condition(click_dist, alt.value(0.5), alt.value(0.8)), |
| tooltip=[alt.Tooltip("properties.dist_num:N", title="District")], |
| ) |
| .add_params(click_dist) |
| ) |
|
|
| geo_points = ( |
| alt.Chart(df_map_sample) |
| .mark_circle(size=5) |
| .encode( |
| longitude="longitude:Q", |
| latitude="latitude:Q", |
| color=alt.condition( |
| click_dist, |
| alt.Color("District:N", scale=alt.Scale(scheme="tableau10"), |
| legend=alt.Legend(title="District", orient="right")), |
| alt.value("#e0dbd6"), |
| ), |
| opacity=alt.condition(click_dist, alt.value(0.6), alt.value(0.05)), |
| tooltip=[ |
| alt.Tooltip("Primary Type:N", title="Crime Type"), |
| alt.Tooltip("District:N", title="District"), |
| alt.Tooltip("date:T", title="Date"), |
| ], |
| ) |
| .add_params(brush) |
| ) |
|
|
| map_layer = (background + geo_points).project(type="mercator").properties( |
| width=420, height=450, |
| title=f"Chicago Crime Map (map shows {MAP_SAMPLE:,} sampled points for performance)", |
| ) |
|
|
| |
| type_chart = ( |
| alt.Chart(df) |
| .mark_bar() |
| .encode( |
| x=alt.X("count():Q", title="Number of Crimes"), |
| y=alt.Y("Primary Type:N", sort="-x", title="Crime Type"), |
| color=alt.condition(click_type, alt.value("steelblue"), alt.value("lightgray")), |
| tooltip=["Primary Type:N", "count():Q"], |
| ) |
| .properties(width=300, height=450, title="Crime Types (full dataset)") |
| .add_params(click_type) |
| .transform_filter(brush) |
| .transform_filter(click_dist) |
| ) |
|
|
| |
| period_order = ["Morning (6am-12pm)", "Afternoon (12pm-6pm)", |
| "Evening (6pm-12am)", "Late Night (12am-6am)", "Total Daily"] |
| period_range = ["#f4a261", "#e9c46a", "#e76f51", "#264653", "grey"] |
|
|
| period_lines = ( |
| alt.Chart(df) |
| .mark_line(point=False, strokeWidth=1.5) |
| .encode( |
| x=alt.X("Date_Only:T", title="Timeline"), |
| y=alt.Y("count:Q", title="Number of Incidents", scale=alt.Scale(zero=True)), |
| color=alt.Color( |
| "Period:N", |
| scale=alt.Scale(domain=period_order, range=period_range), |
| legend=alt.Legend(title="Time of Day", orient="right"), |
| ), |
| tooltip=[ |
| alt.Tooltip("Date_Only:T", title="Date"), |
| alt.Tooltip("Period:N", title="Period"), |
| alt.Tooltip("count:Q", title="Incidents"), |
| ], |
| ) |
| .transform_filter(brush) |
| .transform_filter(click_type) |
| .transform_filter(click_dist) |
| .transform_aggregate(count="count()", groupby=["Date_Only", "Period"]) |
| .transform_impute(impute="count", key="Date_Only", groupby=["Period"], value=0) |
| ) |
|
|
| total_line = ( |
| alt.Chart(df) |
| .mark_line(opacity=0.5) |
| .encode( |
| x=alt.X("Date_Only:T"), |
| y=alt.Y("count():Q"), |
| color=alt.datum("Total Daily"), |
| tooltip=[ |
| alt.Tooltip("Date_Only:T", title="Date"), |
| alt.Tooltip("count():Q", title="Total Incidents"), |
| ], |
| ) |
| .transform_filter(brush) |
| .transform_filter(click_type) |
| .transform_filter(click_dist) |
| ) |
|
|
| line_chart = (total_line + period_lines).properties( |
| width=760, height=220, |
| title="Daily Crime Trend by Time of Day (full dataset)", |
| ).resolve_scale(color="shared") |
|
|
| dashboard = ((map_layer | type_chart) & line_chart).resolve_scale(color="independent") |
| st.altair_chart(dashboard, use_container_width=True) |
|
|
| |
| |
| |
| st.markdown("---") |
| st.header("When Do Crimes Happen in Chicago?") |
| st.markdown( |
| """ |
| Different crimes follow different schedules. Use the **dropdown below** to filter |
| the heatmap by crime category. |
| *(This is now fully interactive in your browser, filtering happens instantly without lag!)* |
| """ |
| ) |
|
|
| |
| top_types_hm = df["Primary Type"].value_counts().head(10).index.tolist() |
|
|
| |
| hm_agg = ( |
| df.dropna(subset=["Primary Type"]) |
| .groupby(["Primary Type", "weekday", "Hour"]) |
| .size() |
| .reset_index(name="crime_count") |
| ) |
|
|
| |
| dropdown = alt.binding_select( |
| options=[None] + top_types_hm, |
| labels=["All"] + top_types_hm, |
| name="Filter by Crime Type: " |
| ) |
| type_select = alt.selection_point(fields=["Primary Type"], bind=dropdown) |
|
|
| weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] |
|
|
| heatmap = ( |
| alt.Chart(hm_agg) |
| .mark_rect() |
| .encode( |
| x=alt.X("weekday:N", sort=weekday_order, title="Day of Week"), |
| y=alt.Y("Hour:O", title="Hour of Day (0-23)", sort="ascending"), |
| |
| color=alt.Color("sum(crime_count):Q", scale=alt.Scale(scheme="reds"), title="Number of Crimes"), |
| tooltip=[ |
| alt.Tooltip("weekday:N", title="Day"), |
| alt.Tooltip("Hour:O", title="Hour"), |
| alt.Tooltip("sum(crime_count):Q", title="Total Crimes"), |
| ], |
| ) |
| .add_params(type_select) |
| .transform_filter(type_select) |
| .properties( |
| width=700, height=380, |
| title="Crime Heatmap (Instantly filterable)", |
| ) |
| ) |
| st.altair_chart(heatmap, use_container_width=True) |
|
|
| |
| |
| |
| st.markdown("---") |
| st.header("Does Poverty Predict Crime?") |
| st.markdown( |
| """ |
| Socioeconomic inequality is one of the most studied predictors of crime at the |
| neighborhood level. The choropleth map on the left shades each of Chicago's 77 |
| community areas by their poverty rate - darker orange means higher poverty - |
| with a binned crime density heatmap overlaid. The heatmap uses the full dataset |
| with no sampling: each cell's color reflects how many incidents fall in that |
| geographic bin, giving a clear picture of crime hotspots. |
| |
| The scatter plot on the right makes the poverty-crime relationship explicit: |
| each dot is one community area, and the dashed line is a statistical trend. |
| There is a moderate positive correlation, though it is far from deterministic - |
| policy, policing patterns, and reporting rates all play a role. |
| |
| **Socioeconomic data source:** [Census Data - Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2) |
| """ |
| ) |
|
|
| col3, col4 = st.columns(2) |
|
|
| with col3: |
| if not df_socio.empty and community_geojson["features"]: |
| poverty_map = ( |
| alt.Chart(communities) |
| .mark_geoshape(stroke="white", strokeWidth=0.4) |
| .transform_lookup( |
| lookup="properties.area_num_1", |
| from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]), |
| ) |
| .encode( |
| color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), |
| title="Poverty Rate (%)"), |
| tooltip=[ |
| alt.Tooltip("properties.community:N", title="Community"), |
| alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"), |
| ], |
| ) |
| .project(type="mercator") |
| .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area") |
| ) |
| |
| |
| df_geo_binned = df_geo.copy() |
| |
| df_geo_binned['lat_bin'] = df_geo_binned['latitude'].round(3) |
| df_geo_binned['lon_bin'] = df_geo_binned['longitude'].round(3) |
| |
| |
| density_agg = df_geo_binned.groupby(['lat_bin', 'lon_bin']).size().reset_index(name='incident_count') |
|
|
| |
| crime_density = ( |
| alt.Chart(density_agg) |
| .mark_circle(opacity=0.6, size=15) |
| .encode( |
| longitude="lon_bin:Q", |
| latitude="lat_bin:Q", |
| color=alt.Color( |
| "incident_count:Q", |
| scale=alt.Scale(scheme="blues"), |
| title="Incident Count", |
| legend=alt.Legend(title="Incidents"), |
| ), |
| tooltip=[ |
| alt.Tooltip("incident_count:Q", title="Total Incidents") |
| ] |
| ) |
| ) |
| |
| st.altair_chart( |
| (poverty_map + crime_density).resolve_scale(color="independent"), |
| use_container_width=True, |
| ) |
| else: |
| st.info("Socioeconomic or boundary data unavailable.") |
|
|
| with col4: |
| if not df_socio.empty and df["community_area"].notna().any(): |
| df_crime_count = ( |
| df.dropna(subset=["community_area"]) |
| .groupby("community_area").size() |
| .reset_index(name="crime_count") |
| ) |
| df_crime_count["ca"] = ( |
| df_crime_count["community_area"].astype(float).astype(int).astype(str) |
| ) |
| |
| df_scatter = pd.merge( |
| df_socio[["ca", "community_area_name", "poverty_rate"]], |
| df_crime_count[["ca", "crime_count"]], |
| on="ca", how="inner", |
| ) |
| |
| if len(df_scatter) > 5: |
| sc = ( |
| alt.Chart(df_scatter) |
| .mark_circle(size=80, opacity=0.75) |
| .encode( |
| x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"), |
| y=alt.Y("crime_count:Q", title="Crime Count (2026)"), |
| color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), |
| legend=None), |
| tooltip=[ |
| alt.Tooltip("community_area_name:N", title="Community"), |
| alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"), |
| alt.Tooltip("crime_count:Q", title="Crime Count"), |
| ], |
| ) |
| ) |
| reg = sc.transform_regression("poverty_rate", "crime_count").mark_line( |
| color="gray", strokeDash=[4, 4], strokeWidth=1.5 |
| ) |
| st.altair_chart( |
| (sc + reg).properties( |
| width=360, height=440, |
| title="Higher Poverty -> More Crimes? (each dot = one community area)", |
| ), |
| use_container_width=True, |
| ) |
| else: |
| st.info("Not enough community-level overlap to render scatter plot.") |
| else: |
| st.info("Community area data not available in this dataset sample.") |
|
|
| |
| |
| |
| st.markdown("---") |
| st.header("Data Sources & Citations") |
| st.markdown( |
| """ |
| | Dataset | Source | Link | |
| |---|---|---| |
| | Chicago Crimes 2001-Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) | |
| | Socioeconomic Indicators by Community | City of Chicago Data Portal | [kn9c-c2s2](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2) | |
| | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) | |
| | Community Area Boundaries (GeoJSON) | City of Chicago Data Portal | [igwz-8jzy](https://data.cityofchicago.org/Facilities-Geographic-Boundaries/Boundaries-Community-Areas-current-/cauq-8yn6) | |
| |
| All data accessed April 2026. Visualizations built with [Altair](https://altair-viz.github.io/) and [Streamlit](https://streamlit.io/). |
| """ |
| ) |