Kaveh commited on
Commit
eb373a7
·
unverified ·
1 Parent(s): 58423aa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +435 -212
app.py CHANGED
@@ -19,7 +19,7 @@ import traceback
19
  # No Streamlit config needed
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) }
23
  DEFAULT_TIME_PERIOD = "Last Year"
24
  PERF_TYPE_OPTIONS_SINGLE = ['Bullet', 'Blitz', 'Rapid']
25
  DEFAULT_PERF_TYPE = 'Bullet'
@@ -28,7 +28,7 @@ ECO_CSV_PATH = "eco_to_opening.csv"
28
  TITLES_TO_ANALYZE = ['GM', 'IM', 'FM', 'CM', 'WGM', 'WIM', 'WFM', 'WCM', 'NM']
29
 
30
  # =============================================
31
- # Helper Function: Categorize Time Control *** OBSESSIVELY REWRITTEN ***
32
  # =============================================
33
  def categorize_time_control(tc_str, speed_info):
34
  """Categorizes time control based on speed info or parsed string."""
@@ -47,49 +47,39 @@ def categorize_time_control(tc_str, speed_info):
47
  parts = tc_str.split('+')
48
  if len(parts) == 2:
49
  base_str, increment_str = parts[0], parts[1]
50
- base, increment = None, None # Initialize
51
- # *** Isolate ONLY the conversion in try-except ***
52
  try:
53
  base = int(base_str)
54
  increment = int(increment_str)
 
 
 
 
 
 
55
  except ValueError:
56
- return 'Unknown' # Conversion failed
57
-
58
- # *** Classification happens AFTER successful conversion ***
59
- total = base + 40 * increment
60
- if total >= 1500: return 'Classical'
61
- if total >= 480: return 'Rapid'
62
- if total >= 180: return 'Blitz'
63
- if total > 0 : return 'Bullet'
64
- return 'Unknown'
65
  else:
66
- # '+' was present but not exactly two parts
67
  return 'Unknown'
68
 
69
  # 4. Handle format like "300" (only base time)
70
  else:
71
- base = None # Initialize
72
- # *** Isolate ONLY the conversion in try-except ***
73
  try:
74
  base = int(tc_str)
 
 
 
 
 
75
  except ValueError:
76
- # *** Fallback to keywords ONLY if conversion fails ***
77
  tc_lower = tc_str.lower()
78
  if 'classical' in tc_lower: return 'Classical'
79
  if 'rapid' in tc_lower: return 'Rapid'
80
  if 'blitz' in tc_lower: return 'Blitz'
81
  if 'bullet' in tc_lower: return 'Bullet'
82
- return 'Unknown' # Failed conversion and keyword match
83
-
84
- # *** Classification happens AFTER successful conversion ***
85
- if base >= 1500: return 'Classical'
86
- if base >= 480: return 'Rapid'
87
- if base >= 180: return 'Blitz'
88
- if base > 0 : return 'Bullet'
89
- return 'Unknown' # Base time is 0 or negative
90
 
91
  # =============================================
92
- # Helper Function: Load ECO Mapping (Unchanged)
93
  # =============================================
94
  ECO_MAPPING = {}
95
  try:
@@ -97,214 +87,361 @@ try:
97
  if "ECO Code" in df_eco_global.columns and "Opening Name" in df_eco_global.columns:
98
  ECO_MAPPING = df_eco_global.drop_duplicates(subset=['ECO Code']).set_index('ECO Code')['Opening Name'].to_dict()
99
  print(f"OK: Loaded {len(ECO_MAPPING)} ECO mappings.")
100
- else: print(f"WARN: ECO file '{ECO_CSV_PATH}' missing columns.")
101
- except FileNotFoundError: print(f"WARN: ECO file '{ECO_CSV_PATH}' not found.")
102
- except Exception as e: print(f"WARN: Error loading ECO file: {e}")
 
 
 
103
 
104
  # =============================================
105
- # API Data Loading and Processing Function (Unchanged)
106
  # =============================================
107
- @gr.Progress(track_tqdm=True)
108
  def load_from_lichess_api(username: str, time_period_key: str, perf_type: str, rated: bool, eco_map: dict, progress=None):
109
- # ... (Code identical to version 15 - calls the fixed helper now) ...
110
  if not username: return pd.DataFrame(), "⚠️ Enter username."
111
  if not perf_type: return pd.DataFrame(), "⚠️ Select game type."
112
- if progress: progress(0, desc="Initializing...");
113
- username_lower=username.lower(); status_message=f"Fetching {perf_type} games..."
114
- if progress: progress(0.1, desc=status_message);
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)
117
- api_params={"rated":str(rated).lower(), "perfType":perf_type.lower(), "opening":"true", "moves":"false", "tags":"false", "pgnInJson":"false" }
118
- if since_timestamp_ms: api_params["since"]=since_timestamp_ms
119
- api_url=f"https://lichess.org/api/games/user/{username}"; headers={"Accept":"application/x-ndjson"}
120
- all_games_data=[]; error_counter=0; lines_processed=0
 
 
 
 
 
 
 
121
  try:
122
- response=requests.get(api_url, params=api_params, headers=headers, stream=True); response.raise_for_status()
 
123
  if progress: progress(0.3, desc="Processing stream...")
124
  for line in response.iter_lines():
125
  if line:
126
- lines_processed += 1; game_data_raw=line.decode('utf-8'); game_data=None;
127
- if progress and lines_processed % 100 == 0: progress(0.3 + (lines_processed % 1000 / 2000), desc=f"Processing game {lines_processed}...")
 
 
128
  try:
129
- game_data=json.loads(game_data_raw); white_info=game_data.get('players',{}).get('white',{}); black_info=game_data.get('players',{}).get('black',{})
130
- white_user=white_info.get('user',{}); black_user=black_info.get('user',{}); opening_info=game_data.get('opening',{}); clock_info=game_data.get('clock')
131
- 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');
 
 
 
 
 
 
 
132
  if pd.isna(game_date): continue
133
- 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')
134
- 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')
135
- white_rating=pd.to_numeric(white_info.get('rating'),errors='coerce'); black_rating=pd.to_numeric(black_info.get('rating'),errors='coerce')
136
- player_color,player_elo,opp_name_raw,opp_title_raw,opp_elo=(None,None,'Unknown',None,None)
137
- 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)
138
- 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)
139
- else: continue
 
 
 
 
 
 
 
 
 
 
 
140
  if player_color is None or pd.isna(player_elo) or pd.isna(opp_elo): continue
141
- res_num,res_str=(0.5,"Draw");
142
- if status not in ['draw','stalemate']:
143
- if winner==player_color.lower(): res_num,res_str=(1,"Win")
144
- elif winner is not None: res_num,res_str=(0,"Loss")
145
- tc_str="Unknown";
146
- if clock_info: init=clock_info.get('initial');incr=clock_info.get('increment');
147
- if init is not None and incr is not None: tc_str=f"{init}+{incr}"
148
- elif speed=='correspondence': tc_str="Correspondence"
149
- eco=opening_info.get('eco','Unknown'); op_name_api=opening_info.get('name','Unknown Opening').replace('?','').split(':')[0].strip()
150
- op_name_custom=eco_map.get(eco, f"ECO: {eco}" if eco!='Unknown' else 'Unknown Opening')
151
- 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"}
152
- term=term_map.get(status,"Unknown")
153
- opp_title_final='Unknown'
154
- if opp_title_raw and opp_title_raw.strip(): opp_title_clean=opp_title_raw.replace(' ','').strip().upper();
155
- if opp_title_clean and opp_title_clean!='?': opp_title_final=opp_title_clean
156
- def clean_name(n): return re.sub(r'^(GM|IM|FM|WGM|WIM|WFM|CM|WCM|NM)\s+','',n).strip()
157
- opp_name_clean=clean_name(opp_name_raw)
158
- 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})
159
- except json.JSONDecodeError: error_counter += 1
160
- except Exception: error_counter += 1
161
- except requests.exceptions.RequestException as e: return pd.DataFrame(), f"🚨 API Error: {e}"
162
- except Exception as e: return pd.DataFrame(), f"🚨 Error: {e}\n{traceback.format_exc()}"
163
- status_message = f"Processed {len(all_games_data)} games.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  if error_counter > 0: status_message += f" Skipped {error_counter} errors."
165
  if not all_games_data: return pd.DataFrame(), f"⚠️ No games found matching criteria."
166
  if progress: progress(0.8, desc="Finalizing...")
167
- df = pd.DataFrame(all_games_data);
168
  if not df.empty:
169
- df['Date']=pd.to_datetime(df['Date'],errors='coerce'); df=df.dropna(subset=['Date'])
 
170
  if df.empty: return df, "⚠️ No games with valid dates."
171
- 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()
172
- df['PlayerElo']=df['PlayerElo'].astype(int); df['OpponentElo']=df['OpponentElo'].astype(int)
173
- df['EloDiff']=df['PlayerElo']-df['OpponentElo']; df['TimeControl_Category']=df.apply(lambda r: categorize_time_control(r['TimeControl'], r['Speed']), axis=1) # Calls corrected func
174
- df=df.sort_values(by='Date').reset_index(drop=True)
 
 
 
 
 
 
 
175
  if progress: progress(1, desc="Complete!")
176
  return df, status_message
177
 
178
  # =============================================
179
- # Plotting Functions (Unchanged)
180
  # =============================================
