// ==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://github.com/johnsyweb/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.0.24 // ==/UserScript== (function () { 'use strict'; const STYLES = { backgroundColor: '#2b223d', accentColor: '#FFA300', completedColor: '#53BA9D', pendingColor: '#666', textColor: '#e0e0e0', subtleTextColor: '#cccccc', }; // Patterns to match compass directions in parkrun names const DIRECTION_PATTERNS = { north: /north/i, south: /south/i, east: /east/i, west: /west/i, }; function findResultsTable() { const tables = document.querySelectorAll('#results'); return tables[tables.length - 1]; } function extractCompassData(table) { const directions = { north: null, south: null, east: null, west: null, }; let completedCount = 0; let dateOfCompletion = null; let totalEvents = 0; const rows = table.querySelectorAll('tr'); // First collect all results since they're in reverse order const allResults = []; rows.forEach((row) => { const cells = row.querySelectorAll('td'); if (cells.length < 5) return; const eventName = cells[0].textContent.trim(); const date = cells[1].textContent.trim(); const eventNumber = cells[2].textContent.trim(); const time = cells[4].textContent.trim(); if (!time || !eventNumber || !date || !eventName) return; allResults.push({ eventName, date, eventNumber, time, }); }); // Process in chronological order (reverse the array) const reversedResults = allResults.reverse(); for (let i = 0; !dateOfCompletion && i < reversedResults.length; i++) { const result = reversedResults[i]; const { eventName, date, eventNumber, time } = result; totalEvents++; // Check for compass directions in the event name for (const [direction, pattern] of Object.entries(DIRECTION_PATTERNS)) { if (pattern.test(eventName) && !directions[direction]) { directions[direction] = { eventName, date, eventNumber, time, }; completedCount++; // Check if we've completed the challenge with this event if (completedCount === 4 && !dateOfCompletion) { dateOfCompletion = date; } } } } return { directions, completedCount, dateOfCompletion, totalEvents, }; } function createCompassContainer(title) { const container = document.createElement('div'); container.className = 'parkrun-compass-container'; container.style.width = '100%'; container.style.maxWidth = '800px'; container.style.margin = '20px auto'; container.style.padding = '20px'; 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'; const heading = document.createElement('h3'); heading.textContent = title; heading.style.marginBottom = '15px'; heading.style.color = STYLES.accentColor; container.appendChild(heading); return container; } function createCompass(data) { const container = createCompassContainer('Compass Challenge'); // Add stats const statsContainer = document.createElement('div'); statsContainer.style.marginBottom = '20px'; statsContainer.style.color = STYLES.textColor; let statsText = `
${data.completedCount} of 4 compass directions completed
`; statsText += `
After ${data.totalEvents} parkruns
`; if (data.dateOfCompletion) { statsText += `
🧭 Challenge completed on ${data.dateOfCompletion}
`; } statsContainer.innerHTML = statsText; container.appendChild(statsContainer); // Create compass visual const compassContainer = document.createElement('div'); compassContainer.style.position = 'relative'; compassContainer.style.width = '700px'; compassContainer.style.height = '700px'; compassContainer.style.margin = '0 auto'; compassContainer.style.boxSizing = 'content-box'; // Create SVG for compass const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('viewBox', '0 0 700 700'); svg.style.position = 'absolute'; svg.style.top = '0'; svg.style.left = '0'; // Define centers and radius const centerX = 350; const centerY = 350; const innerRadius = 30; const outerRadius = 230; // Create the main compass circle const 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 const 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((dir) => { const angle = dir.angle * (Math.PI / 180); // Calculate the coordinates for the diamond shape const tipX = centerX + outerRadius * Math.cos(angle); const tipY = centerY + outerRadius * Math.sin(angle); // Calculate the middle point (50% from center to edge) const midDistance = outerRadius * 0.5; const needleWidth = outerRadius * 0.12; const midPointX = centerX + midDistance * Math.cos(angle); const midPointY = centerY + midDistance * Math.sin(angle); // Calculate the perpendicular angle const perpAngle = angle + Math.PI / 2; // Calculate width points at the middle of the needle const widthX1 = midPointX + needleWidth * Math.cos(perpAngle); const widthY1 = midPointY + needleWidth * Math.sin(perpAngle); const widthX2 = midPointX - needleWidth * Math.cos(perpAngle); const widthY2 = midPointY - needleWidth * Math.sin(perpAngle); // Create needle path const needlePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const pathData = `M ${centerX} ${centerY} L ${widthX1} ${widthY1} L ${tipX} ${tipY} L ${widthX2} ${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) const labelDistance = outerRadius * 0.5; const labelX = centerX + labelDistance * Math.cos(angle); const labelY = centerY + labelDistance * Math.sin(angle); const 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 const nameMatch = dir.data.eventName.match(/(.+?)(?:\s+parkrun)?$/i); const displayName = nameMatch ? nameMatch[1] : dir.data.eventName; // Create combined text (name and date) const combinedText = `${displayName} - ${dir.data.date}`; // Create curved text path around the outside of the circle const pathId = `text-path-${dir.name}`; const textPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // Create curved path for each direction that follows the circle circumference let pathD = ''; const adjustedOffset = outerRadius + 15; // Match the offset used for other directions switch (dir.name) { case 'north': pathD = `M ${centerX - 170} ${centerY - adjustedOffset} A ${adjustedOffset} ${adjustedOffset} 0 0 1 ${centerX + 170} ${centerY - adjustedOffset}`; break; case 'east': pathD = `M ${centerX + adjustedOffset} ${centerY - 170} A ${adjustedOffset} ${adjustedOffset} 0 0 1 ${centerX + adjustedOffset} ${centerY + 170}`; break; case 'south': // Arc from left to right across the bottom (text curves anticlockwise) pathD = `M ${centerX - 170} ${centerY + adjustedOffset} A ${adjustedOffset} ${adjustedOffset} 0 0 0 ${centerX + 170} ${centerY + adjustedOffset}`; break; case 'west': pathD = `M ${centerX - adjustedOffset} ${centerY + 170} A ${adjustedOffset} ${adjustedOffset} 0 0 1 ${centerX - adjustedOffset} ${centerY - 170}`; break; default: console.warn(`Unknown direction: ${dir.name}`); break; } textPath.setAttribute('id', pathId); textPath.setAttribute('d', pathD); textPath.setAttribute('fill', 'none'); svg.appendChild(textPath); // Create the text element const 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 const textPathElement = document.createElementNS('http://www.w3.org/2000/svg', 'textPath'); textPathElement.setAttribute('href', `#${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 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); const 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%'); const 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 const 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 const 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 = `${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) { const btnContainer = document.createElement('div'); btnContainer.style.marginTop = '15px'; btnContainer.id = 'compass-download-btn-container'; const downloadBtn = document.createElement('button'); downloadBtn.textContent = '💾 Save as Image'; downloadBtn.style.padding = '8px 15px'; 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'; // 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((canvas) => { // Show the button again downloadBtn.style.display = 'block'; const link = document.createElement('a'); const timestamp = new Date().toISOString().split('T')[0]; const pageUrl = window.location.pathname.split('/'); const parkrunnerId = pageUrl[2] || 'parkrunner'; link.download = `compass-challenge-${parkrunnerId}-${timestamp}.png`; link.href = canvas.toDataURL('image/png'); link.click(); }); }); btnContainer.appendChild(downloadBtn); container.appendChild(btnContainer); } function insertAfterTitle(element) { const pageTitle = document.querySelector('h2'); if (pageTitle && pageTitle.parentNode) { if (pageTitle.nextSibling) { pageTitle.parentNode.insertBefore(element, pageTitle.nextSibling); } else { pageTitle.parentNode.appendChild(element); } } } function initCompassChallenge() { const resultsTable = document.querySelector('#results'); if (!resultsTable) { console.log('Results table not found'); return; } // Extract data const table = findResultsTable(); const data = extractCompassData(table); const compassContainer = createCompass(data); insertAfterTitle(compassContainer); } // Run the script initCompassChallenge(); })();