zliang commited on
Commit
0209d2c
·
verified ·
1 Parent(s): 63ff9c8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +292 -176
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # app_simple_plus.py — ASAP Explorer (no table, log-scale map, English-filtered word cloud, top-15 composers)
2
  import re
3
  import pandas as pd
4
  import numpy as np
@@ -9,14 +8,35 @@ from dateutil import parser as dateparser
9
  import gradio as gr
10
  from datasets import load_dataset
11
  import os
 
12
 
 
 
 
13
  COMPOSITION_PATH = "ASAPcomposition.csv"
14
  PEOPLE_PATH = "ASAPdata.csv"
15
  PLOT_TEMPLATE = "plotly_white"
16
  HF_TOKEN = os.environ.get("HF_TOKEN")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- # ===== Word cloud English filter =====
19
- MIN_ASCII_RATIO = 0.7 # keep titles whose ASCII-char ratio >= this
 
20
 
21
  def _ascii_ratio(s: str) -> float:
22
  if not s:
@@ -25,9 +45,8 @@ def _ascii_ratio(s: str) -> float:
25
  ascii_count = sum(1 for ch in s if ord(ch) < 128)
26
  return ascii_count / total if total else 0.0
27
 
28
- # =========================
29
- # Robust Year Parsing
30
- # =========================
31
  def _norm_year_text(s: str) -> str:
32
  s = s.strip()
33
  s = s.replace("–", "-").replace("—", "-").replace(" to ", "-")
@@ -37,7 +56,8 @@ def _norm_year_text(s: str) -> str:
37
  sl = re.sub(r"\s+", " ", sl).strip()
38
  return sl
39
 
40
- def _parse_year_robust(y):
 
41
  if pd.isna(y):
42
  return (np.nan, np.nan)
43
  s = _norm_year_text(str(y))
@@ -76,8 +96,10 @@ def _parse_year_robust(y):
76
  pass
77
  return (np.nan, np.nan)
78
 
 
79
  def _parse_duration(d):
80
- if pd.isna(d): return np.nan
 
81
  s = str(d).strip().lower()
82
  if ":" in s:
83
  parts = [p.strip() for p in s.split(":")]
@@ -97,32 +119,26 @@ def _parse_duration(d):
97
  return np.nan
98
 
99
  # =========================
100
- # Loaders (compositions)
101
  # =========================
102
- def load_compositions():
103
- ds = load_dataset("csv", data_files="hf://datasets/zliang/ASAP/ASAPcomposition.csv", token=HF_TOKEN)
104
- df = ds["train"].to_pandas()
105
- df = df.rename(columns={
106
- "Name": "Composer",
107
- "Composition Title": "Title",
108
- "Duration": "Duration",
109
- "Year": "Year",
110
- "Level": "Level"
111
- })
112
- starts, ends = [], []
113
- for raw in df.get("Year", pd.Series([np.nan] * len(df))):
114
- y0, y1 = _parse_year_robust(raw)
115
- starts.append(y0); ends.append(y1)
116
- df["YearStart"] = starts
117
- df["YearEnd"] = ends
118
- df["YearParsed"] = df["YearStart"]
119
- df["DurationMin"] = df.get("Duration", pd.Series([np.nan]*len(df))).map(_parse_duration)
120
- df["LevelStd"] = df.get("Level", pd.Series(["Unknown"]*len(df))).fillna("Unknown")
121
- return df
122
 
123
- # =========================
124
- # Demonyms & country normalization (people)
125
- # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  _COUNTRY_ALIASES = {
127
  "czech republic": "Czechia",
128
  "viet nam": "Vietnam",
@@ -179,19 +195,6 @@ _DEMONYM_TO_COUNTRY = {
179
  "australian": "Australia", "new zealander": "New Zealand", "fijian": "Fiji", "samoan": "Samoa", "tongan": "Tonga",
180
  }
181
 
182
- def _col(df, *candidates):
183
- cols = {c.lower().strip(): c for c in df.columns}
184
- for cand in candidates:
185
- k = cand.lower().strip()
186
- if k in cols: return cols[k]
187
- return None
188
-
189
- def _basic_clean_nat(s: str) -> str:
190
- s = str(s)
191
- s = s.replace("(", " ").replace(")", " ")
192
- s = re.sub(r"[.\u200b]", " ", s)
193
- s = re.sub(r"\s+", " ", s)
194
- return s.strip()
195
 
