Spaces:
Running
Running
| 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) |