181
- # (Insert ALL plotting functions here - code identical to previous version v15)
182
- # ... (plot_win_loss_pie, ..., plot_time_forfeit_by_tc) ...
183
  def plot_win_loss_pie(df, display_name):
184
  if 'PlayerResultString' not in df.columns: return go.Figure()
185
  result_counts = df['PlayerResultString'].value_counts()
186
- fig = px.pie(values=result_counts.values, names=result_counts.index, title=f'Overall Results for {display_name}', color=result_counts.index, color_discrete_map={'Win':'#4CAF50', 'Draw':'#B0BEC5', 'Loss':'#F44336'}, hole=0.3)
187
- fig.update_traces(textposition='inside', textinfo='percent+label', pull=[0.05 if x == 'Win' else 0 for x in result_counts.index]); fig.update_layout(dragmode=False); return fig
 
 
 
 
188
  def plot_win_loss_by_color(df):
189
  if not all(col in df.columns for col in ['PlayerColor', 'PlayerResultString']): return go.Figure()
190
- try: color_results=df.groupby(['PlayerColor','PlayerResultString']).size().unstack(fill_value=0)
191
- except KeyError: return go.Figure().update_layout(title="Error: Missing Columns")
192
- for res in ['Win','Draw','Loss']: color_results[res]=color_results.get(res,0)
193
- color_results=color_results[['Win','Draw','Loss']]; total=color_results.sum(axis=1); color_results_pct=color_results.apply(lambda x:x*100/total[x.name] if total[x.name]>0 else 0,axis=1)
194
- fig=px.bar(color_results_pct, barmode='stack', title='Results by Color', labels={'value':'%', 'PlayerColor':'Played As'}, color='PlayerResultString', color_discrete_map={'Win':'#4CAF50', 'Draw':'#B0BEC5', 'Loss':'#F44336'}, text_auto='.1f', category_orders={"PlayerColor":["White","Black"]})
195
- fig.update_layout(yaxis_title="Percentage (%)", xaxis_title="Color Played", dragmode=False); fig.update_traces(textangle=0); return fig
 
 
 
 
 
 
 
 
 
196
  def plot_rating_trend(df, display_name):
197
  if not all(col in df.columns for col in ['Date', 'PlayerElo']): return go.Figure()
198
- df_plot=df.copy(); df_plot['PlayerElo']=pd.to_numeric(df_plot['PlayerElo'],errors='coerce'); df_sorted=df_plot[df_plot['PlayerElo'].notna() & (df_plot['PlayerElo']>0)].sort_values('Date')
 
 
199
  if df_sorted.empty: return go.Figure().update_layout(title=f"No Elo data")
200
- fig=go.Figure(); fig.add_trace(go.Scatter(x=df_sorted['Date'], y=df_sorted['PlayerElo'], mode='lines+markers', name='Elo', line=dict(color='#1E88E5',width=2), marker=dict(size=5,opacity=0.7)))
201
- fig.update_layout(title=f'{display_name}\'s Rating Trend', xaxis_title='Date', yaxis_title='Elo Rating', hovermode="x unified", xaxis_rangeslider_visible=True, dragmode=False); return fig
 
 
 
 
 
202
  def plot_performance_vs_opponent_elo(df):
203
  if not all(col in df.columns for col in ['PlayerResultString', 'EloDiff']): return go.Figure()
204
- fig=px.box(df, x='PlayerResultString', y='EloDiff', title='Elo Advantage vs. Result', labels={'PlayerResultString':'Result', 'EloDiff':'Your Elo - Opponent Elo'}, category_orders={"PlayerResultString":["Win","Draw","Loss"]}, color='PlayerResultString', color_discrete_map={'Win':'#4CAF50','Draw':'#B0BEC5','Loss':'#F44336'}, points='outliers')
205
- fig.add_hline(y=0, line_dash="dash", line_color="grey"); fig.update_traces(marker=dict(opacity=0.8)); fig.update_layout(dragmode=False); return fig
 
 
 
 
 
 
 
206
  def plot_games_by_dow(df):
207
  if 'DayOfWeekName' not in df.columns: return go.Figure()
