2026 Midterms Forecast
Chances for the Senate, the House and governorships, and turnout in every county, from a forecast that is published in advance and scored after the election.
Forecast v1 · 22 October 2026 · 20,000 simulations How the model works →
Code
Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/[email protected]/+esm")
topojson = import("https://cdn.jsdelivr.net/npm/[email protected]/+esm")
us = d3.json("https://cdn.jsdelivr.net/npm/[email protected]/counties-10m.json")
states = topojson.feature(us, us.objects.states).features
counties = topojson.feature(us, us.objects.counties).features
stateMesh = topojson.mesh(us, us.objects.states, (a, b) => a !== b)
isTrue = v => String(v).toLowerCase() === "true"
races = (await FileAttachment("data/races.csv").csv({typed: true})).map(r => ({...r, special: isTrue(r.special), open_seat: isTrue(r.open_seat)}))
isSpecial = r => r.special === true
national = (await FileAttachment("data/national.csv").csv({typed: true}))[0]
senateSeats = normalise(await FileAttachment("data/senate_seats.csv").csv({typed: true}))
governorSeats = normalise(await FileAttachment("data/governor_seats.csv").csv({typed: true}))
houseSeats = normalise(await FileAttachment("data/house_seats.csv").csv({typed: true}))
districts = FileAttachment("data/house_districts.csv").csv({typed: true})
turnout = FileAttachment("data/turnout_counties.csv").csv({typed: true})
turnoutStates = FileAttachment("data/turnout_states.csv").csv({typed: true})
credits = FileAttachment("img/credits.json").json()
photoFor = new Map(credits.map(c => [`${c.race_id}-${c.side}`, c]))
DEM = "#4d6a8f"
REP = "#c9503f"
IND = "#5f6368"
GREEN = "#4e7d5b"
PURPLE = "#8c6d8f"
NO_RACE = "#9aa0a8"
TURNOUT_2022 = 0.465
probColour = d3.piecewise(d3.interpolateRgb, [REP, "#e0a59c", "#d6d7d9", "#9fb1c8", DEM])
changeColour = d3.interpolateRgbBasis(["#4f3a5c", PURPLE, "#eeeff0", GREEN, "#2e5539"])
// party colours as text: a CSS variable per mode, so small labels stay legible in dark mode
textColour = c => ({[DEM]: "var(--fc-dem-text)", [REP]: "var(--fc-rep-text)", [GREEN]: "var(--fc-green-text)"})[c] ?? c
FIPS = ({"01":"AL","02":"AK","04":"AZ","05":"AR","06":"CA","08":"CO","09":"CT","10":"DE","11":"DC","12":"FL",
"13":"GA","15":"HI","16":"ID","17":"IL","18":"IN","19":"IA","20":"KS","21":"KY","22":"LA","23":"ME","24":"MD",
"25":"MA","26":"MI","27":"MN","28":"MS","29":"MO","30":"MT","31":"NE","32":"NV","33":"NH","34":"NJ","35":"NM",
"36":"NY","37":"NC","38":"ND","39":"OH","40":"OK","41":"OR","42":"PA","44":"RI","45":"SC","46":"SD","47":"TN",
"48":"TX","49":"UT","50":"VT","51":"VA","53":"WA","54":"WV","55":"WI","56":"WY"})
NAME = ({AL:"Alabama",AK:"Alaska",AZ:"Arizona",AR:"Arkansas",CA:"California",CO:"Colorado",CT:"Connecticut",
DE:"Delaware",DC:"District of Columbia",FL:"Florida",GA:"Georgia",HI:"Hawaii",ID:"Idaho",IL:"Illinois",IN:"Indiana",
IA:"Iowa",KS:"Kansas",KY:"Kentucky",LA:"Louisiana",ME:"Maine",MD:"Maryland",MA:"Massachusetts",MI:"Michigan",
MN:"Minnesota",MS:"Mississippi",MO:"Missouri",MT:"Montana",NE:"Nebraska",NV:"Nevada",NH:"New Hampshire",
NJ:"New Jersey",NM:"New Mexico",NY:"New York",NC:"North Carolina",ND:"North Dakota",OH:"Ohio",OK:"Oklahoma",
OR:"Oregon",PA:"Pennsylvania",RI:"Rhode Island",SC:"South Carolina",SD:"South Dakota",TN:"Tennessee",TX:"Texas",
UT:"Utah",VT:"Vermont",VA:"Virginia",WA:"Washington",WV:"West Virginia",WI:"Wisconsin",WY:"Wyoming"})
// v1's seat files were rounded to four decimals, which loses up to 0.3% of the total; rescale to 1.
normalise = (rows) => { const t = d3.sum(rows, d => d.probability); return rows.map(d => ({...d, probability: d.probability / t})) }
pct = (p) => p >= 0.995 ? ">99%" : p <= 0.005 ? "<1%" : `${Math.round(p * 100)}%`
share = (x) => `${(x * 100).toFixed(1)}%`
pts = (x) => `${x > 0 ? "+" : x < 0 ? "−" : ""}${Math.abs(x * 100).toFixed(1)}`
esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]))
isInd = (r) => r.d_side !== "democrat"
favourite = (r) => r.p_dem_win >= 0.5
? {side: isInd(r) ? "Independent" : "Democrat", name: r.dem_candidate, p: r.p_dem_win, colour: isInd(r) ? IND : DEM}
: {side: "Republican", name: r.rep_candidate, p: 1 - r.p_dem_win, colour: REP}
rating = (p) => { const q = Math.max(p, 1 - p); return q < 0.6 ? "Toss-Up" : q < 0.8 ? "Lean" : q < 0.95 ? "Likely" : "Solid" }
ratingLabel = (r) => { const f = favourite(r), k = rating(f.p); return k === "Toss-Up" ? k : `${k} ${f.side.slice(0, 3)}` }
distribution = (rows, key) => d3.rollups(rows, v => d3.sum(v, d => d.probability), d => d[key])
.map(([seats, p]) => ({seats, p})).sort((a, b) => a.seats - b.seats)
median = (dist) => quantile(dist, 0.5)
quantile = (dist, q) => { let c = 0; for (const d of dist) { c += d.p; if (c >= q) return d.seats } }
// Candidates: party, display name, photo (if a free one exists) and the initials used when there is none.
PARTY = ({D: {short: "Dem", long: "Democrat", colour: DEM}, R: {short: "Rep", long: "Republican", colour: REP}, I: {short: "Ind", long: "Independent", colour: IND}})
sideParty = (r, side) => side === "rep" ? "R" : isInd(r) ? "I" : "D"
candName = (r, side) => side === "rep"
? (r.rep_candidate.startsWith("Several Republicans") ? "Republican Field (Top Four)" : r.rep_candidate)
: r.dem_candidate
initials = (name) => {
if (name.startsWith("Republican Field")) return "R"
const w = name.split(/\s+/).filter(x => x && !/^(Jr\.?|Sr\.?|II|III|IV)$/i.test(x) && !/^[A-Z]\.$/.test(x))
return w.length ? (w[0][0] + (w.length > 1 ? w[w.length - 1][0] : "")).toUpperCase() : "?"
}
raceTitle = (r) => `${NAME[r.state]} ${r.office === "senate" ? "Senate" : "Governor"}${isSpecial(r) ? " (Special)" : ""}`
avatar = (name, party, photo) => html`<span class="fc-av" style="--av:${PARTY[party].colour}" aria-hidden="true">${initials(name)}${photo
? `<img src="img/${esc(photo.image)}" alt="" loading="lazy" decoding="async" onerror="this.remove()">` : ""}</span>`
candLine = (party, name, p, photo) => html`<span class="${p >= 0.5 ? "fc-cand is-fav" : "fc-cand"}">
${avatar(name, party, photo)}
<span class="fc-cand-name"><span>${name}</span><span class="fc-cand-party">${PARTY[party].long}</span></span>
<span class="fc-cand-p" style="color:${textColour(PARTY[party].colour)}">${pct(p)}</span>
</span>`
candRow = (r, side) => candLine(sideParty(r, side), candName(r, side), side === "rep" ? 1 - r.p_dem_win : r.p_dem_win, photoFor.get(`${r.race_id}-${side}`))
segBar = (segs) => html`<span class="fc-pbar" aria-hidden="true">${segs.filter(s => s.p > 0).map(s => `<span style="width:${s.p * 100}%;background:${s.colour}"></span>`).join("")}<i></i></span>`
probBar = (r) => segBar([{p: r.p_dem_win, colour: PARTY[sideParty(r, "dem")].colour}, {p: 1 - r.p_dem_win, colour: REP}])
describe = (r) => `${raceTitle(r)}: ${candName(r, "dem")} (${PARTY[sideParty(r, "dem")].short}) ${pct(r.p_dem_win)}, ${candName(r, "rep")} (Rep) ${pct(1 - r.p_dem_win)}. ${ratingLabel(r)}.`
textOn = (fill) => d3.lab(fill).l > 62 ? "#1d1f23" : "#ffffff"
chip = (party, text) => `<span class="fc-chip" style="--chip:${PARTY[party].colour}">${text}</span>`
reveal = (sel) => setTimeout(() => document.querySelector(sel)?.scrollIntoView({block: "nearest", behavior: "smooth"}), 80)
// Cards for the closest races: clicking one selects it. `mark` highlights the selected card without redrawing.
cardGrid = (items, onPick) => {
const el = html`<div class="fc-races">${items.map(it => html`<button type="button" class="fc-race" data-id="${it.id}" aria-pressed="false">
<span class="fc-race-head"><span class="fc-race-title">${it.title}</span><span class="fc-rating">${it.rating}</span></span>
${it.lines}${it.bar}<span class="fc-race-foot">${it.foot}</span></button>`)}</div>`
for (const b of el.querySelectorAll("button")) b.addEventListener("click", () => onPick(b.dataset.id))
el.mark = id => { for (const b of el.querySelectorAll("button")) b.setAttribute("aria-pressed", b.dataset.id === id) }
return el
}
// A light sortable, searchable table. Each column: {key, label, value(row) for sorting, render(row) as HTML, cls}.
dataTable = ({items, columns, sortKey, sortDir = 1, searchText, placeholder, noun, caption, onPick}) => {
const el = html`<div class="fc-tbl">
<div class="fc-tbl-bar">
<label class="fc-tbl-search"><span class="visually-hidden">Search ${noun[1]}</span>
<input type="search" placeholder="${placeholder}" autocomplete="off"></label>
<span class="fc-tbl-count" aria-live="polite"></span>
</div>
<div class="fc-tbl-scroll"><table><caption class="visually-hidden">${caption}</caption>
<thead><tr>${columns.map(c => `<th scope="col" class="${c.cls ?? ""}" data-key="${c.key}" aria-sort="none"><button type="button">${c.label}<span class="fc-sort" aria-hidden="true"></span></button></th>`).join("")}</tr></thead>
<tbody></tbody></table></div></div>`
const input = el.querySelector("input"), tbody = el.querySelector("tbody"), count = el.querySelector(".fc-tbl-count")
const index = items.map(it => ({it, text: searchText(it).toLowerCase()}))
const draw = () => {
const words = input.value.toLowerCase().split(/\s+/).filter(Boolean)
const col = columns.find(c => c.key === sortKey)
const cmp = col.compare ?? ((a, b) => d3.ascending(col.value(a), col.value(b)))
const rows = index.filter(x => words.every(w => x.text.includes(w))).map(x => x.it).sort((a, b) => sortDir * cmp(a, b))
count.textContent = rows.length === items.length ? `${items.length} ${noun[1]}` : `${rows.length} of ${items.length} ${noun[1]}`
for (const th of el.querySelectorAll("th")) th.setAttribute("aria-sort", th.dataset.key === sortKey ? (sortDir > 0 ? "ascending" : "descending") : "none")
if (!rows.length) {
const tr = document.createElement("tr"), td = document.createElement("td"), btn = document.createElement("button")
tr.className = "fc-tbl-empty"; td.colSpan = columns.length
td.append(`No ${noun[1]} match “${input.value.trim()}”. `)
btn.type = "button"; btn.textContent = "Clear the Search"; btn.onclick = () => { input.value = ""; draw(); input.focus() }
td.append(btn); tr.append(td); tbody.replaceChildren(tr); return
}
tbody.innerHTML = rows.map(r => `<tr data-id="${r.id}">${columns.map(c => `<td class="${c.cls ?? ""}">${c.render(r)}</td>`).join("")}</tr>`).join("")
}
input.addEventListener("input", draw)
for (const th of el.querySelectorAll("th")) th.querySelector("button").addEventListener("click", () => {
sortDir = th.dataset.key === sortKey ? -sortDir : 1; sortKey = th.dataset.key; draw()
})
if (onPick) tbody.addEventListener("click", e => {
const tr = e.target.closest("tr[data-id]")
if (tr && (e.target.closest(".fc-row-link") || !e.target.closest("a, button"))) onPick(tr.dataset.id)
})
draw()
return el
}Code
senateDist = distribution(senateSeats, "dem_seats")
pSenateD = d3.sum(senateSeats.filter(d => d.dem_seats >= 51), d => d.probability)
pSenateR = d3.sum(senateSeats.filter(d => 100 - d.dem_seats - d.independent_seats >= 50), d => d.probability)
govDist = distribution(governorSeats, "dem_seats")
pGovD = d3.sum(governorSeats.filter(d => d.dem_seats >= 26), d => d.probability)
pGovR = d3.sum(governorSeats.filter(d => d.dem_seats + d.independent_seats <= 24), d => d.probability)
houseDist = distribution(houseSeats, "dem_seats")
pHouseD = d3.sum(houseSeats.filter(d => d.dem_seats >= 218), d => d.probability)
pHouseR = d3.sum(houseSeats.filter(d => d.rep_seats >= 218), d => d.probability)Code
// The one control for the page: four headline numbers that are also the tabs. Once the reader scrolls
// into the tab content it sticks under the site header as a slim row of labels and numbers.
viewof tab = {
const tabs = ["senate", "house", "governors", "turnout"]
const panel = k => k === "house" ? "fc-panel-house" : k === "turnout" ? "fc-panel-turnout" : "fc-panel-races"
const fromHash = () => { const h = location.hash.slice(1).toLowerCase(); return tabs.includes(h) ? h : null }
const bar = (pD, pR) => `<span class="fc-card-bar" aria-hidden="true"><span class="fc-bar"><span style="width:${pD * 100}%;background:${DEM}"></span><span style="flex:1;background:var(--fc-tie)"></span><span style="width:${pR * 100}%;background:${REP}"></span></span></span>`
const card = (key, label, big, colour, text, extra) => `<button type="button" role="tab" class="fc-card" id="fc-tab-${key}" data-tab="${key}" aria-controls="${panel(key)}">
<span class="fc-card-label">${label}</span>
<span class="fc-card-big" style="color:${textColour(colour)}">${big}</span>
<span class="fc-card-text">${text}</span>
${extra}</button>`
const race = (key, label, prize, pD, pR) => {
const [lead, other] = pD >= pR ? [["Democrats", pD, DEM], ["Republicans", pR]] : [["Republicans", pR, REP], ["Democrats", pD]]
return card(key, label, pct(lead[1]), lead[2], `Chance ${lead[0]} win ${prize}. ${other[0]}: ${pct(other[1])}.`, bar(pD, pR))
}
const lo = national.turnout_2026_lo80, hi = national.turnout_2026_hi80, scale = x => (x - 0.3) / 0.35 * 100
const el = html`<div class="fc-switch">
<div class="fc-cards" role="tablist" aria-label="Forecast sections">
${race("senate", "Senate", "the Senate", pSenateD, pSenateR)}
${race("house", "House", "the House", pHouseD, pHouseR)}
${race("governors", "Governors", "a majority", pGovD, pGovR)}
${card("turnout", "Turnout", `${(national.turnout_2026_median * 100).toFixed(1)}%`, GREEN,
`Expected share of adult citizens voting. In 2022: ${(TURNOUT_2022 * 100).toFixed(1)}%.`,
`<span class="fc-card-bar" aria-hidden="true"><span class="fc-range"><span style="left:${scale(lo)}%;width:${scale(hi) - scale(lo)}%;background:${GREEN}"></span><i style="left:${scale(TURNOUT_2022)}%"></i></span></span>`)}
</div>
<div class="fc-jump"></div>
</div>`
const buttons = [...el.querySelectorAll("[role=tab]")]
const paint = () => buttons.forEach(b => { const on = b.dataset.tab === el.value; b.setAttribute("aria-selected", on); b.tabIndex = on ? 0 : -1 })
const head = () => { const h = document.querySelector(".sitehead")?.offsetHeight ?? 64; document.body.style.setProperty("--fc-head", `${h}px`); return h }
// Compact once the tab content has scrolled under the full bar by the height the bar is about to give up.
// That height goes into --fc-shrink, a top margin above the stuck bar, so at the switch the bar stays stuck,
// the content beneath it is already continuous with its new bottom edge, and nothing moves or opens a gap.
let compactH = 0, fullH = 0
el.barH = () => compactH || 49
const measure = (cell) => {
fullH = cell.offsetHeight
el.classList.add("is-compact"); compactH = cell.offsetHeight; el.classList.remove("is-compact")
document.body.style.setProperty("--fc-bar", `${compactH}px`)
}
const fit = (force) => {
const cell = el.closest(".fc-tabbar"), wrap = el.closest(".fc-tabwrap")
if (!cell || !wrap) return
if (force || !compactH) { el.classList.remove("is-compact"); cell.style.setProperty("--fc-shrink", "0px"); measure(cell) }
const want = wrap.getBoundingClientRect().top < head() - (fullH - compactH) - 1
if (!want && el.classList.contains("is-compact")) { el.classList.remove("is-compact"); cell.style.setProperty("--fc-shrink", "0px") }
if (want && !el.classList.contains("is-compact")) {
el.classList.add("is-compact")
cell.style.setProperty("--fc-shrink", `${fullH - compactH}px`)
}
}
el.jump = el.querySelector(".fc-jump")
el.setTab = (k, focus) => {
if (!tabs.includes(k)) return
if (location.hash.slice(1) !== k) history.replaceState(null, "", `#${k}`)
if (k !== el.value) {
el.value = k; paint(); el.dispatchEvent(new Event("input", {bubbles: true}))
// If the reader has scrolled into a tab, start the new one from its top, just under the slim bar.
// (every panel starts at the same point, so the one still showing gives the position)
const p = document.querySelector(".fc-panel.is-active")
if (p && el.classList.contains("is-compact")) {
const y = Math.max(0, p.getBoundingClientRect().top + window.scrollY - head() - el.barH() - 16)
setTimeout(() => window.scrollTo({top: y, behavior: "instant"}), 0)
}
}
const b = buttons.find(b => b.dataset.tab === k)
if (focus) b.focus()
}
el.addEventListener("click", e => { const b = e.target.closest("[role=tab]"); if (b) el.setTab(b.dataset.tab) })
el.addEventListener("keydown", e => {
const i = buttons.indexOf(document.activeElement)
if (i < 0) return
const j = {ArrowRight: i + 1, ArrowLeft: i - 1, ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1}[e.key]
if (j === undefined) return
e.preventDefault()
el.setTab(buttons[(j + buttons.length) % buttons.length].dataset.tab, true)
})
addEventListener("hashchange", () => { const h = fromHash(); if (h) el.setTab(h) })
el.value = fromHash() ?? "senate"
paint()
head()
let raf = 0
const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => fit()) }
const onResize = () => { head(); compactH = 0; fit(true) }
addEventListener("scroll", onScroll, {passive: true})
addEventListener("resize", onResize)
invalidation.then(() => { removeEventListener("scroll", onScroll); removeEventListener("resize", onResize) })
// the site header can change height (fonts loading, narrow screens), so keep the sticky offset in step with it
const sh = document.querySelector(".sitehead")
if (sh) new ResizeObserver(head).observe(sh)
setTimeout(() => fit(), 0)
return el
}Code
{
const which = tab === "house" ? "house" : tab === "turnout" ? "turnout" : "races"
for (const k of ["races", "house", "turnout"]) {
const p = document.getElementById(`fc-panel-${k}`)
if (!p) continue
p.classList.toggle("is-active", k === which)
p.setAttribute("role", "tabpanel")
p.setAttribute("aria-labelledby", `fc-tab-${k === "races" ? (tab === "governors" ? "governors" : "senate") : k}`)
}
}Code
// The width the tab panels actually have (narrower than the page when the contents rail is showing).
pw = Generators.observe(notify => {
const el = document.querySelector(".fc-tabbar")
let last = 0
const measure = () => { const w = Math.floor(el?.getBoundingClientRect().width || width); if (Math.abs(w - last) > 4) { last = w; notify(Math.max(280, w)) } }
measure()
const ro = new ResizeObserver(measure)
if (el) ro.observe(el)
return () => ro.disconnect()
})Code
// Contents for the current tab: a sticky rail on wide screens, a compact jump menu in the tab bar on narrow ones.
{
const panel = document.getElementById(tab === "house" ? "fc-panel-house" : tab === "turnout" ? "fc-panel-turnout" : "fc-panel-races")
// Quarto puts each heading's id on its <section>; the rail scrolls to the section.
const heads = panel ? [...panel.querySelectorAll("section.level2")] : []
const label = {senate: "Senate", house: "House", governors: "Governors", turnout: "Turnout"}[tab]
const items = heads.map(h => ({id: h.id, text: h.querySelector("h2")?.textContent.trim() ?? ""}))
const nav = html`<nav class="fc-rail" aria-label="Sections in the ${label} tab"><div class="fc-rail-title">${label}</div>
<ol>${items.map(i => `<li><a href="#${i.id}" data-id="${i.id}">${esc(i.text)}</a></li>`).join("")}</ol></nav>`
const jump = html`<label class="fc-jumpsel"><span class="visually-hidden">Jump to a section</span><select>
<option value="">Jump To</option>${items.map(i => `<option value="${i.id}">${esc(i.text)}</option>`).join("")}</select></label>`
const sel = jump.querySelector("select")
viewof tab.jump.replaceChildren(jump)
const offset = () => (document.querySelector(".sitehead")?.offsetHeight ?? 64) + viewof tab.barH() + 16
const go = (id) => {
const h = document.getElementById(id)
if (!h) return
// window.scrollY, not scrollY: OJS reads a bare global once, when the cell first runs
window.scrollTo({top: h.getBoundingClientRect().top + window.scrollY - offset() + 4, behavior: "smooth"})
h.setAttribute("tabindex", "-1"); h.focus({preventScroll: true})
}
nav.addEventListener("click", e => { const a = e.target.closest("a[data-id]"); if (a) { e.preventDefault(); go(a.dataset.id) } })
sel.addEventListener("change", () => { if (sel.value) go(sel.value); sel.value = "" })
const links = [...nav.querySelectorAll("a")]
const spy = () => {
if (!panel?.classList.contains("is-active")) return
let cur = heads[0]
for (const h of heads) if (h.getBoundingClientRect().top - offset() <= 8) cur = h
for (const a of links) { const on = a.dataset.id === cur?.id; a.classList.toggle("is-current", on); on ? a.setAttribute("aria-current", "true") : a.removeAttribute("aria-current") }
}
let raf = 0
const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(spy) }
addEventListener("scroll", onScroll, {passive: true})
invalidation.then(() => removeEventListener("scroll", onScroll))
spy(); setTimeout(spy, 300); setTimeout(spy, 1500)
return nav
}Code
office = tab === "governors" ? "governor" : "senate"
officeRaces = races.filter(r => r.office === office)
closeness = (a, b) => Math.abs(a.p_dem_win - 0.5) - Math.abs(b.p_dem_win - 0.5)
keyRaces = officeRaces.filter(r => favourite(r).p < 0.8).sort(closeness)
closestRaces = [...officeRaces].sort(closeness).slice(0, 6)
officeWord = office === "senate" ? "Senate" : "governor"
mutable pickId = null
current = officeRaces.find(r => r.race_id === pickId) ?? closestRaces[0]
raceByState = new Map(officeRaces.map(r => [r.state, r]))
mapData = states.map(f => ({...f, state: FIPS[f.id], race: raceByState.get(FIPS[f.id])}))
pickRace = (id) => { mutable pickId = id; reveal(".fc-detail") }Closest Races
Code
keyCards = cardGrid(closestRaces.map(r => ({
id: r.race_id, title: raceTitle(r), rating: ratingLabel(r),
lines: [candRow(r, "dem"), candRow(r, "rep")], bar: probBar(r),
foot: r.n_polls > 0 ? `Poll Average: ${PARTY[sideParty(r, "dem")].short} ${share(r.poll_avg)} (${r.n_polls} Poll${r.n_polls === 1 ? "" : "s"})` : "No Polls Yet"
})), pickRace)Every Race
Select a state, or pick a race from the list, to see its candidates and the numbers behind the chance. The small north-eastern states are labelled to the right of the map.
Code
raceMap = {
const narrow = pw < 860
const w = Math.round(narrow ? pw : pw - 356), gutter = narrow ? 46 : 58, h = Math.round((w - gutter) * 0.62)
const projection = d3.geoAlbersUsa().fitExtent([[4, 4], [w - gutter, h - 4]], {type: "FeatureCollection", features: states})
const path = d3.geoPath(projection)
const select = (id) => { mutable pickId = id }
const picker = html`<label class="fc-pick"><span>Race</span><select>${[...officeRaces].sort((a, b) => d3.ascending(NAME[a.state], NAME[b.state]))
.map(r => `<option value="${r.race_id}">${NAME[r.state]}${isSpecial(r) ? " (Special)" : ""}: ${ratingLabel(r)}</option>`).join("")}</select></label>`
const sel = picker.querySelector("select")
sel.addEventListener("change", () => select(sel.value))
const svg = d3.create("svg").attr("class", "fc-map").attr("viewBox", [0, 0, w, h]).attr("width", w).attr("height", h)
.attr("role", "group").attr("aria-label", `Map of the ${officeRaces.length} ${officeWord} races, coloured by the chance each side wins`)
svg.append("g").selectAll("path").data(mapData.filter(d => !d.race)).join("path").attr("class", "fc-st-none").attr("d", path)
const raced = mapData.filter(d => d.race)
const race = svg.append("g").selectAll("path").data(raced).join("path")
.attr("class", "fc-st").attr("d", path).attr("fill", d => probColour(d.race.p_dem_win))
.attr("tabindex", 0).attr("role", "button").attr("aria-label", d => describe(d.race))
.on("click", (e, d) => select(d.race.race_id))
.on("keydown", (e, d) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(d.race.race_id) } })
race.append("title").text(d => describe(d.race))
const outline = svg.append("path").attr("class", "fc-st-sel")
// Labels: inside the state where it fits, otherwise a callout in a column to the right with a leader line.
const SMALL = new Set(["VT", "NH", "MA", "RI", "CT", "NJ", "DE", "MD", "DC"])
const inside = raced.filter(d => !SMALL.has(d.state))
const out = raced.filter(d => SMALL.has(d.state)).map(d => ({d, c: path.centroid(d)})).sort((a, b) => a.c[1] - b.c[1])
let y = -Infinity
for (const o of out) { o.y = Math.max(o.c[1], y + 17); y = o.y }
const over = (out.at(-1)?.y ?? 0) - (h - 10)
if (over > 0) out.forEach(o => o.y -= over)
const lx = w - gutter / 2 + 2
svg.append("g").attr("class", "fc-st-labels").attr("aria-hidden", "true").selectAll("text").data(inside).join("text")
.attr("transform", d => { const [x, yy] = path.centroid(d); return `translate(${x + (d.state === "FL" ? 6 : d.state === "MI" ? 4 : 0)},${yy + (d.state === "MI" ? 8 : 0)})` })
.attr("dy", "0.35em").attr("fill", d => textOn(probColour(d.race.p_dem_win))).text(d => d.state)
const call = svg.append("g").attr("class", "fc-st-callouts").attr("aria-hidden", "true").selectAll("g").data(out).join("g")
.on("click", (e, o) => select(o.d.race.race_id))
call.append("path").attr("class", "fc-leader").attr("d", o => `M${o.c[0]},${o.c[1]}L${lx - 20},${o.y}`)
call.append("circle").attr("class", "fc-leader-dot").attr("cx", o => o.c[0]).attr("cy", o => o.c[1]).attr("r", 2)
call.append("rect").attr("x", lx - 17).attr("y", o => o.y - 8).attr("width", 34).attr("height", 16).attr("rx", 8)
.attr("fill", o => probColour(o.d.race.p_dem_win))
call.append("text").attr("x", lx).attr("y", o => o.y).attr("dy", "0.35em").attr("fill", o => textOn(probColour(o.d.race.p_dem_win))).text(o => o.d.state)
const legend = Plot.legend({color: {type: "linear", domain: [0, 1], interpolate: probColour,
tickFormat: d => d === 0 ? "Rep" : d === 1 ? "Dem" : d === 0.5 ? "Even" : "", ticks: [0, 0.5, 1]},
width: 210, marginLeft: 10, marginRight: 10, label: "Chance Democrats Win", className: "fc-plot"})
const el = html`<div class="fc-map-wrap"><div class="fc-map-top">${picker}<div class="fc-legend fc-map-legend">${legend}<span class="fc-key"><span class="fc-key-none"></span>No Race</span></div></div>${svg.node()}</div>`
el.mark = id => {
const d = mapData.find(m => m.race?.race_id === id)
outline.attr("d", d ? path(d) : null)
race.attr("aria-pressed", m => m.race.race_id === id)
call.classed("is-selected", o => o.d.race.race_id === id)
sel.value = id
}
return el
}Code
detailPanel = {
const el = html`<aside class="fc-detail" aria-live="polite" aria-label="Selected race"></aside>`
el.render = (r) => {
const dp = PARTY[sideParty(r, "dem")], inc = PARTY[r.incumbent_party]?.short ?? r.incumbent_party
const seat = r.open_seat ? `Open seat. ${r.incumbent} (${inc}) is not on the ballot.` : `${r.incumbent} (${inc}) is running again.`
el.replaceChildren(html`<div class="fc-detail-body"><div class="fc-detail-kicker">${r === closestRaces[0] ? "Closest Race" : "Selected Race"}</div>
<h3 class="fc-detail-title">${raceTitle(r)}</h3>
<span class="fc-rating">${ratingLabel(r)}</span>
<div class="fc-detail-cands">${candRow(r, "dem")}${candRow(r, "rep")}</div>
${probBar(r)}
<dl class="fc-facts">
<dt>Seat</dt><dd>${seat}</dd>
<dt>Expected Vote</dt><dd>${dp.short} ${share(r.dem_share_median)}, Rep ${share(1 - r.dem_share_median)} of the two-party vote</dd>
<dt>Likely Range</dt><dd>${dp.short} between ${share(r.dem_share_lo80)} and ${share(r.dem_share_hi80)} in 80% of simulations</dd>
<dt>Polls</dt><dd>${r.n_polls > 0 ? `${dp.short} ${share(r.poll_avg)} on average, from ${r.n_polls} poll${r.n_polls === 1 ? "" : "s"}` : "No polls yet, so this race rests on the fundamentals alone"}</dd>
${r.n_polls > 0 ? `<dt>Without Polls</dt><dd>${candName(r, "dem")} would have a ${pct(r.fundamentals_p_dem_win)} chance</dd>` : ""}
</dl></div>`)
}
return el
}Balance of Power
Code
{
const up = [...officeRaces].sort((a, b) => b.p_dem_win - a.p_dem_win)
const total = office === "senate" ? 100 : 50
const notUpD = office === "senate" ? 47 - up.filter(r => ["D", "I"].includes(r.incumbent_party)).length : 6
const notUpR = total - notUpD - up.length
const cells = [
...d3.range(notUpD).map(() => ({p: 1, label: "Democratic, not up this year"})),
...up.map(r => ({p: r.p_dem_win, race: r, label: describe(r)})),
...d3.range(notUpR).map(() => ({p: 0, label: "Republican, not up this year"}))
]
const favD = notUpD + up.filter(r => r.p_dem_win >= 0.5).length
const el = html`<div class="fc-strip-wrap">
<p class="fc-takeaway">Every ${office === "senate" ? "Senate seat" : "governorship"}, safest Democratic on the left to safest Republican on the right; the solid ends are not up this year. If every race went to its favourite, Democrats would hold ${favD} and Republicans ${total - favD}.</p>
<div class="fc-strip-labels"><span style="color:${textColour(DEM)}">Democratic</span><strong>${office === "senate" ? "51 for Control" : "26 for a Majority"}</strong><span style="color:${textColour(REP)}">Republican</span></div>
<div class="fc-strip">${cells.map(d => `<span class="${d.race ? "is-race" : ""}" data-id="${d.race?.race_id ?? ""}" style="background:${probColour(d.p)}" title="${esc(d.label)}"></span>`).join("")}<i></i></div>
<div class="fc-strip-labels fc-strip-foot"><span>${notUpD} Not Up</span><span>${up.length} on the Ballot</span><span>${notUpR} Not Up</span></div>
</div>`
for (const s of el.querySelectorAll(".is-race")) s.addEventListener("click", () => pickRace(s.dataset.id))
return el
}Range of Outcomes
Code
function seatChart({dist, lo, hi, majority, majorityLabel, step, xLabel, colourOf, unit}) {
const rows = dist.filter(d => d.seats >= lo && d.seats <= hi)
const med = rows.filter(d => d.seats === median(dist))
const top = d3.max(rows, d => d.p) * 1.3
return Plot.plot({
width: pw, height: 250, marginLeft: 44, marginTop: 26, className: "fc-plot",
x: {label: xLabel, domain: [lo - 0.5, hi + 0.5], ticks: d3.range(Math.ceil(lo / step) * step, hi + 1, step), tickFormat: "d"},
y: {label: "Chance (%)", grid: true, percent: true, domain: [0, top * 100]},
marks: [
Plot.rectY(rows, {x1: d => d.seats - 0.5, x2: d => d.seats + 0.5, y: "p", fill: colourOf, inset: rows.length > 40 ? 0.3 : 1,
tip: true, title: d => `${d.seats} ${unit}: ${(d.p * 100).toFixed(1)}%`}),
Plot.ruleX([majority], {y1: 0, y2: top, stroke: "currentColor", strokeWidth: 1.2, strokeDasharray: "4,3"}),
Plot.text([majorityLabel], {x: majority, frameAnchor: "top", dy: -14, dx: 5, textAnchor: "start", fontSize: 11.5, fontWeight: 600, className: "fc-halo"}),
Plot.text(med, {x: "seats", y: "p", text: d => `Median ${d.seats}`, dy: -9, fontSize: 11.5, className: "fc-halo"}),
Plot.ruleY([0])
]
})
}
seatKey = (items) => html`<div class="fc-legend fc-seat-key">${items.map(([c, t]) => `<span class="fc-key"><span style="background:${c}"></span>${t}</span>`).join("")}</div>`Code
{
const dist = office === "senate" ? senateDist : govDist
const unit = office === "senate" ? "Democratic-caucus Senate seats" : "Democratic governors"
const takeaway = html`<p class="fc-takeaway">The median simulation gives ${median(dist)} ${unit}. In 80% of simulations the number lands between ${quantile(dist, 0.1)} and ${quantile(dist, 0.9)}.</p>`
const chart = office === "senate"
? seatChart({dist, lo: 42, hi: 58, majority: 50.5, majorityLabel: "51 for Control", step: 2, xLabel: "Democratic-Caucus Senate Seats", unit: "seats",
colourOf: d => d.seats >= 51 ? DEM : REP})
: seatChart({dist, lo: 16, hi: 36, majority: 25.5, majorityLabel: "26 for a Majority", step: 2, xLabel: "Democratic Governors", unit: "Democratic governors",
colourOf: d => d.seats >= 26 ? DEM : d.seats === 25 ? NO_RACE : REP})
const key = office === "senate"
? seatKey([[DEM, "Democratic Control"], [REP, "Republican Control (50 With the VP)"]])
: seatKey([[DEM, "Democratic Majority"], [NO_RACE, "25–25 Split"], [REP, "Republican Majority"]])
return html`<div>${takeaway}<div class="fc-chartbox">${chart}${key}</div></div>`
}All Races
Code
dataTable({
items: officeRaces.map(r => ({...r, id: r.race_id})),
caption: `All ${officeRaces.length} ${officeWord} races`,
noun: ["race", "races"], placeholder: "Search a state or candidate",
searchText: r => `${NAME[r.state]} ${r.state} ${r.dem_candidate} ${r.rep_candidate} ${ratingLabel(r)}`,
sortKey: "race", onPick: pickRace,
columns: [
{key: "race", label: "Race", value: r => NAME[r.state] + (isSpecial(r) ? " 2" : " 1"),
render: r => `<button type="button" class="fc-row-link">${NAME[r.state]}${isSpecial(r) ? " (Special)" : ""}</button><span class="fc-sub fc-narrow">${candName(r, "dem")} v ${candName(r, "rep")}</span>`},
{key: "dem", label: "Democrat", cls: "fc-wide", value: r => r.dem_candidate,
render: r => `${r.dem_candidate}${isInd(r) ? ` <span class="fc-dim">(Ind)</span>` : ""}`},
{key: "rep", label: "Republican", cls: "fc-wide", value: r => r.rep_candidate, render: r => candName(r, "rep")},
{key: "fav", label: "Favourite", value: r => favourite(r).p,
render: r => { const f = favourite(r), party = r.p_dem_win >= 0.5 ? sideParty(r, "dem") : "R"
return `${chip(party, `${PARTY[party].short} ${pct(f.p)}`)}<span class="fc-sub fc-narrow">${ratingLabel(r)}</span>` }},
{key: "rating", label: "Rating", cls: "fc-wide fc-nowrap", value: r => favourite(r).p, render: r => ratingLabel(r)},
{key: "polls", label: "Poll Average", cls: "fc-wide fc-num", value: r => r.n_polls > 0 ? r.poll_avg : -1,
render: r => r.n_polls > 0 ? `${share(r.poll_avg)} <span class="fc-dim">${PARTY[sideParty(r, "dem")].short}</span>` : `<span class="fc-dim">No Polls</span>`}
]
})Code
houseRows = [...districts].map(r => {
const names = (s) => (s ?? "").split(";").map(x => x.trim()).filter(Boolean).join(", ")
const cands = []
if (r.dem_candidates) cands.push({party: "D", name: names(r.dem_candidates), p: r.p_dem_win})
if (r.rep_candidates) cands.push({party: "R", name: names(r.rep_candidates), p: r.p_rep_win})
if (!r.certain_hold_party && (r.stand_in || r.p_ind_win > 0.005)) cands.push({party: "I", name: r.stand_in || "Other Candidate", p: r.p_ind_win})
const fav = r.certain_hold_party ? {party: r.certain_hold_party, p: 1} : d3.greatest(cands, c => c.p) ?? {party: "R", p: r.p_rep_win}
const k = rating(fav.p)
return {...r, id: r.cd, cands, fav,
rating: r.certain_hold_party ? `Certain ${PARTY[fav.party].short}` : k === "Toss-Up" ? k : `${k} ${PARTY[fav.party].short}`,
p: r.certain_hold_party === "D" ? 1 : r.certain_hold_party === "R" ? 0 : r.p_dem_win}
}).sort((a, b) => b.p - a.p).map((r, i) => ({...r, i}))
mutable districtPick = null
currentDistrict = houseRows.find(r => r.cd === districtPick) ?? null
closeDistricts = houseRows.filter(r => !r.certain_hold_party).sort((a, b) => a.fav.p - b.fav.p).slice(0, 9)
pickDistrict = (cd) => { mutable districtPick = cd; reveal(".fc-hemi") }
districtBar = (r) => segBar(["D", "I", "R"].map(party => ({p: r.cands.find(c => c.party === party)?.p ?? (r.certain_hold_party === party ? 1 : 0), colour: PARTY[party].colour})))
cdOrder = (cd) => { const [s, n] = cd.split("-"); return `${s}-${n === "AL" ? "00" : n.padStart(2, "0")}` }Closest Districts
Code
{
const favD = houseRows.filter(r => r.fav.party === "D").length, favR = houseRows.filter(r => r.fav.party === "R").length
return html`<p class="fc-takeaway fc-lead">Democrats are favoured in ${favD} of the 435 districts and Republicans in ${favR}. The median simulation gives Democrats ${median(houseDist)} seats.</p>`
}Code
houseCards = cardGrid(closeDistricts.map(r => ({
id: r.cd, title: r.cd, rating: r.rating,
lines: r.cands.map(c => candLine(c.party, c.name, c.p)), bar: districtBar(r),
foot: r.incumbents_running ? `Incumbent Running: ${r.incumbents_running.replaceAll(";", ",")}` : "No Incumbent Running"
})), pickDistrict)Every District
Code
hemicycle = {
const d = houseRows, n = d.length
const W = Math.min(pw, 720), R = W / 2 - 8, rows = 12, r0 = 0.36, top = 30
const radii = d3.range(rows).map(i => R * (r0 + (1 - r0) * i / (rows - 1)))
const counts = radii.map(r => Math.floor(n * r / d3.sum(radii)))
for (let i = rows - 1, left = n - d3.sum(counts); left > 0; i = (i - 1 + rows) % rows, left--) counts[i]++
const cx = W / 2, cy = top + R, dot = (R * (1 - r0) / (rows - 1)) * 0.42
const seats = radii.flatMap((r, i) => d3.range(counts[i]).map(j => {
const a = Math.PI * (1 - (counts[i] === 1 ? 0.5 : j / (counts[i] - 1)))
return {a, r, x: cx + r * Math.cos(a), y: cy - r * Math.sin(a)}
})).sort((p, q) => d3.descending(p.a, q.a) || d3.ascending(p.r, q.r))
d.forEach((r, i) => Object.assign(r, {x: seats[i].x, y: seats[i].y}))
const H = cy + 22
const svg = d3.create("svg").attr("class", "fc-hemi-svg").attr("viewBox", [0, 0, W, H]).attr("width", W).attr("height", H)
.attr("tabindex", 0).attr("role", "group")
.attr("aria-label", "All 435 House districts in a half circle, from the safest Democratic seat on the left to the safest Republican seat on the right. Use the arrow keys to move between districts and Escape to close the details.")
svg.append("line").attr("class", "fc-hemi-mid").attr("x1", cx).attr("x2", cx).attr("y1", 14).attr("y2", cy - R * r0 + 6)
svg.append("text").attr("class", "fc-hemi-label").attr("x", cx).attr("y", 10).text("218 for a Majority")
svg.append("g").selectAll("circle").data(d).join("circle")
.attr("cx", r => r.x).attr("cy", r => r.y).attr("r", dot).attr("fill", r => probColour(r.p))
svg.append("circle").attr("class", "fc-hemi-218").attr("cx", d[217].x).attr("cy", d[217].y).attr("r", dot + 2.2)
const ring = svg.append("circle").attr("class", "fc-hemi-sel").attr("r", dot + 2.6).attr("opacity", 0)
svg.append("text").attr("class", "fc-hemi-end").attr("x", cx - R).attr("y", cy + 18).attr("text-anchor", "start").style("fill", textColour(DEM)).text("Safest Dem")
svg.append("text").attr("class", "fc-hemi-end").attr("x", cx + R).attr("y", cy + 18).attr("text-anchor", "end").style("fill", textColour(REP)).text("Safest Rep")
const pop = html`<div class="fc-pop" hidden></div>`
const live = html`<div class="visually-hidden" aria-live="polite"></div>`
const wrap = html`<div class="fc-hemi">${svg.node()}${pop}${live}</div>`
let at = 217, pinned = false
const place = (r) => {
const box = svg.node().getBoundingClientRect(), k = box.width / W
const x = r.x * k, y = r.y * k, pwid = pop.offsetWidth, ph = pop.offsetHeight, ww = wrap.clientWidth
const left = Math.max(0, Math.min(ww - pwid, x - pwid / 2))
const above = y - ph - dot * k - 10
pop.style.left = `${left}px`
pop.style.top = `${above >= 0 ? above : y + dot * k + 10}px`
}
const show = (i, pin) => {
at = Math.max(0, Math.min(n - 1, i)); const r = d[at]
pinned = pin ?? pinned
ring.attr("cx", r.x).attr("cy", r.y).attr("opacity", 1)
pop.replaceChildren(html`<div class="fc-pop-head"><strong>${r.cd}</strong><span class="fc-rating">${r.rating}</span>
<button type="button" class="fc-pop-close" aria-label="Close">×</button></div>
<div class="fc-pop-kicker">${at === 217 ? "The 218th Seat" : `Seat ${at + 1} of 435`}</div>
${r.certain_hold_party
? `<p class="fc-dim">Only ${r.certain_hold_party === "D" ? "Democrats" : "Republicans"} are on the ballot (${r.cands.map(c => c.name).join(", ")}), so this is a certain hold.</p>`
: html`<div class="fc-pop-cands">${r.cands.map(c => candLine(c.party, c.name, c.p))}</div>`}
${r.certain_hold_party ? "" : districtBar(r)}
${r.incumbents_running ? `<p class="fc-dim">Incumbent running: ${r.incumbents_running.replaceAll(";", ",")}</p>` : ""}`)
pop.querySelector(".fc-pop-close").addEventListener("click", () => { hide(true); svg.node().focus() })
pop.hidden = false
place(r)
live.textContent = `${r.cd}, ${r.rating}. ${r.cands.map(c => `${c.name} ${pct(c.p)}`).join(", ")}`
}
const hide = (force) => { if (pinned && !force) return; pinned = false; pop.hidden = true; ring.attr("opacity", 0) }
const nearest = (e) => {
const [px, py] = d3.pointer(e, svg.node())
const best = d3.least(d, r => (r.x - px) ** 2 + (r.y - py) ** 2)
return (best.x - px) ** 2 + (best.y - py) ** 2 < (dot * 3) ** 2 ? best : null
}
svg.on("pointermove", e => { if (pinned || e.pointerType === "touch") return; const r = nearest(e); r ? show(r.i, false) : hide() })
svg.on("pointerleave", () => hide())
svg.on("click", e => { const r = nearest(e); if (r) { mutable districtPick = r.cd; show(r.i, true) } else hide(true) })
svg.on("keydown", e => {
const step = {ArrowRight: 1, ArrowLeft: -1, ArrowUp: 12, ArrowDown: -12, Home: -n, End: n}[e.key]
if (step) { e.preventDefault(); show(at + step, true) }
else if (e.key === "Escape") hide(true)
else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); show(at, true) }
})
svg.on("focus", () => { if (pop.hidden) show(at, true) })
const onKey = e => { if (e.key === "Escape" && !pop.hidden) hide(true) }
addEventListener("keydown", onKey)
invalidation.then(() => removeEventListener("keydown", onKey))
const out = html`<div>
<p class="fc-takeaway">Each dot is one district, ordered from the safest Democratic seat on the left to the safest Republican seat on the right. ${d.filter(r => !r.certain_hold_party && r.fav.p < 0.6).length} are closer than 60–40. The ringed dot at the top is the 218th seat, ${d[217].cd}, where the majority is decided. Hover over, tap or select a dot for its candidates.</p>
${wrap}</div>`
out.show = (cd) => { const r = d.find(x => x.cd === cd); if (r) show(r.i, true) }
return out
}Range of Outcomes
Code
{
const takeaway = html`<p class="fc-takeaway">Democrats win ${median(houseDist)} seats in the median simulation, and between ${quantile(houseDist, 0.1)} and ${quantile(houseDist, 0.9)} in 80% of simulations. A majority is 218.</p>`
const chart = seatChart({dist: houseDist, lo: 200, hi: 270, majority: 217.5, majorityLabel: "218 for a Majority", step: 10,
xLabel: "Democratic House Seats", unit: "Democratic seats", colourOf: d => d.seats >= 218 ? DEM : REP})
return html`<div>${takeaway}<div class="fc-chartbox">${chart}${seatKey([[DEM, "Democratic Majority"], [REP, "Republican Majority"]])}</div></div>`
}All Districts
Code
dataTable({
items: houseRows,
caption: "All 435 House districts",
noun: ["district", "districts"], placeholder: "Search a district, state or candidate",
searchText: r => `${r.cd} ${NAME[r.state] ?? ""} ${r.cands.map(c => c.name).join(" ")} ${r.rating}`,
sortKey: "cd", onPick: pickDistrict,
columns: [
{key: "cd", label: "District", value: r => cdOrder(r.cd),
render: r => `<button type="button" class="fc-row-link">${r.cd}</button><span class="fc-sub fc-narrow">${r.cands.map(c => c.name).join(" v ")}</span>`},
{key: "dem", label: "Democrat", cls: "fc-wide", value: r => r.dem_candidates ?? "", render: r => r.cands.find(c => c.party === "D")?.name ?? `<span class="fc-dim">None</span>`},
{key: "rep", label: "Republican or Other", cls: "fc-wide", value: r => r.rep_candidates ?? r.stand_in ?? "",
render: r => r.cands.filter(c => c.party !== "D").map(c => c.party === "I" ? `${c.name} <span class="fc-dim">(Other)</span>` : c.name).join(", ") || `<span class="fc-dim">None</span>`},
{key: "fav", label: "Favourite", value: r => r.fav.p,
render: r => `${chip(r.fav.party, r.certain_hold_party ? `${PARTY[r.fav.party].short}, Certain` : `${PARTY[r.fav.party].short} ${pct(r.fav.p)}`)}<span class="fc-sub fc-narrow">${r.rating}</span>`},
{key: "rating", label: "Rating", cls: "fc-wide fc-nowrap", value: r => r.fav.p, render: r => r.rating},
{key: "share", label: "Dem Share", cls: "fc-wide fc-num", value: r => r.dem_share_median ?? -1,
render: r => r.dem_share_median != null ? share(r.dem_share_median) : `<span class="fc-dim">n/a</span>`}
]
})Code
Turnout by County
Code
Code
{
const unit = id => id.startsWith("02") ? "02000" : id.startsWith("09") ? "09000" : ["29047", "29165"].includes(id) ? "29095" : id
const byUnit = new Map(turnout.map(t => [String(t.unit_fips).padStart(5, "0"), t]))
const data = counties.map(f => ({...f, t: byUnit.get(unit(f.id))}))
const up = turnout.filter(t => t.turnout_2026_median > t.turnout_2022).length
const top3 = stateTurnout.slice(0, 3).map(s => `${NAME[s.state]} (${pts(s.change)})`)
return html`<div><p class="fc-takeaway">Green means turnout is expected to rise from 2022 and purple means it is expected to fall. It rises in ${up.toLocaleString("en-GB")} of ${turnout.length.toLocaleString("en-GB")} counties. By state, the biggest rises, in points, are ${top3.slice(0, 2).join(", ")} and ${top3[2]}. Hover over or tap a county for its numbers.</p>
${Plot.plot({
// Map and legend fit in about 80% of the window height, so the whole map is visible while scrolling.
projection: "albers-usa", width: Math.round(Math.min(pw, (innerHeight * 0.8 - 64) / 0.6)), height: Math.round(Math.min(pw, (innerHeight * 0.8 - 64) / 0.6) * 0.6), className: "fc-plot",
color: {type: "linear", domain: [-10, 10], clamp: true, interpolate: changeColour, legend: true,
label: "Change in Turnout From 2022 (Points)", tickFormat: d => d > 0 ? `+${d}` : d},
marks: [
Plot.geo(data, {fill: d => d.t ? (d.t.turnout_2026_median - d.t.turnout_2022) * 100 : null, stroke: null}),
Plot.geo(stateMesh, {stroke: "white", strokeWidth: 0.7}),
Plot.tip(data.filter(d => d.t), Plot.pointer(Plot.geoCentroid({title: d =>
`${d.t.county}, ${d.t.state}\n2022: ${(d.t.turnout_2022 * 100).toFixed(1)}%\n2026 forecast: ${(d.t.turnout_2026_median * 100).toFixed(1)}% (likely ${(d.t.turnout_2026_lo80_county * 100).toFixed(0)} to ${(d.t.turnout_2026_hi80_county * 100).toFixed(0)}%)`})))
]
})}</div>`
}Turnout by State
Code
{
const rows = stateTurnout
const up = rows.filter(r => r.change > 0).length, bottom = rows.at(-1)
const changeChip = (x) => `<span class="fc-chip fc-chip-soft" style="--chip:${x >= 0 ? GREEN : PURPLE}">${pts(x)} Pts</span>`
return html`<div><p class="fc-takeaway">Turnout rises in ${up} of ${rows.length} states (counting DC) and falls in ${rows.length - up}. The biggest fall is in ${NAME[bottom.state]} (${pts(bottom.change)} points).</p>
${dataTable({
items: rows, caption: "Turnout by state, 2022 and 2026 forecast", noun: ["state", "states"], placeholder: "Search a state",
searchText: r => `${NAME[r.state]} ${r.state}`, sortKey: "change", sortDir: -1,
columns: [
{key: "state", label: "State", value: r => NAME[r.state], render: r => NAME[r.state]},
{key: "t22", label: "2022", cls: "fc-num fc-wide", value: r => r.turnout_2022, render: r => share(r.turnout_2022)},
{key: "t26", label: "2026 Forecast", cls: "fc-num", value: r => r.turnout_2026_median, render: r => share(r.turnout_2026_median)},
{key: "change", label: "Change", cls: "fc-num", value: r => r.change, render: r => changeChip(r.change)},
{key: "range", label: "Likely Range", cls: "fc-num fc-wide", value: r => r.turnout_2026_lo80_state,
render: r => `${(r.turnout_2026_lo80_state * 100).toFixed(0)} to ${(r.turnout_2026_hi80_state * 100).toFixed(0)}%`}
]
})}</div>`
}How to Read These Numbers
A 70% chance is not a prediction that something will happen. It means that in about seven of every ten simulated elections it did, and in three it did not. In the bars, the grey gap between the parties is the share of outcomes where neither wins outright, such as a 25–25 split of governorships or independents holding the balance in the Senate.
Using This Forecast
For Everyone
- It is a forecast, not a prediction of a single outcome. Each number is the share of 20,000 simulated elections in which that outcome happened.
- Polls are included and weighted by how accurate they were in past cycles. Even so, the model’s ranges were a little too narrow in its 2018 and 2022 tests, so the most confident calls are probably overconfident.
- It is published in advance and will be scored. This version is fixed in a dated, tagged release, and every forecast will be compared with the certified results after the election.
For Forecasters
The full tables are free to use as a baseline, with a manifest giving the input dates and file hashes.
- Races: vote share, 80% interval and win chance, with and without polls
- House districts and House seats
- Senate seats and governor seats
- County turnout and state turnout
- Backtests and the manifest
The method, backtests and limits are set out in the full write-up.
Code
html`<details class="fc-disclose fc-credits"><summary>Photo Credits</summary>
<p>Candidate photos come from Wikimedia Commons and are used under the licences below. Candidates without a freely licensed photo are shown by their initials.</p>
<ul>${[...credits].sort((a, b) => d3.ascending(a.candidate, b.candidate)).map(c => `<li><strong>${esc(c.candidate)}</strong>:
<a href="${esc(c.source_url)}">${esc(c.commons_file.replace(/^File:/, ""))}</a>, ${esc(c.author || "author not stated")},
${c.licence_url ? `<a href="${esc(c.licence_url)}">${esc(c.licence)}</a>` : esc(c.licence)}</li>`).join("")}</ul>
</details>`