196
  def _normalize_country_or_demonym(token: str):
197
  if token is None or (isinstance(token, float) and np.isnan(token)):
@@ -199,20 +202,25 @@ def _normalize_country_or_demonym(token: str):
199
  t = _basic_clean_nat(token).lower()
200
  t = re.sub(r"\b(citizen|national|born|of|the|composer)\b", " ", t)
201
  t = re.sub(r"\s+", " ", t).strip()
202
- if t in _DEMONYM_TO_COUNTRY: return _DEMONYM_TO_COUNTRY[t]
203
- if t in _COUNTRY_ALIASES: return _COUNTRY_ALIASES[t]
 
 
204
  short = {
205
  "usa": "United States", "u.s.a": "United States", "u.s": "United States", "us": "United States",
206
  "uk": "United Kingdom", "england": "United Kingdom", "scotland": "United Kingdom", "wales": "United Kingdom",
207
  "korea": "South Korea", "russia": "Russia",
208
  }
209
- if t in short: return short[t]
210
- if re.search(r"[a-z]", t): # likely a country already
 
211
  return t.title()
212
  return np.nan
213
 
 
214
  def _split_and_normalize_nationalities(value):
215
- if pd.isna(value): return []
 
216
  s = _basic_clean_nat(value)
217
  s = re.sub(r"\s*(/|;|&|\band\b|,)\s*", ",", s, flags=re.I)
218
  parts = [p for p in (x.strip() for x in s.split(",")) if p]
@@ -223,56 +231,137 @@ def _split_and_normalize_nationalities(value):
223
  out.append(norm)
224
  return out
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def load_people():
227
- ds = load_dataset("csv", data_files="hf://datasets/zliang/ASAP/ASAPdata.csv", token=HF_TOKEN)
 
 
 
 
 
 
 
 
228
  df = ds["train"].to_pandas()
 
 
229
  col_gender = _col(df, "Gender", "gender")
230
  col_nat = _col(df, "Nationality", "Country", "nationality", "country")
231
  col_eth = _col(df, "Ethnicity", "ethnicity")
232
  col_era = _col(df, "Music Era", "Era", "Period", "music era", "era", "period")
233
- if col_gender: df = df.rename(columns={col_gender: "Gender"})
234
- else: df["Gender"] = np.nan
235
- if col_nat: df = df.rename(columns={col_nat: "Nationality"})
236
- else: df["Nationality"] = np.nan
237
- if col_eth: df = df.rename(columns={col_eth: "Ethnicity"})
238
- else: df["Ethnicity"] = np.nan
239
- if col_era: df = df.rename(columns={col_era: "Music Era"})
240
- else: df["Music Era"] = np.nan
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  df["NationalityList"] = df["Nationality"].apply(_split_and_normalize_nationalities)
242
  df_exp = df.explode("NationalityList").rename(columns={"NationalityList": "NationalityNorm"})
243
  df_exp["NationalityNorm"] = df_exp["NationalityNorm"].replace({"": np.nan})
 
 
244
  return df, df_exp
245
 
246
  # =========================
247
- # Visuals Compositions
248
  # =========================
 
 
 
 
 
 
 
 
249
  def area_timeline(df):
250
  d = df.dropna(subset=["YearParsed"])
251
- if d.empty: return go.Figure()
252
- counts = d.groupby(["YearParsed", "LevelStd"])["Title"].count().reset_index(name="Count")
253
- counts = counts.sort_values("YearParsed")
254
- counts["Smoothed"] = counts.groupby("LevelStd")["Count"].transform(lambda s: s.rolling(3, min_periods=1).mean())
255
- fig = px.area(counts, x="YearParsed", y="Smoothed", color="LevelStd",
256
- title="Compositions per Year by Level", template=PLOT_TEMPLATE)
257
- fig.update_layout(margin=dict(l=10, r=10, t=50, b=10), legend_title="Level")
258
- fig.update_xaxes(title="Year"); fig.update_yaxes(title="Smoothed count")
259
- return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  def bar_top_composers(df, top_n=15):
262
  if df.empty:
263
  return go.Figure()
264
 
265
- # group + sort
266
  d = (
267
- df.groupby("Composer")["Title"].count()
268
- .sort_values(ascending=False)
269
- .head(top_n)
270
- .reset_index()
271
- .rename(columns={"Title": "Count"})
272
  )
