XinyiC11 commited on
Commit
2bd110f
Β·
verified Β·
1 Parent(s): 46c236b

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +196 -160
src/streamlit_app.py CHANGED
@@ -4,7 +4,7 @@ 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="πŸ”",
@@ -16,7 +16,7 @@ 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?
@@ -39,74 +39,104 @@ st.markdown(
39
  # ── Data loading ──────────────────────────────────────────────────────────────
40
  @st.cache_data(show_spinner="Loading Chicago crime data…")
41
  def load_crime_data():
42
- url = "https://data.cityofchicago.org/resource/ijzp-q8t2.json?$where=year=2026&$limit=10000&$order=date DESC"
43
- df = pd.read_json(url)
 
 
 
 
 
 
 
 
 
 
 
44
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
45
- df["latitude"] = pd.to_numeric(df.get("latitude", pd.Series(dtype=float)), errors="coerce")
46
- df["longitude"] = pd.to_numeric(df.get("longitude", pd.Series(dtype=float)), errors="coerce")
 
47
  df = df.dropna(subset=["latitude", "longitude", "date"])
48
  df["date_only"] = df["date"].dt.floor("d")
49
  df["hour"] = df["date"].dt.hour
50
  df["month"] = df["date"].dt.month
51
  df["weekday"] = df["date"].dt.day_name().str[:3]
52
- df["primary_type"] = df.get("primary_type", pd.Series(dtype=str)).str.title()
53
- df["district"] = pd.to_numeric(df.get("district", pd.Series(dtype=str)), errors="coerce").fillna(-1).astype(int).astype(str)
 
 
 
 
 
54
  return df
55
 
 
56
  @st.cache_data(show_spinner="Loading CTA station data…")
57
  def load_cta():
58
  url = "https://data.cityofchicago.org/resource/8pix-ypme.json"
59
- df = pd.read_json(url)
60
- df["lat"] = df["location"].apply(lambda x: float(x["latitude"]) if isinstance(x, dict) else None)
61
- df["lon"] = df["location"].apply(lambda x: float(x["longitude"]) if isinstance(x, dict) else None)
62
- return df.dropna(subset=["lat", "lon"])
 
 
 
 
 
63
 
64
  @st.cache_data(show_spinner="Loading socioeconomic data…")
65
  def load_socio():
66
  url = "https://data.cityofchicago.org/resource/kn9c-c2s2.json"
67
- df = pd.read_json(url)
68
- df = df.dropna(subset=["ca"])
69
- df["ca"] = df["ca"].astype(float).astype(int).astype(str)
70
- df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
71
- return df
72
-
73
- @st.cache_data(show_spinner="Loading district boundaries…")
74
- def load_districts():
75
- url = "https://data.cityofchicago.org/resource/24zt-jpfn.geojson"
76
- with urllib.request.urlopen(url) as r:
77
- return json.loads(r.read())
78
-
79
- @st.cache_data(show_spinner="Loading community boundaries…")
80
- def load_communities():
81
- url = "https://data.cityofchicago.org/resource/igwz-8jzy.geojson"
82
- with urllib.request.urlopen(url) as r:
83
- return json.loads(r.read())
84
-
85
- df = load_crime_data()
86
- df_cta = load_cta()
 
 
 
 
 
 
87
  df_socio = load_socio()
88
- district_geojson = load_districts()
89
- community_geojson = load_communities()
90
 
91
- districts = alt.Data(values=district_geojson["features"])
92
  communities = alt.Data(values=community_geojson["features"])
93
 
94
- # ── Section 1: Interactive main dashboard ────────────────────────────────────
 
 
 
 
95
  st.markdown("---")
96
  st.header("πŸ—ΊοΈ Explore Chicago Crime Interactively")
97
  st.markdown(
98
  """
99
- Use the filters below to slice the data by crime type. The map shows where crimes
100
- happened, the bar chart shows the breakdown by type, and the heatmap at the bottom
101
- reveals *when* crime peaks β€” by day of week and hour of day.
 
102
 
103
- **How to read this:** Darker cells in the heatmap mean more incidents at that
104
- day-and-hour combination. Fridays and Saturdays in the afternoon tend to be
105
- particularly active.
106
  """
107
  )
108
 
109
- # Filter controls
110
  top_types = df["primary_type"].value_counts().head(12).index.tolist()
111
  selected_types = st.multiselect(
112
  "Filter by Crime Type (leave blank = show all)",
@@ -118,13 +148,10 @@ filtered_df = df[df["primary_type"].isin(selected_types)] if selected_types else
118
  col1, col2 = st.columns([1.2, 1])
119
 
120
  with col1:
121
- # Crime map
122
- background = (
123
- alt.Chart(districts)
124
- .mark_geoshape(fill="#f0f0f0", stroke="#aaa", strokeWidth=0.5)
125
- )
126
- crime_pts = (
127
- alt.Chart(filtered_df.sample(min(3000, len(filtered_df)), random_state=42))
128
  .mark_circle(size=4, opacity=0.35, color="crimson")
129
  .encode(
130
  longitude="longitude:Q",
@@ -135,14 +162,16 @@ with col1:
135
  ],
136
  )
137
  )
138
- crime_map = (background + crime_pts).project(type="mercator").properties(
139
- width=480, height=450, title="Crime Incident Locations (sample up to 3 000)"
 
 
 
 
140
  )
141
- st.altair_chart(crime_map, use_container_width=True)
142
 
143
  with col2:
144
- # Crime type bar chart
145
- bar = (
146
  alt.Chart(filtered_df)
147
  .mark_bar(color="steelblue")
148
  .encode(
@@ -150,13 +179,12 @@ with col2:
150
  y=alt.Y("primary_type:N", sort="-x", title="Crime Type"),
151
  tooltip=["primary_type:N", "count():Q"],
152
  )
153
- .properties(width=380, height=450, title="Incidents by Crime Type")
 
154
  )
155
- st.altair_chart(bar, use_container_width=True)
156
 
157
- # When-do-crimes-happen heatmap
158
  weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
159
- heatmap = (
160
  alt.Chart(filtered_df)
161
  .mark_rect()
162
  .encode(
@@ -170,34 +198,34 @@ heatmap = (
170
  ],
171
  )
172
  .properties(
173
- width=700,
174
- height=300,
175
  title=alt.TitleParams(
176
  text="When Do Crimes Happen in Chicago?",
177
  subtitle="Darker = more incidents at that day Γ— hour combination",
178
  fontSize=14,
179
  ),
180
- )
 
181
  )
182
- st.altair_chart(heatmap, use_container_width=True)
183
 
184
- # ── Section 2: Monthly trend ─────────────────────────────────────────────────
185
  st.markdown("---")
186
  st.header("πŸ“… Monthly Crime Trends")
187
  st.markdown(
188
  """
189
  Crime in Chicago is not evenly distributed across the calendar. The bar chart below
190
- shows how total reported incidents vary month by month. Warmer months β€” typically
191
- May through August β€” tend to see elevated activity, a pattern observed consistently
192
- in cities across the United States and attributed to more people spending time outdoors.
 
193
 
194
  This seasonal pattern is important context: a spike in summer crime does not
195
  necessarily mean the city is becoming more dangerous overall; it may simply reflect
196
- the rhythm of urban life.
 
197
  """
198
  )
199
-
200
- monthly = (
201
  alt.Chart(df)
202
  .mark_bar(color="#4a90d9")
203
  .encode(
@@ -205,9 +233,9 @@ monthly = (
205
  y=alt.Y("count():Q", title="Total Incidents"),
206
  tooltip=[alt.Tooltip("month:O", title="Month"), alt.Tooltip("count():Q", title="Incidents")],
207
  )
208
- .properties(width=700, height=300, title="Monthly Crime Counts β€” Chicago 2026")
 
209
  )
210
- st.altair_chart(monthly, use_container_width=True)
211
 
212
  # ── Section 3: CTA overlay ────────────────────────────────────────────────────
213
  st.markdown("---")
@@ -217,8 +245,7 @@ st.markdown(
217
  Chicago's 'L' elevated rail network connects the entire city β€” but do transit hubs
218
  attract crime? The map below overlays CTA 'L' station locations (orange circles) on
219
  top of crime incident dots (crimson). Dense red areas near orange circles would suggest
220
- a transit-crime relationship, while sparse red areas far from stations would suggest
221
- the opposite.
222
 
223
  The data shows that many high-crime areas coincide with station-dense corridors
224
  (especially the Loop and the Red Line), though causation is complex: these are also
@@ -228,32 +255,35 @@ st.markdown(
228
  **Data source:** [CTA 'L' Stops β€” Chicago Data Portal](https://data.cityofchicago.org/Transportation/CTA-System-Information-List-of-L-Stops/8pix-ypme)
229
  """
230
  )
231
-
232
- cta_bg = alt.Chart(districts).mark_geoshape(fill="#f0f0f0", stroke="#aaa", strokeWidth=0.5)
233
- crime_layer = (
234
- alt.Chart(df.sample(min(4000, len(df)), random_state=1))
235
- .mark_circle(size=4, opacity=0.25, color="crimson")
236
- .encode(longitude="longitude:Q", latitude="latitude:Q")
237
- )
238
- cta_layer = (
239
- alt.Chart(df_cta)
240
- .mark_circle(size=50, color="orange", opacity=0.85, stroke="white", strokeWidth=0.8)
241
- .encode(
242
- longitude="lon:Q",
243
- latitude="lat:Q",
244
- tooltip=[alt.Tooltip("station_name:N", title="Station")],
245
  )
246
- )
247
- cta_map = (cta_bg + crime_layer + cta_layer).project(type="mercator").properties(
248
- width=700,
249
- height=500,
250
- title=alt.TitleParams(
251
- text="Chicago Crime vs. CTA 'L' Stations",
252
- subtitle="Crimson dots = crime incidents | Orange circles = 'L' stations",
253
- fontSize=14,
254
- ),
255
- )
256
- st.altair_chart(cta_map, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  # ── Section 4: Poverty choropleth + scatter ───────────────────────────────────
259
  st.markdown("---")
@@ -269,82 +299,88 @@ st.markdown(
269
  particularly on the South and West sides β€” also carry the heaviest poverty burden.
270
  The scatter plot on the right makes this relationship more explicit: each dot is one
271
  community area, and the dashed line is a statistical trend line. There is a moderate
272
- positive correlation, though the relationship is far from deterministic β€” some
273
- lower-poverty areas still report significant crime, and vice versa.
274
 
275
- **Socioeconomic data source:** [Census Data β€” Selected Socioeconomic Indicators, Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2)
276
  """
277
  )
278
 
279
  col3, col4 = st.columns(2)
280
 
281
  with col3:
282
- poverty_map = (
283
- alt.Chart(communities)
284
- .mark_geoshape(stroke="white", strokeWidth=0.4)
285
- .transform_lookup(
286
- lookup="properties.area_num_1",
287
- from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]),
288
- )
289
- .encode(
290
- color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), title="Poverty Rate (%)"),
291
- tooltip=[
292
- alt.Tooltip("properties.community:N", title="Community"),
293
- alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
294
- ],
295
- )
296
- .project(type="mercator")
297
- .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area")
298
- )
299
- crime_dots_overlay = (
300
- alt.Chart(df.sample(min(5000, len(df)), random_state=42))
301
- .mark_circle(size=3, color="steelblue", opacity=0.3)
302
- .encode(longitude="longitude:Q", latitude="latitude:Q")
303
- )
304
- st.altair_chart(poverty_map + crime_dots_overlay, use_container_width=True)
305
-
306
- with col4:
307
- df_crime_count = df.groupby("community_area").size().reset_index(name="crime_count")
308
- df_crime_count["ca"] = df_crime_count["community_area"].astype(str)
309
- df_scatter = pd.merge(
310
- df_socio[["ca", "community_area_name", "poverty_rate"]],
311
- df_crime_count,
312
- on="ca",
313
- how="inner",
314
- )
315
-
316
- if len(df_scatter) > 5:
317
- scatter = (
318
- alt.Chart(df_scatter)
319
- .mark_circle(size=80, opacity=0.75)
320
  .encode(
321
- x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
322
- y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
323
- color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), legend=None),
324
  tooltip=[
325
- alt.Tooltip("community_area_name:N", title="Community"),
326
  alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
327
- alt.Tooltip("crime_count:Q", title="Crime Count"),
328
  ],
