// ==UserScript== // @name parkrun Compass Challenge // @description Visualizes your progress on the compass challenge (North, South, East, West parkruns) // @author Pete Johns (@johnsyweb) // @downloadURL https://raw.githubusercontent.com/johnsyweb/tampermonkey-parkrun/refs/heads/main/compass-challenge.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/parkrunner/*/all* // @match *://www.parkrun.co.at/parkrunner/*/all* // @match *://www.parkrun.co.nl/parkrunner/*/all* // @match *://www.parkrun.co.nz/parkrunner/*/all* // @match *://www.parkrun.co.za/parkrunner/*/all* // @match *://www.parkrun.com.au/parkrunner/*/all* // @match *://www.parkrun.com.de/parkrunner/*/all* // @match *://www.parkrun.dk/parkrunner/*/all* // @match *://www.parkrun.fi/parkrunner/*/all* // @match *://www.parkrun.fr/parkrunner/*/all* // @match *://www.parkrun.ie/parkrunner/*/all* // @match *://www.parkrun.it/parkrunner/*/all* // @match *://www.parkrun.jp/parkrunner/*/all* // @match *://www.parkrun.lt/parkrunner/*/all* // @match *://www.parkrun.my/parkrunner/*/all* // @match *://www.parkrun.no/parkrunner/*/all* // @match *://www.parkrun.org.uk/parkrunner/*/all* // @match *://www.parkrun.pl/parkrunner/*/all* // @match *://www.parkrun.se/parkrunner/*/all* // @match *://www.parkrun.sg/parkrunner/*/all* // @match *://www.parkrun.us/parkrunner/*/all* // @namespace http://tampermonkey.net/ // @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/compass-challenge.user.js // @version 1.1.3 // ==/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', accentColor: '#FFA300', completedColor: '#53BA9D', pendingColor: '#666', textColor: '#e0e0e0', subtleTextColor: '#cccccc' }; // Patterns to match compass directions in parkrun names var DIRECTION_PATTERNS = { north: /north/i, south: /south/i, east: /east/i, west: /west/i }; function getResponsiveConfig() { var mobileConfig = { isMobile: true, spacing: { small: '10px', medium: '15px', statsMarginBottom: '8px', completionMarginTop: '8px' }, container: { padding: '10px', marginTop: '10px' }, typography: { heading: '1.1em', stats: '1em', statsSubtext: '0.9em', completion: '0.95em' }, compass: { baseSize: 320 }, button: { padding: '6px 12px', fontSize: '0.9em', marginTop: '10px' } }; var desktopConfig = { isMobile: false, spacing: { small: '20px', medium: '20px', statsMarginBottom: '10px', completionMarginTop: '10px' }, container: { padding: '20px', marginTop: '20px' }, typography: { heading: '1.3em', stats: '1.2em', statsSubtext: '1em', completion: '1.1em' }, compass: { baseSize: 700 }, button: { padding: '8px 15px', fontSize: '1em', marginTop: '15px' } }; var isMobile = window.innerWidth < 768; return isMobile ? mobileConfig : desktopConfig; } function findResultsTable() { var tables = document.querySelectorAll('#results'); return tables[tables.length - 1]; } function extractCompassData(table) { var directions = { north: null, south: null, east: null, west: null }; var completedCount = 0; var dateOfCompletion = null; var totalEvents = 0; var rows = table.querySelectorAll('tr'); // First collect all results since they're in reverse order var allResults = []; rows.forEach(function (row) { var cells = row.querySelectorAll('td'); if (cells.length < 5) return; var eventName = cells[0].textContent.trim(); var date = cells[1].textContent.trim(); var eventNumber = cells[2].textContent.trim(); var time = cells[4].textContent.trim(); if (!time || !eventNumber || !date || !eventName) return; allResults.push({ eventName: eventName, date: date, eventNumber: eventNumber, time: time }); }); // Process in chronological order (reverse the array) var reversedResults = allResults.reverse(); for (var i = 0; !dateOfCompletion && i < reversedResults.length; i++) { var result = reversedResults[i]; var eventName = result.eventName, date = result.date, eventNumber = result.eventNumber, time = result.time; totalEvents++; // Check for compass directions in the event name for (var _i = 0, _Object$entries = Object.entries(DIRECTION_PATTERNS); _i < _Object$entries.length; _i++) { var _Object$entries$_i = _slicedToArray(_Object$entries[_i], 2), direction = _Object$entries$_i[0], pattern = _Object$entries$_i[1]; if (pattern.test(eventName) && !directions[direction]) { directions[direction] = { eventName: eventName, date: date, eventNumber: eventNumber, time: time }; completedCount++; // Check if we've completed the challenge with this event if (completedCount === 4 && !dateOfCompletion) { dateOfCompletion = date; } } } } return { directions: directions, completedCount: completedCount, dateOfCompletion: dateOfCompletion, totalEvents: totalEvents }; } function createCompassContainer(title) { var responsive = getResponsiveConfig(); var container = document.createElement('div'); container.className = 'parkrun-compass-container'; container.style.width = '100%'; container.style.maxWidth = '800px'; container.style.margin = "".concat(responsive.container.marginTop, " auto"); container.style.padding = responsive.container.padding; container.style.backgroundColor = STYLES.backgroundColor; container.style.borderRadius = '8px'; container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; container.style.textAlign = 'center'; var heading = document.createElement('h3'); heading.textContent = title; heading.style.marginBottom = responsive.spacing.small; heading.style.color = STYLES.accentColor; heading.style.fontSize = responsive.typography.heading; container.appendChild(heading); return container; } function createCompass(data) { var responsive = getResponsiveConfig(); var container = createCompassContainer('Compass Challenge'); // Add stats var statsContainer = document.createElement('div'); statsContainer.style.marginBottom = responsive.spacing.small; statsContainer.style.color = STYLES.textColor; var statsText = "
").concat(data.completedCount, " of 4 compass directions completed
"); statsText += "
After ").concat(data.totalEvents, " parkruns
"); if (data.dateOfCompletion) { statsText += "
\uD83E\uDDED Challenge completed on ").concat(data.dateOfCompletion, "
"); } statsContainer.innerHTML = statsText; container.appendChild(statsContainer); // Calculate responsive compass size var desktopBaseSize = 700; // Reference size for scaling calculations var baseSize = responsive.isMobile ? Math.min(responsive.compass.baseSize, window.innerWidth - 40) : responsive.compass.baseSize; // Create compass visual var compassContainer = document.createElement('div'); compassContainer.style.position = 'relative'; compassContainer.style.width = "".concat(baseSize, "px"); compassContainer.style.height = "".concat(baseSize, "px"); compassContainer.style.margin = '0 auto'; compassContainer.style.boxSizing = 'content-box'; // Create SVG for compass var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('viewBox', "0 0 ".concat(desktopBaseSize, " ").concat(desktopBaseSize)); svg.style.position = 'absolute'; svg.style.top = '0'; svg.style.left = '0'; // Define centers and radius (using fixed coordinate system - viewBox handles scaling) var centerX = 350; var centerY = 350; var innerRadius = 30; var outerRadius = 230; // Create the main compass circle var compassCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); compassCircle.setAttribute('cx', centerX); compassCircle.setAttribute('cy', centerY); compassCircle.setAttribute('r', outerRadius); compassCircle.setAttribute('fill', '#334'); compassCircle.setAttribute('stroke', STYLES.accentColor); compassCircle.setAttribute('stroke-width', '5'); svg.appendChild(compassCircle); // First add directions and paths - we'll add the center rose later to ensure higher z-index var directions = [{ name: 'north', angle: 270, label: 'N', data: data.directions.north }, { name: 'east', angle: 0, label: 'E', data: data.directions.east }, { name: 'south', angle: 90, label: 'S', data: data.directions.south }, { name: 'west', angle: 180, label: 'W', data: data.directions.west }]; // Draw the needle for each direction directions.forEach(function (dir) { var angle = dir.angle * (Math.PI / 180); // Calculate the coordinates for the diamond shape var tipX = centerX + outerRadius * Math.cos(angle); var tipY = centerY + outerRadius * Math.sin(angle); // Calculate the middle point (50% from center to edge) var midDistance = outerRadius * 0.5; var needleWidth = outerRadius * 0.12; var midPointX = centerX + midDistance * Math.cos(angle); var midPointY = centerY + midDistance * Math.sin(angle); // Calculate the perpendicular angle var perpAngle = angle + Math.PI / 2; // Calculate width points at the middle of the needle var widthX1 = midPointX + needleWidth * Math.cos(perpAngle); var widthY1 = midPointY + needleWidth * Math.sin(perpAngle); var widthX2 = midPointX - needleWidth * Math.cos(perpAngle); var widthY2 = midPointY - needleWidth * Math.sin(perpAngle); // Create needle path var needlePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); var pathData = "M ".concat(centerX, " ").concat(centerY, " L ").concat(widthX1, " ").concat(widthY1, " L ").concat(tipX, " ").concat(tipY, " L ").concat(widthX2, " ").concat(widthY2, " Z"); needlePath.setAttribute('d', pathData); needlePath.setAttribute('fill', dir.data ? STYLES.completedColor : STYLES.pendingColor); needlePath.setAttribute('stroke', '#000'); needlePath.setAttribute('stroke-width', '1'); needlePath.setAttribute('stroke-opacity', '0.3'); svg.appendChild(needlePath); // Add direction label (N, E, S, W) var labelDistance = outerRadius * 0.5; var labelX = centerX + labelDistance * Math.cos(angle); var labelY = centerY + labelDistance * Math.sin(angle); var dirLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); dirLabel.setAttribute('x', labelX); dirLabel.setAttribute('y', labelY); dirLabel.setAttribute('text-anchor', 'middle'); dirLabel.setAttribute('dominant-baseline', 'middle'); dirLabel.setAttribute('fill', '#fff'); dirLabel.setAttribute('font-size', '28px'); dirLabel.setAttribute('font-weight', 'bold'); dirLabel.setAttribute('stroke', '#000'); dirLabel.setAttribute('stroke-width', '1'); dirLabel.setAttribute('paint-order', 'stroke'); dirLabel.textContent = dir.label; svg.appendChild(dirLabel); // Add parkrun info if completed if (dir.data) { // Extract parkrun name without "parkrun" suffix var nameMatch = dir.data.eventName.match(/(.+?)(?:\s+parkrun)?$/i); var displayName = nameMatch ? nameMatch[1] : dir.data.eventName; // Create combined text (name and date) var combinedText = "".concat(displayName, " - ").concat(dir.data.date); // Create curved text path around the outside of the circle var pathId = "text-path-".concat(dir.name); var textPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // Create curved path for each direction that follows the circle circumference var pathD = ''; var adjustedOffset = outerRadius + 15; // Match the offset used for other directions var pathWidth = 170; switch (dir.name) { case 'north': pathD = "M ".concat(centerX - pathWidth, " ").concat(centerY - adjustedOffset, " A ").concat(adjustedOffset, " ").concat(adjustedOffset, " 0 0 1 ").concat(centerX + pathWidth, " ").concat(centerY - adjustedOffset); break; case 'east': pathD = "M ".concat(centerX + adjustedOffset, " ").concat(centerY - pathWidth, " A ").concat(adjustedOffset, " ").concat(adjustedOffset, " 0 0 1 ").concat(centerX + adjustedOffset, " ").concat(centerY + pathWidth); break; case 'south': // Arc from left to right across the bottom (text curves anticlockwise) pathD = "M ".concat(centerX - pathWidth, " ").concat(centerY + adjustedOffset, " A ").concat(adjustedOffset, " ").concat(adjustedOffset, " 0 0 0 ").concat(centerX + pathWidth, " ").concat(centerY + adjustedOffset); break; case 'west': pathD = "M ".concat(centerX - adjustedOffset, " ").concat(centerY + pathWidth, " A ").concat(adjustedOffset, " ").concat(adjustedOffset, " 0 0 1 ").concat(centerX - adjustedOffset, " ").concat(centerY - pathWidth); break; default: console.warn("Unknown direction: ".concat(dir.name)); break; } textPath.setAttribute('id', pathId); textPath.setAttribute('d', pathD); textPath.setAttribute('fill', 'none'); svg.appendChild(textPath); // Create the text element var eventText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); eventText.setAttribute('fill', '#FFFFFF'); eventText.setAttribute('font-size', '14px'); eventText.setAttribute('font-weight', 'bold'); // Create the textPath element var textPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'textPath'); textPathElement.setAttribute('href', "#".concat(pathId)); textPathElement.setAttribute('startOffset', '50%'); textPathElement.setAttribute('text-anchor', 'middle'); if (dir.name === 'south') { textPathElement.setAttribute('side', 'left'); // Ensures the text is rendered anticlockwise } textPathElement.textContent = combinedText; // Append the textPath to the text element eventText.appendChild(textPathElement); // Append the text element to the SVG svg.appendChild(eventText); } }); // Add drop shadow filter for text var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); var filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter'); filter.setAttribute('id', 'text-shadow'); filter.setAttribute('x', '-20%'); filter.setAttribute('y', '-20%'); filter.setAttribute('width', '140%'); filter.setAttribute('height', '140%'); var feDropShadow = document.createElementNS('http://www.w3.org/2000/svg', 'feDropShadow'); feDropShadow.setAttribute('dx', '0'); feDropShadow.setAttribute('dy', '0'); feDropShadow.setAttribute('stdDeviation', '2'); feDropShadow.setAttribute('flood-color', '#000000'); feDropShadow.setAttribute('flood-opacity', '0.7'); filter.appendChild(feDropShadow); defs.appendChild(filter); svg.appendChild(defs); // Now add compass center AFTER all needles to ensure higher z-index var centerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); centerCircle.setAttribute('cx', centerX); centerCircle.setAttribute('cy', centerY); centerCircle.setAttribute('r', innerRadius); centerCircle.setAttribute('fill', STYLES.accentColor); svg.appendChild(centerCircle); // Make sure the percentage is added last to be on top of everything var percentageText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); percentageText.setAttribute('x', centerX); percentageText.setAttribute('y', centerY); percentageText.setAttribute('text-anchor', 'middle'); percentageText.setAttribute('dominant-baseline', 'middle'); percentageText.setAttribute('fill', STYLES.backgroundColor); percentageText.setAttribute('font-size', '24px'); percentageText.setAttribute('font-weight', 'bold'); percentageText.textContent = "".concat(Math.round(data.completedCount / 4 * 100), "%"); svg.appendChild(percentageText); compassContainer.appendChild(svg); container.appendChild(compassContainer); // Add download button addDownloadButton(container); return container; } function addDownloadButton(container) { var responsive = getResponsiveConfig(); var btnContainer = document.createElement('div'); btnContainer.style.marginTop = responsive.button.marginTop; btnContainer.id = 'compass-download-btn-container'; var downloadBtn = document.createElement('button'); downloadBtn.textContent = '💾 Save as Image'; downloadBtn.style.padding = responsive.button.padding; downloadBtn.style.backgroundColor = STYLES.accentColor; downloadBtn.style.color = STYLES.backgroundColor; downloadBtn.style.border = 'none'; downloadBtn.style.borderRadius = '4px'; downloadBtn.style.cursor = 'pointer'; downloadBtn.style.fontWeight = 'bold'; downloadBtn.style.fontSize = responsive.button.fontSize; // Add hover effect downloadBtn.addEventListener('mouseover', function () { this.style.backgroundColor = '#e59200'; }); downloadBtn.addEventListener('mouseout', function () { this.style.backgroundColor = STYLES.accentColor; }); // Download handler downloadBtn.addEventListener('click', function () { // Hide the download button temporarily for the screenshot downloadBtn.style.display = 'none'; // Use html2canvas to capture the container // eslint-disable-next-line no-undef html2canvas(container, { backgroundColor: STYLES.backgroundColor, scale: 2, // Higher resolution logging: false, allowTaint: true, useCORS: true }).then(function (canvas) { // Show the button again downloadBtn.style.display = 'block'; var link = document.createElement('a'); var timestamp = new Date().toISOString().split('T')[0]; var pageUrl = window.location.pathname.split('/'); var parkrunnerId = pageUrl[2] || 'parkrunner'; link.download = "compass-challenge-".concat(parkrunnerId, "-").concat(timestamp, ".png"); link.href = canvas.toDataURL('image/png'); link.click(); }); }); btnContainer.appendChild(downloadBtn); container.appendChild(btnContainer); } function insertAfterTitle(element) { var pageTitle = document.querySelector('h2'); if (pageTitle && pageTitle.parentNode) { if (pageTitle.nextSibling) { pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling); } else { pageTitle.parentNode.appendChild(element); } } } function initCompassChallenge() { var resultsTable = document.querySelector('#results'); if (!resultsTable) { console.log('Results table not found'); return; } // Extract data var table = findResultsTable(); var data = extractCompassData(table); var compassContainer = createCompass(data); insertAfterTitle(compassContainer); } // Run the script initCompassChallenge(); })();