208
- dow_order=["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
209
- games_by_dow=df['DayOfWeekName'].value_counts().reindex(dow_order, fill_value=0)
210
- fig=px.bar(games_by_dow, x=games_by_dow.index, y=games_by_dow.values, title="Games by Day of Week", labels={'x':'Day','y':'Games'}, text=games_by_dow.values)
211
- fig.update_traces(marker_color='#9C27B0', textposition='outside'); fig.update_layout(dragmode=False); return fig
 
 
 
 
212
  def plot_winrate_by_dow(df):
213
  if not all(col in df.columns for col in ['DayOfWeekName', 'PlayerResultNumeric']): return go.Figure()
214
- dow_order=["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
215
- wins_by_dow=df[df['PlayerResultNumeric']==1].groupby('DayOfWeekName').size(); total_by_dow=df.groupby('DayOfWeekName').size()
216
- win_rate=(wins_by_dow.reindex(total_by_dow.index,fill_value=0)/total_by_dow).fillna(0)*100
217
- win_rate=win_rate.reindex(dow_order,fill_value=0)
218
- fig=px.bar(win_rate, x=win_rate.index, y=win_rate.values, title="Win Rate (%) by Day", labels={'x':'Day','y':'Win Rate (%)'}, text=win_rate.values)
219
- fig.update_traces(marker_color='#FF9800', texttemplate='%{text:.1f}%', textposition='outside'); fig.update_layout(yaxis_range=[0,100], dragmode=False); return fig
 
 
 
 
 
220
  def plot_games_by_hour(df):
221
  if 'Hour' not in df.columns: return go.Figure()
222
- games_by_hour=df['Hour'].value_counts().sort_index().reindex(range(24),fill_value=0)
223
- fig=px.bar(games_by_hour, x=games_by_hour.index, y=games_by_hour.values, title="Games by Hour (UTC)", labels={'x':'Hour','y':'Games'}, text=games_by_hour.values)
224
- fig.update_traces(marker_color='#03A9F4', textposition='outside'); fig.update_layout(xaxis=dict(tickmode='linear'), dragmode=False); return fig
 
 
 
 
225
  def plot_winrate_by_hour(df):
226
  if not all(col in df.columns for col in ['Hour', 'PlayerResultNumeric']): return go.Figure()
227
- wins_by_hour=df[df['PlayerResultNumeric']==1].groupby('Hour').size(); total_by_hour=df.groupby('Hour').size()
228
- win_rate=(wins_by_hour.reindex(total_by_hour.index,fill_value=0)/total_by_hour).fillna(0)*100
229
- win_rate=win_rate.reindex(range(24),fill_value=0)
230
- fig=px.line(win_rate, x=win_rate.index, y=win_rate.values, markers=True, title="Win Rate (%) by Hour (UTC)", labels={'x':'Hour','y':'Win Rate (%)'})
231
- fig.update_traces(line_color='#8BC34A'); fig.update_layout(yaxis_range=[0,100], xaxis=dict(tickmode='linear'), dragmode=False); return fig
 
 
 
 
 
232
  def plot_games_per_year(df):
233
  if 'Year' not in df.columns: return go.Figure()
234
  games_per_year = df['Year'].value_counts().sort_index()
235
- fig = px.bar(games_per_year, x=games_per_year.index, y=games_per_year.values, title='Games Per Year', labels={'x':'Year','y':'Games'}, text=games_per_year.values)
236
- fig.update_traces(marker_color='#2196F3', textposition='outside'); fig.update_layout(xaxis_title="Year", yaxis_title="Number of Games", xaxis={'type':'category'}, dragmode=False); return fig
 
 
 
 
237
  def plot_win_rate_per_year(df):
238
  if not all(col in df.columns for col in ['Year', 'PlayerResultNumeric']): return go.Figure()
239
- wins_per_year=df[df['PlayerResultNumeric']==1].groupby('Year').size(); total_per_year=df.groupby('Year').size()
240
- win_rate=(wins_per_year.reindex(total_per_year.index,fill_value=0)/total_per_year).fillna(0)*100
241
- win_rate.index=win_rate.index.astype(str)
242
- fig=px.line(win_rate, x=win_rate.index, y=win_rate.values, title='Win Rate (%) Per Year', markers=True, labels={'x':'Year','y':'Win Rate (%)'})
243
- fig.update_traces(line_color='#FFC107', line_width=2.5); fig.update_layout(yaxis_range=[0,100], dragmode=False); return fig
 
 
 
 
 
244
  def plot_performance_by_time_control(df):
245
- if not all(col in df.columns for col in ['TimeControl_Category', 'PlayerResultString']): return go.Figure()
246
- try:
247
- tc_results=df.groupby(['TimeControl_Category','PlayerResultString']).size().unstack(fill_value=0)
248
- for res in ['Win','Draw','Loss']: tc_results[res]=tc_results.get(res,0)
249
- tc_results=tc_results[['Win','Draw','Loss']]; total=tc_results.sum(axis=1)
250
- tc_results_pct=tc_results.apply(lambda x:x*100/total[x.name] if total[x.name]>0 else 0, axis=1)
251
- found=df['TimeControl_Category'].unique(); pref=['Bullet','Blitz','Rapid','Classical','Correspondence','Unknown']
252
- order=[c for c in pref if c in found]+[c for c in found if c not in pref]
253
- tc_results_pct=tc_results_pct.reindex(index=order).dropna(axis=0,how='all')
254
- fig=px.bar(tc_results_pct, title='Performance by Time Control', labels={'value':'%','TimeControl_Category':'Category'}, color='PlayerResultString', color_discrete_map={'Win':'#4CAF50','Draw':'#B0BEC5','Loss':'#F44336'}, barmode='group', text_auto='.1f')
255
- fig.update_layout(xaxis_title="Time Control Category", yaxis_title="Percentage (%)", dragmode=False); fig.update_traces(textangle=0); return fig
256
- except Exception: return go.Figure().update_layout(title="Error")
 
 
 
 
 
 
 
 
257
  def plot_opening_frequency(df, top_n=20, opening_col='OpeningName_API'):
258
  if opening_col not in df.columns: return go.Figure()
259
  source_label = "Lichess API" if opening_col == 'OpeningName_API' else "Custom Mapping"
260
  opening_counts = df[df[opening_col] != 'Unknown Opening'][opening_col].value_counts().nlargest(top_n)
261
- fig = px.bar(opening_counts, y=opening_counts.index, x=opening_counts.values, orientation='h', title=f'Top {top_n} Openings ({source_label})', labels={'y':'Opening','x':'Games'}, text=opening_counts.values)
262
- fig.update_layout(yaxis={'categoryorder':'total ascending'}, dragmode=False); fig.update_traces(marker_color='#673AB7', textposition='outside'); return fig
 
 
 
 
263
  def plot_win_rate_by_opening(df, min_games=5, top_n=20, opening_col='OpeningName_API'):
264
  if not all(col in df.columns for col in [opening_col, 'PlayerResultNumeric']): return go.Figure()
265
  source_label = "Lichess API" if opening_col == 'OpeningName_API' else "Custom Mapping"
266
- opening_stats=df.groupby(opening_col).agg(total_games=('PlayerResultNumeric','count'), wins=('PlayerResultNumeric',lambda x:(x==1).sum()))
267
- opening_stats=opening_stats[(opening_stats['total_games']>=min_games)&(opening_stats.index!='Unknown Opening')].copy()
 
268
  if opening_stats.empty: return go.Figure().update_layout(title=f"No openings >= {min_games} games ({source_label})")
269
- opening_stats['win_rate']=(opening_stats['wins']/opening_stats['total_games'])*100
270
- opening_stats_plot=opening_stats.nlargest(top_n, 'win_rate')
271
- fig=px.bar(opening_stats_plot, y=opening_stats_plot.index, x='win_rate', orientation='h', title=f'Top {top_n} Openings by Win Rate (Min {min_games} games, {source_label})', labels={'win_rate':'Win Rate (%)',opening_col:'Opening'}, text='win_rate')
272
- fig.update_traces(texttemplate='%{text:.1f}%', textposition='inside', marker_color='#009688'); fig.update_layout(yaxis={'categoryorder':'total ascending'}, xaxis_title="Win Rate (%)", dragmode=False); return fig
 
 
 
 
 
273
  def plot_most_frequent_opponents(df, top_n=20):
274
  if 'OpponentName' not in df.columns: return go.Figure()
275
- opp_counts=df[df['OpponentName']!='Unknown']['OpponentName'].value_counts().nlargest(top_n)
276
- fig=px.bar(opp_counts, y=opp_counts.index, x=opp_counts.values, orientation='h', title=f'Top {top_n} Opponents', labels={'y':'Opponent','x':'Games'}, text=opp_counts.values)
277
- fig.update_layout(yaxis={'categoryorder':'total ascending'}, dragmode=False); fig.update_traces(marker_color='#FF5722', textposition='outside'); return fig
 
 
 
 
278
  def plot_games_by_dom(df):
279
  if 'Day' not in df.columns: return go.Figure()
280
  games_by_dom = df['Day'].value_counts().sort_index().reindex(range(1, 32), fill_value=0)
281
- fig = px.bar(games_by_dom, x=games_by_dom.index, y=games_by_dom.values, title="Games Played per Day of Month", labels={'x': 'Day of Month', 'y': 'Number of Games'}, text=games_by_dom.values)
282
- fig.update_traces(marker_color='#E91E63', textposition='outside'); fig.update_layout(xaxis=dict(tickmode='linear'), dragmode=False); return fig
 
 
 
 
283
  def plot_winrate_by_dom(df):
284
  if not all(col in df.columns for col in ['Day', 'PlayerResultNumeric']): return go.Figure()
285
- wins_by_dom=df[df['PlayerResultNumeric']==1].groupby('Day').size(); total_by_dom=df.groupby('Day').size()
286
- win_rate=(wins_by_dom.reindex(total_by_dom.index,fill_value=0)/total_by_dom).fillna(0)*100
287
- win_rate=win_rate.reindex(range(1,32),fill_value=0)
288
- fig=px.line(win_rate, x=win_rate.index, y=win_rate.values, markers=True, title="Win Rate (%) per Day of Month", labels={'x': 'Day of Month', 'y': 'Win Rate (%)'})
289
- fig.update_traces(line_color='#FF5722'); fig.update_layout(yaxis_range=[0,100], xaxis=dict(tickmode='linear'), dragmode=False); return fig
 
 
 
 
 
290
  def plot_time_forfeit_summary(wins_tf, losses_tf):
291
- data={'Outcome':['Won on Time','Lost on Time'],'Count':[wins_tf,losses_tf]}
292
- df_tf=pd.DataFrame(data)
293
- fig=px.bar(df_tf,x='Outcome',y='Count',title="Time Forfeit Summary", color='Outcome', color_discrete_map={'Won on Time':'#4CAF50','Lost on Time':'#F44336'}, text='Count')
294
- fig.update_layout(showlegend=False, dragmode=False); fig.update_traces(textposition='outside'); return fig
 
 
 
 
295
  def plot_time_forfeit_by_tc(tf_games_df):
296
  if 'TimeControl_Category' not in tf_games_df.columns or tf_games_df.empty: return go.Figure().update_layout(title="No TF Data by Category")
297
- tf_by_tc=tf_games_df['TimeControl_Category'].value_counts()
298
- 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)
299
- fig.update_layout(dragmode=False); fig.update_traces(marker_color='#795548', textposition='outside'); return fig
 
 
 
300
 
301
  # =============================================
302
  # Helper Functions
303
  # =============================================
304
- # ... (Functions identical to v15) ...
305
  def filter_and_analyze_titled(df, titles):
306
  if 'OpponentTitle' not in df.columns: return pd.DataFrame()
307
- titled_games = df[df['OpponentTitle'].isin(titles)].copy(); return titled_games
 
 
308
  def filter_and_analyze_time_forfeits(df):
309
  if 'Termination' not in df.columns: return pd.DataFrame(), 0, 0
310
  tf_games = df[df['Termination'].str.contains("Time forfeit", na=False, case=False)].copy()
@@ -314,30 +451,52 @@ def filter_and_analyze_time_forfeits(df):
314
  return tf_games, wins_tf, losses_tf
315
 
316
  # =============================================
317
- # Gradio Main Analysis Function (Unchanged)
318
  # =============================================
319
- # ... (Function identical to v15) ...
320
  def perform_full_analysis(username, time_period_key, perf_type, selected_titles_list, progress=gr.Progress(track_tqdm=True)):
321
  df, status_msg = load_from_lichess_api(username, time_period_key, perf_type, DEFAULT_RATED_ONLY, ECO_MAPPING, progress)
322
- num_outputs = 30 # Define the total number of expected output components
323
  if not isinstance(df, pd.DataFrame) or df.empty:
324
- return status_msg, pd.DataFrame(), *( [None] * (num_outputs - 2) ) # Return None for plot/df components
325
  try:
326
- 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)
327
- total_g=len(df); w=len(df[df['PlayerResultNumeric']==1]); l=len(df[df['PlayerResultNumeric']==0]); d=len(df[df['PlayerResultNumeric']==0.5])
328
- wr=(w/total_g*100) if total_g>0 else 0; avg_opp=df['OpponentElo'].mean(); overview_stats_md=f"**Total:** {total_g:,} | **WR:** {wr:.1f}% | **W/L/D:** {w}/{l}/{d} | **Avg Opp:** {avg_opp:.0f if not pd.isna(avg_opp) else 'N/A'}"
329
- 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)
330
- 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)
331
- fig_games_dom=plot_games_by_dom(df); fig_wr_dom=plot_winrate_by_dom(df)
332
- 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')
333
- fig_open_freq_cust=plot_opening_frequency(df,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else go.Figure().update_layout(title="Custom Map Unavailable"); fig_open_wr_cust=plot_win_rate_by_opening(df,min_games=5,top_n=15,opening_col='OpeningName_Custom') if ECO_MAPPING else go.Figure().update_layout(title="Custom Map Unavailable")
334
- 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)
335
- tf_games,wins_tf,losses_tf=filter_and_analyze_time_forfeits(df)
336
- fig_tf_summary=plot_time_forfeit_summary(wins_tf,losses_tf) if not tf_games.empty else go.Figure().update_layout(title="No Time Forfeit Data"); fig_tf_tc=plot_time_forfeit_by_tc(tf_games) if not tf_games.empty else go.Figure().update_layout(title="No TF Data by Category")
337
- 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()
338
- 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)
339
- fig_term_all.update_layout(dragmode=False); fig_term_all.update_traces(textposition='outside')
340
- titled_status_msg = ""; fig_titled_pie, fig_titled_color, fig_titled_rating, df_titled_h2h = go.Figure(), go.Figure(), go.Figure(), pd.DataFrame()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  if selected_titles_list:
342
  titled_games = filter_and_analyze_titled(df, selected_titles_list)
