Guess Peek (Geoguessr)

Click on your pin to see where you've guessed!

// ==UserScript==
// @name         Guess Peek (Geoguessr)
// @namespace    alienperfect
// @version      1.4.9
// @description  Click on your pin to see where you've guessed!
// @author       Alien Perfect
// @match        https://www.geoguessr.com/*
// @icon         https://www.google.com/s2/favicons?sz=32&domain=geoguessr.com
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_info
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @grant        unsafeWindow
// @grant        window.onurlchange
// ==/UserScript==

"use strict";

const SEARCH_RADIUS = 250000;
const STORAGE_CAP = 30;
const SCRIPT_NAME = GM_info.script.name;
const GAMES_API = "https://www.geoguessr.com/api/v3/games/";
const SELECTORS = {
  marker: "[data-qa='guess-marker']",
  markerList: "[class*='map-pin_']:not([data-qa='correct-location-marker'])",
  roundEnd: "[data-qa='close-round-result']",
  gameEnd: "[data-qa='play-again-button']",
  results: "[data-qa='results-map']",
};

let svs, markerObserver, markerListObserver;

function interceptFetch() {
  const _fetch = unsafeWindow.fetch;

  unsafeWindow.fetch = async (resource, options) => {
    const response = await _fetch(resource, options);
    const url = resource.toString();

    if (url.includes(GAMES_API) && options) {
      await getGuessData(response, options);
    }

    return response;
  };
}

async function getGuessData(response, options) {
  try {
    const resp = await response.clone().json();
    const token = getGameToken(location.pathname);
    const round = resp.round;
    const guessList = resp.player.guesses;
    const gameFinished = resp.state === "finished";
    const challenge = resp.type === "challenge";

    if (!token) throw new Error("token is null!");

    if (options.method === "POST") {
      const guess = guessList.at(-1);
      const coords = { lat: guess.lat, lng: guess.lng };
      const pano = await getNearestPano(coords);

      savePano(token, round, pano);
      observeMarker(round);
    }

    if (gameFinished && !challenge) observeMarkerList();
  } catch (error) {
    console.error(`${SCRIPT_NAME} error: ${error}`);
  }
}

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",
      });

      radius = computeDistanceBetween(coords, panorama.data.location.latLng);
      pano.radius = radius;
      pano.url = getStreetViewUrl(panorama.data.location.pano);

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

  return pano;
}

function savePano(token, round, pano) {
  const panos = GM_getValue(token, { [round]: pano });
  const history = GM_getValue("history", []);

  panos[round] = pano;
  GM_setValue(token, panos);

  // Don't duplicate the same token for each pano.
  if (!history.includes(token)) {
    history.unshift(token);
    GM_setValue("history", history);
  }

  cleanStorage(history);
}

function cleanStorage(history) {
  if (history.length > STORAGE_CAP) {
    const lastItem = history.pop();

    GM_setValue("history", history);
    GM_deleteValue(lastItem);
  }
}

function observeMarker(round) {
  markerObserver = new MutationObserver(() => {
    const roundEnd = document.querySelector(SELECTORS.roundEnd);
    const marker = document.querySelector(SELECTORS.marker);
    const token = getGameToken(location.pathname);
    const panoList = GM_getValue(token);

    if (!(roundEnd && marker && panoList)) return;

    markerObserver.disconnect();
    updateMarker(marker, panoList[round]);
  });

  markerObserver.observe(document.body, {
    childList: true,
    subtree: true,
  });
}

function observeMarkerList() {
  markerListObserver = new MutationObserver(() => {
    const results =
      document.querySelector(SELECTORS.results) ||
      document.querySelector(SELECTORS.gameEnd);
    const markerList = document.querySelectorAll(SELECTORS.markerList);
    const token = getGameToken(location.pathname);
    const panoList = GM_getValue(token);

    // No point in checking this page.
    if (!panoList) return markerListObserver.disconnect();
    if (!(results && markerList.length > 0)) return;

    markerListObserver.disconnect();
    
    for (const [round, pano] of Object.entries(panoList)) {
      const marker = markerList.item(parseInt(round) - 1);
      updateMarker(marker, pano);
    }
  });

  markerListObserver.observe(document.body, {
    childList: true,
    subtree: true,
  });
}

function updateMarker(marker, pano) {
  let distance = convertDistance(SEARCH_RADIUS);
  const tooltip = document.createElement("div");
  tooltip.className = "peek-tooltip";
  tooltip.textContent = `No location was found within ${distance}!`;

  marker.setAttribute("data-pano", "false");

  if (Object.keys(pano).length > 0) {
    distance = convertDistance(pano.radius);
    tooltip.textContent = `Click to see the nearest location! [${distance}]`;

    marker.setAttribute("data-pano", "true");
    marker.addEventListener("click", () => {
      GM_openInTab(pano.url, { active: true });
    });
  }

  marker.append(tooltip);
}

function initObservers() {
  stopObservers();
  if (inResults()) observeMarkerList();
}

function stopObservers() {
  if (markerObserver) markerObserver.disconnect();
  if (markerListObserver) markerListObserver.disconnect();
}

function inResults() {
  return location.pathname.includes("/results/");
}

function getGameToken(url) {
  const token = url.match(/[0-9a-zA-Z]{16}/);
  return token ? token[0] : null;
}

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

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 initSVS() {
  svs = new unsafeWindow.google.maps.StreetViewService();
}

function main() {
  interceptFetch();
  initObservers();
  window.addEventListener("urlchange", initObservers);

  console.log(`${SCRIPT_NAME} is running!`);

  GM_addStyle(`
  .peek-tooltip {
    display: none;
    position: absolute;
    width: 120px;
    background: #323232;
    border-radius: 4px;
    text-align: center;
    padding: 0.5rem;
    font-size: 0.9rem;
    right: 50%;
    bottom: 220%;
    margin-right: -60px;
    opacity: 90%;
    z-index: 4;
  }

  .peek-tooltip:after {
    content: "";
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -5px;
    border-width: 5px;
    border-style: solid;
    border-color: #323232 transparent transparent transparent;
  }

  [data-pano="true"]:hover .peek-tooltip,
  [data-pano="false"]:hover .peek-tooltip {
    display: block;
  }

  [data-pano="true"] > :first-child {
    cursor: pointer;
    --border-color: #E91E63 !important;
    --border-size-factor: 2 !important;
  }

  [data-pano="false"] > :first-child {
    cursor: initial;
    --border-color: #323232 !important;
    --border-size-factor: 1.5 !important;
  }
`);
}

main();