import gradio as gr import pandas as pd import numpy as np import plotly.graph_objects as go import plotly.express as px # ============================================================ # LOAD DATA # ============================================================ DATA_FILE = "gk_scores_final.csv" df = pd.read_csv(DATA_FILE) df.columns = ( df.columns .str.strip() .str.lower() .str.replace(" ", "_") .str.replace("-", "_") .str.replace("/", "_") ) # ============================================================ # COLUMN SETUP # ============================================================ PLAYER_COL = "player_name" TEAM_COL = "team_name" COMP_COL = "competition_name" POSITION_COL = "primary_position" AGE_COL = "age" SEASON_COL = "season_name" HEIGHT_COL = "player_height" MINUTES_COL = "player_season_minutes" # GK-specific scoring columns ARCHETYPE_COL = "best_gk_archetype" ARCHETYPE_SCORE_COL = "best_gk_archetype_score" GK_SCORE_COL = "gk_score" # GK category scores (gk_cat_*) CATEGORY_METRICS = [ "gk_cat_shot_stopping", "gk_cat_sweeping", "gk_cat_short_passing", "gk_cat_long_passing", "gk_cat_ball_claiming", "gk_cat_overall_value", ] # GK archetype scores SCORING_METRICS = [ "shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score", "best_gk_archetype", "best_gk_archetype_score", "gk_score", ] # Key per-90 / ratio metrics shown in tables and profile KEY_METRICS = [ "player_season_save_ratio", "player_season_gsaa_90", "player_season_gsaa", "player_season_shots_faced_90", "player_season_goals_faced_90", "player_season_errors_90", "player_season_clcaa", "player_season_da_aggressive_distance", "player_season_passing_ratio", "player_season_long_ball_ratio", "player_season_aerial_ratio", "player_season_obv_gk_90", "player_season_obv_90", "player_season_pressures_90", ] # Radar uses the GK category scores RADAR_METRICS = [ "gk_cat_shot_stopping", "gk_cat_sweeping", "gk_cat_short_passing", "gk_cat_long_passing", "gk_cat_ball_claiming", "gk_cat_overall_value", ] # Percentile bar chart metrics PERCENTILE_METRICS = [ "player_season_save_ratio", "player_season_gsaa_90", "player_season_shots_faced_90", "player_season_goals_faced_90", "player_season_errors_90", "player_season_clcaa", "player_season_da_aggressive_distance", "player_season_passing_ratio", "player_season_long_ball_ratio", "player_season_aerial_ratio", "player_season_obv_gk_90", "gk_cat_shot_stopping", "gk_cat_sweeping", "gk_cat_overall_value", ] # Fit score calculator — weights map to these columns FIT_SCORE_METRICS = [ "gk_cat_shot_stopping", "gk_cat_sweeping", "gk_cat_short_passing", "gk_cat_long_passing", "gk_cat_ball_claiming", "gk_cat_overall_value", ] # ============================================================ # CLEANING HELPERS # ============================================================ def available_cols(cols): return [c for c in cols if c in df.columns] def label_col(col): return ( col.replace("player_season_", "") .replace("gk_cat_", "") .replace("_90", " per 90") .replace("_", " ") .title() ) def safe_numeric(data, col): if col in data.columns: return pd.to_numeric(data[col], errors="coerce") return pd.Series(dtype=float) # Coerce numeric columns _numeric_cols = available_cols( KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS + PERCENTILE_METRICS + FIT_SCORE_METRICS ) for col in _numeric_cols: if col != ARCHETYPE_COL: df[col] = pd.to_numeric(df[col], errors="coerce") if AGE_COL in df.columns: df[AGE_COL] = pd.to_numeric(df[AGE_COL], errors="coerce") # ============================================================ # DROPDOWN OPTIONS # ============================================================ player_options = sorted(df[PLAYER_COL].dropna().astype(str).unique().tolist()) competition_options = sorted(df[COMP_COL].dropna().astype(str).unique().tolist()) team_options = sorted(df[TEAM_COL].dropna().astype(str).unique().tolist()) position_options = sorted(df[POSITION_COL].dropna().astype(str).unique().tolist()) age_min = int(np.floor(df[AGE_COL].min())) if AGE_COL in df.columns else 15 age_max = int(np.ceil(df[AGE_COL].max())) if AGE_COL in df.columns else 45 metric_options = available_cols(KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS) shortlist = [] # ============================================================ # PLAYER SEARCH # ============================================================ def search_players(search, competitions, teams, positions, min_age, max_age, min_minutes): data = df.copy() if search and PLAYER_COL in data.columns: data = data[data[PLAYER_COL].astype(str).str.contains(str(search), case=False, na=False)] if competitions and COMP_COL in data.columns: data = data[data[COMP_COL].astype(str).isin(competitions)] if teams and TEAM_COL in data.columns: data = data[data[TEAM_COL].astype(str).isin(teams)] if positions and POSITION_COL in data.columns: data = data[data[POSITION_COL].astype(str).isin(positions)] if AGE_COL in data.columns: data[AGE_COL] = pd.to_numeric(data[AGE_COL], errors="coerce") data = data[(data[AGE_COL] >= min_age) & (data[AGE_COL] <= max_age)] if MINUTES_COL in data.columns: data[MINUTES_COL] = pd.to_numeric(data[MINUTES_COL], errors="coerce") data = data[data[MINUTES_COL].fillna(0) >= min_minutes] table_cols = available_cols([ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, AGE_COL, MINUTES_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL, ] + KEY_METRICS) out = data[table_cols].copy() if out.empty: return pd.DataFrame({"Message": ["No players found. Try clearing some filters."]}) if AGE_COL in out.columns: out[AGE_COL] = pd.to_numeric(out[AGE_COL], errors="coerce").round(1) numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) if GK_SCORE_COL in out.columns: out = out.sort_values(by=GK_SCORE_COL, ascending=False) return out.reset_index(drop=True) # ============================================================ # PLAYER PROFILE # ============================================================ def get_player_row(player): rows = df[df[PLAYER_COL].astype(str) == str(player)] if rows.empty: return None return rows.iloc[0] def player_profile(player): row = get_player_row(player) if row is None: return "Select a player to view their profile." lines = [] lines.append(f"# {row[PLAYER_COL]}") lines.append(f"### {row.get(TEAM_COL, 'N/A')} | {row.get(COMP_COL, 'N/A')}") lines.append("") lines.append("## Player Details") lines.append(f"- **Position:** {row.get(POSITION_COL, 'N/A')}") lines.append(f"- **Age:** {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}") lines.append(f"- **Height:** {row.get(HEIGHT_COL, 'N/A')} cm") lines.append(f"- **Minutes:** {round(row.get(MINUTES_COL, 0), 0)}") lines.append(f"- **Appearances:** {row.get('player_season_appearances', 'N/A')}") lines.append("") lines.append("## GK Scoring") lines.append(f"- **Best Archetype:** {row.get(ARCHETYPE_COL, 'N/A')}") lines.append(f"- **Best Archetype Score:** {round(row.get(ARCHETYPE_SCORE_COL, np.nan), 2) if pd.notna(row.get(ARCHETYPE_SCORE_COL, np.nan)) else 'N/A'}") lines.append(f"- **Overall GK Score:** {round(row.get(GK_SCORE_COL, np.nan), 2) if pd.notna(row.get(GK_SCORE_COL, np.nan)) else 'N/A'}") lines.append("") lines.append("## Archetype Scores") for col in ["shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score"]: val = row.get(col, np.nan) if col in df.columns and pd.notna(val): lines.append(f"- **{label_col(col.replace('_score', ''))}:** {round(val, 2)}") lines.append("") lines.append("## Key Season Stats") for col in available_cols(KEY_METRICS): value = row.get(col, np.nan) if pd.notna(value): lines.append(f"- **{label_col(col)}:** {round(value, 2)}") return "\n".join(lines) def category_table(player): row = get_player_row(player) if row is None: return pd.DataFrame({"Message": ["Select a player."]}) rows = [] for col in available_cols(CATEGORY_METRICS): value = row.get(col, np.nan) if pd.notna(value): rows.append({ "Category": label_col(col), "Score": round(value, 2) }) if not rows: return pd.DataFrame({"Message": ["No category data available."]}) return pd.DataFrame(rows).sort_values("Score", ascending=False) # ============================================================ # RADAR CHART # ============================================================ def radar_chart(player): row = get_player_row(player) if row is None: return go.Figure() metrics = available_cols(RADAR_METRICS) if len(metrics) < 3: fig = go.Figure() fig.update_layout(title="Need at least 3 radar metrics.") return fig comp = row[COMP_COL] group = df[df[COMP_COL] == comp].copy() labels = [label_col(m) for m in metrics] player_values = [row[m] if pd.notna(row[m]) else 0 for m in metrics] avg_values = [group[m].mean() for m in metrics] fig = go.Figure() fig.add_trace(go.Scatterpolar(r=player_values, theta=labels, fill="toself", name=str(player))) fig.add_trace(go.Scatterpolar(r=avg_values, theta=labels, fill="toself", name=f"GK Avg in {comp}")) fig.update_layout( title=f"{player} vs Competition Average", polar=dict(radialaxis=dict(visible=True, range=[0, 100])), showlegend=True ) return fig # ============================================================ # PERCENTILE BARS # ============================================================ def percentile_chart(player): row = get_player_row(player) if row is None: return go.Figure() comp = row[COMP_COL] group = df[df[COMP_COL] == comp].copy() rows = [] for metric in available_cols(PERCENTILE_METRICS): value = row.get(metric, np.nan) values = pd.to_numeric(group[metric], errors="coerce").dropna() if pd.notna(value) and len(values) > 1: pct = (values < value).mean() * 100 rows.append({"Metric": label_col(metric), "Percentile": round(pct, 1), "Value": round(value, 2)}) if not rows: fig = go.Figure() fig.update_layout(title="No percentile data available.") return fig plot_df = pd.DataFrame(rows) fig = px.bar( plot_df.sort_values("Percentile"), x="Percentile", y="Metric", orientation="h", hover_data=["Value"], title=f"{player} Percentiles vs Same Competition", range_x=[0, 100] ) fig.update_layout(yaxis_title="", xaxis_title="Percentile") return fig # ============================================================ # PERFORMANCE OVER TIME # ============================================================ def performance_chart(player, metric): row_data = df[df[PLAYER_COL].astype(str) == str(player)].copy() if row_data.empty or metric not in df.columns: return go.Figure() if SEASON_COL not in df.columns or row_data[SEASON_COL].nunique() <= 1: fig = go.Figure() fig.add_trace(go.Bar(x=[label_col(metric)], y=[row_data.iloc[0][metric]])) fig.update_layout( title="Only one season in this file — showing single-season value.", yaxis_title=label_col(metric) ) return fig row_data[metric] = pd.to_numeric(row_data[metric], errors="coerce") fig = px.line(row_data, x=SEASON_COL, y=metric, markers=True, title=f"{player}: {label_col(metric)} Over Time") return fig # ============================================================ # PLAYER COMPARISON # ============================================================ def compare_players(player_1, player_2, player_3): players = [p for p in [player_1, player_2, player_3] if p] if not players: return pd.DataFrame({"Message": ["Select at least one player."]}) data = df[df[PLAYER_COL].astype(str).isin(players)].copy() cols = available_cols([ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, AGE_COL, MINUTES_COL, HEIGHT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL, "shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score", ] + KEY_METRICS + CATEGORY_METRICS) out = data[cols].copy() numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) return out.reset_index(drop=True) def comparison_radar(player_1, player_2, player_3): players = [p for p in [player_1, player_2, player_3] if p] metrics = available_cols(RADAR_METRICS) fig = go.Figure() if not players or len(metrics) < 3: fig.update_layout(title="Select players to compare.") return fig labels = [label_col(m) for m in metrics] for player in players: row = get_player_row(player) if row is not None: fig.add_trace(go.Scatterpolar( r=[row[m] if pd.notna(row[m]) else 0 for m in metrics], theta=labels, fill="toself", name=str(player) )) fig.update_layout( title="Side-by-Side Radar Comparison", polar=dict(radialaxis=dict(visible=True, range=[0, 100])), showlegend=True ) return fig # ============================================================ # FIT SCORE CALCULATOR # ============================================================ def fit_score(shot_stopping_w, sweeping_w, short_passing_w, long_passing_w, ball_claiming_w, overall_value_w): weights = { "gk_cat_shot_stopping": shot_stopping_w, "gk_cat_sweeping": sweeping_w, "gk_cat_short_passing": short_passing_w, "gk_cat_long_passing": long_passing_w, "gk_cat_ball_claiming": ball_claiming_w, "gk_cat_overall_value": overall_value_w, } data = df.copy() total_weight = sum(weights.values()) if total_weight == 0: return pd.DataFrame({"Message": ["At least one weight must be above 0."]}) score = 0 for col, weight in weights.items(): if col in data.columns: values = pd.to_numeric(data[col], errors="coerce") min_v, max_v = values.min(), values.max() if pd.notna(min_v) and pd.notna(max_v) and max_v != min_v: normalized = ((values - min_v) / (max_v - min_v)) * 100 else: normalized = values score += normalized.fillna(0) * weight data["custom_fit_score"] = score / total_weight cols = available_cols([ PLAYER_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL, ]) + ["custom_fit_score"] out = data[cols].sort_values("custom_fit_score", ascending=False).head(25).copy() numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) return out.reset_index(drop=True) # ============================================================ # SIMILAR PLAYER FINDER # ============================================================ def similar_players(player): row = get_player_row(player) if row is None: return pd.DataFrame({"Message": ["Select a player."]}) metrics = available_cols(CATEGORY_METRICS + KEY_METRICS) candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy() for metric in metrics: candidates[metric] = pd.to_numeric(candidates[metric], errors="coerce") sd = pd.to_numeric(df[metric], errors="coerce").std() player_val = row[metric] if pd.notna(row[metric]) else 0 if pd.isna(sd) or sd == 0: candidates[f"dist_{metric}"] = 0 else: candidates[f"dist_{metric}"] = ((candidates[metric] - player_val) / sd) ** 2 dist_cols = [f"dist_{m}" for m in metrics] candidates["similarity_distance"] = candidates[dist_cols].sum(axis=1) candidates["similarity_score"] = 100 / (1 + candidates["similarity_distance"]) cols = available_cols([ PLAYER_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL, ]) + ["similarity_score"] out = candidates[cols].sort_values("similarity_score", ascending=False).head(5).copy() numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) return out.reset_index(drop=True) # ============================================================ # SHORTLIST # ============================================================ def add_to_shortlist(player): global shortlist if player and player not in shortlist: shortlist.append(player) return view_shortlist() def clear_shortlist(): global shortlist shortlist = [] return view_shortlist() def view_shortlist(): if not shortlist: return pd.DataFrame({"Message": ["No players added to shortlist yet."]}) data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy() cols = available_cols([ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL, AGE_COL, MINUTES_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL, ] + KEY_METRICS) out = data[cols].copy() numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) return out.reset_index(drop=True) def export_shortlist_csv(): if not shortlist: return None data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy() out_file = "shortlist_export.csv" data.to_csv(out_file, index=False) return out_file # ============================================================ # SCOUTING REPORT EXPORT # ============================================================ def export_player_report(player, notes): row = get_player_row(player) if row is None: return None report = [] report.append(f"GK Scouting Report: {row[PLAYER_COL]}") report.append("=" * 60) report.append("") report.append(f"Club: {row.get(TEAM_COL, 'N/A')}") report.append(f"Competition: {row.get(COMP_COL, 'N/A')}") report.append(f"Position: {row.get(POSITION_COL, 'N/A')}") report.append(f"Age: {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}") report.append(f"Height: {row.get(HEIGHT_COL, 'N/A')} cm") report.append(f"Minutes: {round(row.get(MINUTES_COL, 0), 0)}") report.append("") report.append("GK Scoring") report.append("-" * 60) report.append(f"Best Archetype: {row.get(ARCHETYPE_COL, 'N/A')}") report.append(f"Best Archetype Score: {round(row.get(ARCHETYPE_SCORE_COL, np.nan), 2) if pd.notna(row.get(ARCHETYPE_SCORE_COL, np.nan)) else 'N/A'}") report.append(f"Overall GK Score: {round(row.get(GK_SCORE_COL, np.nan), 2) if pd.notna(row.get(GK_SCORE_COL, np.nan)) else 'N/A'}") report.append("") report.append("Archetype Scores") report.append("-" * 60) for col in ["shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score"]: if col in df.columns: val = row.get(col, np.nan) if pd.notna(val): report.append(f"{label_col(col.replace('_score', ''))}: {round(val, 2)}") report.append("") report.append("Key Season Metrics") report.append("-" * 60) for col in available_cols(KEY_METRICS): value = row.get(col, np.nan) if pd.notna(value): report.append(f"{label_col(col)}: {round(value, 2)}") report.append("") report.append("Category Scores") report.append("-" * 60) for col in available_cols(CATEGORY_METRICS): value = row.get(col, np.nan) if pd.notna(value): report.append(f"{label_col(col)}: {round(value, 2)}") report.append("") report.append("Scout Notes") report.append("-" * 60) report.append(notes if notes else "No notes entered.") safe_name = str(row[PLAYER_COL]).replace(" ", "_").replace("/", "_") out_file = f"{safe_name}_gk_scouting_report.txt" with open(out_file, "w", encoding="utf-8") as f: f.write("\n".join(report)) return out_file # ============================================================ # APP LAYOUT # ============================================================ with gr.Blocks(title="Oldham Athletic GK Scouting") as app: gr.Markdown( """ # Oldham Athletic GK Scouting Interactive goalkeeper scouting dashboard powered by StatsBomb data. """ ) with gr.Tab("Player Search"): gr.Markdown("## Search and Filter Goalkeepers") with gr.Row(): search_box = gr.Textbox(label="Search Player Name") competition_filter = gr.Dropdown( choices=competition_options, value=[], label="Competition", multiselect=True ) team_filter = gr.Dropdown( choices=team_options, value=[], label="Team", multiselect=True ) with gr.Row(): position_filter = gr.Dropdown( choices=position_options, value=[], label="Position", multiselect=True ) min_age_filter = gr.Slider(minimum=age_min, maximum=age_max, value=age_min, step=1, label="Minimum Age") max_age_filter = gr.Slider(minimum=age_min, maximum=age_max, value=age_max, step=1, label="Maximum Age") minutes_filter = gr.Slider( minimum=0, maximum=int(df[MINUTES_COL].max()) if MINUTES_COL in df.columns else 3000, value=0, step=100, label="Minimum Minutes" ) search_button = gr.Button("Search Players") search_results = gr.Dataframe(label="Goalkeeper Results (sorted by GK Score)", interactive=False) search_button.click( fn=search_players, inputs=[search_box, competition_filter, team_filter, position_filter, min_age_filter, max_age_filter, minutes_filter], outputs=search_results ) with gr.Tab("Player Profile"): gr.Markdown("## Full GK Profile") selected_player = gr.Dropdown(player_options, label="Select Player") with gr.Row(): profile_output = gr.Markdown() category_output = gr.Dataframe(label="GK Category Scores", interactive=False) with gr.Row(): radar_output = gr.Plot(label="Radar Chart") percentile_output = gr.Plot(label="Percentile Bars") with gr.Row(): profile_metric = gr.Dropdown( metric_options, value=metric_options[0] if metric_options else None, label="Performance Metric" ) trend_button = gr.Button("Show Performance Chart") trend_output = gr.Plot(label="Performance Over Time") scout_notes = gr.Textbox(label="Scout Notes", lines=5, placeholder="Enter notes to include in the scouting report.") report_button = gr.Button("Generate Scouting Report") report_file = gr.File(label="Download Scouting Report") shortlist_button = gr.Button("Add Player to Shortlist") shortlist_from_profile = gr.Dataframe(label="Current Shortlist", interactive=False) selected_player.change(player_profile, selected_player, profile_output) selected_player.change(category_table, selected_player, category_output) selected_player.change(radar_chart, selected_player, radar_output) selected_player.change(percentile_chart, selected_player, percentile_output) trend_button.click(performance_chart, [selected_player, profile_metric], trend_output) report_button.click(export_player_report,[selected_player, scout_notes], report_file) shortlist_button.click(add_to_shortlist, selected_player, shortlist_from_profile) with gr.Tab("Player Comparison Tool"): gr.Markdown("## Compare Up To Three Goalkeepers") with gr.Row(): compare_1 = gr.Dropdown(player_options, label="Player 1") compare_2 = gr.Dropdown(player_options, label="Player 2") compare_3 = gr.Dropdown(player_options, label="Player 3") compare_button = gr.Button("Compare Players") comparison_table = gr.Dataframe(label="Stat Comparison Table", interactive=False) comparison_radar_plot = gr.Plot(label="Side-by-Side Radar Chart") compare_button.click(compare_players, [compare_1, compare_2, compare_3], comparison_table) compare_button.click(comparison_radar, [compare_1, compare_2, compare_3], comparison_radar_plot) with gr.Tab("Fit Score Calculator"): gr.Markdown( """ ## Custom GK Fit Score Calculator Weight the GK attributes that matter most to Oldham. The app will rank all goalkeepers based on your custom profile. """ ) with gr.Row(): shot_stopping_w = gr.Slider(0, 10, value=5, step=1, label="Shot Stopping") sweeping_w = gr.Slider(0, 10, value=5, step=1, label="Sweeping") short_passing_w = gr.Slider(0, 10, value=5, step=1, label="Short Passing") with gr.Row(): long_passing_w = gr.Slider(0, 10, value=5, step=1, label="Long Passing") ball_claiming_w = gr.Slider(0, 10, value=5, step=1, label="Ball Claiming") overall_value_w = gr.Slider(0, 10, value=5, step=1, label="Overall Value") fit_button = gr.Button("Generate Ranked Recommendations") fit_table = gr.Dataframe(label="Ranked Recommendations", interactive=False) fit_button.click( fit_score, [shot_stopping_w, sweeping_w, short_passing_w, long_passing_w, ball_claiming_w, overall_value_w], fit_table ) with gr.Tab("Similar Player Finder"): gr.Markdown("## Find Similar Goalkeepers") similar_player_select = gr.Dropdown(player_options, label="Select Player") similar_button = gr.Button("Find Similar Players") similar_table = gr.Dataframe(label="Five Most Similar Goalkeepers", interactive=False) similar_button.click(similar_players, similar_player_select, similar_table) with gr.Tab("Shortlist Manager"): gr.Markdown("## Shortlist Manager") shortlist_player = gr.Dropdown(player_options, label="Add Player") add_shortlist_button = gr.Button("Add to Shortlist") clear_shortlist_button = gr.Button("Clear Shortlist") export_shortlist_button = gr.Button("Export Shortlist CSV") shortlist_table = gr.Dataframe(label="Saved Players", interactive=False) shortlist_file = gr.File(label="Download Shortlist CSV") add_shortlist_button.click(add_to_shortlist, shortlist_player, shortlist_table) clear_shortlist_button.click(clear_shortlist, None, shortlist_table) export_shortlist_button.click(export_shortlist_csv, None, shortlist_file) app.launch()