Win Probability Chart — D3.js

A win probability chart shows how each team's likelihood of winning evolves over the course of a game. The line starts near 50% and fluctuates based on scoring events, ultimately reaching 100% or 0% at game end. The area above and below the 50% baseline is filled with team colors to convey momentum at a glance. This visualization is widely used across major sports for post-game analysis and live broadcasting.

Win Probability Chart rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// line-win-probability: Win Probability Chart
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 94/100 | Created: 2026-06-21

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

const margin = { top: 80, right: 160, bottom: 100, left: 90 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// Portland Trail Blazers (home) vs Houston Rockets — synthetic NBA play-by-play
// win probability over 48 minutes; each row is a significant game event
const gameData = [
  { t: 0.0, p: 0.500 },
  { t: 1.0, p: 0.520 },
  { t: 2.0, p: 0.550 },
  { t: 3.5, p: 0.540 },
  { t: 5.0, p: 0.580 },
  { t: 6.0, p: 0.600 },  // Portland opens up a 9-4 run
  { t: 7.0, p: 0.580 },
  { t: 8.5, p: 0.620 },
  { t: 10.0, p: 0.650 },
  { t: 11.5, p: 0.640 },
  { t: 12.0, p: 0.630 },  // End Q1: Portland 28-22
  { t: 13.0, p: 0.610 },
  { t: 14.5, p: 0.570 },
  { t: 16.0, p: 0.530 },
  { t: 17.0, p: 0.480 },
  { t: 18.0, p: 0.410 },  // Houston 10-2 run, takes lead
  { t: 19.0, p: 0.430 },
  { t: 20.0, p: 0.450 },
  { t: 21.0, p: 0.460 },
  { t: 22.0, p: 0.475 },
  { t: 23.0, p: 0.460 },
  { t: 24.0, p: 0.450 },  // Halftime: Portland 47-48
  { t: 25.0, p: 0.470 },
  { t: 26.5, p: 0.510 },
  { t: 28.0, p: 0.540 },
  { t: 29.0, p: 0.560 },
  { t: 30.0, p: 0.575 },
  { t: 31.5, p: 0.540 },
  { t: 33.0, p: 0.530 },
  { t: 34.5, p: 0.565 },
  { t: 36.0, p: 0.570 },  // End Q3: Portland 83-78
  { t: 37.0, p: 0.550 },
  { t: 38.5, p: 0.520 },
  { t: 39.5, p: 0.480 },
  { t: 40.5, p: 0.440 },
  { t: 41.0, p: 0.390 },  // Houston 3PT + foul, 4-point lead
  { t: 42.0, p: 0.430 },
  { t: 43.0, p: 0.500 },
  { t: 44.0, p: 0.580 },  // Lillard 3-pointer with 4 min left
  { t: 45.0, p: 0.680 },  // Houston star fouls out
  { t: 46.0, p: 0.750 },
  { t: 47.0, p: 0.820 },
  { t: 48.0, p: 0.850 },  // Final: Portland 112 - Houston 108
];

// Key events to annotate (significant probability swings)
const keyEvents = [
  { t: 6.0,  p: 0.600, label: "Portland 9-4 run", above: true  },
  { t: 18.0, p: 0.410, label: "Houston 10-2 run",  above: false },
  { t: 41.0, p: 0.390, label: "Houston 3PT+foul",  above: false },
  { t: 44.0, p: 0.580, label: "Lillard 3-PT",       above: true  },
];

const HOME_COLOR = t.palette[0];  // #009E73 — Portland home team (Imprint position 1)
const AWAY_COLOR = t.palette[4];  // #AE3030 — Houston away team (semantic loss anchor)

// SVG
const svg = d3.select("#container")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

const g = svg.append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

// Scales
const x = d3.scaleLinear().domain([0, 48]).range([0, iw]);
const y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);
const midY = y(0.5);

// Area fills (Math.min/max trick splits fill at the 50% baseline)
const homeArea = d3.area()
  .x(d => x(d.t))
  .y0(midY)
  .y1(d => Math.min(y(d.p), midY))  // fills above 50% only
  .curve(d3.curveMonotoneX);

const awayArea = d3.area()
  .x(d => x(d.t))
  .y0(midY)
  .y1(d => Math.max(y(d.p), midY))  // fills below 50% only
  .curve(d3.curveMonotoneX);

// Win probability line
const lineGen = d3.line()
  .x(d => x(d.t))
  .y(d => y(d.p))
  .curve(d3.curveMonotoneX);

// Draw areas first (behind line)
g.append("path").datum(gameData)
  .attr("fill", HOME_COLOR).attr("fill-opacity", 0.22)
  .attr("d", homeArea);

