XinyiC11 commited on
Commit
6a99134
·
verified ·
1 Parent(s): 2b8dc6e

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +118 -140
src/streamlit_app.py CHANGED
@@ -4,19 +4,12 @@ import altair as alt
4
  import json
5
  import urllib.request
6
 
7
- # ── Page config ───────────────────────────────────────────────────────────────
8
- st.set_page_config(
9
- page_title="Crimes in Chicago 2026",
10
- page_icon="🔍",
11
- layout="wide",
12
- )
13
 
14
- # ── Header ────────────────────────────────────────────────────────────────────
15
- st.title("🔍 Crimes in Chicago 2026")
16
- st.markdown("**Authors: Xinyi Chen, Zhongyin Wang** · Group 6")
17
  st.markdown("---")
18
 
19
- # ── Introduction ──────────────────────────────────────────────────────────────
20
  st.markdown(
21
  """
22
  ## What Is This About?
@@ -24,8 +17,8 @@ st.markdown(
24
  Every day, hundreds of crime incidents are reported across Chicago's 77 community areas.
25
  But where do they happen? At what time? And does poverty play a role?
26
 
27
- This interactive article walks you through 2026 Chicago crime data drawn directly from
28
- the [Chicago Data Portal](https://data.cityofchicago.org/) to help you explore the
29
  geography, timing, and social context of crime in one of America's largest cities.
30
 
31
  The dataset records every reported crime incident in 2026, including the exact location,
@@ -35,14 +28,14 @@ st.markdown(
35
  """
36
  )
37
 
38
- # ── Data loading ──────────────────────────────────────────────────────────────
39
- @st.cache_data(show_spinner="Loading Chicago crime data (full dataset)…")
 
 
 
40
  def load_crime_data():
41
- """
42
- Fetch ALL 2026 records in batches of 50 000.
43
- Bar charts, line charts, and heatmaps use the full DataFrame.
44
- Only map geo-points are sampled at render time.
45
- """
46
  all_chunks = []
47
  limit = 50000
48
  offset = 0
@@ -74,16 +67,13 @@ def load_crime_data():
74
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
75
  for col in ["latitude", "longitude"]:
76
  df[col] = pd.to_numeric(df.get(col, pd.Series(dtype=float)), errors="coerce")
77
- df = df.dropna(subset=["date"]) # keep rows even if coords missing; drop only for map
78
 
79
  df["Date_Only"] = df["date"].dt.floor("d")
80
  df["Hour"] = df["date"].dt.hour
81
  df["weekday"] = df["date"].dt.day_name().str[:3]
82
 
83
- if "primary_type" in df.columns:
84
- df["Primary Type"] = df["primary_type"].str.upper()
85
- else:
86
- df["Primary Type"] = "UNKNOWN"
87
 
88
  if "district" in df.columns:
89
  df["District_Str"] = (
@@ -92,31 +82,25 @@ def load_crime_data():
92
  )
93
  df["District"] = df["District_Str"]
94
  else:
95
- df["District_Str"] = "-1"
96
- df["District"] = "-1"
97
 
98
  if "community_area" not in df.columns:
99
  df["community_area"] = None
100
 
101
- def get_period(hour):
102
- if 6 < hour <= 12:
103
- return "Morning (6am-12pm)"
104
- elif 12 < hour <= 18:
105
- return "Afternoon (12pm-6pm)"
106
- elif 18 < hour <= 24:
107
- return "Evening (6pm-12am)"
108
- else:
109
- return "Late Night (12am-6am)"
110
 
111
  df["Period"] = df["Hour"].apply(get_period)
112
  return df
113
 
114
 
115
- @st.cache_data(show_spinner="Loading socioeconomic data")
116
  def load_socio():
117
- url = "https://data.cityofchicago.org/resource/kn9c-c2s2.json"
118
  try:
119
- df = pd.read_json(url)
120
  df = df.dropna(subset=["ca"])
121
  df["ca"] = df["ca"].astype(float).astype(int).astype(str)
