JakeR3's picture
Upload 3 files
526acc6 verified
Raw
History Blame Contribute Delete
34.1 kB
"""
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" &nbsp; <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}
&nbsp; {_status_badge(game['status'])}
</div>
<div class="meta">
{game.get('game_time','')}
&middot; {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"]} &nbsp;'
f'{r["HR"]}HR &nbsp;{r["K"]}K &nbsp;'
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} &middot; {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 &nbsp; {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,
)