Use head-to-head duel statistics data to display a rating graph
当前为
// ==UserScript== // @name Geoguessr rating graph // @namespace http://tampermonkey.net/ // @version 0.4.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_MIN_VERSION = 5; const DATABASE_MIN_TEAM_DUELS_VERSION = 6; const DUELS_STATISTICS_URL = 'https://greasyfork.org/en/scripts/550251-geoguessr-head-to-head-duel-statistics'; 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_MIN_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 fetchTeam = (members) => { return fetch('https://www.geoguessr.com/api/v4/ranked-team-duels/teams?' + members.map(member => `userId=${member}`).join('&')) .then(resp => resp.status == 200 ? resp.json() : null, resp => null) .then(async json => { if (json) { return `${json.teamName} ${json.members.map(member => flag(member.countryCode)).join('')}`; } else { /* Team is no longer on the leaderboard; we have to build our own record. */ const nicks = [], flags = []; for (const member of members) { const user = await fetch('https://www.geoguessr.com/api/v3/users/' + member) .then(resp => resp.status == 200 ? resp.json() : null, resp => null); nicks.push(user.nick); flags.push(flag(user ? user.countryCode : "zz")); } return nicks.map(nick => nick.substring(0, 3)).join('/') + ' ' + flags.join(''); } }); }; 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, partner = null, reverse = false, maxGames = null) => { return new Promise(resolve => { const tx = db.transaction(`${partner ? 'team' : ''}duels_${userId}`, 'readonly'); const duelsStore = tx.objectStore(`${partner ? 'team' : ''}duels_${userId}`); let index, keyRange; if (! partner) { if (gameMode) { index = duelsStore.index('timeGameModeIndex'); keyRange = IDBKeyRange.bound([gameMode, timeFrom], [gameMode, timeTo]); } else { index = duelsStore.index('timeIndex'); keyRange = IDBKeyRange.bound(timeFrom, timeTo); } } else { index = duelsStore.index('timePartnerIndex'); keyRange = IDBKeyRange.bound([partner, timeFrom], [partner, timeTo]); } const games = []; const cursorRequest = index.openCursor(keyRange, reverse ? 'prev' : 'next'); let lastGameId = null; cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { // Team duels are in the database twice, once for each opponent if (! (partner && cursor.value.gameId == lastGameId)) games.push(cursor.value); lastGameId = cursor.value.gameId; if (! maxGames || games.length < maxGames) { cursor.continue(); } else { resolve(games); } } else { resolve(games); } }; }); }; const collectGamesFrom = async (db, userId, daysAgo = null, partner = 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(), null, partner) }; for (const mode of ['StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { games[mode] = partner ? [] : await getGames(db, userId, fromDate, new Date(), mode); } return games; }; const extremalGameDate = async (db, userId, getLatest = false, partner = null) => { const latestGame = await getGames(db, userId, new Date(-8640000000000000), new Date(), null, partner, getLatest, 1); return latestGame.length > 0 ? latestGame[0].time : null; }; 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, opponents: 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, opponents: game.opponents ? game.opponents : null }); lastEloAfter = mode == 'overall' ? game.ourEloAfter : game.ourModeEloAfter; lastTime = gameTime; } if (datapoints.length > 0) 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 opponent data 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 = null; if (data.opponents) { const opponentIds = data.opponents.join(','); opponentText = opponentIds in opponents ? opponents[opponentIds] : await fetchTeam(data.opponents); opponents[opponentIds] = opponentText; } else 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 (opponentText) { 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('auxclick', function(event) { if (event.button != 1) return; const elements = chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }); if (elements.length > 0) { const el = elements[0]; const gameId = chart.data.datasets[el.datasetIndex].data[el.index].gameId; if (gameId) { const anchor = document.createElement('a'); anchor.href = `/duels/${gameId}/summary`; anchor.target = "_blank"; anchor.click(); anchor.remove(); } } }); 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 = (isTeam) => { 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 = `${isTeam ? 'Team r' : 'R'}ating graph`; buttonDiv.append(button); button.append(buttonWrapper); buttonWrapper.append(buttonLabel); return button; }; const makeRangeSelect = (earliest, latest) => { const select = document.createElement('select'); select.className = cn('text-input_textInput__'); select.style.width = "4rem"; select.style.padding = "6px"; select.style.fontSize = "12px"; select.style.background = "transparent"; const now = new Date(); const selectDays = [30, 60, 90, 120, 180, 366]; for (let idx = 0; idx < selectDays.length; ++idx) { if (new Date().setDate(now.getDate() - selectDays[idx]) > latest) continue; const option = document.createElement('option'); option.value = selectDays[idx]; option.innerHTML = `${selectDays[idx]} days`; select.append(option); if (idx < selectDays.length - 1 && new Date().setDate(now.getDate() - selectDays[idx + 1]) < earliest) break; } select.innerHTML += '<option value="0">All time</option>'; return select; }; const showError = (script_min_version, spacer) => { const errorMessage = document.createElement('div'); errorMessage.innerHTML = 'Can\'t display rating graph: duels database version doesn\'t match.<br>' + `You need to be running <a href="${DUELS_STATISTICS_URL}">Head-to-head duels statistics</a>` + `version ${script_min_version} or higher.`; spacer.insertAdjacentElement('afterend', errorMessage); }; let graphPage = null; let lastButtonContainer = null; const runOnProfilePage = async (partner = null) => { // some styles are loaded later if ((! partner) && ! cn('profile-header_actions__')) return; if (partner && ! cn('friend-status_actions__')) return; navigator.locks.request("userscript_duels_graph", async (lock) => { let buttonContainer = null; if (! partner) buttonContainer = document.querySelector(`.${cn('profile-header_actions__')}`); if (partner) buttonContainer = document.querySelector(`.${cn('friend-status_actions__')}`); if (! buttonContainer) return; if (buttonContainer == lastButtonContainer) return; lastButtonContainer = buttonContainer; openDB().then(async (db) => { if (partner && db.version < DATABASE_MIN_TEAM_DUELS_VERSION) return; const userId = await fetchUserId(); const latest = await extremalGameDate(db, userId, true, partner); if (! latest) return; // There are no games to graph const earliest = await extremalGameDate(db, userId, false, partner); const graphButton = makeButton(partner != null); buttonContainer.insertAdjacentElement('afterbegin', graphButton); const graphSelect = makeRangeSelect(earliest, latest); graphButton.insertAdjacentElement('afterend', graphSelect); graphButton.addEventListener('click', (event) => { if (graphPage == location.pathname) { const canvas = document.querySelector(`.${USERSCRIPT_GRAPH_CANVAS_CLASS}`); canvas.remove(); const spacer = document.querySelector(`.${USERSCRIPT_GRAPH_CANVAS_SPACER_CLASS}`); spacer.remove(); } graphPage = location.pathname; 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, partner); makeChart(canvas, games, days); spacer.insertAdjacentElement('afterend', canvas); }, () => showError('0.4.2', spacer)); }); }); }); }; const run = async (mutations) => { if (location.pathname.match(/^\/(..\/)?me\/profile$/)) { scanStyles().then(runOnProfilePage); } else if (location.pathname.match(/^\/(..\/)?user/)) { const partner = location.pathname.split('/').pop(); scanStyles().then(runOnProfilePage(partner)); } else { graphPage = null; lastButtonContainer = null; } }; new MutationObserver(run).observe(document.body, { subtree: true, childList: true }); run();