Guess Preview (GeoGuessr)

Preview your GeoGuessr guess before placing it!

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

// Credit to Alien Perfect's original Guess Peek

const SETTINGS = {
	SEARCH_RADIUS: 50000,
	PREVIEW_SIZE_WIDTH: "30%",
	PREVIEW_SIZE_HEIGHT: "25%",
	EMBED_ON_HOVER: true,
	ENLARGE_ON_HOVER: true,
	HOVER_WIDTH: "70%",
	HOVER_HEIGHT: "70%",
};
const KEYBINDINGS = {
	TOGGLE_PREVIEW: "x",
};

GM_addStyle(`
	.guess-preview-button {
	position: absolute;
	bottom: 0;
	left: 0;
	width: ${SETTINGS.PREVIEW_SIZE_WIDTH};
	height: ${SETTINGS.PREVIEW_SIZE_HEIGHT};
	z-index: 10;
	user-select: none;
}
	.guess-preview-button:hover {
	width: ${
		(SETTINGS.ENLARGE_ON_HOVER && SETTINGS.HOVER_WIDTH) ||
		SETTINGS.PREVIEW_SIZE_WIDTH
	};
	height: ${
		(SETTINGS.ENLARGE_ON_HOVER && SETTINGS.HOVER_HEIGHT) ||
		SETTINGS.PREVIEW_SIZE_HEIGHT
	};
}
`);

let isPreviewEnabled = GM_getValue("isPreviewEnabled") || true;

let svs;

let latestCoords;

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 = SETTINGS.SEARCH_RADIUS;
	if (!svs) initSVS();

	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&thumbfov=120`;
			pano.streetViewEmbed = `https://www.google.com/maps/embed?pb=!4v1749491810223!6m8!1m7!1s${panorama.data.location.pano}!2m2!1d-16.36128053634264!2d-44.39690412269235!3f${roadHeading}!4f0!5f0.4000000000000002`;

			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()) {
		latestCoords = null;
		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;
			img.style.position = "absolute";
			img.style.objectFit = "cover";

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

			button.addEventListener("mouseenter", () => {
				if (SETTINGS.ENLARGE_ON_HOVER && !SETTINGS.EMBED_ON_HOVER) {
					// Make image higher resolution
					img.src = img.src.replace("?w=640&h=360", "?w=1024&h=576");
				}

				if (
					SETTINGS.EMBED_ON_HOVER &&
					!button.querySelector(".guess-preview-sv-embed")
				) {
					const wrapper = document.createElement("div");
					wrapper.className = "guess-preview-sv-embed";
					wrapper.style.width = "100%";
					wrapper.style.height = "100%";
					wrapper.style.position = "absolute";
					wrapper.style.zIndex = 11;

					iframe = document.createElement("iframe");
					iframe.style.position = "absolute";
					iframe.style.width = "100%";
					iframe.style.height = "100%";
					iframe.style.border = "0";
					iframe.allowFullscreen = true;
					iframe.loading = "lazy";
					iframe.referrerPolicy = "no-referrer-when-downgrade";
					iframe.style.zIndex = 12;
					iframe.src = img.getAttribute("sv-embed");
					// img.style.display = "none";

					wrapper.appendChild(iframe);
					button.appendChild(wrapper);
				}
			});
		}

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

const originalFetch = unsafeWindow.fetch;
let lastRanEpoch = 0;

document.addEventListener("keydown", (input) => {
	const key = input.key;
	if (
		key == KEYBINDINGS.TOGGLE_PREVIEW.toLowerCase() ||
		key == KEYBINDINGS.TOGGLE_PREVIEW
	) {
		isPreviewEnabled = !isPreviewEnabled;
		GM_setValue("isPreviewEnabled", isPreviewEnabled);
		if (!isPreviewEnabled) {
			removeImage();
		} else if (latestCoords != null) {
			setPanoFromCoords(latestCoords);
		}
	}
});

async function setPanoFromCoords(coords) {
	if (!isPreviewEnabled) {
		return removeImage();
	}
	let imgInfo = getImage();
	if (!imgInfo || !imgInfo[0] || !imgInfo[1]) {
		return;
	}
	let img = imgInfo[0];
	let button = imgInfo[1];
	let locationInfo = await getNearestPano(coords);
	if (!locationInfo || !locationInfo.image) {
		button.style.display = "none";
		return;
	}
	button.style.display = "block";
	img.style.display = "block";
	img.src = locationInfo.image;
	button.href = locationInfo.url;
	img.setAttribute("sv-embed", locationInfo.streetViewEmbed);
	const svEmbed = button.querySelector(".guess-preview-sv-embed");
	if (svEmbed) {
		button.removeChild(svEmbed);
	}
}

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

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

	if (timeBeforeLastEpoch < 250) {
		await new Promise((resolve) =>
			setTimeout(resolve, timeBeforeLastEpoch + 100)
		);
	}
	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 {
				const jsonBody = JSON.parse(requestBody);
				latestCoords = jsonBody;
				setPanoFromCoords(jsonBody);
			} 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;
};