Poll averages since the 2022 Federal election
Author
Simon Jackman
Published
12:36am, Saturday, 3 May 2025 Australia/Sydney
Grey window in lower panel controls time slice; it can be dragged or widened to change the date range.
Line is poll average; shaded region is a 95% credible interval.
Roll over plot to reveal details of trajectory, individual polls and events.
Orange horizontal reference line indicates 2022 election result.
outcomes = [
{
name: "ALP_2PP",
label: "ALP 2PP",
color: "red",
y_lab: "↑ ALP 2PP"
},
{
name: "ALP",
label: "ALP primary",
color: "red",
y_lab: "↑ ALP"
},
{
name: "Coalition",
label: "Coalition primary",
color: "blue",
y_lab: "↑ Coalition"
},
{
name: "GRN",
label: "Greens primary",
color: "green",
y_lab: "↑ Greens"
}
]
viewof theParty = Inputs.select(
outcomes,
{
label: "Party: ",
value: "ALP_2PP",
format: x => x.label
}
);
data = Object.assign(
d3.csvParse(
await FileAttachment("xi.csv").text(), d3.autoType)
.filter(d => d.party == theParty.name)
.map(({date, xi, lo, up}) => ({date, value: xi, lo, up })),
{y: theParty.y_lab}
)
/* xi = transpose(xi_raw)
data = Object.assign(
transpose(xi_raw).filter(d => d.party == theParty)
.map(({date, xi, lo, up}) => ({date, value: xi, lo, up })),
{y: "↑" + theParty }
)
*/
//data
polls_overlay = d3.csvParse(
await FileAttachment("polls_for_overlay.csv").text(), d3.autoType)
.filter(d => d.party == theParty.name)
.filter(d => !isNaN(d.y));
/*
polls_overlay = transpose(polls_for_overlay_raw)
.filter(d => d.party == theParty)
.filter(d => !isNaN(d.y));
*/
//polls_overlay
events = d3.csvParse(
await FileAttachment("events.csv").text(), d3.autoType);
//election_2022
election_2022 = transpose(election_2022_raw);
chart = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("display", "block")
.style("pointer-events","auto");
const clipId = DOM.uid("clip");
var plotting_region = svg.append("clipPath")
.attr("id", clipId.id)
.append("rect")
.attr("x", margin.left)
.attr("y", 0)
.attr("height", height)
.attr("width", width - margin.left - margin.right);
const gx = svg.append("g");
const gy = svg.append("g");
const ci_path = svg.append("path")
.datum(data)
.attr("clip-path", clipId)
.attr("fill","#CCCCCC7F");
console.log(ci_path);
const path = svg.append("path")
.datum(data)
.attr("clip-path", clipId)
.attr("fill","transparent")
.attr("stroke",theParty.color)
.attr("stroke-width",4);
const d_overlay = svg.append("g")
.attr("clip-path", clipId)
.selectAll("circle")
.data(polls_overlay)
.join("circle")
.attr("cy", d => y(d.y))
.attr("cx", d => x(d.date))
.attr("r", d => z(d.Samplesize))
.attr("r_orig", d => z(d.Samplesize))
.attr("stroke", "#333")
.attr("fill", "transparent")
.attr("class","poll_circle")
.attr("id", d => "poll_circle_" + d.poll_id);
var crosshair = svg
.append("g")
.attr("clip-path", clipId)
.attr("class","crosshair")
.style("display","none");
var crosshair_horizontal = crosshair.append("line")
.attr("class","crosshair_line")
.attr("x1", margin.left)
.attr("x2", width - margin.right)
.attr("y1", 0)
.attr("y2", 0);
// Append vertical line
var crosshair_vertical = crosshair.append("line")
.attr("class", "crosshair_line")
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", 0)
.attr("y2", height - margin.bottom);
// Append a circle at the intersection
var crosshair_circle = crosshair.append("circle")
.attr("class", "crosshair_circle")
.attr("r", 5);
// add info top right
var info_text = svg.append("text")
.attr("class", "info_text")
.attr("x",width - margin.right)
.attr("y",margin.top)
.attr("text-anchor","end")
.text("Roll over plot to reveal polls and trend");
// horizontal reference
var y2022 = election_2022.filter(d => d.party == theParty.name).map(d => d.result)[0];
var result_2022 = svg.append("line")
.attr("x1",margin.left)
.attr("x2",width - margin.right)
.attr("y1",y(y2022))
.attr("y2",y(y2022))
.attr("stroke","orange")
.attr("stroke-width",2);
var result_2022_text = svg.append("text")
.attr("class", "info_text")
.attr("x",margin.left+4)
.attr("y",y(y2022))
.attr("dy",-6)
.attr("text-anchor","start")
.text("2022 election result");
var events_line = svg.append("g")
.attr("clip-path", clipId)
.selectAll("line")
.data(events)
.join("line")
.attr("y1", height - margin.bottom)
.attr("y2", height - margin.bottom - 12)
.attr("x1", d => x(d.date))
.attr("x2", d => x(d.date))
.attr("stroke", "#ddd")
.attr("stroke-width",1)
.attr("id", d => "event_line_id_" + d.event_id);
var events_text = svg.append("g")
.attr("class", "info_text")
.attr("clip-path", clipId)
.selectAll("text")
.data(events)
.join("text")
.attr("y", height - margin.bottom)
.attr("x", d => x(d.date))
.attr("dx",-2)
.attr("dy",-2)
.attr("text-anchor","end")
.text(d => d.event)
.style("display","none")
.attr("id", d => "event_text_id_" + d.event_id);
// poll id text
var poll_text = svg.append("text")
.attr("class","info_text")
.attr("x",width - margin.right)
.attr("y",theParty.name == "Coalition" ? height - margin.bottom - 72 : margin.top + 22)
.attr("text-anchor","end");
return Object.assign(svg.node(), {
update(focusX, focusY) {
gx.call(xAxis, focusX, height);
gy.call(yAxis, focusY, data.y);
events_line.attr("x1", d => focusX(d.date)).attr("x2", d => focusX(d.date));
ci_path.attr("d", area(focusX, focusY));
path.attr("d", line(focusX, focusY));
d_overlay
.attr("cx", d => focusX(d.date))
.attr("cy", d => focusY(d.y));
result_2022.attr("y1",focusY(y2022)).attr("y2",focusY(y2022));
result_2022_text.attr("y",focusY(y2022));
var selectedCircle_id = null;
var currentCircle_id = null;
var selectedEvent_id = null;
var currentEvent_id = null;
// Delaunay/Voronoi for mouseover etc
const delaunay = d3.Delaunay
.from(
polls_overlay,
d => focusX(d.date),
d => focusY(d.y)
);
const radius = 25;
// ineractions (many)
svg
.on("mousemove",
function(event,d){
let [mx, my] = d3.pointer(event);
//console.log(mouseX);
var nearestDataPoint = bisect(data,focusX.invert(mx));
var nearestEvent = bisect(events,focusX.invert(mx));
//console.log(nearestEvent);
//console.log(nearestDataPoint);
//console.log(nearestDataPoint.date);
//console.log(nearestDataPoint.value);
var xloc = focusX(nearestDataPoint.date);
var yloc = focusY(nearestDataPoint.value);
// Update line/circle position
crosshair_horizontal
.attr("x2",xloc)
.attr("y1",yloc)
.attr("y2",yloc);
crosshair_vertical
.attr("y1",yloc)
.attr("x1",xloc)
.attr("x2",xloc);
crosshair_circle
.attr("cx", xloc)
.attr("cy", yloc);
// Update text
info_text
.text(
[ "Poll average: " + d3.utcFormat("%b %e %Y")(nearestDataPoint.date),
theParty.label,
d3.format(".1%")(nearestDataPoint.value),
"±" + d3.format(".1f")(100*(nearestDataPoint.up - nearestDataPoint.lo)/2)
].join(" ")
);
// update circles
var allCircles = d3.selectAll(".poll_circle");
const p = delaunay.find(mx, my);
const d_test = polls_overlay[p];
const dist = Math.hypot(
mx - focusX(d_test.date),
my - focusY(d_test.y)
);
if(dist < radius){
currentCircle_id = d_test.poll_id;
if(currentCircle_id != selectedCircle_id){
allCircles
.interrupt()
.attr("fill","transparent")
.attr("r", function(){return this.getAttribute("r_orig");});
// all polls from this pollster get some highlighting
allCircles
.filter(d => d.Brand === d_test.Brand)
.attr("fill","#FFA500A0");
var thisCircle = d3.select("#" + "poll_circle_" + currentCircle_id);
var circleHighlight = thisCircle
.transition()
.duration(150) // Adjust the duration as needed
.attr("r",20)
//.on("end",function(){
// thisCircle
// .transition()
// .duration(250)
//.attr("r", function(){return this.getAttribute("r_orig");})
.on("end",function(){
thisCircle
.transition()
.duration(0)
.attr("fill","#FFA500A0")
//});
});
// update poll text
poll_text
.attr("display",null)
.datum(
[
"Highlighted poll:",
d_test.Brand,
d_test.Date,
theParty.label + ": " + d3.format(".1%")(d_test.y),
"Sample size: " + d3.format(",")(d_test.Samplesize)
].join("\n"))
.call(multilineText);
// update selected circle
selectedCircle_id = currentCircle_id;
}
} else {
d3.selectAll(".poll_circle")
.interrupt()
.attr("fill","transparent")
.attr("r", function(){return this.getAttribute("r_orig");});
poll_text.attr("display","none");
}
// update event
const event_dist = Math.abs(mx - focusX(nearestEvent.date));
if(event_dist < 20){
currentEvent_id = nearestEvent.event_id;
if(currentEvent_id != selectedEvent_id){
events_line
.attr("stroke-width",1)
.attr("stroke","#ddd")
.attr("y2",height - margin.bottom - 12);
events_text.style("display","none");
var thisEvent_line = d3.select("#" + "event_line_id_" + currentEvent_id);
console.log(thisEvent_line);
thisEvent_line
.attr("y2",margin.top)
.attr("stroke-width",2)
.attr("stroke","orange");
var thisEvent_text = d3.select("#" + "event_text_id_" + currentEvent_id);
thisEvent_text
.attr("x",d => focusX(d.date))
.attr("dx", d => focusX(d.date) > (width/2) ? -4 : 4)
.attr("text-anchor", d => focusX(d.date) > (width/2) ? "end" : "start")
.style("display",null);
selectedEvent_id = currentEvent_id;
}
} else {
events_line
.attr("stroke-width",1)
.attr("stroke","#ddd")
.attr("y2",height - margin.bottom - 12);
events_text.style("display","none");
}
selectedEvent_id = null;
}
);
svg.on("mouseover", function(){
crosshair.style("display", null);
});
svg.on("mouseout", function(){
crosshair.style("display", "none");
});
}
});
}
viewof focus = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, focusHeight])
.style("display", "block");
const brush = d3.brushX()
.extent([[margin.left, 0.5], [width - margin.right, focusHeight - margin.bottom + 0.5]])
.on("brush", brushed)
.on("end", brushended);
//const defaultSelection = [x(d3.utcYear.offset(x.domain()[1], -1)), x.range()[1]];
// close to the election focus in on 2025 to election day
const defaultSelection = [ x(d3.utcYear()), x.range()[1]];
console.log(defaultSelection);
svg.append("g")
.call(xAxis, x, focusHeight);
svg.append("path")
.datum(data)
.attr("fill", "#ccc")
.attr("d", area(x, y.copy().range([focusHeight - margin.bottom, 4])));
svg.append("path")
.datum(data)
.attr("fill", "transparent")
.attr("stroke",theParty.color)
.attr("d", line(x, y.copy().range([focusHeight - margin.bottom, 4])));
const gb = svg.append("g")
.call(brush)
.call(brush.move, defaultSelection);
function brushed({selection}) {
if (selection) {
svg.property("value", selection.map(x.invert, x).map(d3.utcDay.round));
svg.dispatch("input");
}
}
function brushended({selection}) {
if (!selection) {
gb.call(brush.move, defaultSelection);
}
}
return svg.node();
}
line = (x, y) => d3.line()
.defined(d => !isNaN(d.value))
.x(d => x(d.date))
.y(d => y(d.value));
area = (x,y) => d3.area()
.defined(d => !isNaN(d.value))
.x(d => x(d.date))
.y0(d => y(d.lo))
.y1(d => y(d.up));
update = {
const [minX, maxX] = focus;
const maxY = d3.max([
d3.max(data, d => minX <= d.date && d.date <= maxX ? d.up : NaN),
d3.max(polls_overlay, d => minX <= d.date && d.date <= maxX ? d.y + .0025 : NaN)
]);
const minY = d3.min([
d3.min(data, d => minX <= d.date && d.date <= maxX ? d.lo : NaN),
d3.min(polls_overlay, d => minX <= d.date && d.date <= maxX ? d.y - .01 : NaN)
]);
chart.update(x.copy().domain(focus), y.copy().domain([minY, maxY]));
}
function multilineText(
el,
{
fontFamily,
fontSize = 11.5,
lineHeight = 1.45,
textAnchor = "end",
dominantBaseline = "auto"
} = {}
) {
el.each(function (text) {
const lines = text.split("\n");
const textContentHeight = (lines.length - 1) * lineHeight * fontSize;
const el = d3.select(this);
const anchor = {
x: +el.attr("x"),
y: +el.attr("y")
};
const dy =
dominantBaseline === "middle"
? -textContentHeight / 2
: dominantBaseline === "hanging"
? -textContentHeight
: 0;
el
.attr("font-family", fontFamily)
.attr("font-size", fontSize)
.attr("dominant-baseline", dominantBaseline)
.attr("text-anchor", textAnchor)
.selectAll("tspan")
.data(lines)
.join("tspan")
.text((d) => d)
.attr("x", anchor.x)
.attr("y", (d, i) => anchor.y + i * lineHeight * fontSize + dy);
});
}
x = d3.scaleUtc()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
y = d3.scaleLinear()
.domain([
d3.min([
d3.min(polls_overlay, d => d.y) - .0025,
d3.min(data, d => d.lo)
]),
d3.max([
d3.max(polls_overlay, d => d.y) + .0025,
d3.max(data, d => d.up)
])
])
.range([height - margin.bottom, margin.top]);
z = d3.scaleLinear()
.domain(d3.extent(polls_overlay, d => d.Samplesize)).nice()
.range([3,10]);
xAxis = (g, x, height) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));
yAxis = (g, y, title) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).tickFormat(d3.format(".0%")))
.call(
g => g.selectAll(".tick text")
.attr("font-size","11px")
)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".title").data([title]).join("text")
.attr("class", "title")
.attr("x", -margin.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.text(title));
bisect = {
const bisectDate = d3.bisector(d => d.date).center;
return (data, date) => data[bisectDate(data, date)];
}
margin = ({top: 20, right: 20, bottom: 30, left: 40})
height = 440;
focusHeight = 100;
Differences across pollsters
The poll averaging model contains pollster-specific effects (“house effects” or biases) that (a) are constant over time; (b) “cancel out” (or sum to zero) across the included pollsters.
delta = transpose(delta_raw)
delta_data = delta.filter(d => d.party == theParty.name)
Plot.plot({
title: theParty.label,
label: null,
width: 700,
marginLeft: 80,
marginRight: 80,
x: {
axis: "top",
label: null,
labelAnchor: "center",
tickFormat: "+",
percent: true
},
color: {
scheme: "RdBu",
type: "ordinal"
},
marks: [
Plot.barX(
delta_data,
{
x: (d) => d.delta,
y: "label",
fill: (d) => d.delta > 0,
sort: { y: "x" }
}
),
Plot.gridX({ stroke: "white", strokeOpacity: 0.5 }),
d3
.groups(delta_data, (d) => d.delta > 0)
.map(([growth, pollsters]) => [
Plot.axisY({
x: 0,
ticks: pollsters.map((d) => d.label),
tickSize: 0,
anchor: growth ? "left" : "right"
}),
Plot.textX(pollsters, {
x: "delta",
y: "label",
//text: ((f) => (d) => f(d.delta))(d3.format("+.1%")),
text: (d) => d3.format("+.1%")(d.delta) + " ±" + d3.format(".1f")(d.moe*100),
textAnchor: growth ? "start" : "end",
dx: growth ? 4 : -4,
})
]),
Plot.ruleX([0])
]
})