273
-
274
- # order composers by descending count
275
- composer_order = d["Composer"].tolist()
276
 
277
  fig = px.bar(
278
  d,
@@ -280,69 +369,59 @@ def bar_top_composers(df, top_n=15):
280
  y="Composer",
281
  orientation="h",
282
  title=f"Top {top_n} Composers by Number of Compositions",
283
- template=PLOT_TEMPLATE
284
- )
285
-
286
- # force y-axis order: most at top → least at bottom
287
- fig.update_layout(
288
- yaxis=dict(categoryorder="array", categoryarray=composer_order[::-1]),
289
- margin=dict(l=10, r=10, t=50, b=10)
290
  )
291
-
292
- return fig
293
 
294
 
295
  def treemap_composer_level(df):
296
- if df.empty: return go.Figure()
297
- d = df.groupby(["Composer", "LevelStd"])["Title"].count().reset_index(name="Count")
298
- fig = px.treemap(d, path=["Composer", "LevelStd"], values="Count",
299
- title="Catalog Structure: Composer → Level", template=PLOT_TEMPLATE)
300
- fig.update_layout(margin=dict(l=10, r=10, t=50, b=10))
301
- return fig
302
 
303
  def make_wordcloud(df):
 
 
 
 
304
  titles = df["Title"].dropna().astype(str)
305
- # English filter: keep titles with sufficient ASCII ratio
306
  titles = [t for t in titles if _ascii_ratio(t) >= MIN_ASCII_RATIO]
307
  if not titles:
308
  return None
309
  text = " ".join(titles)
310
  stopwords = set(STOPWORDS)
311
- stopwords.update({"Piano", "II", "IV","V","III","Piece","Pieces","Op","VII","IX","VIII"}) # 👈 add more as needed
312
- wc = WordCloud(width=1200, height=500, background_color="white",
313
- stopwords=stopwords, collocations=True)
314
- return wc.generate(text).to_image()
315
 
316
- # =========================
317
- # Visuals People (pies + LOG map)
318
- # =========================
319
- def pie(df_people_exp_or_raw, column, title, max_slices=12):
320
- if column not in df_people_exp_or_raw.columns:
321
  return go.Figure()
322
- s = df_people_exp_or_raw[column].dropna()
323
- if s.empty: return go.Figure()
324
  counts = s.value_counts(dropna=False)
325
  if len(counts) > max_slices:
326
- head = counts.iloc[:max_slices-1]
327
- other = pd.Series({"Other": counts.iloc[max_slices-1:].sum()})
328
  counts = pd.concat([head, other])
329
  d = counts.reset_index()
330
- d.columns = [column, "Count"]
331
- fig = px.pie(d, names=column, values="Count", title=title, template=PLOT_TEMPLATE, hole=0.35)
332
  fig.update_traces(textposition="inside", textinfo="percent+label")
333
- fig.update_layout(margin=dict(l=10, r=10, t=50, b=10))
334
- return fig
335
 
336
  def world_map_nationality(df_people_exp):
337
- # Exploded + normalized nationalities expected here
338
  s = df_people_exp["NationalityNorm"].dropna()
339
  if s.empty:
340
  return go.Figure()
341
 
342
  counts = s.value_counts().reset_index()
343
  counts.columns = ["country", "count"]
344
-
345
- # ---- log transform in the data (avoid coloraxis.type) ----
346
  counts["count_log10"] = np.log10(counts["count"] + 1.0)
347
 
348
  fig = px.choropleth(
@@ -352,93 +431,130 @@ def world_map_nationality(df_people_exp):
352
  color="count_log10",
353
  color_continuous_scale="Blues",
354
  title="Global Distribution by Nationality (log scale)",
355
- template=PLOT_TEMPLATE,
356
  hover_data={"count": True, "count_log10": False, "country": False},
357
  )
358
 
359
- # Colorbar: show a few human-friendly ticks with labels in raw counts
360
- if not counts.empty:
361
- vmax = int(counts["count"].max())
362
- # choose nice ticks (1, 3, 10, 30, 100, ...)
363
- raw_ticks = []
364
- step = 1
365
- base = [1, 3]
366
- while step <= vmax:
367
- for b in base:
368
- val = b * step
369
- if val <= vmax:
370
- raw_ticks.append(val)
371
- step *= 10
372
- raw_ticks = sorted(set([1] + raw_ticks + [vmax]))
373
-
374
- tickvals = np.log10(np.array(raw_ticks, dtype=float) + 1.0)
375
- ticktext = [str(v) for v in raw_ticks]
376
-
377
- fig.update_layout(
378
- margin=dict(l=10, r=10, t=50, b=10),
379
- coloraxis_colorbar=dict(title="Count", tickvals=tickvals, ticktext=ticktext),
380
- )
381
- else:
382
- fig.update_layout(margin=dict(l=10, r=10, t=50, b=10), coloraxis_colorbar_title="Count")
383
-
384
- return fig
385
 
