CoolFace
Apppublic

osama-n097/match-performance-api

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
player_dashboard.py842 linesDownload Raw Back to visualizations
1"""2visualizations/player_dashboard.py — Player Analysis Dashboard3واجهة تحليل اللاعب الكاملة مع filter باسم اللاعب4"""5 6import io7import base648import numpy as np9import pandas as pd10import matplotlib11matplotlib.use("Agg")12import matplotlib.pyplot as plt13import matplotlib.gridspec as gridspec14from matplotlib.patches import FancyBboxPatch15import warnings16 17warnings.filterwarnings("ignore")18 19try:20    from mplsoccer import Pitch, VerticalPitch21    HAS_MPLSOCCER = True22except ImportError:23    HAS_MPLSOCCER = False24 25from config import DATA_DIR, VIZ_COLORS26 27# ── Load Data (once) ──────────────────────────────────────────────────────────28_cache = {}29 30def _load():31    if not _cache:32        _cache["scores"]   = pd.read_parquet(DATA_DIR / "model_scores.parquet")33        _cache["computed"] = pd.read_parquet(DATA_DIR / "computed_features.parquet")34        _cache["events"]   = pd.read_parquet(DATA_DIR / "events_clean.parquet")35        _cache["matches"]  = pd.read_parquet(DATA_DIR / "matches.parquet")36        _cache["vaep"]     = pd.read_parquet(DATA_DIR / "player_vaep_ratings.parquet")37    return _cache38 39 40def get_player_list() -> list[str]:41    """قائمة بأسماء كل اللاعبين"""42    data = _load()43    names = data["scores"]["player_name"].dropna().unique().tolist()44    return sorted(names)45 46 47def _fuzzy_match(name_series: pd.Series, query: str) -> pd.Series:48    """Match if ALL tokens in query appear as substrings in the stored name."""49    tokens = query.strip().lower().split()50    mask = pd.Series(False, index=name_series.index)51    for i, val in enumerate(name_series):52        if pd.isna(val):53            continue54        lower = str(val).lower()55        if all(tok in lower for tok in tokens):56            mask.iloc[i] = True57    return mask58 59def get_player_data(player_name: str) -> dict:60    """Fetch all data for a given player (fuzzy name matching)."""61    data    = _load()62    scores  = data["scores"]63    computed= data["computed"]64    events  = data["events"]65    matches = data["matches"]66    vaep    = data["vaep"]67 68    # Filter by player (fuzzy token matching)69    match_mask = _fuzzy_match(scores["player_name"], player_name)70    p_scores  = scores[match_mask]71    p_feats   = computed[computed["player_id"].isin(p_scores["player_id"].unique())]72    p_events  = events[events["player_id"].isin(p_scores["player_id"].unique())]73    p_vaep    = vaep[vaep["player_id"].isin(p_scores["player_id"].unique())]74 75    if len(p_scores) == 0:76        return None77 78    # Merge مع التواريخ79    p_scores_dated = p_scores.merge(80        matches[["match_id","match_date","home_team","away_team"]],81        on="match_id", how="left"82    ).sort_values("match_date")83 84    return {85        "name":           str(p_scores.iloc[0]["player_name"]),86        "player_id":      int(p_scores.iloc[0]["player_id"]),87        "position":       str(p_scores.iloc[0].get("position_group","Unknown")),88        "cluster":        str(p_scores.iloc[0].get("player_cluster","Unknown")),89        "trend":          str(p_scores.iloc[0].get("performance_trend","Stable")),90        "scores":         p_scores_dated,91        "features":       p_feats,92        "events":         p_events,93        "vaep":           p_vaep,94        "avg_overall":    round(float(p_scores["overall_score"].mean()), 2),95        "matches_played": int(p_scores["match_id"].nunique()),96    }97 98 99def get_player_chart_data(player_name: str, match_id: int = None) -> dict:100    """101    جيب البيانات الخام للـ Charts (JSON بدل Base64 images)102    للـ Frontend Rendering مع Animation103    """104    player_data = get_player_data(player_name)105    if not player_data:106        return {"error": f"Player '{player_name}' not found"}107 108    scores = player_data["scores"].sort_values("match_date").reset_index(drop=True)109    dims = ["passing_score", "shooting_score", "positioning_score",110            "pressing_score", "movement_score", "physical_score", "behavioral_score"]111    labels = ["Passing", "Shooting", "Positioning", "Pressing", "Movement", "Physical", "Behavioral"]112 113    # 1. Radar Chart Data114    radar_vals = [float(scores[d].mean()) if d in scores.columns else 0.0 for d in dims]115    radar_data = {116        "labels": labels,117        "values": [round(v, 2) for v in radar_vals]118    }119 120    # 2. Trend Chart Data121    trend_data = [122        {123            "idx": int(i),124            "date": str(row.get("match_date", f"M{i+1}")),125            "overall": float(row.get("overall_score", 0)),126            "rolling_avg": float(scores.iloc[:i+1]["overall_score"].tail(3).mean()) if i >= 0 else 0127        }128        for i, (_, row) in enumerate(scores.iterrows())129    ]130 131    # 3. Score Breakdown Data132    breakdown_data = [133        {"name": label, "value": round(float(scores[dim].mean()) if dim in scores.columns else 0.0, 2)}134        for label, dim in zip(labels, dims)135    ]136 137    # 4. VAEP Data138    vaep_merged = scores.copy()139    for col in ["vaep_rating", "offensive_value", "defensive_value"]:140        if col not in vaep_merged.columns:141            vaep_merged[col] = 0.0142 143    vaep_timeline = [144        {145            "idx": int(i),146            "date": str(row.get("match_date", f"M{i+1}")),147            "vaep": float(row.get("vaep_rating", 0))148        }149        for i, (_, row) in enumerate(vaep_merged.iterrows())150    ]151 152    vaep_totals = {153        "offensive": round(float(vaep_merged["offensive_value"].sum()), 2),154        "defensive": round(float(vaep_merged["defensive_value"].sum()), 2)155    }156 157    vaep_data = {158        "timeline": vaep_timeline,159        "totals": vaep_totals160    }161 162    # 5. Position Comparison Data163    data = _load()164    all_scores = data["scores"]165    pos = player_data["position"]166    pos_avg_scores = all_scores[all_scores["position_group"] == pos]167 168    position_data = [169        {170            "name": label,171            "player": round(float(scores[dim].mean()) if dim in scores.columns else 0.0, 2),172            "position_avg": round(float(pos_avg_scores[dim].mean()) if dim in pos_avg_scores.columns else 0.0, 2)173        }174        for label, dim in zip(labels, dims)175    ]176 177    # 6. Percentile Data178    percentile_data = {179        "in_team": round(float(scores["percentile_in_team"].mean()) if "percentile_in_team" in scores.columns else 50, 2),180        "in_league": round(float(scores["percentile_in_league"].mean()) if "percentile_in_league" in scores.columns else 50, 2),181        "in_position": round(float(scores["percentile_in_position"].mean()) if "percentile_in_position" in scores.columns else 50, 2)182    }183 184    return {185        "player_name": player_data["name"],186        "position": player_data["position"],187        "cluster": player_data["cluster"],188        "trend": player_data["trend"],189        "avg_overall": player_data["avg_overall"],190        "matches_played": player_data["matches_played"],191        "charts": {192            "radar": radar_data,193            "trend": trend_data,194            "breakdown": breakdown_data,195            "vaep": vaep_data,196            "position_comparison": position_data,197            "percentiles": percentile_data198        }199    }200 201 202def _fig_to_base64(fig) -> str:203    buf = io.BytesIO()204    fig.savefig(buf, format="png", dpi=150, bbox_inches="tight",205                facecolor=fig.get_facecolor())206    buf.seek(0)207    encoded = base64.b64encode(buf.read()).decode("utf-8")208    plt.close(fig)209    return f"data:image/png;base64,{encoded}"210 211 212# ── Chart 1: Radar Chart ──────────────────────────────────────────────────────213 214def radar_chart(player_data: dict) -> str:215    scores = player_data["scores"]216    dims   = ["passing_score","shooting_score","positioning_score",217               "pressing_score","movement_score","physical_score","behavioral_score"]218    labels = ["Passing","Shooting","Positioning","Pressing","Movement","Physical","Behavioral"]219    vals   = [round(float(scores[d].mean()), 2) for d in dims if d in scores.columns]220    vals  += vals[:1]221 222    angles = [n / float(len(labels)) * 2 * np.pi for n in range(len(labels))]223    angles+= angles[:1]224 225    fig, ax = plt.subplots(figsize=(7,7), subplot_kw=dict(polar=True))226    fig.patch.set_facecolor("#1a1a2e")227    ax.set_facecolor("#1a1a2e")228 229    ax.plot(angles, vals, "o-", linewidth=2.5, color="#00d4ff")230    ax.fill(angles, vals, alpha=0.3, color="#00d4ff")231 232    ax.set_xticks(angles[:-1])233    ax.set_xticklabels(labels, fontsize=11, color="white")234    ax.set_ylim(0, 10)235    ax.set_yticks([2, 4, 6, 8, 10])236    ax.set_yticklabels(["2","4","6","8","10"], fontsize=8, color="#888888")237    ax.tick_params(colors="white")238    ax.spines["polar"].set_color("#444444")239    ax.grid(color="#333333")240 241    ax.set_title(f"{player_data['name']}\nPerformance Radar",242                 fontsize=13, fontweight="bold", color="white", pad=20)243 244    return _fig_to_base64(fig)245 246 247# ── Chart 2: Trend Chart ──────────────────────────────────────────────────────248 249def trend_chart(player_data: dict) -> str:250    scores = player_data["scores"].copy()251    scores = scores.sort_values("match_date").reset_index(drop=True)252 253    fig, ax = plt.subplots(figsize=(14, 5))254    fig.patch.set_facecolor("#1a1a2e")255    ax.set_facecolor("#1a1a2e")256 257    x = range(len(scores))258    ax.plot(x, scores["overall_score"], "o-",259            color="#00d4ff", linewidth=2, markersize=6, label="Match Score", zorder=3)260 261    rolling = scores["overall_score"].rolling(3, min_periods=1).mean()262    ax.plot(x, rolling, "--", color="#ff9f43", linewidth=2.5, label="3-Match Avg")263 264    avg = scores["overall_score"].mean()265    ax.axhline(avg, color="#ff6b6b", linestyle=":", alpha=0.8, label=f"Season Avg ({avg:.2f})")266 267    ax.fill_between(x, scores["overall_score"], avg, alpha=0.1, color="#00d4ff")268 269    ax.set_ylim(0, 10)270    ax.set_xlabel("Match Number", color="white")271    ax.set_ylabel("Overall Score (0-10)", color="white")272    ax.set_title(f"{player_data['name']} — Performance Trend",273                 fontsize=13, fontweight="bold", color="white")274    ax.tick_params(colors="white")275    ax.spines["bottom"].set_color("#444444")276    ax.spines["left"].set_color("#444444")277    ax.spines["top"].set_visible(False)278    ax.spines["right"].set_visible(False)279    ax.grid(color="#333333", alpha=0.5)280    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white")281 282    return _fig_to_base64(fig)283 284 285# ── Chart 3: Heatmap ──────────────────────────────────────────────────────────286 287def heatmap_chart(player_data: dict, match_id: int = None) -> str:288    events = player_data["events"]289    if match_id is not None and "match_id" in events.columns:290        events = events[events["match_id"] == match_id]291    events = events[events["location_x"].notna()]292 293    fig, ax = plt.subplots(figsize=(12, 8))294    fig.patch.set_facecolor("#22312b")295 296    if HAS_MPLSOCCER:297        pitch = Pitch(pitch_type="statsbomb", pitch_color="#22312b", line_color="white")298        pitch.draw(ax=ax)299        if len(events) > 10:300            pitch.kdeplot(x=events["location_x"], y=events["location_y"],301                          ax=ax, cmap="hot", fill=True, alpha=0.7,302                          shade_lowest=False, cbar=False)303    else:304        ax.set_facecolor("#22312b")305        ax.scatter(events["location_x"], events["location_y"],306                   alpha=0.3, s=5, c="orange")307        ax.set_xlim(0, 120)308        ax.set_ylim(0, 80)309 310    match_suffix = f" (Match {match_id})" if match_id is not None else " (Season)"311    ax.set_title(f"{player_data['name']} — Position Heatmap{match_suffix}",312                 fontsize=13, fontweight="bold", color="white", pad=15)313 314    return _fig_to_base64(fig)315 316 317# ── Chart 4: Pass Map ─────────────────────────────────────────────────────────318 319def pass_map_chart(player_data: dict, match_id: int = None) -> str:320    events = player_data["events"]321    passes = events[events["event_type"] == "Pass"].copy()322 323    if match_id:324        passes = passes[passes["match_id"] == match_id]325    else:326        # أحسن ماتش من ناحية عدد التمريرات327        best_match = passes.groupby("match_id").size().idxmax()328        passes     = passes[passes["match_id"] == best_match]329 330    complete   = passes[passes["pass_outcome"].isna() | (passes["pass_outcome"] == "Complete")]331    incomplete = passes[~passes.index.isin(complete.index)]332 333    fig, ax = plt.subplots(figsize=(12, 8))334    fig.patch.set_facecolor("#22312b")335 336    if HAS_MPLSOCCER:337        pitch = Pitch(pitch_type="statsbomb", pitch_color="#22312b", line_color="white")338        pitch.draw(ax=ax)339        if len(complete):340            pitch.arrows(complete["location_x"], complete["location_y"],341                         complete["pass_end_x"], complete["pass_end_y"],342                         ax=ax, color="#00ff88", width=1.5, headwidth=5, alpha=0.7)343        if len(incomplete):344            pitch.arrows(incomplete["location_x"], incomplete["location_y"],345                         incomplete["pass_end_x"], incomplete["pass_end_y"],346                         ax=ax, color="#ff4444", width=1.5, headwidth=5, alpha=0.7)347    else:348        ax.set_facecolor("#22312b")349        if len(complete):350            ax.quiver(complete["location_x"], complete["location_y"],351                      complete["pass_end_x"] - complete["location_x"],352                      complete["pass_end_y"] - complete["location_y"],353                      color="#00ff88", alpha=0.6, scale=1, scale_units="xy", angles="xy")354        if len(incomplete):355            ax.quiver(incomplete["location_x"], incomplete["location_y"],356                      incomplete["pass_end_x"] - incomplete["location_x"],357                      incomplete["pass_end_y"] - incomplete["location_y"],358                      color="#ff4444", alpha=0.6, scale=1, scale_units="xy", angles="xy")359 360    ax.set_title(361        f"{player_data['name']} — Pass Map\n"362        f"Complete: {len(complete)}  |  Incomplete: {len(incomplete)}",363        fontsize=12, fontweight="bold", color="white", pad=15364    )365    return _fig_to_base64(fig)366 367 368# ── Chart 5: Score Breakdown Bar Chart ────────────────────────────────────────369 370def score_breakdown_chart(player_data: dict) -> str:371    scores = player_data["scores"]372    dims   = ["passing_score","shooting_score","positioning_score",373               "pressing_score","movement_score","physical_score","behavioral_score"]374    labels = ["Passing","Shooting","Positioning","Pressing","Movement","Physical","Behavioral"]375    vals   = [round(float(scores[d].mean()), 2) for d in dims if d in scores.columns]376    colors = ["#00d4ff","#ff6b6b","#ffd32a","#0be881","#ff9f43","#9b59b6","#2ecc71"]377 378    fig, ax = plt.subplots(figsize=(10, 6))379    fig.patch.set_facecolor("#1a1a2e")380    ax.set_facecolor("#1a1a2e")381 382    bars = ax.barh(labels, vals, color=colors, edgecolor="none", height=0.6)383    ax.set_xlim(0, 10)384 385    for bar, val in zip(bars, vals):386        ax.text(val + 0.1, bar.get_y() + bar.get_height()/2,387                f"{val:.1f}", va="center", fontsize=11, color="white", fontweight="bold")388 389    overall = player_data["avg_overall"]390    ax.axvline(overall, color="white", linestyle="--", alpha=0.5, label=f"Overall: {overall:.2f}")391 392    ax.set_xlabel("Score (0-10)", color="white")393    ax.set_title(f"{player_data['name']} — Dimension Scores",394                 fontsize=13, fontweight="bold", color="white")395    ax.tick_params(colors="white")396    ax.spines["bottom"].set_color("#444444")397    ax.spines["left"].set_color("#444444")398    ax.spines["top"].set_visible(False)399    ax.spines["right"].set_visible(False)400    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white")401 402    return _fig_to_base64(fig)403 404 405# ── Chart 6: VAEP Over Season ─────────────────────────────────────────────────406 407def vaep_chart(player_data: dict) -> str:408    scores = player_data["scores"].sort_values("match_date")409    vaep   = player_data["vaep"]410 411    # Start from scores (it already contains VAEP fields in model_scores),412    # then backfill from player_vaep_ratings when needed.413    merged = scores.copy()414    need_cols = ["vaep_rating", "offensive_value", "defensive_value"]415    missing = [c for c in need_cols if c not in merged.columns]416 417    if missing and {"match_id", "player_id"}.issubset(vaep.columns):418        vaep_subset_cols = ["match_id", "player_id"] + [c for c in need_cols if c in vaep.columns]419        merged = merged.merge(420            vaep[vaep_subset_cols],421            on=["match_id", "player_id"],422            how="left",423            suffixes=("", "_vaep")424        )425        for c in need_cols:426            if c not in merged.columns and f"{c}_vaep" in merged.columns:427                merged[c] = merged[f"{c}_vaep"]428 429    for c in need_cols:430        if c not in merged.columns:431            merged[c] = 0.0432        merged[c] = merged[c].fillna(0)433 434    fig, axes = plt.subplots(1, 2, figsize=(14, 5))435    fig.patch.set_facecolor("#1a1a2e")436 437    # VAEP Timeline438    ax = axes[0]439    ax.set_facecolor("#1a1a2e")440    x = range(len(merged))441    vaep_values = merged["vaep_rating"].fillna(0)442    ax.bar(x, vaep_values,443           color=["#00d4ff" if v >= 0 else "#ff6b6b" for v in vaep_values])444    ax.axhline(0, color="white", linewidth=0.8)445    ax.set_title("VAEP per Match", color="white", fontsize=11)446    ax.tick_params(colors="white")447    ax.set_facecolor("#1a1a2e")448    ax.spines["bottom"].set_color("#444444")449    ax.spines["left"].set_color("#444444")450    ax.spines["top"].set_visible(False)451    ax.spines["right"].set_visible(False)452 453    # Offensive vs Defensive454    ax2 = axes[1]455    ax2.set_facecolor("#1a1a2e")456    total_off = merged["offensive_value"].sum()457    total_def = merged["defensive_value"].sum()458    bars = ax2.bar(["Offensive", "Defensive"], [total_off, total_def],459                   color=["#00d4ff","#0be881"], width=0.5)460    ax2.set_title("Total Season VAEP Breakdown", color="white", fontsize=11)461    ax2.tick_params(colors="white")462    ax2.spines["bottom"].set_color("#444444")463    ax2.spines["left"].set_color("#444444")464    ax2.spines["top"].set_visible(False)465    ax2.spines["right"].set_visible(False)466    for bar in bars:467        h = bar.get_height()468        ax2.text(bar.get_x() + bar.get_width()/2, h + 0.3,469                 f"{h:.2f}", ha="center", color="white", fontweight="bold")470 471    fig.suptitle(f"{player_data['name']} — VAEP Analysis", color="white",472                 fontsize=13, fontweight="bold")473    return _fig_to_base64(fig)474 475 476# ── Chart 7: Comparison with Position Average ────────────────────────────────477 478def position_comparison_chart(player_data: dict) -> str:479    scores  = player_data["scores"]480    data    = _load()481    all_sc  = data["scores"]482    pos     = player_data["position"]483 484    pos_avg = all_sc[all_sc["position_group"] == pos]485    dims    = ["passing_score","shooting_score","positioning_score",486                "pressing_score","movement_score","physical_score","behavioral_score"]487    labels  = ["Passing","Shooting","Positioning","Pressing","Movement","Physical","Behavioral"]488 489    player_vals = [float(scores[d].mean()) for d in dims if d in scores.columns]490    pos_vals    = [float(pos_avg[d].mean()) for d in dims if d in pos_avg.columns]491 492    x    = np.arange(len(labels))493    width= 0.35494 495    fig, ax = plt.subplots(figsize=(12, 6))496    fig.patch.set_facecolor("#1a1a2e")497    ax.set_facecolor("#1a1a2e")498 499    ax.bar(x - width/2, player_vals, width, label=player_data["name"],500           color="#00d4ff", alpha=0.9, edgecolor="none")501    ax.bar(x + width/2, pos_vals,    width, label=f"{pos} Average",502           color="#ff9f43", alpha=0.9, edgecolor="none")503 504    ax.set_xticks(x)505    ax.set_xticklabels(labels, rotation=30, ha="right", color="white")506    ax.set_ylim(0, 10)507    ax.set_ylabel("Score (0-10)", color="white")508    ax.set_title(f"{player_data['name']} vs {pos} Position Average",509                 fontsize=13, fontweight="bold", color="white")510    ax.tick_params(colors="white")511    ax.spines["bottom"].set_color("#444444")512    ax.spines["left"].set_color("#444444")513    ax.spines["top"].set_visible(False)514    ax.spines["right"].set_visible(False)515    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white")516 517    return _fig_to_base64(fig)518 519 520# ── Chart 8: Shooting Map (Shot Locations + xG) ──────────────────────────────521 522def shooting_map_chart(player_data: dict, match_id: int = None) -> str:523    events = player_data["events"]524    shots  = events[events["event_type"] == "Shot"].copy()525    if match_id is not None and "match_id" in shots.columns:526        shots = shots[shots["match_id"] == match_id]527 528    fig, ax = plt.subplots(figsize=(8, 6))529    fig.patch.set_facecolor("#22312b")530 531    if HAS_MPLSOCCER:532        pitch = VerticalPitch(pitch_type="statsbomb", pitch_color="#22312b",533                              line_color="white", half=True)534        pitch.draw(ax=ax)535 536        if len(shots):537            goals = shots[shots["shot_outcome"] == "Goal"]538            saves = shots[shots["shot_outcome"] != "Goal"]539 540            if len(saves):541                ax.scatter(saves["location_y"], saves["location_x"],542                           c="#ff6b6b", s=saves["shot_xg"].fillna(0.1) * 1000 + 50,543                           alpha=0.7, zorder=3, label="No Goal")544            if len(goals):545                ax.scatter(goals["location_y"], goals["location_x"],546                           c="#ffd32a", s=goals["shot_xg"].fillna(0.1) * 1000 + 50,547                           alpha=1.0, zorder=4, marker="*", label="Goal", edgecolors="white")548    else:549        ax.set_facecolor("#22312b")550        if len(shots):551            goals = shots[shots["shot_outcome"] == "Goal"]552            saves = shots[shots["shot_outcome"] != "Goal"]553            if len(saves):554                ax.scatter(saves["location_x"], saves["location_y"],555                           c="#ff6b6b", s=50, alpha=0.7, label="No Goal")556            if len(goals):557                ax.scatter(goals["location_x"], goals["location_y"],558                           c="#ffd32a", s=100, marker="*", label="Goal")559 560    match_suffix = f" (Match {match_id})" if match_id is not None else " (Season)"561    ax.set_title(f"{player_data['name']} — Shot Map{match_suffix}\n"562                 f"(Size = xG value | Star = Goal)",563                 color="white", fontsize=11, fontweight="bold")564    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white")565    return _fig_to_base64(fig)566 567 568# ── Chart 8b: Saves Map (Goalkeeper — Shots Faced) ──────────────────────────569 570def saves_map_chart(player_data: dict, match_id: int = None,571                    full_events: pd.DataFrame = None) -> str:572    """Show shots a goalkeeper faced: saves made (green) and goals conceded (red)."""573    pid = player_data["player_id"]574 575    if full_events is not None:576        events = full_events577    else:578        events = _load()["events"]579 580    # Filter to match581    if match_id is not None and "match_id" in events.columns:582        events = events[events["match_id"] == match_id]583 584    # Determine the GK's team from their own events585    gk_events = events[events["player_id"] == pid]586    gk_team_id = None587    if len(gk_events) and "team_id" in gk_events.columns:588        teams = gk_events["team_id"].dropna().unique()589        if len(teams):590            gk_team_id = int(teams[0])591 592    shots = events[events["event_type"] == "Shot"].copy()593    if not len(shots):594        fig, ax = plt.subplots(figsize=(8, 6))595        fig.patch.set_facecolor("#22312b")596        ax.set_facecolor("#22312b")597        ax.text(0.5, 0.5, "No shot data for this match",598                ha="center", va="center", color="white", fontsize=12)599        return _fig_to_base64(fig)600 601    saves = shots[(shots["shot_outcome"] == "Saved") & (shots["player_id"] == pid)]602 603    goals_conceded = pd.DataFrame()604    if gk_team_id is not None:605        goals_conceded = shots[606            (shots["shot_outcome"] == "Goal")607            & (shots["team_id"] != gk_team_id)608            & (shots["team_id"].notna())609        ]610 611    other_faced = pd.DataFrame()612    if gk_team_id is not None:613        idx = set(saves.index) | set(goals_conceded.index)614        other_faced = shots[615            (shots["team_id"] != gk_team_id)616            & (shots["team_id"].notna())617            & ~shots.index.isin(idx)618        ]619 620    fig, ax = plt.subplots(figsize=(8, 6))621    fig.patch.set_facecolor("#22312b")622 623    if HAS_MPLSOCCER:624        pitch = VerticalPitch(pitch_type="statsbomb", pitch_color="#22312b",625                              line_color="white", half=True)626        pitch.draw(ax=ax)627 628        if len(saves):629            ax.scatter(saves["location_y"], saves["location_x"],630                       c="#22c55e", s=saves["shot_xg"].fillna(0.1) * 1000 + 50,631                       alpha=0.8, zorder=3, label="Saved", edgecolors="white", linewidth=0.5)632        if len(goals_conceded):633            ax.scatter(goals_conceded["location_y"], goals_conceded["location_x"],634                       c="#ef4444", s=goals_conceded["shot_xg"].fillna(0.1) * 1000 + 80,635                       alpha=1.0, zorder=4, marker="*", label="Goal Conceded", edgecolors="white")636        if len(other_faced):637            ax.scatter(other_faced["location_y"], other_faced["location_x"],638                       c="#94a3b8", s=other_faced["shot_xg"].fillna(0.1) * 500 + 30,639                       alpha=0.5, zorder=2, label="Other Shot Faced")640    else:641        ax.set_facecolor("#22312b")642        if len(saves):643            ax.scatter(saves["location_x"], saves["location_y"],644                       c="#22c55e", s=80, alpha=0.8, label="Saved")645        if len(goals_conceded):646            ax.scatter(goals_conceded["location_x"], goals_conceded["location_y"],647                       c="#ef4444", s=120, marker="*", label="Goal Conceded")648        if len(other_faced):649            ax.scatter(other_faced["location_x"], other_faced["location_y"],650                       c="#94a3b8", s=40, alpha=0.5, label="Other Shot Faced")651 652    match_suffix = f" (Match {match_id})" if match_id is not None else " (Season)"653    ax.set_title(f"{player_data['name']} — Saves Map{match_suffix}\n"654                 f"(Green = Saved | Red = Goal | Grey = Other)",655                 color="white", fontsize=11, fontweight="bold")656    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white")657    return _fig_to_base64(fig)658 659 660# ── Chart 8c: Defensive Actions Map (Tackles, Interceptions, Blocks, Clearances, Fouls) ──661 662def defensive_actions_map_chart(player_data: dict, match_id: int = None,663                                full_events: pd.DataFrame = None) -> str:664    """Show defensive actions: tackles/duels, interceptions, blocks, clearances, fouls committed."""665    pid = player_data["player_id"]666 667    if full_events is not None:668        events = full_events669    else:670        events = _load()["events"]671 672    if match_id is not None and "match_id" in events.columns:673        events = events[events["match_id"] == match_id]674 675    player_events = events[events["player_id"] == pid]676 677    tackles  = player_events[player_events["event_type"] == "Duel"].copy()678    interceptions = player_events[player_events["event_type"] == "Interception"].copy()679    blocks   = player_events[player_events["event_type"] == "Block"].copy()680    clearances = player_events[player_events["event_type"] == "Clearance"].copy()681    fouls    = player_events[player_events["event_type"] == "Foul Committed"].copy()682    recoveries = player_events[player_events["event_type"] == "Ball Recovery"].copy()683 684    fig, ax = plt.subplots(figsize=(8, 6))685    fig.patch.set_facecolor("#22312b")686 687    if HAS_MPLSOCCER:688        pitch = Pitch(pitch_type="statsbomb", pitch_color="#22312b",689                      line_color="white")690        pitch.draw(ax=ax)691 692        if len(tackles):693            pitch.scatter(tackles["location_x"], tackles["location_y"], ax=ax,694                       c="#f59e0b", s=60, alpha=0.8, zorder=3,695                       label=f"Duels ({len(tackles)})", edgecolors="white", linewidth=0.5)696        if len(interceptions):697            pitch.scatter(interceptions["location_x"], interceptions["location_y"], ax=ax,698                       c="#0ea5e9", s=60, alpha=0.8, zorder=3,699                       label=f"Interceptions ({len(interceptions)})", edgecolors="white", linewidth=0.5)700        if len(blocks):701            pitch.scatter(blocks["location_x"], blocks["location_y"], ax=ax,702                       c="#f97316", s=60, alpha=0.8, zorder=3, marker="s",703                       label=f"Blocks ({len(blocks)})", edgecolors="white", linewidth=0.5)704        if len(clearances):705            pitch.scatter(clearances["location_x"], clearances["location_y"], ax=ax,706                       c="#14b8a6", s=60, alpha=0.8, zorder=3, marker="^",707                       label=f"Clearances ({len(clearances)})", edgecolors="white", linewidth=0.5)708        if len(fouls):709            pitch.scatter(fouls["location_x"], fouls["location_y"], ax=ax,710                       c="#ef4444", s=60, alpha=0.7, zorder=3, marker="v",711                       label=f"Fouls ({len(fouls)})", edgecolors="white", linewidth=0.5)712        if len(recoveries):713            pitch.scatter(recoveries["location_x"], recoveries["location_y"], ax=ax,714                       c="#6366f1", s=40, alpha=0.6, zorder=2, marker="D",715                       label=f"Recoveries ({len(recoveries)})", edgecolors="white", linewidth=0.5)716    else:717        ax.set_facecolor("#22312b")718        if len(tackles):719            ax.scatter(tackles["location_x"], tackles["location_y"],720                       c="#f59e0b", s=60, alpha=0.8, label=f"Duels ({len(tackles)})")721        if len(interceptions):722            ax.scatter(interceptions["location_x"], interceptions["location_y"],723                       c="#0ea5e9", s=60, alpha=0.8, label=f"Interceptions ({len(interceptions)})")724        if len(blocks):725            ax.scatter(blocks["location_x"], blocks["location_y"],726                       c="#f97316", s=60, alpha=0.8, marker="s", label=f"Blocks ({len(blocks)})")727        if len(clearances):728            ax.scatter(clearances["location_x"], clearances["location_y"],729                       c="#14b8a6", s=60, alpha=0.8, marker="^", label=f"Clearances ({len(clearances)})")730        if len(fouls):731            ax.scatter(fouls["location_x"], fouls["location_y"],732                       c="#ef4444", s=60, alpha=0.7, marker="v", label=f"Fouls ({len(fouls)})")733        if len(recoveries):734            ax.scatter(recoveries["location_x"], recoveries["location_y"],735                       c="#6366f1", s=40, alpha=0.6, marker="D", label=f"Recoveries ({len(recoveries)})")736 737    match_suffix = f" (Match {match_id})" if match_id is not None else " (Season)"738    ax.set_title(f"{player_data['name']} — Defensive Actions{match_suffix}",739                 color="white", fontsize=11, fontweight="bold")740    ax.legend(facecolor="#2a2a3e", edgecolor="none", labelcolor="white",741              fontsize=7, loc="lower left")742    return _fig_to_base64(fig)743 744 745# ── Chart 9: Percentile Profile ───────────────────────────────────────────────746 747def percentile_chart(player_data: dict) -> str:748    scores = player_data["scores"]749    percs  = {750        "In Team"    : float(scores["percentile_in_team"].mean())     if "percentile_in_team"     in scores.columns else 50,751        "In League"  : float(scores["percentile_in_league"].mean())   if "percentile_in_league"   in scores.columns else 50,752        "In Position": float(scores["percentile_in_position"].mean()) if "percentile_in_position" in scores.columns else 50,753    }754 755    fig, ax = plt.subplots(figsize=(8, 4))756    fig.patch.set_facecolor("#1a1a2e")757    ax.set_facecolor("#1a1a2e")758 759    colors = ["#00d4ff","#ff9f43","#0be881"]760    bars   = ax.barh(list(percs.keys()), list(percs.values()),761                     color=colors, height=0.5, edgecolor="none")762    ax.set_xlim(0, 100)763    ax.axvline(50, color="#666666", linestyle="--", alpha=0.5)764 765    for bar, val in zip(bars, percs.values()):766        ax.text(val + 1, bar.get_y() + bar.get_height()/2,767                f"{val:.1f}%", va="center", color="white", fontweight="bold")768 769    ax.set_xlabel("Percentile", color="white")770    ax.set_title(f"{player_data['name']} — Percentile Rankings",771                 color="white", fontsize=12, fontweight="bold")772    ax.tick_params(colors="white")773    ax.spines["bottom"].set_color("#444444")774    ax.spines["left"].set_color("#444444")775    ax.spines["top"].set_visible(False)776    ax.spines["right"].set_visible(False)777 778    return _fig_to_base64(fig)779 780 781# ── MAIN: Generate All Charts ─────────────────────────────────────────────────782 783def generate_all_charts(player_name: str, match_id: int = None) -> dict:784    """785    توليد كل الـ charts للاعب معين786    بيرجع dict فيه base64 images787    """788    player_data = get_player_data(player_name)789    if not player_data:790        return {"error": f"Player '{player_name}' not found"}791 792    scores = player_data["scores"].sort_values("match_date")793    match_meta = scores[["match_id", "match_date", "home_team", "away_team"]].drop_duplicates(subset=["match_id"]).copy()794 795    available_matches = []796    for _, row in match_meta.iterrows():797        mid = int(row["match_id"])798        match_date = ""799        if "match_date" in row and pd.notna(row["match_date"]):800            match_date = str(row["match_date"])801        home_team = str(row.get("home_team", "")) if pd.notna(row.get("home_team", None)) else ""802        away_team = str(row.get("away_team", "")) if pd.notna(row.get("away_team", None)) else ""803        label = f"{match_date} | {home_team} vs {away_team}" if (home_team or away_team) else f"Match {mid}"804        available_matches.append({805            "match_id": mid,806            "match_date": match_date,807            "home_team": home_team,808            "away_team": away_team,809            "label": label,810        })811 812    available_ids = {m["match_id"] for m in available_matches}813    selected_match_id = int(match_id) if match_id is not None and int(match_id) in available_ids else None814    if selected_match_id is None and len(available_matches) > 0:815        selected_match_id = int(available_matches[-1]["match_id"])816 817    print(f"[CHART] Generating charts for: {player_data['name']} | match_id={selected_match_id}")818 819    return {820        "player_info": {821            "name":        player_data["name"],822            "position":    player_data["position"],823            "cluster":     player_data["cluster"],824            "trend":       player_data["trend"],825            "avg_overall": player_data["avg_overall"],826            "matches":     player_data["matches_played"],827        },828        "selected_match_id": selected_match_id,829        "available_matches": available_matches,830        "charts": {831            "radar":              radar_chart(player_data),832            "trend":              trend_chart(player_data),833            "heatmap":            heatmap_chart(player_data, selected_match_id),834            "pass_map":           pass_map_chart(player_data, selected_match_id),835            "score_breakdown":    score_breakdown_chart(player_data),836            "vaep":               vaep_chart(player_data),837            "position_comparison":position_comparison_chart(player_data),838            "shooting_map":       shooting_map_chart(player_data, selected_match_id),839            "percentiles":        percentile_chart(player_data),840        }841    }842