xsanda / GeoGuessr Path Logger

// ==UserScript==
// @name GeoGuessr Path Logger
// @namespace   xsanda
// @description Add a trace of where you have been to GeoGuessr’s results screen
// @version 0.2.4
// @include https://www.geoguessr.com/*
// @run-at document-start
// @license MIT
// ==/UserScript==

const MAPS_API_URL = "https://maps.googleapis.com/maps/api/js?";

// Wait for Google Maps to be loaded
const googleMapsPromise = new Promise(resolve => {

  // Watch <head> and <body> for the Google Maps script to be added
  let scriptObserver = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.tagName === "SCRIPT" && node.src.startsWith(MAPS_API_URL)) {
          // When it’s been added and loaded, load the script below.
          node.onload = () => resolve();
          scriptObserver && scriptObserver.disconnect();
          scriptObserver = undefined;
        }
      }
    }
  });

  // Wait for the head and body to be actually added to the page, applying the
  // observer above to these elements directly.
  // There are two separate observers because only the direct children of <head>
  // and <body> should be watched, but these elements are not necessarily
  // present at document-start.
  let bodyDone = false;
  let headDone = false;
  new MutationObserver((_, observer) => {
    if (!bodyDone && document.body) {
      bodyDone = true;
      scriptObserver && scriptObserver.observe(document.body, {
        childList: true
      });
    }
    if (!headDone && document.head) {
      headDone = true;
      scriptObserver && scriptObserver.observe(document.head, {
        childList: true
      });
    }
    if (headDone && bodyDone) observer.disconnect();
  }).observe(document.documentElement, {
    childList: true,
    subtree: true,
  });
});

// Inject code to be run. This is needed (via toString) because functions
// created inside GreaseMonkey/TamperMonkey may be sandboxed, which gets
// awkward.
function runAsClient(f) {
  var s = document.createElement('script');
  s.type = 'text/javascript';
  s.text = "(" + f.toString() + ")()";
  document.body.appendChild(s);
}