329
  )
 
 
330
  )
331
- regression = scatter.transform_regression("poverty_rate", "crime_count").mark_line(
332
- color="gray", strokeDash=[4, 4], strokeWidth=1.5
 
 
333
  )
334
- st.altair_chart(
335
- (scatter + regression).properties(
336
- width=360,
337
- height=440,
338
- title=alt.TitleParams(
339
- text="Higher Poverty β†’ More Crimes?",
340
- subtitle="Each dot = one community area | Dashed = trend",
341
- fontSize=13,
342
- ),
343
- ),
344
- use_container_width=True,
 
 
 
 
 
345
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  else:
347
- st.info("Not enough community-level data to render scatter plot with this dataset sample.")
348
 
349
  # ── Citations ─────────────────────────────────────────────────────────────────
350
  st.markdown("---")
@@ -353,7 +389,7 @@ st.markdown(
353
  """
354
  | Dataset | Source | Link |
355
  |---|---|---|
356
- | Chicago Crimes 2026 | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
357
  | CTA 'L' Stop Locations | City of Chicago Data Portal | [8pix-ypme](https://data.cityofchicago.org/Transportation/CTA-System-Information-List-of-L-Stops/8pix-ypme) |
358
  | 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) |
359
  | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |
 
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="πŸ”",
 
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?
 
39
  # ── Data loading ──────────────────────────────────────────────────────────────
40
  @st.cache_data(show_spinner="Loading Chicago crime data…")
41
  def load_crime_data():
42
+ # Use %20 for space in $order β€” bare spaces cause InvalidURL in Python 3.13
43
+ url = (
44
+ "https://data.cityofchicago.org/resource/ijzp-q8t2.json"
45
+ "?$where=year=2026"
46
+ "&$limit=10000"
47
+ "&$order=date%20DESC"
48
+ )
49
+ try:
50
+ df = pd.read_json(url)
51
+ except Exception as e:
52
+ st.error(f"Failed to load crime data: {e}")
53
+ return pd.DataFrame()
54
+
55
  df["date"] = pd.to_datetime(df["date"], errors="coerce")
56
+ for col in ["latitude", "longitude"]:
57
+ df[col] = pd.to_numeric(df.get(col, pd.Series(dtype=float)), errors="coerce")
58
+
59
  df = df.dropna(subset=["latitude", "longitude", "date"])
60
  df["date_only"] = df["date"].dt.floor("d")
61
  df["hour"] = df["date"].dt.hour
62
  df["month"] = df["date"].dt.month
63
  df["weekday"] = df["date"].dt.day_name().str[:3]
64
+ df["primary_type"] = df.get("primary_type", pd.Series(dtype=str)).str.title().fillna("Unknown")
65
+ df["district"] = (
66
+ pd.to_numeric(df.get("district", pd.Series(dtype=str)), errors="coerce")
67
+ .fillna(-1).astype(int).astype(str)
68
+ )
69
+ if "community_area" not in df.columns:
70
+ df["community_area"] = None
71
  return df
72
 
73
+
74
  @st.cache_data(show_spinner="Loading CTA station data…")
75
  def load_cta():
76
  url = "https://data.cityofchicago.org/resource/8pix-ypme.json"
77
+ try:
78
+ df = pd.read_json(url)
79
+ df["lat"] = df["location"].apply(lambda x: float(x["latitude"]) if isinstance(x, dict) else None)
80
+ df["lon"] = df["location"].apply(lambda x: float(x["longitude"]) if isinstance(x, dict) else None)
81
+ return df.dropna(subset=["lat", "lon"])
82
+ except Exception as e:
83
+ st.warning(f"Could not load CTA data: {e}")
84
+ return pd.DataFrame(columns=["lat", "lon", "station_name"])
85
+
86
 
87
  @st.cache_data(show_spinner="Loading socioeconomic data…")
88
  def load_socio():
89
  url = "https://data.cityofchicago.org/resource/kn9c-c2s2.json"
90
+ try:
91
+ df = pd.read_json(url)
92
+ df = df.dropna(subset=["ca"])
93
+ df["ca"] = df["ca"].astype(float).astype(int).astype(str)
94
+ df["poverty_rate"] = pd.to_numeric(df["percent_households_below_poverty"], errors="coerce")
95
+ return df
96
+ except Exception as e:
97
+ st.warning(f"Could not load socioeconomic data: {e}")
98
+ return pd.DataFrame(columns=["ca", "community_area_name", "poverty_rate"])
99
+
100
+
101
+ @st.cache_data(show_spinner="Loading boundaries…")
102
+ def load_geojson(url):
103
+ try:
104
+ with urllib.request.urlopen(url) as r:
105
+ return json.loads(r.read())
106
+ except Exception as e:
107
+ st.warning(f"Could not load GeoJSON: {e}")
108
+ return {"features": []}
109
+
110
+
111
+ district_geojson = load_geojson("https://data.cityofchicago.org/resource/24zt-jpfn.geojson")
112
+ community_geojson = load_geojson("https://data.cityofchicago.org/resource/igwz-8jzy.geojson")
113
+
114
+ df = load_crime_data()
115
+ df_cta = load_cta()
116
  df_socio = load_socio()
 
 
117
 
118
+ districts = alt.Data(values=district_geojson["features"])
119
  communities = alt.Data(values=community_geojson["features"])
120
 
121
+ if df.empty:
122
+ st.error("⚠️ Crime data could not be loaded. Please check the Chicago Data Portal.")
123
+ st.stop()
124
+
125
+ # ── Section 1: Interactive dashboard ─────────────────────────────────────────
126
  st.markdown("---")
127
  st.header("πŸ—ΊοΈ Explore Chicago Crime Interactively")
128
  st.markdown(
129
  """
130
+ Use the filter below to focus on specific crime types. The map shows where crimes
131
+ happened across Chicago's police districts, while the bar chart ranks the most common
132
+ crime categories. The day Γ— hour heatmap at the bottom reveals *when* crime peaks β€”
133
+ darker cells mean more incidents at that time slot.
134
 
135
+ **Tip:** Fridays and Saturdays in the afternoon and evening consistently show
136
+ elevated activity across most crime categories.
 
137
  """
138
  )
139
 
 
140
  top_types = df["primary_type"].value_counts().head(12).index.tolist()
141
  selected_types = st.multiselect(
142
  "Filter by Crime Type (leave blank = show all)",
 
148
  col1, col2 = st.columns([1.2, 1])
149
 
150
  with col1:
151
+ bg = alt.Chart(districts).mark_geoshape(fill="#f0f0f0", stroke="#aaa", strokeWidth=0.5)
152
+ n = min(3000, len(filtered_df))
153
+ pts = (
154
+ alt.Chart(filtered_df.sample(n, random_state=42))
 
 
 
155
  .mark_circle(size=4, opacity=0.35, color="crimson")
156
  .encode(
157
  longitude="longitude:Q",
 
162
  ],
163
  )
164
  )
165
+ st.altair_chart(
166
+ (bg + pts).project(type="mercator").properties(
167
+ width=480, height=450,
168
+ title=f"Crime Incident Locations (showing {n:,} points)"
169
+ ),
170
+ use_container_width=True,
171
  )
 
172
 
173
  with col2:
174
+ st.altair_chart(
 
175
  alt.Chart(filtered_df)
176
  .mark_bar(color="steelblue")
177
  .encode(
 
179
  y=alt.Y("primary_type:N", sort="-x", title="Crime Type"),
180
  tooltip=["primary_type:N", "count():Q"],
181
  )
182
+ .properties(width=380, height=450, title="Incidents by Crime Type"),
183
+ use_container_width=True,
184
  )
 
185
 
 
186
  weekday_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
187
+ st.altair_chart(
188
  alt.Chart(filtered_df)
189
  .mark_rect()
190
  .encode(
 
198
  ],
199
  )
200
  .properties(
201
+ width=700, height=300,
 
202
  title=alt.TitleParams(
203
  text="When Do Crimes Happen in Chicago?",
204
  subtitle="Darker = more incidents at that day Γ— hour combination",
205
  fontSize=14,
206
  ),
207
+ ),
208
+ use_container_width=True,
209
  )
 
210
 
211
+ # ── Section 2: Monthly trend ──────────────────────────────────────────────────
212
  st.markdown("---")
213
  st.header("πŸ“… Monthly Crime Trends")
214
  st.markdown(
215
  """
216
  Crime in Chicago is not evenly distributed across the calendar. The bar chart below
217
+ shows how total reported incidents vary month by month in 2026. Warmer months β€”
218
+ typically May through August β€” tend to see elevated activity, a pattern observed
219
+ consistently in cities across the United States and attributed to more people spending
220
+ time outdoors and in public spaces.
221
 
222
  This seasonal pattern is important context: a spike in summer crime does not
223
  necessarily mean the city is becoming more dangerous overall; it may simply reflect
224
+ the rhythm of urban life. Conversely, lower numbers in winter months partly reflect
225
+ people staying indoors, which reduces opportunities for certain street crimes.
226
  """
227
  )
228
+ st.altair_chart(
 
229
  alt.Chart(df)
230
  .mark_bar(color="#4a90d9")
231
  .encode(
 
233
  y=alt.Y("count():Q", title="Total Incidents"),
234
  tooltip=[alt.Tooltip("month:O", title="Month"), alt.Tooltip("count():Q", title="Incidents")],
235
  )
236
+ .properties(width=700, height=300, title="Monthly Crime Counts β€” Chicago 2026"),
237
+ use_container_width=True,
238
  )
 
239
 
240
  # ── Section 3: CTA overlay ────────────────────────────────────────────────────
241
  st.markdown("---")
 
245
  Chicago's 'L' elevated rail network connects the entire city β€” but do transit hubs
246
  attract crime? The map below overlays CTA 'L' station locations (orange circles) on
247
  top of crime incident dots (crimson). Dense red areas near orange circles would suggest
248
+ a transit-crime relationship.
 
249
 
250
  The data shows that many high-crime areas coincide with station-dense corridors
251
  (especially the Loop and the Red Line), though causation is complex: these are also
 
255
  **Data source:** [CTA 'L' Stops β€” Chicago Data Portal](https://data.cityofchicago.org/Transportation/CTA-System-Information-List-of-L-Stops/8pix-ypme)
256
  """
257
  )
258
+ if not df_cta.empty:
259
+ cta_bg = alt.Chart(districts).mark_geoshape(fill="#f0f0f0", stroke="#aaa", strokeWidth=0.5)
260
+ crime_layer = (
261
+ alt.Chart(df.sample(min(4000, len(df)), random_state=1))
262
+ .mark_circle(size=4, opacity=0.25, color="crimson")
263
+ .encode(longitude="longitude:Q", latitude="latitude:Q")
 
 
 
 
 
 
 
 
264
  )
265
+ cta_layer = (
266
+ alt.Chart(df_cta)
267
+ .mark_circle(size=50, color="orange", opacity=0.85, stroke="white", strokeWidth=0.8)
268
+ .encode(
269
+ longitude="lon:Q",
270
+ latitude="lat:Q",
271
+ tooltip=[alt.Tooltip("station_name:N", title="Station")],
272
+ )
273
+ )
274
+ st.altair_chart(
275
+ (cta_bg + crime_layer + cta_layer).project(type="mercator").properties(
276
+ width=700, height=500,
277
+ title=alt.TitleParams(
278
+ text="Chicago Crime vs. CTA 'L' Stations",
279
+ subtitle="Crimson dots = crime incidents | Orange circles = 'L' stations",
280
+ fontSize=14,
281
+ ),
282
+ ),
283
+ use_container_width=True,
284
+ )
285
+ else:
286
+ st.info("CTA station data unavailable.")
287
 
288
  # ── Section 4: Poverty choropleth + scatter ───────────────────────────────────
289
  st.markdown("---")
 
299
  particularly on the South and West sides β€” also carry the heaviest poverty burden.
300
  The scatter plot on the right makes this relationship more explicit: each dot is one
301
  community area, and the dashed line is a statistical trend line. There is a moderate
302
+ positive correlation, though the relationship is far from deterministic.
 
303
 
304
+ **Socioeconomic data source:** [Census Data β€” Chicago Data Portal](https://data.cityofchicago.org/Health-Human-Services/Census-Data-Selected-Socioeconomic-Indicators-in-C/kn9c-c2s2)
305
  """
306
  )
307
 
308
  col3, col4 = st.columns(2)
309
 
310
  with col3:
311
+ if not df_socio.empty and community_geojson["features"]:
312
+ poverty_map = (
313
+ alt.Chart(communities)
314
+ .mark_geoshape(stroke="white", strokeWidth=0.4)
315
+ .transform_lookup(
316
+ lookup="properties.area_num_1",
317
+ from_=alt.LookupData(df_socio, "ca", ["poverty_rate", "community_area_name"]),
318
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  .encode(
320
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), title="Poverty Rate (%)"),
 
 
321
  tooltip=[
322
+ alt.Tooltip("properties.community:N", title="Community"),
323
  alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
 
324
  ],
325
  )
326
+ .project(type="mercator")
327
+ .properties(width=360, height=440, title="Chicago Poverty Rate by Community Area")
328
  )
329
+ crime_overlay = (
330
+ alt.Chart(df.sample(min(5000, len(df)), random_state=42))
331
+ .mark_circle(size=3, color="steelblue", opacity=0.3)
332
+ .encode(longitude="longitude:Q", latitude="latitude:Q")
333
  )
334
+ st.altair_chart(poverty_map + crime_overlay, use_container_width=True)
335
+ else:
336
+ st.info("Socioeconomic or boundary data unavailable.")
337
+
338
+ with col4:
339
+ if not df_socio.empty and df["community_area"].notna().any():
340
+ df_crime_count = (
341
+ df.dropna(subset=["community_area"])
342
+ .groupby("community_area").size()
343
+ .reset_index(name="crime_count")
344
+ )
345
+ df_crime_count["ca"] = df_crime_count["community_area"].astype(float).astype(int).astype(str)
346
+ df_scatter = pd.merge(
347
+ df_socio[["ca", "community_area_name", "poverty_rate"]],
348
+ df_crime_count[["ca", "crime_count"]],
349
+ on="ca", how="inner",
350
  )
351
+ if len(df_scatter) > 5:
352
+ sc = (
353
+ alt.Chart(df_scatter)
354
+ .mark_circle(size=80, opacity=0.75)
355
+ .encode(
356
+ x=alt.X("poverty_rate:Q", title="Poverty Rate (%)"),
357
+ y=alt.Y("crime_count:Q", title="Crime Count (2026)"),
358
+ color=alt.Color("poverty_rate:Q", scale=alt.Scale(scheme="orangered"), legend=None),
359
+ tooltip=[
360
+ alt.Tooltip("community_area_name:N", title="Community"),
361
+ alt.Tooltip("poverty_rate:Q", title="Poverty Rate (%)", format=".1f"),
362
+ alt.Tooltip("crime_count:Q", title="Crime Count"),
363
+ ],
364
+ )
365
+ )
366
+ reg = sc.transform_regression("poverty_rate", "crime_count").mark_line(
367
+ color="gray", strokeDash=[4, 4], strokeWidth=1.5
368
+ )
369
+ st.altair_chart(
370
+ (sc + reg).properties(
371
+ width=360, height=440,
372
+ title=alt.TitleParams(
373
+ text="Higher Poverty β†’ More Crimes?",
374
+ subtitle="Each dot = one community area | Dashed = trend",
375
+ fontSize=13,
376
+ ),
377
+ ),
378
+ use_container_width=True,
379
+ )
380
+ else:
381
+ st.info("Not enough community-level overlap to render scatter plot.")
382
  else:
383
+ st.info("Community area data not available in this dataset sample.")
384
 
385
  # ── Citations ─────────────────────────────────────────────────────────────────
386
  st.markdown("---")
 
389
  """
390
  | Dataset | Source | Link |
391
  |---|---|---|
392
+ | Chicago Crimes 2001–Present | City of Chicago Data Portal | [ijzp-q8t2](https://data.cityofchicago.org/Public-Safety/Crimes-2001-to-Present/ijzp-q8t2) |
393
  | CTA 'L' Stop Locations | City of Chicago Data Portal | [8pix-ypme](https://data.cityofchicago.org/Transportation/CTA-System-Information-List-of-L-Stops/8pix-ypme) |
394
  | 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) |
395
  | Police District Boundaries (GeoJSON) | City of Chicago Data Portal | [24zt-jpfn](https://data.cityofchicago.org/Public-Safety/Boundaries-Police-Districts-current-/24zt-jpfn) |