Spaces:
Sleeping
Sleeping
Kaveh commited on
Update app.py
Browse files
app.py
CHANGED
|
@@ -19,7 +19,7 @@ import traceback
|
|
| 19 |
# No Streamlit config needed
|
| 20 |
|
| 21 |
# --- Constants & Defaults ---
|
| 22 |
-
TIME_PERIOD_OPTIONS = {
|
| 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
|
| 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'
|
| 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'
|
| 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
|
| 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:
|
| 101 |
-
|
| 102 |
-
except
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
# =============================================
|
| 105 |
-
# API Data Loading and Processing Function
|
| 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()
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
if
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
try:
|
| 122 |
-
response=requests.get(api_url, params=api_params, headers=headers, stream=True)
|
|
|
|
| 123 |
if progress: progress(0.3, desc="Processing stream...")
|
| 124 |
for line in response.iter_lines():
|
| 125 |
if line:
|
| 126 |
-
lines_processed += 1
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
try:
|
| 129 |
-
game_data=json.loads(game_data_raw)
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
if pd.isna(game_date): continue
|
| 133 |
-
variant
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 144 |
-
|
| 145 |
-
tc_str="Unknown"
|
| 146 |
-
if clock_info:
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')
|
|
|
|
| 170 |
if df.empty: return df, "⚠️ No games with valid dates."
|
| 171 |
-
df['Year']
|
| 172 |
-
df['
|
| 173 |
-
df['
|
| 174 |
-
df=df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if progress: progress(1, desc="Complete!")
|
| 176 |
return df, status_message
|
| 177 |
|
| 178 |
# =============================================
|
| 179 |
-
# Plotting Functions
|
| 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}',
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
| 199 |
if df_sorted.empty: return go.Figure().update_layout(title=f"No Elo data")
|
| 200 |
-
fig=go.Figure()
|
| 201 |
-
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',
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 216 |
-
|
| 217 |
-
win_rate=
|
| 218 |
-
|
| 219 |
-
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)",
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 228 |
-
|
| 229 |
-
win_rate=
|
| 230 |
-
|
| 231 |
-
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',
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 240 |
-
|
| 241 |
-
win_rate.index=
|
| 242 |
-
|
| 243 |
-
fig.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
def plot_performance_by_time_control(df):
|
| 245 |
-
|
| 246 |
-
|
| 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']]
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'),
|
| 267 |
-
|
|
|
|
| 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',
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 286 |
-
|
| 287 |
-
win_rate=
|
| 288 |
-
|
| 289 |
-
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',
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
| 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
|
| 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
|
| 323 |
if not isinstance(df, pd.DataFrame) or df.empty:
|
| 324 |
-
return status_msg, pd.DataFrame(), *(
|
| 325 |
try:
|
| 326 |
-
fig_pie
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
|
|
|
|
|
|
| 351 |
df_titled_h2h = h2h.sort_values('Total', ascending=False).reset_index()
|
| 352 |
-
else:
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
# =============================================
|
| 358 |
-
# Gradio Interface Definition
|
| 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):
|
| 367 |
-
gr.Markdown("## ⚙️ Settings")
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
| 377 |
with gr.Tabs() as tabs:
|
| 378 |
-
with gr.TabItem("1. Overview", id=0):
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
with gr.TabItem("
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__":
|