Aquileo | New Zealand Polling Tracker
Pollster
Uncertainty
Poll dots
${s.avg.toFixed(1)}%
${arrow(s.change)} · ±${s.margin}
`;
c.appendChild(card);
});
}
/* ── Legend ── */
function buildLegend() {
const c = document.getElementById('nzptLegend');
c.innerHTML = '
Parties · tap to toggle
';
parties.forEach(party => {
const cfg = partyConfig[party];
const s = latest(party);
const item = document.createElement('div');
item.className = 'nzpt-legend-item' + (visibleParties.has(party) ? '' : ' inactive');
item.innerHTML = `
${cfg.name}
${s.avg.toFixed(1)}% `;
item.addEventListener('click', () => {
if (visibleParties.has(party)) {
if (visibleParties.size === 1) return; // keep at least one party on
visibleParties.delete(party);
} else visibleParties.add(party);
item.classList.toggle('inactive');
buildSummary();
updateChart();
});
c.appendChild(item);
});
}
/* ── Chart ── */
const ctx = document.getElementById('nzptChart').getContext('2d');
function buildDatasets() {
const mobile = isMobile();
const ds = [];
parties.forEach(party => {
if (!visibleParties.has(party)) return;
const color = partyConfig[party].color;
const s = cache.out[party];
if (showBand) {
ds.push({ label: party + '_u', data: s.upper, borderColor: 'transparent', backgroundColor: 'transparent',
pointRadius: 0, fill: false, tension: 0.35, spanGaps: false, order: 6 });
ds.push({ label: party + '_b', data: s.lower, borderColor: 'transparent', backgroundColor: color + '12',
pointRadius: 0, fill: '-1', tension: 0.35, spanGaps: false, order: 6 });
}
ds.push({
label: partyConfig[party].name, data: s.trend,
borderColor: color, backgroundColor: color,
borderWidth: mobile ? 2.25 : 2.75,
pointRadius: 0, pointHoverRadius: mobile ? 4 : 5,
pointHoverBackgroundColor: color, pointHoverBorderColor: '#fff', pointHoverBorderWidth: 2,
tension: 0.35, fill: false, spanGaps: false, order: 1
});
});
return ds;
}
// Draws the 5% MMP threshold, the individual poll dots, and the hover crosshair.
const overlay = {
id: 'nzptOverlay',
beforeDatasetsDraw(c) {
const { ctx, chartArea: { left, right, top, bottom }, scales: { y } } = c;
const yp = y.getPixelForValue(5);
if (yp >= top && yp {
if (!visibleParties.has(party)) return;
ctx.save();
ctx.fillStyle = partyConfig[party].color + '52';
getFiltered().forEach(d => {
if (d[party] == null) return;
const px = x.getPixelForValue(+new Date(d.date));
const py = y.getPixelForValue(d[party]);
if (py bottom) return;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill();
});
ctx.restore();
});
}
const yp = y.getPixelForValue(5);
if (yp >= top && yp 0 && t.getActiveElements && t.getActiveElements().length) {
ctx.save();
ctx.beginPath(); ctx.moveTo(t.caretX, top); ctx.lineTo(t.caretX, bottom);
ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.strokeStyle = 'rgba(15,23,42,0.18)'; ctx.stroke();
ctx.restore();
}
}
};
function chartOpts() {
const mobile = isMobile();
return {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false, axis: 'x' },
plugins: {
legend: { display: false },
tooltip: {
filter: i => !i.dataset.label.includes('_') && i.parsed.y != null,
itemSort: (a, b) => b.parsed.y - a.parsed.y,
backgroundColor: '#0f172a',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: '#334155',
borderWidth: 1,
padding: { top: 11, bottom: 11, left: 14, right: 16 },
cornerRadius: 10,
titleFont: { size: 12, weight: '600', family: "'DM Sans', sans-serif" },
titleMarginBottom: 8,
bodyFont: { size: 12, family: "'JetBrains Mono', monospace" },
bodySpacing: 6,
displayColors: true,
boxWidth: 8, boxHeight: 8, boxPadding: 6,
usePointStyle: true,
callbacks: {
title: items => {
if (!items.length) return '';
return new Date(items[0].parsed.x).toLocaleDateString('en-NZ', { day: 'numeric', month: 'short', year: 'numeric' });
},
label: i => {
const name = i.dataset.label;
const pad = name + ' '.repeat(Math.max(1, 14 - name.length));
return pad + i.parsed.y.toFixed(1) + '%';
}
}
}
},
scales: {
x: {
type: 'time',
time: { unit: mobile ? 'quarter' : 'month', displayFormats: { month: 'MMM yy', quarter: 'MMM yy' } },
grid: { display: false },
border: { display: false },
ticks: {
color: '#94a3b8',
font: { size: mobile ? 10 : 11, weight: '500', family: "'DM Sans', sans-serif" },
maxRotation: 0, maxTicksLimit: mobile ? 5 : 10, padding: 8
}
},
y: {
min: 0, max: yMax(),
grid: { color: '#f1f5f9', lineWidth: 1 },
border: { display: false },
ticks: {
color: '#94a3b8',
font: { size: mobile ? 10 : 11, weight: '500', family: "'DM Sans', sans-serif" },
stepSize: 10, padding: 12, callback: v => v + '%'
}
}
},
animation: { duration: 500, easing: 'easeOutQuart' }
};
}
let chart;
function updateChart() {
chart.data.datasets = buildDatasets();
chart.options = chartOpts();
chart.update('none');
}
/* ── Header meta ── */
function setMeta() {
const last = new Date(data[data.length - 1].date);
const first = new Date(data[0].date);
const fmt = (d, o) => d.toLocaleDateString('en-NZ', o);
document.getElementById('nzptEyebrow').textContent =
'Updated ' + fmt(last, { day: 'numeric', month: 'short', year: 'numeric' });
document.getElementById('nzptSub').textContent =
`Rolling 30-day average from ${data.length} polls · ${fmt(first, { month: 'short', year: 'numeric' })} – ${fmt(last, { month: 'short', year: 'numeric' })}`;
}
/* ── Resize ── */
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateChart, 200);
});
/* ── Init ── */
cache = buildAll(getFiltered());
chart = new Chart(ctx, { type: 'line', data: { datasets: buildDatasets() }, options: chartOpts(), plugins: [overlay] });
setMeta();
buildSummary();
buildLegend();
})();