30d676517e
- Players page: fix regression_chart xg_col default (goals_p90 -> xg_p90) - charts.py: make regression_chart defensive against missing columns - All pages: replace use_container_width=True with width='stretch' - Verified: all 5 pages render with zero errors via Playwright browser test
332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""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<br>GW %{x}: difficulty %{z:.1f}<extra></extra>",
|
||
))
|
||
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}<extra></extra>",
|
||
)])
|
||
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=(
|
||
"<b>%{text}</b><br>"
|
||
"FV: %{y:.2f}<br>Price: %{x:.0f}<br>"
|
||
"Games: %{marker.size:.0f}<extra></extra>"
|
||
),
|
||
))
|
||
|
||
# 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))
|