googleMapsPromise.then(() => runAsClient(() => {
  const google = window.google;
  
  const KEEP_FOR = 1000 * 60 * 60 * 24 * 7; // 1 week

  // Keep a track of the lines drawn on the map, so they can be removed
  let markers = [];

  const isGamePage = () => location.pathname.startsWith("/challenge/") || location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/");

  // Detect if a results screen is visible, so the traces should be shown
  const resultShown = () => !!document.querySelector('.result') || location.href.includes('results');

  // Detect if only a single result is shown
  const singleResult = () => !!document.querySelector("[data-qa=guess-description]");

  // Keep a track of whether we are in a round already
  let inGame = false;

  // Get the game ID, for storing the trace against
  const id = () => location.href.match(/\w{15,}/);
  const roundNumber = () => {
    const el = document.querySelector('[data-qa=round-number] :nth-child(2)');
    return el ? parseInt(el.innerHTML) : 0;
  };
  const roundID = (n, gameID) => (gameID || id()) + '-' + (n || roundNumber());

  // Get the location of the street view
  const getPosition = sv => ({
    lat: sv.position.lat(),
    lng: sv.position.lng(),
  });

  // Record the time a game was played
  const updateTimestamp = () => {
    const timestamps = JSON.parse(localStorage.timestamps || "{}");
    timestamps[id()] = Date.now();
    localStorage.timestamps = JSON.stringify(timestamps);
  };

  // Remove all games older than a day
  const clearOldGames = () => {
    const timestamps = JSON.parse(localStorage.timestamps || "{}");
    // Delete all games older than a day
    const cutoff = Date.now() - KEEP_FOR;
    for (const [gameID, gameTime] of Object.entries(timestamps)) {
      if (gameTime < cutoff) {
        delete timestamps[gameID];
        for (let i = 1; i <= 5; i++) delete localStorage[roundID(i, gameID)];
      }
    }
    localStorage.timestamps = JSON.stringify(timestamps);
  };

  clearOldGames();

  // Keep a track of the current round’s route
  let route;

  // Keep a track of the start location for the current round, for detecting the return to start button
  let start;

  // Handle the street view being navigated
  const onMove = (sv) => {
    try {
      if (!isGamePage()) return;

      const position = getPosition(sv);

      if (!inGame) {
        // Do nothing if the map is being updated in the background, e.g. on page load while the results are still shown
        if (resultShown()) return;
        // otherwise start the round
        inGame = true;
        start = position;
        route = [];
      }
      // If we’re at the start, begin a new trace
      if (position.lat == start.lat && position.lng == start.lng) route.push([]);

      // Add the location to the trace
      route[route.length - 1].push(position);
    }
    catch (e) {
      console.error("GeoGuessr Path Logger Error:", e);
    }
  };

  let mapState = 0;
  
  // The geometry API isn’t loaded unless a Street View has been displayed since the last load.
  const loadGeometry = () => new Promise((resolve, reject) => {
    const existingScript = document.querySelector("script[src^='https://maps.googleapis.com/maps-api-v3/api/js/']")
    if (!existingScript) reject("No Google Maps loaded yet");
  	const libraryURL = existingScript.src.replace(/(.+\/)(.+?)(\.js)/,'$1geometry$3');
    document.head.appendChild(Object.assign(document.createElement("script"), {
    	onload: resolve,
      type: "text/javascript",
    	src: libraryURL,
    }));
  });
  
  const onMapUpdate = (map) => {
    try {
      if (!isGamePage()) return;
      
			if (!google.maps.geometry) {
      	loadGeometry().then(() => onMapUpdate(map));
        return;
      }

      // create a checksum of the game state, only updating the map when this changes, to save on computation
      const newMapState = (inGame ? 5 : 0) + (resultShown() ? 10 : 0) + (singleResult() ? 20 : 0) + roundNumber();
      if (newMapState == mapState) return;
      mapState = newMapState;

      // Hide all traces
      markers.forEach(m => m.setMap(null));
      // If we’re looking at the results, draw the traces again
      if (resultShown()) {
        // If we were in a round the last time we checked, then we need to save the route
        if (inGame) {
          // encode the route to reduce the storage required.
          const encodedRoutes = route.map(path => google.maps.geometry.encoding.encodePath(path.map(point => new google.maps.LatLng(point))));
          localStorage[roundID()] = JSON.stringify(encodedRoutes);
          updateTimestamp();
        }
        inGame = false;
        // Show all rounds for the current game when viewing the full results
        const roundsToShow = singleResult() ? [roundNumber()] : [1, 2, 3, 4, 5];
        markers = roundsToShow
          .map(i => localStorage[roundID(i)]) // Get the map for this round
          .filter(r => r) // Ignore missing rounds
          .flatMap(r =>
            // Render each trace within each round as a red line
            JSON.parse(r).map(polyline =>
              new google.maps.Polyline({
                path: google.maps.geometry.encoding.decodePath(polyline),
                geodesic: true,
                strokeColor: '#FF0000',
                strokeOpacity: 1.0,
                strokeWeight: 2,
              })
            )
          );

        // Add all traces to the map
        markers.forEach(m => m.setMap(map));

      }
    }
    catch (e) {
      console.error("GeoGuessr Path Logger Error:", e);
    }
  };

  // When a StreetViewPanorama is constructed, add a listener for moving
  const oldSV = google.maps.StreetViewPanorama;
  google.maps.StreetViewPanorama = Object.assign(function (...args) {
    const res = oldSV.apply(this, args);
    this.addListener('position_changed', () => onMove(this));
    return res;
  }, {
    prototype: Object.create(oldSV.prototype)
  });

  // When a Map is constructed, add a listener for updating
  const oldMap = google.maps.Map;
  google.maps.Map = Object.assign(function (...args) {
    const res = oldMap.apply(this, args);
    this.addListener('idle', () => onMapUpdate(this));
    return res;
  }, {
    prototype: Object.create(oldMap.prototype)
  });
}));