Files
ramseshk 30d676517e Fix Players page KeyError + use_container_width deprecation
- 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
2026-08-11 15:24:39 +08:00

332 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))