386
  # =========================
387
- # Pipeline
388
  # =========================
389
- def compute_all():
390
- comp = load_compositions()
391
  ppl_raw, ppl_exp = load_people()
392
  return (
393
- # compositions
 
 
 
 
 
 
 
 
 
 
394
  area_timeline(comp),
395
  bar_top_composers(comp),
 
 
 
 
 
 
396
  treemap_composer_level(comp),
397
  make_wordcloud(comp),
398
- # people pies + log map
399
- pie(ppl_raw, "Gender", "Gender"),
400
- pie(ppl_exp, "NationalityNorm", "Nationality"),
401
- pie(ppl_raw, "Ethnicity", "Ethnicity"),
402
- pie(ppl_raw, "Music Era", "Music Era"),
403
- world_map_nationality(ppl_exp)
404
  )
405
 
406
  # =========================
407
- # UI (simple, no uploads, no filters, no table)
408
  # =========================
 
409
  theme = gr.themes.Soft(primary_hue="blue", neutral_hue="slate")
410
 
411
- with gr.Blocks(title="A Seat At The Piano", theme=theme) as demo:
 
 
 
 
 
 
 
412
  gr.Markdown(
413
- f"## 🎼 ASAP Explorer\n"
414
- f"A Seat at the Piano was founded in the summer of 2020 in the midst of social and racial reckoning around the world. We are a team of classically trained pianists with varying backgrounds and experiences, who strive to raise the voices of those who are less heard and to inspire more thoughtful, inclusive programming within the performing and pedagogical spheres.\n"
 
415
  )
416
 
417
- with gr.Tabs():
418
- with gr.Tab("Composer Demographics"):
419
- with gr.Row():
420
- pie_gender = gr.Plot()
421
- pie_nat = gr.Plot()
422
- with gr.Row():
423
- pie_eth = gr.Plot()
424
- pie_era = gr.Plot()
425
- world_map = gr.Plot()
426
- with gr.Tab("Compositions Overview"):
427
- timeline_plot = gr.Plot()
428
- top_plot = gr.Plot()
429
- with gr.Tab("Compositions Catogories"):
430
- tree_plot = gr.Plot()
431
- wc_img = gr.Image(type="pil")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
 
 
 
434
  demo.load(
435
- compute_all,
436
  inputs=None,
437
- outputs=[
438
- timeline_plot, top_plot, tree_plot, wc_img,
439
- pie_gender, pie_nat, pie_eth, pie_era, world_map
440
- ],
441
  )
442
 
443
  if __name__ == "__main__":
444
- demo.launch()
 
 
 
1
  import re
2
  import pandas as pd
3
  import numpy as np
 
8
  import gradio as gr
9
  from datasets import load_dataset
10
  import os
11
+ from typing import Tuple, Optional
12
 
13
+ # =========================
14
+ # Config
15
+ # =========================
16
  COMPOSITION_PATH = "ASAPcomposition.csv"
17
  PEOPLE_PATH = "ASAPdata.csv"
18
  PLOT_TEMPLATE = "plotly_white"
19
  HF_TOKEN = os.environ.get("HF_TOKEN")
20
+ MIN_ASCII_RATIO = 0.7 # wordcloud English-ish filter
21
+
22
+ # Small, mobile-friendly visual defaults
23
+ _MOBILE_LAYOUT_KW = dict(
24
+ margin=dict(l=8, r=8, t=38, b=8),
25
+ legend=dict(orientation="h", yanchor="bottom", y=-0.25, xanchor="left", x=0),
26
+ transition_duration=0,
27
+ )
28
+
29
+ # =========================
30
+ # In-memory caches (avoid recomputation + network hits)
31
+ # =========================
32
+ _COMPOSITIONS_DF: Optional[pd.DataFrame] = None
33
+ _PEOPLE_RAW_DF: Optional[pd.DataFrame] = None
34
+ _PEOPLE_EXP_DF: Optional[pd.DataFrame] = None
35
+ _WC_IMG = None
36
 