122
  df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
@@ -126,7 +110,7 @@ def load_socio():
126
  return pd.DataFrame(columns=["ca", "community_area_name", "poverty_rate"])
127
 
128
 
129
- @st.cache_data(show_spinner="Loading boundaries")
130
  def load_geojson(url):
131
  try:
132
  with urllib.request.urlopen(url) as r:
@@ -141,24 +125,21 @@ community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8
141
 
142
  df = load_crime_data()
143
  df_socio = load_socio()
144
-
145
  districts = alt.Data(values=district_geojson["features"])
146
  communities = alt.Data(values=community_geojson["features"])
147
 
148
  if df.empty:
149
- st.error("⚠️ Crime data could not be loaded. Please check the Chicago Data Portal.")
150
  st.stop()
151
 
152
- # Geo-only subset for map points (needs valid coords)
153
  df_geo = df.dropna(subset=["latitude", "longitude"]).copy()
 
154
 
155
- st.info(f"✅ Loaded **{len(df):,}** crime records for 2026 ({len(df_geo):,} with coordinates).")
156
-
157
- # ═══════════════════════════════════════════════════════════════════════════════
158
  # SECTION 1 — Linked dashboard
159
- # ═══════════════════════════════════════════════════════════════════════════════
160
  st.markdown("---")
161
- st.header("🗺️ Interactive Crime Dashboard")
162
  st.markdown(
163
  """
164
  This dashboard lets you explore Chicago crime data across three linked views.
@@ -167,8 +148,8 @@ st.markdown(
167
  You can also **click a crime category** in the bar chart to drill into its temporal trend.
168
 
169
  The bottom line chart breaks daily incident counts into four time-of-day periods
170
- (plus a grey total line), so you can see not just *where* crime happens but *when* it peaks.
171
- The bar chart and line chart use the **full dataset** no sampling.
172
  """
173
  )
174
 
@@ -176,8 +157,7 @@ brush = alt.selection_interval(name="brush")
176
  click_type = alt.selection_point(fields=["Primary Type"], name="click_type")
177
  click_dist = alt.selection_point(fields=["District_Str"], name="click_dist")
178
 
179
- # Map: geo points sampled to keep browser responsive; bar + line use full df
180
- MAP_SAMPLE = 5000
181
  df_map_sample = df_geo.sample(min(MAP_SAMPLE, len(df_geo)), random_state=42)
182
 