g.append("path").datum(gameData)
  .attr("fill", AWAY_COLOR).attr("fill-opacity", 0.22)
  .attr("d", awayArea);

// Subtle horizontal grid lines at 25% and 75%
[0.25, 0.75].forEach(pct => {
  g.append("line")
    .attr("x1", 0).attr("x2", iw)
    .attr("y1", y(pct)).attr("y2", y(pct))
    .attr("stroke", t.grid).attr("stroke-width", 0.8);
});

// Quarter boundary vertical lines (dashed)
[12, 24, 36].forEach(qt => {
  g.append("line")
    .attr("x1", x(qt)).attr("x2", x(qt))
    .attr("y1", 0).attr("y2", ih)
    .attr("stroke", t.grid)
    .attr("stroke-width", 1)
    .attr("stroke-dasharray", "6,4");
});

// 50% reference line (prominent dashed)
g.append("line")
  .attr("x1", 0).attr("x2", iw)
  .attr("y1", midY).attr("y2", midY)
  .attr("stroke", t.inkSoft)
  .attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "8,4");

// Quarter period labels near top of chart area
const quarterCenters = [6, 18, 30, 42];
const quarterNames   = ["Q1", "Q2", "Q3", "Q4"];
quarterCenters.forEach((mid, i) => {
  g.append("text")
    .attr("x", x(mid)).attr("y", 18)
    .attr("text-anchor", "middle")
    .attr("fill", t.inkSoft)
    .style("font-size", "13px")
    .style("font-weight", "500")
    .text(quarterNames[i]);
});

// Win probability line (drawn on top of area fills)
g.append("path").datum(gameData)
  .attr("fill", "none")
  .attr("stroke", t.ink)
  .attr("stroke-width", 2.5)
  .attr("d", lineGen);

// X-axis
const xAxis = g.append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x)
    .tickValues([0, 12, 24, 36, 48])
    .tickFormat(d => `${d}m`));

// Y-axis
const yAxis = g.append("g")
  .call(d3.axisLeft(y)
    .tickValues([0, 0.25, 0.5, 0.75, 1.0])
    .tickFormat(d => `${Math.round(d * 100)}%`));

// Style both axes
for (const ax of [xAxis, yAxis]) {
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  ax.selectAll(".tick line").attr("stroke", t.grid);
  ax.select(".domain").attr("stroke", t.inkSoft);
}

// "50%" label beside the reference line (right edge of chart area)
g.append("text")
  .attr("x", iw + 6).attr("y", midY + 5)
  .attr("fill", t.inkSoft)
  .style("font-size", "12px")
  .text("50%");

// Team name labels in the right margin
svg.append("text")
  .attr("x", margin.left + iw + 10)
  .attr("y", margin.top + midY - 38)
  .attr("fill", HOME_COLOR)
  .style("font-size", "14px")
  .style("font-weight", "600")
  .text("Portland");

svg.append("text")
  .attr("x", margin.left + iw + 10)
  .attr("y", margin.top + midY + 58)
  .attr("fill", AWAY_COLOR)
  .style("font-size", "14px")
  .style("font-weight", "600")
  .text("Houston");

// Key event annotation markers and labels
keyEvents.forEach(ev => {
  const cx = x(ev.t);
  const cy = y(ev.p);
  const color = ev.above ? HOME_COLOR : AWAY_COLOR;

  g.append("circle")
    .attr("cx", cx).attr("cy", cy).attr("r", 6)
    .attr("fill", color)
    .attr("stroke", t.ink)
    .attr("stroke-width", 1.5);

  g.append("text")
    .attr("x", cx)
    .attr("y", ev.above ? cy - 13 : cy + 24)
    .attr("text-anchor", "middle")
    .attr("fill", t.inkSoft)
    .style("font-size", "12px")
    .text(ev.label);
});

// Final score annotation (upper-right corner)
svg.append("text")
  .attr("x", margin.left + iw)
  .attr("y", margin.top - 14)
  .attr("text-anchor", "end")
  .attr("fill", t.ink)
  .style("font-size", "14px")
  .style("font-weight", "600")
  .text("Final: Portland 112 – Houston 108");

// Axis labels
svg.append("text")
  .attr("x", margin.left + iw / 2)
  .attr("y", height - 8)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("Game Time (minutes)");

svg.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -(margin.top + ih / 2))
  .attr("y", 18)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("Win Probability (Home Team)");

// Title
const title = "line-win-probability · javascript · d3 · anyplot.ai";
const titleFontSize = title.length > 67 ? Math.round(22 * 67 / title.length) : 22;

svg.append("text")
  .attr("x", width / 2)
  .attr("y", 50)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", `${titleFontSize}px`)
  .style("font-weight", "600")
  .text(title);

Part of Win Probability Chart on anyplot.ai.

Other implementations