343
  if not titled_games.empty:
@@ -346,46 +505,110 @@ def perform_full_analysis(username, time_period_key, perf_type, selected_titles_
346
  fig_titled_color = plot_win_loss_by_color(titled_games)
347
  fig_titled_rating = plot_rating_trend(titled_games, f"{username} (vs Titles)")
348
  h2h = titled_games.groupby('OpponentNameRaw')['PlayerResultString'].value_counts().unstack(fill_value=0)
349
- for res in ['Win','Loss','Draw']: h2h[res]=h2h.get(res,0)
350
- h2h = h2h[['Win','Loss','Draw']]; h2h['Total']=h2h.sum(axis=1); h2h['Score']=h2h['Win']+0.5*h2h['Draw']
 
 
351
  df_titled_h2h = h2h.sort_values('Total', ascending=False).reset_index()
352
- else: titled_status_msg = f"ℹ️ No games found vs selected titles ({', '.join(selected_titles_list)})."
353
- else: titled_status_msg = "ℹ️ Select titles from the sidebar to analyze."
354
- return ( status_msg, df, fig_pie, overview_stats_md, fig_color, fig_rating, fig_elo_diff, fig_games_yr, fig_wr_yr, "(Results by color shown in Overview)", fig_games_dow, fig_wr_dow, fig_games_hod, fig_wr_hod, fig_games_dom, fig_wr_dom, fig_perf_tc, fig_open_freq_api, fig_open_wr_api, fig_open_freq_cust, fig_open_wr_cust, fig_opp_freq, df_opp_list, fig_opp_elo, titled_status_msg, fig_titled_pie, fig_titled_color, fig_titled_rating, df_titled_h2h, fig_tf_summary, fig_tf_tc, df_tf_list, fig_term_all )
355
- except Exception as e: error_msg = f"🚨 Error generating results: {e}\n{traceback.format_exc()}"; return error_msg, pd.DataFrame(), *( [None] * num_outputs )
 
 
 
 
 
 
 
 
356
 
357
  # =============================================
358
- # Gradio Interface Definition (Unchanged UI Structure)
359
  # =============================================
360
- # ... (Code identical to version 15) ...
361
  css = """.gradio-container { font-family: 'IBM Plex Sans', sans-serif; } footer { display: none !important; }"""
362
  with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo:
363
  gr.Markdown("# ♟️ Lichess Insights\nAnalyze rated game statistics from Lichess API.")
364
  df_state = gr.State(pd.DataFrame())
365
  with gr.Row():
366
- with gr.Column(scale=1, min_width=250): # Sidebar
367
- 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("### Analyze vs Titled Players"); titled_player_select=gr.CheckboxGroup(label="Select Opponent Titles", choices=TITLES_TO_ANALYZE, value=['GM', 'IM'], elem_id="titled_select"); gr.Markdown("*(Analysis updates on 'Analyze Games' click)*");
368
- with gr.Column(scale=4): # Main Content
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()
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 List", wrap=True); opp_plot_elo=gr.Plot(label="Elo Advantage vs Result")
375
- titled_status=gr.Markdown(); 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_comp=gr.DataFrame(label="Head-to-Head vs Selected Titles", wrap=True); # Component name
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; with gr.Row(): overview_plot_pie; overview_plot_color; overview_plot_rating; overview_plot_elo_diff
379
- with gr.TabItem("2. Perf. Over Time", id=1): overview_plot_rating; time_plot_games_yr; time_plot_wr_yr
380
- with gr.TabItem("3. Perf. by Color", id=2): overview_plot_color; color_plot_placeholder
381
- with gr.TabItem("4. Time & Date", id=3): gr.Markdown("### Day of Week"); with gr.Row(): time_plot_games_dow; time_plot_wr_dow; gr.Markdown("### Hour of Day (UTC)"); with gr.Row(): time_plot_games_hod; time_plot_wr_hod; gr.Markdown("### Day of Month"); with gr.Row(): time_plot_games_dom; time_plot_wr_dom; gr.Markdown("### Time Control Category"); time_plot_perf_tc
382
- with gr.TabItem("5. ECO & Openings", id=4): gr.Markdown("#### API Names"); eco_plot_freq_api; eco_plot_wr_api; gr.Markdown("---"); gr.Markdown("#### Custom Map"); if not ECO_MAPPING: gr.Markdown("⚠️ Custom map not loaded."); else: eco_plot_freq_cust; eco_plot_wr_cust
383
- with gr.TabItem("6. Opponents", id=5): opp_plot_freq; opp_df_list; opp_plot_elo
384
- with gr.TabItem("7. vs Titled", id=6): gr.Markdown("Analysis based on sidebar selection."); titled_status; with gr.Row(): titled_plot_pie; titled_plot_color; titled_plot_rating; titled_df_h2h_comp
385
- with gr.TabItem("8. Termination", id=7): gr.Markdown("### Time Forfeit"); term_plot_tf_summary; term_plot_tf_tc; with gr.Accordion("View Recent TF Games",open=False): term_df_tf_list; gr.Markdown("### Overall Termination"); term_plot_all
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_status, titled_plot_pie, titled_plot_color, titled_plot_rating, titled_df_h2h_comp, # Correct component name
387
- term_plot_tf_summary, term_plot_tf_tc, term_df_tf_list, term_plot_all ]
388
- analyze_btn.click(fn=perform_full_analysis, inputs=[username_input, time_period_input, perf_type_input, titled_player_select], outputs=outputs_list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  # --- Launch the Gradio App ---
391
  if __name__ == "__main__":
 
19
  # No Streamlit config needed
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)}
23
  DEFAULT_TIME_PERIOD = "Last Year"
24
  PERF_TYPE_OPTIONS_SINGLE = ['Bullet', 'Blitz', 'Rapid']
25
  DEFAULT_PERF_TYPE = 'Bullet'
 
28
  TITLES_TO_ANALYZE = ['GM', 'IM', 'FM', 'CM', 'WGM', 'WIM', 'WFM', 'WCM', 'NM']
29
 
30
  # =============================================
31
+ # Helper Function: Categorize Time Control
32
  # =============================================
33
  def categorize_time_control(tc_str, speed_info):
34
  """Categorizes time control based on speed info or parsed string."""
 
47
  parts = tc_str.split('+')
48
  if len(parts) == 2:
49
  base_str, increment_str = parts[0], parts[1]
 
 
50
  try:
51
  base = int(base_str)
52
  increment = int(increment_str)
53
+ total = base + 40 * increment
54
+ if total >= 1500: return 'Classical'
55
+ if total >= 480: return 'Rapid'
56
+ if total >= 180: return 'Blitz'
57
+ if total > 0: return 'Bullet'
58
+ return 'Unknown'
59
  except ValueError:
60
+ return 'Unknown'
 
 
 
 
 
 
 
 
61
  else:
 
62
  return 'Unknown'
63
 
64
  # 4. Handle format like "300" (only base time)
65
  else:
 
 
66
  try:
67
  base = int(tc_str)
68
+ if base >= 1500: return 'Classical'
69
+ if base >= 480: return 'Rapid'
70
+ if base >= 180: return 'Blitz'
71
+ if base > 0: return 'Bullet'
72
+ return 'Unknown'
73
  except ValueError:
 
74
  tc_lower = tc_str.lower()
75
  if 'classical' in tc_lower: return 'Classical'
76
  if 'rapid' in tc_lower: return 'Rapid'
77
  if 'blitz' in tc_lower: return 'Blitz'
78
  if 'bullet' in tc_lower: return 'Bullet'
79
+ return 'Unknown'
 
 
 
 
 
 
 
80
 
81
  # =============================================
82
+ # Helper Function: Load ECO Mapping
83
  # =============================================
84
  ECO_MAPPING = {}
85
  try:
 
87
  if "ECO Code" in df_eco_global.columns and "Opening Name" in df_eco_global.columns:
88
  ECO_MAPPING = df_eco_global.drop_duplicates(subset=['ECO Code']).set_index('ECO Code')['Opening Name'].to_dict()
89
  print(f"OK: Loaded {len(ECO_MAPPING)} ECO mappings.")
90
+ else:
91
+ print(f"WARN: ECO file '{ECO_CSV_PATH}' missing columns.")
92
+ except FileNotFoundError:
93
+ print(f"WARN: ECO file '{ECO_CSV_PATH}' not found.")
94
+ except Exception as e:
95
+ print(f"WARN: Error loading ECO file: {e}")
96
 
97
  # =============================================
98
+ # API Data Loading and Processing Function
99
  # =============================================
 
100
  def load_from_lichess_api(username: str, time_period_key: str, perf_type: str, rated: bool, eco_map: dict, progress=None):
 
101
  if not username: return pd.DataFrame(), "⚠️ Enter username."
102
  if not perf_type: return pd.DataFrame(), "⚠️ Select game type."
103
+ if progress: progress(0, desc="Initializing...")
104
+ username_lower = username.lower()
105
+ status_message = f"Fetching {perf_type} games..."
106
+ if progress: progress(0.1, desc=status_message)
107
+ since_timestamp_ms = None
108
+ time_delta = TIME_PERIOD_OPTIONS.get(time_period_key)
109
+ if time_delta:
110
+ start_date = datetime.now(timezone.utc) - time_delta
111
+ since_timestamp_ms = int(start_date.timestamp() * 1000)
112
+ api_params = {"rated": str(rated).lower(), "perfType": perf_type.lower(), "opening": "true", "moves": "false", "tags": "false", "pgnInJson": "false"}
113
+ if since_timestamp_ms: api_params["since"] = since_timestamp_ms
114
+ api_url = f"https://lichess.org/api/games/user/{username}"
115
+ headers = {"Accept": "application/x-ndjson"}
116
+ all_games_data = []
117
+ error_counter = 0
118
+ lines_processed = 0
119
  try:
120
+ response = requests.get(api_url, params=api_params, headers=headers, stream=True)
121
+ response.raise_for_status()
122
  if progress: progress(0.3, desc="Processing stream...")
123
  for line in response.iter_lines():
124
  if line:
125
+ lines_processed += 1
126
+ game_data_raw = line.decode('utf-8')
127
+ if progress and lines_processed % 100 == 0:
128
+ progress(0.3 + (lines_processed % 1000 / 2000), desc=f"Processing game {lines_processed}...")
129
  try:
130
+ game_data = json.loads(game_data_raw)
131
+ white_info = game_data.get('players', {}).get('white', {})
132
+ black_info = game_data.get('players', {}).get('black', {})
133
+ white_user = white_info.get('user', {})
134
+ black_user = black_info.get('user', {})
135
+ opening_info = game_data.get('opening', {})
136
+ clock_info = game_data.get('clock')
137
+ game_id = game_data.get('id', 'N/A')
138
+ created_at_ms = game_data.get('createdAt')
139
+ game_date = pd.to_datetime(created_at_ms, unit='ms', utc=True, errors='coerce')
140
  if pd.isna(game_date): continue
141
+ variant = game_data.get('variant', 'standard')
142
+ speed = game_data.get('speed', 'unknown')
143
+ perf = game_data.get('perf', 'unknown')
144
+ status = game_data.get('status', 'unknown')
145
+ winner = game_data.get('winner')
146
+ white_name = white_user.get('name', 'Unknown')
147
+ black_name = black_user.get('name', 'Unknown')
148
+ white_title = white_user.get('title')
149
+ black_title = black_user.get('title')
150
+ white_rating = pd.to_numeric(white_info.get('rating'), errors='coerce')
151
+ black_rating = pd.to_numeric(black_info.get('rating'), errors='coerce')
152
+ player_color, player_elo, opp_name_raw, opp_title_raw, opp_elo = (None, None, 'Unknown', None, None)
153
+ if username_lower == white_name.lower():
154
+ player_color, player_elo, opp_name_raw, opp_title_raw, opp_elo = ('White', white_rating, black_name, black_title, black_rating)
155
+ elif username_lower == black_name.lower():
156
+ player_color, player_elo, opp_name_raw, opp_title_raw, opp_elo = ('Black', black_rating, white_name, white_title, white_rating)
157
+ else:
158
+ continue
159
  if player_color is None or pd.isna(player_elo) or pd.isna(opp_elo): continue
160
+ res_num, res_str = (0.5, "Draw")
161
+ if status not in ['draw', 'stalemate']:
162
+ if winner == player_color.lower(): res_num, res_str = (1, "Win")
163
+ elif winner is not None: res_num, res_str = (0, "Loss")
164
+ tc_str = "Unknown"
165
+ if clock_info:
166
+ init = clock_info.get('initial')
167
+ incr = clock_info.get('increment')
168
+ if init is not None and incr is not None: tc_str = f"{init}+{incr}"
169
+ elif speed == 'correspondence': tc_str = "Correspondence"
170
+ eco = opening_info.get('eco', 'Unknown')
171
+ op_name_api = opening_info.get('name', 'Unknown Opening').replace('?', '').split(':')[0].strip()
172
+ op_name_custom = eco_map.get(eco, f"ECO: {eco}" if eco != 'Unknown' else 'Unknown Opening')
173
+ 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"}
174
+ term = term_map.get(status, "Unknown")
175
+ opp_title_final = 'Unknown'
176
+ if opp_title_raw and opp_title_raw.strip():
177
+ opp_title_clean = opp_title_raw.replace(' ', '').strip().upper()
178
+ if opp_title_clean and opp_title_clean != '?': opp_title_final = opp_title_clean
179
+ def clean_name(n): return re.sub(r'^(GM|IM|FM|WGM|WIM|WFM|CM|WCM|NM)\s+', '', n).strip()
180
+ opp_name_clean = clean_name(opp_name_raw)
181
+ all_games_data.append({
182
+ 'Date': game_date, 'Event': perf, 'White': white_name, 'Black': black_name,
183
+ 'Result': "1-0" if winner == 'white' else ("0-1" if winner == 'black' else "1/2-1/2"),
184
+ 'WhiteElo': int(white_rating) if not pd.isna(white_rating) else 0,
185
+ 'BlackElo': int(black_rating) if not pd.isna(black_rating) else 0,
186
+ 'ECO': eco, 'OpeningName_API': op_name_api, 'OpeningName_Custom': op_name_custom,
187
+ 'TimeControl': tc_str, 'Termination': term, 'PlyCount': game_data.get('turns', 0),
188
+ 'LichessID': game_id, 'PlayerID': username, 'PlayerColor': player_color,
189
+ 'PlayerElo': int(player_elo), 'OpponentName': opp_name_clean, 'OpponentNameRaw': opp_name_raw,
190
+ 'OpponentElo': int(opp_elo), 'OpponentTitle': opp_title_final, 'PlayerResultNumeric': res_num,
191
+ 'PlayerResultString': res_str, 'Variant': variant, 'Speed': speed, 'Status': status, 'PerfType': perf
192
+ })
193
+ except json.JSONDecodeError:
194
+ error_counter += 1
195
+ except Exception:
196
+ error_counter += 1
197
+ except requests.exceptions.RequestException as e:
198
+ return pd.DataFrame(), f"🚨 API Error: {e}"
199
+ except Exception as e:
200
+ return pd.DataFrame(), f"🚨 Error: {e}\n{traceback.format_exc()}"
201
+ status_message = f"Processed {len(all_games_data)} games."
202
  if error_counter > 0: status_message += f" Skipped {error_counter} errors."
203
  if not all_games_data: return pd.DataFrame(), f"⚠️ No games found matching criteria."
204
  if progress: progress(0.8, desc="Finalizing...")
205
+ df = pd.DataFrame(all_games_data)
206
  if not df.empty:
207
+ df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
208
+ df = df.dropna(subset=['Date'])
209
  if df.empty: return df, "⚠️ No games with valid dates."
210
+ df['Year'] = df['Date'].dt.year
211
+ df['Month'] = df['Date'].dt.month
212
+ df['Day'] = df['Date'].dt.day
213
+ df['Hour'] = df['Date'].dt.hour
214
+ df['DayOfWeekNum'] = df['Date'].dt.dayofweek
215
+ df['DayOfWeekName'] = df['Date'].dt.day_name()
216
+ df['PlayerElo'] = df['PlayerElo'].astype(int)
217
+ df['OpponentElo'] = df['OpponentElo'].astype(int)
218
+ df['EloDiff'] = df['PlayerElo'] - df['OpponentElo']
219
+ df['TimeControl_Category'] = df.apply(lambda r: categorize_time_control(r['TimeControl'], r['Speed']), axis=1)
220
+ df = df.sort_values(by='Date').reset_index(drop=True)
221
  if progress: progress(1, desc="Complete!")
222
  return df, status_message
223
 
224
  # =============================================
225
+ # Plotting Functions
226
  # =============================================
 
 
227
  def plot_win_loss_pie(df, display_name):
228
  if 'PlayerResultString' not in df.columns: return go.Figure()
229
  result_counts = df['PlayerResultString'].value_counts()
230
+ fig = px.pie(values=result_counts.values, names=result_counts.index, title=f'Overall Results for {display_name}',
231
+ color=result_counts.index, color_discrete_map={'Win': '#4CAF50', 'Draw': '#B0BEC5', 'Loss': '#F44336'}, hole=0.3)
232
+ fig.update_traces(textposition='inside', textinfo='percent+label', pull=[0.05 if x == 'Win' else 0 for x in result_counts.index])
233
+ fig.update_layout(dragmode=False)
234
+ return fig
235
+
236
  def plot_win_loss_by_color(df):
237
  if not all(col in df.columns for col in ['PlayerColor', 'PlayerResultString']): return go.Figure()
238
+ try:
239
+ color_results = df.groupby(['PlayerColor', 'PlayerResultString']).size().unstack(fill_value=0)
240
+ except KeyError:
241
+ return go.Figure().update_layout(title="Error: Missing Columns")
242
+ for res in ['Win', 'Draw', 'Loss']: color_results[res] = color_results.get(res, 0)
243
+ color_results = color_results[['Win', 'Draw', 'Loss']]
244
+ total = color_results.sum(axis=1)
245
+ color_results_pct = color_results.apply(lambda x: x * 100 / total[x.name] if total[x.name] > 0 else 0, axis=1)
246
+ fig = px.bar(color_results_pct, barmode='stack', title='Results by Color', labels={'value': '%', 'PlayerColor': 'Played As'},
247
+ color='PlayerResultString', color_discrete_map={'Win': '#4CAF50', 'Draw': '#B0BEC5', 'Loss': '#F44336'},
248
+ text_auto='.1f', category_orders={"PlayerColor": ["White", "Black"]})
249
+ fig.update_layout(yaxis_title="Percentage (%)", xaxis_title="Color Played", dragmode=False)
250
+ fig.update_traces(textangle=0)
251
+ return fig
252
+
253
  def plot_rating_trend(df, display_name):
