from __future__ import annotations import os from datetime import datetime import pandas as pd import plotly.express as px import plotly.graph_objects as go import requests import streamlit as st from data_access import load_gold_table st.set_page_config(page_title="WorldCup Pulse Overview", page_icon="⚽", layout="wide", initial_sidebar_state="expanded") # --------------------------------------------------------------------------- # DATA DEPENDENCIES # # Tables already used by the original dashboard (no ETL change needed): # kpi_summary.parquet, goals_by_matchday.parquet, goals_by_minute_bucket.parquet, # host_cities.parquet, team_key_metrics.parquet, team_radar_stats.parquet, # top_players.parquet # # NEW tables this revision can use if present (every page degrades gracefully # with an in-app "waiting on ETL" notice + documented schema if a table is # missing, so nothing here can crash the app): # # matches.parquet # match_id, matchday, stage, group, match_date, kickoff_local, venue, city, # home_team, home_flag, away_team, away_flag, home_score, away_score, # home_xg, away_xg, attendance, status ("completed"|"live"|"scheduled") # # group_standings.parquet # group, team, flag, played, won, drawn, lost, goals_for, goals_against, # goal_diff, points, qualification_status ("qualified"|"in_contention"|"eliminated") # # match_events.parquet (powers the Match Detail timeline, shot map, and the # Tournament Patterns tab) # match_id, minute, half (1|2|3 for ET), event_type # ("goal"|"penalty_goal"|"var_goal"|"own_goal"|"yellow_card"|"red_card"), # team, player, assist_player, shot_x, shot_y (0-100 pitch coords, goals only) # NOTE: "team" on a goal event = the team CREDITED with the goal # (an own goal is credited to the attacking/benefiting side). # # substitutions.parquet # match_id, team, minute, player_off, player_on # # lineups.parquet # match_id, team, player, position, shirt_number, is_starting (bool) # # goalkeepers.parquet # player, team, saves, save_pct, penalties_saved, clean_sheets, goals_conceded # # match_player_stats.parquet (granular per-player-per-match form data) # match_id, player, team, stage, matchday, minutes_played, goals, assists, # rating, distance_km, sprint_speed_kmh, pass_accuracy_pct, tackles, interceptions # # OPTIONAL new columns on EXISTING tables (all read with safe .get(), so missing # columns simply show "N/A" instead of breaking anything): # kpi_summary.parquet + matches_remaining, total_yellow_cards, total_red_cards, # penalties_awarded, var_goals # team_key_metrics.parquet + goals_against, clean_sheets, setpiece_goals # top_players.parquet + position, rating, distance_km, sprint_speed_kmh, # pass_accuracy_pct, tackles, interceptions # --------------------------------------------------------------------------- CSS = """ """ st.markdown(CSS, unsafe_allow_html=True) PLOTLY_TEMPLATE = "plotly_dark" NEON = "#00F5A0" CYAN = "#00D4FF" INDIGO = "#3454FF" PINK = "#FF3CAC" AMBER = "#FFD166" PANEL = "#08111F" TEXT = "#F8FBFF" # Reference points only — public record, not pulled from the lakehouse. # Source: FIFA / Statista (Transfermarkt) goals-per-match by edition. HISTORICAL_GOALS_PER_MATCH = {"2014": 2.67, "2018": 2.64, "2022": 2.69} # --------------------------------------------------------------------------- # Data + small UI helpers # --------------------------------------------------------------------------- def safe_load(table: str) -> tuple[pd.DataFrame, bool]: """Load a gold table without ever crashing a page if it isn't there yet.""" try: df = load_gold_table(table) return df, (df is not None and not df.empty) except Exception: return pd.DataFrame(), False def trigger_etl() -> tuple[bool, str]: repo = os.environ.get("GITHUB_REPO", "") token = os.environ.get("GITHUB_TOKEN", "") if not repo or not token: return False, "Missing GITHUB_REPO or GITHUB_TOKEN Space secret." try: r = requests.post( f"https://api.github.com/repos/{repo}/dispatches", headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "User-Agent": "worldcup-pulse-space"}, json={"event_type": "run-etl", "client_payload": {"source": "space_button"}}, timeout=20, ) return r.ok, f"GitHub dispatch status={r.status_code}" except Exception as exc: return False, str(exc) def hero(title: str, subtitle: str): st.markdown(f'

{title}

{subtitle}

