// ==UserScript==
// @name parkrun Charts
// @description Displays charts on parkrun pages: finishers per minute on results pages and event history on event history pages
// @author Pete Johns (@johnsyweb)
// @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-charts.user.js
// @grant none
// @homepage https://github.com/johnsyweb/tampermonkey-parkrun
// @icon https://www.google.com/s2/favicons?sz=64&domain=parkrun.com.au
// @license MIT
// @match *://www.parkrun.ca/*/results/*
// @match *://www.parkrun.co.at/*/results/*
// @match *://www.parkrun.co.nl/*/results/*
// @match *://www.parkrun.co.nz/*/results/*
// @match *://www.parkrun.co.za/*/results/*
// @match *://www.parkrun.com.au/*/results/*
// @match *://www.parkrun.com.de/*/results/*
// @match *://www.parkrun.dk/*/results/*
// @match *://www.parkrun.fi/*/results/*
// @match *://www.parkrun.fr/*/results/*
// @match *://www.parkrun.ie/*/results/*
// @match *://www.parkrun.it/*/results/*
// @match *://www.parkrun.jp/*/results/*
// @match *://www.parkrun.lt/*/results/*
// @match *://www.parkrun.my/*/results/*
// @match *://www.parkrun.no/*/results/*
// @match *://www.parkrun.org.uk/*/results/*
// @match *://www.parkrun.pl/*/results/*
// @match *://www.parkrun.se/*/results/*
// @match *://www.parkrun.sg/*/results/*
// @match *://www.parkrun.us/*/results/*
// @namespace http://tampermonkey.net/
// @require https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js
// @require https://html2canvas.hertzen.com/dist/html2canvas.min.js
// @run-at document-end
// @supportURL https://github.com/johnsyweb/tampermonkey-parkrun/issues/
// @tag parkrun
// @updateURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-charts.user.js
// @version 1.0.24
// ==/UserScript==
(function () {
'use strict';
const STYLES = {
backgroundColor: '#2b223d',
barColor: '#FFA300',
lineColor: '#53BA9D',
textColor: '#e0e0e0',
subtleTextColor: '#cccccc',
gridColor: 'rgba(200, 200, 200, 0.2)',
};
const DEBUG_WATERMARK = false;
function createChartContainer(title, id, width = 800) {
const container = document.createElement('div');
container.className = 'parkrun-chart-container ' + id + '-container';
container.style.width = '100%';
container.style.maxWidth = width + 'px';
container.style.margin = '20px auto';
container.style.padding = '15px';
container.style.backgroundColor = STYLES.backgroundColor;
container.style.borderRadius = '8px';
container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
const heading = document.createElement('h3');
heading.textContent = title;
heading.style.textAlign = 'center';
heading.style.marginBottom = '15px';
heading.style.color = STYLES.barColor;
container.appendChild(heading);
const canvas = document.createElement('canvas');
canvas.id = id;
container.appendChild(canvas);
return { container, canvas };
}
function insertAfterFirst(selector, element) {
const pageTitle = document.querySelector(selector);
if (pageTitle && pageTitle.parentNode) {
if (pageTitle.nextSibling) {
pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling);
} else {
pageTitle.parentNode.appendChild(element);
}
}
}
function addChartDownloadButton(container) {
const controlsContainer = document.createElement('div');
controlsContainer.style.display = 'flex';
controlsContainer.style.justifyContent = 'center';
controlsContainer.style.marginTop = '10px';
controlsContainer.style.marginBottom = '10px';
const downloadBtn = document.createElement('button');
downloadBtn.textContent = '💾 Save as Image';
downloadBtn.style.padding = '6px 12px';
downloadBtn.style.backgroundColor = STYLES.barColor;
downloadBtn.style.color = '#2b223d';
downloadBtn.style.border = 'none';
downloadBtn.style.borderRadius = '4px';
downloadBtn.style.cursor = 'pointer';
downloadBtn.style.fontWeight = 'bold';
downloadBtn.style.display = 'inline-block';
downloadBtn.style.margin = '0 5px';
downloadBtn.title = 'Download chart as PNG image';
downloadBtn.addEventListener('mouseover', function () {
this.style.backgroundColor = '#e59200';
});
downloadBtn.addEventListener('mouseout', function () {
this.style.backgroundColor = STYLES.barColor;
});
downloadBtn.addEventListener('click', function () {
downloadBtn.style.display = 'none';
// eslint-disable-next-line no-undef
html2canvas(container, {
backgroundColor: STYLES.backgroundColor,
scale: 2,
logging: false,
allowTaint: true,
useCORS: true,
}).then((canvas) => {
downloadBtn.style.display = 'block';
const link = document.createElement('a');
const timestamp = new Date().toISOString().split('T')[0];
const pageUrl = window.location.pathname.split('/');
const eventName = pageUrl[1];
const chartType = container.classList.contains('eventHistoryChart-container')
? 'event-history'
: 'finishers';
link.download = `${eventName}-${chartType}-${timestamp}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
});
});
controlsContainer.appendChild(downloadBtn);
container.appendChild(controlsContainer);
return controlsContainer;
}
function extractFinishTimeData() {
const timeData = {};
const rows = document.querySelectorAll('tr.Results-table-row');
let minMinute = Infinity;
let maxMinute = 0;
rows.forEach((row) => {
const timeCell = row.querySelector('td.Results-table-td--time');
if (!timeCell) return;
const timeText = timeCell.textContent.trim();
let totalMinutes;
const hourMatch = timeText.match(/(\d+):(\d+):(\d+)/);
if (hourMatch) {
const hours = parseInt(hourMatch[1]);
const minutes = parseInt(hourMatch[2]);
totalMinutes = hours * 60 + minutes;
} else {
const minuteMatch = timeText.match(/(\d+):(\d+)/);
if (!minuteMatch) return;
totalMinutes = parseInt(minuteMatch[1]);
}
minMinute = Math.min(minMinute, totalMinutes);
maxMinute = Math.max(maxMinute, totalMinutes);
if (!timeData[totalMinutes]) {
timeData[totalMinutes] = 1;
} else {
timeData[totalMinutes]++;
}
});
for (let min = minMinute; min <= maxMinute; min++) {
if (!timeData[min]) {
timeData[min] = 0;
}
}
return {
timeData,
minMinute,
maxMinute,
};
}
function prepareFinisherChartData({ timeData, minMinute, maxMinute }) {
const minutes = [];
const counts = [];
for (let min = minMinute; min <= maxMinute; min++) {
minutes.push(min);
counts.push(timeData[min] || 0);
}
const labels = minutes.map((min) => {
const hours = Math.floor(min / 60);
const remainingMins = min % 60;
return `${hours}:${remainingMins.toString().padStart(2, '0')}`;
});
return {
labels,
data: counts,
};
}
function addWatermark(canvas) {
const ctx = canvas.getContext('2d');
let scriptName = 'parkrun-charts';
let scriptVersion = 'unknown';
let scriptUrl = '';
if (typeof GM_info !== 'undefined' && GM_info && GM_info.script) {
scriptName = GM_info.script.name || scriptName;
scriptVersion = GM_info.script.version || scriptVersion;
scriptUrl = GM_info.script.homepage || '';
}
const watermarkText = [`Generated by ${scriptName} v${scriptVersion}`, scriptUrl];
ctx.save();
ctx.font = DEBUG_WATERMARK ? 'bold 16px Arial' : '10px Arial';
ctx.fillStyle = DEBUG_WATERMARK ? 'rgba(255, 0, 0, 0.5)' : 'rgba(200, 200, 200, 0.1)';
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
const padding = 10;
const lineHeight = DEBUG_WATERMARK ? 20 : 14;
const x = canvas.width - padding;
let y = canvas.height - padding;
for (let i = watermarkText.length - 1; i >= 0; i--) {
const text = watermarkText[i];
if (text) {
ctx.fillText(text, x, y);
y -= lineHeight;
}
}
ctx.restore();
}
function createFinishersChart() {
const eventName = document.querySelector('h1')?.textContent?.trim();
const eventDate = document.querySelector('h3')?.textContent?.trim();
const titlePrefix = [eventName, eventDate].filter(Boolean).join(' | ');
const title = [titlePrefix, 'Finishers per Minute'].filter(Boolean).join(': ');
const timeData = extractFinishTimeData();
const chartData = prepareFinisherChartData(timeData);
if (chartData.labels.length === 0) {
console.log('No finish time data found');
return;
}
if (document.getElementById('finishersChart')) {
console.log('Finishers chart already exists, skipping render');
return;
}
const { container, canvas } = createChartContainer(title, 'finishersChart');
insertAfterFirst('h3', container);
addChartDownloadButton(container);
const ctx = canvas.getContext('2d');
// eslint-disable-next-line no-undef
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Number of Finishers',
data: chartData.data,
backgroundColor: STYLES.barColor,
borderColor: STYLES.barColor,
borderWidth: 1,
},
],
},
options: {
animation: false,
responsive: true,
plugins: {
legend: {
labels: {
color: STYLES.textColor,
},
},
title: {
display: false,
color: STYLES.textColor,
},
tooltip: {
callbacks: {
title: (tooltipItems) => {
const item = tooltipItems[0];
const label = item.label;
if (label.includes(':')) {
const [hours, mins] = label.split(':');
return `${hours} hour${hours === '1' ? '' : 's'} ${mins} minute${mins === '01' ? '' : 's'}`;
} else {
const minute = label.replace("'", '');
return `${minute} minute${minute === '1' ? '' : 's'}`;
}
},
label: (context) => {
return `${context.raw} finisher${context.raw === 1 ? '' : 's'}`;
},
},
},
},
scales: {
x: {
title: {
display: true,
text: 'Finish Time',
color: STYLES.textColor,
},
ticks: {
color: STYLES.subtleTextColor,
},
grid: {
color: STYLES.gridColor,
},
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Number of Finishers',
color: STYLES.textColor,
},
ticks: {
precision: 0,
color: STYLES.subtleTextColor,
},
grid: {
color: STYLES.gridColor,
},
},
},
onHover: () => {
setTimeout(() => addWatermark(canvas), 0);
},
onResize: () => {
setTimeout(() => addWatermark(canvas), 0);
},
customPlugin: {
id: 'watermarkPlugin',
afterDraw: () => {
setTimeout(() => addWatermark(canvas), 0);
},
},
},
});
setTimeout(() => addWatermark(canvas), 0);
}
function extractEventHistoryData() {
const title = document.querySelector('h1')?.textContent.trim() ?? 'Event History';
const eventNumbers = [];
const dates = [];
const finishers = [];
const volunteers = [];
const rows = document.querySelectorAll('tr.Results-table-row');
Array.from(rows)
.reverse()
.forEach((row) => {
const eventNumber = row.getAttribute('data-parkrun');
if (eventNumber) {
eventNumbers.push(eventNumber);
}
const date = row.getAttribute('data-date');
if (date) {
const dateObj = new Date(date);
const formattedDate = dateObj.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
dates.push(formattedDate);
}
const finishersCount = row.getAttribute('data-finishers');
if (finishersCount) {
finishers.push(parseInt(finishersCount, 10));
}
const volunteersCount = row.getAttribute('data-volunteers');
if (volunteersCount) {
volunteers.push(parseInt(volunteersCount, 10));
}
});
return {
title,
eventNumbers,
dates,
finishers,
volunteers,
};
}
function calculateRollingAverage(data, windowSize) {
const result = [];
for (let i = 0; i < data.length; i++) {
if (i < windowSize - 1) {
result.push(null);
} else {
let sum = 0;
for (let j = 0; j < windowSize; j++) {
sum += data[i - j];
}
result.push(parseFloat((sum / windowSize).toFixed(1)));
}
}
return result;
}
function findMinMaxPoints(data, eventNumbers, dates) {
let minValue = Infinity;
let maxValue = -Infinity;
let minIndex = -1;
let maxIndex = -1;
for (let i = 0; i < data.length; i++) {
if (data[i] < minValue) {
minValue = data[i];
minIndex = i;
}
if (data[i] === minValue) {
minIndex = i;
}
if (data[i] > maxValue) {
maxValue = data[i];
maxIndex = i;
}
if (data[i] === maxValue) {
maxIndex = i;
}
}
return {
min: {
value: minValue,
eventNumber: eventNumbers[minIndex],
date: dates[minIndex],
index: minIndex,
},
max: {
value: maxValue,
eventNumber: eventNumbers[maxIndex],
date: dates[maxIndex],
index: maxIndex,
},
};
}
function sameOrderOfMagnitude(a, b) {
if (a === 0 || b === 0) return false;
return Math.floor(Math.log10(a)) === Math.floor(Math.log10(b));
}
function createEventHistoryChart() {
if (document.getElementById('eventHistoryChart')) {
console.log('Event history chart already exists, skipping render');
return;
}
const historyData = extractEventHistoryData();
if (historyData.eventNumbers.length === 0) {
console.log('No event history data found');
return;
}
const rollingAvgWindowSize = 12;
const finishersRollingAvg = calculateRollingAverage(
historyData.finishers,
rollingAvgWindowSize
);
const volunteersRollingAvg = calculateRollingAverage(
historyData.volunteers,
rollingAvgWindowSize
);
const finishersMinMax = findMinMaxPoints(
historyData.finishers,
historyData.eventNumbers,
historyData.dates
);
const volunteersMinMax = findMinMaxPoints(
historyData.volunteers,
historyData.eventNumbers,
historyData.dates
);
const axisDefs = {
'y-parkrunners': {
type: 'linear',
position: 'left',
beginAtZero: true,
title: { display: true, text: 'parkrunners', color: STYLES.textColor },
ticks: { precision: 0, color: STYLES.subtleTextColor },
grid: { color: STYLES.gridColor },
},
'y-finishers': {
type: 'linear',
position: 'left',
beginAtZero: true,
title: { display: true, text: 'Number of Finishers', color: STYLES.barColor },
ticks: { precision: 0, color: STYLES.barColor },
grid: { color: STYLES.gridColor },
},
'y-volunteers': {
type: 'linear',
position: 'right',
beginAtZero: true,
title: { display: true, text: 'Number of Volunteers', color: STYLES.lineColor },
ticks: { precision: 0, color: STYLES.lineColor },
grid: { display: false },
},
};
const finishersMax = finishersMinMax.max.value;
const volunteersMax = volunteersMinMax.max.value;
const useSingleYAxis = sameOrderOfMagnitude(finishersMax, volunteersMax);
const finishersAxisId = useSingleYAxis ? 'y-parkrunners' : 'y-finishers';
const volunteersAxisId = useSingleYAxis ? 'y-parkrunners' : 'y-volunteers';
const { container, canvas } = createChartContainer(
`${historyData.title}: Finishers & Volunteers`,
'eventHistoryChart',
1000
);
canvas.height = 400;
canvas.style.height = '400px';
canvas.style.maxHeight = '400px';
insertAfterFirst('h1', container);
const ctx = canvas.getContext('2d');
const statsFooter = document.createElement('div');
statsFooter.className = 'chart-stats-footer';
statsFooter.style.marginTop = '10px';
statsFooter.style.padding = '10px';
statsFooter.style.backgroundColor = STYLES.backgroundColor;
statsFooter.style.color = STYLES.textColor;
statsFooter.style.borderRadius = '4px';
statsFooter.style.fontSize = '14px';
statsFooter.style.textAlign = 'center';
statsFooter.innerHTML = `
Finishers: Min: ${finishersMinMax.min.value} (${finishersMinMax.min.date}, Event #${finishersMinMax.min.eventNumber}) |
Max: ${finishersMinMax.max.value} (${finishersMinMax.max.date}, Event #${finishersMinMax.max.eventNumber})
Volunteers: Min: ${volunteersMinMax.min.value} (${volunteersMinMax.min.date}, Event #${volunteersMinMax.min.eventNumber}) |
Max: ${volunteersMinMax.max.value} (${volunteersMinMax.max.date}, Event #${volunteersMinMax.max.eventNumber})
`;
container.appendChild(statsFooter);
const xAxis = {
title: {
display: true,
text: 'Date',
color: STYLES.textColor,
},
ticks: {
color: STYLES.subtleTextColor,
maxRotation: 45,
minRotation: 45,
callback: function (value, index) {
const totalEvents = historyData.dates.length;
let showEvery = 1;
if (totalEvents > 100) {
showEvery = 10;
} else if (totalEvents > 50) {
showEvery = 5;
} else if (totalEvents > 20) {
showEvery = 2;
}
if (index === 0 || index === historyData.dates.length - 1) {
return historyData.dates[index];
}
return index % showEvery === 0 ? historyData.dates[index] : '';
},
},
grid: {
color: STYLES.gridColor,
},
};
const datasets = [
{
label: 'Finishers',
data: historyData.finishers,
backgroundColor: STYLES.barColor,
borderColor: STYLES.barColor,
borderWidth: 1,
yAxisID: finishersAxisId,
order: 1,
},
{
label: `${rollingAvgWindowSize}-Event Avg (Finishers)`,
data: finishersRollingAvg,
type: 'line',
borderColor: STYLES.barColor,
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 5],
pointRadius: 0,
fill: false,
yAxisID: finishersAxisId,
order: 0,
},
{
label: 'Volunteers',
data: historyData.volunteers,
type: 'line',
borderColor: STYLES.lineColor,
backgroundColor: 'rgba(83, 186, 157, 0.2)',
borderWidth: 1,
pointBackgroundColor: STYLES.lineColor,
pointRadius: 2,
fill: false,
tension: 0.2,
yAxisID: volunteersAxisId,
order: 2,
},
{
label: `${rollingAvgWindowSize}-Event Avg (Volunteers)`,
data: volunteersRollingAvg,
type: 'line',
borderColor: STYLES.lineColor,
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 5],
pointRadius: 0,
fill: false,
yAxisID: volunteersAxisId,
order: 3,
},
];
const scales = { x: xAxis };
scales[finishersAxisId] = axisDefs[finishersAxisId];
if (volunteersAxisId !== finishersAxisId) {
scales[volunteersAxisId] = axisDefs[volunteersAxisId];
}
// eslint-disable-next-line no-undef
new Chart(ctx, {
type: 'bar',
data: {
labels: historyData.dates,
datasets: datasets,
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2.5,
plugins: {
legend: {
labels: {
color: STYLES.textColor,
usePointStyle: true,
},
},
tooltip: {
mode: 'index',
callbacks: {
title: function (tooltipItems) {
const index = tooltipItems[0].dataIndex;
const eventNumber = historyData.eventNumbers[index];
const date = historyData.dates[index];
return `${date} (Event #${eventNumber})`;
},
label: function (tooltipItem) {
const datasetLabel = tooltipItem.dataset.label || '';
if (datasetLabel === 'Finishers') {
return `Finishers: ${tooltipItem.raw}`;
} else if (datasetLabel === 'Volunteers') {
return `Volunteers: ${tooltipItem.raw}`;
} else if (datasetLabel.includes('Avg')) {
return `${datasetLabel}: ${tooltipItem.raw}`;
}
return tooltipItem.formattedValue;
},
},
},
},
scales: scales,
onHover: () => {
setTimeout(() => addWatermark(canvas), 0);
},
onResize: () => {
setTimeout(() => addWatermark(canvas), 0);
},
customPlugin: {
id: 'watermarkPlugin',
afterDraw: () => {
setTimeout(() => addWatermark(canvas), 0);
},
},
},
});
setTimeout(() => addWatermark(canvas), 0);
addChartDownloadButton(container);
}
function initCharts() {
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
const resultsTable = document.querySelector('.Results-table');
if (!resultsTable) {
console.log('Results table not found');
return;
}
const pageUrl = window.location.href;
const isEventHistoryPage = pageUrl.includes('/eventhistory/');
if (isEventHistoryPage) {
createEventHistoryChart();
} else {
createFinishersChart();
}
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports.sameOrderOfMagnitude = sameOrderOfMagnitude;
} else {
initCharts();
}
})();