183
  background = (
@@ -200,18 +180,15 @@ geo_points = (
200
  latitude="latitude:Q",
201
  color=alt.condition(
202
  click_dist,
203
- alt.Color(
204
- "District:N",
205
- scale=alt.Scale(scheme="tableau10"),
206
- legend=alt.Legend(title="District", orient="right"),
207
- ),
208
  alt.value("#e0dbd6"),
209
  ),
210
  opacity=alt.condition(click_dist, alt.value(0.6), alt.value(0.05)),
211
  tooltip=[
212
  alt.Tooltip("Primary Type:N", title="Crime Type"),
213
- alt.Tooltip("District:N", title="District"),
214
- alt.Tooltip("date:T", title="Date"),
215
  ],
216
  )
217
  .add_params(brush)
@@ -222,7 +199,7 @@ map_layer = (background + geo_points).project(type="mercator").properties(
222
  title=f"Chicago Crime Map (map shows {MAP_SAMPLE:,} sampled points for performance)",
223
  )
224
 
225
- # Bar chart FULL df, filtered by brush + district selections
226
  type_chart = (
227
  alt.Chart(df)
228
  .mark_bar()
@@ -238,14 +215,9 @@ type_chart = (
238
  .transform_filter(click_dist)
239
  )
240
 
241
- # Line chart FULL df, filtered by all three selections
242
- period_order = [
243
- "Morning (6am-12pm)",
244
- "Afternoon (12pm-6pm)",
245
- "Evening (6pm-12am)",
246
- "Late Night (12am-6am)",
247
- "Total Daily",
248
- ]
249
  period_range = ["#f4a261", "#e9c46a", "#e76f51", "#264653", "grey"]
250
 
251
  period_lines = (
@@ -261,8 +233,8 @@ period_lines = (
261
  ),
262
  tooltip=[
263
  alt.Tooltip("Date_Only:T", title="Date"),
264
- alt.Tooltip("Period:N", title="Period"),
265
- alt.Tooltip("count:Q", title="Incidents"),
266
  ],
267
  )
268
  .transform_filter(brush)
@@ -281,7 +253,7 @@ total_line = (
281
  color=alt.datum("Total Daily"),
282
  tooltip=[
283
  alt.Tooltip("Date_Only:T", title="Date"),
284
- alt.Tooltip("count():Q", title="Total Incidents"),
285
  ],
286
  )
287
  .transform_filter(brush)
@@ -297,82 +269,81 @@ line_chart = (total_line + period_lines).properties(
297
  dashboard = ((map_layer | type_chart) & line_chart).resolve_scale(color="independent")
298
  st.altair_chart(dashboard, use_container_width=True)
299
 
300
- # ═══════════════════════════════════════════════════════════════════════════════
301
- # SECTION 2 — When do crimes happen? (standalone heatmap + dropdown)
302
- # ═══════════════════════════════════════════════════════════════════════════════
 
 
303
  st.markdown("---")
304
- st.header("🕐 When Do Crimes Happen in Chicago?")
305
  st.markdown(
306
  """
307
  Different crimes follow different schedules. Use the **dropdown below** to filter
308
- the heatmap to a specific crime category or leave it on *All* to see the overall
309
- pattern. Each cell shows the total number of incidents at that day-of-week × hour-of-day
310
- combination across the **full dataset**; darker red means more incidents.
311
 
312
- Across nearly every category, Friday and Saturday evenings (6 pm midnight)
313
- stand out as the most active windows, while the early-morning hours (25 am)
314
  are consistently quietest.
315
  """
316
  )
317
 
318
  top_types_hm = df["Primary Type"].value_counts().head(10).index.tolist()
319
- selected_hm = st.selectbox(
320
- "Select Crime Type",
321
- options=["All"] + top_types_hm,
322
- index=0,
323
- )
324
 
325
- hm_df = df if selected_hm == "All" else df[df["Primary Type"] == selected_hm]
326
  weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
 
 
 
 
 
 
 
327
 
328
  heatmap = (
329
- alt.Chart(hm_df)
330
  .mark_rect()
331
  .encode(
332
  x=alt.X("weekday:N", sort=weekday_order, title="Day of Week"),
333
- y=alt.Y("Hour:O", title="Hour of Day (023)", sort="ascending"),
334
- color=alt.Color(
335
- "count():Q",
336
- scale=alt.Scale(scheme="reds"),
337
- title="Number of Crimes",
338
- ),
339
  tooltip=[
340
- alt.Tooltip("weekday:N", title="Day"),
341
- alt.Tooltip("Hour:O", title="Hour"),
342
- alt.Tooltip("count():Q", title="Total Crimes"),
343
  ],
344
  )
345
  .properties(
346
  width=700, height=380,
347
- title=alt.TitleParams(
348
- text=f"Crime Heatmap — {selected_hm}",
349
- subtitle="Select a crime type above to filter · Full dataset · Darker = more incidents",
350
- fontSize=14,
351
- ),
352
  )
353
  )
354
  st.altair_chart(heatmap, use_container_width=True)
355
 
356
- # ═══════════════════════════════════════════════════════════════��═══════════════
357
  # SECTION 3 — Poverty vs. Crime
358
- # ═══════════════════════════════════════════════════════════════════════════════
 
 
359
  st.markdown("---")
360
- st.header("💸 Does Poverty Predict Crime?")
361
  st.markdown(
362
  """
363
  Socioeconomic inequality is one of the most studied predictors of crime at the
364
  neighborhood level. The choropleth map on the left shades each of Chicago's 77
365
- community areas by their poverty rate darker orange means higher poverty
366
- with crime incident dots overlaid in blue.
 
 
367
 
368
- A visual comparison suggests that some of the highest-crime community areas,
369
- particularly on the South and West sides, also carry the heaviest poverty burden.
370
- The scatter plot on the right makes this relationship explicit: each dot is one
371
- community area, and the dashed line is a statistical trend. There is a moderate
372
- positive correlation, though it is far from deterministic — policy, policing
373
- patterns, and reporting rates all play a role.
374
 
375
- **Socioeconomic data source:** [Census Data Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2)
376
  """
377
  )
378
 
@@ -388,11 +359,8 @@ with col3:
388
  from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]),
389
  )