254
  if not all(col in df.columns for col in ['Date', 'PlayerElo']): return go.Figure()
255
+ df_plot = df.copy()
256
+ df_plot['PlayerElo'] = pd.to_numeric(df_plot['PlayerElo'], errors='coerce')
257
+ df_sorted = df_plot[df_plot['PlayerElo'].notna() & (df_plot['PlayerElo'] > 0)].sort_values('Date')
258
  if df_sorted.empty: return go.Figure().update_layout(title=f"No Elo data")
259
+ fig = go.Figure()
260
+ fig.add_trace(go.Scatter(x=df_sorted['Date'], y=df_sorted['PlayerElo'], mode='lines+markers', name='Elo',
261
+ line=dict(color='#1E88E5', width=2), marker=dict(size=5, opacity=0.7)))
262
+ fig.update_layout(title=f'{display_name}\'s Rating Trend', xaxis_title='Date', yaxis_title='Elo Rating',
263
+ hovermode="x unified", xaxis_rangeslider_visible=True, dragmode=False)
264
+ return fig
265
+
266
  def plot_performance_vs_opponent_elo(df):
267
  if not all(col in df.columns for col in ['PlayerResultString', 'EloDiff']): return go.Figure()
268
+ fig = px.box(df, x='PlayerResultString', y='EloDiff', title='Elo Advantage vs. Result',
269
+ labels={'PlayerResultString': 'Result', 'EloDiff': 'Your Elo - Opponent Elo'},
270
+ category_orders={"PlayerResultString": ["Win", "Draw", "Loss"]}, color='PlayerResultString',
271
+ color_discrete_map={'Win': '#4CAF50', 'Draw': '#B0BEC5', 'Loss': '#F44336'}, points='outliers')
272
+ fig.add_hline(y=0, line_dash="dash", line_color="grey")
273
+ fig.update_traces(marker=dict(opacity=0.8))
274
+ fig.update_layout(dragmode=False)
275
+ return fig
276
+
277
  def plot_games_by_dow(df):
278
  if 'DayOfWeekName' not in df.columns: return go.Figure()
279
+ dow_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
280
+ games_by_dow = df['DayOfWeekName'].value_counts().reindex(dow_order, fill_value=0)
281
+ fig = px.bar(games_by_dow, x=games_by_dow.index, y=games_by_dow.values, title="Games by Day of Week",
282
+ labels={'x': 'Day', 'y': 'Games'}, text=games_by_dow.values)
283
+ fig.update_traces(marker_color='#9C27B0', textposition='outside')
284
+ fig.update_layout(dragmode=False)
285
+ return fig
286
+
287
  def plot_winrate_by_dow(df):
288
  if not all(col in df.columns for col in ['DayOfWeekName', 'PlayerResultNumeric']): return go.Figure()
289
+ dow_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
290
+ wins_by_dow = df[df['PlayerResultNumeric'] == 1].groupby('DayOfWeekName').size()
291
+ total_by_dow = df.groupby('DayOfWeekName').size()
292
+ win_rate = (wins_by_dow.reindex(total_by_dow.index, fill_value=0) / total_by_dow).fillna(0) * 100
293
+ win_rate = win_rate.reindex(dow_order, fill_value=0)
294
+ fig = px.bar(win_rate, x=win_rate.index, y=win_rate.values, title="Win Rate (%) by Day",
295
+ labels={'x': 'Day', 'y': 'Win Rate (%)'}, text=win_rate.values)
296
+ fig.update_traces(marker_color='#FF9800', texttemplate='%{text:.1f}%', textposition='outside')
297
+ fig.update_layout(yaxis_range=[0, 100], dragmode=False)
298
+ return fig
299
+
300
  def plot_games_by_hour(df):
301
  if 'Hour' not in df.columns: return go.Figure()
302
+ games_by_hour = df['Hour'].value_counts().sort_index().reindex(range(24), fill_value=0)
303
+ fig = px.bar(games_by_hour, x=games_by_hour.index, y=games_by_hour.values, title="Games by Hour (UTC)",
304
+ labels={'x': 'Hour', 'y': 'Games'}, text=games_by_hour.values)
305
+ fig.update_traces(marker_color='#03A9F4', textposition='outside')
306
+ fig.update_layout(xaxis=dict(tickmode='linear'), dragmode=False)
307
+ return fig
308
+
309
  def plot_winrate_by_hour(df):
310
  if not all(col in df.columns for col in ['Hour', 'PlayerResultNumeric']): return go.Figure()
311
+ wins_by_hour = df[df['PlayerResultNumeric'] == 1].groupby('Hour').size()
312
+ total_by_hour = df.groupby('Hour').size()
313
+ win_rate = (wins_by_hour.reindex(total_by_hour.index, fill_value=0) / total_by_hour).fillna(0) * 100
314
+ win_rate = win_rate.reindex(range(24), fill_value=0)
315
+ fig = px.line(win_rate, x=win_rate.index, y=win_rate.values, markers=True, title="Win Rate (%) by Hour (UTC)",
316
+ labels={'x': 'Hour', 'y': 'Win Rate (%)'})
317
+ fig.update_traces(line_color='#8BC34A')
318
+ fig.update_layout(yaxis_range=[0, 100], xaxis=dict(tickmode='linear'), dragmode=False)
319
+ return fig
320
+
321
  def plot_games_per_year(df):
322
  if 'Year' not in df.columns: return go.Figure()
323
  games_per_year = df['Year'].value_counts().sort_index()
324
+ fig = px.bar(games_per_year, x=games_per_year.index, y=games_per_year.values, title='Games Per Year',
325
+ labels={'x': 'Year', 'y': 'Games'}, text=games_per_year.values)
326
+ fig.update_traces(marker_color='#2196F3', textposition='outside')
327
+ fig.update_layout(xaxis_title="Year", yaxis_title="Number of Games", xaxis={'type': 'category'}, dragmode=False)
328
+ return fig
329
+
330
  def plot_win_rate_per_year(df):
331
  if not all(col in df.columns for col in ['Year', 'PlayerResultNumeric']): return go.Figure()
332
+ wins_per_year = df[df['PlayerResultNumeric'] == 1].groupby('Year').size()
333
+ total_per_year = df.groupby('Year').size()
334
+ win_rate = (wins_per_year.reindex(total_per_year.index, fill_value=0) / total_per_year).fillna(0) * 100
335
+ win_rate.index = win_rate.index.astype(str)
336
+ fig = px.line(win_rate, x=win_rate.index, y=win_rate.values, title='Win Rate (%) Per Year', markers=True,
337
+ labels={'x': 'Year', 'y': 'Win Rate (%)'})
338
+ fig.update_traces(line_color='#FFC107', line_width=2.5)
339
+ fig.update_layout(yaxis_range=[0, 100], dragmode=False)
340
+ return fig
341
+
342
  def plot_performance_by_time_control(df):
343
+ if not all(col in df.columns for col in ['TimeControl_Category', 'PlayerResultString']): return go.Figure()
344
+ try:
345
+ tc_results = df.groupby(['TimeControl_Category', 'PlayerResultString']).size().unstack(fill_value=0)
346
+ for res in ['Win', 'Draw', 'Loss']: tc_results[res] = tc_results.get(res, 0)
347
+ tc_results = tc_results[['Win', 'Draw', 'Loss']]
348
+ total = tc_results.sum(axis=1)
349
+ tc_results_pct = tc_results.apply(lambda x: x * 100 / total[x.name] if total[x.name] > 0 else 0, axis=1)
350
+ found = df['TimeControl_Category'].unique()
351
+ pref = ['Bullet', 'Blitz', 'Rapid', 'Classical', 'Correspondence', 'Unknown']
352
+ order = [c for c in pref if c in found] + [c for c in found if c not in pref]
353
+ tc_results_pct = tc_results_pct.reindex(index=order).dropna(axis=0, how='all')
354
+ fig = px.bar(tc_results_pct, title='Performance by Time Control', labels={'value': '%', 'TimeControl_Category': 'Category'},
355
+ color='PlayerResultString', color_discrete_map={'Win': '#4CAF50', 'Draw': '#B0BEC5', 'Loss': '#F44336'},
356
+ barmode='group', text_auto='.1f')
357
+ fig.update_layout(xaxis_title="Time Control Category", yaxis_title="Percentage (%)", dragmode=False)
358
+ fig.update_traces(textangle=0)
359
+ return fig
360
+ except Exception:
361
+ return go.Figure().update_layout(title="Error")
362
+
363
  def plot_opening_frequency(df, top_n=20, opening_col='OpeningName_API'):
364
  if opening_col not in df.columns: return go.Figure()
365
  source_label = "Lichess API" if opening_col == 'OpeningName_API' else "Custom Mapping"
366
  opening_counts = df[df[opening_col] != 'Unknown Opening'][opening_col].value_counts().nlargest(top_n)
367
+ fig = px.bar(opening_counts, y=opening_counts.index, x=opening_counts.values, orientation='h',
368
+ title=f'Top {top_n} Openings ({source_label})', labels={'y': 'Opening', 'x': 'Games'}, text=opening_counts.values)
369
+ fig.update_layout(yaxis={'categoryorder': 'total ascending'}, dragmode=False)
370
+ fig.update_traces(marker_color='#673AB7', textposition='outside')
371
+ return fig
372
+
373
  def plot_win_rate_by_opening(df, min_games=5, top_n=20, opening_col='OpeningName_API'):
