Code
chart_hard_hit(batting)
Stats, refreshed the morning after every game
June 23, 2026
Last refreshed: June 22, 2026 at 9:40 PM PDT
This site treats a baseball season like a bioinformatics experiment: players are samples, their offensive stats are features, and we look for the hitters and pitchers behaving differently from the rest. The numbers come from FanGraphs and the MLB Stats API and are re-rendered automatically the morning after each Mariners game.
Read the full differential analysis →
Hard contact vs results. Does hitting the ball hard translate into production? The dashed line is the league-wide trend; the grey band is its 95% confidence interval (how sure we are of that average relationship — not a per-player normal range). Players well above the line are out-producing their contact quality (lucky or crafty); well below, under-producing (unlucky or poor launch angles).
BABIP — luck on balls in play. League average is ~.300; sustained extremes are rare, so red bars tend to cool off and navy bars tend to warm up.
Pitching: ERA vs the peripherals. Negative bars mean a pitcher has allowed fewer runs than his strikeouts, walks, and contact quality would predict.
The biggest gaps between what hitters have actually produced (wOBA) and what their batted-ball quality says they should have produced (xwOBA).
glance <- batting |>
dplyr::transmute(
Player = player,
Pos = pos,
PA = pa,
wOBA = round(woba, 3),
xwOBA = round(xwoba, 3),
Diff = round(woba - xwoba, 3)
) |>
dplyr::arrange(dplyr::desc(Diff))
over <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
dplyr::arrange(Diff)Running hot — over-performing
| Player | Pos | PA | wOBA | xwOBA | Diff |
|---|---|---|---|---|---|
| Colt Emerson | 3B | 101 | 0.334 | 0.228 | 0.105 |
| Brendan Donovan | UNK | 101 | 0.372 | 0.345 | 0.027 |
| Randy Arozarena | UNK | 302 | 0.365 | 0.353 | 0.012 |
| Dominic Canzone | DH | 201 | 0.390 | 0.384 | 0.006 |
| Mitch Garver | C | 112 | 0.287 | 0.295 | -0.007 |
Running cold — under-performing
| Player | Pos | PA | wOBA | xwOBA | Diff |
|---|---|---|---|---|---|
| Patrick Wisdom | UNK | 44 | 0.172 | 0.265 | -0.093 |
| Connor Joe | RF | 45 | 0.274 | 0.337 | -0.063 |
| Rob Refsnyder | DH | 115 | 0.202 | 0.257 | -0.055 |
| Cal Raleigh | C | 205 | 0.255 | 0.293 | -0.038 |
| Jhonny Pereda | UNK | 68 | 0.309 | 0.346 | -0.037 |
Pitching staff — ERA − xERA
| Player | Role | IP | ERA | xERA | ERA − xERA |
|---|---|---|---|---|---|
| Nick Davila | RP | 12.2 | 0.00 | 3.99 | -3.99 |
| Matt Brash | RP | 16.2 | 0.54 | 4.11 | -3.57 |
| Cole Wilcox | RP | 13.1 | 5.40 | 7.50 | -2.10 |
| Eduard Bazardo | SP | 34.1 | 2.10 | 4.12 | -2.02 |
| Gabe Speier | RP | 18.2 | 1.93 | 3.80 | -1.87 |
| Emerson Hancock | SP | 85.0 | 3.60 | 4.56 | -0.96 |
| Logan Gilbert | SP | 93.0 | 3.29 | 3.93 | -0.64 |
| Bryce Miller | SP | 40.0 | 1.57 | 2.15 | -0.58 |
| José A. Ferrer | SP | 32.0 | 2.81 | 2.94 | -0.13 |
| Cooper Criswell | SP | 30.2 | 3.52 | 3.31 | 0.21 |
| George Kirby | SP | 90.0 | 4.10 | 3.63 | 0.47 |
| Luis Castillo | SP | 70.2 | 5.22 | 4.61 | 0.61 |
| Bryan Woo | SP | 89.0 | 3.94 | 3.27 | 0.67 |
| Andrés Muñoz | SP | 27.1 | 5.27 | 4.36 | 0.91 |
| Alex Hoppe | SP | 22.0 | 5.32 | 3.56 | 1.76 |
How this updates: a scheduled GitHub Actions workflow checks each morning whether the Mariners finished a game the night before. If so, it re-fetches the stats, re-renders this site, and deploys it to Vercel. Off-days are skipped, and the synthetic fallback is never published.
---
title: "Seattle Mariners 2026"
subtitle: "Stats, refreshed the morning after every game"
date: today
page-layout: full
code-fold: true
---
```{r setup, include=FALSE}
source(here::here("R", "00_setup.R"))
source(here::here("R", "charts.R"))
# In CI the data is fetched once before the render. When this page is rendered
# standalone the .rds files may not exist yet — fetch then.
if (!file.exists(here("data", "batting_2026.rds"))) {
source(here::here("R", "01_fetch_data.R"))
}
batting <- readRDS(here("data", "batting_2026.rds"))
pitching <- readRDS(here("data", "pitching_2026.rds"))
refreshed <- format(Sys.time(), tz = "America/Los_Angeles",
format = "%B %e, %Y at %l:%M %p %Z")
# Note when the underlying numbers came from the synthetic fallback rather than
# the live feed, so the page is never silently wrong.
src <- tryCatch(readLines(here("data", "data_source.txt"), n = 1),
error = function(e) "unknown")
```
::: {.refreshed-badge}
Last refreshed: `r refreshed`
:::
```{r live-warning, echo=FALSE, results='asis'}
if (!identical(src, "live")) {
cat("> ⚠️ **Heads up:** these numbers are from the synthetic fallback — the live",
"FanGraphs / MLB Stats API feed was unavailable at render time.\n")
}
```
This site treats a baseball season like a bioinformatics experiment: players are
*samples*, their offensive stats are *features*, and we look for the hitters and
pitchers behaving differently from the rest. The numbers come from FanGraphs and
the MLB Stats API and are re-rendered automatically the morning after each
Mariners game.
[**Read the full differential analysis →**](/analysis)
## Who's hot, who's not
**Hard contact vs results.** Does hitting the ball hard translate into production?
The dashed line is the league-wide trend; the grey band is its 95% confidence
interval (how sure we are of that *average* relationship — not a per-player normal
range). Players well *above* the line are out-producing their contact quality
(lucky or crafty); well *below*, under-producing (unlucky or poor launch angles).
```{r hard-hit, fig.width = 9, fig.height = 6}
chart_hard_hit(batting)
```
**BABIP — luck on balls in play.** League average is ~.300; sustained extremes are
rare, so red bars tend to cool off and navy bars tend to warm up.
```{r babip, fig.width = 9, fig.height = 5}
chart_babip(batting)
```
**Pitching: ERA vs the peripherals.** Negative bars mean a pitcher has allowed
fewer runs than his strikeouts, walks, and contact quality would predict.
```{r pitching-luck, fig.width = 9, fig.height = 5}
chart_pitching_luck(pitching)
```
## The numbers
The biggest gaps between what hitters have actually produced (**wOBA**) and what
their batted-ball quality says they *should* have produced (**xwOBA**).
```{r glance}
glance <- batting |>
dplyr::transmute(
Player = player,
Pos = pos,
PA = pa,
wOBA = round(woba, 3),
xwOBA = round(xwoba, 3),
Diff = round(woba - xwoba, 3)
) |>
dplyr::arrange(dplyr::desc(Diff))
over <- dplyr::slice_max(glance, Diff, n = 5, with_ties = FALSE)
under <- dplyr::slice_min(glance, Diff, n = 5, with_ties = FALSE) |>
dplyr::arrange(Diff)
```
::: {layout-ncol="2"}
::: {}
**Running hot — over-performing**
```{r over}
knitr::kable(over, row.names = FALSE)
```
:::
::: {}
**Running cold — under-performing**
```{r under}
knitr::kable(under, row.names = FALSE)
```
:::
:::
**Pitching staff — ERA − xERA**
```{r pitching-glance}
pitching |>
dplyr::transmute(
Player = player,
Role = role,
IP = ip,
ERA = round(era, 2),
xERA = round(xera, 2),
`ERA − xERA` = round(era_minus_xera, 2)
) |>
dplyr::arrange(`ERA − xERA`) |>
knitr::kable(row.names = FALSE)
```
---
*How this updates: a scheduled GitHub Actions workflow checks each morning whether
the Mariners finished a game the night before. If so, it re-fetches the stats,
re-renders this site, and deploys it to Vercel. Off-days are skipped, and the
synthetic fallback is never published.*