Public
Edited
May 6
1 fork
Insert cell
Insert cell
<div style="display: flex; flex-direction: column;">
<input type="text" placeholder="Search bill description..." id="searchBar" style="margin-bottom: 10px; padding: 5px; font-size: 18px;"/>
<svg width="1200" height="600" viewBox="-30 80 1700 550" style="background-color: #bbb;">
<g id="leftPlot" transform="translate(0,0)"></g>
<g id="rightPlot" transform="translate(600,0)"></g>

<style>
.grid-lines{
shape-rendering: crispEdges;
}
.maintitle{
text-anchor: middle;
font-size: 24px;
font-weight: bold;
}
.citation{
font-size: 16px;
fill: darkblue;
}
.notabene{
font-size: 14px;
fill: red;
}
</style>

<text x="290" y="0" class="maintitle">NOMINATE Midpoints of the 118th Congressional Bills</text>
<text x="900" y="0" class="maintitle">NOMINATE Scores of the 118th Congressional Members</text>

<text x="20" y="610" class="nomnotes">
<tspan x="20" dy="1em">
DW-NOMINATE (Dynamic Weighted NOMINAl Three-step Estimation) is a multidimensional scaling procedure used to calculate the ideological parameters of
</tspan>
<tspan x="20" dy="1.1em">
legislators and the legislation they vote on. The primary dimension (x-axis) correlates to the traditional liberal-conservative political spectrum, while the secondary
</tspan>
<tspan x="20" dy="1.1em">
dimension (y-axis) picks up differences within the major political parties over various contemporary social issues. Due to increased political polarization in the
</tspan>
<tspan x="20" dy="1.1em">
modern day, many political scientists see the secondary dimension as ancillary. NOMINATE can also be used to estimate the probability of a legislator making their
</tspan>
<tspan x="20" dy="1.1em">
vote as recorded: the larger the size of a datapoint is, the less likely that legislator was to vote as they did.
</tspan>
</text>

<text x="20" y="600" class="notabene">
<tspan font-style="italic">NB: NOMINATE midpoints of a bill represent that bill's estimated cutting lines, and may not reflect the ideological content of the bill itself.</tspan>
</text>
<text x="20" y="730" class="citation">
Lewis, Jeffrey B., Keith Poole, Howard Rosenthal, Adam Boche, Aaron Rudkin, and Luke Sonnet (2025).
<tspan font-style="italic">Voteview: Congressional Roll-Call Votes Database.</tspan>
https://voteview.com/
</text>
</svg>
</div>
Insert cell
{
// select SVG containers and subplots
let voteSVG = d3.select(dashboard).select("svg")
let billSVG = voteSVG.select("#leftPlot");
let memberSVG = voteSVG.select("#rightPlot");

// set plot dimensions and margins
let plotWidth = 600;
let plotHeight = 600;
const margin = { top: 50, right: 40, bottom: 40, left: 40 };
const innerWidth = plotWidth - margin.left - margin.right;
const innerHeight = plotHeight - margin.top - margin.bottom;

// set tooltip style
const tooltip = d3.select("body")
.append("div")
.attr("class", "tooltip")
.style("position", "absolute")
.style("display", "none")
.style("pointer-events", "none")
.style("background", "white")
.style("border", "1px solid #aaa")
.style("border-radius", "4px")
.style("padding", "6px")
.style("font-size", "14px")
.style("box-shadow", "0 2px 6px rgba(0,0,0,0.2)")
.style("max-width", "300px")
.style("white-space", "normal")
.style("z-index", "10");

// filter rollcalls to exclude bills without description
let named_rollcalls = hs118_rollcalls.filter(d => d.vote_desc != null && d.vote_desc !== "");

// set plot scales
const xScale = d3.scaleLinear()
.domain(d3.extent(named_rollcalls, d => d.nominate_mid_1))
.range([margin.left, innerWidth + margin.left]);
const yScale = d3.scaleLinear()
.domain(d3.extent(named_rollcalls, d => d.nominate_mid_2))
.range([innerHeight + margin.top, margin.top]);

// set axes
const xAxis = (g) => g
.attr('transform', `translate(0,${plotHeight - margin.bottom})`)
.call(d3.axisBottom(xScale))
.attr("font-size", "14px");

const yAxis = (g) => g
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale))
.attr("font-size", "14px");