390
  .encode(
391
- color=alt.Color(
392
- "poverty_rate:Q",
393
- scale=alt.Scale(scheme="orangered"),
394
- title="Poverty Rate (%)",
395
- ),
396
  tooltip=[
397
  alt.Tooltip("properties.community:N", title="Community"),
398
  alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
@@ -401,19 +369,34 @@ with col3:
401
  .project(type="mercator")
402
  .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area")
403
  )
404
- # Overlay: sample only for rendering dots on the map (visual only, not analysis)
405
- crime_overlay = (
406
- alt.Chart(df_geo.sample(min(5000, len(df_geo)), random_state=42))
407
- .mark_circle(size=3, color="steelblue", opacity=0.3)
408
- .encode(longitude="longitude:Q", latitude="latitude:Q")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  )
410
- st.altair_chart(poverty_map + crime_overlay, use_container_width=True)
411
  else:
412
  st.info("Socioeconomic or boundary data unavailable.")
413
 
414
  with col4:
415
  if not df_socio.empty and df["community_area"].notna().any():
416
- # Crime count by community uses the FULL df — no sampling
417
  df_crime_count = (
418
  df.dropna(subset=["community_area"])
419
  .groupby("community_area").size()
@@ -433,16 +416,13 @@ with col4:
433
  .mark_circle(size=80, opacity=0.75)
434
  .encode(
435
  x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
436
- y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
437
- color=alt.Color(
438
- "poverty_rate:Q",
439
- scale=alt.Scale(scheme="orangered"),
440
- legend=None,
441
- ),
442
  tooltip=[
443
  alt.Tooltip("community_area_name:N", title="Community"),
444
- alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
445
- alt.Tooltip("crime_count:Q", title="Crime Count"),
446
  ],
447
  )
448
  )
@@ -452,11 +432,7 @@ with col4:
452
  st.altair_chart(
453
  (sc + reg).properties(
454
  width=360, height=440,
455
- title=alt.TitleParams(
456
- text="Higher Poverty → More Crimes?",
457
- subtitle="Each dot = one community area | Dashed = trend | Full dataset counts",
458
- fontSize=13,
459
- ),
460
  ),
461
  use_container_width=True,
462
  )
@@ -465,14 +441,16 @@ with col4:
465
  else:
466
  st.info("Community area data not available in this dataset sample.")
467
 
468
- # ── Citations ─────────────────────────────────────────────────────────────────
 
 
469
  st.markdown("---")
470
- st.header("📚 Data Sources & Citations")
471
  st.markdown(
472
  """
473
  | Dataset | Source | Link |
474
  |---|---|---|
475
- | Chicago Crimes 2001Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
476
  | 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) |
477
  | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |
478
  | Community Area Boundaries (GeoJSON) | City of Chicago Data Portal | [igwz-8jzy](https://data.cityofchicago.org/Facilities-Geographic-Boundaries/Boundaries-Community-Areas-current-/cauq-8yn6) |
 
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?
 
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,
 
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
 
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"] = (
 
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")
 
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:
 
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.
 
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
 
 
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 = (
 
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)
 
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()
 
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 = (
 
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)
 
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)
 
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
 
 
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"),
 
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()
 
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
  )
 
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
  )
 
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) |