"""Pure chart functions. df in → plotly Figure out. Unit-testable.""" import numpy as np import pandas as pd import plotly.graph_objects as go import plotly.express as px from plotly.subplots import make_subplots from .template import ( PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER, TEXT, TEXT_SECONDARY, GRIDLINE, WHITE, ROLE_COLORS, FANTABETO_TEMPLATE, HEATMAP_COLORS, DISCRETE_10, insight_caption, ) # ───────────────────────────────────────────────────────────────── # KPI sparkline row # ───────────────────────────────────────────────────────────────── def kpi_sparkline(values: list, label: str, color: str = SKY) -> go.Figure: fig = go.Figure() fig.add_trace(go.Scatter( y=values, mode="lines", line=dict(color=color, width=2), fill="tozeroy", fillcolor=f"rgba({_hex_to_rgb(color)},0.15)", showlegend=False, hoverinfo="skip", )) fig.update_layout( template=FANTABETO_TEMPLATE, height=60, width=180, margin=dict(l=0, r=0, t=0, b=0), xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), yaxis=dict(showgrid=False, zeroline=False, showticklabels=False), ) return fig # ───────────────────────────────────────────────────────────────── # Fixture difficulty heatmap # ───────────────────────────────────────────────────────────────── def fixture_heatmap(df: pd.DataFrame) -> go.Figure: """Square matrix: teams × matchdays, colored by opponent FV strength. Args: df: columns ['team', 'matchday', 'opp_strength'] """ pivot = df.pivot(index="team", columns="matchday", values="opp_strength") fig = go.Figure(data=go.Heatmap( z=pivot.values, x=[f"GW {c}" for c in pivot.columns], y=pivot.index, colorscale=HEATMAP_COLORS, zmid=np.mean([pivot.min().min(), pivot.max().max()]), hovertemplate="%{y} vs opponent
GW %{x}: difficulty %{z:.1f}", )) fig.update_layout( template=FANTABETO_TEMPLATE, height=440, xaxis=dict(side="top", tickangle=-45), yaxis=dict(autorange="reversed"), ) return fig # ───────────────────────────────────────────────────────────────── # Percentile radar chart # ───────────────────────────────────────────────────────────────── def percentile_radar(player_row: pd.Series, metrics: list, labels: list, role_avg: pd.Series = None) -> go.Figure: """Single-player percentile radar across N metrics. Args: player_row: Series with metric values (raw). metrics: column names to plot. labels: display labels for each metric. role_avg: optional league-average Series to normalize against. """ values = [] for m in metrics: if role_avg is not None and m in role_avg.index and role_avg[m] > 0: pct = min(100, max(0, (player_row.get(m, 0) / role_avg[m]) * 50)) else: pct = 50 values.append(pct) values.append(values[0]) labels_closed = labels + [labels[0]] fig = go.Figure() fig.add_trace(go.Scatterpolar( r=values, theta=labels_closed, fill="toself", fillcolor=f"rgba({_hex_to_rgb(SKY)},0.2)", line=dict(color=SKY, width=2), name=player_row.get("player", "Player"), )) fig.update_layout( template=FANTABETO_TEMPLATE, polar=dict( radialaxis=dict(range=[0, 100], showticklabels=False, gridcolor=GRIDLINE), angularaxis=dict(gridcolor=GRIDLINE, tickfont=dict(size=9, color=TEXT_SECONDARY)), bgcolor=BG, ), height=350, showlegend=False, margin=dict(l=40, r=40, t=20, b=20), ) return fig # ───────────────────────────────────────────────────────────────── # Regression chart (actual vs xG) # ───────────────────────────────────────────────────────────────── def regression_chart(df: pd.DataFrame, player: str, xg_col="xg_p90", goal_col="goals_season", games_col="games_season") -> go.Figure: """Rolling actual goals vs expected with divergence shading.""" actual = df[goal_col].iloc[0] if goal_col in df.columns else 0 xg_val = df[xg_col].iloc[0] if xg_col in df.columns else 0 games = df[games_col].iloc[0] if games_col in df.columns else 1 expected = xg_val * 1.2 * games fig = go.Figure() fig.add_trace(go.Bar( x=["Actual Goals", "Expected (xG * 1.2)"], y=[actual, expected], marker_color=[PITCH_GREEN, SKY], texttemplate="%{y:.1f}", textposition="outside", textfont=dict(color=TEXT, size=13), showlegend=False, )) fig.update_layout( template=FANTABETO_TEMPLATE, height=200, margin=dict(l=10, r=10, t=10, b=10), ) return fig # ───────────────────────────────────────────────────────────────── # Bonus/malus donut # ───────────────────────────────────────────────────────────────── def bonus_donut(goals: float, assists: float, cards_malus: float) -> go.Figure: bonus = goals * 3 + assists * 1 malus = abs(cards_malus) fig = go.Figure(data=[go.Pie( labels=["Goal Bonus", "Assist Bonus", "Card Malus"], values=[goals * 3, assists, malus if malus > 0 else 0.01], hole=0.55, marker_colors=[PITCH_GREEN, SKY, RED], textinfo="label+value", textfont=dict(color=TEXT, size=10), hovertemplate="%{label}: %{value:.1f}", )]) fig.update_layout( template=FANTABETO_TEMPLATE, height=200, showlegend=False, margin=dict(l=0, r=0, t=10, b=10), ) return fig # ───────────────────────────────────────────────────────────────── # Card risk gauge # ───────────────────────────────────────────────────────────────── def card_gauge(yellow_per_game: float, red_per_game: float) -> go.Figure: ypct = min(100, yellow_per_game / 0.5 * 100) rpct = min(100, red_per_game / 0.1 * 100) fig = go.Figure() fig.add_trace(go.Indicator( mode="gauge+number", value=ypct, title={"text": "Yellow Risk", "font": {"size": 11, "color": TEXT_SECONDARY}}, gauge={ "axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY}, "bar": {"color": GOLD}, "bgcolor": CARD_BG, "borderwidth": 0, "steps": [ {"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"}, {"range": [30, 70], "color": f"rgba({_hex_to_rgb(GOLD)},0.15)"}, {"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.15)"}, ], }, number={"font": {"size": 22, "color": WHITE}}, domain={"row": 0, "column": 0}, )) fig.add_trace(go.Indicator( mode="gauge+number", value=rpct, title={"text": "Red Risk", "font": {"size": 11, "color": TEXT_SECONDARY}}, gauge={ "axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY}, "bar": {"color": RED}, "bgcolor": CARD_BG, "borderwidth": 0, "steps": [ {"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"}, {"range": [30, 70], "color": f"rgba({_hex_to_rgb(RED)},0.15)"}, {"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.3)"}, ], }, number={"font": {"size": 22, "color": WHITE}}, domain={"row": 0, "column": 1}, )) fig.update_layout( template=FANTABETO_TEMPLATE, grid={"rows": 1, "columns": 2}, height=180, margin=dict(l=10, r=10, t=30, b=10), ) return fig # ───────────────────────────────────────────────────────────────── # Budget waterfall # ───────────────────────────────────────────────────────────────── def budget_waterfall(allocations: dict) -> go.Figure: """allocations: {'GK': amount, 'DEF': amount, 'MID': amount, 'FWD': amount}""" measures = ["relative", "relative", "relative", "relative", "total"] labels = list(allocations.keys()) + ["Total"] values = list(allocations.values()) + [sum(allocations.values())] fig = go.Figure(go.Waterfall( measure=measures, x=labels, y=values, connector=dict(line=dict(color=BORDER, width=1)), decreasing=dict(marker=dict(color=RED)), increasing=dict(marker=dict(color=PITCH_GREEN)), totals=dict(marker=dict(color=SKY)), text=[f"{v:.0f} cr" for v in values], textposition="outside", )) fig.update_layout( template=FANTABETO_TEMPLATE, height=280, showlegend=False, ) return fig # ───────────────────────────────────────────────────────────────── # Value scatter (FV vs price) # ───────────────────────────────────────────────────────────────── def value_scatter(df: pd.DataFrame) -> go.Figure: """Scatter: fv_avg vs qi, bubble = games_season, labeled steals.""" df = df.copy() df["value_ratio"] = df["fv_avg"] / df["qi"].clip(lower=1) fig = go.Figure() for role, color in ROLE_COLORS.items(): rdf = df[df["role"] == role] if rdf.empty: continue fig.add_trace(go.Scatter( x=rdf["qi"], y=rdf["fv_avg"], mode="markers+text", marker=dict( size=rdf["games_season"].clip(lower=5) / 2, color=color, opacity=0.7, line=dict(width=1, color=BORDER), ), text=rdf["player"].where(rdf["value_ratio"] > rdf["value_ratio"].quantile(0.9), ""), textposition="top center", textfont=dict(size=9, color=TEXT), name=f"{role} ({len(rdf)})", hovertemplate=( "%{text}
" "FV: %{y:.2f}
Price: %{x:.0f}
" "Games: %{marker.size:.0f}" ), )) # Isoline: cost per FV point x_range = [df["qi"].min(), df["qi"].max()] for cpp in [3, 5, 8]: fig.add_trace(go.Scatter( x=x_range, y=[x / cpp for x in x_range], mode="lines", line=dict(dash="dash", color=TEXT_SECONDARY, width=0.5), name=f"{cpp} cr/FV", showlegend=False, )) fig.update_layout( template=FANTABETO_TEMPLATE, height=500, xaxis_title="Quotazione Iniziale (cr)", yaxis_title="Fantavoto Avg (25/26)", hovermode="closest", ) return fig # ───────────────────────────────────────────────────────────────── # Error violin by role # ───────────────────────────────────────────────────────────────── def error_violins(df: pd.DataFrame) -> go.Figure: """Violins of prediction error by role.""" roles = ["P", "D", "C", "A"] fig = go.Figure() for i, role in enumerate(roles): rdf = df[df["role"] == role] if rdf.empty or "error" not in rdf.columns: continue fig.add_trace(go.Violin( y=rdf["error"], name=role, marker=dict(color=ROLE_COLORS.get(role, SKY)), box_visible=True, meanline_visible=True, side="positive" if i % 2 == 0 else "negative", )) fig.update_layout( template=FANTABETO_TEMPLATE, height=300, xaxis=dict(title="Role"), yaxis=dict(title="Prediction Error (FV)"), violingap=0, violinmode="overlay", ) return fig # ───────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────── def _hex_to_rgb(hex_color: str) -> str: h = hex_color.lstrip("#") return ",".join(str(int(h[i:i+2], 16)) for i in (0, 2, 4))