Kaveh commited on
Commit
793a2f7
·
unverified ·
1 Parent(s): 8b23036

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +291 -201
app.py CHANGED
@@ -1,10 +1,10 @@
1
  # -*- coding: utf-8 -*-
2
  # =============================================
3
- # Streamlit App for Chess Game Analysis - Lichess API Version
4
- # v15: Meticulously rewritten categorize_time_control to ensure correct syntax.
5
  # =============================================
6
 
7
- import streamlit as st
8
  import pandas as pd
9
  import plotly.express as px
10
  import plotly.graph_objects as go
@@ -16,7 +16,8 @@ import re
16
  import traceback
17
 
18
  # --- Configuration ---
19
- st.set_page_config(layout="wide", page_title="Lichess Insights", page_icon="♟️")
 
20
 
21
  # --- Constants & Defaults ---
22
  TIME_PERIOD_OPTIONS = { "Last Month": timedelta(days=30), "Last 3 Months": timedelta(days=90), "Last Year": timedelta(days=365), "Last 3 Years": timedelta(days=3*365) }
@@ -30,163 +31,124 @@ FAMOUS_OPPONENTS = [ "DrNykterstein", "MagnusCarlsen", "Hikaru", "AnishGiri", "F
30
  "lachesisQ", "WesleySo", "GMWSO", "VladislavArtemiev", "Duhless", ]
31
 
32
  # =============================================
33
- # Helper Function: Categorize Time Control *** REWRITTEN WITH EXTREME CARE ***
34
  # =============================================
35
  def categorize_time_control(tc_str, speed_info):
36
- """Categorizes time control based on speed info or parsed string."""
37
- # 1. Prioritize speed info from API
38
- if isinstance(speed_info, str) and speed_info in ['bullet', 'blitz', 'rapid', 'classical', 'correspondence']:
39
- return speed_info.capitalize()
40
-
41
- # 2. Handle invalid or special tc_str inputs
42
- if not isinstance(tc_str, str) or tc_str in ['-', '?', 'Unknown']:
43
- return 'Unknown'
44
- if tc_str == 'Correspondence':
45
- return 'Correspondence'
46
-
47
- # 3. Handle format like "180+2"
48
  if '+' in tc_str:
49
- base = -1 # Initialize with invalid values
50
- increment = -1
51
- try: # *** TRY block for splitting and converting ***
52
- parts = tc_str.split('+')
53
- if len(parts) == 2:
54
- base = int(parts[0])
55
- increment = int(parts[1])
56
- else:
57
- return 'Unknown' # Invalid format
58
- except (ValueError, IndexError): # *** EXCEPT block for splitting/conversion errors ***
59
- return 'Unknown'
60
- # If conversion was successful (no exception occurred):
61
- if base >= 0 and increment >= 0: # Check if values were successfully parsed
62
- total = base + 40 * increment
63
- if total >= 1500: return 'Classical'
64
- if total >= 480: return 'Rapid'
65
- if total >= 180: return 'Blitz'
66
- if total > 0 : return 'Bullet'
67
- return 'Unknown' # Return Unknown if values were invalid or classification failed
68
-
69
- # 4. Handle format like "300" (only base time)
70
  else:
71
- base = -1
72
- try: # *** TRY block for base time conversion ***
73
- base = int(tc_str)
74
- except ValueError: # *** EXCEPT block for base time conversion failure ***
75
- # Fallback to keywords only if integer conversion fails
76
- tc_lower = tc_str.lower()
77
- if 'classical' in tc_lower: return 'Classical'
78
- if 'rapid' in tc_lower: return 'Rapid'
79
- if 'blitz' in tc_lower: return 'Blitz'
80
- if 'bullet' in tc_lower: return 'Bullet'
81
- return 'Unknown' # Failed all checks
82
-
83
- # If conversion was successful:
84
- if base >= 1500: return 'Classical'
85
- if base >= 480: return 'Rapid'
86
- if base >= 180: return 'Blitz'
87
- if base > 0 : return 'Bullet'
88
- return 'Unknown' # Base time is 0 or negative
89
 
90
  # =============================================
91
- # Helper Function: Load ECO Mapping (Unchanged)
92
  # =============================================
93
- @st.cache_data
94
- def load_eco_mapping(csv_path):
95
- try:
96
- df_eco = pd.read_csv(csv_path);
97
- if "ECO Code" not in df_eco.columns or "Opening Name" not in df_eco.columns:
98
- st.error(f"ECO file missing required columns."); return {}
99
- eco_map = df_eco.drop_duplicates(subset=['ECO Code']).set_index('ECO Code')['Opening Name'].to_dict()
100
- st.sidebar.success(f"Loaded {len(eco_map)} ECO mappings.")
101
- return eco_map
102
- except FileNotFoundError: st.sidebar.error(f"ECO file '{csv_path}' not found."); return {}
103
- except Exception as e: st.sidebar.error(f"Error loading ECO file: {e}"); return {}
104
 
105
  # =============================================
106
- # API Data Loading and Processing Function (Unchanged)
107
  # =============================================
108
- @st.cache_data(ttl=3600)
109
- def load_from_lichess_api(username: str, time_period_key: str, perf_type: str, rated: bool, eco_map: dict):
110
- # ... (Code identical to version 13 - calls the fixed helper) ...
111
- if not username: st.warning("Please enter a Lichess username."); return pd.DataFrame()
112
- if not perf_type: st.warning("Please select a game type."); return pd.DataFrame()
113
- username_lower = username.lower()
114
- st.info(f"Fetching games for '{username}' ({time_period_key} | Type: {perf_type})...")
115
- since_timestamp_ms = None; time_delta = TIME_PERIOD_OPTIONS.get(time_period_key)
116
- if time_delta: start_date = datetime.now(timezone.utc) - time_delta; since_timestamp_ms = int(start_date.timestamp() * 1000); st.caption(f"Fetching since: {start_date.strftime('%Y-%m-%d %H:%M:%S UTC')}")
117
- else: st.warning("Invalid time period selected.")
118
- api_params = {"rated":str(rated).lower(), "perfType":perf_type.lower(), "opening":"true", "moves":"false", "tags":"false", "pgnInJson":"false" }
119
- if since_timestamp_ms: api_params["since"] = since_timestamp_ms
120
- api_url = f"https://lichess.org/api/games/user/{username}"; headers = {"Accept":"application/x-ndjson"}
121
- all_games_data = []; error_counter = 0; games_processed_for_log = 0
122
  try:
123
- with st.spinner(f"Calling Lichess API for {username} ({perf_type} games)..."):
124
- response = requests.get(api_url, params=api_params, headers=headers, stream=True); response.raise_for_status()
125
- for line in response.iter_lines():
126
- if line:
127
- game_data_raw = line.decode('utf-8'); game_data = None; games_processed_for_log += 1
128
- try:
129
- game_data = json.loads(game_data_raw)
130
- white_info=game_data.get('players',{}).get('white',{}); black_info=game_data.get('players',{}).get('black',{})
131
- white_user=white_info.get('user',{}); black_user=black_info.get('user',{})
132
- opening_info=game_data.get('opening',{}); clock_info=game_data.get('clock')
133
- game_id=game_data.get('id','N/A'); created_at_ms=game_data.get('createdAt')
134
- game_date=pd.to_datetime(created_at_ms,unit='ms',utc=True,errors='coerce');
135
- if pd.isna(game_date): continue
136
- variant=game_data.get('variant','standard'); speed=game_data.get('speed','unknown')
137
- perf=game_data.get('perf','unknown'); status=game_data.get('status','unknown'); winner=game_data.get('winner')
138
- white_name=white_user.get('name','Unknown'); black_name=black_user.get('name','Unknown')
139
- white_title=white_user.get('title'); black_title=black_user.get('title')
140
- white_rating=pd.to_numeric(white_info.get('rating'),errors='coerce')
141
- black_rating=pd.to_numeric(black_info.get('rating'),errors='coerce')
142
- player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=(None,None,'Unknown',None,None)
143
- if username_lower==white_name.lower(): player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=('White',white_rating,black_name,black_title,black_rating)
144
- elif username_lower==black_name.lower(): player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=('Black',black_rating,white_name,white_title,white_rating)
145
- else: continue
146
- if player_color is None or pd.isna(player_elo) or pd.isna(opp_elo): continue
147
- res_num,res_str=(0.5,"Draw")
148
- if status not in ['draw','stalemate']:
149
- if winner==player_color.lower(): res_num,res_str=(1,"Win")
150
- elif winner is not None: res_num,res_str=(0,"Loss")
151
- tc_str="Unknown"
152
- if clock_info: init=clock_info.get('initial');incr=clock_info.get('increment');
153
- if init is not None and incr is not None: tc_str=f"{init}+{incr}"
154
- elif speed=='correspondence': tc_str="Correspondence"
155
- eco=opening_info.get('eco','Unknown'); op_name_api=opening_info.get('name','Unknown Opening').replace('?','').split(':')[0].strip()
156
- op_name_custom=eco_map.get(eco, f"ECO: {eco}" if eco!='Unknown' else 'Unknown Opening')
157
- term_map={"mate":"Normal","resign":"Normal","stalemate":"Normal","timeout":"Time forfeit","draw":"Normal","outoftime":"Time forfeit","cheat":"Cheat","noStart":"Aborted","unknownFinish":"Unknown","variantEnd":"Variant End"}
158
- term=term_map.get(status,"Unknown")
159
- opp_title_final='Unknown'
160
- if opp_title_raw and opp_title_raw.strip(): opp_title_clean=opp_title_raw.replace(' ','').strip().upper();
161
- if opp_title_clean and opp_title_clean!='?': opp_title_final=opp_title_clean
162
- def clean_name(n): return re.sub(r'^(GM|IM|FM|WGM|WIM|WFM|CM|WCM|NM)\s+','',n).strip()
163
- opp_name_clean=clean_name(opp_name_raw)
164
- all_games_data.append({'Date':game_date,'Event':perf,'White':white_name,'Black':black_name,'Result':"1-0" if winner=='white' else ("0-1" if winner=='black' else "1/2-1/2"),'WhiteElo':int(white_rating) if not pd.isna(white_rating) else 0,'BlackElo':int(black_rating) if not pd.isna(black_rating) else 0,'ECO':eco,'OpeningName_API':op_name_api,'OpeningName_Custom':op_name_custom,'TimeControl':tc_str,'Termination':term,'PlyCount':game_data.get('turns',0),'LichessID':game_id,'PlayerID':username,'PlayerColor':player_color,'PlayerElo':int(player_elo),'OpponentName':opp_name_clean,'OpponentNameRaw':opp_name_raw,'OpponentElo':int(opp_elo),'OpponentTitle':opp_title_final,'PlayerResultNumeric':res_num,'PlayerResultString':res_str,'Variant':variant,'Speed':speed,'Status':status,'PerfType':perf})
165
- except json.JSONDecodeError: error_counter += 1
166
- except Exception: error_counter += 1
167
- except requests.exceptions.RequestException as e: st.error(f"🚨 API Request Failed: {e}"); return pd.DataFrame()
168
- except Exception as e: st.error(f"🚨 Unexpected error: {e}"); st.text(traceback.format_exc()); return pd.DataFrame()
169
- if error_counter > 0: st.warning(f"Skipped {error_counter} entries due to processing errors.")
170
- if not all_games_data: st.warning(f"No games found for '{username}' matching criteria."); return pd.DataFrame()
171
- df = pd.DataFrame(all_games_data); st.success(f"Processed {len(df)} games.")
172
  if not df.empty:
173
- df['Date'] = pd.to_datetime(df['Date'], errors='coerce'); df = df.dropna(subset=['Date'])
174
- if df.empty: return df
175
- df['Year'] = df['Date'].dt.year; df['Month'] = df['Date'].dt.month; df['Day'] = df['Date'].dt.day
176
- df['Hour'] = df['Date'].dt.hour; df['DayOfWeekNum'] = df['Date'].dt.dayofweek; df['DayOfWeekName'] = df['Date'].dt.day_name()
177
- df['PlayerElo'] = df['PlayerElo'].astype(int); df['OpponentElo'] = df['OpponentElo'].astype(int)
178
- df['EloDiff'] = df['PlayerElo'] - df['OpponentElo']
179
- df['TimeControl_Category'] = df.apply(lambda row: categorize_time_control(row['TimeControl'], row['Speed']), axis=1) # Calls corrected func
180
- # df = df.rename(columns={'Opening': 'OpeningName'}) # No longer needed
181
- df = df.sort_values(by='Date').reset_index(drop=True)
182
- return df
183
-
184
 
185
  # =============================================
186
- # Plotting Functions (Unchanged from v12)
187
  # =============================================
188
- # (Insert ALL plotting functions here - plot_win_loss_pie, ..., plot_time_forfeit_by_tc)
189
- # ... (Code identical to previous version v12) ...
190
  def plot_win_loss_pie(df, display_name):
191
  if 'PlayerResultString' not in df.columns: return go.Figure()
192
  result_counts = df['PlayerResultString'].value_counts()
@@ -305,7 +267,6 @@ def plot_time_forfeit_by_tc(tf_games_df):
305
  fig=px.bar(tf_by_tc,x=tf_by_tc.index,y=tf_by_tc.values, title="Time Forfeits by Time Control", labels={'x':'Category','y':'Forfeits'}, text=tf_by_tc.values)