// set gridlines
const xGrid = (g) => g
.attr('class', 'grid-lines')
.selectAll('line')
.data(xScale.ticks())
.join('line')
.attr('x1', d => xScale(d))
.attr('x2', d => xScale(d))
.attr('y1', margin.top)
.attr('y2', plotHeight - margin.bottom)
.attr('stroke', d => d === 0 ? '#666' : '#ddd') // 0-axes style is more pronounced
.attr('stroke-width', d => d === 0 ? 2.5 : 1)
.attr('stroke-opacity', d => d === 0 ? 0.5 : 0.3);

const yGrid = (g) => g
.attr('class', 'grid-lines')
.selectAll('line')
.data(yScale.ticks())
.join('line')
.attr('x1', margin.left)
.attr('x2', margin.left + innerWidth)
.attr('y1', d => yScale(d))
.attr('y2', d => yScale(d))
.attr('stroke', d => d === 0 ? '#666' : '#ddd') // 0-axes style is more pronounced
.attr('stroke-width', d => d === 0 ? 2.5 : 1)
.attr('stroke-opacity', d => d === 0 ? 0.5 : 0.3);

// set continuous color scale for bills based on the vote split difference
// since the House and Senate have a different number of members, they need different scales
// this way, the scaling measures proportion of split, and Senate differences are not hidden
const colorScaleHouse = d3.scaleLinear()
.domain([-451, 0, 451])
.range(["purple", "white", "green"]);
const colorScaleSenate = d3.scaleLinear()
.domain([-100, 0, 100])
.range(["purple", "white", "green"]);

const getColor = d => {
const diff = d.yea_count - d.nay_count;
return d.chamber === "Senate"
? colorScaleSenate(diff)
: colorScaleHouse(diff);
};

// function for drawing Bills plot
function drawLeftPlot(group, dataset, getX, getY) {
group.selectAll("*").remove();

// separate to passed bills and failed bills (represented by different shapes)
let yeaData = dataset.filter(d => d.yea_count - d.nay_count >= 0);
let nayData = dataset.filter(d => d.yea_count - d.nay_count < 0);

// rules for instantiating member plots on click
const handleClick = (event, d) => {
// find NOM coordinates for later placement
let midX = getX(d);
let midY = getY(d);

// get votes corresponding to selected observation
let relevantVotes = hs118_votes.filter(v => v.rollnumber === d.rollnumber && v.chamber === d.chamber);
let voteMap = new Map(relevantVotes.map(v => [v.icpsr, v]));

// get member data corresponding to those votes (if they exist)
let enrichedMembers = hs118_members.map(m => {
let vote = voteMap.get(m.icpsr);
return vote
? {...m, cast_code: vote.cast_code, prob: vote.prob, voted: true }
: {...m, voted: false }; // mark false if the member didn't vote (different chamber)
});
// instantiate Member plot
drawMemberVotes(memberSVG, enrichedMembers, relevantVotes, d => d.nominate_dim1, d => d.nominate_dim2, midX, midY, d.vote_question, d.vote_desc, d.yea_count, d.nay_count);
};

// draw passed bills (circles)
group.selectAll("circle")
.data(yeaData, d => d.id)
.join("circle")
.attr("cx", d => xScale(getX(d)))
.attr("cy", d => yScale(getY(d)))
.attr("r", 5)
.attr("fill", "transparent")
.attr("stroke", d => getColor(d))
.attr("stroke-width", 2)
.on("click", handleClick) // clicking the object will draw the corresponding member plot
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bill_number}</strong><br/>
<em>${d.vote_question}</em><br/>
${d.vote_desc}<br/>
<span style="color:green">${d.yea_count} yeas</span> -
<span style="color:purple">${d.nay_count} nays</span>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});

