{
let voteSVG = d3.select(dashboard).select("svg")
let billSVG = voteSVG.select("#leftPlot");
let memberSVG = voteSVG.select("#rightPlot");
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;
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");
let named_rollcalls = hs118_rollcalls.filter(d => d.vote_desc != null && d.vote_desc !== "");
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);
}
}