|
1 | 1 | import streamlit as st |
2 | | - |
3 | | -# Modulos internos (Clean Architecture) |
4 | 2 | from src.streamlit.dashboard_data import load_streamlit_data |
5 | | -from src.streamlit.dashboard_plots import ( |
6 | | - graficar_ranking, |
7 | | - graficar_radar, |
8 | | - graficar_heatmap_zonas, |
9 | | - graficar_heatmap_xt, |
10 | | - graficar_red_pases, |
11 | | - graficar_evolucion |
12 | | -) |
13 | | - |
14 | | -# ─────────────────────────────── CONFIG ─────────────────────────────── |
15 | | -st.set_page_config( |
16 | | - page_title="United Passing Intelligence | alvarosalinaso", |
17 | | - page_icon="🏟️", |
18 | | - layout="wide", |
19 | | - initial_sidebar_state="expanded", |
20 | | -) |
| 3 | +from src.streamlit.dashboard_plots import graficar_ranking, graficar_radar, graficar_heatmap_zonas, graficar_heatmap_xt, graficar_red_pases, graficar_evolucion |
| 4 | + |
| 5 | +st.set_page_config(page_title="United Passing Data", page_icon="🏟️", layout="wide", initial_sidebar_state="expanded") |
21 | 6 |
|
22 | 7 | st.markdown(""" |
23 | 8 | <style> |
24 | | -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); |
| 9 | +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap'); |
25 | 10 | html, body, [class*="css"] { font-family: 'Inter', sans-serif; } |
26 | | -.block-container { padding: 1.5rem 2rem; } |
27 | 11 | .stApp { background-color: #0d1117; color: #e6edf3; } |
28 | | -
|
29 | | -/* SIDEBAR & METRICS */ |
30 | | -[data-testid="stSidebar"] { background: #161b22 !important; border-right: 1px solid #30363d; } |
31 | | -[data-testid="metric-container"] { background: #161b22 !important; border: 1px solid #30363d; border-radius: 12px; padding: 1rem; } |
| 12 | +[data-testid="metric-container"] { background: #161b22 !important; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; } |
32 | 13 | [data-testid="stMetricValue"] { color: #58a6ff !important; font-weight: 800 !important; } |
33 | | -[data-testid="stMetricDelta"] { font-weight: 600 !important; } |
34 | | -
|
35 | | -/* Text & Sections */ |
36 | | -h1 { color: #DA291C !important; font-weight: 800 !important; letter-spacing: -1px; } |
37 | | -.section-header { |
38 | | - font-weight: 800; |
39 | | - font-size: 1.8rem; |
40 | | - color: #DA291C; |
41 | | - margin-top: 3rem; |
42 | | - margin-bottom: 0.5rem; |
43 | | - border-bottom: 1px solid #30363d; |
44 | | - padding-bottom: 0.5rem; |
45 | | -} |
46 | | -.section-text { font-size: 1.05rem; color: #8b949e; margin-bottom: 1.5rem; line-height: 1.6; } |
| 14 | +.section-header { font-weight: 800; font-size: 1.8rem; color: #DA291C; margin-top: 3rem; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; } |
| 15 | +.section-text { font-size: 1.05rem; color: #8b949e; margin-bottom: 1.5rem; } |
47 | 16 | </style> |
48 | 17 | """, unsafe_allow_html=True) |
49 | 18 |
|
50 | | -# ─────────────────────────────── DATA LAYER ─────────────────────────── |
51 | | -df, df_time, kpis = load_streamlit_data() |
| 19 | +df_all, df_t, k_dict = load_streamlit_data() |
52 | 20 |
|
53 | | -# ─────────────────────────────── SIDEBAR ────────────────────────────── |
54 | 21 | with st.sidebar: |
55 | | - st.markdown(""" |
56 | | - <div style='text-align:center;margin-bottom:1.2rem;'> |
57 | | - <div style='font-size:3rem;'>🏟️</div> |
58 | | - <div style='font-weight:800;font-size:1.2rem;color:#DA291C;'>United Passing Intelligence</div> |
59 | | - <div style='font-size:.8rem;color:#8b949e;margin-top:.2rem;'>Temporada 2024-25 · Premier League</div> |
60 | | - </div> |
61 | | - <hr> |
62 | | - """, unsafe_allow_html=True) |
63 | | - |
64 | | - st.markdown("**🎛️ Filtros Globales**") |
65 | | - sel_pos = st.selectbox("Posición Táctica", ["Todos"] + sorted(df["pos"].unique().tolist())) |
66 | | - min_apps = st.slider("Partidos Mínimos Jugados", 0, 32, 15) |
67 | | - focus_player = st.selectbox("Jugador en Foco (Radares)", ["Todos"] + df["player"].unique().tolist()) |
| 22 | + st.markdown("<h2 style='color:#DA291C;text-align:center;'>United Passing Stats</h2><hr>", unsafe_allow_html=True) |
68 | 23 |
|
| 24 | + pos_filter = st.selectbox("Posición", ["Todos"] + sorted(df_all["pos"].unique().tolist())) |
| 25 | + min_games = st.slider("Min Partidos", 0, 32, 15) |
| 26 | + f_player = st.selectbox("Foco (Radar)", ["Todos"] + df_all["player"].unique().tolist()) |
| 27 | + |
69 | 28 | st.markdown("---") |
70 | | - st.markdown("**⚔️ Contexto Opositor (Filtro Senior)**") |
71 | | - sel_context = st.radio("Nivel de Presión Rival", ["Resto PL", "Top 6 (Alta Presión)"], help="Audita qué jugador 'infla' sus estadísticas frente a equipos de baja tabal y quién sobrevive a la presión asfixiante de la élite de Inglaterra.") |
72 | | - |
| 29 | + opp_tier = st.radio("Dificultad de Partido", ["Resto PL", "Top 6"]) |
| 30 | + |
73 | 31 | st.markdown("---") |
74 | | - st.markdown("**📌 Métrica de Enfoque Principal**") |
75 | | - metric_map = { |
76 | | - "xT Generado (Ameaza Esperada)": "xT_gen", "Pases Progresivos / 90": "prog_passes", |
77 | | - "Precisión de pases (%)": "pass_acc", "Índice de Verticalidad": "vert_idx", |
78 | | - "Pérdidas / 90": "losses" |
79 | | - } |
80 | | - sel_metric_label = st.selectbox("Seleccionar", list(metric_map.keys())) |
81 | | - sel_metric = metric_map[sel_metric_label] |
82 | | - |
83 | | -# ─────────────────────────────── FILTER ─────────────────────────────── |
84 | | -df_f = df.copy() |
85 | | - |
86 | | -# 1. Filtro Contextual (Crucial para el análisis Senior) |
87 | | -target_tier = sel_context.replace(" (Alta Presión)", "") |
88 | | -df_f = df_f[df_f["opponent_tier"] == target_tier] |
89 | | - |
90 | | -if sel_pos != "Todos": |
91 | | - df_f = df_f[df_f["pos"] == sel_pos] |
92 | | -df_f = df_f[df_f["apps"] >= min_apps] |
93 | | - |
94 | | -# ─────────────────────────────── HERO SECTION ─────────────────────────────── |
95 | | -st.markdown("<h1 style='margin-bottom:0;'>⚽ Manchester United: Midfield Intelligence</h1>", unsafe_allow_html=True) |
96 | | -st.markdown("<p style='font-size:1.1rem; color:#8b949e; margin-top:.2rem;'>Un análisis interactivo del comportamiento en la construcción de juego durante la temporada 2024-25. Evalúa qué mediocampistas rompen líneas, quiénes retienen la posesión, y cómo fluye el balón en Old Trafford.</p><hr>", unsafe_allow_html=True) |
97 | | - |
98 | | -# KPI ROW |
99 | | -k1, k2, k3, k4, k5 = st.columns(5) |
100 | | -k1.metric("Precisión Promedio", f"{df_f['pass_acc'].mean():.1f}%", delta=f"{df_f['pass_acc'].mean() - 87.0:+.1f}% vs liga") |
101 | | -k2.metric("Prog. Passes / 90", f"{df_f['prog_passes'].mean():.1f}", delta=f"{df_f['prog_passes'].mean() - 3.8:+.1f} vs avg") |
102 | | -k3.metric("xT Promedio / 90", f"{df_f['xT_gen'].mean():.3f}", delta="+2.1% vs prev") |
103 | | -avg_vert = df_f['vert_idx'].mean() |
104 | | -k4.metric("Índice Vertical", f"{avg_vert:.2f}", delta=f"{'↑ alto' if avg_vert > 0.65 else '↓ bajo'}") |
105 | | -total_losses = df_f["losses"].mean() |
106 | | -k5.metric("Pérdidas / 90", f"{total_losses:.1f}", delta=f"{-(total_losses - 2.1):+.1f} vs prev", delta_color="inverse") |
107 | | - |
108 | | -st.info("💡 **Metodología Avanzada:** El **xT (Expected Threat)** cuantifica cuánto aumenta la probabilidad de que el United anote un gol producto directo de la zona a la que el jugador envía el pase. El **Índice de Verticalidad** castiga los pases horizontales de seguridad, premiando a los jugadores que asumen riesgos hacia el área rival.") |
109 | | - |
110 | | -# ─────────────────────────────── SECCIÓN 1 ─────────────────────────────── |
111 | | -st.markdown("<div class='section-header'>1. Rankings y Perfiles Multidimensionales 📊</div>", unsafe_allow_html=True) |
112 | | -st.markdown("<div class='section-text'>Todo análisis comienza aislando a los líderes estadísticos. Compara lado a lado cómo la plantilla rinde en la métrica seleccionada en el menú lateral, y evalúa el polígono de rendimiento completo del jugador en foco frente al resto de sus compañeros.</div>", unsafe_allow_html=True) |
113 | | - |
114 | | -col_l, col_r = st.columns([3, 2]) |
115 | | -with col_l: |
116 | | - df_rank = df_f.sort_values(sel_metric, ascending=(sel_metric == "losses")) |
117 | | - st.plotly_chart(graficar_ranking(df_rank, sel_metric, sel_metric_label), use_container_width=True) |
118 | | - |
119 | | -with col_r: |
120 | | - st.plotly_chart(graficar_radar(df_f, focus_player), use_container_width=True) |
121 | | - |
122 | | -with st.expander("Ver tabla completa de datos consolidados"): |
123 | | - display_cols = {"player": "Jugador", "pos": "Pos.", "apps": "PJ", "pass_acc": "Precisión %", "prog_passes": "Prog/90", "key_passes": "Claves/90", "vert_idx": "Verticalidad", "xT_gen": "xT generado", "xA": "xA", "losses": "Pérdidas/90"} |
124 | | - df_disp = df_f[list(display_cols.keys())].rename(columns=display_cols).round(2) |
125 | | - if not df_disp.empty: |
126 | | - cols_numericas = df_disp.select_dtypes(include=['number']).columns.tolist() |
127 | | - try: |
128 | | - styled_df = df_disp.style.background_gradient(cmap='viridis', subset=cols_numericas if cols_numericas else None).format(precision=2) |
129 | | - st.dataframe(styled_df, use_container_width=True, hide_index=True) |
130 | | - except ValueError: |
131 | | - st.dataframe(df_disp, use_container_width=True, hide_index=True) |
132 | | - |
133 | | -# ─────────────────────────────── SECCIÓN 2 ─────────────────────────────── |
134 | | -st.markdown("<div class='section-header'>2. Radiografía Táctica Funcional 🔥</div>", unsafe_allow_html=True) |
135 | | -st.markdown("<div class='section-text'>El fútbol no se juega en una hoja de Excel, sino en el césped. Aisla a un jugador para observar empíricamente en qué zonas específicas del campo ejecuta sus entregas de balón (izquierda) y en qué cuadrantes genera el mayor grado de amenaza xT contra el arco rival (derecha).</div>", unsafe_allow_html=True) |
136 | | - |
137 | | -col_sel, _ = st.columns([2, 5]) |
138 | | -hm_player = col_sel.selectbox("Filtrar jugador para los mapas de calor tácticos", df_f["player"].unique().tolist(), key="hm_player") |
139 | | -player_row = df_f[df_f["player"] == hm_player].iloc[0] |
| 32 | + m_map = {"xT Generado": "xT_gen", "Pases Prog": "prog_passes", "Precisión": "pass_acc", "Verticalidad": "vert_idx", "Pérdidas": "losses"} |
| 33 | + target_stat = st.selectbox("Métrica Foco", list(m_map.keys())) |
| 34 | + sel_var = m_map[target_stat] |
| 35 | + |
| 36 | +# filtro core |
| 37 | +cur_val = opp_tier.replace("Top 6", "Top 6") |
| 38 | +df_work = df_all[df_all["opponent_tier"] == cur_val].copy() |
| 39 | + |
| 40 | +if pos_filter != "Todos": df_work = df_work[df_work["pos"] == pos_filter] |
| 41 | +df_work = df_work[df_work["apps"] >= min_games] |
| 42 | + |
| 43 | +st.title("⚽ United: Foco en la Medular") |
| 44 | +st.markdown("Métricas transaccionales del mediocampo actual. Análisis duro sin fanatismos.") |
| 45 | + |
| 46 | +c1, c2, c3, c4, c5 = st.columns(5) |
| 47 | +c1.metric("Acc%", f"{df_work['pass_acc'].mean():.1f}%") |
| 48 | +c2.metric("ProgP/90", f"{df_work['prog_passes'].mean():.1f}") |
| 49 | +c3.metric("xT/90", f"{df_work['xT_gen'].mean():.3f}") |
| 50 | +c4.metric("Índice Vert", f"{df_work['vert_idx'].mean():.2f}") |
| 51 | +c5.metric("Pérdidas/90", f"{df_work['losses'].mean():.1f}", delta_color="inverse") |
| 52 | + |
| 53 | +st.markdown("<div class='section-header'>1. Leaderboards</div>", unsafe_allow_html=True) |
| 54 | +ca, cb = st.columns([3, 2]) |
| 55 | +with ca: |
| 56 | + r_df = df_work.sort_values(sel_var, ascending=(sel_var == "losses")) |
| 57 | + st.plotly_chart(graficar_ranking(r_df, sel_var, target_stat), use_container_width=True) |
| 58 | +with cb: st.plotly_chart(graficar_radar(df_work, f_player), use_container_width=True) |
| 59 | + |
| 60 | +with st.expander("Tabla Raw"): |
| 61 | + cols = {"player": "Jugador", "pos": "Pos", "apps": "PJ", "pass_acc": "Acc%", "prog_passes": "Prog/90", "vert_idx": "Vert", "xT_gen": "xT", "losses": "Pérdidas"} |
| 62 | + disp = df_work[list(cols.keys())].rename(columns=cols).round(2) |
| 63 | + st.dataframe(disp.style.background_gradient(cmap='viridis', subset=disp.select_dtypes('number').columns).format(precision=2), use_container_width=True, hide_index=True) |
| 64 | + |
| 65 | +st.markdown("<div class='section-header'>2. Zonas de Amenaza (xT)</div>", unsafe_allow_html=True) |
| 66 | +p_sel = st.selectbox("Jugador para heatmap táctico", df_work["player"].unique().tolist()) |
| 67 | +p_row = df_work[df_work["player"] == p_sel].iloc[0] |
140 | 68 |
|
141 | 69 | h1, h2 = st.columns(2) |
142 | | -with h1: |
143 | | - st.plotly_chart(graficar_heatmap_zonas(player_row), use_container_width=True) |
144 | | -with h2: |
145 | | - st.plotly_chart(graficar_heatmap_xt(player_row), use_container_width=True) |
146 | | - |
147 | | -# ─────────────────────────────── SECCIÓN 3 ─────────────────────────────── |
148 | | -st.markdown("<div class='section-header'>3. Red de Asociaciones en el Campo 🕸️</div>", unsafe_allow_html=True) |
149 | | -st.markdown("<div class='section-text'>La fortaleza de un mediocampo radica en sus triángulos de asociación. Observa el flujo microscópico de la pelota entre nuestras figuras. <b>El grosor del cable</b> señala el colosal volumen de pases exitosos completados entre ambos jugadores. <b>El tamaño del nodo</b> de cada jugador simboliza su contribución directa de xT en cada toque.</div>", unsafe_allow_html=True) |
150 | | - |
151 | | -st.plotly_chart(graficar_red_pases(df), use_container_width=True) |
152 | | - |
153 | | -# ─────────────────────────────── SECCIÓN 4 ─────────────────────────────── |
154 | | -st.markdown("<div class='section-header'>4. Consistencia y Evolución a lo largo de la Premier League 📈</div>", unsafe_allow_html=True) |
155 | | -st.markdown("<div class='section-text'>Incluso las mayores estrellas sufren caídas de rendimiento. Compara a tus jugadores favoritos a lo largo de todos los partidos de la temporada usando una <b>Media Móvil de 5 Jornadas</b> que purga el ruido y clarifica la genuina curva de forma de los jugadores. Selecciona libremente qué métrica temporal contrastar.</div>", unsafe_allow_html=True) |
156 | | - |
157 | | -ec1, ec2, _ = st.columns([2, 2, 6]) |
158 | | -with ec1: |
159 | | - players_evo = st.multiselect("Vincular jugadores a comparar", df_f["player"].tolist(), default=df_f["player"].tolist()[:2], key="evo_players") |
160 | | -with ec2: |
161 | | - mevo_label = st.selectbox("Seleccionar Métrica a medir", ["Precisión %", "Pases prog/90", "xT generado"], key="metric_evo") |
162 | | - |
163 | | -mevo_col = {"Precisión %": "pass_acc", "Pases prog/90": "prog_passes", "xT generado": "xT_gen"}[mevo_label] |
| 70 | +with h1: st.plotly_chart(graficar_heatmap_zonas(p_row), use_container_width=True) |
| 71 | +with h2: st.plotly_chart(graficar_heatmap_xt(p_row), use_container_width=True) |
| 72 | + |
| 73 | +st.markdown("<div class='section-header'>3. Red de Flujo</div>", unsafe_allow_html=True) |
| 74 | +st.plotly_chart(graficar_red_pases(df_all), use_container_width=True) |
| 75 | + |
| 76 | +st.markdown("<div class='section-header'>4. Tracking Estacional</div>", unsafe_allow_html=True) |
| 77 | +tr_c1, tr_c2 = st.columns([1,1]) |
| 78 | +with tr_c1: p_track = st.multiselect("Benchmark jugadores", df_work["player"].tolist(), default=df_work["player"].tolist()[:2]) |
| 79 | +with tr_c2: m_track_lb = st.selectbox("Benchmark Métrica", ["Precisión", "Pases Prog", "xT generado"]) |
164 | 80 |
|
165 | | -if players_evo: |
166 | | - st.plotly_chart(graficar_evolucion(df_time, df_f, players_evo, mevo_col, mevo_label), use_container_width=True) |
167 | | -else: |
168 | | - st.warning("Selecciona al menos un jugador para graficar su curva de evolución.") |
| 81 | +m_track_col = {"Precisión": "pass_acc", "Pases Prog": "prog_passes", "xT generado": "xT_gen"}[m_track_lb] |
| 82 | +if p_track: st.plotly_chart(graficar_evolucion(df_t, df_work, p_track, m_track_col, m_track_lb), use_container_width=True) |
| 83 | +else: st.warning("Mete a alguien para trazar la curva.") |
0 commit comments