""" 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="%{x}
Inning: %{y}", )) 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="%{x}
Pitches: %{y}", )) 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}
Median: %{{median:.1f}}", )) 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%}", 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"" f"vs {hand}" f"{s.get('K_pct',0):.1%}" f"{s.get('BB_pct',0):.1%}" f"{s.get('xwOBA',0):.3f}" f"{s.get('PA',0)}" f"" ) else: xw_col = _xwoba_color(s.get("xwOBA", 0), pitcher=False) pa = s.get("PA", 0) flag = " ⚠" if pa < 100 else "" row = ( f"" f"vs {hand}" f"{s.get('xwOBA',0):.3f}{flag}" f"{pa} PA" f"" ) rows += row header_cells = ( "SplitK%BB%xwOBAPA" if pitcher else "SplitxwOBAPA" ) return f""" {header_cells}{rows}
""" # ══════════════════════════════════════════════════════════════════════════════ # 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'' f'{abbr}' )