// ==UserScript== // @name parkrun Walker Analysis // @description Highlight and summarize walkers (>=10:00/km) and compare with faster participants on parkrun results pages. // @author Pete Johns (@johnsyweb) // @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/parkrun-walker-analysis.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/latestresults/ // @match *://www.parkrun.co.at/*/results/latestresults/ // @match *://www.parkrun.co.nl/*/results/latestresults/ // @match *://www.parkrun.co.nz/*/results/latestresults/ // @match *://www.parkrun.co.za/*/results/latestresults/ // @match *://www.parkrun.com.au/*/results/latestresults/ // @match *://www.parkrun.com.de/*/results/latestresults/ // @match *://www.parkrun.dk/*/results/latestresults/ // @match *://www.parkrun.fi/*/results/latestresults/ // @match *://www.parkrun.fr/*/results/latestresults/ // @match *://www.parkrun.ie/*/results/latestresults/ // @match *://www.parkrun.it/*/results/latestresults/ // @match *://www.parkrun.jp/*/results/latestresults/ // @match *://www.parkrun.lt/*/results/latestresults/ // @match *://www.parkrun.my/*/results/latestresults/ // @match *://www.parkrun.no/*/results/latestresults/ // @match *://www.parkrun.org.uk/*/results/latestresults/ // @match *://www.parkrun.pl/*/results/latestresults/ // @match *://www.parkrun.se/*/results/latestresults/ // @match *://www.parkrun.sg/*/results/latestresults/ // @match *://www.parkrun.us/*/results/latestresults/ // @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-walker-analysis.user.js // @version 1.1.3 // ==/UserScript== // DO NOT EDIT - generated from src/ by scripts/build-scripts.js function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } function _regenerator() { /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ var e, t, r = "function" == typeof Symbol ? Symbol : {}, n = r.iterator || "@@iterator", o = r.toStringTag || "@@toStringTag"; function i(r, n, o, i) { var c = n && n.prototype instanceof Generator ? n : Generator, u = Object.create(c.prototype); return _regeneratorDefine2(u, "_invoke", function (r, n, o) { var i, c, u, f = 0, p = o || [], y = !1, G = { p: 0, n: 0, v: e, a: d, f: d.bind(e, 4), d: function d(t, r) { return i = t, c = 0, u = e, G.n = r, a; } }; function d(r, n) { for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { var o, i = p[t], d = G.p, l = i[2]; r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); } if (o || r > 1) return a; throw y = !0, n; } return function (o, p, l) { if (f > 1) throw TypeError("Generator is already running"); for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); try { if (f = 2, i) { if (c || (o = "next"), t = i[o]) { if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); if (!t.done) return t; u = t.value, c < 2 && (c = 0); } else 1 === c && (t = i.return) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); i = e; } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; } catch (t) { i = e, c = 1, u = t; } finally { f = 1; } } return { value: t, done: y }; }; }(r, o, i), !0), u; } var a = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} t = Object.getPrototypeOf; var c = [][n] ? t(t([][n]())) : (_regeneratorDefine2(t = {}, n, function () { return this; }), t), u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); function f(e) { return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, _regeneratorDefine2(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, _regeneratorDefine2(u, "constructor", GeneratorFunctionPrototype), _regeneratorDefine2(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", _regeneratorDefine2(GeneratorFunctionPrototype, o, "GeneratorFunction"), _regeneratorDefine2(u), _regeneratorDefine2(u, o, "Generator"), _regeneratorDefine2(u, n, function () { return this; }), _regeneratorDefine2(u, "toString", function () { return "[object Generator]"; }), (_regenerator = function _regenerator() { return { w: i, m: f }; })(); } function _regeneratorDefine2(e, r, n, t) { var i = Object.defineProperty; try { i({}, "", {}); } catch (e) { i = 0; } _regeneratorDefine2 = function _regeneratorDefine(e, r, n, t) { function o(r, n) { _regeneratorDefine2(e, r, function (e) { return this._invoke(r, n, e); }); } r ? i ? i(e, r, { value: n, enumerable: !t, configurable: !t, writable: !t }) : e[r] = n : (o("next", 0), o("throw", 1), o("return", 2)); }, _regeneratorDefine2(e, r, n, t); } function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); } function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } var ChartRef = typeof window !== 'undefined' && window.Chart ? window.Chart : undefined; function assignUnknownFinishTimes(finishers) { function findPreviousKnownTime(finishers, startIndex) { var previousFinisher = finishers.slice(0, startIndex).reverse().find(function (f) { return f.timeStr && f.timeSec > 0; }); return previousFinisher ? previousFinisher.timeSec : null; } function findNextKnownTime(finishers, startIndex) { var nextFinisher = finishers.slice(startIndex + 1).find(function (f) { return f.timeStr && f.timeSec > 0; }); return nextFinisher ? nextFinisher.timeSec : null; } return finishers.map(function (finisher, index) { if (finisher.timeStr && finisher.timeSec > 0) { return finisher; } var prevTime = findPreviousKnownTime(finishers, index); var nextTime = findNextKnownTime(finishers, index); var estimatedTime = prevTime || nextTime || 0; return _objectSpread(_objectSpread({}, finisher), {}, { timeSec: estimatedTime, estimatedTime: estimatedTime > 0 }); }); } function getEventMetadata() { var eventName = ''; var eventDate = ''; var eventNumber = ''; var h1 = typeof document !== 'undefined' ? document.querySelector('h1') : null; if (h1) { eventName = h1.textContent.trim(); } else if (typeof document !== 'undefined' && document.title) { eventName = document.title.split('-')[0].trim(); } var h3 = typeof document !== 'undefined' ? document.querySelector('h3') : null; if (h3) { var h3Text = h3.textContent; var dateMatch = h3Text.match(/(\d{1,2}\/\d{1,2}\/\d{2,4})/); if (dateMatch) eventDate = dateMatch[1]; var numMatch = h3Text.match(/#(\d+)/); if (numMatch) eventNumber = numMatch[1]; } return { eventName: eventName, eventDate: eventDate, eventNumber: eventNumber }; } function generateExportFilename(metadata, chartName) { var eventPart = metadata.eventName ? metadata.eventName.replace(/[^a-z0-9]+/gi, '').toLowerCase() : 'event'; var datePart = metadata.eventDate ? metadata.eventDate.replace(/\//g, '_') : 'date'; var numPart = metadata.eventNumber ? metadata.eventNumber : 'num'; return "".concat(eventPart, "_").concat(datePart, "_").concat(numPart, "_").concat(chartName, ".png"); } function computeWalkerThreshold(url) { var DEFAULT = 5; var JUNIOR = 2; var courseLength = DEFAULT; try { if (typeof url === 'string' && url && url.toLowerCase().includes('-juniors')) { courseLength = JUNIOR; } } catch (ignore) { void ignore; } return courseLength * 10 * 60; } function parkrunWalkerAnalysisMain() { 'use strict'; function timeToSeconds(timeStr) { if (!timeStr) return 0; var parts = timeStr.split(':').map(Number); if (parts.length === 2) return parts[0] * 60 + parts[1]; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; return 0; } var rows = Array.from(document.querySelectorAll('tr.Results-table-row')); if (!rows.length) return; function getMilestoneClub(count, prefix) { var milestones = [1000, 500, 250, 100, 50, 25, 10]; for (var _i = 0, _milestones = milestones; _i < _milestones.length; _i++) { var m = _milestones[_i]; if (count >= m) { return { status: "".concat(prefix, " ").concat(m, " Club"), milestone: m }; } } return { status: null, milestone: 0 }; } var finishers = rows.map(function (row, idx) { var timeCell = row.querySelector('.Results-table-td--time .compact'); var timeStr = timeCell ? timeCell.textContent.trim() : ''; var timeSec = timeToSeconds(timeStr); var gender = (row.getAttribute('data-gender') || '').trim(); var runs = parseInt(row.getAttribute('data-runs'), 10); var vols = parseInt(row.getAttribute('data-vols'), 10); var achievement = (row.getAttribute('data-achievement') || '').trim(); var parkrunExperience = 'Unknown'; if (!isNaN(runs) && runs > 0) { if (runs === 1) { parkrunExperience = 'First Timer (anywhere)'; } else if (achievement === 'First Timer!') { parkrunExperience = 'First Timer (to this event)'; } else if (runs < 10) { parkrunExperience = 'Multiple parkruns'; } else { var club = getMilestoneClub(runs, 'parkrun'); parkrunExperience = club.status || 'Multiple parkruns'; } } var volunteerStatus = 'Unknown'; var volunteerMilestone = 0; if (!isNaN(vols)) { if (vols === 0) { volunteerStatus = 'Yet to Volunteer'; } else if (vols === 1) { volunteerStatus = 'Volunteered once'; } else if (vols > 1 && vols < 10) { volunteerStatus = 'Volunteered multiple times'; } else { var _club = getMilestoneClub(vols, 'Volunteer'); volunteerStatus = _club.status || 'Has Volunteered'; volunteerMilestone = _club.milestone; } } var clubMatch = row.innerHTML.match(/milestone-v(\d+)/); if (clubMatch) { volunteerStatus = "Volunteer ".concat(clubMatch[1], " Club"); volunteerMilestone = parseInt(clubMatch[1], 10); } var ageGrade = ''; var ageGradeCell = row.querySelector('.Results-table-td--agegrade'); if (ageGradeCell) { var ag = ageGradeCell.textContent.trim(); if (ag) ageGrade = ag.replace('%', ''); } var ageGroup = 'Unknown'; var agRaw = row.getAttribute('data-agegroup') || ''; if (agRaw) { ageGroup = agRaw.replace(/^[A-Z]+/, ''); } else { var ageGroupCell = row.querySelector('.Results-table-td--agegroup'); if (ageGroupCell) { var cellText = ageGroupCell.textContent.trim().replace(/^[A-Z]+/, ''); ageGroup = cellText || (timeStr ? 'Not specified' : 'Unknown'); } else if (timeStr) { ageGroup = 'Not specified'; } } var normGender = gender.toLowerCase(); if (normGender === 'male' || normGender === 'm') { normGender = 'Male'; } else if (normGender === 'female' || normGender === 'f') { normGender = 'Female'; } else if (timeStr) { normGender = 'Not specified'; } else { normGender = 'Unknown'; } return { timeStr: timeStr, timeSec: timeSec, gender: normGender, parkrunExperience: parkrunExperience, volunteerStatus: volunteerStatus, volunteerMilestone: volunteerMilestone, ageGrade: ageGrade, ageGroup: ageGroup, _row: row, _idx: idx }; }); var finishersWithEstimatedTimes = assignUnknownFinishTimes(finishers); function groupByMinute(breakdownKey) { var bins = {}; var minMinute = Infinity, maxMinute = 0; finishersWithEstimatedTimes.forEach(function (f) { if (f.timeSec === 0) return; var min = Math.floor(f.timeSec / 60); minMinute = Math.min(minMinute, min); maxMinute = Math.max(maxMinute, min); if (!bins[min]) bins[min] = {}; var key = f[breakdownKey] || 'Unknown'; bins[min][key] = (bins[min][key] || 0) + 1; }); return { bins: bins, minMinute: minMinute, maxMinute: maxMinute }; } var milestoneColours = { 10: '#EBE9F0', 25: '#6D5698', 50: '#C81D31', 100: '#2E393B', 250: '#2C504A', 500: '#2E4DA7', 1000: '#FFE049', 'Volunteered once': '#90EE90', 'Volunteered multiple times': '#00CEAE', 'Has Volunteered': '#00CEAE', 'Yet to Volunteer': '#FFA300', Unknown: '#A1B6B7' }; var breakdowns = [{ key: 'parkrunExperience', label: 'parkrun Experience' }, { key: 'volunteerStatus', label: 'Volunteer Experience' }, { key: 'gender', label: 'Gender' }, { key: 'ageGroup', label: 'Age Group' }]; var currentBreakdown = 'parkrunExperience'; var chartContainerId = 'finishersStackedChart'; var walkerChartInstance = null; var controlDiv = document.getElementById('walkerAnalysisControls'); if (!controlDiv) { controlDiv = document.createElement('div'); controlDiv.id = 'walkerAnalysisControls'; controlDiv.style.textAlign = 'center'; controlDiv.style.margin = '20px 0 10px 0'; controlDiv.style.color = '#e0e0e0'; controlDiv.style.background = '#2b223d'; controlDiv.style.padding = '10px'; controlDiv.style.borderRadius = '8px'; controlDiv.style.maxWidth = '900px'; controlDiv.style.marginLeft = 'auto'; controlDiv.style.marginRight = 'auto'; } function buildTable(breakdownKey) { var threshold = computeWalkerThreshold(typeof document !== 'undefined' && document.location ? document.location.href : ''); var walkers = finishersWithEstimatedTimes.filter(function (f) { return f.timeSec >= threshold; }); var runners = finishersWithEstimatedTimes.filter(function (f) { return f.timeSec > 0 && f.timeSec < threshold; }); var totalWalkers = walkers.length; var totalRunners = runners.length; var allValues = new Set(); walkers.forEach(function (f) { return allValues.add(f[breakdownKey] || 'Unknown'); }); runners.forEach(function (f) { return allValues.add(f[breakdownKey] || 'Unknown'); }); var valueList = Array.from(allValues); if (breakdownKey === 'ageGroup') { valueList = valueList.filter(function (v) { return v && v !== 'Unknown' && v !== 'Not specified'; }); valueList.sort(function (a, b) { var aLow = parseInt((a || '').split('-')[0], 10); var bLow = parseInt((b || '').split('-')[0], 10); if (isNaN(aLow)) return 1; if (isNaN(bLow)) return -1; return aLow - bLow; }); if (allValues.has('Not specified')) valueList.push('Not specified'); if (allValues.has('Unknown')) valueList.push('Unknown'); } else if (breakdownKey === 'parkrunExperience') { var experienceOrder = ['First Timer (anywhere)', 'First Timer (to this event)', 'Multiple parkruns', 'parkrun 10 Club', 'parkrun 25 Club', 'parkrun 50 Club', 'parkrun 100 Club', 'parkrun 250 Club', 'parkrun 500 Club', 'parkrun 1000 Club']; var experienceIndex = function experienceIndex(v) { var idx = experienceOrder.indexOf(v); if (idx !== -1) return idx; var m = v.match(/parkrun (\d+) Club/); if (m) { var milestones = [10, 25, 50, 100, 250, 500, 1000]; var num = parseInt(m[1], 10); var milestoneIdx = milestones.indexOf(num); return milestoneIdx !== -1 ? 3 + milestoneIdx : 200 + num; } if (v === 'Unknown') return 9999; return 999; }; valueList.sort(function (a, b) { return experienceIndex(a) - experienceIndex(b); }); } else if (breakdownKey === 'volunteerStatus') { var milestoneOrder = ['Yet to Volunteer', 'Volunteered once', 'Volunteered multiple times', 'Volunteer 10 Club', 'Volunteer 25 Club', 'Volunteer 50 Club', 'Volunteer 100 Club', 'Volunteer 250 Club', 'Volunteer 500 Club', 'Volunteer 1000 Club']; var milestoneIndex = function milestoneIndex(v) { var idx = milestoneOrder.indexOf(v); if (idx !== -1) return idx; var m = v.match(/(\d+)/); if (m) return 200 + parseInt(m[1], 10); if (v === 'Has Volunteered') return 150; if (v === 'Unknown') return 9999; return 999; }; valueList.sort(function (a, b) { return milestoneIndex(a) - milestoneIndex(b); }); } else { valueList.sort(); } var totalFinishers = totalWalkers + totalRunners; var walkerPercent = totalFinishers ? (totalWalkers / totalFinishers * 100).toFixed(1) : '0.0'; var runnerPercent = totalFinishers ? (totalRunners / totalFinishers * 100).toFixed(1) : '0.0'; var html = "
| ".concat(breakdowns.find(function (b) { return b.key === breakdownKey; }).label, " | Walkers (n) | Walkers (%) | Runners (n) | Runners (%) | Total (n) | Total (%) |
|---|---|---|---|---|---|---|
| ".concat(val, " | ").concat(w, " | ").concat(totalWalkers ? (w / totalWalkers * 100).toFixed(1) : '0.0', "% | ").concat(r, " | ").concat(totalRunners ? (r / totalRunners * 100).toFixed(1) : '0.0', "% | ").concat(t, " | ").concat(totalFinishers ? (t / totalFinishers * 100).toFixed(1) : '0.0', "% |
| Total | ".concat(totalWalkers, " | 100.0% | ").concat(totalRunners, " | 100.0% | ").concat(totalFinishers, " | 100.0% |