XinyiC11 commited on
Commit
5e60a0f
·
verified ·
1 Parent(s): 56cc940

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +365 -117
src/streamlit_app.py CHANGED
@@ -3,215 +3,463 @@ import pandas as pd
3
  import altair as alt
4
  import json
5
  import urllib.request
6
- import os
7
 
8
  st.set_page_config(page_title="Crimes in Chicago 2026", page_icon="🚨", layout="wide")
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
- ## What Is This About?
15
- Every day, hundreds of crime incidents are reported across Chicago's 77 community areas.
16
- This dashboard explores geography, timing, and socioeconomic context behind crime patterns.
17
- """)
 
 
 
 
 
 
 
 
18
 
19
  # ---------------------------------------------------------------------------
20
- # DATA LOADING
21
  # ---------------------------------------------------------------------------
22
  @st.cache_data(show_spinner="Loading local Chicago crime data...")
23
  def load_crime_data():
24
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
25
- file_path = os.path.join(BASE_DIR, "Crimes_-_2026_20260417.csv")
 
 
 
 
 
 
 
 
26
 
27
- df = pd.read_csv(file_path)
28
- df.columns = [c.lower().replace(" ", "_") for c in df.columns]
29
 
 
 
 
 
 
 
 
 
30
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
31
 
32
  for col in ["latitude", "longitude"]:
33
- df[col] = pd.to_numeric(df[col], errors="coerce")
34
 
35
  df = df.dropna(subset=["date"])
36
 
37
- df["date_only"] = df["date"].dt.floor("D")
38
- df["hour"] = df["date"].dt.hour
39
  df["weekday"] = df["date"].dt.day_name().str[:3]
40
 
41
- df["primary_type"] = df["primary_type"].str.upper()
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- df["district"] = pd.to_numeric(df["district"], errors="coerce").fillna(-1).astype(int).astype(str)
44
- df["district_str"] = df["district"]
45
 
46
  def get_period(h):
47
  if 6 < h <= 12:
48
- return "Morning"
49
  elif 12 < h <= 18:
50
- return "Afternoon"
51
  elif 18 < h <= 24:
52
- return "Evening"
53
  else:
54
- return "Late Night"
55
 
56
- df["period"] = df["hour"].apply(get_period)
57
 
58
  return df
59
 
60
-
61
  @st.cache_data(show_spinner="Loading socioeconomic data...")
62
  def load_socio():
63
- url = "https://data.cityofchicago.org/resource/kn9c-c2s2.json"
64
- df = pd.read_json(url)
65
-
66
- df = df.dropna(subset=["ca"])
67
- df["ca"] = df["ca"].astype(int).astype(str)
68
- df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
69
-
70
- return df
71
-
72
 
73
  @st.cache_data(show_spinner="Loading boundaries...")
74
  def load_geojson(url):
75
- with urllib.request.urlopen(url) as r:
76
- return json.loads(r.read())
77
-
78
-
79
- district_geojson = load_geojson("https://data.cityofchicago.org/resource/24zt-jpfn.geojson")
 
 
 
80
  community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8jzy.geojson")
81
 
82
- df = load_crime_data()
83
  df_socio = load_socio()
84
-
85
- districts = alt.Data(values=district_geojson["features"])
86
  communities = alt.Data(values=community_geojson["features"])
87
 
88
  if df.empty:
89
- st.error("No data loaded")
90
  st.stop()
91
 
92
- df_geo = df.dropna(subset=["latitude", "longitude"])
93
-
94
- st.info(f"Loaded {len(df):,} records")
95
 
96
  # ---------------------------------------------------------------------------
97
- # SECTION 1 DASHBOARD
98
  # ---------------------------------------------------------------------------
 
99
  st.header("Interactive Crime Dashboard")
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- brush = alt.selection_interval()
102
- click_type = alt.selection_point(fields=["primary_type"])
103
- click_dist = alt.selection_point(fields=["district_str"])
 
 
104
 
105
- sample_df = df_geo.sample(min(5000, len(df_geo)), random_state=42)
 
 
 
 
 
 
 
 
 
 
106
 
107
- map_chart = (
108
- alt.Chart(sample_df)
109
  .mark_circle(size=5)
110
  .encode(
111
  longitude="longitude:Q",
112
  latitude="latitude:Q",
113
- color=alt.condition(click_dist, "district_str:N", alt.value("lightgray")),
114
- tooltip=["primary_type", "district_str", "date"]
 
 
 
 
 
 
 
 
 
 
115
  )
116
  .add_params(brush)
117
  )
118
 
119
- bar_chart = (
 
 
 
 
 
 
120
  alt.Chart(df)
121
  .mark_bar()
122
  .encode(
123
- x="count()",
124
- y=alt.Y("primary_type:N", sort="-x"),
125
- color=alt.condition(click_type, alt.value("steelblue"), alt.value("lightgray"))
 
126
  )
 
127
  .add_params(click_type)
128
  .transform_filter(brush)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  )
130
 
131
- line_chart = (
132
  alt.Chart(df)
133
- .mark_line()
134
  .encode(
135
- x="date_only:T",
136
- y="count()",
137
- color="period:N"
 
 
 
 
138
  )
139
  .transform_filter(brush)
 
 
140
  )
141
 
142
- dashboard = (map_chart | bar_chart) & line_chart
 
 
 
 
 
143
  st.altair_chart(dashboard, use_container_width=True)
144
 
145
  # ---------------------------------------------------------------------------
146
- # SECTION 2 HEATMAP
147
  # ---------------------------------------------------------------------------
148
- st.header("When Crimes Happen")
 
 
 
 
 
 
 
 
149
 
150
- top_types = df["primary_type"].value_counts().head(10).index.tolist()
 
151
 
152
- hm = (
153
- df.groupby(["primary_type", "weekday", "hour"])
 
 
154
  .size()
155
- .reset_index(name="count")
 
 
 
 
 
 
 
156
  )
 
157
 
158
- dropdown = alt.binding_select(options=[None] + list(top_types))
159
- select = alt.selection_point(fields=["primary_type"], bind=dropdown)
160
 
161
  heatmap = (
162
- alt.Chart(hm)
163
  .mark_rect()
164
  .encode(
165
- x="weekday:N",
166
- y="hour:O",
167
- color="sum(count):Q"
 
 
 
 
 
 
 
 
 
 
 
 
168
  )
169
- .add_params(select)
170
- .transform_filter(select)
171
  )
172
-
173
  st.altair_chart(heatmap, use_container_width=True)
174
 
175
  # ---------------------------------------------------------------------------
176
- # SECTION 3 (REMOVED crime_density)
177
  # ---------------------------------------------------------------------------
178
- st.header("Does Poverty Predict Crime")
179
-
180
- poverty_map = (
181
- alt.Chart(communities)
182
- .mark_geoshape()
183
- .transform_lookup(
184
- lookup="properties.area_num_1",
185
- from_=alt.LookupData(df_socio, "ca", ["poverty_rate"])
186
- )
187
- .encode(
188
- color="poverty_rate:Q",
189
- tooltip=["poverty_rate:Q"]
190
- )
191
- )
192
-
193
- st.altair_chart(poverty_map, use_container_width=True)
194
-
195
- # scatter
196
- crime_by_area = (
197
- df.dropna(subset=["community_area"])
198
- .groupby("community_area")
199
- .size()
200
- .reset_index(name="crime_count")
201
  )
202
 
203
- crime_by_area["ca"] = crime_by_area["community_area"].astype(int).astype(str)
204
-
205
- merged = pd.merge(df_socio, crime_by_area, on="ca", how="inner")
206
-
207
- scatter = (
208
- alt.Chart(merged)
209
- .mark_circle()
210
- .encode(
211
- x="poverty_rate",
212
- y="crime_count",
213
- tooltip=["community_area_name", "poverty_rate", "crime_count"]
214
- )
215
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
- st.altair_chart(scatter, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  st.title("Crimes in Chicago - 2026")
9
  st.markdown("**Authors: Xinyi Chen, Zhongyin Wang** - Group 6")
10
  st.markdown("---")
11
+ st.markdown(
12
+ """
13
+ ## What Is This About?
14
+ Every day, hundreds of crime incidents are reported across Chicago's 77 community areas.
15
+ But where do they happen? At what time? And does poverty play a role?
16
+ This interactive article walks you through 2026 Chicago crime data drawn directly from
17
+ the [Chicago Data Portal](https://data.cityofchicago.org/) to help you explore the
18
+ geography, timing, and social context of crime in one of America's largest cities.
19
+ The dataset records every reported crime incident in 2026, including the exact location,
20
+ date and time, crime type, and the police district that handled it. Each row is one
21
+ reported incident. We also include community-level socioeconomic data to examine the
22
+ relationship between poverty and crime rates across Chicago's neighborhoods.
23
+ """
24
+ )
25
 
26
  # ---------------------------------------------------------------------------
27
+ # Data loading (Now using local CSV for extreme speedup)
28
  # ---------------------------------------------------------------------------
29
  @st.cache_data(show_spinner="Loading local Chicago crime data...")
30
  def load_crime_data():
31
+ """Robust loading for Hugging Face Spaces (handles path issues)."""
32
+ import os
33
+
34
+ try:
35
+ # 获取当前脚本所在目录
36
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
37
+ file_path = os.path.join(BASE_DIR, "Crimes_-_2026_20260417.csv")
38
+
39
+ # 读取 CSV
40
+ df = pd.read_csv(file_path)
41
 
42
+ # 标准化列名
43
+ df.columns = [c.lower().replace(" ", "_") for c in df.columns]
44
 
45
+ except FileNotFoundError:
46
+ st.error("❌ CSV file not found. Check filename and path.")
47
+ return pd.DataFrame()
48
+ except Exception as e:
49
+ st.error(f"❌ Failed to read CSV: {e}")
50
+ return pd.DataFrame()
51
+
52
+ # 后处理
53
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
54
 
55
  for col in ["latitude", "longitude"]:
56
+ df[col] = pd.to_numeric(df.get(col, pd.Series(dtype=float)), errors="coerce")
57
 
58
  df = df.dropna(subset=["date"])
59
 
60
+ df["Date_Only"] = df["date"].dt.floor("d")
61
+ df["Hour"] = df["date"].dt.hour
62
  df["weekday"] = df["date"].dt.day_name().str[:3]
63
 
64
+ df["Primary Type"] = (
65
+ df["primary_type"].str.upper()
66
+ if "primary_type" in df.columns else "UNKNOWN"
67
+ )
68
+
69
+ if "district" in df.columns:
70
+ df["District_Str"] = (
71
+ pd.to_numeric(df["district"], errors="coerce")
72
+ .fillna(-1).astype(int).astype(str)
73
+ )
74
+ df["District"] = df["District_Str"]
75
+ else:
76
+ df["District_Str"] = df["District"] = "-1"
77
 
78
+ if "community_area" not in df.columns:
79
+ df["community_area"] = None
80
 
81
  def get_period(h):
82
  if 6 < h <= 12:
83
+ return "Morning (6am-12pm)"
84
  elif 12 < h <= 18:
85
+ return "Afternoon (12pm-6pm)"
86
  elif 18 < h <= 24:
87
+ return "Evening (6pm-12am)"
88
  else:
89
+ return "Late Night (12am-6am)"
90
 
91
+ df["Period"] = df["Hour"].apply(get_period)
92
 
93
  return df
94
 
 
95
  @st.cache_data(show_spinner="Loading socioeconomic data...")
96
  def load_socio():
97
+ try:
98
+ df = pd.read_json("https://data.cityofchicago.org/resource/kn9c-c2s2.json")
99
+ df = df.dropna(subset=["ca"])
100
+ df["ca"] = df["ca"].astype(float).astype(int).astype(str)
101
+ df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
102
+ return df
103
+ except Exception as e:
104
+ st.warning(f"Could not load socioeconomic data: {e}")
105
+ return pd.DataFrame(columns=["ca", "community_area_name", "poverty_rate"])
106
 
107
  @st.cache_data(show_spinner="Loading boundaries...")
108
  def load_geojson(url):
109
+ try:
110
+ with urllib.request.urlopen(url) as r:
111
+ return json.loads(r.read())
112
+ except Exception as e:
113
+ st.warning(f"Could not load GeoJSON: {e}")
114
+ return {"features": []}
115
+
116
+ district_geojson = load_geojson("https://data.cityofchicago.org/resource/24zt-jpfn.geojson")
117
  community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8jzy.geojson")
118
 
119
+ df = load_crime_data()
120
  df_socio = load_socio()
121
+ districts = alt.Data(values=district_geojson["features"])
 
122
  communities = alt.Data(values=community_geojson["features"])
123
 
124
  if df.empty:
125
+ st.error("Crime data could not be loaded.")
126
  st.stop()
127
 
128
+ df_geo = df.dropna(subset=["latitude", "longitude"]).copy()
129
+ st.info(f"Loaded **{len(df):,}** crime records for 2026 ({len(df_geo):,} with coordinates).")
 
130
 
131
  # ---------------------------------------------------------------------------
132
+ # SECTION 1 — Linked dashboard
133
  # ---------------------------------------------------------------------------
134
+ st.markdown("---")
135
  st.header("Interactive Crime Dashboard")
136
+ st.markdown(
137
+ """
138
+ This dashboard lets you explore Chicago crime data across three linked views.
139
+ **Drag a box on the map** to select a geographic area, or **click a district boundary**
140
+ to highlight it — both actions filter the bar chart on the right and the timeline below.
141
+ You can also **click a crime category** in the bar chart to drill into its temporal trend.
142
+
143
+ *(Note: If the map points look like a strict grid, it is because the Chicago Police Department
144
+ anonymizes crime locations to the nearest block level, aligning perfectly with Chicago's grid street system!)*
145
+ """
146
+ )
147
 
148
+ brush = alt.selection_interval(name="brush")
149
+ click_type = alt.selection_point(fields=["Primary Type"], name="click_type")
150
+ click_dist = alt.selection_point(fields=["District_Str"], name="click_dist")
151
+ MAP_SAMPLE = 5000
152
+ df_map_sample = df_geo.sample(min(MAP_SAMPLE, len(df_geo)), random_state=42)
153
 
154
+ background = (
155
+ alt.Chart(districts)
156
+ .mark_geoshape(stroke="black", strokeWidth=0.6)
157
+ .transform_calculate(District_Str="datum.properties.dist_num")
158
+ .encode(
159
+ color=alt.condition(click_dist, alt.value("white"), alt.value("grey")),
160
+ opacity=alt.condition(click_dist, alt.value(0.5), alt.value(0.8)),
161
+ tooltip=[alt.Tooltip("properties.dist_num:N", title="District")],
162
+ )
163
+ .add_params(click_dist)
164
+ )
165
 
166
+ geo_points = (
167
+ alt.Chart(df_map_sample)
168
  .mark_circle(size=5)
169
  .encode(
170
  longitude="longitude:Q",
171
  latitude="latitude:Q",
172
+ color=alt.condition(
173
+ click_dist,
174
+ alt.Color("District:N", scale=alt.Scale(scheme="tableau10"),
175
+ legend=alt.Legend(title="District", orient="right")),
176
+ alt.value("#e0dbd6"),
177
+ ),
178
+ opacity=alt.condition(click_dist, alt.value(0.6), alt.value(0.05)),
179
+ tooltip=[
180
+ alt.Tooltip("Primary Type:N", title="Crime Type"),
181
+ alt.Tooltip("District:N", title="District"),
182
+ alt.Tooltip("date:T", title="Date"),
183
+ ],
184
  )
185
  .add_params(brush)
186
  )
187
 
188
+ map_layer = (background + geo_points).project(type="mercator").properties(
189
+ width=420, height=450,
190
+ title=f"Chicago Crime Map (map shows {MAP_SAMPLE:,} sampled points for performance)",
191
+ )
192
+
193
+ # Bar chart - full df
194
+ type_chart = (
195
  alt.Chart(df)
196
  .mark_bar()
197
  .encode(
198
+ x=alt.X("count():Q", title="Number of Crimes"),
199
+ y=alt.Y("Primary Type:N", sort="-x", title="Crime Type"),
200
+ color=alt.condition(click_type, alt.value("steelblue"), alt.value("lightgray")),
201
+ tooltip=["Primary Type:N", "count():Q"],
202
  )
203
+ .properties(width=300, height=450, title="Crime Types (full dataset)")
204
  .add_params(click_type)
205
  .transform_filter(brush)
206
+ .transform_filter(click_dist)
207
+ )
208
+
209
+ # Line chart - full df
210
+ period_order = ["Morning (6am-12pm)", "Afternoon (12pm-6pm)",
211
+ "Evening (6pm-12am)", "Late Night (12am-6am)", "Total Daily"]
212
+ period_range = ["#f4a261", "#e9c46a", "#e76f51", "#264653", "grey"]
213
+
214
+ period_lines = (
215
+ alt.Chart(df)
216
+ .mark_line(point=False, strokeWidth=1.5)
217
+ .encode(
218
+ x=alt.X("Date_Only:T", title="Timeline"),
219
+ y=alt.Y("count:Q", title="Number of Incidents", scale=alt.Scale(zero=True)),
220
+ color=alt.Color(
221
+ "Period:N",
222
+ scale=alt.Scale(domain=period_order, range=period_range),
223
+ legend=alt.Legend(title="Time of Day", orient="right"),
224
+ ),
225
+ tooltip=[
226
+ alt.Tooltip("Date_Only:T", title="Date"),
227
+ alt.Tooltip("Period:N", title="Period"),
228
+ alt.Tooltip("count:Q", title="Incidents"),
229
+ ],
230
+ )
231
+ .transform_filter(brush)
232
+ .transform_filter(click_type)
233
+ .transform_filter(click_dist)
234
+ .transform_aggregate(count="count()", groupby=["Date_Only", "Period"])
235
+ .transform_impute(impute="count", key="Date_Only", groupby=["Period"], value=0)
236
  )
237
 
238
+ total_line = (
239
  alt.Chart(df)
240
+ .mark_line(opacity=0.5)
241
  .encode(
242
+ x=alt.X("Date_Only:T"),
243
+ y=alt.Y("count():Q"),
244
+ color=alt.datum("Total Daily"),
245
+ tooltip=[
246
+ alt.Tooltip("Date_Only:T", title="Date"),
247
+ alt.Tooltip("count():Q", title="Total Incidents"),
248
+ ],
249
  )
250
  .transform_filter(brush)
251
+ .transform_filter(click_type)
252
+ .transform_filter(click_dist)
253
  )
254
 
255
+ line_chart = (total_line + period_lines).properties(
256
+ width=760, height=220,
257
+ title="Daily Crime Trend by Time of Day (full dataset)",
258
+ ).resolve_scale(color="shared")
259
+
260
+ dashboard = ((map_layer | type_chart) & line_chart).resolve_scale(color="independent")
261
  st.altair_chart(dashboard, use_container_width=True)
262
 
263
  # ---------------------------------------------------------------------------
264
+ # SECTION 2 — When do crimes happen? heatmap + dropdown (NOW LAG-FREE)
265
  # ---------------------------------------------------------------------------
266
+ st.markdown("---")
267
+ st.header("When Do Crimes Happen in Chicago?")
268
+ st.markdown(
269
+ """
270
+ Different crimes follow different schedules. Use the **dropdown below** to filter
271
+ the heatmap by crime category.
272
+ *(This is now fully interactive in your browser, filtering happens instantly without lag!)*
273
+ """
274
+ )
275
 
276
+ # 提取前10大罪案类型
277
+ top_types_hm = df["Primary Type"].value_counts().head(10).index.tolist()
278
 
279
+ # Python 预先计算所有 (Primary Type, weekday, Hour) 的聚合数量,减少传到前端的数据量
280
+ hm_agg = (
281
+ df.dropna(subset=["Primary Type"])
282
+ .groupby(["Primary Type", "weekday", "Hour"])
283
  .size()
284
+ .reset_index(name="crime_count")
285
+ )
286
+
287
+ # 🔥 核心提速秘籍:创建一个 Altair 原生的下拉绑定,把过滤操作全推给前端浏览器做,不重启 Streamlit!
288
+ dropdown = alt.binding_select(
289
+ options=[None] + top_types_hm,
290
+ labels=["All"] + top_types_hm,
291
+ name="Filter by Crime Type: "
292
  )
293
+ type_select = alt.selection_point(fields=["Primary Type"], bind=dropdown)
294
 
295
+ weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
 
296
 
297
  heatmap = (
298
+ alt.Chart(hm_agg)
299
  .mark_rect()
300
  .encode(
301
+ x=alt.X("weekday:N", sort=weekday_order, title="Day of Week"),
302
+ y=alt.Y("Hour:O", title="Hour of Day (0-23)", sort="ascending"),
303
+ # 使用 sum(crime_count) 确保选 All 的时候数字正确累加
304
+ color=alt.Color("sum(crime_count):Q", scale=alt.Scale(scheme="reds"), title="Number of Crimes"),
305
+ tooltip=[
306
+ alt.Tooltip("weekday:N", title="Day"),
307
+ alt.Tooltip("Hour:O", title="Hour"),
308
+ alt.Tooltip("sum(crime_count):Q", title="Total Crimes"),
309
+ ],
310
+ )
311
+ .add_params(type_select) # 绑定前端选择器
312
+ .transform_filter(type_select) # 让图表根据选择器过滤数据
313
+ .properties(
314
+ width=700, height=380,
315
+ title="Crime Heatmap (Instantly filterable)",
316
  )
 
 
317
  )
 
318
  st.altair_chart(heatmap, use_container_width=True)
319
 
320
  # ---------------------------------------------------------------------------
321
+ # SECTION 3 Poverty vs. Crime
322
  # ---------------------------------------------------------------------------
323
+ st.markdown("---")
324
+ st.header("Does Poverty Predict Crime?")
325
+ st.markdown(
326
+ """
327
+ Socioeconomic inequality is one of the most studied predictors of crime at the
328
+ neighborhood level. The choropleth map on the left shades each of Chicago's 77
329
+ community areas by their poverty rate - darker orange means higher poverty -
330
+ with a binned crime density heatmap overlaid. The heatmap uses the full dataset
331
+ with no sampling: each cell's color reflects how many incidents fall in that
332
+ geographic bin, giving a clear picture of crime hotspots.
333
+
334
+ The scatter plot on the right makes the poverty-crime relationship explicit:
335
+ each dot is one community area, and the dashed line is a statistical trend.
336
+ There is a moderate positive correlation, though it is far from deterministic -
337
+ policy, policing patterns, and reporting rates all play a role.
338
+
339
+ **Socioeconomic data source:** [Census Data - Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2)
340
+ """
 
 
 
 
 
341
  )
342
 
343
+ col3, col4 = st.columns(2)
344
+
345
+ with col3:
346
+ if not df_socio.empty and community_geojson["features"]:
347
+ poverty_map = (
348
+ alt.Chart(communities)
349
+ .mark_geoshape(stroke="white", strokeWidth=0.4)
350
+ .transform_lookup(
351
+ lookup="properties.area_num_1",
352
+ from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]),
353
+ )
354
+ .encode(
355
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"),
356
+ title="Poverty Rate (%)"),
357
+ tooltip=[
358
+ alt.Tooltip("properties.community:N", title="Community"),
359
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
360
+ ],
361
+ )
362
+ .project(type="mercator")
363
+ .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area")
364
+ )
365
+
366
+ # --- FIX: 改成了更高精度的 round(3) 结合 mark_circle 来实现细腻的热力图外观 ---
367
+ df_geo_binned = df_geo.copy()
368
+ # round(3) 大约对应100米的网格,比原来的 1.1公里 (round 2) 精细很多
369
+ df_geo_binned['lat_bin'] = df_geo_binned['latitude'].round(3)
370
+ df_geo_binned['lon_bin'] = df_geo_binned['longitude'].round(3)
371
+
372
+ # 统计每个细微网格的案件数量
373
+ density_agg = df_geo_binned.groupby(['lat_bin', 'lon_bin']).size().reset_index(name='incident_count')
374
+
375
+ # Binned geo-heatmap: 使用半透明的小圆点(mark_circle)模拟完美的热力云图
376
+ crime_density = (
377
+ alt.Chart(density_agg)
378
+ .mark_circle(opacity=0.6, size=15) # 调小了size,换成了圆形
379
+ .encode(
380
+ longitude="lon_bin:Q",
381
+ latitude="lat_bin:Q",
382
+ color=alt.Color(
383
+ "incident_count:Q",
384
+ scale=alt.Scale(scheme="blues"),
385
+ title="Incident Count",
386
+ legend=alt.Legend(title="Incidents"),
387
+ ),
388
+ tooltip=[
389
+ alt.Tooltip("incident_count:Q", title="Total Incidents")
390
+ ]
391
+ )
392
+ )
393
+
394
+ st.altair_chart(
395
+ (poverty_map + crime_density).resolve_scale(color="independent"),
396
+ use_container_width=True,
397
+ )
398
+ else:
399
+ st.info("Socioeconomic or boundary data unavailable.")
400
+
401
+ with col4:
402
+ if not df_socio.empty and df["community_area"].notna().any():
403
+ df_crime_count = (
404
+ df.dropna(subset=["community_area"])
405
+ .groupby("community_area").size()
406
+ .reset_index(name="crime_count")
407
+ )
408
+ df_crime_count["ca"] = (
409
+ df_crime_count["community_area"].astype(float).astype(int).astype(str)
410
+ )
411
+
412
+ df_scatter = pd.merge(
413
+ df_socio[["ca", "community_area_name", "poverty_rate"]],
414
+ df_crime_count[["ca", "crime_count"]],
415
+ on="ca", how="inner",
416
+ )
417
+
418
+ if len(df_scatter) > 5:
419
+ sc = (
420
+ alt.Chart(df_scatter)
421
+ .mark_circle(size=80, opacity=0.75)
422
+ .encode(
423
+ x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
424
+ y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
425
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"),
426
+ legend=None),
427
+ tooltip=[
428
+ alt.Tooltip("community_area_name:N", title="Community"),
429
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
430
+ alt.Tooltip("crime_count:Q", title="Crime Count"),
431
+ ],
432
+ )
433
+ )
434
+ reg = sc.transform_regression("poverty_rate", "crime_count").mark_line(
435
+ color="gray", strokeDash=[4, 4], strokeWidth=1.5
436
+ )
437
+ st.altair_chart(
438
+ (sc + reg).properties(
439
+ width=360, height=440,
440
+ title="Higher Poverty -> More Crimes? (each dot = one community area)",
441
+ ),
442
+ use_container_width=True,
443
+ )
444
+ else:
445
+ st.info("Not enough community-level overlap to render scatter plot.")
446
+ else:
447
+ st.info("Community area data not available in this dataset sample.")
448
 
449
+ # ---------------------------------------------------------------------------
450
+ # Citations
451
+ # ---------------------------------------------------------------------------
452
+ st.markdown("---")
453
+ st.header("Data Sources & Citations")
454
+ st.markdown(
455
+ """
456
+ | Dataset | Source | Link |
457
+ |---|---|---|
458
+ | Chicago Crimes 2001-Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
459
+ | 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) |
460
+ | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |
461
+ | Community Area Boundaries (GeoJSON) | City of Chicago Data Portal | [igwz-8jzy](https://data.cityofchicago.org/Facilities-Geographic-Boundaries/Boundaries-Community-Areas-current-/cauq-8yn6) |
462
+
463
+ All data accessed April 2026. Visualizations built with [Altair](https://altair-viz.github.io/) and [Streamlit](https://streamlit.io/).
464
+ """
465
+ )