306
  fig.update_layout(dragmode=False); fig.update_traces(marker_color='#795548', textposition='outside'); return fig
307
 
308
-
309
  # =============================================
310
  # Helper Functions
311
  # =============================================
@@ -321,71 +282,200 @@ def filter_and_analyze_time_forfeits(df):
321
  return tf_games, wins_tf, losses_tf
322
 
323
  # =============================================
324
- # Main Gradio Analysis Function (Unchanged from v12)
325
- # =============================================
326
- def run_analysis(username, time_period_key, perf_type, progress=gr.Progress(track_tqdm=True)):
327
- # ... (Code identical to version 12) ...
328
- if not username: return "⚠️ Please enter a Lichess username.", *( [None] * 25 ) # Adjusted number of Nones
329
- df, status_msg = load_from_lichess_api(username, time_period_key, perf_type, DEFAULT_RATED_ONLY, ECO_MAPPING, progress)
330
- if not isinstance(df, pd.DataFrame) or df.empty: return status_msg, *( [None] * 25 )
331
- try:
332
- fig_pie=plot_win_loss_pie(df,username); fig_color=plot_win_loss_by_color(df); fig_rating=plot_rating_trend(df,username); fig_elo_diff=plot_performance_vs_opponent_elo(df)
333
- total_g=len(df); w=len(df[df['PlayerResultNumeric']==1]); l=len(df[df['PlayerResultNumeric']==0]); d=len(df[df['PlayerResultNumeric']==0.5])
334
- wr=(w/total_g*100) if total_g>0 else 0; avg_opp=df['OpponentElo'].mean(); overview_stats_md=f"**Total:** {total_g:,} | **Win Rate:** {wr:.1f}% | **W/L/D:** {w}/{l}/{d} | **Avg Opp Elo:** {avg_opp:.0f}"
335
- fig_games_yr=plot_games_per_year(df); fig_wr_yr=plot_win_rate_per_year(df); fig_perf_tc=plot_performance_by_time_control(df)
336
- fig_games_dow=plot_games_by_dow(df); fig_wr_dow=plot_winrate_by_dow(df); fig_games_hod=plot_games_by_hour(df); fig_wr_hod=plot_winrate_by_hour(df)
337
- fig_games_dom=plot_games_by_dom(df); fig_wr_dom=plot_winrate_by_dom(df)
338
- fig_open_freq_api=plot_opening_frequency(df,top_n=15,opening_col='OpeningName_API'); fig_open_wr_api=plot_win_rate_by_opening(df,min_games=5,top_n=15,opening_col='OpeningName_API')
339
- fig_open_freq_cust=plot_opening_frequency(df,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else None; fig_open_wr_cust=plot_win_rate_by_opening(df,min_games=5,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else None
340
- fig_opp_freq=plot_most_frequent_opponents(df,top_n=20); df_opp_list=df[df['OpponentName']!='Unknown']['OpponentName'].value_counts().reset_index(name='Games').head(20) if 'OpponentName' in df else pd.DataFrame(); fig_opp_elo=plot_performance_vs_opponent_elo(df)
341
- titled_h2h_md="Select titles in the 'Games vs Titled' tab for detailed analysis."
342
- tf_games,wins_tf,losses_tf=filter_and_analyze_time_forfeits(df)
343
- fig_tf_summary=plot_time_forfeit_summary(wins_tf,losses_tf) if not tf_games.empty else None; fig_tf_tc=plot_time_forfeit_by_tc(tf_games) if not tf_games.empty else None
344
- df_tf_list=tf_games[['Date','OpponentName','PlayerColor','PlayerResultString','TimeControl','PlyCount','Termination']].sort_values('Date',ascending=False).head(20) if not tf_games.empty else pd.DataFrame()
345
- term_counts=df['Termination'].value_counts(); fig_term_all=px.bar(term_counts,x=term_counts.index,y=term_counts.values,title="Overall Termination Reasons",labels={'x':'Reason','y':'Count'},text=term_counts.values)
346
- fig_term_all.update_layout(dragmode=False); fig_term_all.update_traces(textposition='outside')
347
- return (status_msg, df, # Output order must match component definition
348
- fig_pie, overview_stats_md, fig_color, fig_rating, fig_elo_diff, fig_games_yr, fig_wr_yr,
349
- None, # Placeholder for color tab plot 2
350
- fig_games_dow, fig_wr_dow, fig_games_hod, fig_wr_hod, fig_games_dom, fig_wr_dom, fig_perf_tc,
351
- fig_open_freq_api, fig_open_wr_api, fig_open_freq_cust, fig_open_wr_cust,
352
- fig_opp_freq, df_opp_list, fig_opp_elo, gr.Markdown(titled_h2h_md),
353
- fig_tf_summary, fig_tf_tc, df_tf_list, fig_term_all )
354
- except Exception as e: error_msg = f"🚨 Error generating results: {e}\n{traceback.format_exc()}"; return error_msg, pd.DataFrame(), *( [None] * 25 )
355
-
356
- # =============================================
357
- # Gradio Interface Definition (Unchanged from v12)
358
  # =============================================
359
  css = """.gradio-container { font-family: 'IBM Plex Sans', sans-serif; } footer { display: none !important; }"""
360
  with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo:
361
  gr.Markdown("# ♟️ Lichess Insights\nAnalyze rated game statistics from Lichess API.")
362
- df_state = gr.State(pd.DataFrame())
363
- username_state = gr.State("")
 
364
  with gr.Row():
365
- with gr.Column(scale=1, min_width=250): # Sidebar
366
- gr.Markdown("## ⚙️ Settings"); username_input=gr.Textbox(label="Lichess Username", placeholder="e.g., DrNykterstein", elem_id="username_box"); time_period_input=gr.Dropdown(label="Time Period", choices=list(TIME_PERIOD_OPTIONS.keys()), value=DEFAULT_TIME_PERIOD); perf_type_input=gr.Dropdown(label="Game Type", choices=PERF_TYPE_OPTIONS_SINGLE, value=DEFAULT_PERF_TYPE); analyze_btn=gr.Button("Analyze Games", variant="primary"); status_output=gr.Markdown(""); gr.Markdown("---"); gr.Markdown("Navigate analysis sections using the tabs.")
367
- with gr.Column(scale=4): # Main Content
368
- # Define all output components matching run_analysis return order
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  overview_plot_pie=gr.Plot(label="Overall Results"); overview_stats_md_out=gr.Markdown(); overview_plot_color=gr.Plot(label="Results by Color"); overview_plot_rating=gr.Plot(label="Rating Trend"); overview_plot_elo_diff=gr.Plot(label="Elo Advantage vs. Result")
 
370
  time_plot_games_yr=gr.Plot(label="Games per Year"); time_plot_wr_yr=gr.Plot(label="Win Rate per Year")
371
- color_plot_placeholder=gr.Markdown("(Results by color shown in Overview)")
372
  time_plot_games_dow=gr.Plot(label="Games by Day of Week"); time_plot_wr_dow=gr.Plot(label="Win Rate by Day of Week"); time_plot_games_hod=gr.Plot(label="Games by Hour (UTC)"); time_plot_wr_hod=gr.Plot(label="Win Rate by Hour (UTC)"); time_plot_games_dom=gr.Plot(label="Games by Day of Month"); time_plot_wr_dom=gr.Plot(label="Win Rate by Day of Month"); time_plot_perf_tc=gr.Plot(label="Performance by Time Control")
 
373
  eco_plot_freq_api=gr.Plot(label="Opening Frequency (API)"); eco_plot_wr_api=gr.Plot(label="Opening Win Rate (API)"); eco_plot_freq_cust=gr.Plot(label="Opening Frequency (Custom)"); eco_plot_wr_cust=gr.Plot(label="Opening Win Rate (Custom)")
374
- opp_plot_freq=gr.Plot(label="Frequent Opponents"); opp_df_list=gr.DataFrame(label="Top Opponents", wrap=True); opp_plot_elo=gr.Plot(label="Elo Advantage vs Result")
375
- titled_output_area=gr.Markdown()
 
 
 
376
  term_plot_tf_summary=gr.Plot(label="Time Forfeit Summary"); term_plot_tf_tc=gr.Plot(label="Time Forfeits by Time Control"); term_df_tf_list=gr.DataFrame(label="Recent TF Games", wrap=True); term_plot_all=gr.Plot(label="Overall Termination")
 
 
377
  with gr.Tabs() as tabs:
378
- with gr.TabItem("1. Overview", id=0): overview_stats_md_out.render(); with gr.Row(): overview_plot_pie.render(); overview_plot_color.render(); overview_plot_rating.render(); overview_plot_elo_diff.render()
379
- with gr.TabItem("2. Perf. Over Time", id=1): time_plot_rating.render(); time_plot_games_yr.render(); time_plot_wr_yr.render()
380
- with gr.TabItem("3. Perf. by Color", id=2): overview_plot_color.render(); color_plot_placeholder.render()
381
- with gr.TabItem("4. Time & Date", id=3): gr.Markdown("### Day of Week"); with gr.Row(): time_plot_games_dow.render(); time_plot_wr_dow.render(); gr.Markdown("### Hour of Day (UTC)"); with gr.Row(): time_plot_games_hod.render(); time_plot_wr_hod.render(); gr.Markdown("### Day of Month"); with gr.Row(): time_plot_games_dom.render(); time_plot_wr_dom.render(); gr.Markdown("### Time Control Category"); time_plot_perf_tc.render()
382
- with gr.TabItem("5. ECO & Openings", id=4): gr.Markdown("#### Based on Lichess API"); eco_plot_freq_api.render(); eco_plot_wr_api.render(); gr.Markdown("---"); gr.Markdown("#### Based on Custom ECO Map"); if not ECO_MAPPING: gr.Markdown("⚠️ Custom ECO map not loaded."); else: eco_plot_freq_cust.render(); eco_plot_wr_cust.render()
383
- with gr.TabItem("6. Opponents", id=5): opp_plot_freq.render(); opp_df_list.render(); opp_plot_elo.render()
384
- with gr.TabItem("7. vs Titled", id=6): gr.Markdown("Select titles below..."); titled_output_area.render(); gr.Markdown("*(Implementation pending)*") # Placeholder
385
- with gr.TabItem("8. Termination", id=7): gr.Markdown("### Time Forfeit"); term_plot_tf_summary.render(); term_plot_tf_tc.render(); with gr.Accordion("View Recent TF Games",open=False): term_df_tf_list.render(); gr.Markdown("### Overall Termination"); term_plot_all.render()
386
- outputs_list = [ status_output, df_state, overview_plot_pie, overview_stats_md_out, overview_plot_color, overview_plot_rating, overview_plot_elo_diff, time_plot_games_yr, time_plot_wr_yr, color_plot_placeholder, time_plot_games_dow, time_plot_wr_dow, time_plot_games_hod, time_plot_wr_hod, time_plot_games_dom, time_plot_wr_dom, time_plot_perf_tc, eco_plot_freq_api, eco_plot_wr_api, eco_plot_freq_cust, eco_plot_wr_cust, opp_plot_freq, opp_df_list, opp_plot_elo, titled_output_area, term_plot_tf_summary, term_plot_tf_tc, term_df_tf_list, term_plot_all ]
387
- analyze_btn.click(fn=run_analysis, inputs=[username_input, time_period_input, perf_type_input], outputs=outputs_list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
  # --- Launch the Gradio App ---
390
  if __name__ == "__main__":
391
- demo.launch(debug=True)
 
1
  # -*- coding: utf-8 -*-
2
  # =============================================
3
+ # Gradio App for Chess Game Analysis - Lichess API Version
4
+ # v16: Fixed SyntaxError in Gradio UI definition (gr.Blocks).
5
  # =============================================
6
 
7
+ import gradio as gr
8
  import pandas as pd
9
  import plotly.express as px
10
  import plotly.graph_objects as go
 
16
  import traceback
17
 
18
  # --- Configuration ---
19
+ st.set_page_config(layout="wide", page_title="Lichess Insights", page_icon="♟️") # Keep for Streamlit if switching back? Or remove. Let's remove.
20
+ # Use gr.set_config() if needed, but often not necessary.
21
 
22
  # --- Constants & Defaults ---
23
  TIME_PERIOD_OPTIONS = { "Last Month": timedelta(days=30), "Last 3 Months": timedelta(days=90), "Last Year": timedelta(days=365), "Last 3 Years": timedelta(days=3*365) }
 
31
  "lachesisQ", "WesleySo", "GMWSO", "VladislavArtemiev", "Duhless", ]
32
 
33
  # =============================================
34
+ # Helper Function: Categorize Time Control (Correct)
35
  # =============================================
36
  def categorize_time_control(tc_str, speed_info):
37
+ if isinstance(speed_info, str) and speed_info in ['bullet', 'blitz', 'rapid', 'classical', 'correspondence']: return speed_info.capitalize()
38
+ if not isinstance(tc_str, str) or tc_str in ['-', '?', 'Unknown','Correspondence']: return 'Unknown' if tc_str!='Correspondence' else 'Correspondence'
 
 
 
 
 
 
 
 
 
 
39
  if '+' in tc_str:
40
+ try: parts=tc_str.split('+');
41
+ if len(parts)==2: base=int(parts[0]); increment=int(parts[1]); total=base+40*increment
42
+ else: return 'Unknown'
43
+ except(ValueError,IndexError): return 'Unknown'
44
+ if total>=1500: return 'Classical';
45
+ if total>=480: return 'Rapid';
46
+ if total>=180: return 'Blitz';
47
+ if total>0 : return 'Bullet';
48
+ return 'Unknown'
 
 
 
 
 
 
 
 
 
 
 
 
49
  else:
50
+ try: base=int(tc_str)
51
+ if base>=1500: return 'Classical';
52
+ if base>=480: return 'Rapid';
53
+ if base>=180: return 'Blitz';
54
+ if base>0 : return 'Bullet';
55
+ return 'Unknown'
56
+ except ValueError: tc_lower=tc_str.lower();
57
+ if 'classical' in tc_lower: return 'Classical';
58
+ if 'rapid' in tc_lower: return 'Rapid';
59
+ if 'blitz' in tc_lower: return 'Blitz';
60
+ if 'bullet' in tc_lower: return 'Bullet';
61
+ return 'Unknown'
 
 
 
 
 
 
62
 
63
  # =============================================
64
+ # Helper Function: Load ECO Mapping
65
  # =============================================
66
+ ECO_MAPPING = {}
67
+ try:
68
+ df_eco_global = pd.read_csv(ECO_CSV_PATH)
69
+ if "ECO Code" in df_eco_global.columns and "Opening Name" in df_eco_global.columns:
70
+ ECO_MAPPING = df_eco_global.drop_duplicates(subset=['ECO Code']).set_index('ECO Code')['Opening Name'].to_dict()
71
+ print(f"OK: Loaded {len(ECO_MAPPING)} ECO mappings.") # Log success
72
+ else: print(f"WARN: ECO file '{ECO_CSV_PATH}' missing columns.")
73
+ except FileNotFoundError: print(f"WARN: ECO file '{ECO_CSV_PATH}' not found.")
74
+ except Exception as e: print(f"WARN: Error loading ECO file: {e}")
 
 
75
 
76
  # =============================================
77
+ # API Data Loading and Processing Function (Correct)
78
  # =============================================
79
+ def load_from_lichess_api(username: str, time_period_key: str, perf_type: str, rated: bool, eco_map: dict, progress=gr.Progress()):
80
+ progress(0, desc="Initializing...");
81
+ if not username: return pd.DataFrame(), "⚠️ Enter username."
82
+ if not perf_type: return pd.DataFrame(), "⚠️ Select game type."
83
+ username_lower=username.lower(); status_message=f"Fetching {perf_type} games..."
84
+ progress(0.1, desc=status_message); since_timestamp_ms=None; time_delta=TIME_PERIOD_OPTIONS.get(time_period_key)
85
+ if time_delta: start_date=datetime.now(timezone.utc)-time_delta; since_timestamp_ms=int(start_date.timestamp()*1000)
86
+ api_params={"rated":str(rated).lower(), "perfType":perf_type.lower(), "opening":"true", "moves":"false", "tags":"false", "pgnInJson":"false" }
87
+ if since_timestamp_ms: api_params["since"]=since_timestamp_ms
88
+ api_url=f"https://lichess.org/api/games/user/{username}"; headers={"Accept":"application/x-ndjson"}
89
+ all_games_data=[]; error_counter=0; lines_processed=0
 
 
 
90
  try:
91
+ response=requests.get(api_url, params=api_params, headers=headers, stream=True); response.raise_for_status()
92
+ progress(0.3, desc="Processing stream...")
93
+ for line in response.iter_lines():
94
+ if line:
95
+ lines_processed += 1; game_data_raw=line.decode('utf-8'); game_data=None;
96
+ if lines_processed % 100 == 0: progress(0.3 + (lines_processed % 1000 / 2000), desc=f"Processing game {lines_processed}...")
97
+ try:
98
+ game_data=json.loads(game_data_raw); white_info=game_data.get('players',{}).get('white',{}); black_info=game_data.get('players',{}).get('black',{})
99
+ white_user=white_info.get('user',{}); black_user=black_info.get('user',{}); opening_info=game_data.get('opening',{}); clock_info=game_data.get('clock')
100
+ game_id=game_data.get('id','N/A'); created_at_ms=game_data.get('createdAt'); game_date=pd.to_datetime(created_at_ms,unit='ms',utc=True,errors='coerce');
101
+ if pd.isna(game_date): continue
102
+ variant=game_data.get('variant','standard'); speed=game_data.get('speed','unknown'); perf=game_data.get('perf','unknown'); status=game_data.get('status','unknown'); winner=game_data.get('winner')
103
+ white_name=white_user.get('name','Unknown'); black_name=black_user.get('name','Unknown'); white_title=white_user.get('title'); black_title=black_user.get('title')
104
+ white_rating=pd.to_numeric(white_info.get('rating'),errors='coerce'); black_rating=pd.to_numeric(black_info.get('rating'),errors='coerce')
105
+ player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=(None,None,'Unknown',None,None)
106
+ if username_lower==white_name.lower(): player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=('White',white_rating,black_name,black_title,black_rating)
107
+ elif username_lower==black_name.lower(): player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=('Black',black_rating,white_name,white_title,white_rating)
108
+ else: continue
109
+ if player_color is None or pd.isna(player_elo) or pd.isna(opp_elo): continue
110
+ res_num,res_str=(0.5,"Draw");
111
+ if status not in ['draw','stalemate']:
112
+ if winner==player_color.lower(): res_num,res_str=(1,"Win")
113
+ elif winner is not None: res_num,res_str=(0,"Loss")
114
+ tc_str="Unknown";
115
+ if clock_info: init=clock_info.get('initial');incr=clock_info.get('increment');
116
+ if init is not None and incr is not None: tc_str=f"{init}+{incr}"
117
+ elif speed=='correspondence': tc_str="Correspondence"
118
+ eco=opening_info.get('eco','Unknown'); op_name_api=opening_info.get('name','Unknown Opening').replace('?','').split(':')[0].strip()
119
+ op_name_custom=eco_map.get(eco, f"ECO: {eco}" if eco!='Unknown' else 'Unknown Opening')
120
+ term_map={"mate":"Normal","resign":"Normal","stalemate":"Normal","timeout":"Time forfeit","draw":"Normal","outoftime":"Time forfeit","cheat":"Cheat","noStart":"Aborted","unknownFinish":"Unknown","variantEnd":"Variant End"}
121
+ term=term_map.get(status,"Unknown")
122
+ opp_title_final='Unknown'
123
+ if opp_title_raw and opp_title_raw.strip(): opp_title_clean=opp_title_raw.replace(' ','').strip().upper();
124
+ if opp_title_clean and opp_title_clean!='?': opp_title_final=opp_title_clean
125
+ def clean_name(n): return re.sub(r'^(GM|IM|FM|WGM|WIM|WFM|CM|WCM|NM)\s+','',n).strip()
126
+ opp_name_clean=clean_name(opp_name_raw)
127
+ all_games_data.append({'Date':game_date,'Event':perf,'White':white_name,'Black':black_name,'Result':"1-0" if winner=='white' else ("0-1" if winner=='black' else "1/2-1/2"),'WhiteElo':int(white_rating) if not pd.isna(white_rating) else 0,'BlackElo':int(black_rating) if not pd.isna(black_rating) else 0,'ECO':eco,'OpeningName_API':op_name_api,'OpeningName_Custom':op_name_custom,'TimeControl':tc_str,'Termination':term,'PlyCount':game_data.get('turns',0),'LichessID':game_id,'PlayerID':username,'PlayerColor':player_color,'PlayerElo':int(player_elo),'OpponentName':opp_name_clean,'OpponentNameRaw':opp_name_raw,'OpponentElo':int(opp_elo),'OpponentTitle':opp_title_final,'PlayerResultNumeric':res_num,'PlayerResultString':res_str,'Variant':variant,'Speed':speed,'Status':status,'PerfType':perf})
128
+ except json.JSONDecodeError: error_counter += 1
129
+ except Exception: error_counter += 1
130
+ except requests.exceptions.RequestException as e: return pd.DataFrame(), f"🚨 API Error: {e}"
131
+ except Exception as e: return pd.DataFrame(), f"🚨 Error: {e}\n{traceback.format_exc()}"
132
+ status_message = f"Processed {len(all_games_data)} games.";
133
+ if error_counter > 0: status_message += f" Skipped {error_counter} errors."
134
+ if not all_games_data: return pd.DataFrame(), f"⚠️ No games found matching criteria."
135
+ progress(0.8, desc="Finalizing...")
136
+ df = pd.DataFrame(all_games_data);
 
 
 
137
  if not df.empty:
138
+ df['Date']=pd.to_datetime(df['Date'],errors='coerce'); df=df.dropna(subset=['Date'])
139
+ if df.empty: return df, "⚠️ No games with valid dates."
140
+ df['Year']=df['Date'].dt.year; df['Month']=df['Date'].dt.month; df['Day']=df['Date'].dt.day; df['Hour']=df['Date'].dt.hour; df['DayOfWeekNum']=df['Date'].dt.dayofweek; df['DayOfWeekName']=df['Date'].dt.day_name()
141
+ df['PlayerElo']=df['PlayerElo'].astype(int); df['OpponentElo']=df['OpponentElo'].astype(int)
142
+ df['EloDiff']=df['PlayerElo']-df['OpponentElo']; df['TimeControl_Category']=df.apply(lambda r: categorize_time_control(r['TimeControl'], r['Speed']), axis=1)
143
+ df=df.sort_values(by='Date').reset_index(drop=True)
144
+ progress(1, desc="Complete!")
145
+ return df, status_message
 
 
 
146
 
147
  # =============================================
148
+ # Plotting Functions (Unchanged)
149
  # =============================================
150
+ # (Insert ALL plotting functions here - code identical to previous version v14)
151
+ # ... (plot_win_loss_pie, ..., plot_time_forfeit_by_tc) ...
152
  def plot_win_loss_pie(df, display_name):
153
  if 'PlayerResultString' not in df.columns: return go.Figure()
154
  result_counts = df['PlayerResultString'].value_counts()
 
267
  fig=px.bar(tf_by_tc,x=tf_by_tc.index,y=tf_by_tc.values, title="Time Forfeits by Time Control", labels={'x':'Category','y':'Forfeits'}, text=tf_by_tc.values)
268
  fig.update_layout(dragmode=False); fig.update_traces(marker_color='#795548', textposition='outside'); return fig
269
 
 
270
  # =============================================
271
  # Helper Functions
272
  # =============================================
 
282
  return tf_games, wins_tf, losses_tf
283
 
284
  # =============================================
285
+ # Gradio Interface Definition (Corrected UI Syntax)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  # =============================================
287
  css = """.gradio-container { font-family: 'IBM Plex Sans', sans-serif; } footer { display: none !important; }"""
288
  with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo:
289
  gr.Markdown("# ♟️ Lichess Insights\nAnalyze rated game statistics from Lichess API.")
290
+ df_state = gr.State(pd.DataFrame()) # Holds the main dataframe
291
+ username_state = gr.State("") # Holds the username for display
292
+
293
  with gr.Row():
294
+ with gr.Column(scale=1, min_width=250): # Sidebar Area
295
+ gr.Markdown("## ⚙️ Settings")
296
+ username_input = gr.Textbox(label="Lichess Username", placeholder="e.g., DrNykterstein", elem_id="username_box")
297
+ time_period_input = gr.Dropdown(label="Time Period", choices=list(TIME_PERIOD_OPTIONS.keys()), value=DEFAULT_TIME_PERIOD)
298
+ perf_type_input = gr.Dropdown(label="Game Type", choices=PERF_TYPE_OPTIONS_SINGLE, value=DEFAULT_PERF_TYPE)
299
+ analyze_btn = gr.Button("Analyze Games", variant="primary")
300
+ status_output = gr.Markdown("") # For status messages like "Fetching...", "Processed X games"
301
+
302
+ # Titled Player Selection (Moved to Sidebar for better context)
303
+ gr.Markdown("---")
304
+ gr.Markdown("### Analyze vs Titled Players")
305
+ titled_player_select = gr.CheckboxGroup(label="Select Opponent Titles", choices=TITLES_TO_ANALYZE, value=['GM', 'IM'], elem_id="titled_select")
306
+ # Note: Clicking this won't trigger analysis automatically in this setup.
307
+ # A separate button or logic linked to this component would be needed for dynamic filtering.
308
+
309
+ with gr.Column(scale=4): # Main Content Area
310
+ # -- Define Output Components --
311
+ # Overview
312
  overview_plot_pie=gr.Plot(label="Overall Results"); overview_stats_md_out=gr.Markdown(); overview_plot_color=gr.Plot(label="Results by Color"); overview_plot_rating=gr.Plot(label="Rating Trend"); overview_plot_elo_diff=gr.Plot(label="Elo Advantage vs. Result")
313
+ # Perf Over Time
314
  time_plot_games_yr=gr.Plot(label="Games per Year"); time_plot_wr_yr=gr.Plot(label="Win Rate per Year")
315
+ # Time & Date
316
  time_plot_games_dow=gr.Plot(label="Games by Day of Week"); time_plot_wr_dow=gr.Plot(label="Win Rate by Day of Week"); time_plot_games_hod=gr.Plot(label="Games by Hour (UTC)"); time_plot_wr_hod=gr.Plot(label="Win Rate by Hour (UTC)"); time_plot_games_dom=gr.Plot(label="Games by Day of Month"); time_plot_wr_dom=gr.Plot(label="Win Rate by Day of Month"); time_plot_perf_tc=gr.Plot(label="Performance by Time Control")
317
+ # ECO & Opening
318
  eco_plot_freq_api=gr.Plot(label="Opening Frequency (API)"); eco_plot_wr_api=gr.Plot(label="Opening Win Rate (API)"); eco_plot_freq_cust=gr.Plot(label="Opening Frequency (Custom)"); eco_plot_wr_cust=gr.Plot(label="Opening Win Rate (Custom)")
319
+ # Opponent Analysis
320
+ opp_plot_freq=gr.Plot(label="Frequent Opponents"); opp_df_list=gr.DataFrame(label="Top Opponents List", wrap=True); opp_plot_elo=gr.Plot(label="Elo Advantage vs Result")
321
+ # Titled Players
322
+ titled_plot_pie=gr.Plot(label="Results vs Selected Titles"); titled_plot_color=gr.Plot(label="Results by Color vs Selected Titles"); titled_plot_rating=gr.Plot(label="Rating Trend vs Selected Titles"); titled_df_h2h=gr.DataFrame(label="Head-to-Head vs Selected Titles", wrap=True); titled_status=gr.Markdown("") # Status for this section
323
+ # Termination Analysis
324
  term_plot_tf_summary=gr.Plot(label="Time Forfeit Summary"); term_plot_tf_tc=gr.Plot(label="Time Forfeits by Time Control"); term_df_tf_list=gr.DataFrame(label="Recent TF Games", wrap=True); term_plot_all=gr.Plot(label="Overall Termination")
325
+
326
+ # -- Arrange Components in Tabs (Corrected Syntax) --
327
  with gr.Tabs() as tabs:
328
+ with gr.TabItem("1. Overview", id=0):
329
+ overview_stats_md_out # Display metrics first
330
+ with gr.Row():
331
+ overview_plot_pie
332
+ overview_plot_color
333
+ overview_plot_rating
334
+ overview_plot_elo_diff
335
+
336
+ with gr.TabItem("2. Perf. Over Time", id=1):
337
+ # plot_rating_trend defined above, just place the variable
338
+ overview_plot_rating # Reuse rating trend plot
339
+ time_plot_games_yr
340
+ time_plot_wr_yr
341
+
342
+ with gr.TabItem("3. Perf. by Color", id=2):
343
+ # Reuse color plot
344
+ overview_plot_color
345
+ gr.Markdown("(Further color analysis can be added)")
346
+
347
+ with gr.TabItem("4. Time & Date", id=3):
348
+ gr.Markdown("### Day of Week"); with gr.Row(): time_plot_games_dow; time_plot_wr_dow
349
+ gr.Markdown("### Hour of Day (UTC)"); with gr.Row(): time_plot_games_hod; time_plot_wr_hod
350
+ gr.Markdown("### Day of Month"); with gr.Row(): time_plot_games_dom; time_plot_wr_dom
351
+ gr.Markdown("### Time Control Category"); time_plot_perf_tc
352
+
353
+ with gr.TabItem("5. ECO & Openings", id=4):
354
+ gr.Markdown("#### Based on Lichess API Opening Names"); eco_plot_freq_api; eco_plot_wr_api
355
+ gr.Markdown("---"); gr.Markdown("#### Based on Custom ECO Map")
356
+ if not ECO_MAPPING: gr.Markdown("⚠️ Custom ECO map file not loaded.")
357
+ else: eco_plot_freq_cust; eco_plot_wr_cust
358
+
359
+ with gr.TabItem("6. Opponents", id=5):
360
+ opp_plot_freq; opp_df_list; opp_plot_elo
361
+
362
+ with gr.TabItem("7. vs Titled", id=6):
363
+ gr.Markdown("Analysis based on titles selected in the sidebar.")
364
+ titled_status # Show status message (e.g., "X games found")
365
+ with gr.Row():
366
+ titled_plot_pie # Plot results vs selected titles
367
+ titled_plot_color # Plot results by color vs selected titles
368
+ titled_plot_rating # Plot rating trend vs selected titles
369
+ titled_df_h2h # Show H2H table
370
+
371
+ with gr.TabItem("8. Termination", id=7):
372
+ gr.Markdown("### Time Forfeit"); term_plot_tf_summary; term_plot_tf_tc
373
+ with gr.Accordion("View Recent TF Games", open=False): term_df_tf_list
374
+ gr.Markdown("### Overall Termination"); term_plot_all
375
+
376
+ # --- Define Analysis Logic on Button Click ---
377
+ def perform_full_analysis(username, time_period_key, perf_type, selected_titles_list, progress=gr.Progress(track_tqdm=True)):
378
+ """Loads data and generates all outputs for the Gradio interface."""
379
+ # 1. Load base data
380
+ df, status_msg = load_from_lichess_api(username, time_period_key, perf_type, DEFAULT_RATED_ONLY, ECO_MAPPING, progress)
381
+ if not isinstance(df, pd.DataFrame) or df.empty:
382
+ # Return empty/default values for all outputs
383
+ num_outputs = 28 # Adjust this count based on the final outputs list below!
384
+ return status_msg, pd.DataFrame(), *( [None] * num_outputs )
385
+
386
+ # 2. Generate base plots/data
387
+ try:
388
+ fig_pie=plot_win_loss_pie(df,username); fig_color=plot_win_loss_by_color(df); fig_rating=plot_rating_trend(df,username); fig_elo_diff=plot_performance_vs_opponent_elo(df)
389
+ total_g=len(df); w=len(df[df['PlayerResultNumeric']==1]); l=len(df[df['PlayerResultNumeric']==0]); d=len(df[df['PlayerResultNumeric']==0.5])
390
+ wr=(w/total_g*100) if total_g>0 else 0; avg_opp=df['OpponentElo'].mean(); overview_stats_md=f"**Total:** {total_g:,} | **Win Rate:** {wr:.1f}% | **W/L/D:** {w}/{l}/{d} | **Avg Opp Elo:** {avg_opp:.0f}"
391
+ fig_games_yr=plot_games_per_year(df); fig_wr_yr=plot_win_rate_per_year(df); fig_perf_tc=plot_performance_by_time_control(df)
392
+ fig_games_dow=plot_games_by_dow(df); fig_wr_dow=plot_winrate_by_dow(df); fig_games_hod=plot_games_by_hour(df); fig_wr_hod=plot_winrate_by_hour(df)
393
+ fig_games_dom=plot_games_by_dom(df); fig_wr_dom=plot_winrate_by_dom(df)
394
+ fig_open_freq_api=plot_opening_frequency(df,top_n=15,opening_col='OpeningName_API'); fig_open_wr_api=plot_win_rate_by_opening(df,min_games=5,top_n=15,opening_col='OpeningName_API')
395
+ fig_open_freq_cust=plot_opening_frequency(df,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else None; fig_open_wr_cust=plot_win_rate_by_opening(df,min_games=5,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else None
396
+ fig_opp_freq=plot_most_frequent_opponents(df,top_n=20); df_opp_list=df[df['OpponentName']!='Unknown']['OpponentName'].value_counts().reset_index(name='Games').head(20) if 'OpponentName' in df else pd.DataFrame(); fig_opp_elo=plot_performance_vs_opponent_elo(df)
397
+ tf_games,wins_tf,losses_tf=filter_and_analyze_time_forfeits(df)
398
+ fig_tf_summary=plot_time_forfeit_summary(wins_tf,losses_tf) if not tf_games.empty else None; fig_tf_tc=plot_time_forfeit_by_tc(tf_games) if not tf_games.empty else None
399
+ df_tf_list=tf_games[['Date','OpponentName','PlayerColor','PlayerResultString','TimeControl','PlyCount','Termination']].sort_values('Date',ascending=False).head(20) if not tf_games.empty else pd.DataFrame()
400
+ term_counts=df['Termination'].value_counts(); fig_term_all=px.bar(term_counts,x=term_counts.index,y=term_counts.values,title="Overall Termination Reasons",labels={'x':'Reason','y':'Count'},text=term_counts.values)
401
+ fig_term_all.update_layout(dragmode=False); fig_term_all.update_traces(textposition='outside')
402
+
403
+ # 3. Generate Titled Player analysis based on selection
404
+ titled_status_msg = ""
405
+ fig_titled_pie, fig_titled_color, fig_titled_rating, df_titled_h2h = None, None, None, pd.DataFrame()
406
+ if selected_titles_list:
407
+ titled_games = filter_and_analyze_titled(df, selected_titles_list)
408
+ if not titled_games.empty:
409
+ titled_status_msg = f"✅ Found {len(titled_games)} games vs {', '.join(selected_titles_list)}."
410
+ fig_titled_pie = plot_win_loss_pie(titled_games, f"{username} vs {','.join(selected_titles_list)}")
411
+ fig_titled_color = plot_win_loss_by_color(titled_games)
412
+ fig_titled_rating = plot_rating_trend(titled_games, f"{username} (vs {','.join(selected_titles_list)})")
413
+ # Calculate H2H for titled
414
+ h2h = titled_games.groupby('OpponentNameRaw')['PlayerResultString'].value_counts().unstack(fill_value=0)
415
+ for res in ['Win','Loss','Draw']: h2h[res]=h2h.get(res,0)
416
+ h2h = h2h[['Win','Loss','Draw']]; h2h['Total']=h2h.sum(axis=1); h2h['Score']=h2h['Win']+0.5*h2h['Draw']
417
+ df_titled_h2h = h2h.sort_values('Total', ascending=False).reset_index()
418
+ else:
419
+ titled_status_msg = f"ℹ️ No games found vs selected titles ({', '.join(selected_titles_list)})."
420
+ else:
421
+ titled_status_msg = "ℹ️ Select titles from the sidebar to analyze."
422
+
423
+
424
+ # 4. Return all results in the correct order
425
+ return (
426
+ status_msg, df, # Status and DataFrame state
427
+ # Tab 1: Overview
428
+ fig_pie, overview_stats_md, fig_color, fig_rating, fig_elo_diff,
429
+ # Tab 2: Perf Over Time (Rating is reused, so only 2 new figs)
430
+ fig_games_yr, fig_wr_yr,
431
+ # Tab 3: Perf By Color (Plot reused, only placeholder)
432
+ "(Results by color shown in Overview)", # Output for color_plot_placeholder
433
+ # Tab 4: Time & Date
434
+ fig_games_dow, fig_wr_dow, fig_games_hod, fig_wr_hod, fig_games_dom, fig_wr_dom, fig_perf_tc,
435
+ # Tab 5: ECO & Opening
436
+ fig_open_freq_api, fig_open_wr_api, fig_open_freq_cust, fig_open_wr_cust,
437
+ # Tab 6: Opponent Analysis
438
+ fig_opp_freq, df_opp_list, fig_opp_elo,
439
+ # Tab 7: Titled Players
440
+ titled_status_msg, fig_titled_pie, fig_titled_color, fig_titled_rating, df_titled_h2h, # Outputs for the titled tab
441
+ # Tab 8: Termination Analysis
442
+ fig_tf_summary, fig_tf_tc, df_tf_list, fig_term_all
443
+ )
444
+
445
+ except Exception as e:
446
+ error_msg = f"🚨 Error generating results: {e}\n{traceback.format_exc()}"
447
+ num_outputs = 28 + 4 # Base outputs + new titled outputs
448
+ return error_msg, pd.DataFrame(), *( [None] * num_outputs )
449
+
450
+ # --- Connect Button Click to the Main Analysis Function ---
451
+ # Define the full list of outputs in the correct order matching the return statement
452
+ # MUST match the return order of perform_full_analysis
453
+ outputs_list = [
454
+ status_output, df_state, # Status and State first
455
+ # Tab 1 Outputs
456
+ overview_plot_pie, overview_stats_md_out, overview_plot_color, overview_plot_rating, overview_plot_elo_diff,
457
+ # Tab 2 Outputs (Rating plot is reused)
458
+ time_plot_games_yr, time_plot_wr_yr,
459
+ # Tab 3 Outputs
460
+ color_plot_placeholder, # Placeholder value
461
+ # Tab 4 Outputs
462
+ time_plot_games_dow, time_plot_wr_dow, time_plot_games_hod, time_plot_wr_hod, time_plot_games_dom, time_plot_wr_dom, time_plot_perf_tc,
463
+ # Tab 5 Outputs
464
+ eco_plot_freq_api, eco_plot_wr_api, eco_plot_freq_cust, eco_plot_wr_cust,
465
+ # Tab 6 Outputs
466
+ opp_plot_freq, opp_df_list, opp_plot_elo,
467
+ # Tab 7 Outputs (New components added)
468
+ titled_status, titled_plot_pie, titled_plot_color, titled_plot_rating, titled_df_h2h,
469
+ # Tab 8 Outputs
470
+ term_plot_tf_summary, term_plot_tf_tc, term_df_tf_list, term_plot_all
471
+ ]
472
+
473
+ analyze_btn.click(
474
+ fn=perform_full_analysis,
475
+ inputs=[username_input, time_period_input, perf_type_input, titled_player_select], # Add titled selection as input
476
+ outputs=outputs_list
477
+ )
478
 
479
  # --- Launch the Gradio App ---
480
  if __name__ == "__main__":
481
+ demo.launch(debug=True) # Enable debug for local testing