Use head-to-head duel statistics data to display a rating graph
// ==UserScript== // @name Geoguessr rating graph // @namespace http://tampermonkey.net/ // @version 0.3.2 // @description Use head-to-head duel statistics data to display a rating graph // @author irrational // @match https://www.geoguessr.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com // @license MIT // @require https://greasyfork.org/scripts/460322-geoguessr-styles-scan/code/Geoguessr%20Styles%20Scan.js?version=1151668 // @require https://cdn.jsdelivr.net/npm/[email protected] // @grant none // ==/UserScript== const DATABASE_VERSION = 5; const USERSCRIPT_GRAPH_CANVAS_CLASS = "__userscript_graph_canvas"; const USERSCRIPT_GRAPH_CANVAS_SPACER_CLASS = "__userscript_graph_canvas_spacer"; const ZOOM_HALF_WINDOW = 24 * 3600 * 1000; const ZOOM_TICK_SPACING = 3 * 3600 * 1000; const openDB = async (userId) => { const request = indexedDB.open('userscript_duels'); return new Promise((resolve, reject) => { request.onsuccess = (event) => { const db = event.target.result; db.version == DATABASE_VERSION ? resolve(db) : reject(); }; request.onerror = (event) => reject(); }); }; const fetchUserId = () => { return fetch('https://www.geoguessr.com/api/v3/profiles') .then(response => response.json()) .then(json => json.user.id); }; const flag = (cc) => cc.toUpperCase() == "ZZ" ? "🇺🇳" : String.fromCodePoint(...cc.toUpperCase().split('') .map(char => 127397 + char.charCodeAt())); const fetchUser = (userId) => { return fetch('https://www.geoguessr.com/api/v3/users/' + userId) .then(response => response.json()) .then(user => `${user.nick} ${flag(user.countryCode)}`, () => 'Anonymous ' + flag('zz')); }; const getGames = (db, userId, timeFrom, timeTo, gameMode = null) => { return new Promise(resolve => { const tx = db.transaction(`duels_${userId}`, 'readonly'); const duelsStore = tx.objectStore(`duels_${userId}`); let index, keyRange; if (gameMode) { index = duelsStore.index('timeGameModeIndex'); keyRange = IDBKeyRange.bound([gameMode, timeFrom], [gameMode, timeTo]); } else { index = duelsStore.index('timeIndex'); keyRange = IDBKeyRange.bound(timeFrom, timeTo); } const games = []; const cursorRequest = index.openCursor(keyRange); cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { games.push(cursor.value); cursor.continue(); } else { resolve(games); } }; }); }; const collectGamesFrom = async (db, userId, daysAgo = null) => { let fromDate; if (daysAgo) { fromDate = new Date(); fromDate.setDate(fromDate.getDate() - daysAgo); } else { // https://262.ecma-international.org/15.0/#sec-time-values-and-time-range fromDate = new Date(-8640000000000000); } const games = { overall: await getGames(db, userId, fromDate, new Date()) }; for (const mode of ['StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { games[mode] = await getGames(db, userId, fromDate, new Date(), mode); } return games; }; const makeSpacer = (height) => { const spacer = document.createElement('div'); spacer.className = `${cn('spacer_space__')} ${cn('spacer_height__')}`; spacer.style = `--height: ${height}`; return spacer; }; const modeToLabel = mode => { switch (mode) { case 'overall': return 'Overall'; case 'StandardDuels': return 'Moving'; case 'NoMoveDuels': return 'No Move'; case 'NmpzDuels': return 'NMPZ'; } }; const makeChart = (canvas, games, days) => { const font = { family: '"ggFont", sans-serif', size: 12 }; const color = 'rgba(255, 255, 255, 0.6)'; let zoomedIn = false; const datasets = []; for (const mode of ['overall', 'StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { const datapoints = []; let lastEloAfter = null; let lastTime = null; for (const game of games[mode]) { const gameTime = game.time.getTime(); if (lastEloAfter && (mode == 'overall' ? game.ourEloBefore : game.ourModeEloBefore) != lastEloAfter) { // Plot rating gaps (refunds, missing games, or rating system glitches) as separate datapoints datapoints.push({ x: (lastTime + gameTime) / 2, y: mode == 'overall' ? game.ourEloBefore : game.ourModeEloBefore, eloDifference: mode == 'overall' ? game.ourEloBefore - lastEloAfter : game.ourModeEloBefore - lastEloAfter, gameId: null, gameMode: mode, opponentId: null }); } datapoints.push({ x: gameTime, y: mode == 'overall' ? game.ourEloAfter : game.ourModeEloAfter, eloDifference: mode == 'overall' ? game.ourEloAfter - game.ourEloBefore : game.ourModeEloAfter - game.ourModeEloBefore, gameId: game.gameId, gameMode: mode, opponentId: game.opponent }); lastEloAfter = mode == 'overall' ? game.ourEloAfter : game.ourModeEloAfter; lastTime = gameTime; } datasets.push({ label: modeToLabel(mode), data: datapoints }); } let footerText = null; let lastDatapointOnHover = null; const opponents = {}; const chart = new Chart(canvas, { /* Date adapters don't work in userscripts, so we work with timestamps instead and convert to dates for display */ type: 'line', data: { datasets: datasets }, options: { stepped: 'middle', scales: { x: { type: 'linear', position: 'bottom', ticks: { callback: (value) => zoomedIn ? new Date(value).toLocaleString() : new Date(value).toLocaleDateString(), font: font, color: color }, grid: { color: color } }, y: { grid: { color: color }, ticks: { font: font, color: color }, } }, onClick: (event, elements) => { if (elements.length > 0) { const el = elements[0]; const gameId = chart.data.datasets[el.datasetIndex].data[el.index].gameId; if (gameId) location.pathname = `/duels/${gameId}/summary`; } }, plugins: { tooltip: { callbacks: { title: (context) => { return new Date(context[0].parsed.x).toLocaleString(); }, footer: (context) => { /* This function can't return a promise and so can't be async but fetching the user is async. We must work around that: */ const updateFooter = async (context) => { const data = context[0].raw; if (lastDatapointOnHover == `${data.gameId}-${data.gameMode}`) return; lastDatapointOnHover = `${data.gameId}-${data.gameMode}`; let opponentText; if (data.opponentId) { opponentText = data.opponentId in opponents ? opponents[data.opponentId] : await fetchUser(data.opponentId); opponents[data.opponentId] = opponentText; } const eloType = (data.gameMode == 'overall' ? '' : modeToLabel(data.gameMode) + ' ') + 'Elo'; footerText = `${data.eloDifference > 0 ? '+' : ''}${data.eloDifference} ${eloType}`; if (data.opponentId) { footerText += ` against ${opponentText}`; } else { footerText += "\n(rating gap: refund, missing games, or rating system glitch)"; } chart.update(); }; updateFooter(context); return footerText; } } }, legend: { labels: { font: font, color: color } }, title: { display: true, text: days ? `Rating (past ${days} days)` : 'Rating (all time)', font: {...font, size: 16 }, color: 'white' } } } }); canvas.addEventListener('dblclick', (event) => { const scale = chart.options.scales.x; if (! zoomedIn) { zoomedIn = true; const canvasPosition = Chart.helpers.getRelativePosition(event, chart); const middleX = chart.scales.x.getValueForPixel(canvasPosition.x); scale.min = middleX - ZOOM_HALF_WINDOW; scale.max = middleX + ZOOM_HALF_WINDOW; scale.ticks.stepSize = ZOOM_TICK_SPACING; } else { zoomedIn = false; scale.min = null; scale.max = null; scale.ticks.stepSize = null; } chart.update(); }); let dragging = false; let dragStartX = null; canvas.addEventListener('mousedown', (event) => { if (! zoomedIn) return; dragging = true; const canvasPosition = Chart.helpers.getRelativePosition(event, chart); dragStartX = chart.scales.x.getValueForPixel(canvasPosition.x); }); canvas.addEventListener('mousemove', (event) => { if (! zoomedIn || ! dragging) return; const canvasPosition = Chart.helpers.getRelativePosition(event, chart); const deltaX = chart.scales.x.getValueForPixel(canvasPosition.x) - dragStartX; chart.options.scales.x.min -= deltaX; chart.options.scales.x.max -= deltaX; dragStartX += deltaX; chart.update(); }); canvas.addEventListener('mouseup', (event) => { dragging = false; }); canvas.addEventListener('mouseleave', (event) => { dragging = false; }); }; const makeButton = (parentElement) => { const buttonDiv = document.createElement('div'); buttonDiv.className = cn('game-history-button_gameHistoryButton__'); const button = document.createElement('button'); button.type = 'button'; button.className = `${cn('button_button__')} ${cn('button_variantSecondary__')} ${cn('button_sizeSmall__')}`; const buttonWrapper = document.createElement('div'); buttonWrapper.className = cn('button_wrapper__'); const buttonLabel = document.createElement('span'); buttonLabel.innerHTML = 'Rating graph'; buttonDiv.append(button); button.append(buttonWrapper); buttonWrapper.append(buttonLabel); parentElement.insertAdjacentElement('afterbegin', buttonDiv); return button; }; const makeRangeSelect = () => { const select = document.createElement('select'); select.className = cn('text-input_textInput__'); select.style.width = "4rem"; select.style.padding = "4px"; select.style.fontSize = "12px"; select.style.marginLeft = "1ex"; select.style.background = "transparent"; for (const days of [30, 60, 90, 120, 180, 366]) { const option = document.createElement('option'); option.value = days; option.innerHTML = `${days} days`; select.append(option); } select.innerHTML += '<option value="0">All time</option>'; return select; }; let haveGraphButton = false; let haveGraph = false; const runOnProfilePage = async () => { if (! cn('profile-header_actions__')) return; // some styles are loaded later const buttonContainer = document.querySelector(`.${cn('profile-header_actions__')}`); if (!buttonContainer) return; navigator.locks.request("userscript_duels_graph", async (lock) => { if (haveGraphButton) return; haveGraphButton = true; const graphButton = makeButton(buttonContainer); const graphSelect = makeRangeSelect(); graphButton.insertAdjacentElement('afterend', graphSelect); graphButton.addEventListener('click', (event) => { if (haveGraph) { const canvas = document.querySelector(`.${USERSCRIPT_GRAPH_CANVAS_CLASS}`); canvas.remove(); const spacer = document.querySelector(`.${USERSCRIPT_GRAPH_CANVAS_SPACER_CLASS}`); spacer.remove(); } haveGraph = true; const profileHeader = document.querySelector(`.${cn('profile-header_header__')}`); const spacer = makeSpacer(32); spacer.className = USERSCRIPT_GRAPH_CANVAS_SPACER_CLASS; profileHeader.insertAdjacentElement('afterend', spacer); openDB().then(async (db) => { const days = graphSelect.value > 0 ? graphSelect.value : null; const canvas = document.createElement('canvas'); canvas.className = USERSCRIPT_GRAPH_CANVAS_CLASS; const userId = await fetchUserId(); const games = await collectGamesFrom(db, userId, days); makeChart(canvas, games, days); spacer.insertAdjacentElement('afterend', canvas); }, () => { const errorMessage = document.createElement('div'); errorMessage.innerHTML = 'Can\'t display rating graph: duels database version doesn\'t match.'; spacer.insertAdjacentElement('afterend', errorMessage); }); }); }); }; const run = async (mutations) => { if (location.pathname.match(/^\/(..\/)?me\/profile$/)) { scanStyles().then(runOnProfilePage); } else { haveGraphButton = false; haveGraph = false; } }; new MutationObserver(run).observe(document.body, { subtree: true, childList: true }); run();