import { fmt_pct, rate, fmt_surname, AP } from "./utils.js"
d3 = require("d3")
d_sum = FileAttachment("summary.json").json()
geom_states = FileAttachment("states.json").json()
d_history = FileAttachment("history.csv").csv({typed: true})
d_hist = FileAttachment("seats_hist_senate.csv").csv({typed: true})
d_distr_list = {
let raw = await FileAttachment("senate_races.csv").csv({typed: true});
return raw.map(d => {
d.name = AP[d.state];
d.rcv = ["AK", "ME"].includes(d.state);
if (d.state == "OK" && d.rep_cand == "M. MULLIN") {
d.state = "OK-S";
d.name += " Special"
}
d.interval = [d.dem_mean, d.dem_q10, d.dem_q25, d.dem_q75, d.dem_q90, d.rcv];
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);
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);
}
Senate Forecast
Author
Cory McCartan
{
let exag_fac = 1 - Math.sqrt(width)/160;
let last_elec = 50;
let xmin = Math.max(Math.min(d_sum.sen_q10*exag_fac, 50 - 10), 0)
let xmax = Math.min(Math.max(d_sum.sen_q90/exag_fac, 50 + 10), 100)
let s_gain = (d_sum.sen_med >= last_elec ? "+" : "-") + Math.abs(last_elec - Math.round(d_sum.sen_med));
let overl = 0.45;
let text_shadow = function(text, opt) {
let opt2 = {...opt}
opt2.dx = opt2.dx + 3
opt2.dy = opt2.dy + 3
opt2.fill = "#0007"
opt2.stroke = "none"
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 >= 50 ? BLUE : RED,
}),
Plot.ruleY([0]),
Plot.ruleY([0], {
dy: 2,
strokeWidth: 5,
x1: d_sum.sen_q10,
x2: d_sum.sen_q90,
color: "red",
}),
Plot.ruleX([last_elec, d_sum.sen_med]),
text_shadow(s_gain, {
x: d_sum.sen_med, dy: -48, dx: 3,
frameAnchor: "bottom", textAnchor: "start", lineAnchor: "bottom",
fill: "white", fontSize: 40, fontWeight: "bold", stroke: "#0003",
}),
text_shadow(d_sum.sen_med >= last_elec ? "GAIN" : "LOSS", {
x: d_sum.sen_med, dy: -42, dx: 5,
frameAnchor: "bottom", textAnchor: "start", lineAnchor: "top",
fill: "white", fontSize: 14, fontWeight: "bold", stroke: "#0003",
}),
Plot.text(["← DEM. MAJORITY"], {
x: 49.5, dy: -4, dx: -6,
frameAnchor: "bottom", textAnchor: "end",
fontWeight: "bold", fill: "#111",
}),
Plot.text(["REP. MAJORITY →"], {
x: 49.5, dy: -4, dx: 6,
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.sen_prob > 0.5
{
let win_party = dem_lead ? "Democrats" : "Republicans"
let win_class = dem_lead ? "dem" : "rep"
let min_seats = dem_lead ? d_sum.sen_q10 : 100 - d_sum.sen_q90
let max_seats = dem_lead ? d_sum.sen_q90 : 100 - d_sum.sen_q10
let phrase = dem_lead ? "controlling" : "flipping"
let prob = dem_lead ? d_sum.sen_prob : 1 - d_sum.sen_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 Senate.
<p class="updated">Last updated ${date_fmt}.</p>`;
}
The Senate map
Each state 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 state to learn more.
{
let distrs = topojson.feature(geom_states, geom_states.objects.foo).features.map(d => {
d.match = d_distr.get(d.properties.state);
if (typeof d.match == "undefined") {
d.flip = false;
d.match = null;
} else {
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.68;
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(1.30*w)
.translate([0.85*w/2, 0.18*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 => {
if (d.match === null) {
return "#c0c0c0";
} else {
return 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;
d3.select(this).attr("stroke-width", 2.4);
let txt;
if (d.match === null) {
txt = `<h3>${AP[d.properties.state]}</h3><p></p>
<p>No election this year.</p>`;
} else {
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>` : "";
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: {
}
})
}
The 35 Senate races
viewof search = Inputs.search(d_distr_list, {
label: "Races:",
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}** races selected below, on average.`
You can search by state, candidate, incumbency, rating, or contestedness. Try searching for “Schumer”, “WA,” “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(${int[0] > 0.5 ? x_dem(int[0]) : x_rep(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", "cands", "inc_seat", "pr_dem", "interval", "part_base", ];
let cols_small = ["margin", "name", "cands", "pr_dem", "interval", "part_base", ];
let cols_tiny = ["margin", "name", "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: "Race",
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: 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: "sen_prob", strokeWidth: 3, stroke: BLUE}),
Plot.line(d_history, {x: "from_date", y: d => 1 - d.sen_prob, strokeWidth: 3, stroke: RED}),
Plot.ruleY([0.5], {stroke: "#0007"}),
Plot.ruleX([elec_date, today]),
Plot.text(["Odds of winning Senate"], {
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.sen_prob)], {
x: today, y: d_sum.sen_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.sen_prob)], {
x: today, y: 1 - d_sum.sen_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.sen_q10) * 0.9;
const ymax = d3.max(d_history, d => d.sen_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.sen_q10, 217.5),
y2: d => Math.max(d.sen_q90, 217.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.max(d.sen_q25, 217.5),
y2: d => Math.max(d.sen_q75, 217.5),
fill: BLUE, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.min(d.sen_q10, 217.5),
y2: d => Math.min(d.sen_q90, 217.5),
fill: RED, opacity: 0.4,
}),
Plot.areaY(d_history, {
x: "from_date",
y1: d => Math.min(d.sen_q25, 217.5),
y2: d => Math.min(d.sen_q75, 217.5),
fill: RED, opacity: 0.4,
}),
Plot.line(d_history, {x: "from_date", y: "sen_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.sen_med], {
x: today, y: d_sum.sen_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",
})
}
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, Jeremiah, Kuriwaki, and Snyder 2021, and IPUMS.