import { fmt_pct, rate, fmt_surname, AP } from "./utils.js"
d3 = require("d3")
d_sum = FileAttachment("summary.json").json()
geom_hex = FileAttachment("hex.json").json()
d_history = FileAttachment("history.csv").csv({typed: true})
d_hist = FileAttachment("seats_hist_house.csv").csv({typed: true})
d_distr_list = {
let raw = await FileAttachment("house_districts.csv").csv({typed: true});
return raw.map(d => {
let at_large = ["AK", "DE", "ND", "SD", "VT", "WY"].includes(d.state);
d.name = AP[d.state] + " " + (at_large ? "At-Large" : d.district);
d.name_short = AP[d.state] + (at_large ? "" : " " + d.district);
d.rcv = ["AK", "ME"].includes(d.state);
d.interval = [d.dem_mean, d.dem_q10, d.dem_q25, d.dem_q75, d.dem_q90, d.rcv && (!d.state == "AK")];
d.rating = rate(d.pr_dem);
d.margin = Math.abs(d.dem_mean - 0.5);
d.dem_cand = fmt_surname(d.dem_cand);
d.rep_cand = fmt_surname(d.rep_cand);
if (d.state == "AK") {
//d.dem_cand = null;
d.rep_cand = null;
}
d.cands = [d.dem_cand, d.rep_cand, d.inc_seat];
d.search = {dem: "incumbent dem. ", open: "open seat ", gop: "incumbent rep. incumbent gop. "}[d.inc_seat]
+ ["contested ", "unopposed uncontested "][d.unopp];
return d;
})
}
d_distr = d3.index(d_distr_list, d => d.state, d => d.district)
DARK_BLUE = "#0063B1"
DARK_RED = "#A0442C"
BLUE = "#3D77BB"
RED = "#B25D4C"
w_BODY = 796;
SMALL = width < 600;
elec_date = new Date("2022-11-08")
color = {
let midpt = "#fafffa";
let color_dem = d3.scaleLinear()
.domain([0.5, 1.0])
.range([midpt, DARK_BLUE]);
let color_gop = d3.scaleLinear()
.domain([0.0, 0.5])
.range([DARK_RED, midpt]);
return x => x <= 0.5 ? color_gop(x) : color_dem(x);
}
House Forecast
Author
Cory McCartan
{
let exag_fac = 1 - Math.sqrt(width)/80;
let last_elec = 222;
let xmin = Math.max(Math.min(d_sum.house_q10*exag_fac, 218 - 10), 0)
let xmax = Math.min(Math.max(d_sum.house_q90/exag_fac, 218 + 10), 435)
let s_gain = "-" + Math.abs(last_elec - Math.round(d_sum.house_med));
let overl = Math.max(0.58, 1 - Math.sqrt(width) / 80);
let text_shadow = function(text, opt) {
let opt2 = {...opt}
opt2.dx = opt2.dx + 3
opt2.dy = opt2.dy + 3
opt2.fill = "#0007"
return [Plot.text([text], opt2), Plot.text([text], opt)]
}
window.plot = Plot.plot({
x: {
domain: [xmin, xmax],
clamp: true,
inset: 10,
tickSize: 8,
reverse: true,
},
y: {
legend: false,
axis: null,
insetTop: 4,
},
color: {
legend: false,
},
marks: [
Plot.rectY(d_hist, {
x1: d => d.dem_seats - overl,
x2: d => d.dem_seats + overl,
y: "pr",
fill: d => d.dem_seats >= 218 ? BLUE : RED,
}),
Plot.ruleY([0]),
Plot.ruleY([0], {
dy: 2,
strokeWidth: 5,
x1: d_sum.house_q10,
x2: d_sum.house_q90,
color: "red",
}),
Plot.ruleX([last_elec, d_sum.house_med]),
text_shadow(s_gain, {
x: d_sum.house_med, dy: -48, dx: 3,
frameAnchor: "bottom", textAnchor: "start", lineAnchor: "bottom",
fill: "white", fontSize: 40, fontWeight: "bold",
}),
text_shadow("LOSS", {
x: d_sum.house_med, dy: -42, dx: 5,
frameAnchor: "bottom", textAnchor: "start", lineAnchor: "top",
fill: "white", fontSize: 14, fontWeight: "bold",
}),
Plot.text(["← DEM. MAJORITY"], {
x: 217.5, dy: -4, dx: -3,
frameAnchor: "bottom", textAnchor: "end",
fontWeight: "bold", fill: "#111",
}),
Plot.text(["REP. MAJORITY →"], {
x: 217.5, dy: -4, dx: 3,
frameAnchor: "bottom", textAnchor: "start",
fontWeight: "bold", fill: "#111",
}),
Plot.text([last_elec + " LAST ELECTION"], {
x: last_elec, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text(["← DEM. SEATS"], {
x: xmax, dy: -4 - (SMALL ? 10 : 0), dx: 4,
frameAnchor: "bottom", textAnchor: "start",
fontWeight: "bold",
}),
],
width: width,
height: 240,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
style: {
fontSize: 9,
color: "#444",
overflow: "visible",
}
})
return window.plot;
}
dem_lead = true; // d_sum.house_prob > 0.5
{
let win_party = dem_lead ? "Democrats" : "Republicans"
let win_class = dem_lead ? "dem" : "rep"
let min_seats = dem_lead ? d_sum.house_q10 : 435 - d_sum.house_q90
let max_seats = dem_lead ? d_sum.house_q90 : 435 - d_sum.house_q10
let phrase = dem_lead ? "controlling" : "flipping"
let prob = dem_lead ? d_sum.house_prob : 1 - d_sum.house_prob
let timestamp = d_history[d_history.length-1].timestamp;
let date_fmt = timestamp.toLocaleString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
})
return md`
The <b class="${win_class}">${win_party}</b> are expected to win
**between ${min_seats} and ${max_seats} seats**.
They have a **${fmt_pct(prob)} chance** of ${phrase} the House.
<p class="updated">Last updated ${date_fmt}.</p>`;
}
The House map
Each district is shaded by the probability of a Democratic or Republican win. Cross-hatching indicates an incumbent who has a 50% or higher chance of losing. You can hover over a district to learn more.
{
let distrs = topojson.feature(geom_hex, geom_hex.objects.foo).features.map(d => {
d.match = d_distr.get(d.properties.state).get(d.properties.district);
d.flip = ((d.match.pr_dem > 0.5 && d.match.inc_seat == "gop")
|| (d.match.pr_dem < 0.5 && d.match.inc_seat == "dem"))
&& (Math.abs(d.match.pr_dem - 0.5) > 0.01);
return d;
})
const w = 960;
const h = w * 0.72;
const GOLD = "#bc1";
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, w, h])
.style("width", "100%")
.style("height", "auto");
let proj = d3.geoAlbers()
.scale(0.9*w)
.translate([0.97*w/2, 0.91*h/2]);
let path = d3.geoPath(proj);
let geom_distrs = svg
.append("g")
.attr("class", "distrs")
.selectAll("path")
.data(distrs)
.enter().append("path")
.attr("fill", d => color(d.match.pr_dem))
.attr("stroke", "#111")
.attr("stroke-width", 0.5)
.attr("d", path);
let geom_distrs_hover = svg
.append("g")
.attr("class", "distrs")
.selectAll("path")
.data(distrs)
.enter().append("path")
.attr("fill", d => d.flip ? "url(#diagonalHatch)" : "none")
.attr("stroke", "#000")
.attr("stroke-width", 0.0)
.attr("d", path);
let tt = d3.select("#plot-map").append("div")
.attr("class", "map-tooltip")
.style("visibility", "hidden");
let mmv = function(e, d) {
let [mx, my] = d3.pointer(e);
let svg_dim = svg.node().getBoundingClientRect();
let w_fac = svg_dim.width/w;
if (mx < 50/w_fac) mx = 50/w_fac;
if (mx > w - 190/w_fac) mx = w - 190/w_fac;
let prob = d.match.pr_dem;
let cand;
let miss;
if (prob > 0.5) {
cand = !d.match.dem_cand ? "Democrats" : d.match.dem_cand;
miss = !d.match.dem_cand;
} else {
cand = !d.match.rep_cand ? "Republicans" : d.match.rep_cand;
miss = !d.match.rep_cand;
}
let inc = {
dem: `<b style='color: ${DARK_BLUE}'>Dem.</b> incumbent`,
gop: `<b style='color: ${DARK_RED}'>Rep.</b> incumbent`,
open: "<b>Open</b> seat",
}[d.match.inc_seat]
let prob_text;
let prob_color = prob > 0.5 ? DARK_BLUE : DARK_RED;
if (d.match.unopp == 1) {
prob_text = `<b style="color: ${prob_color}">${cand}</b>
${miss ? "are" : "is"} running unopposed and will win the seat.`
} else {
prob_text = `<b style="color: ${prob_color}">${cand}</b>
${miss ? "have" : "has"} a
<b>${fmt_pct(prob > 0.5 ? prob : 1 - prob)}</b> chance of winning.`
}
let disclaimer = d.match.rcv ? `<p style="color: #777; font-size: 0.75em; font-style: italic; margin-top: 0.5em;">
Estimates do not account for rank-choice voting.
</p>` : "";
if (d.match.rcv && d.match.state == "AK") {
disclaimer = `<p style="color: #777; font-size: 0.75em; font-style: italic; margin-top: 0.5em;">
Estimates approximate but do not fully account for rank-choice voting dynamics.
</p>`;
}
d3.select(this).attr("stroke-width", 2.4);
let txt = `<h3>${d.match.name}</h3>
<p>${inc}</p>
<div style="width: 100%; height: 1rem; margin: 4px 0; display: flex;">
<div style="background: ${BLUE}; flex-basis: ${100*prob}%"></div>
<div style="background: ${RED}; flex-basis: ${100 - 100*prob}%"></div>
</div>
<p>${prob_text}</p>
${disclaimer}
`;
tt.style("visibility", "visible")
.html(txt)
.style("left", (mx - 50)*svg_dim.width/w + "px")
.style("bottom", (h - my + 25)*svg_dim.height/h + "px");
}
let mout = function(d) {
geom_distrs_hover.attr("stroke-width", 0.0);
tt.style("visibility", "hidden");
}
geom_distrs_hover.on("mousemove", mmv);
geom_distrs_hover.on("touchmove", mmv);
svg.on("mouseout", mout);
svg.on("touchend", mout);
return svg.node();
}
Public opinion over time
This chart shows the model’s best estimate of how the generic congressional ballot has evolved over the past few months. The darker and lighter bands show 50% and 80% credible intervals.
plot_gcb = {
let d_gcb = await FileAttachment("natl_intent.csv").csv({typed: true});
const ci_w = d_sum.i_q90 - d_sum.i_q10
const ymin = Math.max(Math.min(d_sum.i_med - 1*ci_w, 0.45), 0)
const ymax = Math.min(Math.max(d_sum.i_med + 1*ci_w, 0.52), 1)
const today = Math.min(elec_date, Date.now());
const days_til = (elec_date - today) / (24 * 3600 * 1000);
return Plot.plot({
y: {
label: null,
domain: [ymin, ymax],
grid: true,
tickFormat: "%",
axis: "right",
ticks: 5,
},
x: {label: null},
marks: [
Plot.areaY(d_gcb, {
x: "date",
y1: d => Math.max(d.q10, 0.5),
y2: d => Math.max(d.q90, 0.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_gcb, {
x: "date",
y1: d => Math.max(d.q25, 0.5),
y2: d => Math.max(d.q75, 0.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_gcb, {
x: "date",
y1: d => Math.min(d.q10, 0.5),
y2: d => Math.min(d.q90, 0.5),
fill: RED, opacity: 0.4,
}),
Plot.areaY(d_gcb, {
x: "date",
y1: d => Math.min(d.q25, 0.5),
y2: d => Math.min(d.q75, 0.5),
fill: RED, opacity: 0.4,
}),
Plot.line(d_gcb.filter(d => d.date <= today),
{x: "date", y: "natl_dem", strokeWidth: 3}),
Plot.line(d_gcb.filter(d => d.date >= today),
{x: "date", y: "natl_dem", strokeWidth: 3,
strokeDasharray: "4,3", strokeLinecap: "butt" }),
Plot.ruleY([0.5], {stroke: "#0007"}),
Plot.rect([0], {
x1: today, x2: elec_date, y1: ymin, y2: ymax,
fill: "#fff7",
}),
Plot.ruleX([elec_date, today]),
Plot.text([SMALL ? "Estimated Dem. vote" : "Estimated Democratic two-party vote"], {
x: d3.min(d_gcb, d => d.date), dy: -4,
frameAnchor: "top", textAnchor: "start", lineAnchor: "bottom",
fontWeight: "bold", fontSize: 15,
}),
Plot.text([days_til > 20 + SMALL*30 ? "TODAY" : ""], {
x: today, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text(["ELECTION DAY"], {
x: elec_date, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text([fmt_pct(d_sum.i_med, 1)], {
x: elec_date, y: d_sum.i_med, dy: -6, dx: -4,
frameAnchor: "top", textAnchor: "end", lineAnchor: "bottom",
fontWeight: "bold", fontSize: 15, fill: "#223",
}),
],
width: Math.min(w_BODY, width),
height: 300,
marginRight: 40,
style: {
}
})
}
How the national environment affects outcomes
The chart below shows the range of outcomes we forecast conditional on different national environments. For example, if Democrats win 51% of the vote nationwide, they are expected to eke out a majority on average, but could win anywhere from around 185 to around 250 seats. The colored box covers the middle 50% of possible outcomes, the thick line covers the middle 80% of outcomes, and the thin line covers 98% of outcomes.
Plot.plot({
x: {
domain: [0.435, 0.535],
tickFormat: "%",
label: "National Democratic vote share",
grid: true,
},
y: {
grid: true,
line: true,
domain: [150, 270],
label: "Democratic seats won",
},
marks: [
Plot.ruleY([217.5], {stroke: "#0007"}),
Plot.ruleX([0.50], {stroke: "#0004"}),
Plot.ruleX(d_shift, {
x: "natl",
y1: "q01",
y2: "q99",
strokeWidth: 1.4,
clip: true,
}),
Plot.ruleX(d_shift, {
x: "natl",
y: "q10",
y2: "q90",
strokeWidth: 4,
clip: true,
}),
Plot.rect(d_shift, {
x1: x => x.natl - 0.002,
x2: x => x.natl + 0.002,
y1: "q25",
y2: "q75",
stroke: "black",
strokeWidth: 0.8,
fill: x => color(x.pr_win),
clip: true,
}),
Plot.text(["Majority"], {
y: 218, dy: -2, dx: 3,
frameAnchor: "left", textAnchor: "start", lineAnchor: "bottom",
}),
() => Plot.plot({
x: {
domain: [0.435, 0.535],
axis: null
},
y: {
grid: false,
domain: [150 - 222, 270 - 222],
transform: x => x - 222,
axis: "right",
line: true,
tickSize: -4,
tickFormat: x => (x >= 0 ? "+" : "") + x,
label: "Seats gained or lost",
},
marks: [
Plot.ruleY(d_shift, {
x1: x => x.natl - 0.002,
x2: x => x.natl + 0.002,
y: "q50",
strokeWidth: 3,
clip: true,
})
],
width: Math.min(w_BODY, width),
height: 400,
marginLeft: 40,
marginRight: 40,
marginBottom: 40,
})
],
width: Math.min(w_BODY, width),
height: 400,
marginRight: 40,
marginLeft: 40,
marginBottom: 40,
style: {
}
})
The 435 House races
viewof search = Inputs.search(d_distr_list, {
label: "Districts:",
columns: ["name", "state", "inc_seat", "search", "rating", "dem_cand", "rep_cand"],
autocomplete: false,
width: Math.min(width - 8, w_BODY),
})
md`Democrats are expected to win **${d3.sum(tab_distr, d => d.pr_dem).toFixed(1)}
of the ${tab_distr.length}** districts selected below, on average.`
You can search by district state, number, candidate, incumbency, rating, or contestedness. Try searching for “Pelosi”, “WA 7,” “open,” “safe,” “contested,” “lean rep.,” or “tossup” seats.
viewof tab_distr = {
let fmt_name = function(x) {
return html`<span style="font-weight: 500; font-size: ${small ? 0.85 : 1.0}em;">${x}</span>`
}
let fmt_inc = function(x) {
return html`<span class="inc" style="color: ${{dem: DARK_BLUE, gop: DARK_RED, open: "black"}[x]}">
${{dem: "Dem.", gop: "Rep.", open: "Open"}[x]}
</span>`
}
let fmt_part = function(x) {
if (x === null) {
return html`<span style="color: #777">–</span>`
} else {
let color = x > 0.5 ? DARK_BLUE : DARK_RED;
return html`<span style="color: ${color}">${fmt_pct(x, 1)}</span>`
}
}
let fmt_prob = function(x) {
let tcolor = Math.abs(x - 0.5) > 0.3 ? "#fffc" : "black";
return html`<div class="chip" style="background: ${color(x)}; color: ${tcolor}">
${fmt_pct(x)}</div>`
}
let xmin = d3.min(d_distr_list, d => d.dem_q10)
let xmax = d3.max(d_distr_list, d => d.dem_q90)
let avg = 50*(xmin + xmax);
let x = d3.scaleLinear().domain([xmax, xmin]).range([0, 100]);
let x_dem = d3.scaleLinear().domain([xmax, 0.5]).range([0, avg]).clamp(true);
let x_rep = d3.scaleLinear().domain([0.5, xmin]).range([avg, 100]).clamp(true);
let ici_d = BLUE + "bb";
let oci_d = BLUE + "77";
let ici_r = RED + "cc";
let oci_r = RED + "88";
let fmt_vs = function(int) {
if (int[0] === null) {
return html`<div style="text-align: center; color: #777; font-size: 0.85em;">(Unopposed)</div>`
} else {
let gr = `linear-gradient(90deg, transparent 0%,
transparent ${x_dem(int[4])}%, ${oci_d} ${x_dem(int[4])}%,
${oci_d} ${x_dem(int[3])}%, ${ici_d} ${x_dem(int[3])}%,
${ici_d} ${x_dem(int[2])}%, ${oci_d} ${x_dem(int[2])}%,
${oci_d} ${x_dem(int[1])}%, transparent ${x_dem(int[1])}%,
transparent ${x_rep(int[4])}%, ${oci_r} ${x_rep(int[4])}%,
${oci_r} ${x_rep(int[3])}%, ${ici_r} ${x_rep(int[3])}%,
${ici_r} ${x_rep(int[2])}%, ${oci_r} ${x_rep(int[2])}%,
${oci_r} ${x_rep(int[1])}%, transparent ${x_rep(int[1])}%,
transparent 100%)`;
let disclaimer = int[5] ? "†" : ""; // RCV
return html`
<div class="est" style="background: ${gr};
padding-left: calc(${x(int[0])}% - 6px);">
● ${fmt_pct(int[0])}${disclaimer}</div>
`;
}
}
let fmt_vs_tiny = function(int) {
if (int[0] === null) {
return html`<span style="color: #777; font-size: 0.85em;">(Unopp.)</span>`
} else {
let color = int[0] > 0.5 ? DARK_BLUE : DARK_RED;
let disclaimer = int[5] ? "†" : ""; // RCV
return html`<span style="color: ${color};">${fmt_pct(int[0], 1)}${disclaimer}</span>`;
}
}
let fmt_cands = function(x) {
let na_html = "<span style='color: #777'>–</span>";
let d_cand = x[0] === null ? na_html : x[0];
let r_cand = x[1] === null ? na_html : x[1];
if (SMALL) {
if (x[2] == "dem") { // incumbent
d_cand += "*"
} else if (x[2] == "rep") {
r_cand += "*"
}
}
return html`<div class="ddem cand">${d_cand}</div>
<div class="drep cand">${r_cand}</div>`
}
let cols = ["margin", "name_short", "cands", "inc_seat", "pr_dem", "interval", "part_base", ];
let cols_small = ["margin", "name_short", "cands", "pr_dem", "interval", "part_base", ];
let cols_tiny = ["margin", "name_short", "cands", "pr_dem", "interval", ];
const tiny = width < 496;
const small = width < 650
return Inputs.table(search, {
columns: small ? (tiny ? cols_tiny : cols_small) : cols,
header: {
name_short: small ? "Distr." : "District",
inc_seat: "Inc.",
cands: "Candidates",
pr_dem: small ? "Prob." : "Dem. prob.",
interval: small ? "Est. vote" : "Vote forecast",
part_base: small ? "Lean" : "Part. Lean",
},
format: {
name_short: fmt_name,
inc_seat: fmt_inc,
pr_dem: fmt_prob,
cands: fmt_cands,
interval: tiny ? fmt_vs_tiny : fmt_vs,
part_base: fmt_part,
},
sort: "margin",
width: {
interval: 400,
},
maxWidth: Math.min(w_BODY, width),
rows: 30,
layout: "fixed",
})
}
SMALL ? html`<small style="color: #777">*Incumbent candidate</small>` : html``
†Estimates do not account for rank-choice voting.
How the odds have changed
The model is regularly re-run as new data and polls come in. The charts below track how the model’s election-day forecast has changed over time.
plot_prob = {
const today = d3.max(d_history, d => d.from_date);
const days_til = (elec_date - today) / (24 * 3600 * 1000);
return Plot.plot({
y: {
label: null,
domain: [0, 1],
grid: true,
tickFormat: "%",
axis: "right",
ticks: [0, 0.25, 0.5, 0.75, 1],
insetTop: 4,
},
x: {label: null},
marks: [
Plot.line(d_history, {x: "from_date", y: "house_prob", strokeWidth: 3, stroke: BLUE}),
Plot.line(d_history, {x: "from_date", y: d => 1 - d.house_prob, strokeWidth: 3, stroke: RED}),
Plot.ruleY([0.5], {stroke: "#0007"}),
Plot.ruleX([elec_date, today]),
Plot.text(["Odds of winning House"], {
x: d3.min(d_history, d => d.from_date), dy: -4,
frameAnchor: "top", textAnchor: "start", lineAnchor: "bottom",
fontWeight: "bold", fontSize: 15,
}),
Plot.text([days_til > 20 + SMALL*30 ? "TODAY" : ""], {
x: today, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text(["ELECTION DAY"], {
x: elec_date, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text([fmt_pct(d_sum.house_prob)], {
x: today, y: d_sum.house_prob, dy: -6, dx: 4,
frameAnchor: "top", textAnchor: "start",
fontWeight: "bold", fontSize: 15, fill: BLUE,
stroke: "white", strokeWidth: 4,
}),
Plot.text([fmt_pct(1 - d_sum.house_prob)], {
x: today, y: 1 - d_sum.house_prob, dy: -6, dx: 4,
frameAnchor: "top", textAnchor: "start",
fontWeight: "bold", fontSize: 15, fill: RED,
stroke: "white", strokeWidth: 4,
}),
],
width: width < 768 ? width : Math.min(w_BODY, width) * 0.48,
height: 300,
marginRight: 40,
marginLeft: 0,
})
}
plot_gcb_prob = {
const today = d3.max(d_history, d => d.from_date);
const days_til = (elec_date - today) / (24 * 3600 * 1000);
return Plot.plot({
y: {
label: null,
domain: [0, 1],
grid: true,
tickFormat: "%",
axis: "right",
ticks: [0, 0.25, 0.5, 0.75, 1],
insetTop: 4,
},
x: {label: null},
marks: [
Plot.line(d_history, {x: "from_date", y: "i_prob", strokeWidth: 3, stroke: BLUE}),
Plot.line(d_history, {x: "from_date", y: d => 1 - d.i_prob, strokeWidth: 3, stroke: RED}),
Plot.ruleY([0.5], {stroke: "#0007"}),
Plot.ruleX([elec_date, today]),
Plot.text(["Odds of winning pop. vote"], {
x: d3.min(d_history, d => d.from_date), dy: -4,
frameAnchor: "top", textAnchor: "start", lineAnchor: "bottom",
fontWeight: "bold", fontSize: 15,
}),
Plot.text([days_til > 20 + SMALL*30 ? "TODAY" : ""], {
x: today, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text(["ELECTION DAY"], {
x: elec_date, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text([fmt_pct(d_sum.i_prob)], {
x: today, y: d_sum.i_prob, dy: -6, dx: 4,
frameAnchor: "top", textAnchor: "start",
fontWeight: "bold", fontSize: 15, fill: BLUE,
stroke: "white", strokeWidth: 4,
}),
Plot.text([fmt_pct(1 - d_sum.i_prob)], {
x: today, y: 1 - d_sum.i_prob, dy: -6, dx: 4,
frameAnchor: "top", textAnchor: "start",
fontWeight: "bold", fontSize: 15, fill: RED,
stroke: "white", strokeWidth: 4,
}),
],
width: width < 768 ? width : Math.min(w_BODY, width) * 0.48,
height: 300,
marginRight: 40,
marginLeft: 0,
})
}
plot_hist_seats = {
const today = d3.max(d_history, d => d.from_date);
const days_til = (elec_date - today) / (24 * 3600 * 1000);
const ymin = d3.min(d_history, d => d.house_q10) * 0.9;
const ymax = d3.max(d_history, d => d.house_q90) / 0.9;
return Plot.plot({
y: {
label: null,
domain: [ymin, ymax],
grid: true,
axis: "right",
ticks: 5,
insetTop: 4,
},
x: {label: null},
marks: [
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.max(d.house_q10, 217.5),
y2: d => Math.max(d.house_q90, 217.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.max(d.house_q25, 217.5),
y2: d => Math.max(d.house_q75, 217.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.min(d.house_q10, 217.5),
y2: d => Math.min(d.house_q90, 217.5),
fill: RED, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.min(d.house_q25, 217.5),
y2: d => Math.min(d.house_q75, 217.5),
fill: RED, opacity: 0.4,
}),
Plot.line(d_history, {x: "from_date", y: "house_med", strokeWidth: 3}),
Plot.ruleY([217.5], {stroke: "#0007"}),
Plot.ruleX([elec_date, today]),
Plot.text([SMALL ? "Forecasted Dem. seats" : "Forecasted Democratic seats won"], {
x: d3.min(d_history, d => d.from_date), dy: -4,
frameAnchor: "top", textAnchor: "start", lineAnchor: "bottom",
fontWeight: "bold", fontSize: 15,
}),
Plot.text([days_til > 20 + SMALL*30 ? "TODAY" : ""], {
x: today, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text(["ELECTION DAY"], {
x: elec_date, dy: -4,
frameAnchor: "top", textAnchor: "middle", lineAnchor: "bottom",
fontWeight: "bold",
}),
Plot.text([d_sum.house_med], {
x: today, y: d_sum.house_med, dy: -6, dx: 4,
frameAnchor: "top", textAnchor: "start",
fontWeight: "bold", fontSize: 15, fill: "#223",
}),
],
width: Math.min(w_BODY, width),
height: 300,
marginRight: 40,
marginLeft: 0,
})
}
National polling
The most recent generic congressional ballot polls are shown in the table below. The Impact column roughly measures how the poll is currently affecting the model—whether it is pulling the forecast towards Democrats or Republicans, and by how much.
{
let d_polls = await FileAttachment("polls.csv").csv({typed: true});
d_polls = d_polls.map(d => {
d.lv_type = [d.lv, d.type];
return d;
});
let fmt_firm = x => html`<div class="firm">${x}</div>`;
let fmt_date = x => html`<span class="date">${x.toLocaleString("en-US", { month: 'short', day: 'numeric' })}</span>`;
let fmt_type = function(x) {
let type = x[1] == "unknown" ? "–" : x[1].toUpperCase();
let lv = x[0] == 1 ? "LV" : "RV/Adults";
return html`<div class="poll-type">${type}</div>
<div class="poll-lv">${lv}</div>`
}
let fmt_bias = function(x) {
let color = x > 0.0 ? DARK_BLUE : DARK_RED;
let prefix = x > 0.0 ? "D" : "R";
return html`<span style="color: ${color}; font-size: ${SMALL ? 0.75 : 0.9}em;">
${prefix}+${(100*Math.abs(x)).toFixed(1)}</span>`;
}
let fmt_est = function(x) {
return html`<div class="chip" style="background: ${color(5*x-2)};">
${fmt_pct(x, 1)}</div>`;
}
let fmt_impact = function(x) {
let tcolor = x > 0.0 ? "#006381" : "#80442C";
let prefix = x > 0.0 ? "D" : "R";
return html`<div class="chip" style="background: ${color(0.5+x)}; color: ${tcolor};">
${prefix}+${Math.abs(x).toFixed(2)}</div>`
}
let cols = ["date", "firm", "firm_bias", "lv_type", "est", "impact"];
let cols_small = ["date", "firm", "firm_bias", "est", "impact"];
return Inputs.table(d_polls, {
columns: SMALL ? cols_small : cols,
header: {
date: "Date",
firm: SMALL ? "Firm" : "Polling firm",
firm_bias: SMALL ? "Bias" : "Firm bias",
lv_type: "Poll type",
est: SMALL ? "Result" : "Poll result",
impact: "Impact",
},
format: {
date: fmt_date,
firm: fmt_firm,
firm_bias: fmt_bias,
lv_type: fmt_type,
est: fmt_est,
impact: fmt_impact,
},
width: {
firm: SMALL ? 120 : 350,
impact: SMALL ? 50 : 80,
},
maxWidth: Math.min(w_BODY, width),
rows: 20,
layout: "fixed",
})
}
50% and 80% credible intervals are presented throughout.
A detailed write-up of the model, along with code and data, are available here.
Data are courtesy of FiveThirtyEight, Data for Progress, VEST, the ALARM Project, Daily Kos Elections, the MIT Election Data + Science Lab, and IPUMS.