374
  if not all(col in df.columns for col in [opening_col, 'PlayerResultNumeric']): return go.Figure()
375
  source_label = "Lichess API" if opening_col == 'OpeningName_API' else "Custom Mapping"
376
+ opening_stats = df.groupby(opening_col).agg(total_games=('PlayerResultNumeric', 'count'),
377
+ wins=('PlayerResultNumeric', lambda x: (x == 1).sum()))
378
+ opening_stats = opening_stats[(opening_stats['total_games'] >= min_games) & (opening_stats.index != 'Unknown Opening')].copy()
379
  if opening_stats.empty: return go.Figure().update_layout(title=f"No openings >= {min_games} games ({source_label})")
380
+ opening_stats['win_rate'] = (opening_stats['wins'] / opening_stats['total_games']) * 100
381
+ opening_stats_plot = opening_stats.nlargest(top_n, 'win_rate')
382
+ fig = px.bar(opening_stats_plot, y=opening_stats_plot.index, x='win_rate', orientation='h',
383
+ title=f'Top {top_n} Openings by Win Rate (Min {min_games} games, {source_label})',
384
+ labels={'win_rate': 'Win Rate (%)', opening_col: 'Opening'}, text='win_rate')
385
+ fig.update_traces(texttemplate='%{text:.1f}%', textposition='inside', marker_color='#009688')
386
+ fig.update_layout(yaxis={'categoryorder': 'total ascending'}, xaxis_title="Win Rate (%)", dragmode=False)
387
+ return fig
388
+
389
  def plot_most_frequent_opponents(df, top_n=20):
390
  if 'OpponentName' not in df.columns: return go.Figure()
391
+ opp_counts = df[df['OpponentName'] != 'Unknown']['OpponentName'].value_counts().nlargest(top_n)
392
+ fig = px.bar(opp_counts, y=opp_counts.index, x=opp_counts.values, orientation='h',
393
+ title=f'Top {top_n} Opponents', labels={'y': 'Opponent', 'x': 'Games'}, text=opp_counts.values)
394
+ fig.update_layout(yaxis={'categoryorder': 'total ascending'}, dragmode=False)
395
+ fig.update_traces(marker_color='#FF5722', textposition='outside')
396
+ return fig
397
+
398
  def plot_games_by_dom(df):
399
  if 'Day' not in df.columns: return go.Figure()
400
  games_by_dom = df['Day'].value_counts().sort_index().reindex(range(1, 32), fill_value=0)
401
+ fig = px.bar(games_by_dom, x=games_by_dom.index, y=games_by_dom.values, title="Games Played per Day of Month",
402
+ labels={'x': 'Day of Month', 'y': 'Number of Games'}, text=games_by_dom.values)
403
+ fig.update_traces(marker_color='#E91E63', textposition='outside')
404
+ fig.update_layout(xaxis=dict(tickmode='linear'), dragmode=False)
405
+ return fig
406
+
407
  def plot_winrate_by_dom(df):
408
  if not all(col in df.columns for col in ['Day', 'PlayerResultNumeric']): return go.Figure()
409
+ wins_by_dom = df[df['PlayerResultNumeric'] == 1].groupby('Day').size()
410
+ total_by_dom = df.groupby('Day').size()
411
+ win_rate = (wins_by_dom.reindex(total_by_dom.index, fill_value=0) / total_by_dom).fillna(0) * 100
412
+ win_rate = win_rate.reindex(range(1, 32), fill_value=0)
413
+ fig = px.line(win_rate, x=win_rate.index, y=win_rate.values, markers=True, title="Win Rate (%) per Day of Month",
414
+ labels={'x': 'Day of Month', 'y': 'Win Rate (%)'})
415
+ fig.update_traces(line_color='#FF5722')
416
+ fig.update_layout(yaxis_range=[0, 100], xaxis=dict(tickmode='linear'), dragmode=False)
417
+ return fig
418
+
419
  def plot_time_forfeit_summary(wins_tf, losses_tf):
420
+ data = {'Outcome': ['Won on Time', 'Lost on Time'], 'Count': [wins_tf, losses_tf]}
421
+ df_tf = pd.DataFrame(data)
422
+ fig = px.bar(df_tf, x='Outcome', y='Count', title="Time Forfeit Summary", color='Outcome',
423
+ color_discrete_map={'Won on Time': '#4CAF50', 'Lost on Time': '#F44336'}, text='Count')
424
+ fig.update_layout(showlegend=False, dragmode=False)
425
+ fig.update_traces(textposition='outside')
426
+ return fig
427
+
428
  def plot_time_forfeit_by_tc(tf_games_df):
429
  if 'TimeControl_Category' not in tf_games_df.columns or tf_games_df.empty: return go.Figure().update_layout(title="No TF Data by Category")
430
+ tf_by_tc = tf_games_df['TimeControl_Category'].value_counts()
431
+ fig = px.bar(tf_by_tc, x=tf_by_tc.index, y=tf_by_tc.values, title="Time Forfeits by Time Control",
432
+ labels={'x': 'Category', 'y': 'Forfeits'}, text=tf_by_tc.values)
433
+ fig.update_layout(dragmode=False)
434
+ fig.update_traces(marker_color='#795548', textposition='outside')
435
+ return fig
436
 
437
  # =============================================
438
  # Helper Functions
439
  # =============================================
 
440
  def filter_and_analyze_titled(df, titles):
441
  if 'OpponentTitle' not in df.columns: return pd.DataFrame()
442
+ titled_games = df[df['OpponentTitle'].isin(titles)].copy()
443
+ return titled_games
444
+
445
  def filter_and_analyze_time_forfeits(df):
446
  if 'Termination' not in df.columns: return pd.DataFrame(), 0, 0
447
  tf_games = df[df['Termination'].str.contains("Time forfeit", na=False, case=False)].copy()
 
451
  return tf_games, wins_tf, losses_tf
452
 
453
  # =============================================
454
+ # Gradio Main Analysis Function
455
  # =============================================
 
456
  def perform_full_analysis(username, time_period_key, perf_type, selected_titles_list, progress=gr.Progress(track_tqdm=True)):
457
  df, status_msg = load_from_lichess_api(username, time_period_key, perf_type, DEFAULT_RATED_ONLY, ECO_MAPPING, progress)
458
+ num_outputs = 30
459
  if not isinstance(df, pd.DataFrame) or df.empty:
460
+ return status_msg, pd.DataFrame(), *([None] * (num_outputs - 2))
461
  try:
462
+ fig_pie = plot_win_loss_pie(df, username)
463
+ fig_color = plot_win_loss_by_color(df)
464
+ fig_rating = plot_rating_trend(df, username)
465
+ fig_elo_diff = plot_performance_vs_opponent_elo(df)
466
+ total_g = len(df)
467
+ w = len(df[df['PlayerResultNumeric'] == 1])
468
+ l = len(df[df['PlayerResultNumeric'] == 0])
469
+ d = len(df[df['PlayerResultNumeric'] == 0.5])
470
+ wr = (w / total_g * 100) if total_g > 0 else 0
471
+ avg_opp = df['OpponentElo'].mean()
472
+ overview_stats_md = f"**Total:** {total_g:,} | **WR:** {wr:.1f}% | **W/L/D:** {w}/{l}/{d} | **Avg Opp:** {avg_opp:.0f if not pd.isna(avg_opp) else 'N/A'}"
473
+ fig_games_yr = plot_games_per_year(df)
474
+ fig_wr_yr = plot_win_rate_per_year(df)
475
+ fig_perf_tc = plot_performance_by_time_control(df)
476
+ fig_games_dow = plot_games_by_dow(df)
477
+ fig_wr_dow = plot_winrate_by_dow(df)
478
+ fig_games_hod = plot_games_by_hour(df)
479
+ fig_wr_hod = plot_winrate_by_hour(df)
480
+ fig_games_dom = plot_games_by_dom(df)
481
+ fig_wr_dom = plot_winrate_by_dom(df)
482
+ fig_open_freq_api = plot_opening_frequency(df, top_n=15, opening_col='OpeningName_API')
483
+ fig_open_wr_api = plot_win_rate_by_opening(df, min_games=5, top_n=15, opening_col='OpeningName_API')
484
+ fig_open_freq_cust = plot_opening_frequency(df, top_n=15, opening_col='OpeningName_Custom') if ECO_MAPPING else go.Figure().update_layout(title="Custom Map Unavailable")
485
+ fig_open_wr_cust = plot_win_rate_by_opening(df, min_games=5, top_n=15, opening_col='OpeningName_Custom') if ECO_MAPPING else go.Figure().update_layout(title="Custom Map Unavailable")
486
+ fig_opp_freq = plot_most_frequent_opponents(df, top_n=20)
487
+ df_opp_list = df[df['OpponentName'] != 'Unknown']['OpponentName'].value_counts().reset_index(name='Games').head(20) if 'OpponentName' in df else pd.DataFrame()
488
+ fig_opp_elo = plot_performance_vs_opponent_elo(df)
489
+ tf_games, wins_tf, losses_tf = filter_and_analyze_time_forfeits(df)
490
+ fig_tf_summary = plot_time_forfeit_summary(wins_tf, losses_tf) if not tf_games.empty else go.Figure().update_layout(title="No Time Forfeit Data")
491
+ fig_tf_tc = plot_time_forfeit_by_tc(tf_games) if not tf_games.empty else go.Figure().update_layout(title="No TF Data by Category")
492
+ 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()
493
+ term_counts = df['Termination'].value_counts()
494
+ fig_term_all = px.bar(term_counts, x=term_counts.index, y=term_counts.values, title="Overall Termination Reasons",
495
+ labels={'x': 'Reason', 'y': 'Count'}, text=term_counts.values)
496
+ fig_term_all.update_layout(dragmode=False)
497
+ fig_term_all.update_traces(textposition='outside')
498
+ titled_status_msg = ""
499
+ fig_titled_pie, fig_titled_color, fig_titled_rating, df_titled_h2h = go.Figure(), go.Figure(), go.Figure(), pd.DataFrame()
500
  if selected_titles_list:
