XinyiC11 commited on
Commit
2905978
·
verified ·
1 Parent(s): 4478280

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +460 -0
src/streamlit_app.py CHANGED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import altair as alt
4
+ import json
5
+ import urllib.request
6
+
7
+ st.set_page_config(page_title="Crimes in Chicago 2026", page_icon="?", layout="wide")
8
+
9
+ st.title("Crimes in Chicago - 2026")
10
+ st.markdown("**Authors: Xinyi Chen, Zhongyin Wang** - Group 6")
11
+ st.markdown("---")
12
+
13
+ st.markdown(
14
+ """
15
+ ## What Is This About?
16
+
17
+ Every day, hundreds of crime incidents are reported across Chicago's 77 community areas.
18
+ But where do they happen? At what time? And does poverty play a role?
19
+
20
+ This interactive article walks you through 2026 Chicago crime data drawn directly from
21
+ the [Chicago Data Portal](https://data.cityofchicago.org/) to help you explore the
22
+ geography, timing, and social context of crime in one of America's largest cities.
23
+
24
+ The dataset records every reported crime incident in 2026, including the exact location,
25
+ date and time, crime type, and the police district that handled it. Each row is one
26
+ reported incident. We also include community-level socioeconomic data to examine the
27
+ relationship between poverty and crime rates across Chicago's neighborhoods.
28
+ """
29
+ )
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Data loading
33
+ # ---------------------------------------------------------------------------
34
+
35
+ @st.cache_data(show_spinner="Loading Chicago crime data (full dataset)...")
36
+ def load_crime_data():
37
+ """Fetch ALL 2026 records in batches. Bar/line/heatmap use the full df;
38
+ only geo rendering on maps uses sampling."""
39
+ all_chunks = []
40
+ limit = 50000
41
+ offset = 0
42
+ while True:
43
+ url = (
44
+ "https://data.cityofchicago.org/resource/ijzp-q8t2.json"
45
+ "?$where=year=2026"
46
+ f"&$limit={limit}"
47
+ f"&$offset={offset}"
48
+ "&$order=date%20DESC"
49
+ )
50
+ try:
51
+ chunk = pd.read_json(url)
52
+ except Exception as e:
53
+ st.error(f"Failed to load crime data at offset {offset}: {e}")
54
+ break
55
+ if chunk.empty:
56
+ break
57
+ all_chunks.append(chunk)
58
+ if len(chunk) < limit:
59
+ break
60
+ offset += limit
61
+
62
+ if not all_chunks:
63
+ return pd.DataFrame()
64
+
65
+ df = pd.concat(all_chunks, ignore_index=True)
66
+
67
+ df["date"] = pd.to_datetime(df["date"], errors="coerce")
68
+ for col in ["latitude", "longitude"]:
69
+ df[col] = pd.to_numeric(df.get(col, pd.Series(dtype=float)), errors="coerce")
70
+ df = df.dropna(subset=["date"])
71
+
72
+ df["Date_Only"] = df["date"].dt.floor("d")
73
+ df["Hour"] = df["date"].dt.hour
74
+ df["weekday"] = df["date"].dt.day_name().str[:3]
75
+
76
+ df["Primary Type"] = df["primary_type"].str.upper() if "primary_type" in df.columns else "UNKNOWN"
77
+
78
+ if "district" in df.columns:
79
+ df["District_Str"] = (
80
+ pd.to_numeric(df["district"], errors="coerce")
81
+ .fillna(-1).astype(int).astype(str)
82
+ )
83
+ df["District"] = df["District_Str"]
84
+ else:
85
+ df["District_Str"] = df["District"] = "-1"
86
+
87
+ if "community_area" not in df.columns:
88
+ df["community_area"] = None
89
+
90
+ def get_period(h):
91
+ if 6 < h <= 12: return "Morning (6am-12pm)"
92
+ elif 12 < h <= 18: return "Afternoon (12pm-6pm)"
93
+ elif 18 < h <= 24: return "Evening (6pm-12am)"
94
+ else: return "Late Night (12am-6am)"
95
+
96
+ df["Period"] = df["Hour"].apply(get_period)
97
+ return df
98
+
99
+
100
+ @st.cache_data(show_spinner="Loading socioeconomic data...")
101
+ def load_socio():
102
+ try:
103
+ df = pd.read_json("https://data.cityofchicago.org/resource/kn9c-c2s2.json")
104
+ df = df.dropna(subset=["ca"])
105
+ df["ca"] = df["ca"].astype(float).astype(int).astype(str)
106
+ df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
107
+ return df
108
+ except Exception as e:
109
+ st.warning(f"Could not load socioeconomic data: {e}")
110
+ return pd.DataFrame(columns=["ca", "community_area_name", "poverty_rate"])
111
+
112
+
113
+ @st.cache_data(show_spinner="Loading boundaries...")
114
+ def load_geojson(url):
115
+ try:
116
+ with urllib.request.urlopen(url) as r:
117
+ return json.loads(r.read())
118
+ except Exception as e:
119
+ st.warning(f"Could not load GeoJSON: {e}")
120
+ return {"features": []}
121
+
122
+
123
+ district_geojson = load_geojson("https://data.cityofchicago.org/resource/24zt-jpfn.geojson")
124
+ community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8jzy.geojson")
125
+
126
+ df = load_crime_data()
127
+ df_socio = load_socio()
128
+ districts = alt.Data(values=district_geojson["features"])
129
+ communities = alt.Data(values=community_geojson["features"])
130
+
131
+ if df.empty:
132
+ st.error("Crime data could not be loaded.")
133
+ st.stop()
134
+
135
+ df_geo = df.dropna(subset=["latitude", "longitude"]).copy()
136
+ st.info(f"Loaded **{len(df):,}** crime records for 2026 ({len(df_geo):,} with coordinates).")
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # SECTION 1 — Linked dashboard
140
+ # ---------------------------------------------------------------------------
141
+ st.markdown("---")
142
+ st.header("Interactive Crime Dashboard")
143
+ st.markdown(
144
+ """
145
+ This dashboard lets you explore Chicago crime data across three linked views.
146
+ **Drag a box on the map** to select a geographic area, or **click a district boundary**
147
+ to highlight it — both actions filter the bar chart on the right and the timeline below.
148
+ You can also **click a crime category** in the bar chart to drill into its temporal trend.
149
+
150
+ The bottom line chart breaks daily incident counts into four time-of-day periods
151
+ (plus a grey total line). The bar chart and line chart use the **full dataset** with
152
+ no sampling; only the map points are sampled to keep the browser responsive.
153
+ """
154
+ )
155
+
156
+ brush = alt.selection_interval(name="brush")
157
+ click_type = alt.selection_point(fields=["Primary Type"], name="click_type")
158
+ click_dist = alt.selection_point(fields=["District_Str"], name="click_dist")
159
+
160
+ MAP_SAMPLE = 5000
161
+ df_map_sample = df_geo.sample(min(MAP_SAMPLE, len(df_geo)), random_state=42)
162
+
163
+ background = (
164
+ alt.Chart(districts)
165
+ .mark_geoshape(stroke="black", strokeWidth=0.6)
166
+ .transform_calculate(District_Str="datum.properties.dist_num")
167
+ .encode(
168
+ color=alt.condition(click_dist, alt.value("white"), alt.value("grey")),
169
+ opacity=alt.condition(click_dist, alt.value(0.5), alt.value(0.8)),
170
+ tooltip=[alt.Tooltip("properties.dist_num:N", title="District")],
171
+ )
172
+ .add_params(click_dist)
173
+ )
174
+
175
+ geo_points = (
176
+ alt.Chart(df_map_sample)
177
+ .mark_circle(size=5)
178
+ .encode(
179
+ longitude="longitude:Q",
180
+ latitude="latitude:Q",
181
+ color=alt.condition(
182
+ click_dist,
183
+ alt.Color("District:N", scale=alt.Scale(scheme="tableau10"),
184
+ legend=alt.Legend(title="District", orient="right")),
185
+ alt.value("#e0dbd6"),
186
+ ),
187
+ opacity=alt.condition(click_dist, alt.value(0.6), alt.value(0.05)),
188
+ tooltip=[
189
+ alt.Tooltip("Primary Type:N", title="Crime Type"),
190
+ alt.Tooltip("District:N", title="District"),
191
+ alt.Tooltip("date:T", title="Date"),
192
+ ],
193
+ )
194
+ .add_params(brush)
195
+ )
196
+
197
+ map_layer = (background + geo_points).project(type="mercator").properties(
198
+ width=420, height=450,
199
+ title=f"Chicago Crime Map (map shows {MAP_SAMPLE:,} sampled points for performance)",
200
+ )
201
+
202
+ # Bar chart - full df
203
+ type_chart = (
204
+ alt.Chart(df)
205
+ .mark_bar()
206
+ .encode(
207
+ x=alt.X("count():Q", title="Number of Crimes"),
208
+ y=alt.Y("Primary Type:N", sort="-x", title="Crime Type"),
209
+ color=alt.condition(click_type, alt.value("steelblue"), alt.value("lightgray")),
210
+ tooltip=["Primary Type:N", "count():Q"],
211
+ )
212
+ .properties(width=300, height=450, title="Crime Types (full dataset)")
213
+ .add_params(click_type)
214
+ .transform_filter(brush)
215
+ .transform_filter(click_dist)
216
+ )
217
+
218
+ # Line chart - full df
219
+ period_order = ["Morning (6am-12pm)", "Afternoon (12pm-6pm)",
220
+ "Evening (6pm-12am)", "Late Night (12am-6am)", "Total Daily"]
221
+ period_range = ["#f4a261", "#e9c46a", "#e76f51", "#264653", "grey"]
222
+
223
+ period_lines = (
224
+ alt.Chart(df)
225
+ .mark_line(point=False, strokeWidth=1.5)
226
+ .encode(
227
+ x=alt.X("Date_Only:T", title="Timeline"),
228
+ y=alt.Y("count:Q", title="Number of Incidents", scale=alt.Scale(zero=True)),
229
+ color=alt.Color(
230
+ "Period:N",
231
+ scale=alt.Scale(domain=period_order, range=period_range),
232
+ legend=alt.Legend(title="Time of Day", orient="right"),
233
+ ),
234
+ tooltip=[
235
+ alt.Tooltip("Date_Only:T", title="Date"),
236
+ alt.Tooltip("Period:N", title="Period"),
237
+ alt.Tooltip("count:Q", title="Incidents"),
238
+ ],
239
+ )
240
+ .transform_filter(brush)
241
+ .transform_filter(click_type)
242
+ .transform_filter(click_dist)
243
+ .transform_aggregate(count="count()", groupby=["Date_Only", "Period"])
244
+ .transform_impute(impute="count", key="Date_Only", groupby=["Period"], value=0)
245
+ )
246
+
247
+ total_line = (
248
+ alt.Chart(df)
249
+ .mark_line(opacity=0.5)
250
+ .encode(
251
+ x=alt.X("Date_Only:T"),
252
+ y=alt.Y("count():Q"),
253
+ color=alt.datum("Total Daily"),
254
+ tooltip=[
255
+ alt.Tooltip("Date_Only:T", title="Date"),
256
+ alt.Tooltip("count():Q", title="Total Incidents"),
257
+ ],
258
+ )
259
+ .transform_filter(brush)
260
+ .transform_filter(click_type)
261
+ .transform_filter(click_dist)
262
+ )
263
+
264
+ line_chart = (total_line + period_lines).properties(
265
+ width=760, height=220,
266
+ title="Daily Crime Trend by Time of Day (full dataset)",
267
+ ).resolve_scale(color="shared")
268
+
269
+ dashboard = ((map_layer | type_chart) & line_chart).resolve_scale(color="independent")
270
+ st.altair_chart(dashboard, use_container_width=True)
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # SECTION 2 — When do crimes happen? heatmap + dropdown
274
+ # Pre-aggregate to 7x24 = 168 rows in Python before rendering,
275
+ # so switching crime types is instant - no re-streaming of raw data to browser.
276
+ # ---------------------------------------------------------------------------
277
+ st.markdown("---")
278
+ st.header("When Do Crimes Happen in Chicago?")
279
+ st.markdown(
280
+ """
281
+ Different crimes follow different schedules. Use the **dropdown below** to filter
282
+ the heatmap by crime category, or leave it on *All* to see the overall pattern.
283
+ Each cell shows the total number of incidents at that day-of-week x hour-of-day
284
+ combination across the full dataset; darker red means more incidents.
285
+
286
+ Across nearly every category, Friday and Saturday evenings (6 pm to midnight)
287
+ stand out as the most active windows, while the early-morning hours (2 to 5 am)
288
+ are consistently quietest.
289
+ """
290
+ )
291
+
292
+ top_types_hm = df["Primary Type"].value_counts().head(10).index.tolist()
293
+ selected_hm = st.selectbox("Select Crime Type", options=["All"] + top_types_hm, index=0)
294
+
295
+ # Key fix: groupby in Python first -> only 168 rows reach Altair -> instant render
296
+ weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
297
+ hm_source = df if selected_hm == "All" else df[df["Primary Type"] == selected_hm]
298
+ hm_agg = (
299
+ hm_source
300
+ .groupby(["weekday", "Hour"])
301
+ .size()
302
+ .reset_index(name="crime_count")
303
+ )
304
+
305
+ heatmap = (
306
+ alt.Chart(hm_agg)
307
+ .mark_rect()
308
+ .encode(
309
+ x=alt.X("weekday:N", sort=weekday_order, title="Day of Week"),
310
+ y=alt.Y("Hour:O", title="Hour of Day (0-23)", sort="ascending"),
311
+ color=alt.Color("crime_count:Q", scale=alt.Scale(scheme="reds"), title="Number of Crimes"),
312
+ tooltip=[
313
+ alt.Tooltip("weekday:N", title="Day"),
314
+ alt.Tooltip("Hour:O", title="Hour"),
315
+ alt.Tooltip("crime_count:Q", title="Total Crimes"),
316
+ ],
317
+ )
318
+ .properties(
319
+ width=700, height=380,
320
+ title=f"Crime Heatmap - {selected_hm} (full dataset, darker = more incidents)",
321
+ )
322
+ )
323
+ st.altair_chart(heatmap, use_container_width=True)
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # SECTION 3 — Poverty vs. Crime
327
+ # Left: choropleth + binned geo-heatmap (no sampling, full density visible)
328
+ # Right: scatter with full crime counts per community area
329
+ # ---------------------------------------------------------------------------
330
+ st.markdown("---")
331
+ st.header("Does Poverty Predict Crime?")
332
+ st.markdown(
333
+ """
334
+ Socioeconomic inequality is one of the most studied predictors of crime at the
335
+ neighborhood level. The choropleth map on the left shades each of Chicago's 77
336
+ community areas by their poverty rate - darker orange means higher poverty -
337
+ with a binned crime density heatmap overlaid. The heatmap uses the full dataset
338
+ with no sampling: each cell's color reflects how many incidents fall in that
339
+ geographic bin, giving a clear picture of crime hotspots.
340
+
341
+ The scatter plot on the right makes the poverty-crime relationship explicit:
342
+ each dot is one community area, and the dashed line is a statistical trend.
343
+ There is a moderate positive correlation, though it is far from deterministic -
344
+ policy, policing patterns, and reporting rates all play a role.
345
+
346
+ **Socioeconomic data source:** [Census Data - Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2)
347
+ """
348
+ )
349
+
350
+ col3, col4 = st.columns(2)
351
+
352
+ with col3:
353
+ if not df_socio.empty and community_geojson["features"]:
354
+ poverty_map = (
355
+ alt.Chart(communities)
356
+ .mark_geoshape(stroke="white", strokeWidth=0.4)
357
+ .transform_lookup(
358
+ lookup="properties.area_num_1",
359
+ from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]),
360
+ )
361
+ .encode(
362
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"),
363
+ title="Poverty Rate (%)"),
364
+ tooltip=[
365
+ alt.Tooltip("properties.community:N", title="Community"),
366
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
367
+ ],
368
+ )
369
+ .project(type="mercator")
370
+ .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area")
371
+ )
372
+
373
+ # Binned geo-heatmap: full dataset, no sampling needed
374
+ # maxbins=50 -> ~2500 cells max, renders fast and shows full density
375
+ crime_density = (
376
+ alt.Chart(df_geo)
377
+ .mark_rect(opacity=0.55)
378
+ .encode(
379
+ longitude=alt.X("longitude:Q", bin=alt.Bin(maxbins=50)),
380
+ latitude=alt.Y("latitude:Q", bin=alt.Bin(maxbins=50)),
381
+ color=alt.Color(
382
+ "count():Q",
383
+ scale=alt.Scale(scheme="blues"),
384
+ title="Incident Count",
385
+ legend=alt.Legend(title="Incidents"),
386
+ ),
387
+ )
388
+ )
389
+
390
+ st.altair_chart(
391
+ (poverty_map + crime_density).resolve_scale(color="independent"),
392
+ use_container_width=True,
393
+ )
394
+ else:
395
+ st.info("Socioeconomic or boundary data unavailable.")
396
+
397
+ with col4:
398
+ if not df_socio.empty and df["community_area"].notna().any():
399
+ # Full df for crime counts - no sampling
400
+ df_crime_count = (
401
+ df.dropna(subset=["community_area"])
402
+ .groupby("community_area").size()
403
+ .reset_index(name="crime_count")
404
+ )
405
+ df_crime_count["ca"] = (
406
+ df_crime_count["community_area"].astype(float).astype(int).astype(str)
407
+ )
408
+ df_scatter = pd.merge(
409
+ df_socio[["ca", "community_area_name", "poverty_rate"]],
410
+ df_crime_count[["ca", "crime_count"]],
411
+ on="ca", how="inner",
412
+ )
413
+ if len(df_scatter) > 5:
414
+ sc = (
415
+ alt.Chart(df_scatter)
416
+ .mark_circle(size=80, opacity=0.75)
417
+ .encode(
418
+ x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
419
+ y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
420
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"),
421
+ legend=None),
422
+ tooltip=[
423
+ alt.Tooltip("community_area_name:N", title="Community"),
424
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
425
+ alt.Tooltip("crime_count:Q", title="Crime Count"),
426
+ ],
427
+ )
428
+ )
429
+ reg = sc.transform_regression("poverty_rate", "crime_count").mark_line(
430
+ color="gray", strokeDash=[4, 4], strokeWidth=1.5
431
+ )
432
+ st.altair_chart(
433
+ (sc + reg).properties(
434
+ width=360, height=440,
435
+ title="Higher Poverty -> More Crimes? (each dot = one community area)",
436
+ ),
437
+ use_container_width=True,
438
+ )
439
+ else:
440
+ st.info("Not enough community-level overlap to render scatter plot.")
441
+ else:
442
+ st.info("Community area data not available in this dataset sample.")
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Citations
446
+ # ---------------------------------------------------------------------------
447
+ st.markdown("---")
448
+ st.header("Data Sources & Citations")
449
+ st.markdown(
450
+ """
451
+ | Dataset | Source | Link |
452
+ |---|---|---|
453
+ | Chicago Crimes 2001-Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
454
+ | 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) |
455
+ | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |
456
+ | Community Area Boundaries (GeoJSON) | City of Chicago Data Portal | [igwz-8jzy](https://data.cityofchicago.org/Facilities-Geographic-Boundaries/Boundaries-Community-Areas-current-/cauq-8yn6) |
457
+
458
+ All data accessed April 2026. Visualizations built with [Altair](https://altair-viz.github.io/) and [Streamlit](https://streamlit.io/).
459
+ """
460
+ )