Geoguessr Replay Analyzer

analyze geoguessr replay data

// ==UserScript==
// @name         Geoguessr Replay Analyzer
// @namespace    https://greasyfork.org/users/1179204
// @version      0.0.1
// @description  analyze geoguessr replay data
// @author       KaKa
// @match        https://www.geoguessr.com/duels/*
// @match        https://www.geoguessr.com/team-duels/*
// @run-at       document-end
// @icon         https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com
// @license      BSD
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-annotation.min.js
// ==/UserScript==

(function() {
    let replayData

    function getReplayer(){
        let replayControls = document.querySelector('[class^="replay_main__"]');
        const keys = Object.keys(replayControls)
        const key = keys.find(key => key.startsWith("__reactFiber"))
        const props = replayControls[key]
        replayData=props.return.return.return.return.memoizedProps.replay.samples
    }

    async function downloadPanoramaImage(panoId, fileName, w, h, zoom,d) {
        return new Promise(async (resolve, reject) => {
            try {
                let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl;
                const tileWidth = 512;
                const tileHeight = 512;

                let zoomTiles;
                imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoom}&nbt=0&fover=2`;
                zoomTiles = [2, 4, 8, 16, 32];
                tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoom - 1]);
                tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoom - 1] / 2);

                const canvasWidth = tilesPerRow * tileWidth;
                const canvasHeight = tilesPerColumn * tileHeight;
                canvas = document.createElement('canvas');
                ctx = canvas.getContext('2d');
                canvas.width = canvasWidth;
                canvas.height = canvasHeight;

                const loadTile = (x, y) => {
                    return new Promise(async (resolveTile) => {
                        let tile;

                        tileUrl = `${imageUrl}&x=${x}&y=${y}`;


                        try {
                            tile = await loadImage(tileUrl);
                            ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
                            resolveTile();
                        } catch (error) {
                            console.error(`Error loading tile at ${x},${y}:`, error);
                            resolveTile();
                        }
                    });
                };

                let tilePromises = [];
                for (let y = 0; y < tilesPerColumn; y++) {
                    for (let x = 0; x < tilesPerRow; x++) {
                        tilePromises.push(loadTile(x, y));
                    }
                }

                await Promise.all(tilePromises);
                if(d){
                    resolve(canvas.toDataURL('image/jpeg'));}
                else{
                    canvas.toBlob(blob => {
                        const url = window.URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url;
                        a.download = fileName;
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                        window.URL.revokeObjectURL(url);
                        resolve();
                    }, 'image/jpeg');}
            } catch (error) {
                Swal.fire({
                    title: 'Error!',
                    text: error.toString(),
                    icon: 'error',
                    backdrop: false
                });
                reject(error);
            }
        });
    }

    async function loadImage(url) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = 'Anonymous';
            img.onload = () => resolve(img);
            img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
            img.src = url;
        });
    }

    async function searchGooglePano(t, e, z) {
        try {
            const u = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`;
            const r=50*(21-z)**2
            let payload = createPayload(t,e,r);

            const response = await fetch(u, {
                method: "POST",
                headers: {
                    "content-type": "application/json+protobuf",
                    "x-user-agent": "grpc-web-javascript/0.1"
                },
                body: payload,
                mode: "cors",
                credentials: "omit"
            });

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            } else {
                const data = await response.json();
                if(t=='GetMetadata'){
                    return {
                        panoId: data[1][0][1][1],
                        heading: data[1][0][5][0][1][2][0],
                        worldHeight:data[1][0][2][2][0],
                        worldWidth:data[1][0][2][2][1]
                    };
                }
                return {
                    panoId: data[1][1][1],
                    heading: data[1][5][0][1][2][0]
                };
            }
        } catch (error) {
            console.error(`Failed to fetch metadata: ${error.message}`);
        }
    }

    function createPayload(mode,coorData,r) {
        let payload;
        if(!r)r=50
        if (mode === 'GetMetadata') {
            payload = [["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],["en","US"],[[[2,coorData]]],[[1,2,3,4,8,6]]];
        }
        else if (mode === 'SingleImageSearch') {
            payload =[["apiv3"],
                      [[null,null,coorData.lat,coorData.lng],r],
                      [null,["en","US"],null,null,null,null,null,null,[2],null,[[[2,true,2],[10,true,2]]]], [[1,2,3,4,8,6]]]
        } else {
            throw new Error("Invalid mode!");
        }
        return JSON.stringify(payload);
    }

    function analyze(){
        getReplayer()
        Swal.fire({
            title: 'Replay Analysis',
            html: `
<div style="text-align: center; font-family: sans-serif;">
            <div style="margin-bottom: 10px;">
                 <button id="toggleEventBtn" style="background: #007bff; color: white; font-size: 14px; padding: 8px 15px; border: 2px solid grey; border-radius: 6px; cursor: pointer; margin: 5px;">Event Analysis</button>
                <button id="toggleSVBtn" style="background: #ffc107; color: black; font-size: 14px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer;">StreetView Analysis</button>
            </div>
            <canvas id="chartCanvas" width="300" height="150" style="background: white; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);"></canvas>
            <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 20px; margin-top: 5px;">
                <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;">
                    <p><strong>Event Density:</strong> <span id="eventDensity">Loading...</span></p>
                    <p><strong>Avgerage Gap Time:</strong> <span id="AvgGapTime">Loading...</span></p>
                    <p><strong>Pano Event Ratio:</strong> <span id="streetViewRatio">Loading...</span></p>
                    <p><strong>First PanoZoom:</strong> <span id="firstPanoZoomTime">Loading...</span></p>
                    <p><strong>Longest Single Gap:</strong> <span id="longestGapTime">Loading...</span></p>
                </div>
                <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;">
                    <p><strong>Gap Count:</strong> <span id="stagnationCount">Loading...</span></p>
                    <p><strong>Total Gap Time:</strong> <span id="stagnationTime">Loading...</span></p>
                    <p><strong>Map Event Ratio:</strong> <span id="mapEventRatio">Loading...</span></p>
                    <p><strong>First Map Zoom:</strong> <span id="firstMapZoomTime">Loading...</span></p>
                    <p><strong>Pano POV Speed:</strong> <span id="avgPovSpeed">Loading...</span></p>
                </div>
            </div>
        </div>
    `,
            width: 800,
            showCloseButton: true,
            backdrop:null,
            didOpen: () => {

                const canvas = document.getElementById('chartCanvas')
                const ctx = canvas.getContext('2d', {willReadFrequently: true })
                const toggleSVBtn = document.getElementById('toggleSVBtn');
                const toggleEventBtn = document.getElementById('toggleEventBtn');
                function updateChartData(data, playerName) {
                    chart.resize()
                    const interval = 1000;
                    const eventTypes = [
                        "PanoPov",
                        "PanoZoom",
                        "MapPosition",
                        "MapZoom",
                        "PinPosition",
                        "MapDisplay",
                        "PanoPosition",
                        "GuessWithLatLng"
                    ];

                    const keyEventTypes = ["PinPosition", "MapDisplay", "GuessWithLatLng", "PanoPosition","Timer"];
                    const eventColors = {
                        "MapZoom": "#0000FF",
                        "MapPosition": "#FFA500",
                        "PanoPov": "#00FF00",
                        "GuessWithLatLng": "#FF0000",
                        "PinPosition": "#00FFFF",
                        "MapDisplay": "#800080",
                        "PanoZoom": "#FF69B4",
                        "PanoPosition": "#1E90FF"
                    };

                    const eventBuckets = {};
                    const allEventTimes = {};

                    eventTypes.forEach(eventType => {
                        eventBuckets[eventType] = {};

                    });
                    keyEventTypes.forEach(eventType => {
                        allEventTimes[eventType] = [];
                    });

                    data.forEach(event => {
                        const eventTime = event.time;
                        const relativeTime = eventTime - data[0].time;
                        if(eventBuckets[event.type]){
                            const bucket = Math.floor(relativeTime / interval);

                            if (!eventBuckets[event.type][bucket]) {
                                eventBuckets[event.type][bucket] = 0;
                            }
                            eventBuckets[event.type][bucket]++;
                        }
                        if(allEventTimes[event.type]){
                            allEventTimes[event.type].push(relativeTime); }
                    });

                    const labels = [];
                    const maxBucket = Math.max(
                        ...Object.values(eventBuckets).flatMap(bucket => Object.keys(bucket).map(Number))
                    );

                    for (let i = 0; i <= maxBucket; i++) {
                        const relativeSeconds = (i * interval + interval / 2) / 1000; // 获取3秒区间的中点
                        const minutes = Math.floor(relativeSeconds / 60);
                        const seconds = Math.floor(relativeSeconds % 60);
                        const formattedTime = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
                        labels.push(formattedTime);
                    }

                    const datasets = eventTypes.map(eventType => {
                        const dataPoints = labels.map((label, index) => eventBuckets[eventType][index] || 0);
                        return {
                            label: eventType,
                            data: dataPoints,
                            fill: false,
                            borderColor: eventColors[eventType],
                            backgroundColor: eventColors[eventType],
                            tension: 0.5,
                            hidden: true
                        };
                    });

                    const totalEventsData = labels.map((label, index) => {
                        let total = 0;
                        eventTypes.forEach(eventType => {
                            total += eventBuckets[eventType][index] || 0;
                        });
                        return total;
                    });

                    datasets.push({
                        label: 'Total Events',
                        data: totalEventsData,
                        fill: false,
                        borderColor: 'rgba(0,0,0,0.6)',
                        backgroundColor: 'rgba(0,0,0,0.6)',
                        tension: 0.5
                    });

                    const annotations = [];

                    Object.keys(allEventTimes).forEach(eventType => {
                        allEventTimes[eventType].forEach(eventTime => {
                            const xPosition = eventTime / 1000;
                            annotations.push({
                                type: 'line',
                                xMin: xPosition,
                                xMax: xPosition,
                                borderColor: eventColors[eventType],
                                borderWidth: 1.5,
                                borderDash: [5, 5],
                            });
                        });
                    });

                    chart.data.datasets = datasets;
                    chart.data.labels = labels;
                    chart.options.plugins.annotation.annotations = annotations; // 设置虚线标注
                    chart.update();}

                const chart = new Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: [],
                        datasets: []
                    },
                    options: {
                        responsive: true,
                        plugins: {
                            legend: {
                                display: true,
                                labels: {
                                    boxWidth: 30,
                                    boxHeight: 15,
                                    padding: 30
                                },
                                position: 'top',
                                align: 'center',
                                labels: {
                                    usePointStyle: true,
                                    padding: 20,
                                    pointStyle: 'rectRounded'
                                },
                            },
                            tooltip: { mode: 'index', intersect: false,enabled:false },
                            annotation: {
                                annotations: [],
                            },
                        },
                        scales: {
                            x: { title: { display: true } },
                            y: { title: { display: true, text: 'Event Counts' }, beginAtZero: true }
                        },

                    },

                });

                function updateEventAnalysisData(data) {
                    const { eventDensity, stagnationTime, stagnationCount, AvgGapTime, streetViewRatio, mapEventRatio, firstMapZoomTime, firstPanoZoomTime,longestGapTime,avgPovSpeed} = updateEventAnalysis(data);
                    document.getElementById('eventDensity').textContent = eventDensity.toFixed(2) + " times/s";
                    document.getElementById('stagnationTime').textContent = stagnationTime.toFixed(2) + " s";
                    document.getElementById('longestGapTime').textContent = longestGapTime.toFixed(2) + " s";
                    document.getElementById('avgPovSpeed').textContent = avgPovSpeed.toFixed(2) + " °/s";
                    document.getElementById('stagnationCount').textContent = stagnationCount;
                    document.getElementById('AvgGapTime').textContent = (parseFloat(stagnationTime/stagnationCount)).toFixed(2)+"s";;
                    document.getElementById('streetViewRatio').textContent = (streetViewRatio * 100).toFixed(2) + "%";
                    document.getElementById('mapEventRatio').textContent = (mapEventRatio * 100).toFixed(2) + "%";
                    document.getElementById('firstMapZoomTime').textContent = firstMapZoomTime === null ? "None" : "At " + firstMapZoomTime + " s";
                    document.getElementById('firstPanoZoomTime').textContent = firstPanoZoomTime === null ? "None" : "At " + firstPanoZoomTime + " s";
                }

                updateChartData(replayData);
                updateEventAnalysisData(replayData);

                toggleEventBtn.addEventListener('click',()=>{
                    toggleSVBtn.style.border='none'
                    toggleEventBtn.style.border='2px solid grey'
                    canvas.style.pointerEvents = 'auto';
                    updateChartData(replayData);
                    updateEventAnalysisData(replayData);
                })
                toggleSVBtn.addEventListener('click',async () => {
                    toggleEventBtn.style.border='none'
                    toggleSVBtn.style.border='2px solid grey'
                    canvas.style.pointerEvents='none'
                    var centerHeading;
                    const panoId= replayData.find((item)=>item.type==='PanoPosition').payload.panoId
                    const metaData = await searchGooglePano('GetMetadata', panoId);
                    var w = metaData.worldWidth;
                    var h = metaData.worldHeight;

                    centerHeading = metaData.heading;


                    try {
                        const imageUrl = await downloadPanoramaImage(panoId, panoId, w, h, w==13312?5:3, true);
                        const img = await loadImage(imageUrl);
                        canvas.width = img.width;
                        canvas.height = img.height;
                        ctx.drawImage(img, 0, 0);

                        let lastPanoPov = { heading: 0, pitch: 0 };
                        let stagnationPoints =[];
                        const heatData = replayData.filter(event => ["PanoZoom", "PanoPov"].includes(event.type)).map((event, index, events) => {
                            let heading, pitch, type;
                            let time = event.time;

                            if (event.type === "PanoPov") {
                                [heading, pitch] =[event.payload.heading,event.payload.pitch]
                                lastPanoPov = { heading, pitch };
                                type = "PanoPov";
                            } else if (event.type === "PanoZoom") {
                                heading = lastPanoPov.heading;
                                pitch = lastPanoPov.pitch;
                                type = "PanoZoom";
                            }

                            if (index > 0) {
                                const prevEvent = events[index - 1];
                                const timeDiff = Math.abs(time - prevEvent.time);
                                if (timeDiff > 3000) {
                                    stagnationPoints.push(index);
                                }
                            }

                            return { heading, pitch, type};
                        });

                        drawHeatMapOnImage(canvas, heatData, centerHeading,stagnationPoints);
                    } catch (error) {
                        console.error('Error downloading panorama image:', error);
                    }

                })}
        });
    }

    function drawHeatMapOnImage(canvas, heatData, centerHeading,points) {
        const ctx = canvas.getContext('2d');
        heatData.forEach((point, index) => {
            let headingDifference = point.heading - centerHeading;
            if (headingDifference > 180) {
                headingDifference -= 360;
            } else if (headingDifference < -180) {
                headingDifference += 360;
            }
            const x = (headingDifference + 180) / 360 * canvas.width;
            const y = (90 - point.pitch) / 180 * canvas.height;

            ctx.beginPath();
            if(canvas.width===13312) ctx.arc(x, y, (points.includes(index))?80:40, 0,2* Math.PI);
            else ctx.arc(x, y, (points.includes(index))?30:15, 0,2* Math.PI);

            if (points.includes(index)) {
                ctx.fillStyle = 'yellow';
            } else if (point.type === "PanoZoom") {
                ctx.fillStyle = '#FF0000';
            } else if (point.type === "PanoPov") {
                ctx.fillStyle = '#00FF00';
            }

            ctx.fill();
        });
    }

    function updateEventAnalysis(data) {
        let totalEvents = 0;
        let totalTime = 0;
        let stagnationTime = 0;
        let stagnationCount = 0;
        let switchCount = 0;
        let streetViewEvents = 0;
        let mapEvents = 0;
        let lastEventTime = null;
        let longestGapTime = 0;

        let totalHeadingDifference = 0;
        let totalTimeGap = 0;

        let lastPanoPovEventTime = null;
        let lastHeading = null;

        data.forEach(event => {
            const eventTime = event.time;
            const relativeTime = Math.floor((eventTime - data[0].time) / 1000);

            totalEvents++;
            totalTime = relativeTime;

            if (event.type.includes("Pano")) {
                streetViewEvents++;
            } else if (event.type.includes("Map")) {
                mapEvents++;
            }

            if (lastEventTime !== null) {
                const timeGap = (eventTime - lastEventTime) / 1000;

                if (timeGap >= 3) {
                    if (timeGap > longestGapTime) longestGapTime = timeGap;
                    stagnationTime += timeGap;
                    stagnationCount++;
                }
            }

            if (event.type === "PanoPov" && lastPanoPovEventTime !== null) {
                const headingDifference = Math.abs(event.payload.heading - lastHeading);
                const timeGap = (eventTime - lastPanoPovEventTime) / 1000;

                totalHeadingDifference += headingDifference;
                totalTimeGap += timeGap;
            }

            lastEventTime = eventTime;

            if (event.type === "PanoPov") {
                lastPanoPovEventTime = eventTime;
                lastHeading =event.payload.heading;
            }

            if (event.type === "Switch") {
                switchCount++;
            }
        });

        const eventDensity = totalEvents / totalTime;

        const streetViewRatio = streetViewEvents / totalEvents;
        const mapEventRatio = mapEvents / totalEvents;

        let firstMapZoomTime = null;
        let firstMapZoomTime_ = null;
        let firstPanoZoomTime_ = null;
        let firstPanoZoomTime = null;
        data.forEach(event => {
            if (event.type === "MapZoom" && !firstMapZoomTime) {
                if (firstMapZoomTime_ === null) firstMapZoomTime_ = 1;
                else {
                    firstMapZoomTime = Math.floor((event.time - data[0].time) / 1000);
                }
            }
            if (event.type === "PanoZoom" && !firstPanoZoomTime) {
                if (firstPanoZoomTime_ === null) firstPanoZoomTime_ = 1;
                else {
                    firstPanoZoomTime = Math.floor((event.time - data[0].time) / 1000);
                }
            }
        });

        let avgPovSpeed = 0;
        if (totalTimeGap > 0) {
            avgPovSpeed = totalHeadingDifference / totalTimeGap;
        }

        return {
            eventDensity,
            stagnationTime,
            stagnationCount,
            streetViewRatio,
            mapEventRatio,
            firstPanoZoomTime,
            firstMapZoomTime,
            longestGapTime,
            avgPovSpeed
        };
    }


    let onKeyDown =async (e) => {
        if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
            return;
        }
        if (e.shiftKey&&(e.key === 'K' || e.key === 'k')){
            analyze()
        }
    }

    document.addEventListener("keydown", onKeyDown);
})();