XinyiC11 commited on
Commit
e70f227
·
verified ·
1 Parent(s): 7e4bf8b

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +419 -130
src/streamlit_app.py CHANGED
@@ -3,176 +3,465 @@ 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
  # ---------------------------------------------------------------------------
14
- # Data loading - 自动检测文件位置
15
  # ---------------------------------------------------------------------------
16
- @st.cache_data(show_spinner="Loading data...")
17
- def load_all_data():
18
- # 尝试多个可能的路径,确保在本地和 Hugging Face 都能读到
19
- possible_paths = [
20
- "Crimes_-_2026_20260417.csv",
21
- "final/Crimes_-_2026_20260417.csv",
22
- os.path.join(os.path.dirname(__file__), "Crimes_-_2026_20260417.csv"),
23
- os.path.join(os.path.dirname(__file__), "final/Crimes_-_2026_20260417.csv")
24
- ]
25
-
26
- df = None
27
- for path in possible_paths:
28
- if os.path.exists(path):
29
- df = pd.read_csv(path)
30
- break
31
-
32
- if df is None:
33
- st.error("❌ 找不到 CSV 文件,请确认文件名和路径正确!")
34
- st.stop()
35
-
36
- df.columns = [c.lower().replace(" ", "_") for c in df.columns]
 
 
 
37
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
38
- df = df.dropna(subset=["date", "latitude", "longitude"])
39
-
 
 
 
 
40
  df["Date_Only"] = df["date"].dt.floor("d")
41
  df["Hour"] = df["date"].dt.hour
42
  df["weekday"] = df["date"].dt.day_name().str[:3]
43
- df["Primary Type"] = df["primary_type"].str.upper()
44
-
45
- # District 字符串处理
46
- df["District_Str"] = pd.to_numeric(df["district"], errors="coerce").fillna(-1).astype(int).astype(str)
47
-
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def get_period(h):
49
- if 6 < h <= 12: return "Morning (6am-12pm)"
50
- elif 12 < h <= 18: return "Afternoon (12pm-6pm)"
51
- elif 18 < h <= 24: return "Evening (6pm-12am)"
52
- else: return "Late Night (12am-6am)"
 
 
 
 
 
53
  df["Period"] = df["Hour"].apply(get_period)
54
-
55
  return df
56
 
57
- @st.cache_data
58
- def load_resources():
59
- socio = pd.read_json("https://data.cityofchicago.org/resource/kn9c-c2s2.json")
60
- socio["ca"] = socio["ca"].astype(float).astype(int).astype(str)
61
- socio["poverty_rate"] = pd.to_numeric(socio["percent_households_below_poverty"], errors="coerce")
62
-
63
- dist_geo = json.loads(urllib.request.urlopen("https://data.cityofchicago.org/resource/24zt-jpfn.geojson").read())
64
- comm_geo = json.loads(urllib.request.urlopen("https://data.cityofchicago.org/resource/igwz-8jzy.geojson").read())
65
-
66
- return socio, dist_geo, comm_geo
 
 
 
 
 
 
 
 
 
 
67
 
68
- df = load_all_data()
69
- df_socio, district_geojson, community_geojson = load_resources()
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # ---------------------------------------------------------------------------
72
- # SECTION 1 — Interactive Dashboard
73
  # ---------------------------------------------------------------------------
 
74
  st.header("Interactive Crime Dashboard")
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- brush = alt.selection_interval(name="brush")
77
  click_type = alt.selection_point(fields=["Primary Type"], name="click_type")
78
  click_dist = alt.selection_point(fields=["District_Str"], name="click_dist")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- districts_data = alt.Data(values=district_geojson["features"])
