Guess Preview (GeoGuessr)

Preview your GeoGuessr guess before placing it!

目前為 2025-06-09 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Guess Preview (GeoGuessr)
// @namespace    rawblocky
// @version      2025.06.08
// @description  Preview your GeoGuessr guess before placing it!
// @author       Rawblocky
// @match        *://*.geoguessr.com/*
// @run-at       document-start
// @grant        GM_addStyle
// @grant        unsafeWindow
// @grant        window.onurlchange
// @license      MIT
// ==/UserScript==

// Credit to Alien Perfect's original Guess Peek

const SEARCH_RADIUS = 50000;
const PREVIEW_SIZE_WIDTH = "30%";

GM_addStyle(`
	.guess-preview-button {
	position: absolute;
	bottom: 0;
	left: 0;
	width: ${PREVIEW_SIZE_WIDTH};
	height: auto;
	z-index: 10;
	user-select: none;
}
`);

let svs;

function initSVS() {
	svs = new unsafeWindow.google.maps.StreetViewService();
}

function convertDistance(distance) {
	if (distance >= 1000) return (distance / 1000).toFixed(1) + " km";
	return distance.toFixed(1) + " m";
}

function computeDistanceBetween(coords1, coords2) {
	return unsafeWindow.google.maps.geometry.spherical.computeDistanceBetween(
		coords1,
		coords2
	);
}

function getStreetViewUrl(panoId) {
	return `https://www.google.com/maps/@?api=1&map_action=pano&pano=${panoId}`;
}

async function getNearestPano(coords) {
	let pano = {};
	let panorama, oldRadius;
	let radius = SEARCH_RADIUS;
	if (!svs) initSVS();

	// eslint-disable-next-line no-constant-condition
	while (true) {
		try {
			panorama = await svs.getPanorama({
				location: coords,
				radius: radius,
				source: "outdoor",
				preference: "nearest",
			});
			let roadHeading = 0;
			if (panorama.data.tiles && panorama.data.tiles.centerHeading) {
				roadHeading = panorama.data.tiles.centerHeading;
			}

			radius = computeDistanceBetween(coords, panorama.data.location.latLng);
			pano.radius = radius;
			pano.url =
				getStreetViewUrl(panorama.data.location.pano) +
				`&heading=${roadHeading}`;
			pano.image = `https://streetviewpixels-pa.googleapis.com/v1/thumbnail?w=640&h=360&panoid=${panorama.data.location.pano}&yaw=${roadHeading}&cb_client=maps_sv.share`;

			if (oldRadius && radius >= oldRadius) break;
			oldRadius = radius;
		} catch (e) {
			break;
		}
	}

	return pano;
}

function removeImage() {
	const container = document.querySelector(
		'[class^="guess-map_canvasContainer__"]'
	);

	if (container) {
		const button = container.querySelector(".guess-preview-button");
		if (button) {
			container.removeChild(button);
		}
	}
}

function getIsClassicGame() {
	const currentUrl = window.location.href;
	return (
		currentUrl.includes("geoguessr.com/game/") ||
		currentUrl.includes("geoguessr.com/challenge/")
	);
}

function getImage() {
	if (!getIsClassicGame) {
		return removeImage();
	}
	const container = document.querySelector(
		'[class^="guess-map_canvasContainer__"]'
	);

	if (container) {
		let button = container.querySelector(".guess-preview-button");
		if (!button) {
			button = document.createElement("a");
			button.className = "guess-preview-button";
			button.target = "_blank";
			button.style.zIndex = 10;

			let img = document.createElement("img");
			img.className = "guess-preview";
			img.style.width = "100%";
			img.style.height = "100%";
			img.style.zIndex = 10;

			button.appendChild(img);
			container.appendChild(button);
		}

		return [button.querySelector(".guess-preview"), button];
	} else {
		return null;
	}
}

const originalFetch = unsafeWindow.fetch;

let lastRanEpoch = 0;

async function onFetch(args) {
	if (!getIsClassicGame) {
		removeImage();
		return;
	}

	// Cooldown
	const currentEpoch = Date.now();
	const previousEpoch = lastRanEpoch;
	lastRanEpoch = currentEpoch;

	if (currentEpoch - previousEpoch < 1000) {
		await new Promise((resolve) => setTimeout(resolve, 1000));
	}
	if (currentEpoch !== lastRanEpoch) {
		return;
	}

	// Whenever the terrain api gets called, it'll send the coords with it (probably used by Geo to decide to either play the water/plonk SFX)
	// We'll use that to display the current location
	if (
		args[0] === "https://www.geoguessr.com/api/v4/geo-coding/terrain" &&
		args[1]?.method === "POST"
	) {
		const requestBody = args[1]?.body;

		if (requestBody) {
			try {
				let imgInfo = getImage();
				if (!imgInfo || !imgInfo[0] || !imgInfo[1]) {
					return;
				}
				let img = imgInfo[0];
				let button = imgInfo[1];
				const jsonBody = JSON.parse(requestBody);
				let locationInfo = await getNearestPano(jsonBody);
				if (!locationInfo || !locationInfo.image) {
					button.style.display = "none";
					return;
				}
				button.style.display = "block";
				img.src = locationInfo.image;
				button.href = locationInfo.url;
			} catch (e) {
				console.error("Failed to parse JSON body:", e);
			}
		}
	}
}

unsafeWindow.fetch = async function (...args) {
	Promise.resolve().then(() => onFetch(args));

	const response = await originalFetch.apply(this, args);

	return response;
};