---
title: "WAR Trends"
subtitle: "How each hitter and pitcher has piled up WAR — zoom to any window"
date: today
page-layout: full
---
```{r setup, include=FALSE}
source(here::here("R", "00_setup.R"))
# Build the daily WAR history if it's missing (standalone render). In CI the
# leaderboard fetch + this builder run before the render.
if (!file.exists(here("data", "war_history.rds"))) {
source(here::here("R", "06_war_history.R"))
}
history <- readRDS(here("data", "war_history.rds"))
war_source <- tryCatch(readLines(here("data", "war_source.txt"), n = 1),
error = function(e) "unknown")
# Hand the series to Observable JS. Dates go over as ISO strings (OJS parses
# them back to Date objects), which survives the JSON round-trip cleanly.
if (nrow(history) > 0) history$date <- format(as.Date(history$date), "%Y-%m-%d")
ojs_define(war_raw = history)
```
```{r notice, echo=FALSE, results='asis'}
if (identical(war_source, "synthetic")) {
cat("> ⚠️ **Demo data:** the live feed was unavailable at render time, so these",
"trends are from the synthetic fallback — illustrative, not real results.\n")
} else if (identical(war_source, "unavailable")) {
cat("> ℹ️ **Warming up:** game-by-game WAR history wasn't available from the feed",
"at render time. The chart will populate once game logs are reachable.\n")
}
```
**WAR** (wins above replacement) rolls a player's entire season — offense, defense,
baserunning, or run prevention — into one number: how many wins they've added versus a
freely-available replacement player. These are <abbr title="fWAR = FanGraphs WAR. Pitching WAR is built from FIP — strikeouts, walks, and home runs allowed — while hitting, baserunning, and defense use FanGraphs' run values. It differs from Baseball-Reference's bWAR, which is based on actual runs allowed and DRS; the two can diverge, especially for pitchers.">fWAR</abbr>
figures (FanGraphs' version). It's a running season total, but it is **not**
one-directional. Each game's contribution can be negative — a cold bat, a misplayed ball,
a baserunning blunder all *subtract* — and the replacement bar keeps rising with playing
time, so a player has to produce above it just to hold steady. Read the slope: **rising =
a hot, above-replacement stretch**, **flat = treading water**, and **falling = the player
has been worth less than replacement lately** (see Rob Refsnyder below).
Use the selector to zoom the timeline to the last few days or the whole season. By
default the chart shows the **biggest movers** in the chosen window — the players who
gained (or lost) the most WAR over that span — but you can switch to everyone.
```{ojs}
//| echo: false
data = transpose(war_raw)
.map(d => ({
player: d.player,
type: d.type,
date: new Date(d.date + "T00:00:00Z"),
war: +d.war
}))
.filter(d => !isNaN(d.date) && isFinite(d.war))
```
```{ojs}
//| echo: false
viewof days = Inputs.select(
new Map([
["Last 3 days", 3],
["Last 7 days", 7],
["Last 14 days", 14],
["Last 30 days", 30],
["Last 60 days", 60],
["Rest of season", 100000]
]),
{ value: 30, label: "Look back" }
)
```
```{ojs}
//| echo: false
viewof showAll = Inputs.toggle({ label: "Show all players", value: false })
```
```{ojs}
//| echo: false
maxDate = data.length ? d3.max(data, d => d.date) : new Date()
cutoff = new Date(maxDate.getTime() - days * 86400000)
windowed = data.filter(d => d.date >= cutoff)
```
```{ojs}
//| echo: false
// Rank players by how much WAR they gained across the visible window.
movers = {
const byPlayer = d3.group(windowed, d => d.player);
const deltas = Array.from(byPlayer, ([player, rows]) => {
const sorted = rows.slice().sort((a, b) => a.date - b.date);
return { player, delta: sorted[sorted.length - 1].war - sorted[0].war };
});
return deltas.sort((a, b) => d3.descending(Math.abs(a.delta), Math.abs(b.delta)));
}
shown = showAll
? new Set(data.map(d => d.player))
: new Set(movers.slice(0, 8).map(d => d.player))
plotData = windowed.filter(d => shown.has(d.player))
```
```{ojs}
//| echo: false
html`${data.length === 0
? `<div class="trends-empty">Trend data is still warming up — check back after the next refresh.</div>`
: ""}`
```
```{ojs}
//| echo: false
Plot.plot({
width: Math.min(width, 960),
height: 540,
marginRight: 130,
x: { label: "Date", type: "utc" },
y: { label: "Cumulative season WAR", grid: true },
color: { legend: !showAll },
marks: [
Plot.ruleY([0], { stroke: "#999" }),
Plot.line(plotData, {
x: "date", y: "war", stroke: "player",
strokeWidth: showAll ? 1 : 2.2,
strokeOpacity: showAll ? 0.55 : 1,
curve: "monotone-x",
tip: true
}),
Plot.text(plotData, Plot.selectLast({
x: "date", y: "war", z: "player",
text: "player", fill: "player",
textAnchor: "start", dx: 6, fontWeight: 600,
fontSize: showAll ? 9 : 11
}))
]
})
```
```{ojs}
//| echo: false
md`*${showAll
? `Showing all ${shown.size} players`
: `Showing the ${Math.min(8, movers.length)} biggest WAR movers`} over the selected window. Each label sits at the player's latest value.*`
```
---
*WAR here is the season running total: picking a shorter window zooms the x-axis but
keeps each line's accumulated value, so you read recent form as the **slope** of the line
over that span. Live trends are reconstructed from FanGraphs date-range leaderboards
(season-to-date WAR sampled weekly) and refresh with the rest of the site the morning
after each game.*