Spaces:
Sleeping
Sleeping
| """ | |
| chart_builder.py | |
| ================ | |
| Plotly figure factories for the MLB Pregame Dashboard. | |
| All functions return go.Figure objects with a consistent dark theme. | |
| Keep figures compact and information-dense — these live in a tight UI. | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| from data_fetcher import LEAGUE_AVG, TEAM_COLORS | |
| # ── Shared theme ────────────────────────────────────────────────────────────── | |
| BG = "#0f1117" | |
| BG_PAPER = "#181c27" | |
| GRID = "#2a2f3e" | |
| TEXT = "#d4d8e8" | |
| ACCENT = "#4a9eff" | |
| LAYOUT_BASE = dict( | |
| paper_bgcolor=BG_PAPER, | |
| plot_bgcolor=BG, | |
| font=dict(color=TEXT, size=11, family="JetBrains Mono, monospace"), | |
| margin=dict(l=36, r=12, t=28, b=28), | |
| showlegend=False, | |
| ) | |
| # Color scale for xwOBA: red (bad) → gray (avg) → green (good) | |
| # For pitchers "good" = LOW xwOBA; for hitters "good" = HIGH xwOBA. | |
| def _xwoba_color(val: float, pitcher: bool = False) -> str: | |
| """Return a hex color relative to league average xwOBA.""" | |
| avg = LEAGUE_AVG["xwOBA"] | |
| diff = val - avg | |
| if pitcher: | |
| diff = -diff # for pitchers, lower xwOBA is better | |
| # clamp to ±0.06 | |
| diff = max(-0.06, min(0.06, diff)) | |
| if diff > 0: | |
| # green spectrum | |
| g = int(180 + diff / 0.06 * 75) | |
| return f"rgb(60,{g},80)" | |
| else: | |
| # red spectrum | |
| r = int(180 + abs(diff) / 0.06 * 75) | |
| return f"rgb({r},60,60)" | |
| def _kpct_color(val: float, pitcher: bool = False) -> str: | |
| avg = LEAGUE_AVG["K_pct"] | |
| diff = val - avg | |
| if pitcher: | |
| diff = -diff | |
| diff = max(-0.08, min(0.08, diff)) | |
| return _gradient_color(diff) | |
| def _bbpct_color(val: float, pitcher: bool = False) -> str: | |
| avg = LEAGUE_AVG["BB_pct"] | |
| diff = val - avg | |
| if pitcher: | |
| diff = -diff | |
| diff = max(-0.05, min(0.05, diff)) | |
| return _gradient_color(diff) | |
| def _gradient_color(diff: float) -> str: | |
| if diff > 0: | |
| g = int(160 + diff / 0.08 * 75) | |
| return f"rgb(60,{g},80)" | |
| else: | |
| r = int(160 + abs(diff) / 0.08 * 75) | |
| return f"rgb({r},60,60)" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # PITCHER — INNING DISTRIBUTION (column chart) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def make_innings_chart(game_log_df: pd.DataFrame) -> go.Figure: | |
| """ | |
| Bar chart: last-inning-pitched per start. | |
| X = start date (abbreviated), Y = last full inning pitched. | |
| Color = deeper into game → more blue. | |
| """ | |
| if game_log_df.empty: | |
| return _empty_figure("No inning data") | |
| df = game_log_df.copy() | |
| df["last_inn"] = df["IP"].apply(lambda x: int(x)) # floor innings | |
| # Format dates compactly | |
| df["label"] = pd.to_datetime(df["date"]).dt.strftime("%-m/%-d") | |
| colors = [ | |
| f"rgb({max(40, 180 - v * 18)},{max(40, 160 - v * 14)},{min(255, 100 + v * 22)})" | |
| for v in df["last_inn"] | |
| ] | |
| fig = go.Figure(go.Bar( | |
| x=df["label"], | |
| y=df["last_inn"], | |
| marker_color=colors, | |
| marker_line_width=0, | |
| hovertemplate="<b>%{x}</b><br>Inning: %{y}<extra></extra>", | |
| )) | |
| fig.update_layout( | |
| **LAYOUT_BASE, | |
| title=dict(text="Last Inning Pitched (recent starts)", x=0.02, | |
| font=dict(size=11, color="#8899bb")), | |
| xaxis=dict(gridcolor=GRID, showgrid=False, tickfont=dict(size=9)), | |
| yaxis=dict(gridcolor=GRID, range=[0, 10], dtick=1, | |
| title=dict(text="Inn", font=dict(size=9))), | |
| height=175, | |
| ) | |
| return fig | |
| def make_pitch_count_chart(game_log_df: pd.DataFrame) -> go.Figure: | |
| """ | |
| Bar chart of pitch counts per start + horizontal average line. | |
| """ | |
| if game_log_df.empty: | |
| return _empty_figure("No pitch count data") | |
| df = game_log_df.copy() | |
| df["label"] = pd.to_datetime(df["date"]).dt.strftime("%-m/%-d") | |
| avg_pitches = df["pitches"].mean() | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar( | |
| x=df["label"], y=df["pitches"], | |
| marker_color=ACCENT, marker_opacity=0.75, marker_line_width=0, | |
| hovertemplate="<b>%{x}</b><br>Pitches: %{y}<extra></extra>", | |
| )) | |
| fig.add_hline( | |
| y=avg_pitches, line_dash="dot", line_color="#ffaa44", line_width=1.5, | |
| annotation_text=f"Avg {avg_pitches:.0f}", | |
| annotation_font=dict(color="#ffaa44", size=9), | |
| annotation_position="top right", | |
| ) | |
| fig.update_layout( | |
| **LAYOUT_BASE, | |
| title=dict(text="Pitch Count per Start", x=0.02, | |
| font=dict(size=11, color="#8899bb")), | |
| xaxis=dict(gridcolor=GRID, showgrid=False, tickfont=dict(size=9)), | |
| yaxis=dict(gridcolor=GRID, range=[0, 130], | |
| title=dict(text="Pitches", font=dict(size=9))), | |
| height=175, | |
| ) | |
| return fig | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # PITCHER — FASTBALL VELO BY INNING (box plots) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def make_velo_by_inning_chart(velo_by_inning: dict) -> go.Figure: | |
| """ | |
| Box plots of fastball velocity for innings 1–7. | |
| Each box = distribution of all FBs thrown in that inning this season. | |
| """ | |
| if not velo_by_inning: | |
| return _empty_figure("No velocity data") | |
| fig = go.Figure() | |
| for inning in range(1, 8): | |
| velos = velo_by_inning.get(inning, []) | |
| if not velos: | |
| continue | |
| fig.add_trace(go.Box( | |
| y=velos, | |
| x=[f"Inn {inning}"] * len(velos), | |
| name=f"Inn {inning}", | |
| boxpoints=False, | |
| marker_color=ACCENT, | |
| line_color=ACCENT, | |
| fillcolor="rgba(74,158,255,0.18)", | |
| line_width=1.5, | |
| hovertemplate=f"Inn {inning}<br>Median: %{{median:.1f}}<extra></extra>", | |
| )) | |
| fig.update_layout( | |
| **LAYOUT_BASE, | |
| title=dict(text="Fastball Velo by Inning (season)", x=0.02, | |
| font=dict(size=11, color="#8899bb")), | |
| xaxis=dict(gridcolor=GRID), | |
| yaxis=dict(gridcolor=GRID, title=dict(text="mph", font=dict(size=9))), | |
| height=210, | |
| boxmode="group", | |
| ) | |
| return fig | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # BULLPEN — ENTRY INNING DISTRIBUTION | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def make_entry_inning_chart(entry_dist: dict, pitcher_name: str = "") -> go.Figure: | |
| """ | |
| Small horizontal bar chart: % of appearances entering in inn 6/7/8/9+. | |
| Designed to be very compact (height ~90px). | |
| """ | |
| labels = ["6", "7", "8", "9+"] | |
| values = [entry_dist.get(6, 0), entry_dist.get(7, 0), | |
| entry_dist.get(8, 0), entry_dist.get("9+", 0)] | |
| # Colors map to late/high-leverage innings | |
| bar_colors = ["#4a6fa5", "#4a8cbf", "#4aabda", "#4aceff"] | |
| fig = go.Figure(go.Bar( | |
| x=values, y=labels, orientation="h", | |
| marker_color=bar_colors, marker_line_width=0, | |
| hovertemplate="%{y}: %{x:.0%}<extra></extra>", | |
| text=[f"{v:.0%}" if v > 0.05 else "" for v in values], | |
| textfont=dict(size=9, color=TEXT), | |
| textposition="inside", | |
| )) | |
| fig.update_layout( | |
| **LAYOUT_BASE, | |
| margin=dict(l=20, r=8, t=20, b=8), | |
| title=dict(text="Entry Inn", x=0.02, font=dict(size=9, color="#8899bb")), | |
| xaxis=dict(showgrid=False, showticklabels=False, range=[0, 1]), | |
| yaxis=dict(showgrid=False, tickfont=dict(size=9)), | |
| height=110, | |
| ) | |
| return fig | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # SPLITS TABLE (styled HTML, not Plotly — for compactness) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def make_splits_html(splits: dict, pitcher: bool = False) -> str: | |
| """ | |
| Return a compact HTML table for L/R splits. | |
| Cells are color-coded vs league average. | |
| """ | |
| rows = "" | |
| for hand in ("L", "R"): | |
| key = f"vs_{hand}" | |
| s = splits.get(key, {}) | |
| if pitcher: | |
| k_col = _kpct_color(s.get("K_pct", 0), pitcher=True) | |
| bb_col = _bbpct_color(s.get("BB_pct", 0), pitcher=True) | |
| xw_col = _xwoba_color(s.get("xwOBA", 0), pitcher=True) | |
| row = ( | |
| f"<tr>" | |
| f"<td class='split-hand'>vs {hand}</td>" | |
| f"<td style='color:{k_col}'>{s.get('K_pct',0):.1%}</td>" | |
| f"<td style='color:{bb_col}'>{s.get('BB_pct',0):.1%}</td>" | |
| f"<td style='color:{xw_col}'>{s.get('xwOBA',0):.3f}</td>" | |
| f"<td class='split-pa'>{s.get('PA',0)}</td>" | |
| f"</tr>" | |
| ) | |
| else: | |
| xw_col = _xwoba_color(s.get("xwOBA", 0), pitcher=False) | |
| pa = s.get("PA", 0) | |
| flag = " ⚠" if pa < 100 else "" | |
| row = ( | |
| f"<tr>" | |
| f"<td class='split-hand'>vs {hand}</td>" | |
| f"<td style='color:{xw_col}'>{s.get('xwOBA',0):.3f}{flag}</td>" | |
| f"<td class='split-pa'>{pa} PA</td>" | |
| f"</tr>" | |
| ) | |
| rows += row | |
| header_cells = ( | |
| "<th>Split</th><th>K%</th><th>BB%</th><th>xwOBA</th><th>PA</th>" | |
| if pitcher else | |
| "<th>Split</th><th>xwOBA</th><th>PA</th>" | |
| ) | |
| return f""" | |
| <table class="splits-table"> | |
| <thead><tr>{header_cells}</tr></thead> | |
| <tbody>{rows}</tbody> | |
| </table> | |
| """ | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # UTILITY | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def _empty_figure(msg: str = "No data") -> go.Figure: | |
| fig = go.Figure() | |
| fig.add_annotation(text=msg, x=0.5, y=0.5, xref="paper", yref="paper", | |
| showarrow=False, font=dict(color="#556080", size=12)) | |
| fig.update_layout(**LAYOUT_BASE, height=160) | |
| return fig | |
| def team_badge_html(abbr: str, size: int = 28) -> str: | |
| """Return an inline HTML badge with team colors.""" | |
| pri, sec = TEAM_COLORS.get(abbr, ("#444", "#888")) | |
| return ( | |
| f'<span style="display:inline-block;padding:2px 8px;border-radius:3px;' | |
| f'background:{pri};color:{sec};font-weight:700;font-size:{size-8}px;' | |
| f'font-family:monospace;letter-spacing:1px;border:1px solid {sec}22;">' | |
| f'{abbr}</span>' | |
| ) | |