// ==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://www.johnsy.com/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.1.4 // ==/UserScript== // DO NOT EDIT - generated from src/ by scripts/build-scripts.js function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } (function () { 'use strict'; var STYLES = { backgroundColor: '#2b223d', barColor: '#FFA300', lineColor: '#53BA9D', textColor: '#e0e0e0', subtleTextColor: '#cccccc', gridColor: 'rgba(200, 200, 200, 0.2)' }; var DEBUG_WATERMARK = false; function createChartContainer(title, id) { var width = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 800; var 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)'; var heading = document.createElement('h3'); heading.textContent = title; heading.style.textAlign = 'center'; heading.style.marginBottom = '15px'; heading.style.color = STYLES.barColor; container.appendChild(heading); var canvas = document.createElement('canvas'); canvas.id = id; container.appendChild(canvas); return { container: container, canvas: canvas }; } function insertAfterFirst(selector, element) { var 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) { var controlsContainer = document.createElement('div'); controlsContainer.style.display = 'flex'; controlsContainer.style.justifyContent = 'center'; controlsContainer.style.marginTop = '10px'; controlsContainer.style.marginBottom = '10px'; var 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(function (canvas) { downloadBtn.style.display = 'block'; var link = document.createElement('a'); var timestamp = new Date().toISOString().split('T')[0]; var pageUrl = window.location.pathname.split('/'); var eventName = pageUrl[1]; var chartType = container.classList.contains('eventHistoryChart-container') ? 'event-history' : 'finishers'; link.download = "".concat(eventName, "-").concat(chartType, "-").concat(timestamp, ".png"); link.href = canvas.toDataURL('image/png'); link.click(); }); }); controlsContainer.appendChild(downloadBtn); container.appendChild(controlsContainer); return controlsContainer; } function extractFinishTimeData() { var timeData = {}; var rows = document.querySelectorAll('tr.Results-table-row'); var minMinute = Infinity; var maxMinute = 0; rows.forEach(function (row) { var timeCell = row.querySelector('td.Results-table-td--time'); if (!timeCell) return; var timeText = timeCell.textContent.trim(); var totalMinutes; var hourMatch = timeText.match(/(\d+):(\d+):(\d+)/); if (hourMatch) { var hours = parseInt(hourMatch[1]); var minutes = parseInt(hourMatch[2]); totalMinutes = hours * 60 + minutes; } else { var 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 (var min = minMinute; min <= maxMinute; min++) { if (!timeData[min]) { timeData[min] = 0; } } return { timeData: timeData, minMinute: minMinute, maxMinute: maxMinute }; } function prepareFinisherChartData(_ref) { var timeData = _ref.timeData, minMinute = _ref.minMinute, maxMinute = _ref.maxMinute; var minutes = []; var counts = []; for (var min = minMinute; min <= maxMinute; min++) { minutes.push(min); counts.push(timeData[min] || 0); } var labels = minutes.map(function (min) { var hours = Math.floor(min / 60); var remainingMins = min % 60; return "".concat(hours, ":").concat(remainingMins.toString().padStart(2, '0')); }); return { labels: labels, data: counts }; } function addWatermark(canvas) { var ctx = canvas.getContext('2d'); var scriptName = 'parkrun-charts'; var scriptVersion = 'unknown'; var 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 || ''; } var watermarkText = ["Generated by ".concat(scriptName, " v").concat(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'; var padding = 10; var lineHeight = DEBUG_WATERMARK ? 20 : 14; var x = canvas.width - padding; var y = canvas.height - padding; for (var i = watermarkText.length - 1; i >= 0; i--) { var text = watermarkText[i]; if (text) { ctx.fillText(text, x, y); y -= lineHeight; } } ctx.restore(); } function createFinishersChart() { var _document$querySelect, _document$querySelect2; var eventName = (_document$querySelect = document.querySelector('h1')) === null || _document$querySelect === void 0 || (_document$querySelect = _document$querySelect.textContent) === null || _document$querySelect === void 0 ? void 0 : _document$querySelect.trim(); var eventDate = (_document$querySelect2 = document.querySelector('h3')) === null || _document$querySelect2 === void 0 || (_document$querySelect2 = _document$querySelect2.textContent) === null || _document$querySelect2 === void 0 ? void 0 : _document$querySelect2.trim(); var titlePrefix = [eventName, eventDate].filter(Boolean).join(' | '); var title = [titlePrefix, 'Finishers per Minute'].filter(Boolean).join(': '); var timeData = extractFinishTimeData(); var 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; } var _createChartContainer = createChartContainer(title, 'finishersChart'), container = _createChartContainer.container, canvas = _createChartContainer.canvas; insertAfterFirst('h3', container); addChartDownloadButton(container); var 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: function title(tooltipItems) { var item = tooltipItems[0]; var label = item.label; if (label.includes(':')) { var _label$split = label.split(':'), _label$split2 = _slicedToArray(_label$split, 2), hours = _label$split2[0], mins = _label$split2[1]; return "".concat(hours, " hour").concat(hours === '1' ? '' : 's', " ").concat(mins, " minute").concat(mins === '01' ? '' : 's'); } else { var minute = label.replace("'", ''); return "".concat(minute, " minute").concat(minute === '1' ? '' : 's'); } }, label: function label(context) { return "".concat(context.raw, " finisher").concat(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: function onHover() { setTimeout(function () { return addWatermark(canvas); }, 0); }, onResize: function onResize() { setTimeout(function () { return addWatermark(canvas); }, 0); }, customPlugin: { id: 'watermarkPlugin', afterDraw: function afterDraw() { setTimeout(function () { return addWatermark(canvas); }, 0); } } } }); setTimeout(function () { return addWatermark(canvas); }, 0); } function extractEventHistoryData() { var _document$querySelect3, _document$querySelect4; var title = (_document$querySelect3 = (_document$querySelect4 = document.querySelector('h1')) === null || _document$querySelect4 === void 0 ? void 0 : _document$querySelect4.textContent.trim()) !== null && _document$querySelect3 !== void 0 ? _document$querySelect3 : 'Event History'; var eventNumbers = []; var dates = []; var finishers = []; var volunteers = []; var rows = document.querySelectorAll('tr.Results-table-row'); Array.from(rows).reverse().forEach(function (row) { var eventNumber = row.getAttribute('data-parkrun'); if (eventNumber) { eventNumbers.push(eventNumber); } var date = row.getAttribute('data-date'); if (date) { var dateObj = new Date(date); var formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); dates.push(formattedDate); } var finishersCount = row.getAttribute('data-finishers'); if (finishersCount) { finishers.push(parseInt(finishersCount, 10)); } var volunteersCount = row.getAttribute('data-volunteers'); if (volunteersCount) { volunteers.push(parseInt(volunteersCount, 10)); } }); return { title: title, eventNumbers: eventNumbers, dates: dates, finishers: finishers, volunteers: volunteers }; } function calculateRollingAverage(data, windowSize) { var result = []; for (var i = 0; i < data.length; i++) { if (i < windowSize - 1) { result.push(null); } else { var sum = 0; for (var j = 0; j < windowSize; j++) { sum += data[i - j]; } result.push(parseFloat((sum / windowSize).toFixed(1))); } } return result; } function findMinMaxPoints(data, eventNumbers, dates) { var minValue = Infinity; var maxValue = -Infinity; var minIndex = -1; var maxIndex = -1; for (var 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; } var historyData = extractEventHistoryData(); if (historyData.eventNumbers.length === 0) { console.log('No event history data found'); return; } var rollingAvgWindowSize = 12; var finishersRollingAvg = calculateRollingAverage(historyData.finishers, rollingAvgWindowSize); var volunteersRollingAvg = calculateRollingAverage(historyData.volunteers, rollingAvgWindowSize); var finishersMinMax = findMinMaxPoints(historyData.finishers, historyData.eventNumbers, historyData.dates); var volunteersMinMax = findMinMaxPoints(historyData.volunteers, historyData.eventNumbers, historyData.dates); var 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 } } }; var finishersMax = finishersMinMax.max.value; var volunteersMax = volunteersMinMax.max.value; var useSingleYAxis = sameOrderOfMagnitude(finishersMax, volunteersMax); var finishersAxisId = useSingleYAxis ? 'y-parkrunners' : 'y-finishers'; var volunteersAxisId = useSingleYAxis ? 'y-parkrunners' : 'y-volunteers'; var _createChartContainer2 = createChartContainer("".concat(historyData.title, ": Finishers & Volunteers"), 'eventHistoryChart', 1000), container = _createChartContainer2.container, canvas = _createChartContainer2.canvas; canvas.height = 400; canvas.style.height = '400px'; canvas.style.maxHeight = '400px'; insertAfterFirst('h1', container); var ctx = canvas.getContext('2d'); var 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 = "\n Finishers: Min: ").concat(finishersMinMax.min.value, " (").concat(finishersMinMax.min.date, ", Event #").concat(finishersMinMax.min.eventNumber, ") |\n Max: ").concat(finishersMinMax.max.value, " (").concat(finishersMinMax.max.date, ", Event #").concat(finishersMinMax.max.eventNumber, ")
\n Volunteers: Min: ").concat(volunteersMinMax.min.value, " (").concat(volunteersMinMax.min.date, ", Event #").concat(volunteersMinMax.min.eventNumber, ") |\n Max: ").concat(volunteersMinMax.max.value, " (").concat(volunteersMinMax.max.date, ", Event #").concat(volunteersMinMax.max.eventNumber, ")\n "); container.appendChild(statsFooter); var xAxis = { title: { display: true, text: 'Date', color: STYLES.textColor }, ticks: { color: STYLES.subtleTextColor, maxRotation: 45, minRotation: 45, callback: function callback(value, index) { var totalEvents = historyData.dates.length; var 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 } }; var datasets = [{ label: 'Finishers', data: historyData.finishers, backgroundColor: STYLES.barColor, borderColor: STYLES.barColor, borderWidth: 1, yAxisID: finishersAxisId, order: 1 }, { label: "".concat(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: "".concat(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 }]; var 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 title(tooltipItems) { var index = tooltipItems[0].dataIndex; var eventNumber = historyData.eventNumbers[index]; var date = historyData.dates[index]; return "".concat(date, " (Event #").concat(eventNumber, ")"); }, label: function label(tooltipItem) { var datasetLabel = tooltipItem.dataset.label || ''; if (datasetLabel === 'Finishers') { return "Finishers: ".concat(tooltipItem.raw); } else if (datasetLabel === 'Volunteers') { return "Volunteers: ".concat(tooltipItem.raw); } else if (datasetLabel.includes('Avg')) { return "".concat(datasetLabel, ": ").concat(tooltipItem.raw); } return tooltipItem.formattedValue; } } } }, scales: scales, onHover: function onHover() { setTimeout(function () { return addWatermark(canvas); }, 0); }, onResize: function onResize() { setTimeout(function () { return addWatermark(canvas); }, 0); }, customPlugin: { id: 'watermarkPlugin', afterDraw: function afterDraw() { setTimeout(function () { return addWatermark(canvas); }, 0); } } } }); setTimeout(function () { return addWatermark(canvas); }, 0); addChartDownloadButton(container); } function initCharts() { if (typeof Chart === 'undefined') { console.error('Chart.js not loaded'); return; } var resultsTable = document.querySelector('.Results-table'); if (!resultsTable) { console.log('Results table not found'); return; } var pageUrl = window.location.href; var isEventHistoryPage = pageUrl.includes('/eventhistory/'); if (isEventHistoryPage) { createEventHistoryChart(); } else { createFinishersChart(); } } if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports.sameOrderOfMagnitude = sameOrderOfMagnitude; } else { initCharts(); } })();