# ============================================================ # Oldham Athletic Player Scouting App # # Hugging Face files needed: # app.py # OA_sheet_for_app.csv # all_players_enriched_multiseason.csv # requirements.txt # # requirements.txt should contain: # gradio==5.49.1 # pandas # numpy # plotly # fpdf # matplotlib # ============================================================ import os import re import tempfile import warnings import gradio as gr import numpy as np import pandas as pd import plotly.graph_objects as go import plotly.express as px from fpdf import FPDF import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt warnings.filterwarnings("ignore") CUSTOM_CSS = """ .gradio-container { max-width: 98% !important; margin-left: auto !important; margin-right: auto !important; } .dataframe-container { width: 100% !important; overflow-x: auto !important; } table { width: 100% !important; } th, td { white-space: nowrap !important; overflow: visible !important; text-overflow: clip !important; max-width: none !important; } .wrap .dataframe-container, .gradio-dataframe { overflow-x: auto !important; } .profile-card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 18px; background: #ffffff; } """ # ============================================================ # LOAD DATA # ============================================================ MAIN_DATA_FILE = "OA_sheet_for_app.csv" MULTISEASON_DATA_FILE = "all_players_enriched_multiseason.csv" def clean_columns(data): data = data.copy() data.columns = ( data.columns .str.strip() .str.lower() .str.replace(" ", "_") .str.replace("-", "_") .str.replace("/", "_") .str.replace(".", "_", regex=False) ) return data def clean_player_key(x): if pd.isna(x): return "" return ( str(x) .strip() .lower() .replace(".", "") .replace(",", "") .replace("-", " ") .replace(" ", " ") ) df = pd.read_csv(MAIN_DATA_FILE, encoding="latin1", low_memory=False) df = clean_columns(df) if os.path.exists(MULTISEASON_DATA_FILE): multi_df = pd.read_csv(MULTISEASON_DATA_FILE, encoding="latin1", low_memory=False) multi_df = clean_columns(multi_df) else: multi_df = pd.DataFrame() if "player_name" in df.columns: df["_player_key"] = df["player_name"].apply(clean_player_key) multi_player_col = None if not multi_df.empty: for possible_col in ["player_name", "player", "name"]: if possible_col in multi_df.columns: multi_player_col = possible_col break if multi_player_col is not None: multi_df["_player_key"] = multi_df[multi_player_col].apply(clean_player_key) else: multi_df["_player_key"] = "" historical_suffixes_for_merge = ["_2122", "_2223", "_2324", "_2425"] historical_cols_for_merge = [ col for col in multi_df.columns if any(col.endswith(suffix) for suffix in historical_suffixes_for_merge) ] if historical_cols_for_merge and "_player_key" in multi_df.columns: multi_keep = multi_df[["_player_key"] + historical_cols_for_merge].drop_duplicates(subset=["_player_key"]) overlapping_hist_cols = [c for c in historical_cols_for_merge if c in df.columns] if overlapping_hist_cols: df = df.drop(columns=overlapping_hist_cols) df = df.merge( multi_keep, on="_player_key", how="left" ) # ============================================================ # COLUMN SETUP # ============================================================ PLAYER_COL = "player_name" TEAM_COL = "team_name" COMP_COL = "competition_name" SEASON_COL = "season_name" POSITION_COL = "primary_position" SECONDARY_POSITION_COL = "secondary_position" COUNTRY_COL = "country_id" AGE_COL = "age" HEIGHT_COL = "player_height" WEIGHT_COL = "player_weight" MINUTES_COL = "player_season_minutes" MARKET_VALUE_COL = "market_value_eur" CONTRACT_COL = "seasons_left_num" ATTAINABILITY_COL = "attainability" TARGET_SCORE_COL = "target_score" ARCHETYPE_COL = "best_position_archetype_name" ARCHETYPE_SCORE_COL = "best_position_archetype_score" CLUB_RANK_COL = "club_rank" MATCH_TOUGHNESS_COL = "match_toughness" WEIGHTED_MATCH_TOUGHNESS_COL = "wmatch_toughness" ELO_COL = "elo" COMPETITION_RANK_COL = "competition_rank" ATTR_COLS = [ "attr_shot_stopping", "attr_sweeping", "attr_ball_claiming", "attr_short_passing", "attr_long_passing", "attr_pressing", "attr_duels", "attr_aerial", "attr_possession_retention", "attr_blocking", "attr_progression", "attr_set_pieces", "attr_impact", "attr_discipline", "attr_dribbling", "attr_chance_creation", "attr_finishing", "attr_crossing", "attr_box_presence", "attr_holdup", ] CAT_COLS = [ "cat_defensive_ability", "cat_aerial_ability", "cat_finishing", "cat_chance_creation", "cat_dribbling", "cat_ball_progression", "cat_passing", ] POSITION_SCORE_COLS = [ "cb_score", "fb_score", "cmd_score", "cma_score", "wm_score", "cf_score", "st_score", "gk_score", ] ARCHETYPE_SCORE_COLS = [ "score_defensive_cb", "score_pressing_cb", "score_ballplaying_cb", "score_defensive_fb", "score_attacking_fb", "score_possession_fb", "score_poacher", "score_target_man", "score_false_nine", "score_complete_forward", "score_inside_forward", "score_traditional_winger", "score_playmaking_winger", "score_pressing_winger", "score_complete_winger", "score_defensive_midfielder", "score_deep_lying_playmaker", "score_box_to_box_midfielder", "score_advanced_playmaker", "score_wide_midfielder", "score_attacking_runner", "score_shot_stopper_gk", "score_sweeper_keeper_gk", "score_ball_playing_gk", ] KEY_METRICS = [ "player_season_minutes", "player_season_goals_90", "player_season_assists_90", "player_season_np_xg_90", "player_season_xa_90", "player_season_key_passes_90", "player_season_passing_ratio", "player_season_tackles_90", "player_season_interceptions_90", "player_season_tackles_and_interceptions_90", "player_season_aerial_wins_90", "player_season_aerial_ratio", "player_season_dribbles_90", "player_season_crosses_90", "player_season_long_balls_90", "player_season_xgchain_90", "player_season_xgbuildup_90", "player_season_obv_90", ] SEARCH_TABLE_COLS = [ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, COUNTRY_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, ] + POSITION_SCORE_COLS + ATTR_COLS COMPARISON_COLS = [ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, ] + POSITION_SCORE_COLS + ATTR_COLS SHORTLIST_COLS = [ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, ] + KEY_METRICS + ATTR_COLS + POSITION_SCORE_COLS + ARCHETYPE_SCORE_COLS RADAR_METRICS = ATTR_COLS PERCENTILE_METRICS = ATTR_COLS + [ TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL, ] SIMILARITY_METRICS = ATTR_COLS + [ TARGET_SCORE_COL, ATTAINABILITY_COL, ARCHETYPE_SCORE_COL, ] # ============================================================ # HELPERS # ============================================================ def available_cols(cols): seen = set() out = [] for c in cols: if c in df.columns and c not in seen: out.append(c) seen.add(c) return out def pretty_label(col): custom = { "player_name": "Player", "team_name": "Club", "competition_name": "Competition", "season_name": "Season", "primary_position": "Primary Position", "secondary_position": "Secondary Position", "country_id": "Country", "player_height": "Height", "player_weight": "Weight", "player_season_minutes": "Minutes", "market_value_eur": "Market Value", "seasons_left_num": "Seasons Left", "attainability": "Attainability", "target_score": "Target Score", "best_position_archetype_name": "Best Archetype", "best_position_archetype_score": "Best Archetype Score", "cb_score": "CB Score", "fb_score": "FB Score", "cmd_score": "CMD Score", "cma_score": "CMA Score", "wm_score": "WM Score", "cf_score": "CF Score", "st_score": "ST Score", "gk_score": "GK Score", "club_rank": "Club Rank", "match_toughness": "Match Toughness", "elo": "Club ELO", "competition_rank": "Competition Rank", "fit_score": "Fit Score", } if col in custom: return custom[col] label = col label = label.replace("player_season_", "") label = label.replace("attr_", "") label = label.replace("cat_", "") label = label.replace("score_", "") label = label.replace("_90", " Per 90") label = label.replace("_", " ") label = label.title() label = label.replace("Np Xg", "NP xG") label = label.replace("Xa", "xA") label = label.replace("Xgchain", "xGChain") label = label.replace("Xgbuildup", "xGBuildup") label = label.replace("Obv", "OBV") label = label.replace("Gk", "GK") label = label.replace("Cb", "CB") label = label.replace("Fb", "FB") label = label.replace("Cmd", "CMD") label = label.replace("Cma", "CMA") label = label.replace("Wm", "WM") label = label.replace("Cf", "CF") label = label.replace("St", "ST") return label def format_money(x): try: if pd.isna(x) or str(x).strip() in ["", "-", "nan"]: return "Not listed" x = float(x) if x >= 1_000_000: return f"€{x / 1_000_000:.1f}M" if x >= 1_000: return f"€{x / 1_000:.0f}K" return f"€{x:.0f}" except Exception: return "Not listed" def clean_value(x): if pd.isna(x): return "N/A" if isinstance(x, (float, np.floating)): return round(float(x), 2) if isinstance(x, (int, np.integer)): return int(x) return x def pretty_df(data): out = data.copy() if MARKET_VALUE_COL in out.columns: out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money) numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) out = out.rename(columns={c: pretty_label(c) for c in out.columns}) return out def safe_pdf_text(x): text = str(x) text = text.replace("€", "EUR ") text = text.replace("–", "-") text = text.replace("—", "-") text = text.replace("’", "'") text = text.replace("“", '"') text = text.replace("”", '"') return text.encode("latin1", "replace").decode("latin1") def get_player_row(player): if not player or PLAYER_COL not in df.columns: return None rows = df[df[PLAYER_COL].astype(str) == str(player)] if rows.empty: return None return rows.iloc[0] def get_player_group(row): comp = row.get(COMP_COL, None) pos = row.get(POSITION_COL, None) group = df.copy() if COMP_COL in df.columns and POSITION_COL in df.columns and pd.notna(comp) and pd.notna(pos): group = group[(group[COMP_COL] == comp) & (group[POSITION_COL] == pos)] if group.empty: group = df.copy() return group def normalize_0_100(series): values = pd.to_numeric(series, errors="coerce") min_v = values.min() max_v = values.max() if pd.isna(min_v) or pd.isna(max_v) or max_v == min_v: return pd.Series(np.zeros(len(values)), index=series.index) return ((values - min_v) / (max_v - min_v)) * 100 def top_available_attr_cols(row=None, max_cols=8): cols = [] for col in available_cols(RADAR_METRICS): if row is None: cols.append(col) else: if pd.notna(row.get(col, np.nan)): cols.append(col) return cols[:max_cols] def selected_player_from_table(table, evt: gr.SelectData): try: if table is None: return gr.update() if isinstance(table, pd.DataFrame): table_df = table.copy() else: table_df = pd.DataFrame(table) if table_df.empty: return gr.update() row_index = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index if "Player" not in table_df.columns: return gr.update() player = table_df.iloc[row_index]["Player"] if pd.isna(player): return gr.update() return gr.update(value=str(player)) except Exception: return gr.update() def selected_player_status(player): if player: return f"Loaded **{player}** into the Player Profile tab." return "Click a player row to load them into the Player Profile tab." # ============================================================ # PERFORMANCE OVER TIME SETUP # ============================================================ HISTORICAL_SEASONS = { "2122": "2021-22", "2223": "2022-23", "2324": "2023-24", "2425": "2024-25", } CURRENT_MAIN_SEASON_LABEL = "2025-26" PERFORMANCE_TIME_METRICS = POSITION_SCORE_COLS def strip_player_season(metric): return metric.replace("player_season_", "") def historical_candidate_columns(base_metric, season_code): short_metric = strip_player_season(base_metric) candidates = [ f"{base_metric}_{season_code}", f"{short_metric}_{season_code}", ] if short_metric.endswith("_90"): no_90 = short_metric.replace("_90", "") candidates += [ f"{no_90}_90_{season_code}", f"{no_90}_per_90_{season_code}", f"{no_90}_p90_{season_code}", ] replacements = { "crosses": "cross", "goals": "goal", "assists": "assist", "dribbles": "dribble", "tackles": "tackle", "interceptions": "interception", "aerial_wins": "aerial_win", "key_passes": "key_pass", "long_balls": "long_ball", } for plural, singular in replacements.items(): if plural in short_metric: candidates.append(f"{short_metric.replace(plural, singular)}_{season_code}") if short_metric.endswith("_90"): candidates.append( f"{short_metric.replace(plural, singular).replace('_90', '')}_per_90_{season_code}" ) if base_metric in POSITION_SCORE_COLS: position_code = base_metric.replace("_score", "") candidates += [ f"{position_code}_score_{season_code}", f"{position_code}_{season_code}", f"{position_code.upper()}_score_{season_code}".lower(), ] clean_candidates = [] seen = set() for col in candidates: col = col.lower() if col not in seen: clean_candidates.append(col) seen.add(col) return clean_candidates def find_metric_value(row, base_metric, season_code=None): if row is None: return np.nan if season_code is None: if base_metric in row.index: return row.get(base_metric, np.nan) return np.nan for col in historical_candidate_columns(base_metric, season_code): if col in row.index: value = row.get(col, np.nan) if pd.notna(value): return value return np.nan def get_multiseason_row_for_player(player): if multi_df is None or multi_df.empty: return None player_key = clean_player_key(player) if "_player_key" not in multi_df.columns: return None matches = multi_df[multi_df["_player_key"] == player_key] if matches.empty: return None return matches.iloc[0] def build_performance_metric_options(): options = [] for base_metric in PERFORMANCE_TIME_METRICS: current_exists = base_metric in df.columns historical_exists = False for season_code in HISTORICAL_SEASONS.keys(): for candidate in historical_candidate_columns(base_metric, season_code): if candidate in df.columns: historical_exists = True break if not multi_df.empty and candidate in multi_df.columns: historical_exists = True break if historical_exists: break if current_exists or historical_exists: options.append((pretty_label(base_metric), base_metric)) return options # ============================================================ # NUMERIC CLEANING # ============================================================ numeric_cols = available_cols( KEY_METRICS + ATTR_COLS + CAT_COLS + POSITION_SCORE_COLS + ARCHETYPE_SCORE_COLS + [ AGE_COL, HEIGHT_COL, WEIGHT_COL, MINUTES_COL, MARKET_VALUE_COL, ATTAINABILITY_COL, TARGET_SCORE_COL, ARCHETYPE_SCORE_COL, CLUB_RANK_COL, MATCH_TOUGHNESS_COL, WEIGHTED_MATCH_TOUGHNESS_COL, ELO_COL, COMPETITION_RANK_COL, ] ) historical_numeric_cols = [ col for col in df.columns if any(col.endswith(f"_{season}") for season in HISTORICAL_SEASONS.keys()) ] for col in numeric_cols + historical_numeric_cols: if col in df.columns: df[col] = pd.to_numeric(df[col], errors="coerce") if not multi_df.empty: multi_historical_numeric_cols = [ col for col in multi_df.columns if any(col.endswith(f"_{season}") for season in HISTORICAL_SEASONS.keys()) ] for col in multi_historical_numeric_cols: multi_df[col] = pd.to_numeric(multi_df[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()) if COMP_COL in df.columns else [] team_options = sorted(df[TEAM_COL].dropna().astype(str).unique().tolist()) if TEAM_COL in df.columns else [] position_options = sorted(df[POSITION_COL].dropna().astype(str).unique().tolist()) if POSITION_COL in df.columns else [] country_options = sorted(df[COUNTRY_COL].dropna().astype(str).unique().tolist()) if COUNTRY_COL in df.columns else [] age_min = int(np.floor(df[AGE_COL].min())) if AGE_COL in df.columns and df[AGE_COL].notna().any() else 15 age_max = int(np.ceil(df[AGE_COL].max())) if AGE_COL in df.columns and df[AGE_COL].notna().any() else 45 minutes_max = int(df[MINUTES_COL].max()) if MINUTES_COL in df.columns and df[MINUTES_COL].notna().any() else 5000 performance_metric_options = build_performance_metric_options() shortlist = [] def make_player_dropdown_choices(): choices = [] base_cols = available_cols([PLAYER_COL, POSITION_COL, TEAM_COL]) for _, row in df[base_cols].drop_duplicates().iterrows(): player = str(row.get(PLAYER_COL, "")) position = str(row.get(POSITION_COL, "")) team = str(row.get(TEAM_COL, "")) label = f"{player} | {position} | {team}" choices.append((label, player)) choices = sorted(choices, key=lambda x: x[0]) return choices player_dropdown_choices = make_player_dropdown_choices() # ============================================================ # SEARCH # ============================================================ def search_players(search, competitions, teams, positions, countries, min_age, max_age, min_minutes): data = df.copy() if min_age > max_age: min_age, max_age = max_age, min_age if search and PLAYER_COL in data.columns: cleaned_search = str(search).strip() data = data[ data[PLAYER_COL] .astype(str) .str.contains(cleaned_search, case=False, na=False, regex=False) ] if competitions and COMP_COL in data.columns: competitions = [str(x) for x in competitions] data = data[data[COMP_COL].astype(str).isin(competitions)] if teams and TEAM_COL in data.columns: teams = [str(x) for x in teams] data = data[data[TEAM_COL].astype(str).isin(teams)] if positions and POSITION_COL in data.columns: positions = [str(x) for x in positions] data = data[data[POSITION_COL].astype(str).isin(positions)] if countries and COUNTRY_COL in data.columns: countries = [str(x) for x in countries] data = data[data[COUNTRY_COL].astype(str).isin(countries)] if AGE_COL in data.columns: data = data[ (data[AGE_COL].fillna(-999) >= min_age) & (data[AGE_COL].fillna(999) <= max_age) ] if MINUTES_COL in data.columns: data = data[data[MINUTES_COL].fillna(0) >= min_minutes] table_cols = available_cols(SEARCH_TABLE_COLS) out = data[table_cols].copy() if out.empty: empty = pd.DataFrame({"Message": ["No players found. Try clearing one filter or lowering minimum minutes."]}) return empty, empty sort_col = TARGET_SCORE_COL if TARGET_SCORE_COL in out.columns else ATTAINABILITY_COL if sort_col in out.columns: out = out.sort_values(sort_col, ascending=False, na_position="last") out = pretty_df(out).reset_index(drop=True) return out, out # ============================================================ # PLAYER PROFILE # ============================================================ 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.get(PLAYER_COL, 'Unknown Player')}") lines.append(f"### {row.get(TEAM_COL, 'N/A')} | {row.get(COMP_COL, 'N/A')}") lines.append("") lines.append("## Player Details") lines.append(f"- **Primary Position:** {clean_value(row.get(POSITION_COL, np.nan))}") lines.append(f"- **Secondary Position:** {clean_value(row.get(SECONDARY_POSITION_COL, np.nan))}") lines.append(f"- **Age:** {clean_value(row.get(AGE_COL, np.nan))}") lines.append(f"- **Country:** {clean_value(row.get(COUNTRY_COL, np.nan))}") lines.append(f"- **Height:** {clean_value(row.get(HEIGHT_COL, np.nan))} cm") lines.append(f"- **Weight:** {clean_value(row.get(WEIGHT_COL, np.nan))} kg") lines.append(f"- **Market Value:** {format_money(row.get(MARKET_VALUE_COL, np.nan))}") lines.append(f"- **Contract:** {clean_value(row.get(CONTRACT_COL, np.nan))}") lines.append(f"- **Minutes:** {clean_value(row.get(MINUTES_COL, np.nan))}") return "\n".join(lines) def key_performance_summary(player): row = get_player_row(player) if row is None: return pd.DataFrame({"Metric": ["Select a player"], "Value": [""]}) rows = [ {"Metric": "Best Archetype", "Value": clean_value(row.get(ARCHETYPE_COL, np.nan))}, {"Metric": "Best Archetype Score", "Value": clean_value(row.get(ARCHETYPE_SCORE_COL, np.nan))}, {"Metric": "Target Score", "Value": clean_value(row.get(TARGET_SCORE_COL, np.nan))}, {"Metric": "Attainability", "Value": clean_value(row.get(ATTAINABILITY_COL, np.nan))}, {"Metric": "Club Rank", "Value": clean_value(row.get(CLUB_RANK_COL, np.nan))}, {"Metric": "Match Toughness", "Value": clean_value(row.get(MATCH_TOUGHNESS_COL, np.nan))}, {"Metric": "Club ELO", "Value": clean_value(row.get(ELO_COL, np.nan))}, ] return pd.DataFrame(rows) def profile_metric_dropdown_table(player, metric_group): row = get_player_row(player) if row is None: return pd.DataFrame({"Metric": ["Select a player"], "Score": [""]}) if metric_group == "Attributes": cols = ATTR_COLS elif metric_group == "Position Scores": cols = POSITION_SCORE_COLS elif metric_group == "Archetype Scores": cols = ARCHETYPE_SCORE_COLS elif metric_group == "Key Season Stats": cols = KEY_METRICS else: cols = ATTR_COLS rows = [] for col in available_cols(cols): value = row.get(col, np.nan) if pd.notna(value): rows.append({ "Metric": pretty_label(col), "Score": round(float(value), 2) if isinstance(value, (int, float, np.integer, np.floating)) else value }) if not rows: return pd.DataFrame({"Metric": ["No metrics available"], "Score": [""]}) out = pd.DataFrame(rows) if "Score" in out.columns: out = out.sort_values("Score", ascending=False, na_position="last") return out.reset_index(drop=True) # ============================================================ # CHARTS # ============================================================ def radar_chart(player): row = get_player_row(player) if row is None: return go.Figure() metrics = top_available_attr_cols(row, max_cols=8) if len(metrics) < 3: fig = go.Figure() fig.update_layout( title="Not enough attribute metrics available for radar chart.", height=620, margin=dict(l=160, r=160, t=110, b=120), ) return fig group = get_player_group(row) labels = [pretty_label(m) for m in metrics] player_values = [row.get(m, 0) if pd.notna(row.get(m, np.nan)) else 0 for m in metrics] avg_values = [group[m].mean() if m in group.columns else 0 for m in metrics] max_radar_value = max(100, np.nanmax(player_values + avg_values) * 1.1) 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="Position/Competition Avg", )) fig.update_layout( title=dict( text=f"{player} Attribute Radar", x=0.5, xanchor="center", ), polar=dict( domain=dict(x=[0.18, 0.82], y=[0.14, 0.86]), radialaxis=dict( visible=True, range=[0, max_radar_value], ), ), height=720, margin=dict(l=170, r=170, t=120, b=120), showlegend=True, legend=dict( orientation="h", y=-0.08, x=0.5, xanchor="center" ), ) return fig def percentile_chart(player): row = get_player_row(player) if row is None: return go.Figure() group = get_player_group(row) 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: percentile = (values < value).mean() * 100 rows.append({ "Metric": pretty_label(metric), "Percentile": round(percentile, 1), "Percent Label": f"{round(percentile, 1)}%", "Value": round(float(value), 2), }) plot_df = pd.DataFrame(rows) if plot_df.empty: fig = go.Figure() fig.update_layout( title="No percentile data available.", height=550, margin=dict(l=140, r=100, t=90, b=80), ) return fig plot_df = plot_df.sort_values("Percentile") fig = px.bar( plot_df, x="Percentile", y="Metric", orientation="h", text="Percent Label", hover_data=["Value"], range_x=[0, 100], title=f"{player} Percentiles vs Same Position and Competition", ) fig.update_traces(textposition="outside", cliponaxis=False) fig.update_layout( height=max(550, 32 * len(plot_df)), margin=dict(l=190, r=100, t=90, b=80), xaxis_title="Percentile", yaxis_title="", title=dict(x=0.5, xanchor="center"), ) return fig def performance_chart(player, metric): main_row = get_player_row(player) multi_row = get_multiseason_row_for_player(player) if main_row is None or not metric: fig = go.Figure() fig.update_layout( title="Select a player and metric.", height=500, margin=dict(l=80, r=80, t=90, b=80), ) return fig label_to_metric = {pretty_label(m): m for m in PERFORMANCE_TIME_METRICS} if metric not in PERFORMANCE_TIME_METRICS and metric in label_to_metric: metric = label_to_metric[metric] rows = [] for season_code, season_label in HISTORICAL_SEASONS.items(): value = np.nan if multi_row is not None: value = find_metric_value(multi_row, metric, season_code) if pd.isna(value): value = find_metric_value(main_row, metric, season_code) if pd.notna(value): rows.append({ "Season": season_label, "Score": float(value), }) current_value = find_metric_value(main_row, metric, season_code=None) if pd.notna(current_value): rows = [r for r in rows if r["Season"] != CURRENT_MAIN_SEASON_LABEL] rows.append({ "Season": CURRENT_MAIN_SEASON_LABEL, "Score": float(current_value), }) plot_df = pd.DataFrame(rows) if plot_df.empty: fig = go.Figure() fig.update_layout( title=f"No performance data found for {player}: {pretty_label(metric)}.", height=500, margin=dict(l=80, r=80, t=90, b=80), ) return fig season_order = ["2021-22", "2022-23", "2023-24", "2024-25", "2025-26"] plot_df["Season"] = pd.Categorical( plot_df["Season"], categories=season_order, ordered=True ) plot_df = plot_df.sort_values("Season") fig = px.line( plot_df, x="Season", y="Score", markers=True, title=f"{player}: {pretty_label(metric)} Over Time", ) fig.update_traces( mode="lines+markers+text", text=plot_df["Score"].round(2), textposition="top center", ) y_min = plot_df["Score"].min() y_max = plot_df["Score"].max() if y_min == y_max: y_buffer = max(abs(y_max) * 0.25, 1) else: y_buffer = (y_max - y_min) * 0.20 fig.update_layout( height=500, margin=dict(l=80, r=80, t=90, b=80), title=dict(x=0.5, xanchor="center"), yaxis_title=pretty_label(metric), xaxis_title="Season", yaxis=dict(range=[y_min - y_buffer, y_max + y_buffer]), ) return fig # ============================================================ # PDF-SAFE CHARTS # ============================================================ def make_pdf_radar_png(player, filename): row = get_player_row(player) if row is None: return None metrics = top_available_attr_cols(row, max_cols=8) if len(metrics) < 3: return None group = get_player_group(row) labels = [pretty_label(m) for m in metrics] player_values = [ float(row.get(m, 0)) if pd.notna(row.get(m, np.nan)) else 0 for m in metrics ] avg_values = [ float(group[m].mean()) if m in group.columns and pd.notna(group[m].mean()) else 0 for m in metrics ] angles = np.linspace(0, 2 * np.pi, len(metrics), endpoint=False).tolist() player_values += player_values[:1] avg_values += avg_values[:1] angles += angles[:1] labels += labels[:1] fig = plt.figure(figsize=(8, 8)) ax = plt.subplot(111, polar=True) ax.plot(angles, player_values, linewidth=2, label=str(player)) ax.fill(angles, player_values, alpha=0.20) ax.plot(angles, avg_values, linewidth=2, linestyle="--", label="Position/Competition Avg") ax.fill(angles, avg_values, alpha=0.10) ax.set_xticks(angles[:-1]) ax.set_xticklabels(labels[:-1], fontsize=9) ax.set_ylim(0, max(100, max(player_values + avg_values) * 1.1)) ax.set_title(f"{player} Attribute Radar", pad=25, fontsize=14, fontweight="bold") ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.08), ncol=2) plt.tight_layout() plt.savefig(filename, dpi=200, bbox_inches="tight") plt.close(fig) return filename def make_pdf_percentile_png(player, filename): row = get_player_row(player) if row is None: return None group = get_player_group(row) 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: percentile = (values < value).mean() * 100 rows.append({ "Metric": pretty_label(metric), "Percentile": round(percentile, 1), }) plot_df = pd.DataFrame(rows) if plot_df.empty: return None plot_df = plot_df.sort_values("Percentile").tail(14) fig, ax = plt.subplots(figsize=(9, 7)) ax.barh(plot_df["Metric"], plot_df["Percentile"]) for i, value in enumerate(plot_df["Percentile"]): ax.text(value + 1, i, f"{value:.1f}%", va="center", fontsize=9) ax.set_xlim(0, 105) ax.set_xlabel("Percentile") ax.set_title(f"{player} Percentiles", fontsize=14, fontweight="bold") ax.grid(axis="x", alpha=0.25) plt.tight_layout() plt.savefig(filename, dpi=200, bbox_inches="tight") plt.close(fig) return filename # ============================================================ # 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: empty = pd.DataFrame({"Message": ["Select at least one player."]}) return empty, empty data = df[df[PLAYER_COL].astype(str).isin(players)].copy() cols = available_cols(COMPARISON_COLS) out = data[cols].copy() if out.empty: empty = pd.DataFrame({"Message": ["No comparison data found."]}) return empty, empty out = pretty_df(out).reset_index(drop=True) return out, out def comparison_radar(player_1, player_2, player_3): players = [p for p in [player_1, player_2, player_3] if p] fig = go.Figure() if not players: fig.update_layout( title="Select players to compare.", height=650, margin=dict(l=140, r=140, t=100, b=100), ) return fig first_row = get_player_row(players[0]) if first_row is None: return fig metrics = top_available_attr_cols(first_row, max_cols=8) if len(metrics) < 3: fig.update_layout( title="Not enough attributes available for radar chart.", height=650, margin=dict(l=140, r=140, t=100, b=100), ) return fig labels = [pretty_label(m) for m in metrics] max_value = 100 for player in players: row = get_player_row(player) if row is not None: values = [row.get(m, 0) if pd.notna(row.get(m, np.nan)) else 0 for m in metrics] max_value = max(max_value, np.nanmax(values)) fig.add_trace(go.Scatterpolar( r=values, theta=labels, fill="toself", name=str(player), )) fig.update_layout( title=dict( text="Player Attribute Radar Comparison", x=0.5, xanchor="center", ), polar=dict( domain=dict(x=[0.16, 0.84], y=[0.12, 0.88]), radialaxis=dict( visible=True, range=[0, max(100, max_value * 1.1)], ), ), height=700, margin=dict(l=140, r=140, t=110, b=110), showlegend=True, legend=dict(orientation="h", y=-0.08, x=0.5, xanchor="center"), ) return fig # ============================================================ # FIT SCORE CALCULATOR # ============================================================ def fit_score( competitions, positions, pressing_w, duels_w, aerial_w, possession_w, blocking_w, progression_w, impact_w, discipline_w, dribbling_w, chance_w, finishing_w, crossing_w, box_w, holdup_w, target_w, attain_w, ): data = df.copy() if competitions and COMP_COL in data.columns: data = data[data[COMP_COL].astype(str).isin([str(x) for x in competitions])] if positions and POSITION_COL in data.columns: data = data[data[POSITION_COL].astype(str).isin([str(x) for x in positions])] if data.empty: empty = pd.DataFrame({"Message": ["No players found for selected competitions/positions."]}) return empty, empty weights = { "attr_pressing": pressing_w, "attr_duels": duels_w, "attr_aerial": aerial_w, "attr_possession_retention": possession_w, "attr_blocking": blocking_w, "attr_progression": progression_w, "attr_impact": impact_w, "attr_discipline": discipline_w, "attr_dribbling": dribbling_w, "attr_chance_creation": chance_w, "attr_finishing": finishing_w, "attr_crossing": crossing_w, "attr_box_presence": box_w, "attr_holdup": holdup_w, TARGET_SCORE_COL: target_w, ATTAINABILITY_COL: attain_w, } total_weight = sum(weights.values()) if total_weight == 0: empty = pd.DataFrame({"Message": ["At least one scouting weight must be above 0."]}) return empty, empty fit_score_values = pd.Series(np.zeros(len(data)), index=data.index) for col, weight in weights.items(): if col in data.columns and weight > 0: fit_score_values += normalize_0_100(data[col]).fillna(0) * weight data["fit_score"] = fit_score_values / total_weight cols = available_cols([ PLAYER_COL, POSITION_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL, MARKET_VALUE_COL, CONTRACT_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, "attr_pressing", "attr_duels", "attr_aerial", "attr_possession_retention", "attr_blocking", "attr_progression", "attr_impact", "attr_discipline", "attr_dribbling", "attr_chance_creation", "attr_finishing", "attr_crossing", "attr_box_presence", "attr_holdup", ]) + ["fit_score"] out = data[cols].sort_values("fit_score", ascending=False).head(50).copy() out = pretty_df(out).reset_index(drop=True) return out, out # ============================================================ # SIMILAR PLAYER FINDER # ============================================================ def similar_players(player): row = get_player_row(player) if row is None: empty = pd.DataFrame({"Message": ["Select a player."]}) return empty, empty metrics = [] for metric in available_cols(SIMILARITY_METRICS): if pd.notna(row.get(metric, np.nan)): metrics.append(metric) metrics = metrics[:24] if not metrics: empty = pd.DataFrame({"Message": ["No similarity metrics available."]}) return empty, empty pos = row.get(POSITION_COL, None) if POSITION_COL in df.columns and pd.notna(pos): candidates = df[ (df[PLAYER_COL].astype(str) != str(player)) & (df[POSITION_COL] == pos) ].copy() else: candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy() if candidates.empty: candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy() for metric in metrics: values = pd.to_numeric(df[metric], errors="coerce") sd = values.std() if pd.isna(sd) or sd == 0: candidates[f"dist_{metric}"] = 0 else: candidates[f"dist_{metric}"] = ((pd.to_numeric(candidates[metric], errors="coerce") - row[metric]) / sd) ** 2 dist_cols = [f"dist_{metric}" for metric 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, POSITION_COL, AGE_COL, MARKET_VALUE_COL, ARCHETYPE_COL, ARCHETYPE_SCORE_COL, TARGET_SCORE_COL, ATTAINABILITY_COL, ]) + ["Similarity Score"] out = candidates[cols].sort_values("Similarity Score", ascending=False).head(10).copy() out = pretty_df(out).reset_index(drop=True) return out, out # ============================================================ # 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(SHORTLIST_COLS) out = data[cols].copy() if out.empty: return pd.DataFrame({"Message": ["Shortlist is empty or columns were not found."]}) out = pretty_df(out).reset_index(drop=True) return out def export_shortlist_csv(): if not shortlist: return None data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy() cols = available_cols(SHORTLIST_COLS) out = data[cols].copy() if MARKET_VALUE_COL in out.columns: out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money) numeric_cols = out.select_dtypes(include=np.number).columns out[numeric_cols] = out[numeric_cols].round(2) out = out.rename(columns={c: pretty_label(c) for c in out.columns}) out_file = "shortlist_export.csv" out.to_csv(out_file, index=False) return out_file # ============================================================ # PDF REPORT EXPORT # ============================================================ def export_player_report(player, notes): row = get_player_row(player) if row is None: return None safe_name = re.sub(r"[^A-Za-z0-9_]+", "_", str(row.get(PLAYER_COL, "player"))) out_file = f"{safe_name}_scouting_report.pdf" pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) pdf.add_page() pdf.set_font("Arial", "B", 18) pdf.cell(0, 10, safe_pdf_text("Player Scouting Report"), ln=True) pdf.set_font("Arial", "B", 15) pdf.cell(0, 9, safe_pdf_text(row.get(PLAYER_COL, "Unknown Player")), ln=True) pdf.set_font("Arial", "", 10) pdf.cell(0, 7, safe_pdf_text(f"Club: {row.get(TEAM_COL, 'N/A')}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Competition: {row.get(COMP_COL, 'N/A')}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Position: {row.get(POSITION_COL, 'N/A')}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Age: {clean_value(row.get(AGE_COL, np.nan))}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Country: {clean_value(row.get(COUNTRY_COL, np.nan))}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Height: {clean_value(row.get(HEIGHT_COL, np.nan))} cm"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Market Value: {format_money(row.get(MARKET_VALUE_COL, np.nan))}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Contract: {clean_value(row.get(CONTRACT_COL, np.nan))}"), ln=True) pdf.cell(0, 7, safe_pdf_text(f"Minutes: {clean_value(row.get(MINUTES_COL, np.nan))}"), ln=True) pdf.ln(3) pdf.set_font("Arial", "B", 12) pdf.cell(0, 8, safe_pdf_text("Scoring Summary"), ln=True) pdf.set_font("Arial", "", 10) scoring_lines = [ ("Best Archetype", row.get(ARCHETYPE_COL, "N/A")), ("Best Archetype Score", clean_value(row.get(ARCHETYPE_SCORE_COL, np.nan))), ("Target Score", clean_value(row.get(TARGET_SCORE_COL, np.nan))), ("Attainability", clean_value(row.get(ATTAINABILITY_COL, np.nan))), ("Club Rank", clean_value(row.get(CLUB_RANK_COL, np.nan))), ("Match Toughness", clean_value(row.get(MATCH_TOUGHNESS_COL, np.nan))), ("Club ELO", clean_value(row.get(ELO_COL, np.nan))), ] for label, value in scoring_lines: pdf.cell(0, 6, safe_pdf_text(f"{label}: {value}"), ln=True) pdf.ln(3) pdf.set_font("Arial", "B", 12) pdf.cell(0, 8, safe_pdf_text("Top Attribute Scores"), ln=True) pdf.set_font("Arial", "", 10) attr_rows = [] for col in available_cols(ATTR_COLS): value = row.get(col, np.nan) if pd.notna(value): attr_rows.append((pretty_label(col), float(value))) attr_rows = sorted(attr_rows, key=lambda x: x[1], reverse=True) for label, value in attr_rows[:20]: pdf.cell(0, 6, safe_pdf_text(f"{label}: {round(value, 2)}"), ln=True) pdf.ln(3) pdf.set_font("Arial", "B", 12) pdf.cell(0, 8, safe_pdf_text("Key Season Metrics"), ln=True) pdf.set_font("Arial", "", 10) for col in available_cols(KEY_METRICS): value = row.get(col, np.nan) if pd.notna(value): pdf.cell(0, 6, safe_pdf_text(f"{pretty_label(col)}: {round(float(value), 2)}"), ln=True) with tempfile.TemporaryDirectory() as tmpdir: radar_file = os.path.join(tmpdir, "radar.png") pct_file = os.path.join(tmpdir, "percentile.png") radar_saved = make_pdf_radar_png(player, radar_file) pct_saved = make_pdf_percentile_png(player, pct_file) if radar_saved: pdf.add_page() pdf.set_font("Arial", "B", 13) pdf.cell(0, 8, safe_pdf_text("Attribute Radar"), ln=True) pdf.image(radar_saved, x=15, y=25, w=180) if pct_saved: pdf.add_page() pdf.set_font("Arial", "B", 13) pdf.cell(0, 8, safe_pdf_text("Percentile Bars"), ln=True) pdf.image(pct_saved, x=12, y=25, w=185) pdf.add_page() pdf.set_font("Arial", "B", 13) pdf.cell(0, 8, safe_pdf_text("Scout Notes"), ln=True) pdf.set_font("Arial", "", 10) pdf.multi_cell(0, 6, safe_pdf_text(notes if notes else "No notes entered.")) pdf.output(out_file) return out_file # ============================================================ # APP LAYOUT # ============================================================ with gr.Blocks(title="Oldham Athletic Player Scouting", css=CUSTOM_CSS) as app: gr.Markdown( """ # Oldham Athletic Player Scouting Search, filter, compare, shortlist, and generate scouting reports for players in the database. """ ) search_state = gr.State() comparison_state = gr.State() fit_state = gr.State() similar_state = gr.State() with gr.Tabs(): with gr.Tab("Player Search"): gr.Markdown("## Search and Filter Players") 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, ) country_filter = gr.Dropdown( choices=country_options, value=[], label="Country", multiselect=True, ) with gr.Row(): 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=minutes_max, value=0, step=100, label="Minimum Minutes", ) search_button = gr.Button("Search Players") search_results = gr.Dataframe( label="Player Results", interactive=False, ) search_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") with gr.Tab("Player Profile"): gr.Markdown("## Full Player Profile") selected_player = gr.Dropdown( choices=player_dropdown_choices, label="Select Player" ) with gr.Row(): with gr.Column(scale=2): profile_output = gr.Markdown() with gr.Column(scale=1): gr.Markdown("### Key Performance Summary") key_summary_output = gr.Dataframe( label="", interactive=False, ) gr.Markdown("## Player Metrics") metric_group_dropdown = gr.Dropdown( choices=[ "Attributes", "Position Scores", "Archetype Scores", "Key Season Stats" ], value="Attributes", label="Metric Group" ) metric_table_output = gr.Dataframe( label="Metric Breakdown", interactive=False, ) with gr.Row(): radar_output = gr.Plot(label="Attribute Radar") percentile_output = gr.Plot(label="Percentile Bars") with gr.Row(): profile_metric = gr.Dropdown( choices=performance_metric_options, value=performance_metric_options[0][1] if performance_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.", ) with gr.Row(): report_button = gr.Button("Generate Scouting Report PDF") shortlist_button = gr.Button("Add Player to Shortlist") report_file = gr.File(label="Download Scouting Report") shortlist_from_profile = gr.Dataframe(label="Current Shortlist", interactive=False) with gr.Tab("Player Comparison Tool"): gr.Markdown("## Compare Up To Three Players") with gr.Row(): compare_1 = gr.Dropdown(choices=player_dropdown_choices, label="Player 1") compare_2 = gr.Dropdown(choices=player_dropdown_choices, label="Player 2") compare_3 = gr.Dropdown(choices=player_dropdown_choices, label="Player 3") compare_button = gr.Button("Compare Players") comparison_table = gr.Dataframe( label="Comparison Table", interactive=False, ) comparison_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") comparison_radar_plot = gr.Plot(label="Attribute Radar Comparison") with gr.Tab("Fit Score Calculator"): gr.Markdown( """ ## Fit Score Calculator Select the competitions and positions you want to search, then adjust the trait weights to generate a ranked recommendation list. """ ) with gr.Row(): fit_competition_filter = gr.Dropdown( choices=competition_options, value=[], label="Competitions to Search", multiselect=True, ) fit_position_filter = gr.Dropdown( choices=position_options, value=[], label="Positions to Search", multiselect=True, ) with gr.Row(): pressing_w = gr.Slider(0, 10, value=5, step=1, label="Pressing") duels_w = gr.Slider(0, 10, value=5, step=1, label="Duels") aerial_w = gr.Slider(0, 10, value=4, step=1, label="Aerial") with gr.Row(): possession_w = gr.Slider(0, 10, value=5, step=1, label="Possession Retention") blocking_w = gr.Slider(0, 10, value=4, step=1, label="Blocking") progression_w = gr.Slider(0, 10, value=6, step=1, label="Progression") with gr.Row(): impact_w = gr.Slider(0, 10, value=6, step=1, label="Impact") discipline_w = gr.Slider(0, 10, value=3, step=1, label="Discipline") dribbling_w = gr.Slider(0, 10, value=4, step=1, label="Dribbling") with gr.Row(): chance_w = gr.Slider(0, 10, value=5, step=1, label="Chance Creation") finishing_w = gr.Slider(0, 10, value=3, step=1, label="Finishing") crossing_w = gr.Slider(0, 10, value=3, step=1, label="Crossing") with gr.Row(): box_w = gr.Slider(0, 10, value=3, step=1, label="Box Presence") holdup_w = gr.Slider(0, 10, value=3, step=1, label="Holdup") target_w = gr.Slider(0, 10, value=7, step=1, label="Target Score") with gr.Row(): attain_w = gr.Slider(0, 10, value=6, step=1, label="Attainability") fit_button = gr.Button("Generate Ranked Recommendations") fit_table = gr.Dataframe( label="Fit Score Recommendations", interactive=False, ) fit_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") with gr.Tab("Similar Player Finder"): gr.Markdown("## Find Similar Players") similar_player_select = gr.Dropdown(choices=player_dropdown_choices, label="Select Player") similar_button = gr.Button("Find Similar Players") similar_table = gr.Dataframe( label="Similar Players", interactive=False, ) similar_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") with gr.Tab("Shortlist Manager"): gr.Markdown("## Shortlist Manager") with gr.Row(): shortlist_player = gr.Dropdown(choices=player_dropdown_choices, 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") # ======================================================== # EVENTS # ======================================================== search_button.click( fn=search_players, inputs=[ search_box, competition_filter, team_filter, position_filter, country_filter, min_age_filter, max_age_filter, minutes_filter, ], outputs=[search_results, search_state], ) selected_player.change(player_profile, selected_player, profile_output) selected_player.change(key_performance_summary, selected_player, key_summary_output) selected_player.change( profile_metric_dropdown_table, [selected_player, metric_group_dropdown], metric_table_output ) metric_group_dropdown.change( profile_metric_dropdown_table, [selected_player, metric_group_dropdown], metric_table_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) search_results.select( fn=selected_player_from_table, inputs=search_state, outputs=selected_player, ).then( fn=selected_player_status, inputs=selected_player, outputs=search_status, ) compare_button.click( fn=compare_players, inputs=[compare_1, compare_2, compare_3], outputs=[comparison_table, comparison_state], ) compare_button.click( fn=comparison_radar, inputs=[compare_1, compare_2, compare_3], outputs=comparison_radar_plot, ) comparison_table.select( fn=selected_player_from_table, inputs=comparison_state, outputs=selected_player, ).then( fn=selected_player_status, inputs=selected_player, outputs=comparison_status, ) fit_button.click( fn=fit_score, inputs=[ fit_competition_filter, fit_position_filter, pressing_w, duels_w, aerial_w, possession_w, blocking_w, progression_w, impact_w, discipline_w, dribbling_w, chance_w, finishing_w, crossing_w, box_w, holdup_w, target_w, attain_w, ], outputs=[fit_table, fit_state], ) fit_table.select( fn=selected_player_from_table, inputs=fit_state, outputs=selected_player, ).then( fn=selected_player_status, inputs=selected_player, outputs=fit_status, ) similar_button.click( fn=similar_players, inputs=similar_player_select, outputs=[similar_table, similar_state], ) similar_table.select( fn=selected_player_from_table, inputs=similar_state, outputs=selected_player, ).then( fn=selected_player_status, inputs=selected_player, outputs=similar_status, ) 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( server_name="0.0.0.0", server_port=7860, share=True )