let data = "https://beta.ctvnews.ca/content/dam/common/exceltojson/president_polls.txt"
Promise.all([d3.csv(data)]).then((files) => {
let [data, dateIndex, stateArray] = formatData(files[0]);
//Even listeners for filters here?
let page = d3.select(".poll-page");
let dropdown = page.select(".poll-dropdown");
dropdown.append("option").attr("value", "all").text("All polls");
stateArray.forEach((state) => {
let option = dropdown.append("option").attr("value", state).text(state);
if (state === "") {
option.text("National").attr("class", "bold").attr("selected", "");
}
});
dropdown.on("change", () => {
erase();
setTimeout(() => build(data, dropdown.node().value, true, dateIndex), 0);
});
let button = page
.append("button")
.attr("class", "poll-button")
.text("Show all")
.on("click", () => {
erase();
setTimeout(() => build(data, dropdown.node().value, false, dateIndex), 0);
button.style("display", "none");
});
erase();
build(data, "", true, dateIndex); // Filter value of "" for national polls
});
function formatData(data) {
// Initialize array to get name of every state with poll
let stateArray = [];
// Group by Poll ID
let pollArray = [];
data.forEach((result) => {
let poll = pollArray.find((poll) => poll.id === result.poll_id);
if (poll) {
poll.results.push(result);
} else {
let newPoll = {
pollster: result.pollster,
rating: result.fte_grade,
date: { start: result.start_date, end: result.end_date },
id: result.poll_id,
results: [result],
questions: [],
};
pollArray.push(newPoll);
}
});
// Group by Questions within poll
pollArray.forEach((poll) => {
poll.results.forEach((result) => {
let question = poll.questions.find(
(question) => question.id === result.question_id
);
if (question) {
question.results.push(result);
} else {
let newQuestion = {
id: result.question_id,
type: result.population,
sample_size: result.sample_size,
results: [result],
state: result.state,
url: result.url,
};
if (!stateArray.includes(newQuestion.state)) {
stateArray.push(newQuestion.state);
}
poll.questions.push(newQuestion);
}
});
});
//Group by end date
let pollsByDateArray = [];
pollArray.forEach((poll) => {
let date = pollsByDateArray.find((date) => date.date === poll.date.end);
if (date) {
date.polls.push(poll);
} else {
let newDate = {
date: poll.date.end,
polls: [poll],
};
pollsByDateArray.push(newDate);
}
});
pollsByDateArray = pollsByDateArray.filter(
(date) => date.date.split("/")[2] === "20"
);
//let oldDateArray = pollsByDateArray.map((date) => date.date);
let monthLengthArray = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let dateArray = [];
monthLengthArray.forEach((month, m) => {
for (let d = 1; d <= month; d++) {
dateArray.push(`${m + 1}/${d}/20`);
}
});
dateArray.reverse();
let firstIndex = dateArray.indexOf(pollsByDateArray[0].date);
dateArray = dateArray.filter((d, i) => i >= firstIndex);
stateArray = stateArray.sort();
return [pollsByDateArray, dateArray, stateArray];
}
function erase() {
d3.select(".poll-button").style("display", "block");
let svg = d3.select(".poll-chart-div");
svg.selectAll("*").remove();
let parent = d3.select(".poll-grid");
parent.selectAll("*").remove();
parent.append("div").attr("class", "poll-loading");
}
function build(dateArray, filter, limited, dateIndex) {
//Filter data
dateArray.forEach((date) => {
date.polls.forEach((poll) => {
if (filter !== "all") {
poll.filteredQuestions = poll.questions.filter((question) => {
return (
question.state.trim().toLowerCase() ===
filter.trim().toLowerCase() &&
question.results.some((r) => r.answer === "Biden") &&
question.results.some((r) => r.answer === "Trump")
);
});
} else {
poll.filteredQuestions = poll.questions;
}
});
date.filteredPolls = date.polls.filter(
(poll) => poll.filteredQuestions.length > 0
);
});
let filteredDateArray = dateArray.filter(
(date) => date.filteredPolls.length > 0
);
dateArray = dateArray.filter((d) => d.filteredPolls.length > 0);
console.log("filtered array:", dateArray);
//CHART
let svgDiv = d3.select(".poll-chart-div");
let chartHover = d3.select(".poll-chart-hover");
chartHover.selectAll("*").remove();
let chart = {
width: svgDiv.node().offsetWidth,
height: svgDiv.node().offsetHeight,
};
let svg = svgDiv.append("svg").attr("height", "100%").attr("width", "100%");
drawChart();
window.addEventListener(
"resize",
debounce(() => {
drawChart();
})
);
function debounce(func, delay = 100) {
var timer;
return function (event) {
if (timer) clearTimeout(timer);
timer = setTimeout(func, delay, event);
};
}
function drawChart() {
chart.width = svgDiv.node().offsetWidth;
svg.selectAll("*").remove();
let gridLayer = svg.append("g").attr("class", "grid-layer");
let circleLayer = svg.append("g").attr("class", "circle-layer");
let lineLayer = svg.append("g").attr("class", "line-layer");
let yScale = d3
.scaleLinear()
.domain([0, 100])
.range([chart.height - 30, 10]);
let xScale = d3
.scaleLinear()
.domain([0, dateIndex.length - 1])
.range([20, chart.width - 50]);
let rScale = d3.scaleSqrt().domain([0, 20000]).range([1, 6]);
function mapGaps(array, max) {
array.forEach((date) => {
date.index =
dateIndex.length - dateIndex.findIndex((d) => d === date.date);
});
array.forEach((date, i) => {
let prev = i > 0 ? array[i - 1] : null;
let next = i < array.length - 1 ? array[i + 1] : null;
let a = prev ? (date.index + prev.index) / 2 : array[0].index + 10;
let b = next ? (date.index + next.index) / 2 : -10;
date.rectIndeces = [a, b];
});
}
mapGaps(filteredDateArray, 0);
filteredDateArray.forEach((date) => {
date.filteredPolls.forEach((poll) => {
poll.filteredQuestions.forEach((question) => {
question.results
.filter((q) => q.answer === "Biden" || q.answer === "Trump")
.forEach((response) => {
let circle = circleLayer
.append("circle")
//.attr("r", rScale(Number(response.sample_size)))
.attr("r", 3)
.attr("cx", xScale(date.index))
.attr("cy", yScale(Number(response.pct)))
.attr("class", `chart-circle ${response.answer}`);
});
});
});
let rect = lineLayer.append("rect");
let line = lineLayer.append("line");
function makeSparkChart(poll, parent = chartHover) {
let div = parent.append("div").attr("class", "spark-div");
div
.append("div")
.attr("class", "spark-pollster bold")
.text(poll.pollster);
poll.filteredQuestions.forEach((question) => {
let chart = div.append("div").attr("class", "spark-chart");
question.results
.filter((r) => r.answer === "Biden" || r.answer === "Trump")
.forEach((result) => {
chart
.append("div")
.attr("class", "spark-name")
.text(result.answer);
let lineDiv = chart.append("div").attr("class", "spark-line-div");
lineDiv
.append("div")
.attr("class", `spark-line ${result.answer}`)
.style("width", `calc(${result.pct}% - 10px)`);
lineDiv
.append("div")
.attr("class", `spark-number ${result.answer}`)
.text(`${Math.round(result.pct)}%`);
});
});
}
rect
.attr("x", xScale(date.rectIndeces[1]))
.attr("y", yScale(100))
.attr(
"width",
xScale(date.rectIndeces[0]) - xScale(date.rectIndeces[1])
)
.attr("height", yScale(0) - yScale(100))
.attr("fill", "rgba(0,0,0,0)")
.on("mousemove", () => {
line.style("stroke", "rgba(0,0,0,0.4)");
})
.on("mouseover", () => {
chartHover.selectAll("*").remove();
chartHover
.append("div")
.attr("class", "spark-date")
.text(dateFromSlashes(date.date, true));
date.filteredPolls.forEach((poll) => {
makeSparkChart(poll);
});
})
.on("mouseout", () => {
line.style("stroke", "rgba(0,0,0,0)");
});
line
.attr("class", "hover-line")
.attr("x1", xScale(date.index))
.attr("y1", yScale(100))
.attr("x2", xScale(date.index))
.attr("y2", yScale(0))
.style("stroke", "rgba(0,0,0,0)")
.attr("pointer-events", "none");
});
for (let i = 0; i <= 100; i += 10) {
gridLayer
.append("line")
.attr("class", "chart-grid-line")
.attr("y1", yScale(i))
.attr("y2", yScale(i))
.attr("x1", xScale(1))
.attr("x2", chart.width - 40);
gridLayer
.append("text")
.attr("class", "chart-grid-text")
.text(`${i}%`)
.attr("text-anchor", "start")
.attr("x", chart.width - 36)
.attr("y", yScale(i) + 3);
}
dateIndex.forEach((date, i) => {
if (date.split("/")[1] === "1") {
gridLayer
.append("text")
.attr("class", "chart-grid-date")
.text(dateFromSlashes(date))
.attr("text-anchor", "middle")
.attr("x", xScale(dateIndex.length - i))
.attr("y", chart.height - 10);
gridLayer
.append("line")
.attr("class", "chart-grid-line")
.attr("y1", yScale(0))
.attr("y2", yScale(100))
.attr("x1", xScale(dateIndex.length - i))
.attr("x2", xScale(dateIndex.length - i));
}
});
}
// POLLS
// Select parent div and remove all elements
// (so we can just scorch it all and rebuild when we filter out results)
let parent = d3.select(".poll-grid");
parent.selectAll("*").remove();
// For each poll...
let pollCount = 0;
filteredDateArray.forEach((date, i) => {
/* Cap date of displayed polls */
if (limited && i >= 7) {
return;
}
/* */
if (date.polls.length === 0) {
return;
}
let dateContainer = parent.append("div").attr("class", "date-container");
let dateText = dateContainer
.append("div")
.attr("class", "date-text bold")
.text(dateFromSlashes(date.date, true));
date.filteredPolls.forEach((poll) => {
// ...add a div and the pollster's name...
let pollDiv = dateContainer.append("div").attr("class", "poll-div");
let pollName = pollDiv
.append("div")
.attr("class", "poll-name")
.html(poll.pollster);
let stateDate = pollDiv.append("div").attr("class", "state-date-div");
//State div
let stateDiv = stateDate
.append("div")
.attr("class", "state-div bold")
.text(
`${
poll.filteredQuestions[0].state === ""
? "National"
: poll.filteredQuestions[0].state
}`
);
//Date div
let dateDiv = stateDate
.append("div")
.attr("class", "date-div")
.text((d) => {
let start = dateFromSlashes(
poll.filteredQuestions[0].results[0].start_date
);
let end = dateFromSlashes(
poll.filteredQuestions[0].results[0].end_date,
true
);
return `${start} – ${end}`;
});
// ...and then one div for the questions within the poll
let allQuestionDiv = pollDiv
.append("div")
.attr("class", "all-question-div");
let headings = allQuestionDiv.append("div").attr("class", "question-div");
let names = headings.append("div").attr("class", "poll-heading");
names.append("div").attr("class", "candidate-name").text("Biden");
names.append("div").attr("class", "candidate-name").text("Trump");
let lead = headings
.append("div")
.attr("class", "poll-heading")
.text("Point lead");
let sample = headings
.append("div")
.attr("class", "poll-heading")
.text("Sample size/type");
// Initialize a counter to see how many valid questions the poll has. If 0, we end up removing poll
let questionCount = 0;
// For each individual question...
poll.filteredQuestions.forEach((question) => {
// Check results objects for Biden and Trump
let biden = question.results.find((r) => r.answer === "Biden");
let trump = question.results.find((r) => r.answer === "Trump");
// If we don't have a result for both of them (some polls have Biden vs. Pence, etc.)
if (!biden || !trump) {
return;
} else {
questionCount++;
// ...add a results container div...
let questionDiv = allQuestionDiv
.append("div")
.attr("class", "question-div");
// ...a div for each result ...
let resultsDiv = questionDiv
.append("div")
.attr("class", "results-div");
let bidenNum = Math.round(biden.pct);
let trumpNum = Math.round(trump.pct);
let diffNum = Math.round(trump.pct - biden.pct);
let bidenNumberDiv = resultsDiv
.append("div")
.attr("class", "biden results-number ");
bidenNumberDiv
.html(`${bidenNum}%`)
.style("background", bidenNum > trumpNum ? "#cfeeff" : "#fff");
let trumpNumberDiv = resultsDiv
.append("div")
.attr("class", "trump results-number ");
trumpNumberDiv
.html(`${trumpNum}%`)
.style("background", trumpNum > bidenNum ? "#ffc4bd" : "#fff");
// Make the point lead difference div
let diffDiv = questionDiv.append("div").attr("class", "diff-div");
diffDiv.append("div").attr("class", "diff-left").html(" ");
diffDiv.append("div").attr("class", "diff-right").html(" ");
let factor = 1.5; //0.5 = max of 100, 1.0 = max of 50
diffDiv
.append("div")
.attr("class", "diff-line")
.style(
"border-top",
`2px solid ${
diffNum > 0 ? "#f04f3c" : diffNum < 0 ? "#3caef0" : "#777"
}`
)
.style(
"left",
`${Math.max(0, Math.min(50 + diffNum * factor, 50))}%`
)
.style(
"width",
`calc(${Math.min(50, Math.abs(diffNum * factor))}%)`
);
diffDiv
.append("div")
.attr("class", "diff-ball")
.text(Math.round(Math.abs(trump.pct - biden.pct))) //fix because -8.5 Math.rounds to -8 when we want -9, e.g.
.style(
"border",
`2px solid ${
diffNum > 0 ? "#f04f3c" : diffNum < 0 ? "#3caef0" : "#777"
}`
)
.style(
"background",
`${diffNum > 0 ? "#ffc4bd" : diffNum < 0 ? "#cfeeff" : "#fff"}`
)
.style(
"left",
`calc(${Math.max(0, 50 + diffNum * factor)}% - 11px)`
);
let sampleDiv = questionDiv.append("div").attr("class", "sample-div");
let sampleSize = sampleDiv
.append("div")
.attr("class", "sample-size")
.text(
question.sample_size
? Number(question.sample_size).toLocaleString()
: ""
);
let typeKey = {
lv: "likely voters",
rv: "registered voters",
a: "adults",
v: "voters",
};
let sampleType = sampleDiv
.append("div")
.attr("class", "sample-type")
.text(typeKey[question.type]);
let pollLink = questionDiv
.append("a")
.attr("href", question.url)
.attr("target", "_blank")
.attr("class", "poll-link bold")
.text("LINK");
}
});
// Remove poll if no questions meet criteria (SHow only filtered state & Trump vs. Biden)
if (questionCount === 0) {
pollDiv.remove();
}
});
});
}
function dateFromSlashes(slashDate, showYear = false) {
let monthArray = [
"Jan.",
"Feb.",
"Mar.",
"Apr.",
"May",
"Jun.",
"Jul.",
"Aug.",
"Sep.",
"Oct.",
"Nov.",
"Dec.",
];
let [monthNum, dayNum, yearNum] = slashDate
.split("/")
.map((num) => Number(num));
return `${monthArray[monthNum - 1]} ${dayNum}${
showYear ? `, 20${yearNum}` : ""
}`;
}