// draw failed bills (crosses)
group.selectAll("line.cross1")
.data(nayData, d => d.id)
.join("line")
.attr("class", "cross1")
.attr("x1", d => xScale(getX(d)) - 5)
.attr("y1", d => yScale(getY(d)) - 5)
.attr("x2", d => xScale(getX(d)) + 5)
.attr("y2", d => yScale(getY(d)) + 5)
.attr("stroke", d => getColor(d))
.attr("stroke-width", 3)
.on("click", handleClick) // clicking the object will draw the corresponding member plot
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bill_number}</strong><br/>
<em>${d.vote_question}</em><br/>
${d.vote_desc}<br/>
<span style="color:green">${d.yea_count} yeas</span> -
<span style="color:purple">${d.nay_count} nays</span>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});
group.selectAll("line.cross2")
.data(nayData, d => d.id)
.join("line")
.attr("class", "cross2")
.attr("x1", d => xScale(getX(d)) - 5)
.attr("y1", d => yScale(getY(d)) + 5)
.attr("x2", d => xScale(getX(d)) + 5)
.attr("y2", d => yScale(getY(d)) - 5)
.attr("stroke", d => getColor(d))
.attr("stroke-width", 3)
.on("click", handleClick) // clicking the object will draw the corresponding member plot
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bill_number}</strong><br/>
<em>${d.vote_question}</em><br/>
${d.vote_desc}<br/>
<span style="color:green">${d.yea_count} yeas</span> -
<span style="color:purple">${d.nay_count} nays</span>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});

// add axes and gridlines
group.append('g').call(xAxis);
group.append('g').call(yAxis);
group.append('g').call(xGrid);
group.append('g').call(yGrid);
}

// function for drawing Members plot
function drawMemberVotes(group, members, votes, getX, getY, nomX, nomY, question, desc, yeas, nays) {

// remove instruction text
group.selectAll(".instruction-text").remove();

// extract votes from each member
const voteMap = new Map();
votes.forEach(d => voteMap.set(d.icpsr, d));

// set discrete color for members based on cast vote
const getVoteColor = d => {
const v = voteMap.get(d.icpsr);
if (!v) return "none"; // no vote (different chamber)
if (v.cast_code == 1) return "green"; // yea
if (v.cast_code == 6) return "purple"; // nay
if (v.cast_code == 7) return "white"; // present
if (v.cast_code == 9) return "black"; // no vote (absence)
};

// set size for members based on probability of that vote
// size is inversely proportional to probability; lower probability = larger size
// logarithm function sets a functional cap on maximum size, shows difference without risk of explosion
const getSize = d => {
const v = voteMap.get(d.icpsr);
return v ? 3 + 3 * Math.log(1 / ((v.prob/100) || 0.1)) : 3;
};

// separate members by party (different shapes)
const demData = members.filter(d => d.party_code == "100");
const repData = members.filter(d => d.party_code == "200");
const indData = members.filter(d => d.party_code == "328");

// create coordinate lines to indicate NOM scores of the bill
let xLine = group.selectAll("line.x-line").data([null]);
xLine.enter()
.append("line")
.attr("class", "x-line")
.attr("stroke", "orange")
.attr("stroke-dasharray", "4,2")
.attr("stroke-width", 2.5)
.merge(xLine)
.transition().duration(1000)
.attr("x1", xScale(nomX)).attr("x2", xScale(nomX))
.attr("y1", margin.top).attr("y2", plotHeight - margin.bottom);
let yLine = group.selectAll("line.y-line").data([null]);
yLine.enter()
.append("line")
.attr("class", "y-line")
.attr("stroke", "orange")
.attr("stroke-dasharray", "4,2")
.attr("stroke-width", 2.5)
.merge(yLine)
.transition().duration(1000)
.attr("y1", yScale(nomY)).attr("y2", yScale(nomY))
.attr("x1", margin.left).attr("x2", margin.left + innerWidth);

// draw Democratic members (circles)
group.selectAll("circle")
.data(demData, d => d.id)
.join("circle")
.attr("cx", d => xScale(getX(d)))
.attr("cy", d => yScale(getY(d)))
.attr("r", d => getSize(d))
.attr("fill", d => !(voteMap.get(d.icpsr)) ? "none" : "transparent")
.attr("stroke", getVoteColor)
.attr("stroke-width", 2)
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
let party = ""; // color in tooltip depends on party
if (d.party_code === 100) {
party = `<span style="color: #66b3ff;">(D)</span>`;
} else if (d.party_code === 200) {
party = `<span style="color: #ff6666;">(R)</span>`;
} else if (d.party_code === 328) {
party = `<span style="color: black;">(I)</span>`;
}

tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bioname}</strong>: ${d.chamber} ${party}<br/>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});

