jkhare2 commited on
Commit
7d7ee3c
·
verified ·
1 Parent(s): 1154062

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +131 -289
src/streamlit_app.py CHANGED
@@ -10,7 +10,6 @@ import streamlit as st
10
  import pandas as pd
11
  import numpy as np
12
  import plotly.express as px
13
- from urllib.parse import urlencode
14
 
15
 
16
  st.set_page_config(page_title="Chicago Parks in Motion", layout="wide")
@@ -27,118 +26,49 @@ def load_data():
27
  st.error("Could not load dataset from the City of Chicago portal.")
28
  raise e
29
 
30
- # Clean column names
31
  df.columns = [c.strip() for c in df.columns]
32
 
33
- # Fee as numeric if present
34
  if "fee" in df.columns:
35
  df["fee"] = pd.to_numeric(df["fee"], errors="coerce")
36
 
37
- # -------------------------
38
- # Extract lat/lon from location or the_geom
39
- # -------------------------
40
- lat_col = None
41
- lon_col = None
42
-
43
- if "location" in df.columns:
44
- def parse_lat_lon(val):
45
- if pd.isna(val):
46
- return (np.nan, np.nan)
47
- sval = str(val)
48
-
49
- # POINT (lon lat)
50
- if sval.startswith("POINT"):
51
- try:
52
- inside = sval.split("(", 1)[1].rstrip(")")
53
- lon, lat = map(float, inside.strip().split())
54
- return lat, lon
55
- except:
56
- return (np.nan, np.nan)
57
-
58
- # JSON-like with latitude / longitude
59
- if "latitude" in sval and "longitude" in sval:
60
- try:
61
- import json
62
- j = json.loads(sval)
63
- return float(j.get("latitude", np.nan)), float(j.get("longitude", np.nan))
64
- except:
65
- return (np.nan, np.nan)
66
-
67
- # Fallback: two floats
68
- import re
69
- nums = re.findall(r"-?\d+\.\d+", sval)
70
- if len(nums) >= 2:
71
- return float(nums[0]), float(nums[1])
72
  return (np.nan, np.nan)
73
-
74
- latlon = df["location"].map(parse_lat_lon)
75
- df["latitude"] = latlon.map(lambda x: x[0])
76
- df["longitude"] = latlon.map(lambda x: x[1])
77
- lat_col, lon_col = "latitude", "longitude"
78
-
79
- if "the_geom" in df.columns and (lat_col is None or lon_col is None):
80
- def parse_the_geom(val):
81
- if pd.isna(val):
82
  return (np.nan, np.nan)
83
- sval = str(val)
84
- if "POINT" in sval:
85
- try:
86
- inside = sval.split("(", 1)[1].rstrip(")")
87
- lon, lat = map(float, inside.strip().split())
88
- return lat, lon
89
- except:
90
- return (np.nan, np.nan)
91
- return (np.nan, np.nan)
92
 
93
- latlon = df["the_geom"].map(parse_the_geom)
 
94
  df["latitude"] = latlon.map(lambda x: x[0])
95
  df["longitude"] = latlon.map(lambda x: x[1])
96
- lat_col, lon_col = "latitude", "longitude"
 
 
97
 
98
- # -------------------------
99
- # Parse dates
100
- # -------------------------
101
  for c in ["start_date", "end_date"]:
102
  if c in df.columns:
103
  df[c] = pd.to_datetime(df[c], errors="coerce")
104
 
105
- # -------------------------
106
- # Activity type cleaning
107
- # -------------------------
108
  if "activity_type" in df.columns:
109
  df["activity_type_clean"] = df["activity_type"].str.title().fillna("Unknown")
110
  else:
111
- if "program_type" in df.columns:
112
- df["activity_type_clean"] = df["program_type"].str.title().fillna("Unknown")
113
- elif "category" in df.columns:
114
- df["activity_type_clean"] = df["category"].str.title().fillna("Unknown")
115
- else:
116
- df["activity_type_clean"] = "Unknown"
117
-
118
- # -------------------------
119
- # Park name extraction
120
- # -------------------------
121
- possible_park_cols = [
122
- "park_name",
123
- "park",
124
- "location_facility",
125
- "location_name",
126
- "location",
127
- "site_name"
128
- ]
129
-
130
- park_col = None
131
- for col in possible_park_cols:
132
- if col in df.columns:
133
- park_col = col
134
- break
135
-
136
- if park_col is not None:
137
- df["park_name"] = (
138
- df[park_col]
139
- .astype(str)
140
- .replace(["", "nan", "None"], "Unknown Park")
141
- )
142
  else:
