2026_MLB_Model / visualization /game_cards.py
Syntrex's picture
Update visualization/game_cards.py
6626cb0 verified
raw
history blame
16.2 kB
from __future__ import annotations
from typing import Any
import streamlit.components.v1 as components
from visualization.logo_utils import get_team_flag_url, get_team_logo_url
def _safe_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
text = str(value).strip()
if text == "" or text.lower() in {"nan", "none"}:
return fallback
return text
def _safe_score(value: Any) -> str:
if value is None:
return ""
text = str(value).strip()
if text == "" or text.lower() in {"nan", "none"}:
return ""
try:
return str(int(float(text)))
except Exception:
return text
def _safe_num(value: Any, digits: int = 1) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if text in {"", "nan", "none"}:
return ""
try:
return f"{float(value):.{digits}f}"
except Exception:
return str(value)
def _status_color(status: str) -> tuple[str, str]:
s = (status or "").lower()
if any(token in s for token in ["live", "top", "bot", "bottom", "mid", "middle"]):
return "#22c55e", "●"
if any(token in s for token in ["final", "game over", "completed", "ended"]):
return "#fbbf24", "●"
return "#94a3b8", "●"
def _outs_html(outs: int | None) -> str:
try:
if outs is None:
outs_value = 0
else:
text = str(outs).strip().lower()
if text in {"", "nan", "none"}:
outs_value = 0
else:
outs_value = int(float(text))
except Exception:
outs_value = 0
dots = []
for i in range(3):
color = "#f97316" if i < outs_value else "rgba(255,255,255,0.12)"
dots.append(
f'<span style="display:inline-block;width:10px;height:10px;border-radius:999px;background:{color};margin-right:6px;"></span>'
)
return "".join(dots)
def _bases_html(first_on: bool, second_on: bool, third_on: bool) -> str:
def base(on: bool) -> str:
color = "#fbbf24" if on else "rgba(255,255,255,0.10)"
return f'background:{color};transition:background 0.3s ease;'
return f"""
<div style="position:relative;width:38px;height:34px;transform:scale(0.95);">
<span style="position:absolute;top:0;left:14px;width:10px;height:10px;transform:rotate(45deg);border-radius:2px;{base(second_on)}"></span>
<span style="position:absolute;top:12px;left:2px;width:10px;height:10px;transform:rotate(45deg);border-radius:2px;{base(third_on)}"></span>
<span style="position:absolute;top:12px;left:26px;width:10px;height:10px;transform:rotate(45deg);border-radius:2px;{base(first_on)}"></span>
<span style="position:absolute;top:24px;left:14px;width:10px;height:10px;transform:rotate(45deg);border-radius:2px;background:rgba(255,255,255,0.14);"></span>
</div>
"""
def render_game_card(game: dict[str, Any]) -> None:
away_team = _safe_text(game.get("away_team"), "Away")
home_team = _safe_text(game.get("home_team"), "Home")
competition_bucket = _safe_text(game.get("competition_bucket"), "OTHER").upper()
# Be robust to missing / incorrect competition_bucket on some score rows:
# always try MLB logo lookup first, then fall back to country flag lookup.
away_logo_url = get_team_logo_url(away_team)
home_logo_url = get_team_logo_url(home_team)
away_flag_url = away_logo_url or get_team_flag_url(away_team)
home_flag_url = home_logo_url or get_team_flag_url(home_team)
away_score = _safe_score(game.get("away_score"))
home_score = _safe_score(game.get("home_score"))
away_hits = _safe_score(game.get("away_hits"))
home_hits = _safe_score(game.get("home_hits"))
away_errors = _safe_score(game.get("away_errors"))
home_errors = _safe_score(game.get("home_errors"))
status = _safe_text(game.get("status"), "Scheduled")
start_time_et = _safe_text(game.get("start_time_et"))
tv = _safe_text(game.get("tv"))
away_record = _safe_text(game.get("away_record"))
home_record = _safe_text(game.get("home_record"))
batter_name = _safe_text(game.get("batter_name"))
pitcher_name = _safe_text(game.get("pitcher_name"))
balls = _safe_text(game.get("balls"))
strikes = _safe_text(game.get("strikes"))
outs = game.get("outs", 0)
first_on = bool(game.get("runner_on_1b", False))
second_on = bool(game.get("runner_on_2b", False))
third_on = bool(game.get("runner_on_3b", False))
away_wp = game.get("away_win_prob")
home_wp = game.get("home_win_prob")
last_play = _safe_text(game.get("last_play"))
last_pitch = _safe_text(game.get("last_pitch"))
pitch_type = _safe_text(game.get("pitch_type"))
pitch_velocity = _safe_num(game.get("pitch_velocity"), 1)
pitch_spin_rate = _safe_num(game.get("pitch_spin_rate"), 0)
pitch_extension = _safe_num(game.get("pitch_extension"), 1)
pitch_label = last_pitch if last_pitch else (pitch_type if pitch_type else "Not available")
status_color, status_dot = _status_color(status)
has_score_context = any(value not in {"", None} for value in [away_score, home_score])
has_live_context = any(
value not in {"", None}
for value in [batter_name, pitcher_name, balls, strikes, last_pitch, pitch_velocity]
)
status_lower = status.lower()
# Some upstream feeds label upcoming games as plain "Live" before real game-state data exists.
# If there is no score context and no live game context, treat the card as upcoming.
if status_lower == "live" and not has_score_context and not has_live_context:
effective_status = "Scheduled"
else:
effective_status = status
display_status = effective_status
if start_time_et and ("scheduled" in effective_status.lower() or effective_status.strip() == ""):
display_status = start_time_et
effective_status_lower = effective_status.lower()
is_live_game = any(
token in effective_status_lower for token in ["live", "top", "bot", "bottom", "mid", "middle"]
)
is_final_game = any(
token in effective_status_lower for token in ["final", "game over", "completed", "ended"]
)
velo_text = f"{pitch_velocity} mph" if pitch_velocity else "—"
spin_text = f"{pitch_spin_rate} rpm" if pitch_spin_rate else "—"
ext_text = f"{pitch_extension} ft" if pitch_extension else "—"
count_value = f"{balls}-{strikes}" if balls != "" and strikes != "" else ""
matchup_html = ""
if batter_name or pitcher_name or count_value or is_live_game:
matchup_html = f"""
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;">
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">BATTER</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{batter_name or "—"}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">PITCHER</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{pitcher_name or "—"}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:10px;">
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">COUNT</div>
<div style="color:#f8fafc;font-size:14px;font-weight:800;">{count_value or "—"}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">OUTS</div>
<div>{_outs_html(outs)}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">BASES</div>
<div>{_bases_html(first_on, second_on, third_on)}</div>
</div>
</div>
"""
else:
matchup_html = f"""
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:10px;">
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">OUTS</div>
<div>{_outs_html(outs)}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">BASES</div>
<div>{_bases_html(first_on, second_on, third_on)}</div>
</div>
</div>
"""
pitch_html = ""
if is_live_game or is_final_game or last_pitch or pitch_type or pitch_velocity:
pitch_html = f"""
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;">
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">LAST PITCH</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{pitch_label}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">VELO</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{velo_text}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:10px;">
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">SPIN</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{spin_text}</div>
</div>
<div>
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">EXT</div>
<div style="color:#f8fafc;font-size:14px;font-weight:700;">{ext_text}</div>
</div>
</div>
"""
wp_html = ""
if away_wp is not None and home_wp is not None:
try:
away_pct = max(0, min(100, float(away_wp) * 100))
home_pct = max(0, min(100, float(home_wp) * 100))
wp_html = f"""
<div style="margin-top:10px;">
<div style="display:flex;justify-content:space-between;color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:4px;">
<span>{away_team} {away_pct:.0f}%</span>
<span>{home_team} {home_pct:.0f}%</span>
</div>
<div style="width:100%;height:8px;background:rgba(255,255,255,0.08);border-radius:999px;overflow:hidden;">
<div style="width:{away_pct:.1f}%;height:8px;background:linear-gradient(90deg,#38bdf8 0%,#60a5fa 100%);border-radius:999px;"></div>
</div>
</div>
"""
except Exception:
wp_html = ""
rhe_html = ""
if any([away_score, home_score, away_hits, home_hits, away_errors, home_errors]):
rhe_html = f"""
<div style="margin-top:12px;">
<div style="display:grid;grid-template-columns:1.8fr 0.5fr 0.5fr 0.5fr;gap:6px;color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:4px;">
<div></div><div>R</div><div>H</div><div>E</div>
</div>
<div style="display:grid;grid-template-columns:1.8fr 0.5fr 0.5fr 0.5fr;gap:6px;color:#e2e8f0;font-size:14px;font-weight:700;">
<div>{away_team}</div><div>{away_score or "-"}</div><div>{away_hits or "-"}</div><div>{away_errors or "-"}</div>
</div>
<div style="display:grid;grid-template-columns:1.8fr 0.5fr 0.5fr 0.5fr;gap:6px;color:#e2e8f0;font-size:14px;font-weight:700;">
<div>{home_team}</div><div>{home_score or "-"}</div><div>{home_hits or "-"}</div><div>{home_errors or "-"}</div>
</div>
</div>
"""
last_play_html = ""
if last_play or is_live_game:
last_play_html = f"""
<div style="margin-top:12px;">
<div style="color:#94a3b8;font-size:12px;font-weight:700;margin-bottom:2px;">LAST PLAY</div>
<div style="color:#e2e8f0;font-size:13px;font-weight:600;line-height:1.4;">{last_play or "—"}</div>
</div>
"""
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {{
margin: 0;
padding: 0;
background: transparent;
font-family: Arial, sans-serif;
}}
.score-card {{
background: linear-gradient(180deg, rgba(30,41,59,0.96) 0%, rgba(15,23,42,0.96) 100%);
border: 1px solid rgba(148,163,184,0.18);
border-radius: 22px;
padding: 16px;
box-sizing: border-box;
color: #f8fafc;
min-height: 280px;
}}
.status-dot-live {{
color: #22c55e;
animation: livePulse 1.6s infinite;
}}
@keyframes livePulse {{
0% {{opacity:0.35;}}
50% {{opacity:1;}}
100% {{opacity:0.35;}}
}}
.top-row {{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:10px;
}}
.status {{
display:inline-flex;
align-items:center;
gap:7px;
font-size:14px;
font-weight:700;
color:{status_color};
}}
.team-line {{
display:flex;
justify-content:space-between;
align-items:center;
padding:6px 0;
}}
.team-meta {{
display:flex;
gap:8px;
align-items:baseline;
min-width:0;
}}
.team-name {{
color:#f8fafc;
font-size:20px;
font-weight:800;
line-height:1.2;
}}
.team-record {{
color:#94a3b8;
font-size:13px;
font-weight:600;
}}
.team-score {{
color:#f8fafc;
font-size:32px;
font-weight:900;
line-height:1;
}}
.broadcast {{
margin-top:12px;
color:#94a3b8;
font-size:13px;
font-weight:600;
}}
</style>
</head>
<body>
<div class="score-card">
<div class="top-row">
<div class="status">
<span class="{"status-dot-live" if "TOP" in display_status.upper() or "BOT" in display_status.upper() or "MID" in display_status.upper() or "LIVE" in display_status.upper() else ""}">{status_dot}</span>
<span>{display_status}</span>
</div>
</div>
<div class="team-line">
<div class="team-meta">
<div class="team-name">
<span style="display:inline-flex;align-items:center;gap:8px;">
{"<img src='" + away_flag_url + "' loading='lazy' decoding='async' style='width:20px;height:14px;object-fit:cover;border-radius:2px;border:1px solid rgba(255,255,255,0.12);' />" if away_flag_url else ""}
<span>{away_team}</span>
</span>
</div>
<div class="team-record">{away_record}</div>
</div>
<div class="team-score">{away_score}</div>
</div>
<div class="team-line">
<div class="team-meta">
<div class="team-name">
<span style="display:inline-flex;align-items:center;gap:8px;">
{"<img src='" + home_flag_url + "' loading='lazy' decoding='async' style='width:20px;height:14px;object-fit:cover;border-radius:2px;border:1px solid rgba(255,255,255,0.12);' />" if home_flag_url else ""}
<span>{home_team}</span>
</span>
</div>
<div class="team-record">{home_record}</div>
</div>
<div class="team-score">{home_score}</div>
</div>
{matchup_html}
{pitch_html}
{wp_html}
{rhe_html}
{last_play_html}
<div class="broadcast">{tv}</div>
</div>
</body>
</html>
"""
components.html(html, height=620 if (last_play_html or rhe_html or pitch_html or matchup_html) else 460, scrolling=False)