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)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Page 2 — Player Intelligence.
|
||||
|
||||
Radar charts, regression analysis, card risk, RAG news feed.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.warehouse import load_players, load_predictions, load_votes, load_lineups
|
||||
from dashboard.viz.components import inject_css, section, insight, role_chip
|
||||
from dashboard.viz.charts import percentile_radar, regression_chart, bonus_donut, card_gauge
|
||||
from dashboard.viz.template import PITCH_GREEN, GOLD, RED, SKY, ROLE_COLORS, ROLE_ICONS
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
players = load_players()
|
||||
preds = load_predictions()
|
||||
votes = load_votes()
|
||||
lineups = load_lineups()
|
||||
return players, preds, votes, lineups
|
||||
|
||||
|
||||
RADAR_METRICS = [
|
||||
"goals_p90", "assists_p90", "xg_p90", "progressive_passes_p90",
|
||||
"tackles_p90", "interceptions_p90", "shots_on_target_pct",
|
||||
]
|
||||
RADAR_LABELS = ["Goals", "Assists", "xG", "Prog Pass", "Tackles", "Interc.", "SoT%"]
|
||||
|
||||
|
||||
def _get_player_data(players, preds, votes, lineups, player_name):
|
||||
prow = players[players["player"] == player_name]
|
||||
if prow.empty:
|
||||
return None, None, None, None
|
||||
p = prow.iloc[0]
|
||||
|
||||
pred_row = preds[preds["player"] == player_name]
|
||||
if pred_row.empty:
|
||||
pred_row = pd.DataFrame([{"fv_mean": p.get("fv_avg", 6.0)}])
|
||||
|
||||
vote_row = votes[votes["player"] == player_name]
|
||||
line_row = lineups[lineups["player"] == player_name]
|
||||
|
||||
p["starter_pct"] = float(line_row["starter_pct"].values[0]) if not line_row.empty else 50
|
||||
p["fv_projected"] = float(pred_row["fv_mean"].values[0]) if not pred_row.empty else p.get("fv_avg", 6.0)
|
||||
p["games_season"] = p.get("games_season", 0)
|
||||
p["goals_season"] = p.get("goals_season", 0)
|
||||
p["assists_season"] = p.get("assists_season", 0)
|
||||
p["yellow_per_game"] = p.get("yellow_season", 0) / max(p.get("games_season", 1), 1)
|
||||
p["red_per_game"] = p.get("red_season", 0) / max(p.get("games_season", 1), 1)
|
||||
|
||||
return p, pred_row, vote_row, line_row
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Players — Fantabeto", page_icon="👤", layout="wide")
|
||||
inject_css()
|
||||
players, preds, votes, lineups = _get_data()
|
||||
|
||||
st.markdown("## 👤 Player Intelligence")
|
||||
st.caption("Deep-dive into any Serie A player — radar profiles, regression analysis, and news feed.")
|
||||
|
||||
# Search
|
||||
c_search, c_role, c_team = st.columns([3, 1, 1])
|
||||
with c_search:
|
||||
all_players = sorted(players["player"].dropna().unique())
|
||||
selected = st.selectbox("Search player", all_players, key="player_search",
|
||||
placeholder="Type a name...")
|
||||
with c_role:
|
||||
role_filter = st.selectbox("Role", ["All", "P", "D", "C", "A"], index=0)
|
||||
with c_team:
|
||||
teams = sorted(players["team"].dropna().unique())
|
||||
team_filter = st.selectbox("Team", ["All"] + teams, index=0)
|
||||
|
||||
if not selected:
|
||||
st.info("Search for a player above to see their profile.")
|
||||
return
|
||||
|
||||
p, pred_row, vote_row, line_row = _get_player_data(players, preds, votes, lineups, selected)
|
||||
if p is None:
|
||||
st.warning(f"Player '{selected}' not found in database.")
|
||||
return
|
||||
|
||||
# ── Player Header ──
|
||||
st.divider()
|
||||
h1, h2, h3, h4 = st.columns([2, 1, 1, 1])
|
||||
with h1:
|
||||
role = p.get("role", "?")
|
||||
team = p.get("team", "?")
|
||||
st.markdown(f"### {ROLE_ICONS.get(role, '')} {selected}")
|
||||
st.caption(f"{role_chip(role)} {team} · FVM: {p.get('fvm', 0)} · QI: {p.get('qi', 0)} cr")
|
||||
with h2:
|
||||
fv = p.get("fv_avg", 6.0)
|
||||
st.metric("Fantavoto Avg", f"{fv:.2f}", delta=None)
|
||||
with h3:
|
||||
games = p.get("games_season", 0)
|
||||
st.metric("Games (25/26)", f"{games:.0f}")
|
||||
with h4:
|
||||
sp = p.get("starter_pct", 50)
|
||||
st.metric("GW1 Start %", f"{sp:.0f}%")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Radar + Regression ──
|
||||
r1, r2 = st.columns([1, 1])
|
||||
with r1:
|
||||
section("📊 Percentile Radar")
|
||||
role_avg = players[players["role"] == role].mean(numeric_only=True)
|
||||
fig = percentile_radar(p, RADAR_METRICS, RADAR_LABELS, role_avg)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Values normalized vs league average for same role. Outer = better.")
|
||||
|
||||
with r2:
|
||||
section("🎯 Goals vs Expected")
|
||||
pdf = pd.DataFrame([{
|
||||
"goals_season": p.get("goals_season", 0),
|
||||
"xg_p90": p.get("xg_p90", 0) or (p.get("xg_season", 0) / 38) if "xg_season" in p else 0,
|
||||
"games_season": p.get("games_season", 1),
|
||||
}])
|
||||
fig = regression_chart(pdf, selected)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
div = p.get("goals_season", 0) - (p.get("xg_p90", 0) or 0) * p.get("games_season", 1)
|
||||
div_label = "overperforming" if div > 1 else ("underperforming" if div < -1 else "on par with")
|
||||
insight(f"{selected} is {div_label} xG by {abs(div):.1f} goals.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Bonus/Malus + Card Risk ──
|
||||
b1, b2 = st.columns([1, 1])
|
||||
with b1:
|
||||
section("💰 Bonus / Malus Breakdown")
|
||||
goals_26 = p.get("goals_season", 0)
|
||||
assists_26 = p.get("assists_season", 0)
|
||||
yellow = p.get("yellow_season", 0)
|
||||
red_c = p.get("red_season", 0)
|
||||
fig = bonus_donut(goals_26, assists_26, yellow * 0.5 + red_c * 1.0)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight(f"Season totals: {goals_26:.0f}G + {assists_26:.0f}A — {yellow:.0f}🟨 {red_c:.0f}🟥")
|
||||
|
||||
with b2:
|
||||
section("⚠️ Card Risk Gauge")
|
||||
fig = card_gauge(p.get("yellow_per_game", 0), p.get("red_per_game", 0))
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
ypg = p.get("yellow_per_game", 0)
|
||||
if ypg > 0.2:
|
||||
insight(f"⚠️ High yellow risk: {ypg:.2f} per game. Consider rotation in tough fixtures.")
|
||||
else:
|
||||
insight(f"Low card risk: {ypg:.2f} yellows per game.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── RAG News Feed ──
|
||||
section("📰 News & Intelligence", "Live updates from Italian sports press (Gazzetta, Sky, Di Marzio).")
|
||||
st.info("🔌 RAG news pipeline available when `src/features/news_rag.py` is run. "
|
||||
"Shows injury reports, suspensions, tactical shifts, and transfer rumors.")
|
||||
|
||||
# ── Historical votes table ──
|
||||
if vote_row is not None and not vote_row.empty:
|
||||
st.divider()
|
||||
section("📋 Recent Matchday Votes")
|
||||
recent = vote_row.nlargest(10, "matchday")[[
|
||||
"matchday", "vote", "goals", "fantavote"
|
||||
]].sort_values("matchday", ascending=False)
|
||||
recent.columns = ["Matchday", "Vote", "Goals", "Fantavote"]
|
||||
st.dataframe(recent, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user