501
  titled_games = filter_and_analyze_titled(df, selected_titles_list)
502
  if not titled_games.empty:
 
505
  fig_titled_color = plot_win_loss_by_color(titled_games)
506
  fig_titled_rating = plot_rating_trend(titled_games, f"{username} (vs Titles)")
507
  h2h = titled_games.groupby('OpponentNameRaw')['PlayerResultString'].value_counts().unstack(fill_value=0)
508
+ for res in ['Win', 'Loss', 'Draw']: h2h[res] = h2h.get(res, 0)
509
+ h2h = h2h[['Win', 'Loss', 'Draw']]
510
+ h2h['Total'] = h2h.sum(axis=1)
511
+ h2h['Score'] = h2h['Win'] + 0.5 * h2h['Draw']
512
  df_titled_h2h = h2h.sort_values('Total', ascending=False).reset_index()
513
+ else:
514
+ titled_status_msg = f"ℹ️ No games found vs selected titles ({', '.join(selected_titles_list)})."
515
+ else:
516
+ titled_status_msg = "ℹ️ Select titles from the sidebar to analyze."
517
+ return (status_msg, df, fig_pie, overview_stats_md, fig_color, fig_rating, fig_elo_diff, fig_games_yr, fig_wr_yr,
518
+ "(Results by color shown in Overview)", fig_games_dow, fig_wr_dow, fig_games_hod, fig_wr_hod, fig_games_dom,
519
+ fig_wr_dom, fig_perf_tc, fig_open_freq_api, fig_open_wr_api, fig_open_freq_cust, fig_open_wr_cust,
520
+ fig_opp_freq, df_opp_list, fig_opp_elo, titled_status_msg, fig_titled_pie, fig_titled_color, fig_titled_rating,
521
+ df_titled_h2h, fig_tf_summary, fig_tf_tc, df_tf_list, fig_term_all)
522
+ except Exception as e:
523
+ error_msg = f"🚨 Error generating results: {e}\n{traceback.format_exc()}"
524
+ return error_msg, pd.DataFrame(), *([None] * num_outputs)
525
 
526
  # =============================================
527
+ # Gradio Interface Definition
528
  # =============================================
 
529
  css = """.gradio-container { font-family: 'IBM Plex Sans', sans-serif; } footer { display: none !important; }"""
530
  with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo:
531
  gr.Markdown("# ♟️ Lichess Insights\nAnalyze rated game statistics from Lichess API.")
532
  df_state = gr.State(pd.DataFrame())
533
  with gr.Row():
534
+ with gr.Column(scale=1, min_width=250): # Sidebar
535
+ gr.Markdown("## ⚙️ Settings")
536
+ username_input = gr.Textbox(label="Lichess Username", placeholder="e.g., DrNykterstein", elem_id="username_box")
537
+ time_period_input = gr.Dropdown(label="Time Period", choices=list(TIME_PERIOD_OPTIONS.keys()), value=DEFAULT_TIME_PERIOD)
538
+ perf_type_input = gr.Dropdown(label="Game Type", choices=PERF_TYPE_OPTIONS_SINGLE, value=DEFAULT_PERF_TYPE)
539
+ analyze_btn = gr.Button("Analyze Games", variant="primary")
540
+ status_output = gr.Markdown("")
541
+ gr.Markdown("---")
542
+ gr.Markdown("### Analyze vs Titled Players")
543
+ titled_player_select = gr.CheckboxGroup(label="Select Opponent Titles", choices=TITLES_TO_ANALYZE, value=['GM', 'IM'], elem_id="titled_select")
544
+ gr.Markdown("*(Analysis updates on 'Analyze Games' click)*")
545
+ with gr.Column(scale=4): # Main Content
546
  with gr.Tabs() as tabs:
547
+ with gr.TabItem("1. Overview", id=0):
548
+ overview_stats_md_out = gr.Markdown()
549
+ with gr.Row():
550
+ overview_plot_pie = gr.Plot(label="Overall Results")
551
+ overview_plot_color = gr.Plot(label="Results by Color")
552
+ overview_plot_rating = gr.Plot(label="Rating Trend")
553
+ overview_plot_elo_diff = gr.Plot(label="Elo Advantage vs. Result")
554
+ with gr.TabItem("2. Perf. Over Time", id=1):
555
+ time_plot_games_yr = gr.Plot(label="Games per Year")
556
+ time_plot_wr_yr = gr.Plot(label="Win Rate per Year")
557
+ with gr.TabItem("3. Perf. by Color", id=2):
558
+ color_plot_placeholder = gr.Markdown("(Results by color shown in Overview)")
559
+ with gr.TabItem("4. Time & Date", id=3):
560
+ gr.Markdown("### Day of Week")
561
+ with gr.Row():
562
+ time_plot_games_dow = gr.Plot(label="Games by Day of Week")
563
+ time_plot_wr_dow = gr.Plot(label="Win Rate by Day of Week")
564
+ gr.Markdown("### Hour of Day (UTC)")
565
+ with gr.Row():
566
+ time_plot_games_hod = gr.Plot(label="Games by Hour (UTC)")
567
+ time_plot_wr_hod = gr.Plot(label="Win Rate by Hour (UTC)")
568
+ gr.Markdown("### Day of Month")
569
+ with gr.Row():
570
+ time_plot_games_dom = gr.Plot(label="Games by Day of Month")
571
+ time_plot_wr_dom = gr.Plot(label="Win Rate by Day of Month")
572
+ gr.Markdown("### Time Control Category")
573
+ time_plot_perf_tc = gr.Plot(label="Performance by Time Control")
574
+ with gr.TabItem("5. ECO & Openings", id=4):
575
+ gr.Markdown("#### API Names")
576
+ eco_plot_freq_api = gr.Plot(label="Opening Frequency (API)")
577
+ eco_plot_wr_api = gr.Plot(label="Opening Win Rate (API)")
578
+ gr.Markdown("---")
579
+ gr.Markdown("#### Custom Map")
580
+ if not ECO_MAPPING:
581
+ gr.Markdown("⚠️ Custom map not loaded.")
582
+ eco_plot_freq_cust = gr.Plot(label="Opening Frequency (Custom)")
583
+ eco_plot_wr_cust = gr.Plot(label="Opening Win Rate (Custom)")
584
+ with gr.TabItem("6. Opponents", id=5):
585
+ opp_plot_freq = gr.Plot(label="Frequent Opponents")
586
+ opp_df_list = gr.DataFrame(label="Top Opponents List", wrap=True)
587
+ opp_plot_elo = gr.Plot(label="Elo Advantage vs Result")
588
+ with gr.TabItem("7. vs Titled", id=6):
589
+ gr.Markdown("Analysis based on sidebar selection.")
590
+ titled_status = gr.Markdown()
591
+ with gr.Row():
592
+ titled_plot_pie = gr.Plot(label="Results vs Selected Titles")
593
+ titled_plot_color = gr.Plot(label="Results by Color vs Selected Titles")
594
+ titled_plot_rating = gr.Plot(label="Rating Trend vs Selected Titles")
595
+ titled_df_h2h_comp = gr.DataFrame(label="Head-to-Head vs Selected Titles", wrap=True)
596
+ with gr.TabItem("8. Termination", id=7):
597
+ gr.Markdown("### Time Forfeit")
598
+ term_plot_tf_summary = gr.Plot(label="Time Forfeit Summary")
599
+ term_plot_tf_tc = gr.Plot(label="Time Forfeits by Time Control")
600
+ with gr.Accordion("View Recent TF Games", open=False):
601
+ term_df_tf_list = gr.DataFrame(label="Recent TF Games", wrap=True)
602
+ gr.Markdown("### Overall Termination")
603
+ term_plot_all = gr.Plot(label="Overall Termination")
604
+ outputs_list = [status_output, df_state, overview_plot_pie, overview_stats_md_out, overview_plot_color, overview_plot_rating,
605
+ overview_plot_elo_diff, time_plot_games_yr, time_plot_wr_yr, color_plot_placeholder, time_plot_games_dow,
606
+ time_plot_wr_dow, time_plot_games_hod, time_plot_wr_hod, time_plot_games_dom, time_plot_wr_dom,
607
+ time_plot_perf_tc, eco_plot_freq_api, eco_plot_wr_api, eco_plot_freq_cust, eco_plot_wr_cust, opp_plot_freq,
608
+ opp_df_list, opp_plot_elo, titled_status, titled_plot_pie, titled_plot_color, titled_plot_rating,
609
+ titled_df_h2h_comp, term_plot_tf_summary, term_plot_tf_tc, term_df_tf_list, term_plot_all]
610
+ analyze_btn.click(fn=perform_full_analysis, inputs=[username_input, time_period_input, perf_type_input, titled_player_select],
611
+ outputs=outputs_list)
612
 
613
  # --- Launch the Gradio App ---
614
  if __name__ == "__main__":