// draw Republican members (squares)
group.selectAll("rect")
.data(repData, d => d.id)
.join("rect")
.attr("x", d => xScale(getX(d)) - getSize(d))
.attr("y", d => yScale(getY(d)) - getSize(d))
.attr("width", d => getSize(d)*2)
.attr("height", d => getSize(d)*2)
.attr("fill", d => !(voteMap.get(d.icpsr)) ? "none" : "transparent")
.attr("stroke", getVoteColor)
.attr("stroke-width", 2)
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
let party = ""; // color in tooltip depends on party
if (d.party_code === 100) {
party = `<span style="color: #66b3ff;">(D)</span>`;
} else if (d.party_code === 200) {
party = `<span style="color: #ff6666;">(R)</span>`;
} else if (d.party_code === 328) {
party = `<span style="color: black;">(I)</span>`;
}
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bioname}</strong>: ${d.chamber} ${party}<br/>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});

// draw Independent members (crosses)
group.selectAll("line.cross1")
.data(indData, d => d.id)
.join("line")
.attr("class", "cross1")
.attr("x1", d => xScale(getX(d)) - getSize(d))
.attr("y1", d => yScale(getY(d)) - getSize(d))
.attr("x2", d => xScale(getX(d)) + getSize(d))
.attr("y2", d => yScale(getY(d)) + getSize(d))
.attr("stroke", getVoteColor)
.attr("stroke-width", 3)
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
let party = ""; // color in tooltip depends on party
if (d.party_code === 100) {
party = `<span style="color: #66b3ff;">(D)</span>`;
} else if (d.party_code === 200) {
party = `<span style="color: #ff6666;">(R)</span>`;
} else if (d.party_code === 328) {
party = `<span style="color: black;">(I)</span>`;
}
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bioname}</strong>: ${d.chamber} ${party}<br/>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});
group.selectAll("line.cross2")
.data(indData, d => d.id)
.join("line")
.attr("class", "cross2")
.attr("x1", d => xScale(getX(d)) - getSize(d))
.attr("y1", d => yScale(getY(d)) + getSize(d))
.attr("x2", d => xScale(getX(d)) + getSize(d))
.attr("y2", d => yScale(getY(d)) - getSize(d))
.attr("stroke", getVoteColor)
.attr("stroke-width", 3)
.on("mousemove", (event, d) => { // tooltip appears when hovering over the object
let party = ""; // color in tooltip depends on party
if (d.party_code === 100) {
party = `<span style="color: #66b3ff;">(D)</span>`;
} else if (d.party_code === 200) {
party = `<span style="color: #ff6666;">(R)</span>`;
} else if (d.party_code === 328) {
party = `<span style="color: black;">(I)</span>`;
}
tooltip
.style("left", `${event.pageX + 5}px`)
.style("top", `${event.pageY + 5}px`)
.style("display", "block")
.html(`
<strong>${d.bioname}</strong>: ${d.chamber} ${party}<br/>
`);
})
.on("mouseout", () => { // tooltip disappears when hovering stops
tooltip.style("display", "none");
});

