miles-bachman's picture
Update app.py
f777716 verified
Raw
History Blame Contribute Delete
60.7 kB
# ============================================================
# 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
)