81
- background = alt.Chart(districts_data).mark_geoshape(
82
- stroke="black", strokeWidth=0.4
83
- ).transform_calculate(
84
- District_Str="datum.properties.dist_num"
85
- ).encode(
86
- color=alt.condition(click_dist, alt.value("#fcfcfc"), alt.value("#eeeeee")),
87
- tooltip=[alt.Tooltip("properties.dist_num:N", title="District")]
88
- ).add_params(click_dist)
89
-
90
- geo_points = alt.Chart(df.sample(min(5000, len(df)), random_state=42)).mark_circle(size=6, opacity=0.4).encode(
91
- longitude="longitude:Q",
92
- latitude="latitude:Q",
93
- color=alt.condition(click_dist, alt.Color("District_Str:N", legend=None), alt.value("lightgrey")),
94
- tooltip=["Primary Type:N", "District_Str:N", "date:T"]
95
- ).add_params(brush)
96
-
97
- type_chart = alt.Chart(df).mark_bar().encode(
98
- x=alt.X("count():Q", title="Incidents"),
99
- y=alt.Y("Primary Type:N", sort="-x"),
100
- color=alt.condition(click_type, alt.value("steelblue"), alt.value("lightgray"))
101
- ).properties(width=300, height=400).add_params(click_type).transform_filter(brush).transform_filter(click_dist)
102
-
103
- period_lines = alt.Chart(df).mark_line().encode(
104
- x="Date_Only:T",
105
- y="count():Q",
106
- color=alt.Color("Period:N", scale=alt.Scale(scheme="tableau10")),
107
- tooltip=["Date_Only:T", "count():Q", "Period:N"]
108
- ).properties(width=800, height=200).transform_filter(brush).transform_filter(click_type).transform_filter(click_dist)
109
-
110
- st.altair_chart(((background + geo_points).project("mercator") | type_chart) & period_lines, use_container_width=True)
111
 
112
  # ---------------------------------------------------------------------------
113
- # SECTION 2 — Heatmap
114
  # ---------------------------------------------------------------------------
115
  st.markdown("---")
116
- st.header("When Do Crimes Happen?")
 
 
 
 
 
 
 
117
 
118
- top_types = ["ALL"] + df["Primary Type"].value_counts().head(10).index.tolist()
119
- selected_type = st.selectbox("Select a Crime Type", top_types)
120
 
121
- hm_df = df if selected_type == "ALL" else df[df["Primary Type"] == selected_type]
122
- hm_agg = hm_df.groupby(["weekday", "Hour"]).size().reset_index(name="count")
 
 
 
 
 
123
 
