Files
fantabeto/dashboard/viz/charts.py
T
ramseshk 65f5b66b05 Dashboard: Stadium Night design system, 5-page Streamlit app, warehouse exporter
- src/export/warehouse.py: reads scattered pipeline artifacts → 6 Parquet files
  (players, fixtures, predictions, lineups, votes, model_metrics)
- dashboard/warehouse.py: read-only cached Parquet loader
- .streamlit/config.toml: dark theme base, server config
- dashboard/viz/template.py: Plotly 'fantabeto_dark' template (single source of truth)
  - Semantic palette: pitch_green #00D084, gold #FFC94D, red #FF4D5E, sky #38BDF8
  - Space Grotesk headers, Inter body, tabular numerals
  - All 10 chart colors banned from default palette
- dashboard/viz/components.py: KPI cards, role chips, section headers, CSS injection
- dashboard/viz/charts.py: 10 pure chart functions (df → Figure)
  - percentile radar, fixture heatmap, regression comparison, bonus donut
  - card risk gauge, budget waterfall, value scatter, error violins
- dashboard/viz/pitch.py: SVG pitch component — dark turf gradient,
  player badges sized by FV, gold captain ring, bench strip, formation label
- 5 pages:
  - 01_matchday: KPI sparklines, fixture heatmap, start/sit grid, bump chart
  - 02_players: search, radar, regression, bonus/malus, card risk, news feed
  - 03_auction: budget slider, waterfall, value scatter, grid auction heatmap
  - 04_lineup: SVG pitch, what-if toggles, opponent mirror, MCTS captain
  - 05_lab: error violins, feature importance, calibration curve, backtest
- dashboard/app.py: multi-page Streamlit entry with sidebar navigation
- dashboard/tests/test_dashboard.py: 18 unit tests (warehouse, template, charts, pitch)
- DASHBOARD.md: full architecture docs, design system reference
- 49 total tests passing (31 existing + 18 dashboard)
2026-08-11 14:53:51 +08:00

329 lines
14 KiB
Python
Raw 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="goals_p90", goal_col="goals_season",
games_col="games_season") -> go.Figure:
"""Rolling actual goals vs expected with divergence shading.
Uses season-level totals as static chart.
"""
fig = go.Figure()
fig.add_trace(go.Bar(
x=["Actual Goals", "Expected (xG * 1.2)"],
y=[df[goal_col].iloc[0], df[xg_col].iloc[0] * 1.2 * df[games_col].iloc[0]],
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))