37
+ # =========================
38
+ # Utilities
39
+ # =========================
40
 
41
  def _ascii_ratio(s: str) -> float:
42
  if not s:
 
45
  ascii_count = sum(1 for ch in s if ord(ch) < 128)
46
  return ascii_count / total if total else 0.0
47
 
48
+ # ---- Robust Year Parsing ----
49
+
 
50
  def _norm_year_text(s: str) -> str:
51
  s = s.strip()
52
  s = s.replace("–", "-").replace("—", "-").replace(" to ", "-")
 
56
  sl = re.sub(r"\s+", " ", sl).strip()
57
  return sl
58
 
59
+
60
+ def _parse_year_robust(y) -> Tuple[float, float]:
61
  if pd.isna(y):
62
  return (np.nan, np.nan)
63
  s = _norm_year_text(str(y))
 
96
  pass
97
  return (np.nan, np.nan)
98
 
99
+
100
  def _parse_duration(d):
101
+ if pd.isna(d):
102
+ return np.nan
103
  s = str(d).strip().lower()
104
  if ":" in s:
105
  parts = [p.strip() for p in s.split(":")]
 
119
  return np.nan
120
 
121
  # =========================
122
+ # Loaders (with caching)
123
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
+ def _col(df, *candidates):
126
+ cols = {c.lower().strip(): c for c in df.columns}
127
+ for cand in candidates:
128
+ k = cand.lower().strip()
129
+ if k in cols:
130
+ return cols[k]
131
+ return None
132
+
133
+
134
+ def _basic_clean_nat(s: str) -> str:
135
+ s = str(s)
136
+ s = s.replace("(", " ").replace(")", " ")
137
+ s = re.sub(r"[.\u200b]", " ", s)
138
+ s = re.sub(r"\s+", " ", s)
139
+ return s.strip()
140
+
141
+
142
  _COUNTRY_ALIASES = {
143
  "czech republic": "Czechia",
144
  "viet nam": "Vietnam",
 
195
  "australian": "Australia", "new zealander": "New Zealand", "fijian": "Fiji", "samoan": "Samoa", "tongan": "Tonga",
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  def _normalize_country_or_demonym(token: str):
200
  if token is None or (isinstance(token, float) and np.isnan(token)):
 
202
  t = _basic_clean_nat(token).lower()
203
  t = re.sub(r"\b(citizen|national|born|of|the|composer)\b", " ", t)
204
  t = re.sub(r"\s+", " ", t).strip()
205
+ if t in _DEMONYM_TO_COUNTRY:
206
+ return _DEMONYM_TO_COUNTRY[t]
207
+ if t in _COUNTRY_ALIASES:
208
+ return _COUNTRY_ALIASES[t]
209
  short = {
210
  "usa": "United States", "u.s.a": "United States", "u.s": "United States", "us": "United States",
211
  "uk": "United Kingdom", "england": "United Kingdom", "scotland": "United Kingdom", "wales": "United Kingdom",
212
  "korea": "South Korea", "russia": "Russia",
213
  }
214
+ if t in short:
215
+ return short[t]
216
+ if re.search(r"[a-z]", t):
217
  return t.title()
218
  return np.nan
219
 
220
+
221
  def _split_and_normalize_nationalities(value):
222
+ if pd.isna(value):
223
+ return []
224
  s = _basic_clean_nat(value)
225
  s = re.sub(r"\s*(/|;|&|\band\b|,)\s*", ",", s, flags=re.I)
226
  parts = [p for p in (x.strip() for x in s.split(",")) if p]
 
231
  out.append(norm)
232
  return out
233
 
234
+
235
+ # ---- Data loaders (cached in-module) ----
236
+
237
+ def load_compositions() -> pd.DataFrame:
238
+ global _COMPOSITIONS_DF
239
+ if _COMPOSITIONS_DF is not None:
240
+ return _COMPOSITIONS_DF
241
+
242
+ ds = load_dataset(
243
+ "csv",
244
+ data_files="hf://datasets/zliang/ASAP/ASAPcomposition.csv",
245
+ token=HF_TOKEN,
246
+ )
247
+ df = ds["train"].to_pandas()
248
+ df = df.rename(
249
+ columns={
250
+ "Name": "Composer",
251
+ "Composition Title": "Title",
252
+ "Duration": "Duration",
253
+ "Year": "Year",
254
+ "Level": "Level",
255
+ }
256
+ )
257
+
258
+ # Vectorized-ish year parsing (fast enough and clearer)
259
+ parsed = df.get("Year", pd.Series([np.nan] * len(df))).map(_parse_year_robust)
260
+ df["YearStart"] = [p[0] for p in parsed]
261
+ df["YearEnd"] = [p[1] for p in parsed]
262
+ df["YearParsed"] = df["YearStart"]
263
+
264
+ df["DurationMin"] = df.get("Duration", pd.Series([np.nan] * len(df))).map(_parse_duration)
265
+ df["LevelStd"] = df.get("Level", pd.Series(["Unknown"] * len(df))).fillna("Unknown")
266
+
267
+ _COMPOSITIONS_DF = df
268
+ return df
269
+
270
+
271
  def load_people():
272
+ global _PEOPLE_RAW_DF, _PEOPLE_EXP_DF
273
+ if _PEOPLE_RAW_DF is not None and _PEOPLE_EXP_DF is not None:
274
+ return _PEOPLE_RAW_DF, _PEOPLE_EXP_DF
275
+
276
+ ds = load_dataset(
277
+ "csv",
278
+ data_files="hf://datasets/zliang/ASAP/ASAPdata.csv",
279
+ token=HF_TOKEN,
280
+ )
281
  df = ds["train"].to_pandas()
282
+
283
+ # Robust rename with fallbacks
284
  col_gender = _col(df, "Gender", "gender")
285
  col_nat = _col(df, "Nationality", "Country", "nationality", "country")
286
  col_eth = _col(df, "Ethnicity", "ethnicity")
287
  col_era = _col(df, "Music Era", "Era", "Period", "music era", "era", "period")
288
+
289
+ if col_gender:
290
+ df = df.rename(columns={col_gender: "Gender"})
291
+ else:
292
+ df["Gender"] = np.nan
293
+
294
+ if col_nat:
295
+ df = df.rename(columns={col_nat: "Nationality"})
296
+ else:
297
+ df["Nationality"] = np.nan
298
+
299
+ if col_eth:
300
+ df = df.rename(columns={col_eth: "Ethnicity"})
301
+ else:
302
+ df["Ethnicity"] = np.nan
303
+
304
+ if col_era:
305
+ df = df.rename(columns={col_era: "Music Era"})
306
+ else:
307
+ df["Music Era"] = np.nan
308
+
309
  df["NationalityList"] = df["Nationality"].apply(_split_and_normalize_nationalities)
310
  df_exp = df.explode("NationalityList").rename(columns={"NationalityList": "NationalityNorm"})
311
  df_exp["NationalityNorm"] = df_exp["NationalityNorm"].replace({"": np.nan})
312
+
313
+ _PEOPLE_RAW_DF, _PEOPLE_EXP_DF = df, df_exp
314
  return df, df_exp
315
 
316
  # =========================
317
+ # Visuals (tuned for speed + mobile)
318
  # =========================
319
+
320
+ def _apply_mobile_layout(fig: go.Figure) -> go.Figure:
321
+ fig.update_layout(**_MOBILE_LAYOUT_KW, template=PLOT_TEMPLATE)
322
+ fig.update_xaxes(title_standoff=4)
323
+ fig.update_yaxes(title_standoff=4)
324
+ return fig
325
+
326
+
327
  def area_timeline(df):
328
  d = df.dropna(subset=["YearParsed"])
329
+ if d.empty:
330
+ return go.Figure()
331
+
332
+ counts = (
333
+ d.groupby(["YearParsed", "LevelStd"], observed=True)["Title"]
334
+ .count()
335
+ .rename("Count")
336
+ .reset_index()
337
+ .sort_values("YearParsed")
338
+ )
339
+ # quick smoothing (window=3)
340
+ counts["Smoothed"] = (
341
+ counts.groupby("LevelStd", observed=True)["Count"].transform(lambda s: s.rolling(3, min_periods=1).mean())
342
+ )
343
+
344
+ fig = px.area(
345
+ counts,
346
+ x="YearParsed",
347
+ y="Smoothed",
348
+ color="LevelStd",
349
+ title="Compositions per Year by Level",
350
+ )
351
+ fig.update_xaxes(title="Year")
352
+ fig.update_yaxes(title="Smoothed count")
353
+ return _apply_mobile_layout(fig)
354
+
355
 
356
  def bar_top_composers(df, top_n=15):
357
  if df.empty:
358
  return go.Figure()
359
 
 
360
  d = (
361
+ df.groupby("Composer", observed=True)["Title"].count().sort_values(ascending=False).head(top_n).reset_index()
 
 
 
 
362
  )
363
+ d = d.rename(columns={"Title": "Count"})
364
+ order = d["Composer"].tolist()
 
365
 
366
  fig = px.bar(
367
  d,
 
369
  y="Composer",
370
  orientation="h",
371
  title=f"Top {top_n} Composers by Number of Compositions",
 
 
 
 
 
 
 
372
  )
373
+ fig.update_layout(yaxis=dict(categoryorder="array", categoryarray=order[::-1]))
374
+ return _apply_mobile_layout(fig)
375
 
376
 
377
  def treemap_composer_level(df):
378
+ if df.empty:
379
+ return go.Figure()
380
+ d = df.groupby(["Composer", "LevelStd"], observed=True)["Title"].count().reset_index(name="Count")
381
+ fig = px.treemap(d, path=["Composer", "LevelStd"], values="Count", title="Catalog Structure: Composer → Level")
382
+ return _apply_mobile_layout(fig)
383
+
384
 
385
  def make_wordcloud(df):
386
+ global _WC_IMG
387
+ if _WC_IMG is not None:
388
+ return _WC_IMG
389
+
390
  titles = df["Title"].dropna().astype(str)
 
391
  titles = [t for t in titles if _ascii_ratio(t) >= MIN_ASCII_RATIO]
392
  if not titles:
393
  return None
394
  text = " ".join(titles)
395
  stopwords = set(STOPWORDS)
396
+ stopwords.update({"Piano", "II", "IV", "V", "III", "Piece", "Pieces", "Op", "VII", "IX", "VIII"})
397
+ wc = WordCloud(width=1200, height=500, background_color="white", stopwords=stopwords, collocations=True)
398
+ _WC_IMG = wc.generate(text).to_image()
399
+ return _WC_IMG
400
 
401
+
402
+ def pie_counts(series: pd.Series, title: str, max_slices: int = 12):
403
+ s = series.dropna()
404
+ if s.empty:
 
405
  return go.Figure()
 
 
406
  counts = s.value_counts(dropna=False)
407
  if len(counts) > max_slices:
408
+ head = counts.iloc[: max_slices - 1]
409
+ other = pd.Series({"Other": counts.iloc[max_slices - 1 :].sum()})
410
  counts = pd.concat([head, other])
411
  d = counts.reset_index()
412
+ d.columns = ["label", "Count"]
413
+ fig = px.pie(d, names="label", values="Count", title=title, hole=0.35)
414
  fig.update_traces(textposition="inside", textinfo="percent+label")
415
+ return _apply_mobile_layout(fig)
416
+
417
 
418
  def world_map_nationality(df_people_exp):
 
419
  s = df_people_exp["NationalityNorm"].dropna()
420
  if s.empty:
421
  return go.Figure()
422
 
423
  counts = s.value_counts().reset_index()
424
  counts.columns = ["country", "count"]
 
 
425
  counts["count_log10"] = np.log10(counts["count"] + 1.0)
426
 
427
  fig = px.choropleth(
 
431
  color="count_log10",
432
  color_continuous_scale="Blues",
433
  title="Global Distribution by Nationality (log scale)",
 
434
  hover_data={"count": True, "count_log10": False, "country": False},
435
  )
436
 
437
+ # Colorbar ticks in raw counts
438
+ vmax = int(counts["count"].max())
439
+ raw_ticks = [1]
440
+ step = 1
441
+ base = [1, 3]
442
+ while step <= vmax:
443
+ for b in base:
444
+ val = b * step
445
+ if val <= vmax:
446
+ raw_ticks.append(val)
447
+ step *= 10
448
+ raw_ticks = sorted(set(raw_ticks + [vmax]))
449
+ tickvals = np.log10(np.array(raw_ticks, dtype=float) + 1.0)
450
+ ticktext = [str(v) for v in raw_ticks]
451
+
452
+ fig.update_layout(coloraxis_colorbar=dict(title="Count", tickvals=tickvals, ticktext=ticktext))
453
+ return _apply_mobile_layout(fig)
 
 
 
 
 
 
 
 
 
454
 
455
  # =========================
456
+ # Pipelines (computed per-tab, lazily)
457
  # =========================
458
+
459
+ def compute_people_viz():
460
  ppl_raw, ppl_exp = load_people()
461
  return (
462
+ pie_counts(ppl_raw["Gender"], "Gender"),
463
+ pie_counts(ppl_exp["NationalityNorm"], "Nationality"),
464
+ pie_counts(ppl_raw["Ethnicity"], "Ethnicity"),
465
+ pie_counts(ppl_raw["Music Era"], "Music Era"),
466
+ world_map_nationality(ppl_exp),
467
+ )
468
+
469
+
470
+ def compute_compositions_overview():
471
+ comp = load_compositions()
472
+ return (
473
  area_timeline(comp),
474
  bar_top_composers(comp),
475
+ )
476
+
477
+
478
+ def compute_categories_viz():
479
+ comp = load_compositions()
480
+ return (
481
  treemap_composer_level(comp),
482
  make_wordcloud(comp),
 
 
 
 
 
 
483
  )
484
 
485
  # =========================
486
+ # UI (lazy-load tabs; responsive stacking)
487
  # =========================
488
+
489
  theme = gr.themes.Soft(primary_hue="blue", neutral_hue="slate")
490
 
491
+ with gr.Blocks(title="A Seat At The Piano — ASAP Explorer", theme=theme, css="""
492
+ /***** Lightweight responsive tweaks *****/
493
+ .gradio-container {max-width: 1200px}
494
+ .plotly-graph-div {height: auto !important}
495
+ @media (max-width: 700px){
496
+ .gr-row {flex-direction: column !important}
497
+ }
498
+ """) as demo:
499
  gr.Markdown(
500
+ "## 🎼 ASAP Explorer\n"
501
+ "A Seat at the Piano was founded in the summer of 2020 in the midst of social and racial reckoning around the world.\n"
502
+ "We strive to raise the voices of those who are less heard and to inspire more thoughtful, inclusive programming."
503
  )
504
 
505
+ with gr.Tabs() as tabs:
506
+ with gr.Tab("Composer Demographics") as tab_people:
507
+ with gr.Row(equal_height=False):
508
+ pie_gender = gr.Plot(label="Gender")
509
+ pie_nat = gr.Plot(label="Nationality")
510
+ with gr.Row(equal_height=False):
511
+ pie_eth = gr.Plot(label="Ethnicity")
512
+ pie_era = gr.Plot(label="Music Era")
513
+ world_map = gr.Plot(label="Global Map (log scale)")
514
+
515
+ with gr.Tab("Compositions Overview") as tab_overview:
516
+ with gr.Row(equal_height=False):
517
+ timeline_plot = gr.Plot(label="Timeline")
518
+ with gr.Row(equal_height=False):
519
+ top_plot = gr.Plot(label="Top Composers")
520
+
521
+ with gr.Tab("Composition Categories") as tab_cats:
522
+ with gr.Row(equal_height=False):
523
+ tree_plot = gr.Plot(label="Composer → Level Treemap")
524
+ with gr.Row(equal_height=False):
525
+ wc_img = gr.Image(type="pil", label="Word Cloud")
526
+
527
+ # --- Lazy load on tab select (much faster initial render) ---
528
+ tab_people.select(
529
+ fn=compute_people_viz,
530
+ inputs=None,
531
+ outputs=[pie_gender, pie_nat, pie_eth, pie_era, world_map],
532
+ queue=False,
533
+ )
534
 
535
+ tab_overview.select(
536
+ fn=compute_compositions_overview,
537
+ inputs=None,
538
+ outputs=[timeline_plot, top_plot],
539
+ queue=False,
540
+ )
541
+
542
+ tab_cats.select(
543
+ fn=compute_categories_viz,
544
+ inputs=None,
545
+ outputs=[tree_plot, wc_img],
546
+ queue=False,
547
+ )
548
 
549
+ # Optional: pre-warm smallest tab to show something immediately (cheap plots)
550
+ # Comment out if you want totally empty initial view
551
  demo.load(
552
+ fn=compute_compositions_overview,
553
  inputs=None,
554
+ outputs=[timeline_plot, top_plot],
555
+ queue=False,
 
 
556
  )
557
 
558
  if __name__ == "__main__":
559
+ # Gradio queues can add overhead; keep it off for snappier single-user use
560
+ demo.launch(quiet=True)