143
  df["park_name"] = "Unknown Park"
144
 
@@ -148,16 +78,10 @@ def load_data():
148
  df = load_data()
149
 
150
  # -------------------------
151
- # Page header
152
  # -------------------------
153
  st.title("Chicago Parks in Motion: How Our City Plays")
154
- st.markdown("**Author:** Juhi Khare (jkhare2), Alisha Rawat (alishar4), Sutthana Koo-Anupong (sk188)")
155
-
156
- # Explicit central vis label for rubric
157
- st.info(
158
- "**Central Visualization:** The main map/bar chart of programs by park is our central interactive "
159
- "visualization for this public-facing data story."
160
- )
161
 
162
  # -------------------------
163
  # Sidebar filters
@@ -165,51 +89,31 @@ st.info(
165
  st.sidebar.header("Filters & Settings")
166
 
167
  categories = sorted(df["activity_type_clean"].dropna().unique())
168
- categories = [c for c in categories if c != "nan"]
169
  chosen_category = st.sidebar.selectbox("Activity category", ["All"] + categories)
170
 
171
- # Season calculation helper
172
  def season_from_date(dt):
173
- if pd.isna(dt):
174
- return "Unknown"
175
  m = dt.month
176
- if m in (12, 1, 2):
177
- return "Winter"
178
- if m in (3, 4, 5):
179
- return "Spring"
180
- if m in (6, 7, 8):
181
- return "Summer"
182
  return "Fall"
183
 
184
- if "start_date" in df.columns:
185
- df["season"] = df["start_date"].map(season_from_date)
186
- else:
187
- df["season"] = "Unknown"
188
-
189
- seasons = sorted(df["season"].dropna().unique())
190
  chosen_season = st.sidebar.selectbox("Season", ["All"] + seasons)
191
 
192
- # Price filter
193
- has_fee_col = "fee" in df.columns
194
- if has_fee_col:
195
- max_fee = float(np.nanmax(df["fee"].fillna(0)))
196
- fee_limit = st.sidebar.slider(
197
- "Maximum fee (USD)",
198
- 0.0,
199
- max(1.0, max_fee),
200
- float(max_fee)
201
- )
202
  else:
203
  fee_limit = None
204
 
205
- # Park name search
206
- park_query = st.sidebar.text_input("Search park name (partial)")
207
 
208
- # Accessibility note about filters
209
- st.sidebar.caption(
210
- "Filters help novice users explore the dataset without needing technical skills, "
211
- "making the app more accessible and intuitive."
212
- )
213
 
214
  # -------------------------
215
  # Filtering logic
@@ -219,110 +123,74 @@ if chosen_category != "All":
219
  filtered = filtered[filtered["activity_type_clean"] == chosen_category]
220
  if chosen_season != "All":
221
  filtered = filtered[filtered["season"] == chosen_season]
222
- if fee_limit is not None and "fee" in filtered.columns:
223
  filtered = filtered[filtered["fee"].fillna(0) <= fee_limit]
224
- if park_query:
225
- filtered = filtered[
226
- filtered["park_name"].str.contains(park_query, case=False, na=False)
227
- ]
228
-
229
- st.sidebar.markdown(f"**Programs shown:** {len(filtered):,}")
230
-
231
- # ======================================================
232
- # CENTRAL VISUALIZATION (FULL WIDTH, TOP)
233
- # ======================================================
234
- st.subheader("Central Interactive Visualization — Programs by Park")
235
-
236
- view_type = st.radio(
237
- "Choose how to view park activity:",
238
- ["Map (recommended)", "Bar chart (count by park)"],
239
- horizontal=True
240
- )
241
 
242
- if view_type.startswith("Map"):
243
- if (
244
- "latitude" in filtered.columns
245
- and "longitude" in filtered.columns
246
- and filtered[["latitude", "longitude"]].dropna().shape[0] > 0
247
- ):
248
- agg = (
249
- filtered
250
- .groupby(["park_name", "latitude", "longitude"], dropna=True)
251
- .size()
252
- .reset_index(name="count")
253
- )
 
 
 
 
 
254
  fig_map = px.scatter_mapbox(
255
  agg,
256
  lat="latitude",
257
  lon="longitude",
258
  size="count",
259
- size_max=32,
260
- hover_name="park_name",
261
- hover_data={"count": True},
262
  color="count",
263
- # Dark sequential orange scale – strong contrast against map
264
- color_continuous_scale=["#FFB366", "#CC5500"],
265
  zoom=10,
 
 
266
  height=600,
267
  )
268
- fig_map.update_traces(
269
- marker=dict(
270
- opacity=0.92,
271
- sizemode="area",
272
- )
273
- )
274
- fig_map.update_layout(
275
- mapbox_style="open-street-map",
276
- margin={"r": 0, "t": 0, "l": 0, "b": 0},
277
- )
278
  st.plotly_chart(fig_map, use_container_width=True)
279
- st.caption(
280
- "Each circle represents a park. Bigger and darker circles show parks with more programs. "
281
- "We use a dark sequential colormap so parks stand out clearly against the map background."
282
- )
283
  else:
284
- st.warning(
285
- "No geographic coordinates found in the dataset for the current filters. "
286
- "Try switching to the bar chart view."
287
- )
288
  else:
289
- agg = (
290
- filtered
291
- .groupby("park_name")
292
- .size()
293
- .reset_index(name="count")
294
- .sort_values("count", ascending=False)
295
- )
296
- top_n = 25
297
- agg_top = agg.head(top_n)
298
 
299
  fig_bar = px.bar(
300
- agg_top,
301
  x="count",
302
  y="park_name",
303
  orientation="h",
304
  color="count",
305
  color_continuous_scale="Cividis",
306
- labels={"count": "Number of programs", "park_name": "Park"},
307
- height=700,
308
  )
309
- fig_bar.update_layout(yaxis={"categoryorder": "total ascending"})
310
  st.plotly_chart(fig_bar, use_container_width=True)
311
- st.caption(
312
- "This bar chart shows the parks with the most programs. "
313
- "A sequential 'Cividis' colormap helps highlight which parks stand out, "
314
- "while remaining friendly for viewers with color-vision differences."
315
- )
316
 
317
- # Optional sample table
318
- if st.checkbox("Show program sample table (first 50 rows)"):
319
- st.dataframe(filtered.head(50))
 
 
 
 
 
320
 
321
- # ======================================================
322
- # CONTEXTUAL VISUALIZATION 1 — Activity Category Breakdown
323
- # ======================================================
324
- st.markdown("---")
325
- st.subheader("Contextual Visualization 1 — What kinds of activities do parks offer?")
326
 
327
  cat_counts = df["activity_type_clean"].value_counts().reset_index()
328
  cat_counts.columns = ["activity_type", "count"]
@@ -332,92 +200,66 @@ fig_cat = px.pie(
332
  names="activity_type",
333
  values="count",
334
  hole=0.35,
335
- color_discrete_sequence=px.colors.qualitative.Set3,
336
  )
337
  st.plotly_chart(fig_cat, use_container_width=True)
338
 
339
- st.caption(
340
- "This chart shows how programs are split across activity categories, such as sports, aquatics, arts, "
341
- "and youth programming. We use a qualitative color palette so each category has its own distinct color, "
342
- "making it easier for readers to tell them apart at a glance."
343
- )
344
-
345
- # ======================================================
346
- # CONTEXTUAL VISUALIZATION 2 — Programs by Season
347
- # ======================================================
348
- st.markdown("---")
349
- st.subheader("Contextual Visualization 2 — When are most programs offered?")
350
 
351
- season_col = None
352
- for c in df.columns:
353
- if "season" in c.lower():
354
- season_col = c
355
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
- if season_col is None:
358
- st.error("No season information found in the dataset.")
359
- else:
360
- season_counts = df[season_col].dropna().value_counts().reset_index()
361
- season_counts.columns = ["Season", "Program Count"]
362
-
363
- fig_season = px.bar(
364
- season_counts,
365
- x="Season",
366
- y="Program Count",
367
- color="Program Count",
368
- color_continuous_scale="Tealgrn",
369
- text="Program Count",
370
- title="Number of Programs Offered by Season",
371
- )
372
- fig_season.update_traces(textposition="outside")
373
 
374
- st.plotly_chart(fig_season, use_container_width=True)
375
- st.caption(
376
- "This bar chart shows how many programs run in each season. "
377
- "A sequential colormap emphasizes the difference between busy and quiet seasons without adding clutter, "
378
- "which helps novice viewers focus on the main pattern."
379
- )
 
380
 
381
- # ======================================================
382
- # DATA & NOTEBOOK INFO
383
- # ======================================================
384
- st.markdown("---")
385
- st.subheader("Data & Notebook")
386
 
387
  st.markdown("""
388
- **Primary dataset:** Chicago Park District Activities City of Chicago Data Portal
389
- <https://data.cityofchicago.org/Parks-Recreation/Chicago-Park-District-Activities/tn7v-6rnw>
 
390
 
391
- Both contextual visualizations (the activity category breakdown and the seasonal program chart) were also created in our Jupyter Notebook
392
- as part of our original analysis, then migrated here into this Streamlit app for a more public-friendly, interactive experience.
393
  """)
394
 
395
- # ======================================================
396
- # WRITE-UP (3 simple paragraphs for the public)
397
- # ======================================================
398
  st.markdown("---")
399
- st.header("What this data story is showing")
400
-
401
  st.markdown("""
402
- Chicago’s parks offer many kinds of activities for people of all ages. These include sports, arts, fitness classes, youth programs, and seasonal events.
403
- Each row in this dataset represents one program offered at a park. Our main interactive map helps readers quickly see which parks offer the most activities.
404
- Bigger or darker circles show parks with more programs, making it easy to spot busy parks versus quieter ones.
405
-
406
- Where a park is located also matters. Neighborhoods that are larger or more central usually have more programs because they have more space, more facilities, and more visitors.
407
- With the filters on the left, anyone can explore the data by season, activity type, price, or park name.
408
- This makes the information easy to use even for someone with no data experience.
409
- For example, you can look for free programs, summer-only programs, or activities at a specific park in your neighborhood.
410
-
411
- This project also highlights questions about access and opportunities. Some parks offer a wide range of programs, while others have fewer options or mostly offer only one type of activity.
412
- By looking at categories, seasons, and fees, readers can start to see patterns in which communities have more choices and which ones may need more support.
413
- Our goal is to turn public data into something simple and useful, so Chicago residents and decision-makers can better understand how parks are serving their communities.
414
  """)
415
-
416
- # ======================================================
417
- # FOOTER
418
- # ======================================================
419
- st.markdown("---")
420
- st.markdown(
421
- "**Acknowledgements & citations:** Data retrieved directly from the City of Chicago Data Portal (Socrata API). "
422
- "All visualizations were created by the authors using Python and Streamlit."
423
- )
 
10
  import pandas as pd
11
  import numpy as np
12
  import plotly.express as px
 
13
 
14
 
15
  st.set_page_config(page_title="Chicago Parks in Motion", layout="wide")
 
26
  st.error("Could not load dataset from the City of Chicago portal.")
27
  raise e
28
 
 
29
  df.columns = [c.strip() for c in df.columns]
30
 
 
31
  if "fee" in df.columns:
32
  df["fee"] = pd.to_numeric(df["fee"], errors="coerce")
33
 
34
+ # Extract lat/lon
35
+ def extract_latlon(val):
36
+ if pd.isna(val):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  return (np.nan, np.nan)
38
+ sval = str(val)
39
+ if "POINT" in sval:
40
+ try:
41
+ inside = sval.split("(", 1)[1].rstrip(")")
42
+ lon, lat = map(float, inside.split())
43
+ return lat, lon
44
+ except:
 
 
45
  return (np.nan, np.nan)
46
+ return (np.nan, np.nan)
 
 
 
 
 
 
 
 
47
 
48
+ if "location" in df.columns:
49
+ latlon = df["location"].map(extract_latlon)
50
  df["latitude"] = latlon.map(lambda x: x[0])
51
  df["longitude"] = latlon.map(lambda x: x[1])
52
+ else:
53
+ df["latitude"] = np.nan
54
+ df["longitude"] = np.nan
55
 
56
+ # Dates
 
 
57
  for c in ["start_date", "end_date"]:
58
  if c in df.columns:
59
  df[c] = pd.to_datetime(df[c], errors="coerce")
60
 
61
+ # Activity type clean
 
 
62
  if "activity_type" in df.columns:
63
  df["activity_type_clean"] = df["activity_type"].str.title().fillna("Unknown")
64
  else:
65
+ df["activity_type_clean"] = "Unknown"
66
+
67
+ # Park name
68
+ possible_names = ["park_name", "park", "location_facility", "location_name", "site_name"]
69
+ park_col = next((col for col in possible_names if col in df.columns), None)
70
+ if park_col:
71
+ df["park_name"] = df[park_col].astype(str).replace(["", "nan", "None"], "Unknown Park")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  else:
73
  df["park_name"] = "Unknown Park"
74
 
 
78
  df = load_data()
79
 
80
  # -------------------------
81
+ # Title
82
  # -------------------------
83
  st.title("Chicago Parks in Motion: How Our City Plays")
84
+ st.markdown("**Authors:** Juhi Khare (jkhare2), Alisha Rawat (alishar4), Sutthana Koo-Anupong (sk188)")
 
 
 
 
 
 
85
 
86
  # -------------------------
87
  # Sidebar filters
 
89
  st.sidebar.header("Filters & Settings")
90
 
91
  categories = sorted(df["activity_type_clean"].dropna().unique())
 
92
  chosen_category = st.sidebar.selectbox("Activity category", ["All"] + categories)
93
 
94
+ # Season detection
95
  def season_from_date(dt):
96
+ if pd.isna(dt): return "Unknown"
 
97
  m = dt.month
98
+ if m in [12,1,2]: return "Winter"
99
+ if m in [3,4,5]: return "Spring"
100
+ if m in [6,7,8]: return "Summer"
 
 
 
101
  return "Fall"
102
 
103
+ df["season"] = df["start_date"].map(season_from_date)
104
+ seasons = sorted(df["season"].unique())
 
 
 
 
105
  chosen_season = st.sidebar.selectbox("Season", ["All"] + seasons)
106
 
107
+ if "fee" in df.columns:
108
+ max_fee = float(df["fee"].fillna(0).max())
109
+ fee_limit = st.sidebar.slider("Maximum fee (USD)", 0.0, max_fee, max_fee)
 
 
 
 
 
 
 
110
  else:
111
  fee_limit = None
112
 
113
+ park_search = st.sidebar.text_input("Search park name (partial)")
 
114
 
115
+ # Accessibility hint
116
+ st.sidebar.caption("Filters help beginners explore the dataset easily without technical skills.")
 
 
 
117
 
118
  # -------------------------
119
  # Filtering logic
 
123
  filtered = filtered[filtered["activity_type_clean"] == chosen_category]
124
  if chosen_season != "All":
125
  filtered = filtered[filtered["season"] == chosen_season]
126
+ if fee_limit is not None:
127
  filtered = filtered[filtered["fee"].fillna(0) <= fee_limit]
128
+ if park_search:
129
+ filtered = filtered[filtered["park_name"].str.contains(park_search, case=False)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
+ st.sidebar.write(f"Programs shown: **{len(filtered):,}**")
132
+
133
+ # -------------------------
134
+ # CENTRAL VISUALIZATION
135
+ # -------------------------
136
+ st.header("Central Interactive Visualization — Programs by Park")
137
+
138
+ view = st.radio("Choose a view:", ["Map (recommended)", "Bar chart"], horizontal=True)
139
+
140
+ if view.startswith("Map"):
141
+ # Aggregate for map
142
+ agg = (
143
+ filtered.groupby(["park_name", "latitude", "longitude"], dropna=True)
144
+ .size().reset_index(name="count")
145
+ )
146
+
147
+ if agg.dropna().shape[0] > 0:
148
  fig_map = px.scatter_mapbox(
149
  agg,
150
  lat="latitude",
151
  lon="longitude",
152
  size="count",
 
 
 
153
  color="count",
154
+ color_continuous_scale="Bluered",
155
+ size_max=28,
156
  zoom=10,
157
+ hover_name="park_name",
158
+ hover_data={"count": True},
159
  height=600,
160
  )
161
+ fig_map.update_layout(mapbox_style="open-street-map", margin=dict(l=0,r=0,b=0,t=0))
 
 
 
 
 
 
 
 
 
162
  st.plotly_chart(fig_map, use_container_width=True)
 
 
 
 
163
  else:
164
+ st.warning("No geographic coordinates available for this filtered view.")
 
 
 
165
  else:
166
+ agg = filtered.groupby("park_name").size().reset_index(name="count")
167
+ agg = agg.sort_values("count", ascending=False).head(20)
 
 
 
 
 
 
 
168
 
169
  fig_bar = px.bar(
170
+ agg,
171
  x="count",
172
  y="park_name",
173
  orientation="h",
174
  color="count",
175
  color_continuous_scale="Cividis",
176
+ height=600,
 
177
  )
178
+ fig_bar.update_layout(yaxis={'categoryorder':'total ascending'})
179
  st.plotly_chart(fig_bar, use_container_width=True)
 
 
 
 
 
180
 
181
+ # Explanation under central viz
182
+ st.markdown("""
183
+ **What this visualization shows:**
184
+ This is our main visualization because it helps readers understand where activities are happening across Chicago’s parks.
185
+ The map shows each park as a circle, where larger and darker circles represent locations with more programs.
186
+ This makes it easy to see which areas are activity hubs and which are quieter. The filters allow anyone to explore patterns by season,
187
+ category, price, or park—without needing technical experience.
188
+ """)
189
 
190
+ # -------------------------
191
+ # CONTEXTUAL VISUALIZATION 1
192
+ # -------------------------
193
+ st.header("Contextual Visualization 1 — Activity Category Breakdown")
 
194
 
195
  cat_counts = df["activity_type_clean"].value_counts().reset_index()
196
  cat_counts.columns = ["activity_type", "count"]
 
200
  names="activity_type",
201
  values="count",
202
  hole=0.35,
203
+ color_discrete_sequence=px.colors.sequential.RdBu
204
  )
205
  st.plotly_chart(fig_cat, use_container_width=True)
206
 
207
+ st.markdown("""
208
+ **Why this matters:**
209
+ This chart shows what kinds of activities Chicago parks offer most often—such as sports, aquatics, arts, or youth programs.
210
+ It helps readers understand the variety of programs available across the city.
211
+ Using a simple color palette keeps the chart readable for people who may not be familiar with data visualization.
212
+ """)
 
 
 
 
 
213
 
214
+ # -------------------------
215
+ # CONTEXTUAL VISUALIZATION 2
216
+ # -------------------------
217
+ st.header("Contextual Visualization 2 — Programs by Season")
218
+
219
+ season_counts = df["season"].value_counts().reset_index()
220
+ season_counts.columns = ["Season", "Program Count"]
221
+
222
+ fig_season = px.bar(
223
+ season_counts,
224
+ x="Season",
225
+ y="Program Count",
226
+ color="Program Count",
227
+ color_continuous_scale="Tealgrn",
228
+ text="Program Count",
229
+ height=500,
230
+ )
231
+ fig_season.update_traces(textposition="outside")
232
 
233
+ st.plotly_chart(fig_season, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ st.markdown("""
236
+ **Why this is helpful:**
237
+ This chart shows when programs are most active throughout the year.
238
+ Comparing seasons helps readers see whether summer is the busiest time, or whether activities are spread evenly.
239
+ This makes it easier for residents and planners to understand how weather, school schedules, and community needs
240
+ shape the timing of park programs.
241
+ """)
242
 
243
+ # -------------------------
244
+ # FINAL 3-PARAGRAPH EXPLANATION (as provided by you, unchanged)
245
+ # -------------------------
246
+ st.header("📝 What this data story is showing")
 
247
 
248
  st.markdown("""
249
+ Chicago’s parks offer many kinds of activities for people of all ages. These include sports, arts, fitness classes, youth programs, and seasonal events. Each row in this dataset represents one program offered at a park. Our main interactive map helps readers quickly see which parks offer the most activities. Bigger or darker circles show parks with more programs, making it easy to spot busy parks versus quieter ones.
250
+
251
+ Where a park is located also matters. Neighborhoods that are larger or more central usually have more programs because they have more space, more facilities, and more visitors. With the filters on the left, anyone can explore the data by season, activity type, price, or park name. This makes the information easy to use even for someone with no data experience. For example, you can look for free programs, summer-only programs, or activities at a specific park in your neighborhood.
252
 
253
+ This project also highlights questions about access and opportunities. Some parks offer a wide range of programs, while others have fewer options or mostly offer only one type of activity. By looking at categories, seasons, and fees, readers can start to see patterns in which communities have more choices and which ones may need more support. Our goal is to turn public data into something simple and useful, so Chicago residents and decision-makers can better understand how parks are serving their communities.
 
254
  """)
255
 
256
+ # -------------------------
257
+ # CITATIONS
258
+ # -------------------------
259
  st.markdown("---")
260
+ st.subheader("Citations & Data Sources")
 
261
  st.markdown("""
262
+ **Primary dataset:**
263
+ Chicago Park District Activities City of Chicago Data Portal
264
+ https://data.cityofchicago.org/Parks-Recreation/Chicago-Park-District-Activities/tn7v-6rnw
 
 
 
 
 
 
 
 
 
265
  """)