Pregame_MLB_Report / chart_builder.py
JakeR3's picture
Upload 5 files
080efc9 verified
Raw
History Blame Contribute Delete
12.1 kB
"""
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>'
)