// ==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();