Spaces:
Sleeping
Sleeping
| """ | |
| app.py | |
| ====== | |
| MLB Pregame Dashboard — Gradio UI | |
| Run: python app.py (then open http://localhost:7860) | |
| Layout: | |
| ┌──────────────┬──────────────────────────────────────────────┐ | |
| │ SIDEBAR │ MAIN PANEL │ | |
| │ Date picker │ [Hitters] / [Pitchers] toggle │ | |
| │ Game list │ Team filter: Away | All | Home │ | |
| │ Weather │ Content │ | |
| └──────────────┴──────────────────────────────────────────────┘ | |
| """ | |
| import datetime | |
| import json | |
| import gradio as gr | |
| import pandas as pd | |
| import data_fetcher as df_mod | |
| import chart_builder as cb | |
| from data_fetcher import TEAM_COLORS, LEAGUE_AVG | |
| # ── CSS ─────────────────────────────────────────────────────────────────────── | |
| CUSTOM_CSS = """ | |
| /* ── Global ── */ | |
| body, .gradio-container { background:#0f1117 !important; color:#d4d8e8; } | |
| .gradio-container { max-width:1400px; margin:0 auto; } | |
| /* ── Sidebar ── */ | |
| #sidebar { background:#13182a; border-right:1px solid #1e2640; | |
| padding:12px 10px; min-width:220px; max-width:240px; } | |
| /* ── Game card ── */ | |
| .game-card { background:#181c2e; border:1px solid #252a40; border-radius:6px; | |
| padding:8px 10px; margin-bottom:6px; cursor:pointer; | |
| transition:border-color .15s; font-size:12px; } | |
| .game-card:hover { border-color:#4a9eff55; } | |
| .game-card.selected { border-color:#4a9eff; background:#1a2340; } | |
| .game-card .teams { font-weight:700; font-size:13px; letter-spacing:.5px; } | |
| .game-card .meta { color:#6a7a9a; font-size:11px; margin-top:2px; } | |
| /* Status badges */ | |
| .badge-today { background:#1a3a1a; color:#4adf6a; border:1px solid #2a5a2a; | |
| padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } | |
| .badge-live { background:#3a1a1a; color:#ff5555; border:1px solid #5a2a2a; | |
| padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; | |
| animation:pulse 1.4s infinite; } | |
| .badge-future { background:#1a2a3a; color:#4a9eff; border:1px solid #2a4a6a; | |
| padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } | |
| .badge-past { background:#222233; color:#7788aa; border:1px solid #334455; | |
| padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } | |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } | |
| /* ── Section headers ── */ | |
| .section-title { font-size:11px; color:#5a6a8a; text-transform:uppercase; | |
| letter-spacing:1.5px; margin:12px 0 6px; padding-bottom:4px; | |
| border-bottom:1px solid #1e2640; } | |
| /* ── Tab strip (Hitters / Pitchers) ── */ | |
| #main-tabs .tab-nav { background:#13182a !important; border-bottom:1px solid #1e2640; } | |
| #main-tabs .tab-nav button { color:#6a7a9a !important; font-weight:600; | |
| font-size:14px !important; letter-spacing:.5px; } | |
| #main-tabs .tab-nav button.selected { color:#d4d8e8 !important; | |
| border-bottom:2px solid #4a9eff !important; } | |
| /* ── Team toggle ── */ | |
| .team-toggle { display:flex; gap:6px; margin-bottom:12px; align-items:center; } | |
| .team-btn { padding:3px 12px; border-radius:4px; border:1px solid #2a3050; | |
| background:#181c2e; color:#8899bb; font-size:12px; font-weight:700; | |
| cursor:pointer; transition:all .15s; letter-spacing:.5px; } | |
| .team-btn.active { border-color:#4a9eff; color:#d4d8e8; background:#1a2a44; } | |
| /* ── Hitter rows ── */ | |
| .hitter-table { width:100%; border-collapse:collapse; font-size:12px; | |
| font-family:'JetBrains Mono',monospace; } | |
| .hitter-table th { color:#5a6a8a; font-weight:600; font-size:10px; | |
| text-transform:uppercase; letter-spacing:1px; | |
| padding:4px 8px; border-bottom:1px solid #1e2640; | |
| text-align:left; } | |
| .hitter-table td { padding:5px 8px; border-bottom:1px solid #151928; } | |
| .hitter-table tr:hover td { background:#1a1f30; } | |
| .bench-row td { background:#131620 !important; color:#6a7a9a; } | |
| .bench-row:hover td { background:#181c28 !important; } | |
| .spot-col { color:#5a6a8a; width:28px; } | |
| .name-col { font-weight:600; } | |
| .hand-badge { display:inline-block; padding:1px 5px; border-radius:3px; | |
| font-size:10px; font-weight:700; margin-right:4px; } | |
| .hand-L { background:#1a2a3a; color:#4a9eff; border:1px solid #2a4a6a; } | |
| .hand-R { background:#2a1a1a; color:#ff7755; border:1px solid #4a2a2a; } | |
| .hand-S { background:#1a2a1a; color:#4adf6a; border:1px solid #2a4a2a; } | |
| .small-sample { color:#ffaa33; font-size:10px; } | |
| .recent-stat { color:#8899bb; } | |
| .official-yes { color:#4adf6a; font-size:10px; } | |
| .official-no { color:#ffaa33; font-size:10px; } | |
| /* ── Pitcher page ── */ | |
| .pitcher-header { display:flex; align-items:center; gap:12px; margin-bottom:14px; } | |
| .pitcher-name { font-size:20px; font-weight:700; letter-spacing:.5px; } | |
| .pitcher-meta { color:#6a7a9a; font-size:12px; margin-top:2px; } | |
| /* ── Splits table ── */ | |
| .splits-table { border-collapse:collapse; font-size:12px; | |
| font-family:'JetBrains Mono',monospace; width:100%; } | |
| .splits-table th { color:#5a6a8a; font-weight:600; font-size:10px; | |
| text-transform:uppercase; letter-spacing:1px; | |
| padding:3px 8px; border-bottom:1px solid #1e2640; } | |
| .splits-table td { padding:4px 8px; border-bottom:1px solid #151928; } | |
| .split-hand { color:#8899bb; font-weight:600; } | |
| .split-pa { color:#445566; font-size:10px; } | |
| /* ── Bullpen cards ── */ | |
| .bullpen-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(310px,1fr)); | |
| gap:8px; margin-top:8px; } | |
| .bp-card { background:#181c2e; border:1px solid #1e2640; border-radius:6px; padding:10px; } | |
| .bp-card-name { font-weight:700; font-size:13px; margin-bottom:4px; } | |
| .bp-pitches { display:flex; gap:6px; margin-top:6px; } | |
| .bp-pitch-day { background:#13182a; border:1px solid #252a40; border-radius:4px; | |
| padding:3px 8px; font-size:11px; text-align:center; } | |
| .bp-pitch-day .day-label { color:#5a6a8a; font-size:9px; } | |
| .bp-pitch-day .day-count { font-weight:700; | |
| color: var(--pitch-color, #d4d8e8); font-size:13px; } | |
| /* ── Weather widget ── */ | |
| .weather-box { background:#181c2e; border:1px solid #1e2640; border-radius:6px; | |
| padding:8px 10px; font-size:12px; margin-top:6px; } | |
| .weather-temp { font-size:22px; font-weight:700; } | |
| .weather-detail { color:#6a7a9a; font-size:11px; margin-top:2px; } | |
| /* ── Util ── */ | |
| .mono { font-family:'JetBrains Mono',monospace; } | |
| .dimmed { color:#445566; } | |
| """ | |
| # ── State container (shared across callbacks via gr.State) ──────────────────── | |
| _INITIAL_STATE = { | |
| "date": datetime.date.today().isoformat(), | |
| "game_idx": 0, | |
| "tab": "hitters", # 'hitters' | 'pitchers' | |
| "team_filter": "all", # 'away' | 'all' | 'home' | |
| } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # HTML BUILDERS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _status_badge(status: str) -> str: | |
| cls = f"badge-{status.lower()}" | |
| return f'<span class="{cls}">{status.upper()}</span>' | |
| def _game_card_html(game: dict, idx: int, selected: bool) -> str: | |
| away, home = game["away_abbr"], game["home_abbr"] | |
| pri_a, _ = TEAM_COLORS.get(away, ("#333", "#aaa")) | |
| pri_h, _ = TEAM_COLORS.get(home, ("#333", "#aaa")) | |
| score = "" | |
| if game["score_away"] is not None: | |
| score = f" <span class='mono'>{game['score_away']}–{game['score_home']}</span>" | |
| sel_cls = " selected" if selected else "" | |
| return f""" | |
| <div class="game-card{sel_cls}" onclick="selectGame({idx})" id="gc-{idx}"> | |
| <div class="teams"> | |
| <span style="color:{pri_a};font-weight:800;">{away}</span> | |
| <span class="dimmed"> @ </span> | |
| <span style="color:{pri_h};font-weight:800;">{home}</span> | |
| {score} | |
| {_status_badge(game['status'])} | |
| </div> | |
| <div class="meta"> | |
| {game.get('game_time','')} | |
| · {game.get('venue','')[:25]} | |
| </div> | |
| <div class="meta" style="margin-top:3px;"> | |
| SP: {game.get('away_starter_name','TBD')} vs {game.get('home_starter_name','TBD')} | |
| </div> | |
| </div>""" | |
| def build_sidebar_html(games: list, selected_idx: int) -> str: | |
| cards = "".join(_game_card_html(g, i, i == selected_idx) | |
| for i, g in enumerate(games)) | |
| if not cards: | |
| cards = "<div style='color:#445566;font-size:12px;padding:8px;'>No games found.</div>" | |
| return cards | |
| def build_weather_html(weather: dict, game: dict) -> str: | |
| if not weather: | |
| return ( | |
| f"<div class='weather-box'>" | |
| f"<span class='dimmed' style='font-size:11px;'>Weather unavailable" | |
| f" (RotoGrinders today only)</span></div>" | |
| ) | |
| precip = f" · {weather['precip_pct']}% precip" if weather.get("precip_pct") else "" | |
| return f""" | |
| <div class='weather-box'> | |
| <div class='weather-temp'>{weather.get('temp_f','--')}°F</div> | |
| <div class='weather-detail'> | |
| {weather.get('conditions','--')} | |
| · Wind {weather.get('wind_mph','?')} mph {weather.get('wind_dir','')}{precip} | |
| </div> | |
| <div class='weather-detail dimmed' style='margin-top:3px;'>@ {game.get('venue','')}</div> | |
| </div>""" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # HITTERS TAB | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _hand_badge(hand: str) -> str: | |
| cls = f"hand-{hand}" if hand in ("L", "R", "S") else "hand-R" | |
| return f'<span class="hand-badge {cls}">{hand}</span>' | |
| def _xwoba_cell(val: float, pa: int, pitcher: bool = False) -> str: | |
| color = cb._xwoba_color(val, pitcher=pitcher) | |
| flag = ' <span class="small-sample">⚠</span>' if pa < 100 else "" | |
| return f'<span style="color:{color};font-weight:600;">{val:.3f}</span>{flag}' | |
| def _hitter_row(player: dict, spot: int | str, bench: bool = False, | |
| opp_throws: str = "R") -> str: | |
| """Build a single hitter table row.""" | |
| pid = player.get("player_id") | |
| splits = df_mod.get_hitter_splits(pid) if pid else df_mod._mock_hitter_splits() | |
| recent = df_mod.get_hitter_recent(pid) if pid else df_mod._mock_hitter_recent() | |
| hand = player.get("bats", "?") | |
| opp_key = f"vs_{opp_throws}" | |
| split_data = splits.get(opp_key, {}) | |
| xwoba = split_data.get("xwOBA", 0.0) | |
| pa = split_data.get("PA", 0) | |
| xwoba_html = _xwoba_cell(xwoba, pa) | |
| pos = player.get("pos", "") | |
| name = player.get("name", "Unknown") | |
| r = recent | |
| recent_str = (f'<span class="recent-stat">' | |
| f'{r["H"]}/{r["AB"]} ' | |
| f'{r["HR"]}HR {r["K"]}K ' | |
| f'{_xwoba_cell(r["xwOBA"], 999)}' | |
| f'</span>') | |
| bench_cls = ' class="bench-row"' if bench else "" | |
| spot_disp = f"{spot}" if not bench else "PH" | |
| return ( | |
| f"<tr{bench_cls}>" | |
| f"<td class='spot-col'>{spot_disp}<span class='dimmed' style='font-size:9px;'> {pos}</span></td>" | |
| f"<td>{_hand_badge(hand)} {xwoba_html} <span class='split-pa' style='font-size:10px;'>vs{opp_throws} {pa}PA</span></td>" | |
| f"<td class='name-col'>{name}</td>" | |
| f"<td>{recent_str}</td>" | |
| f"</tr>" | |
| ) | |
| def build_hitters_html(game: dict, team_filter: str) -> str: | |
| """Return full hitters section HTML.""" | |
| game_pk = game.get("game_pk") | |
| away_abbr = game.get("away_abbr", "") | |
| home_abbr = game.get("home_abbr", "") | |
| lineups = df_mod.get_lineups(game_pk, away_abbr, home_abbr, | |
| datetime.date.today()) | |
| # Determine opposing pitcher handedness for splits context | |
| # (simplified — use announced starter; default R if unknown) | |
| away_throws = "R" # TODO: look up from pitcher profile | |
| home_throws = "R" | |
| sections = [] | |
| teams = [] | |
| if team_filter in ("all", "away"): | |
| teams.append(("away", away_abbr, home_throws)) | |
| if team_filter in ("all", "home"): | |
| teams.append(("home", home_abbr, away_throws)) | |
| for side, abbr, opp_throws in teams: | |
| side_data = lineups.get(side, {}) | |
| players = side_data.get("players", []) | |
| bench = lineups.get(f"{side}_bench", []) | |
| official = side_data.get("official", False) | |
| pri, sec = TEAM_COLORS.get(abbr, ("#333", "#aaa")) | |
| off_label = ('<span class="official-yes">✓ Official</span>' | |
| if official else | |
| '<span class="official-no">~ Projected</span>') | |
| opp_note = f"vs {'LHP' if opp_throws=='L' else 'RHP'} (default splits)" | |
| rows = "" | |
| for i, p in enumerate(players, 1): | |
| rows += _hitter_row(p, i, bench=False, opp_throws=opp_throws) | |
| bench_rows = "" | |
| if bench: | |
| bench_rows = ( | |
| "<tr><td colspan='4' style='padding:4px 8px;color:#334455;" | |
| "font-size:10px;letter-spacing:1px;'>BENCH / PH</td></tr>" | |
| ) | |
| for p in bench[:6]: | |
| bench_rows += _hitter_row(p, "PH", bench=True, opp_throws=opp_throws) | |
| header_row = ( | |
| "<tr>" | |
| "<th>Spot/Pos</th>" | |
| "<th>Splits</th>" | |
| "<th>Name</th>" | |
| "<th>Last 7 days</th>" | |
| "</tr>" | |
| ) | |
| # If no players loaded, show a message | |
| if not rows: | |
| rows = ("<tr><td colspan='4' style='color:#445566;padding:12px 8px;'>" | |
| "Lineup not yet available — check back closer to game time.</td></tr>") | |
| section = f""" | |
| <div style="margin-bottom:18px;"> | |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"> | |
| <span style="font-size:16px;font-weight:800;color:{pri}; | |
| text-shadow:0 0 12px {pri}44;">{abbr}</span> | |
| <span style="color:#5a6a8a;font-size:11px;">{off_label} · {opp_note}</span> | |
| </div> | |
| <table class="hitter-table"> | |
| <thead>{header_row}</thead> | |
| <tbody>{rows}{bench_rows}</tbody> | |
| </table> | |
| </div>""" | |
| sections.append(section) | |
| return "".join(sections) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # PITCHERS TAB — returns (html_str, [figures]) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def build_pitchers_content(game: dict, team_filter: str): | |
| """ | |
| Returns a dict with HTML and figures for the pitchers view. | |
| Called from the Gradio callback; figures are rendered into Plot components. | |
| """ | |
| away_abbr = game.get("away_abbr", "") | |
| home_abbr = game.get("home_abbr", "") | |
| results = {} | |
| teams = [] | |
| if team_filter in ("all", "away"): | |
| teams.append(("away", away_abbr, | |
| game.get("away_starter_name", "TBD"), | |
| game.get("away_starter_id"))) | |
| if team_filter in ("all", "home"): | |
| teams.append(("home", home_abbr, | |
| game.get("home_starter_name", "TBD"), | |
| game.get("home_starter_id"))) | |
| for side, abbr, starter_name, starter_id in teams: | |
| pri, sec = TEAM_COLORS.get(abbr, ("#333", "#aaa")) | |
| # ── Starter data ────────────────────────────────────────────────────── | |
| game_log = df_mod.get_pitcher_game_log(starter_id) if starter_id else df_mod._mock_pitcher_game_log() | |
| splits = df_mod.get_pitcher_splits(starter_id) if starter_id else df_mod._mock_pitcher_splits() | |
| velo = df_mod.get_pitcher_velo_by_inning(starter_id) if starter_id else df_mod._mock_velo_by_inning() | |
| splits_html = cb.make_splits_html(splits, pitcher=True) | |
| confirmed_label = ( | |
| '<span class="official-yes">✓ Confirmed</span>' | |
| if starter_id else | |
| '<span class="official-no">~ TBD</span>' | |
| ) | |
| # ── Bullpen ─────────────────────────────────────────────────────────── | |
| bullpen = df_mod.get_bullpen(abbr) | |
| bp_cards = _build_bullpen_cards(bullpen) | |
| header_html = f""" | |
| <div class="pitcher-header"> | |
| <span style="font-size:18px;font-weight:800;color:{pri};">{abbr}</span> | |
| <div> | |
| <div class="pitcher-name">{starter_name}</div> | |
| <div class="pitcher-meta">Today's Starter {confirmed_label}</div> | |
| </div> | |
| </div> | |
| <div class="section-title">L/R Splits (Season)</div> | |
| {splits_html} | |
| <div class="section-title">Bullpen</div> | |
| {bp_cards} | |
| """ | |
| results[side] = { | |
| "header_html": header_html, | |
| "inn_chart": cb.make_innings_chart(game_log), | |
| "pitch_chart": cb.make_pitch_count_chart(game_log), | |
| "velo_chart": cb.make_velo_by_inning_chart(velo), | |
| } | |
| return results | |
| def _pitch_count_color(n: int) -> str: | |
| """Color pitch count: 0=dim, low=green, moderate=yellow, high=red.""" | |
| if n == 0: | |
| return "#334455" | |
| elif n < 15: | |
| return "#4adf6a" | |
| elif n < 25: | |
| return "#ffcc44" | |
| else: | |
| return "#ff5544" | |
| def _build_bullpen_cards(bullpen: list) -> str: | |
| """Build HTML grid of bullpen arm cards.""" | |
| cards = "" | |
| for arm in bullpen: | |
| name = arm.get("name", "Unknown") | |
| throws = arm.get("throws", "R") | |
| splits = arm.get("splits", {}) | |
| pitches = arm.get("pitches_last_3_days", [0, 0, 0]) | |
| splits_tbl = cb.make_splits_html(splits, pitcher=True) | |
| day_labels = ["2dAgo", "Yest", "Today"] | |
| pitch_boxes = "" | |
| for lbl, cnt in zip(day_labels, pitches): | |
| color = _pitch_count_color(cnt) | |
| pitch_boxes += ( | |
| f"<div class='bp-pitch-day'>" | |
| f"<div class='day-label'>{lbl}</div>" | |
| f"<div class='day-count' style='color:{color};'>{cnt}</div>" | |
| f"</div>" | |
| ) | |
| cards += f""" | |
| <div class="bp-card"> | |
| <div class="bp-card-name">{name} <span class="hand-badge hand-{throws}">{throws}</span></div> | |
| {splits_tbl} | |
| <div class="dimmed" style="font-size:10px;margin-top:5px;">Pitches (last 3 days)</div> | |
| <div class="bp-pitches">{pitch_boxes}</div> | |
| </div>""" | |
| return f'<div class="bullpen-grid">{cards}</div>' if cards else "<div class='dimmed'>No bullpen data.</div>" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # GRADIO APP | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def build_app(): | |
| with gr.Blocks( | |
| css=CUSTOM_CSS, | |
| title="MLB Pregame Dashboard", | |
| theme=gr.themes.Base( | |
| primary_hue="blue", | |
| neutral_hue="slate", | |
| ).set( | |
| body_background_fill="#0f1117", | |
| body_text_color="#d4d8e8", | |
| block_background_fill="#13182a", | |
| border_color_primary="#1e2640", | |
| ), | |
| ) as app: | |
| # ── Shared state ────────────────────────────────────────────────────── | |
| state = gr.State(value=_INITIAL_STATE.copy()) | |
| gr.HTML('<link rel="preconnect" href="https://fonts.googleapis.com">' | |
| '<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">') | |
| with gr.Row(equal_height=True): | |
| # ── SIDEBAR ─────────────────────────────────────────────────────── | |
| with gr.Column(scale=0, min_width=240, elem_id="sidebar"): | |
| gr.HTML('<div style="font-size:15px;font-weight:800;letter-spacing:2px;' | |
| 'color:#4a9eff;margin-bottom:10px;">⚾ PREGAME</div>') | |
| date_input = gr.Textbox( | |
| label="Game Date (YYYY-MM-DD)", | |
| value=datetime.datetime.now().strftime("%Y-%m-%d"), | |
| placeholder="YYYY-MM-DD", | |
| elem_classes=["mono"], | |
| ) | |
| gr.HTML('<div class="section-title">Games</div>') | |
| game_list_html = gr.HTML(value="<div class='dimmed'>Loading...</div>") | |
| gr.HTML('<div class="section-title">Game State & Weather</div>') | |
| game_state_html = gr.HTML() | |
| weather_html = gr.HTML() | |
| # ── MAIN PANEL ─────────────────────────────────────────────────── | |
| with gr.Column(scale=1): | |
| # Team filter strip | |
| with gr.Row(): | |
| gr.HTML('<div style="font-size:11px;color:#5a6a8a;' | |
| 'margin-top:6px;margin-right:6px;">SHOW:</div>') | |
| filter_radio = gr.Radio( | |
| choices=["Away", "All", "Home"], | |
| value="All", | |
| label="", | |
| container=False, | |
| elem_classes=["mono"], | |
| ) | |
| # Hitters / Pitchers tabs | |
| with gr.Tabs(elem_id="main-tabs") as tabs: | |
| with gr.TabItem("🔨 Hitters", id="hitters"): | |
| hitters_html = gr.HTML( | |
| value="<div class='dimmed' style='padding:20px;'>" | |
| "Select a game to load hitter data.</div>") | |
| with gr.TabItem("⚾ Pitchers", id="pitchers"): | |
| # Away pitcher section | |
| away_pitcher_html = gr.HTML() | |
| away_inn_chart = gr.Plot(label="Inn Distribution", show_label=False) | |
| away_pitch_chart = gr.Plot(label="Pitch Counts", show_label=False) | |
| away_velo_chart = gr.Plot(label="FB Velo by Inning",show_label=False) | |
| gr.HTML('<hr style="border-color:#1e2640;margin:16px 0;">') | |
| # Home pitcher section | |
| home_pitcher_html = gr.HTML() | |
| home_inn_chart = gr.Plot(label="Inn Distribution", show_label=False) | |
| home_pitch_chart = gr.Plot(label="Pitch Counts", show_label=False) | |
| home_velo_chart = gr.Plot(label="FB Velo by Inning",show_label=False) | |
| # ── HIDDEN game index tracker ───────────────────────────────────────── | |
| game_index = gr.Number(value=0, visible=False, precision=0) | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # CALLBACKS | |
| # ══════════════════════════════════════════════════════════════════════ | |
| def load_games(date_val, current_state): | |
| """Triggered when date changes. Refresh game list.""" | |
| if isinstance(date_val, datetime.datetime): | |
| date = date_val.date() | |
| else: | |
| try: | |
| date = datetime.datetime.fromisoformat(str(date_val)).date() | |
| except Exception: | |
| date = datetime.date.today() | |
| games = df_mod.get_schedule(date) | |
| new_st = dict(current_state) | |
| new_st["date"] = date.isoformat() | |
| new_st["game_idx"] = 0 | |
| new_st["games"] = games | |
| sidebar_html = build_sidebar_html(games, 0) | |
| # Auto-load first game | |
| gs_html, wx_html, hit_html = "", "", "" | |
| aw_ph, aw_ic, aw_pc, aw_vc = "", None, None, None | |
| hm_ph, hm_ic, hm_pc, hm_vc = "", None, None, None | |
| if games: | |
| game = games[0] | |
| gs_html = _game_state_html(game) | |
| wx_html = build_weather_html( | |
| df_mod.get_weather(game.get("venue", "")) if date == datetime.date.today() else {}, | |
| game, | |
| ) | |
| hit_html = build_hitters_html(game, "all") | |
| p_data = build_pitchers_content(game, "all") | |
| if "away" in p_data: | |
| aw_ph = p_data["away"]["header_html"] | |
| aw_ic = p_data["away"]["inn_chart"] | |
| aw_pc = p_data["away"]["pitch_chart"] | |
| aw_vc = p_data["away"]["velo_chart"] | |
| if "home" in p_data: | |
| hm_ph = p_data["home"]["header_html"] | |
| hm_ic = p_data["home"]["inn_chart"] | |
| hm_pc = p_data["home"]["pitch_chart"] | |
| hm_vc = p_data["home"]["velo_chart"] | |
| return ( | |
| new_st, sidebar_html, gs_html, wx_html, | |
| hit_html, | |
| aw_ph, aw_ic, aw_pc, aw_vc, | |
| hm_ph, hm_ic, hm_pc, hm_vc, | |
| ) | |
| def select_game(idx, current_state, filter_val): | |
| """Triggered by game index number change (JS sets this).""" | |
| games = current_state.get("games", []) | |
| idx = int(idx) | |
| if not games or idx >= len(games): | |
| return [gr.update()] * 12 | |
| game = games[idx] | |
| new_st = dict(current_state) | |
| new_st["game_idx"] = idx | |
| team_f = filter_val.lower() | |
| date_str = current_state.get("date", datetime.date.today().isoformat()) | |
| is_today = date_str == datetime.date.today().isoformat() | |
| sidebar_html = build_sidebar_html(games, idx) | |
| gs_html = _game_state_html(game) | |
| wx_html = build_weather_html( | |
| df_mod.get_weather(game.get("venue", "")) if is_today else {}, game | |
| ) | |
| hit_html = build_hitters_html(game, team_f) | |
| p_data = build_pitchers_content(game, team_f) | |
| aw_ph = p_data.get("away", {}).get("header_html", "") | |
| aw_ic = p_data.get("away", {}).get("inn_chart") | |
| aw_pc = p_data.get("away", {}).get("pitch_chart") | |
| aw_vc = p_data.get("away", {}).get("velo_chart") | |
| hm_ph = p_data.get("home", {}).get("header_html", "") | |
| hm_ic = p_data.get("home", {}).get("inn_chart") | |
| hm_pc = p_data.get("home", {}).get("pitch_chart") | |
| hm_vc = p_data.get("home", {}).get("velo_chart") | |
| return ( | |
| new_st, sidebar_html, gs_html, wx_html, | |
| hit_html, | |
| aw_ph, aw_ic, aw_pc, aw_vc, | |
| hm_ph, hm_ic, hm_pc, hm_vc, | |
| ) | |
| def change_filter(filter_val, current_state): | |
| """Re-render hitters + pitchers when team filter changes.""" | |
| games = current_state.get("games", []) | |
| idx = current_state.get("game_idx", 0) | |
| if not games: | |
| return [gr.update()] * 9 | |
| game = games[idx] | |
| team_f = filter_val.lower() | |
| hit_html = build_hitters_html(game, team_f) | |
| p_data = build_pitchers_content(game, team_f) | |
| aw_ph = p_data.get("away", {}).get("header_html", "") | |
| aw_ic = p_data.get("away", {}).get("inn_chart") | |
| aw_pc = p_data.get("away", {}).get("pitch_chart") | |
| aw_vc = p_data.get("away", {}).get("velo_chart") | |
| hm_ph = p_data.get("home", {}).get("header_html", "") | |
| hm_ic = p_data.get("home", {}).get("inn_chart") | |
| hm_pc = p_data.get("home", {}).get("pitch_chart") | |
| hm_vc = p_data.get("home", {}).get("velo_chart") | |
| return (hit_html, aw_ph, aw_ic, aw_pc, aw_vc, | |
| hm_ph, hm_ic, hm_pc, hm_vc) | |
| # Wire outputs list (shared between load_games and select_game) | |
| _all_outputs = [ | |
| state, game_list_html, game_state_html, weather_html, | |
| hitters_html, | |
| away_pitcher_html, away_inn_chart, away_pitch_chart, away_velo_chart, | |
| home_pitcher_html, home_inn_chart, home_pitch_chart, home_velo_chart, | |
| ] | |
| date_input.change( | |
| fn=load_games, | |
| inputs=[date_input, state], | |
| outputs=_all_outputs, | |
| ) | |
| game_index.change( | |
| fn=select_game, | |
| inputs=[game_index, state, filter_radio], | |
| outputs=_all_outputs, | |
| ) | |
| filter_radio.change( | |
| fn=change_filter, | |
| inputs=[filter_radio, state], | |
| outputs=[ | |
| hitters_html, | |
| away_pitcher_html, away_inn_chart, away_pitch_chart, away_velo_chart, | |
| home_pitcher_html, home_inn_chart, home_pitch_chart, home_velo_chart, | |
| ], | |
| ) | |
| # ── JS: clicking a game card updates the hidden game_index number ───── | |
| app.load( | |
| fn=load_games, | |
| inputs=[date_input, state], | |
| outputs=_all_outputs, | |
| ) | |
| gr.HTML(""" | |
| <script> | |
| function selectGame(idx) { | |
| // Find the hidden number input Gradio renders and update it | |
| const inputs = document.querySelectorAll('input[type=number]'); | |
| for (const inp of inputs) { | |
| if (inp.closest('.hidden') || inp.style.display === 'none') { | |
| inp.value = idx; | |
| inp.dispatchEvent(new Event('input', {bubbles: true})); | |
| inp.dispatchEvent(new Event('change', {bubbles: true})); | |
| break; | |
| } | |
| } | |
| // Visual selection update | |
| document.querySelectorAll('.game-card').forEach((c,i) => { | |
| c.classList.toggle('selected', i === idx); | |
| }); | |
| } | |
| </script> | |
| """) | |
| return app | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # GAME STATE HTML | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _game_state_html(game: dict) -> str: | |
| status = game.get("status", "") | |
| inning = game.get("inning") | |
| outs = game.get("outs") | |
| s_a = game.get("score_away") | |
| s_h = game.get("score_home") | |
| if status == "Live" and inning: | |
| score_line = (f'<div style="font-size:20px;font-weight:800;' | |
| f'font-family:monospace;">' | |
| f'{game["away_abbr"]} {s_a} – {s_h} {game["home_abbr"]}</div>' | |
| f'<div style="font-size:12px;color:#6a7a9a;">' | |
| f'Inn {inning} · {outs} out(s)</div>') | |
| elif status == "Past" and s_a is not None: | |
| score_line = (f'<div style="font-size:18px;font-weight:800;font-family:monospace;">' | |
| f'Final: {game["away_abbr"]} {s_a} – {s_h} {game["home_abbr"]}</div>') | |
| else: | |
| score_line = (f'<div style="font-size:13px;color:#6a7a9a;">' | |
| f'First pitch: {game.get("game_time","TBD")}</div>') | |
| return f""" | |
| <div class="weather-box" style="margin-top:0;"> | |
| {_status_badge(status)} | |
| <div style="margin-top:6px;">{score_line}</div> | |
| </div>""" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ENTRY POINT | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| if __name__ == "__main__": | |
| app = build_app() | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=False, # set True to get a public Gradio link | |
| show_error=True, | |
| ) | |