', unsafe_allow_html=True) def fig_layout(fig): fig.update_layout( template=PLOTLY_TEMPLATE, paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(8,17,31,.55)", font_color=TEXT, margin=dict(l=10, r=10, t=45, b=10), legend=dict(orientation="h", y=1.05), ) return fig def card(label: str, value: str, icon: str, sub: str = "live gold mart"): st.markdown(f'
{icon} {label}
{value}
{sub}
', unsafe_allow_html=True) def progress(label: str, value: float, max_value: float = 100, suffix: str = ""): pct = max(0, min(100, value / max_value * 100 if max_value else 0)) st.markdown(f'
{label}
{value}{suffix}
', unsafe_allow_html=True) def insight(title: str, body: str): st.markdown(f'
{title}
{body}
', unsafe_allow_html=True) def missing_data_notice(table: str, what_it_unlocks: str = ""): st.markdown( f'
Waiting on data — ' f'{table} hasn\'t landed in the gold layer yet. {what_it_unlocks}
', unsafe_allow_html=True, ) def result_letter_pill(letter: str) -> str: cls = {"W": "pill-w", "D": "pill-d", "L": "pill-l"}.get(letter, "pill-d") return f'{letter}' def status_pill(status: str) -> str: s = (status or "").lower() if s == "live": return '● LIVE' if s == "completed": return 'FT' return 'UPCOMING' def render_match_card(row: pd.Series): home_flag = row.get("home_flag", "") or "" away_flag = row.get("away_flag", "") or "" hs = row.get("home_score", None) as_ = row.get("away_score", None) score = f"{int(hs)} — {int(as_)}" if pd.notna(hs) and pd.notna(as_) else "vs" meta_bits = [str(row.get(k)) for k in ("venue", "city", "match_date") if pd.notna(row.get(k)) and row.get(k) not in ("", None)] meta = " · ".join(meta_bits) st.markdown( f'''
{home_flag} {row.get("home_team", "")}
{score}
{row.get("away_team", "")} {away_flag}
{status_pill(row.get("status", ""))}   {meta}
''', unsafe_allow_html=True, ) def render_standings_table(gdf: pd.DataFrame): rows_html = "" for _, r in gdf.iterrows(): zone = "qzone" if str(r.get("qualification_status", "")).lower() == "qualified" else "" gd = int(r.get("goal_diff", 0) or 0) rows_html += ( f'' f'{r.get("flag", "")} {r.get("team", "")}' f'{int(r.get("played", 0) or 0)}' f'{int(r.get("won", 0) or 0)}' f'{int(r.get("drawn", 0) or 0)}' f'{int(r.get("lost", 0) or 0)}' f'{gd:+d}' f'{int(r.get("points", 0) or 0)}' f'' ) st.markdown( f'''
{rows_html}
TeamPWDLGDPts
''', unsafe_allow_html=True, ) # --------------------------------------------------------------------------- # Sidebar # --------------------------------------------------------------------------- def sidebar() -> str: st.sidebar.markdown("### WorldCup Pulse") pages = [ "⚽ Overview", "🏟️ Match Center", "🏆 Group Standings", "📊 Team Intelligence", "🥇 Player Leaderboard", "🌆 Venues & Cities", ] page = st.sidebar.radio("Navigation", pages, label_visibility="collapsed") matches_df, ok = safe_load("matches.parquet") if ok and "status" in matches_df.columns: live_n = int((matches_df["status"].astype(str).str.lower() == "live").sum()) if live_n: label = "match" if live_n == 1 else "matches" st.sidebar.markdown(f'
🔴 {live_n} {label} live now
', unsafe_allow_html=True) st.sidebar.markdown('', unsafe_allow_html=True) if st.sidebar.button("Trigger ETL Pipeline", use_container_width=True): ok2, msg = trigger_etl() st.toast(("✅ " if ok2 else "⚠️ ") + msg) st.sidebar.caption("Data source: HF Dataset lakehouse · cache ttl 300s") return page # --------------------------------------------------------------------------- # Page: Overview (tournament level) # --------------------------------------------------------------------------- def overview(): hero("⚽ WorldCup Pulse Lakehouse", "Near-real-time football analytics powered by Cloudflare cron, GitHub Actions, Hugging Face Dataset storage, DuckDB and Streamlit.") kpi = load_gold_table("kpi_summary.parquet").iloc[0].to_dict() cols = st.columns(8) data = [ ("Matches Played", kpi.get("matches_played", 0), "📅"), ("Total Goals", kpi.get("total_goals", 0), "🥅"), ("Avg Goals/Match", kpi.get("avg_goals_per_match", 0), "📈"), ("Biggest Win", kpi.get("biggest_win", "N/A"), "🔥"), ("Most Offensive", kpi.get("most_offensive_team", "N/A"), "⚡"), ("Most Defensive", kpi.get("most_defensive_team", "N/A"), "🛡️"), ("Avg Possession", f"{kpi.get('avg_possession', 0)}%", "🎛️"), ("Cards/Match", kpi.get("cards_per_match", 0), "🟨"), ] for c, item in zip(cols, data): with c: card(item[0], str(item[1]), item[2]) st.markdown("#### Discipline & Set Pieces") d_cols = st.columns(5) d_data = [ ("Matches Remaining", kpi.get("matches_remaining", "N/A"), "⏳"), ("Yellow Cards", kpi.get("total_yellow_cards", "N/A"), "🟨"), ("Red Cards", kpi.get("total_red_cards", "N/A"), "🟥"), ("Penalties Awarded", kpi.get("penalties_awarded", "N/A"), "🎯"), ("VAR Goals", kpi.get("var_goals", "N/A"), "📺"), ] for c, item in zip(d_cols, d_data): with c: card(item[0], str(item[1]), item[2], sub="if tracked by ETL") metrics = load_gold_table("team_key_metrics.parquet") minute = load_gold_table("goals_by_minute_bucket.parquet") players, players_ok = safe_load("top_players.parquet") st.markdown("#### 🔍 Tournament Storylines") ic1, ic2, ic3, ic4 = st.columns(4) with ic1: if {"goals_for", "xg", "team"}.issubset(metrics.columns): m2 = metrics.copy() m2["finishing_delta"] = m2["goals_for"] - m2["xg"] top = m2.sort_values("finishing_delta", ascending=False).iloc[0] insight("Most Clinical", f"{top.team} have scored {top.finishing_delta:+.1f} goals above their xG — ruthless in front of net.") with ic2: if {"cards", "team"}.issubset(metrics.columns): calm = metrics.sort_values("cards").iloc[0] insight("Most Disciplined", f"{calm.team} average just {calm.cards} cards per match, the cleanest record so far.") with ic3: if not minute.empty and {"minute_bucket", "goals"}.issubset(minute.columns): peak = minute.sort_values("goals", ascending=False).iloc[0] insight("Peak Drama Window", f"The {peak.minute_bucket} minute bucket has the most goals ({peak.goals}) — that's when matches tend to swing.") with ic4: if players_ok and {"goals", "assists", "player"}.issubset(players.columns): p2 = players.copy() p2["impact"] = p2["goals"].fillna(0) + p2["assists"].fillna(0) star = p2.sort_values("impact", ascending=False).iloc[0] insight("Golden Boot Pace", f"{star.player} leads all contributors with {int(star.goals)} goals and {int(star.assists)} assists.") st.markdown("#### Attacking vs Defensive Trend") avg_now = kpi.get("avg_goals_per_match") if avg_now is not None: baseline = sum(HISTORICAL_GOALS_PER_MATCH.values()) / len(HISTORICAL_GOALS_PER_MATCH) diff = float(avg_now) - baseline tilt = "more attacking than" if diff > 0.05 else ("more defensive than" if diff < -0.05 else "right in line with") insight("Tournament Tempo", f"At {float(avg_now):.2f} goals/match, 2026 is running {tilt} the 2014–2022 average of {baseline:.2f}.") combo = pd.DataFrame( { "edition": list(HISTORICAL_GOALS_PER_MATCH.keys()) + ["2026 (current)"], "goals_per_match": list(HISTORICAL_GOALS_PER_MATCH.values()) + [float(avg_now)], } ) fig = px.bar(combo, x="edition", y="goals_per_match", color="edition", title="Goals per Match — 2026 vs Recent Editions", color_discrete_sequence=[INDIGO, CYAN, PINK, NEON]) fig.update_layout(showlegend=False) st.plotly_chart(fig_layout(fig), use_container_width=True) left, right = st.columns([1.2, 1]) with left: df = load_gold_table("goals_by_matchday.parquet") fig = px.area(df, x="matchday", y="goals", markers=True, title="Goals Trend by Matchday") fig.update_traces(line=dict(color=NEON, width=3), fillcolor="rgba(0,245,160,.18)") fig.update_xaxes(title="Matchday", gridcolor="rgba(157,180,200,.12)") fig.update_yaxes(title="Goals", gridcolor="rgba(157,180,200,.12)") st.plotly_chart(fig_layout(fig), use_container_width=True) with right: heat = pd.DataFrame([minute.set_index("minute_bucket")["goals"].to_dict()], index=["Goals"]) fig = go.Figure(data=go.Heatmap(z=heat.values, x=heat.columns, y=heat.index, colorscale=[[0, "#08111F"], [.5, CYAN], [1, NEON]], text=heat.values, texttemplate="%{text}", hovertemplate="%{x}: %{z} goals")) fig.update_layout(title="Goals by Minute Bucket") st.plotly_chart(fig_layout(fig), use_container_width=True) left2, right2 = st.columns([1.25, .75]) with left2: cities = load_gold_table("host_cities.parquet") fig = px.scatter_mapbox(cities, lat="lat", lon="lon", size="matches", color="country", hover_name="city", hover_data=["stadium", "matches"], zoom=2.1, height=430, title="Host City Map — North America") fig.update_layout(mapbox_style="carto-darkmatter", mapbox=dict(center={"lat": 38, "lon": -96}), margin=dict(l=0, r=0, t=45, b=0)) st.plotly_chart(fig_layout(fig), use_container_width=True) with right2: team = metrics.sort_values("goals_for", ascending=True).tail(10) fig = px.bar(team, x="goals_for", y="team", orientation="h", title="Top Offensive Teams", color="goals_for", color_continuous_scale=[INDIGO, CYAN, NEON]) st.plotly_chart(fig_layout(fig), use_container_width=True) st.markdown("#### Performance Analytics") a1, a2 = st.columns(2) with a1: m3 = metrics.copy() m3["finishing_delta"] = m3["goals_for"] - m3["xg"] m3 = m3.sort_values("finishing_delta") fig = px.bar(m3, x="finishing_delta", y="team", orientation="h", title="Clinical Finishing — Goals Scored vs Expected (xG)", color="finishing_delta", color_continuous_scale=[PINK, INDIGO, NEON]) fig.add_vline(x=0, line_dash="dot", line_color="#9DB4C8") st.plotly_chart(fig_layout(fig), use_container_width=True) with a2: fig = px.scatter(metrics, x="fifa_rank", y="goals_for", size="xg", color="possession_pct", hover_name="team", title="Giant Killers — FIFA Rank vs Goals Scored", color_continuous_scale=[INDIGO, CYAN, NEON]) fig.update_xaxes(autorange="reversed", title="FIFA Rank (lower = stronger)") fig.update_yaxes(title="Goals For") st.plotly_chart(fig_layout(fig), use_container_width=True) # --------------------------------------------------------------------------- # Page: Match Center (match level) — Fixtures, Match Detail, Tournament Patterns # --------------------------------------------------------------------------- def match_center(): hero("🏟️ Match Center", "Live scores, fixtures, match-level breakdowns, and tournament-wide in-game patterns.") df, ok = safe_load("matches.parquet") if not ok: missing_data_notice("matches.parquet", "Once populated: live scores, fixtures, goal timelines, shot maps, and substitution tracking.") return events_df, events_ok = safe_load("match_events.parquet") subs_df, subs_ok = safe_load("substitutions.parquet") lineups_df, lineups_ok = safe_load("lineups.parquet") tab_fixtures, tab_detail, tab_patterns = st.tabs(["📋 Fixtures & Results", "🔍 Match Detail", "📐 Tournament Patterns"]) # --- Fixtures & Results ------------------------------------------------- with tab_fixtures: f1, f2, f3 = st.columns(3) with f1: stages = ["All"] + sorted(df["stage"].dropna().unique().tolist()) if "stage" in df.columns else ["All"] stage_sel = st.selectbox("Stage", stages, key="mc_stage") with f2: groups_ = ["All"] + sorted(df["group"].dropna().unique().tolist()) if "group" in df.columns else ["All"] group_sel = st.selectbox("Group", groups_, key="mc_group") with f3: status_sel = st.selectbox("Status", ["All", "completed", "live", "scheduled"], key="mc_status") view = df.copy() if stage_sel != "All" and "stage" in view.columns: view = view[view["stage"] == stage_sel] if group_sel != "All" and "group" in view.columns: view = view[view["group"] == group_sel] if status_sel != "All" and "status" in view.columns: view = view[view["status"].astype(str).str.lower() == status_sel] if {"home_score", "away_score"}.issubset(view.columns): completed = view[view["status"].astype(str).str.lower() == "completed"] if "status" in view.columns else view.dropna(subset=["home_score", "away_score"]) if not completed.empty: completed = completed.copy() completed["total_goals"] = completed["home_score"] + completed["away_score"] ic1, ic2, ic3 = st.columns(3) with ic1: highest = completed.sort_values("total_goals", ascending=False).iloc[0] insight("Highest-Scoring Match", f"{highest.home_team} {int(highest.home_score)}–{int(highest.away_score)} {highest.away_team} — {int(highest.total_goals)} goals.") with ic2: avg_goals = completed["total_goals"].mean() insight("Avg Goals (filtered)", f"{avg_goals:.2f} goals per match across {len(completed)} completed fixtures shown.") with ic3: if {"home_xg", "away_xg"}.issubset(completed.columns): completed["xg_total"] = completed["home_xg"] + completed["away_xg"] completed["upset_index"] = (completed["total_goals"] - completed["xg_total"]).abs() upset = completed.sort_values("upset_index", ascending=False).iloc[0] insight("Biggest xG Surprise", f"{upset.home_team} vs {upset.away_team} deviated most from the expected-goals script.") st.markdown("#### Fixtures & Results") if "match_date" in view.columns: view = view.sort_values("match_date") if view.empty: st.info("No matches found for this filter combination.") else: for _, row in view.iterrows(): render_match_card(row) # --- Match Detail -------------------------------------------------------- with tab_detail: if df.empty: st.info("No matches available yet.") else: opts = df.sort_values("match_date") if "match_date" in df.columns else df labels = [f"{r.home_team} vs {r.away_team} — {r.get('match_date', '')}" for _, r in opts.iterrows()] idx = st.selectbox("Pick a match", range(len(labels)), format_func=lambda i: labels[i], key="mc_detail_pick") match_row = opts.iloc[idx] render_match_card(match_row) match_id = match_row.get("match_id") if not events_ok: missing_data_notice("match_events.parquet", "Once populated: a minute-by-minute goal/card timeline and a shot map for this match.") else: match_events = events_df[events_df["match_id"] == match_id] if "match_id" in events_df.columns else pd.DataFrame() if match_events.empty: st.info("No event data recorded for this match yet.") else: st.markdown("##### Match Timeline") fig = px.scatter(match_events, x="minute", y="team", color="team", symbol="event_type", hover_name="player", height=240) fig.update_traces(marker=dict(size=14, line=dict(width=1, color="#020617"))) fig.update_xaxes(title="Minute", range=[0, max(95, match_events["minute"].max() + 5)]) fig.update_yaxes(title="") st.plotly_chart(fig_layout(fig), use_container_width=True) shots = match_events[match_events["shot_x"].notna()] if "shot_x" in match_events.columns else pd.DataFrame() if not shots.empty: st.markdown("##### Shot Map (goals)") shot_fig = go.Figure() shot_fig.add_shape(type="rect", x0=0, y0=0, x1=100, y1=100, line=dict(color="#1E3A5F")) shot_fig.add_shape(type="line", x0=50, y0=0, x1=50, y1=100, line=dict(color="#1E3A5F", dash="dot")) for tname, tdf in shots.groupby("team"): shot_fig.add_trace(go.Scatter(x=tdf["shot_x"], y=tdf["shot_y"], mode="markers", name=tname, marker=dict(size=13, symbol="star"))) shot_fig.update_xaxes(visible=False, range=[0, 100]) shot_fig.update_yaxes(visible=False, range=[0, 100]) shot_fig.update_layout(height=320, title="Goal Locations") st.plotly_chart(fig_layout(shot_fig), use_container_width=True) if subs_ok and "match_id" in subs_df.columns: match_subs = subs_df[subs_df["match_id"] == match_id] if not match_subs.empty: st.markdown("##### Substitutions") for _, srow in match_subs.sort_values("minute").iterrows(): st.markdown(f"`{int(srow.minute)}'` **{srow.team}** — {srow.get('player_off', '?')} ➜ {srow.get('player_on', '?')}") elif not subs_ok: missing_data_notice("substitutions.parquet", "Once populated: substitution timing and personnel for this match.") if lineups_ok and "match_id" in lineups_df.columns: match_lineups = lineups_df[lineups_df["match_id"] == match_id] if not match_lineups.empty: st.markdown("##### Starting XI") starters = match_lineups[match_lineups["is_starting"] == True] if "is_starting" in match_lineups.columns else match_lineups show_cols = [c for c in ["shirt_number", "player", "position"] if c in starters.columns] l1, l2 = st.columns(2) home_name, away_name = match_row.get("home_team"), match_row.get("away_team") with l1: st.markdown(f"**{home_name}**") hxi = starters[starters["team"] == home_name][show_cols] if "team" in starters.columns and show_cols else pd.DataFrame() st.dataframe(hxi, use_container_width=True, hide_index=True) with l2: st.markdown(f"**{away_name}**") axi = starters[starters["team"] == away_name][show_cols] if "team" in starters.columns and show_cols else pd.DataFrame() st.dataframe(axi, use_container_width=True, hide_index=True) elif not lineups_ok: missing_data_notice("lineups.parquet", "Once populated: starting XI and bench for each match.") # --- Tournament Patterns -------------------------------------------------- with tab_patterns: if not events_ok: missing_data_notice("match_events.parquet", "Once populated: late-goal share and lead-holding win rate across the whole tournament.") else: p1, p2 = st.columns(2) with p1: goal_events = events_df[events_df["event_type"].astype(str).str.contains("goal", case=False, na=False)] if "event_type" in events_df.columns else pd.DataFrame() if not goal_events.empty and {"minute", "half"}.issubset(goal_events.columns): late = goal_events[((goal_events["half"] == 1) & (goal_events["minute"] >= 30)) | ((goal_events["half"] == 2) & (goal_events["minute"] >= 75))] share = len(late) / len(goal_events) * 100 if len(goal_events) else 0 insight("Late-Goal Drama", f"{share:.0f}% of all goals are scored in the final 15 minutes of each half.") else: missing_data_notice("match_events.parquet (minute, half columns)", "") with p2: required_events = {"match_id", "minute", "team", "event_type"} required_matches = {"match_id", "home_team", "away_team", "home_score", "away_score"} if required_events.issubset(events_df.columns) and required_matches.issubset(df.columns): held, total = 0, 0 for _, m in df.dropna(subset=["home_score", "away_score"]).iterrows(): mid = m.get("match_id") mev = events_df[(events_df["match_id"] == mid) & (events_df["event_type"].astype(str).str.contains("goal", case=False, na=False)) & (events_df["minute"] <= 70)] if mev.empty: continue hg = int((mev["team"] == m["home_team"]).sum()) ag = int((mev["team"] == m["away_team"]).sum()) if hg == ag: continue leader = m["home_team"] if hg > ag else m["away_team"] if m["home_score"] == m["away_score"]: continue winner = m["home_team"] if m["home_score"] > m["away_score"] else m["away_team"] total += 1 if leader == winner: held += 1 if total: insight("Holding the Lead", f"Teams ahead after the 70th minute go on to win {held / total * 100:.0f}% of the time ({held}/{total} matches analyzed).") else: st.caption("Not enough completed matches with a clear 70th-minute leader yet.") # --------------------------------------------------------------------------- # Page: Group Standings (tournament level) # --------------------------------------------------------------------------- def standings(): hero("🏆 Group Standings", "Qualification picture across every group, with knockout zones highlighted.") df, ok = safe_load("group_standings.parquet") if not ok: missing_data_notice("group_standings.parquet", "Once populated: every group renders as a live table with qualification zones highlighted, plus a points-race chart.") return if "group" not in df.columns: st.dataframe(df, use_container_width=True, hide_index=True) return groups_ = sorted(df["group"].dropna().unique().tolist()) per_row = 3 for i in range(0, len(groups_), per_row): row_groups = groups_[i:i + per_row] cols = st.columns(len(row_groups)) for c, g in zip(cols, row_groups): with c: st.markdown(f"**Group {g}**") gdf = df[df["group"] == g].sort_values(["points", "goal_diff"], ascending=False) render_standings_table(gdf) if {"team", "points"}.issubset(df.columns): st.markdown("#### Points Race — Top 12") top = df.sort_values("points", ascending=False).head(12) fig = px.bar(top, x="points", y="team", orientation="h", color="points", color_continuous_scale=[INDIGO, CYAN, NEON], title="Highest Points Across All Groups") st.plotly_chart(fig_layout(fig), use_container_width=True) # --------------------------------------------------------------------------- # Page: Team Intelligence (team level) # --------------------------------------------------------------------------- def team_intelligence(): radar = load_gold_table("team_radar_stats.parquet") metrics = load_gold_table("team_key_metrics.parquet") players = load_gold_table("top_players.parquet") matches_df, matches_ok = safe_load("matches.parquet") teams = metrics[["team_id", "team", "flag", "fifa_rank"]].drop_duplicates().sort_values("team") selected = st.selectbox("Select team", teams["team"].tolist(), index=0) meta = teams[teams["team"].eq(selected)].iloc[0] form_html = 'form data pending' if matches_ok and {"home_team", "away_team", "home_score", "away_score"}.issubset(matches_df.columns): team_matches = matches_df[(matches_df["home_team"] == selected) | (matches_df["away_team"] == selected)].copy() if "match_date" in team_matches.columns: team_matches = team_matches.sort_values("match_date") letters = [] for _, mrow in team_matches.tail(5).iterrows(): gf, ga = (mrow["home_score"], mrow["away_score"]) if mrow["home_team"] == selected else (mrow["away_score"], mrow["home_score"]) if pd.isna(gf) or pd.isna(ga): continue letters.append("W" if gf > ga else ("L" if gf < ga else "D")) if letters: form_html = " ".join(result_letter_pill(l) for l in letters) st.markdown(f'

{meta.flag} {selected}

FIFA Rank #{int(meta.fifa_rank)} · Recent form {form_html} · Tactical intelligence view

', unsafe_allow_html=True) r = radar[radar["team"].eq(selected)].iloc[0] m = metrics[metrics["team"].eq(selected)].iloc[0] c1, c2, c3 = st.columns([1, 1, 1.15]) with c1: cats = ["Attack", "Defense", "Possession", "Passing", "Discipline"] vals = [r.attack, r.defense, r.possession, r.passing, r.discipline] avg_r = radar[["attack", "defense", "possession", "passing", "discipline"]].mean() fig = go.Figure() fig.add_trace(go.Scatterpolar(r=avg_r.tolist() + [avg_r.tolist()[0]], theta=cats + [cats[0]], line=dict(color=CYAN, width=2, dash="dot"), fillcolor="rgba(0,212,255,.06)", fill="toself", name="Tournament Avg")) fig.add_trace(go.Scatterpolar(r=vals + [vals[0]], theta=cats + [cats[0]], fill="toself", line=dict(color=NEON, width=3), fillcolor="rgba(0,245,160,.18)", name=selected)) fig.update_layout(title="Radar Profile vs Tournament Average", polar=dict(bgcolor="rgba(8,17,31,.7)", radialaxis=dict(visible=True, range=[0, 100], gridcolor="rgba(157,180,200,.15)"), angularaxis=dict(gridcolor="rgba(157,180,200,.15)")), showlegend=True) st.plotly_chart(fig_layout(fig), use_container_width=True) with c2: st.markdown("#### Key Performance Metrics") progress("Expected Goals", round(float(m.xg), 2), 3, " xG") progress("Shots / Match", round(float(m.shots_per_match), 1), 20, "") progress("Possession", round(float(m.possession_pct), 1), 100, "%") progress("Pass Accuracy", round(float(m.pass_accuracy_pct), 1), 100, "%") progress("Discipline Index", max(0, round(100 - float(m.cards) * 4, 1)), 100, "%") with c3: st.markdown("#### Player Contribution") p = players[players["team"].eq(selected)].sort_values(["goals", "xg"], ascending=False).head(5).copy() if p.empty: p = players.sort_values(["goals", "xg"], ascending=False).head(5).copy() p["impact"] = (p["goals"].fillna(0) * 20 + p["assists"].fillna(0) * 12 + p["xg"].fillna(0) * 10).clip(0, 100) st.dataframe(p[["player", "goals", "assists", "xg", "impact"]], use_container_width=True, hide_index=True, column_config={"impact": st.column_config.ProgressColumn("Impact", min_value=0, max_value=100, format="%.0f")}) st.markdown("#### Defensive & Set-Piece Profile") d1, d2, d3 = st.columns(3) with d1: card("Clean Sheets", str(m.get("clean_sheets", "N/A")), "🧱", "season to date") with d2: card("Goals Against", str(m.get("goals_against", "N/A")), "🥅", "season to date") with d3: card("Set-Piece Goals", str(m.get("setpiece_goals", "N/A")), "🎯", "corners + free kicks") st.markdown("#### Efficiency Checks") e1, e2 = st.columns(2) with e1: delta = float(m.goals_for) - float(m.xg) if delta < -0.5: insight("Finishing Concern", f"{selected} are underperforming their underlying chances by {abs(delta):.1f} xG — creating the openings, missing the finish.") elif delta > 0.5: insight("Finishing Strength", f"{selected} are overperforming their xG by {delta:+.1f} — clinical relative to chance quality.") else: insight("Finishing in Line with xG", f"{selected}'s goal output ({m.goals_for}) closely tracks their underlying chance quality ({float(m.xg):.1f} xG).") with e2: poss_rank = int(metrics["possession_pct"].rank(ascending=False, method="min")[metrics["team"] == selected].iloc[0]) goals_rank = int(metrics["goals_for"].rank(ascending=False, method="min")[metrics["team"] == selected].iloc[0]) if poss_rank <= 10 and goals_rank > 15: insight("Possession Without Punch", f"{selected} rank #{poss_rank} for possession but only #{goals_rank} for goals — control isn't converting into a scoreboard edge.") else: insight("Possession-to-Goals Balance", f"Possession rank #{poss_rank} lines up reasonably with goal-scoring rank #{goals_rank}.") if matches_ok and {"home_team", "away_team", "home_score", "away_score", "stage"}.issubset(matches_df.columns): team_matches = matches_df[(matches_df["home_team"] == selected) | (matches_df["away_team"] == selected)].copy() team_matches["gf"] = team_matches.apply(lambda row: row["home_score"] if row["home_team"] == selected else row["away_score"], axis=1) gk_split = team_matches.dropna(subset=["gf"]).groupby("stage")["gf"].mean().reset_index() if not gk_split.empty: st.markdown("#### Group Stage vs Knockout Form") fig = px.bar(gk_split, x="stage", y="gf", color="stage", title=f"{selected} — Avg Goals Scored by Stage", color_discrete_sequence=[INDIGO, NEON, PINK, AMBER]) fig.update_yaxes(title="Avg Goals Scored") st.plotly_chart(fig_layout(fig), use_container_width=True) else: missing_data_notice("matches.parquet (stage column)", "Once populated: a group-stage vs knockout scoring comparison for this team.") st.markdown("#### Team Comparison Matrix") fig = px.scatter(metrics, x="possession_pct", y="pass_accuracy_pct", size="goals_for", color="xg", hover_name="team", color_continuous_scale=[INDIGO, CYAN, NEON], title="Possession vs Passing Efficiency") st.plotly_chart(fig_layout(fig), use_container_width=True) # --------------------------------------------------------------------------- # Page: Player Leaderboard (player level) # --------------------------------------------------------------------------- def player_leaderboard(): hero("🥇 Player Leaderboard", "Golden Boot race, playmakers, goalkeepers, and group-vs-knockout form.") players, ok = safe_load("top_players.parquet") if not ok: missing_data_notice("top_players.parquet") return p = players.copy() for col in ("goals", "assists", "xg"): if col not in p.columns: p[col] = 0 p[["goals", "assists", "xg"]] = p[["goals", "assists", "xg"]].fillna(0) p["contributions"] = p["goals"] + p["assists"] p["finishing_delta"] = p["goals"] - p["xg"] f1, f2 = st.columns(2) with f1: teams = ["All"] + sorted(p["team"].dropna().unique().tolist()) if "team" in p.columns else ["All"] team_sel = st.selectbox("Filter by team", teams, key="pl_team") with f2: if "position" in p.columns: positions = ["All"] + sorted(p["position"].dropna().unique().tolist()) pos_sel = st.selectbox("Filter by position", positions, key="pl_pos") else: pos_sel = "All" st.selectbox("Filter by position", ["All — position data pending"], disabled=True, key="pl_pos_disabled") view = p.copy() if team_sel != "All": view = view[view["team"] == team_sel] if pos_sel != "All" and "position" in view.columns: view = view[view["position"] == pos_sel] tab_outfield, tab_gk, tab_form = st.tabs(["⚽ Outfield Leaderboard", "🧤 Goalkeepers", "📈 Group vs Knockout Form"]) with tab_outfield: if not view.empty: c1, c2, c3 = st.columns(3) with c1: top_scorer = view.sort_values("goals", ascending=False).iloc[0] insight("Golden Boot Leader", f"{top_scorer.player} ({top_scorer.team}) — {int(top_scorer.goals)} goals.") with c2: top_assist = view.sort_values("assists", ascending=False).iloc[0] insight("Playmaker Leader", f"{top_assist.player} ({top_assist.team}) — {int(top_assist.assists)} assists.") with c3: clinical = view.sort_values("finishing_delta", ascending=False).iloc[0] insight("Most Clinical Finisher", f"{clinical.player} is {clinical.finishing_delta:+.1f} goals ahead of his xG.") g1, g2 = st.columns(2) with g1: top10 = view.sort_values("goals", ascending=False).head(10) fig = px.bar(top10, x="goals", y="player", orientation="h", color="team", title="Golden Boot Race — Top 10 Scorers") st.plotly_chart(fig_layout(fig), use_container_width=True) with g2: fig = px.scatter(view, x="xg", y="goals", size="contributions", color="team", hover_name="player", title="Finishing Quality — Goals vs Expected Goals") max_v = float(max(view["xg"].max() if not view.empty else 1, view["goals"].max() if not view.empty else 1, 1)) fig.add_shape(type="line", x0=0, y0=0, x1=max_v, y1=max_v, line=dict(color="#9DB4C8", dash="dot")) st.plotly_chart(fig_layout(fig), use_container_width=True) if {"tackles", "interceptions"}.issubset(view.columns): st.markdown("##### Defensive Workload") dview = view.sort_values("tackles", ascending=False).head(10) fig = px.bar(dview, x="tackles", y="player", orientation="h", color="interceptions", title="Top 10 — Tackles (color = Interceptions)", color_continuous_scale=[INDIGO, CYAN, NEON]) st.plotly_chart(fig_layout(fig), use_container_width=True) else: missing_data_notice("top_players.parquet (tackles, interceptions columns)", "Once populated: a defensive-workload leaderboard.") if "rating" in view.columns and not view.empty: st.markdown("##### Player Rating Leaders") rview = view.sort_values("rating", ascending=False).head(10) fig = px.bar(rview, x="rating", y="player", orientation="h", color="team", title="Top 10 — Average Player Rating") st.plotly_chart(fig_layout(fig), use_container_width=True) st.markdown("#### Full Leaderboard") base_cols = [c for c in ["player", "team", "position", "goals", "assists", "xg", "rating", "pass_accuracy_pct", "tackles", "interceptions"] if c in view.columns] show_cols = base_cols + ["finishing_delta", "contributions"] st.dataframe(view.sort_values("contributions", ascending=False)[show_cols], use_container_width=True, hide_index=True) with tab_gk: gk, gk_ok = safe_load("goalkeepers.parquet") if not gk_ok: missing_data_notice("goalkeepers.parquet", "Once populated: save percentage, penalty saves, and clean-sheet leaders.") else: gview = gk[gk["team"] == team_sel] if (team_sel != "All" and "team" in gk.columns) else gk c1, c2 = st.columns(2) with c1: if "save_pct" in gview.columns and not gview.empty: best = gview.sort_values("save_pct", ascending=False).iloc[0] insight("Best Save Percentage", f"{best.player} ({best.team}) — {best.save_pct:.1f}% of shots faced saved.") with c2: if "penalties_saved" in gview.columns and not gview.empty: pk = gview.sort_values("penalties_saved", ascending=False).iloc[0] if pk.penalties_saved and pk.penalties_saved > 0: insight("Penalty Stopper", f"{pk.player} ({pk.team}) has saved {int(pk.penalties_saved)} penalty kick(s).") if "save_pct" in gview.columns and not gview.empty: fig = px.bar(gview.sort_values("save_pct", ascending=True).tail(10), x="save_pct", y="player", orientation="h", color="team", title="Top 10 — Save Percentage") st.plotly_chart(fig_layout(fig), use_container_width=True) st.dataframe(gview, use_container_width=True, hide_index=True) with tab_form: mps, mps_ok = safe_load("match_player_stats.parquet") if not mps_ok: missing_data_notice("match_player_stats.parquet", "Once populated: per-player group-stage vs knockout form, and a rising-form trend across matchdays.") else: scope = mps[mps["team"] == team_sel] if (team_sel != "All" and "team" in mps.columns) else mps if {"player", "stage", "rating"}.issubset(scope.columns) and not scope.empty: split = scope.groupby(["player", "stage"])["rating"].mean().reset_index() top_players_list = scope.groupby("player")["rating"].mean().sort_values(ascending=False).head(8).index.tolist() split = split[split["player"].isin(top_players_list)] fig = px.bar(split, x="player", y="rating", color="stage", barmode="group", title="Avg Rating — Group Stage vs Knockout (Top 8 by rating)") st.plotly_chart(fig_layout(fig), use_container_width=True) if {"player", "goals", "matchday"}.issubset(scope.columns) and not scope.empty: st.caption("Pick a player to see their goal-scoring trend across the tournament.") player_pick = st.selectbox("Player form trend", sorted(scope["player"].dropna().unique().tolist()), key="form_player_pick") ptrend = scope[scope["player"] == player_pick].sort_values("matchday") fig = px.line(ptrend, x="matchday", y="goals", markers=True, title=f"{player_pick} — Goals by Matchday") st.plotly_chart(fig_layout(fig), use_container_width=True) # --------------------------------------------------------------------------- # Page: Venues & Cities # --------------------------------------------------------------------------- def venues_cities(): hero("🌆 Venues & Host Cities", "Stadium workload and geography across Canada, Mexico and the USA.") cities, ok = safe_load("host_cities.parquet") if not ok: missing_data_notice("host_cities.parquet") return c1, c2, c3 = st.columns(3) with c1: insight("Host Footprint", f"{cities['city'].nunique()} cities across {cities['country'].nunique()} countries are staging matches.") with c2: busiest = cities.sort_values("matches", ascending=False).iloc[0] insight("Busiest Stadium", f"{busiest.stadium} in {busiest.city} hosts the most matches ({int(busiest.matches)}).") with c3: by_country = cities.groupby("country")["matches"].sum().sort_values(ascending=False) insight("Matches by Host Nation", " · ".join(f"{k}: {int(v)}" for k, v in by_country.items())) m1, m2 = st.columns([1.3, .7]) with m1: fig = px.scatter_mapbox(cities, lat="lat", lon="lon", size="matches", color="country", hover_name="city", hover_data=["stadium", "matches"], zoom=2.2, height=480, title="Stadium Workload Map") fig.update_layout(mapbox_style="carto-darkmatter", mapbox=dict(center={"lat": 38, "lon": -96}), margin=dict(l=0, r=0, t=45, b=0)) st.plotly_chart(fig_layout(fig), use_container_width=True) with m2: ranked = cities.sort_values("matches", ascending=True) fig = px.bar(ranked, x="matches", y="stadium", orientation="h", color="country", title="Stadiums Ranked by Matches Hosted") st.plotly_chart(fig_layout(fig), use_container_width=True) st.markdown("#### All Venues") st.dataframe(cities.sort_values("matches", ascending=False), use_container_width=True, hide_index=True) # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- page = sidebar() if page == "⚽ Overview": overview() elif page == "🏟️ Match Center": match_center() elif page == "🏆 Group Standings": standings() elif page == "📊 Team Intelligence": team_intelligence() elif page == "🥇 Player Leaderboard": player_leaderboard() else: venues_cities()