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}` : "" }`; }