// remove old text before adding new text
group.selectAll("text").remove();
// add subtitle text (bill that corresponds to the plot)
let billText = group.append("text")
.text(`${question}: ${desc}`)
.attr("x", plotWidth / 2)
.attr("text-anchor", "middle")
.attr("y", 10);

// wrap and resize to fit in space
wrapAndResizeText(billText, `${question}: ${desc}`, 500, 40, 4);

// add vote text (total yeas and total nays)
let splitGroup = group.append("text")
.attr("x", plotWidth / 2)
.attr("y", 600)
.attr("text-anchor", "middle")
.attr("font-size", "16px");
splitGroup.append("tspan")
.text(`${yeas} `);
splitGroup.append("tspan")
.text("yeas")
.attr("fill", "green");
splitGroup.append("tspan")
.text(" - ");
splitGroup.append("tspan")
.text(`${nays} `);
splitGroup.append("tspan")
.text("nays")
.attr("fill", "purple");
//reset and recreate axes and grids (including text objects)
group.selectAll("g").remove();
group.append('g').call(xAxis);
group.append('g').call(yAxis);
group.append('g').call(xGrid);
group.append('g').call(yGrid);
}

// draw initial Bills plot
drawLeftPlot(billSVG, named_rollcalls, d => d.nominate_mid_1, d => d.nominate_mid_2);

// draw initial Members plot
memberSVG.append('g').call(xAxis);
memberSVG.append('g').call(yAxis);
memberSVG.append('g').call(xGrid);
memberSVG.append('g').call(yGrid);

// instructional text to inform the audience of rules of interaction
memberSVG.append("text")
.attr("class", "instruction-text")
.attr("x", plotWidth / 2)
.attr("y", plotHeight / 2)
.attr("text-anchor", "middle")
.attr("font-size", "24px")
.attr("font-weight", "bold")
.text("Click on a bill to see who voted for it!");

// filter Bills plot based on search bar keyword
d3.select("#searchBar").on("input", () => {
const keyword = d3.select("#searchBar").property("value").toLowerCase();
const filteredLeft = named_rollcalls.filter(d => d.vote_desc.toLowerCase().includes(keyword));
drawLeftPlot(billSVG, filteredLeft, d => d.nominate_mid_1, d => d.nominate_mid_2);
});

// function for resizing subtitle text
function wrapAndResizeText(textElem, textContent, maxWidth, maxHeight, minFontSize) {
// initial size
let fontSize = 16;
// split text into words list
let words = textContent.split(/\s+/);

while (fontSize >= minFontSize) {
textElem.text(null);
textElem.attr("font-size", fontSize);
let line = [];
let lines = [];
// create a temporary tspan to measure text width
let tempTspan = textElem.append("tspan")
.attr("x", textElem.attr("x"))
.attr("dy", "1em");

// add words to tspan
for (let i = 0; i < words.length; i++) {
line.push(words[i]);
tempTspan.text(line.join(" "));
if (tempTspan.node().getComputedTextLength() > maxWidth) {
line.pop();
lines.push(line.join(" "));
line = [words[i]];
}
}
if (line.length) lines.push(line.join(" "));
tempTspan.remove();
// check height of lines
let totalHeight = lines.length * fontSize * 1.2;
// if it fits, render the tspan
if (totalHeight <= maxHeight) {
lines.forEach((textLine, i) => {
textElem.append("tspan")
.text(textLine)
.attr("x", textElem.attr("x"))
.attr("dy", i === 0 ? "1em" : "1.2em");
});
return;
}
// otherwise, try again with smaller font
fontSize -= 1;
}
// if nothing fits, render truncated text
textElem.text(textContent.slice(0, 100) + "…")
.attr("font-size", minFontSize);
}
}
Insert cell
HS118_rollcalls.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
HS118_members.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
HS118_parties.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
HS118_votes.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more