124
- heatmap = alt.Chart(hm_agg).mark_rect().encode(
125
- x=alt.X("weekday:N", sort=["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]),
126
- y=alt.Y("Hour:O"),
127
- color=alt.Color("count:Q", scale=alt.Scale(scheme="reds")),
128
- tooltip=["weekday", "Hour", "count"]
129
- ).properties(width=800, height=400)
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  st.altair_chart(heatmap, use_container_width=True)
132
 
133
  # ---------------------------------------------------------------------------
134
- # SECTION 3 — Poverty Map (已修复为显示清晰的点)
135
  # ---------------------------------------------------------------------------
136
  st.markdown("---")
137
  st.header("Does Poverty Predict Crime?")
138
-
139
- col_l, col_r = st.columns(2)
140
-
141
- with col_l:
142
- # 社区背景图(贫困率)
143
- comm_data = alt.Data(values=community_geojson["features"])
144
- poverty_map = alt.Chart(comm_data).mark_geoshape(stroke="white", strokeWidth=0.8).transform_lookup(
145
- lookup="properties.area_num_1",
146
- from_=alt.LookupData(df_socio, "ca", ["poverty_rate"])
147
- ).encode(
148
- color=alt.Color("poverty_rate:Q",
149
- scale=alt.Scale(scheme="yelloworangebrown"),
150
- title="Poverty Rate (%)")
151
- ).project("mercator").properties(width=400, height=500, title="Poverty Rate vs. Crime Incidents")
152
-
153
- # ✅ 修复:不再使用聚合热力图,改为直接显示黑色的采样点
154
- # 使用黑色点(Black)配合低透明度,可以清晰地看到底图颜色,同时点位分明
155
- df_sample_poverty = df.sample(min(4000, len(df)), random_state=42)
156
 
157
- crime_points_layer = alt.Chart(df_sample_poverty).mark_circle(size=4, opacity=0.3).encode(
158
- longitude="longitude:Q",
159
- latitude="latitude:Q",
160
- color=alt.value("black"), # 使用黑色,对比度最高,解决“灰呼呼”的问题
161
- tooltip=["primary_type:N", "date:T"]
162
- )
163
 
164
- st.altair_chart(poverty_map + crime_points_layer, use_container_width=True)
 
 
165
 
166
- with col_r:
167
- crime_counts = df.groupby("community_area").size().reset_index(name="total_crimes")
168
- crime_counts["ca"] = crime_counts["community_area"].astype(float).astype(int).astype(str)
169
- scatter_df = pd.merge(df_socio, crime_counts, on="ca")
170
-
171
- scatter = alt.Chart(scatter_df).mark_point(filled=True, size=100, color="darkred").encode(
172
- x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
173
- y=alt.Y("total_crimes:Q", title="Total Crime Incidents"),
174
- tooltip=["community_area_name", "poverty_rate", "total_crimes"]
175
- ).properties(title="Socioeconomic Correlation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- reg_line = scatter.transform_regression("poverty_rate", "total_crimes").mark_line(color="black", strokeDash=[4,4])
178
- st.altair_chart(scatter + reg_line, 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(df_geo)
378
+ .transform_bin("lat_bin", "latitude", bin=alt.Bin(maxbins=70))
379
+ .transform_bin("lon_bin", "longitude", bin=alt.Bin(maxbins=70))
380
+ .transform_aggregate(
381
+ incident_count="count()",
382
+ groupby=["lat_bin", "lon_bin"]
383
+ )
384
+ .mark_rect(opacity=0.55)
385
+ .encode(
386
+ longitude="lon_bin:Q",
387
+ latitude="lat_bin:Q",
388
+ color=alt.Color(
389
+ "incident_count:Q",
390
+ scale=alt.Scale(scheme="blues"),
391
+ title="Incident Count",
392
+ legend=alt.Legend(title="Incidents"),
393
+ ),
394
+ tooltip=[
395
+ alt.Tooltip("incident_count:Q", title="Total Incidents")
396
+ ]
397
+ )
398
+ .project(type="mercator") # 🔥 MUST align with choropleth
399
+ )
400
+ else:
401
+ st.info("Socioeconomic or boundary data unavailable.")
402
+
403
+ with col4:
404
+ if not df_socio.empty and df["community_area"].notna().any():
405
+ df_crime_count = (
406
+ df.dropna(subset=["community_area"])
407
+ .groupby("community_area").size()
408
+ .reset_index(name="crime_count")
409
+ )
410
+ df_crime_count["ca"] = (
411
+ df_crime_count["community_area"].astype(float).astype(int).astype(str)
412
+ )
413
+
414
+ df_scatter = pd.merge(
415
+ df_socio[["ca", "community_area_name", "poverty_rate"]],
416
+ df_crime_count[["ca", "crime_count"]],
417
+ on="ca", how="inner",
418
+ )
419
+
420
+ if len(df_scatter) > 5:
421
+ sc = (
422
+ alt.Chart(df_scatter)
423
+ .mark_circle(size=80, opacity=0.75)
424
+ .encode(
425
+ x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
426
+ y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
427
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"),
428
+ legend=None),
429
+ tooltip=[
430
+ alt.Tooltip("community_area_name:N", title="Community"),
431
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
432
+ alt.Tooltip("crime_count:Q", title="Crime Count"),
433
+ ],
434
+ )
435
+ )
436
+ reg = sc.transform_regression("poverty_rate", "crime_count").mark_line(
437
+ color="gray", strokeDash=[4, 4], strokeWidth=1.5
438
+ )
439
+ st.altair_chart(
440
+ (sc + reg).properties(
441
+ width=360, height=440,
442
+ title="Higher Poverty -> More Crimes? (each dot = one community area)",
443
+ ),
444
+ use_container_width=True,
445
+ )
446
+ else:
447
+ st.info("Not enough community-level overlap to render scatter plot.")
448
+ else:
449
+ st.info("Community area data not available in this dataset sample.")
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # Citations
453
+ # ---------------------------------------------------------------------------
454
+ st.markdown("---")
455
+ st.header("Data Sources & Citations")
456
+ st.markdown(
457
+ """
458
+ | Dataset | Source | Link |
459
+ |---|---|---|
460
+ | Chicago Crimes 2001-Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
461
+ | 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) |
462
+ | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |
463
+ | Community Area Boundaries (GeoJSON) | City of Chicago Data Portal | [igwz-8jzy](https://data.cityofchicago.org/Facilities-Geographic-Boundaries/Boundaries-Community-Areas-current-/cauq-8yn6) |
464
 
465
+ All data accessed April 2026. Visualizations built with [Altair](https://altair-viz.github.io/) and [Streamlit](https://streamlit.io/).
466
+ """
467
+ )