Geoguessr rating graph

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