const { hostname, origin } = window.location; const testDataPath = "/president_polls-2024.txt"; const liveDataPath = "/content/dam/common/exceltojson/president_polls.txt"; const data2024 = origin + (hostname === "localhost" ? testDataPath : liveDataPath); const fullPageLink = "/world/u-s-election-poll-tracker-how-kamala-harris-and-donald-trump-compare-1.6983378"; // const data2024 = "./president_polls-2024.txt"; //const data2024 = // "/content/dam/common/exceltojson/president_polls.txt"; function makeInteractive() { const parent = document.querySelector(".poll-page"); const userOptions = parent.dataset; start(userOptions); function createForm(parent, data, dateIndex, stateArray, candidates, options) { const form = parent.append("form").attr("class", "poll-options"); const makeDropDown = (form, id, label) => { const div = form.append("div"); div.append("label").attr("for", id).text(label); return div.append("select").attr("name", id).attr("id", id); }; const selectDem = makeDropDown(form, "dem", "Democrat"); const selectRep = makeDropDown(form, "rep", "Republican"); const selectLocation = makeDropDown(form, "location", "Poll region"); // const dropdown = page.append("div").attr("class", "poll-dropdown"); selectLocation.append("option").attr("value", "all").text("All polls"); stateArray.forEach((state) => { const option = selectLocation .append("option") .attr("value", state) .text(state); if (state === "") { option.text("National").attr("class", "bold").attr("selected", ""); } }); const candidateData = { candidates: {}, combos: {}, }; data.forEach((date) => { date.polls.forEach((poll) => { poll.questions.forEach((question) => { question.results.forEach((result) => { let party = candidateData.candidates[result.party]; if (!party) { candidateData.candidates[result.party] = []; party = candidateData.candidates[result.party]; } let name = party.find((can) => can.name === result.answer); if (!name) { const candidate = { name: result.answer, party: result.party, fullName: result.candidate_name, count: 0, }; party.push(candidate); name = candidate; } name.count++; }); const string = question.results .map((d) => d.answer) .sort() .join("/"); if (candidateData.combos[string]) { candidateData.combos[string] = candidateData.combos[string] + 1; } else { candidateData.combos[string] = 1; } }); }); }); // const form = d3.select("form.poll-options"); // const selectDem = form.select("select#dem"); // const selectRep = form.select("select#rep"); const minCount = 10; function buildOptions(selection, party) { selection .selectAll("option") .data(candidateData.candidates[party].filter((d) => d.count >= minCount)) .join("option") .attr("value", (d) => d.name) .property("selected", (d) => d.name === candidates.find((c) => c.party === party).name ? "true" : "" ) .html((d) => d.fullName); } buildOptions(selectDem, "DEM"); buildOptions(selectRep, "REP"); function getCombo() { const dem = getCandidate("DEM", selectDem.node().value); const rep = getCandidate("REP", selectRep.node().value); const candidateCombo = [dem.name, rep.name].sort().join("/"); } function getCandidate(party, name) { return candidateData.candidates[party].find((c) => c.name === name); } form.on("change", () => { erase(); const dem = getCandidate("DEM", selectDem.node().value); const rep = getCandidate("REP", selectRep.node().value); const candidates = [dem, rep]; const location = selectLocation.node().value; const filteredData = filterData(data, location, candidates); build(candidates, filteredData, dateIndex, options); }); } async function start(userOptions) { const defaultOptions = { showPolls: true, showSpark: true, startDate: "1/1/24", chartHeight: "250", }; const options = { ...defaultOptions, ...userOptions }; Promise.all([d3.csv(data2024)]).then((files) => { create(files, options); /* const [data, dateIndex, stateArray] = formatData(files[0], options); console.log("Formated data:", data); const page = d3.select(".poll-page"); const candidates = [ { name: "Harris", party: "DEM" }, { name: "Trump", party: "REP" }, ]; createForm(page, data, dateIndex, stateArray, candidates, options); page .append("div") .attr("class", "poll-chart-title bold") .text("U.S. presidential polls"); page .append("div") .attr("class", "poll-chart-div") .style("height", `${options.chartHeight}px`); if (bool(options.showSpark)) { page.append("div").attr("class", "poll-chart-hover"); } if (bool(options.showPolls)) { page .append("div") .attr("class", "poll-grid capped") .append("div") .attr("class", "poll-loading"); let button = page .append("button") .attr("class", "poll-button") .text("Show all") .on("click", () => { d3.select(".poll-grid").classed("capped", false); button.style("display", "none"); }); } else { page .append("a") .attr("class", "full-page bold") .attr("href", fullPageLink) .text("Visit polling page for full details >"); } erase(); const filteredData = filterData(data, "", candidates); build(candidates, filteredData, dateIndex, options); // Filter value of "" for national polls */ }); } function create(files, options) { const [data, dateIndex, stateArray] = formatData(files[0], options); const page = d3.select(".poll-page"); const candidates = [ { name: "Harris", party: "DEM" }, { name: "Trump", party: "REP" }, ]; createForm(page, data, dateIndex, stateArray, candidates, options); page .append("div") .attr("class", "poll-chart-title bold") .text("U.S. presidential polls"); page .append("div") .attr("class", "poll-chart-div") .style("height", `${options.chartHeight}px`); if (bool(options.showSpark)) { page.append("div").attr("class", "poll-chart-hover"); } if (bool(options.showPolls)) { page .append("div") .attr("class", "poll-grid capped") .append("div") .attr("class", "poll-loading"); let button = page .append("button") .attr("class", "poll-button") .text("Show all") .on("click", () => { d3.select(".poll-grid").classed("capped", false); button.style("display", "none"); }); } else { page .append("a") .attr("class", "full-page bold") .attr("href", fullPageLink) .text("Visit polling page for full details >"); } erase(); const filteredData = filterData(data, "", candidates); build(candidates, filteredData, dateIndex, options); // Filter value of "" for national polls } function formatData(data, options) { // 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, dateCreated: result.created_at, 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); } }); }); // Polls are given one date (to plot on the chart and as headings in list) // Two possible dates: // - The end date of when the poll was run // - The date the poll was published ("created_at") // // Currently using the end date let pollsByDateArray = []; pollArray.forEach((poll) => { const dateCreated = poll.dateCreated.split(" ")[0]; const dateEnd = poll.date.end; let date = pollsByDateArray.find((date) => date.date === dateEnd); if (date) { date.polls.push(poll); } else { let newDate = { date: dateEnd, polls: [poll], }; pollsByDateArray.push(newDate); } }); const years = ["23", "24"]; //let oldDateArray = pollsByDateArray.map((date) => date.date); const monthLengthArray = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; const leapMonthLengthArray = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; let dateArray = []; years.forEach((year) => { const array = year % 4 === 0 ? leapMonthLengthArray : monthLengthArray; array.forEach((month, m) => { for (let d = 1; d <= month; d++) { dateArray.push(`${m + 1}/${d}/${year}`); } }); }); // const firstIndex = dateArray.indexOf(pollsByDateArray[0].date); // pollsByDateArray = pollsByDateArray.filter((date) => // years.includes(date.date.split("/")[2]) // ); //TODO: Set start date for polls const firstIndex = dateArray.indexOf(options.startDate); const lastIndex = dateArray.indexOf(pollsByDateArray[0].date); function getFirstPollIndex(date) { const [M, D, Y] = date.split("/").map((d) => +d); const firstPollIndex = pollsByDateArray.findIndex((d) => d.date === date); if (firstPollIndex === -1) { const nextDate = incrementDate(date); if (!nextDate) { console.log("Can't find start date, returning all polls"); return 0; } return getFirstPollIndex(nextDate); } return firstPollIndex; } function incrementDate(date) { const dateIndex = dateArray.indexOf(date); return dateArray[dateIndex + 1]; } // console.log(incrementDate("1/1/23")); // console.log(getFirstPollIndex("1/1/23")); // const firstPollIndex = pollsByDateArray.findIndex( // (d) => d.date === startDate // ); const firstPollIndex = getFirstPollIndex(options.startDate); pollsByDateArray = pollsByDateArray.filter((d, i) => i <= firstPollIndex); dateArray = dateArray.filter((d, i) => i >= firstIndex && i <= lastIndex); dateArray.reverse(); 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(candidates, dateArray, dateIndex, options) { const filteredDateArray = dateArray .filter((date) => date.filteredPolls.length > 0) .sort((a, b) => dateWithZeroes(b.date).localeCompare(dateWithZeroes(a.date)) ); function dateWithZeroes(date) { return date .split("/") .map((d) => d.padStart(2, "0")) .join("/"); } //CHART let svgDiv = d3.select(".poll-chart-div"); let chartHover = d3.select(".poll-chart-hover"); chartHover.selectAll("*").remove(); chartHover.classed("empty", true); 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() { const chartWidth = svgDiv.node().offsetWidth; chart.width = chartWidth; 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([30, 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((result) => { let circle = circleLayer .append("circle") //.attr("r", rScale(Number(response.sample_size))) .attr("r", 3) .attr("cx", xScale(date.index)) .attr("cy", yScale(Number(result.pct))) .attr("class", `chart-circle ${result.answer} ${result.party}`); }); }); }); 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} ${result.party}`) .style("width", `calc(${result.pct}%)`); lineDiv .append("div") .attr("class", `spark-number ${result.answer} ${result.party}`) .text(`${Math.round(result.pct)}%`); }); }); } rect .attr("x", xScale(date.rectIndeces[1])) .attr("y", yScale(100)) .attr("width", () => { return 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("click", () => { chartHover.selectAll("*").remove(); chartHover .append("div") .attr("class", "spark-date") .text(dateFromSlashes(date.date, true, true)); if (bool(options.showSpark)) { date.filteredPolls.forEach((poll) => { makeSparkChart(poll); }); } chartHover.classed("empty", false); }) .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) => { const [month, day, year] = date.split("/"); if (day === "1") { gridLayer .append("text") .attr("class", "chart-grid-date") .text((d, i) => { if (chartWidth < 500 && Number(month) % 2 === 0) { return; } return dateFromSlashes(date, false, month === "1" || i === 0); }) .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 if (!bool(options.showPolls)) { return; } const parent = d3.select(".poll-grid"); // // For each poll... filteredDateArray.forEach((date, i) => { /* Cap date of displayed polls */ const dateContainer = parent.append("div").attr("class", "date-container"); const dateText = dateContainer .append("div") .attr("class", "date-text bold") .text(dateFromSlashes(date.date, true, true)); date.filteredPolls.forEach((poll) => { // ...add a div and the pollster's name... const pollDiv = dateContainer.append("div").attr("class", "poll-div"); const pollName = pollDiv .append("div") .attr("class", "poll-name") .html(poll.pollster); const stateDate = pollDiv.append("div").attr("class", "state-date-div"); //State div const stateDiv = stateDate .append("div") .attr("class", "state-div bold") .text( `${ poll.filteredQuestions[0].state === "" ? "National" : poll.filteredQuestions[0].state }` ); //Date div const dateDiv = stateDate .append("div") .attr("class", "date-div") .text((d) => { const start = dateFromSlashes( poll.filteredQuestions[0].results[0].start_date, true ); const end = dateFromSlashes( poll.filteredQuestions[0].results[0].end_date, true, true ); return `${start} – ${end}`; }); // ...and then one div for the questions within the poll const allQuestionDiv = pollDiv .append("div") .attr("class", "all-question-div"); const headings = allQuestionDiv .append("div") .attr("class", "main-candidates headers"); const names = headings.append("div").attr("class", "poll-heading"); names .selectAll("div.candidate-name") .data(candidates) .join("div") .attr("class", "candidate-name") .text((d) => d.name); // names.append("div").attr("class", "candidate-name").text(candidate1); // names.append("div").attr("class", "candidate-name").text(candidate2); const lead = headings .append("div") .attr("class", "poll-heading") .text("Point lead"); const 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 chosen candidates const dem = question.results.find((r) => stringIsEqual( r.answer, candidates.find((c) => c.party === "DEM").name ) ); const rep = question.results.find((r) => stringIsEqual( r.answer, candidates.find((c) => c.party === "REP").name ) ); // If we don't have a result for both of them (some polls have Biden vs. Pence, etc.) if (!dem || !rep) { return; } else { questionCount++; const otherCandidates = question.results.filter( (result) => result !== dem && result !== rep ); // ...add a results container div... const questionDiv = allQuestionDiv .append("div") .attr("class", "question-div"); const mainDiv = questionDiv .append("div") .attr("class", "main-candidates"); if (otherCandidates.length > 0) { questionDiv .append("div") .attr("class", "other-candidates") .selectAll(".candidate") .data(otherCandidates) .join("div") .attr("class", "candidate") .text((d) => `${d.answer}: ${Math.round(+d.pct)}%`); } // ...a div for each result ... const resultsDiv = mainDiv.append("div").attr("class", "results-div"); const c1Num = Math.round(dem.pct); const c2Num = Math.round(rep.pct); const diffNum = Math.round(rep.pct - dem.pct); const c1NumberDiv = resultsDiv .append("div") .attr("class", `biden DEM results-number`); c1NumberDiv .html(`${c1Num}%`) .style("background", c1Num > c2Num ? "#cfeeff" : "#fff"); const c2NumberDiv = resultsDiv .append("div") .attr("class", "trump REP results-number "); c2NumberDiv .html(`${c2Num}%`) .style("background", c2Num > c1Num ? "#ffc4bd" : "#fff"); // Make the point lead difference div const diffDiv = mainDiv.append("div").attr("class", "diff-div"); diffDiv.append("div").attr("class", "diff-left").html(" "); diffDiv.append("div").attr("class", "diff-right").html(" "); const 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(rep.pct - dem.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)` ); const sampleDiv = mainDiv.append("div").attr("class", "sample-div"); const sampleSize = sampleDiv .append("div") .attr("class", "sample-size") .text( question.sample_size ? Number(question.sample_size).toLocaleString() : "" ); const typeKey = { lv: "likely voters", rv: "registered voters", a: "adults", v: "voters", }; const sampleType = sampleDiv .append("div") .attr("class", "sample-type") .text(typeKey[question.type]); const pollLink = mainDiv .append("div") .attr("class", "poll-link bold") .append("a") .attr("href", question.url) .attr("target", "_blank") .text("LINK"); } }); // Remove poll if no questions meet criteria (SHow only filtered state & Trump vs. Biden) if (questionCount === 0) { pollDiv.remove(); } }); }); } // Data filtering functions function bool(value) { if (typeof value === "boolean") { return value; } else if (typeof value === "string") { const string = value.trim().toLowerCase(); if (string === "false") { return false; } else if (string === "true") { return true; } } console.warn(`${typeof value} "${value}" returned as false`); return false; } function stringIsEqual(a, b) { return a.trim().toLowerCase() === b.trim().toLowerCase(); } function filterByLocation(data, location) { if (location === "all") return data; const filteredData = []; data.forEach((pollGroup) => { const date = { date: pollGroup.date, polls: [], }; pollGroup.polls.forEach((poll) => { if ( poll.questions.some((question) => stringIsEqual(question.state, location) ) ) { date.polls.push(poll); } }); filteredData.push(date); }); return filteredData; } function filterByCandidates(data, candidates) { const filteredData = []; data.forEach((pollGroup) => { const date = { date: pollGroup.date, polls: pollGroup.polls, filteredPolls: [], }; pollGroup.polls.forEach((poll) => { const filteredPoll = { ...poll, filteredQuestions: [] }; poll.questions.forEach((question) => { // const isCorrectLength = question.results.length === 2; const isCorrectLength = question.results.length >= 2; let hasCandidates = true; candidates.forEach((candidate) => { const resultForCandidate = question.results.some((r) => stringIsEqual(r.answer, candidate.name) ); if (!resultForCandidate) { hasCandidates = false; } }); if (isCorrectLength && hasCandidates) { filteredPoll.filteredQuestions.push(question); } }); if (filteredPoll.filteredQuestions.length > 0) { date.filteredPolls.push(filteredPoll); } }); filteredData.push(date); }); return filteredData; } function filterData(data, location, candidates) { const locData = filterByLocation(data, location); const candidatesData = filterByCandidates(locData, candidates); return candidatesData; } function dateFromSlashes(slashDate, showDay, showYear = false) { const monthArray = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const [monthNum, dayNum, yearNum] = slashDate .split("/") .map((num) => Number(num)); const justMonth = !showDay && !showYear; const month = monthArray[monthNum - 1]; const monthPeriod = !justMonth ? "." : ""; const day = showDay ? ` ${dayNum}` : ""; const yearComma = showDay && showYear ? "," : ""; const year = showYear ? ` 20${yearNum}` : ""; return `${month}${monthPeriod}${day}${yearComma}${year}`; } } function addInteractive(interactiveFunction) { const root = document.querySelector(".root"); const article = document.querySelector(".articlefragment"); if (root && article) { const mutationTarget = root; const mutationObserverConfig = { attributes: true }; const callback = function (mutations) { for (let mutation of mutations) { if (mutation.attributeName === "data-v-app") { interactiveFunction(); } } }; const observer = new MutationObserver(callback); observer.observe(mutationTarget, mutationObserverConfig); } else { interactiveFunction(); } } addInteractive(makeInteractive);