Internet Roadtrip - Look Out the Window v1

Allows you rotate your view 90 degrees and zoom in on neal.fun/internet-roadtrip

// ==UserScript==
// @name         Internet Roadtrip - Look Out the Window v1
// @description  Allows you rotate your view 90 degrees and zoom in on neal.fun/internet-roadtrip
// @namespace    me.netux.site/user-scripts/internet-roadtrip/look-out-the-window-v1
// @version      1.15.0
// @author       Netux
// @license      MIT
// @match        https://neal.fun/internet-roadtrip/
// @icon         https://neal.fun/favicons/internet-roadtrip.png
// @grant        GM.setValues
// @grant        GM.getValues
// @grant        GM.registerMenuCommand
// @run-at       document-end
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

(async () => {
  const LEGACY_LOCAL_STORAGE_KEY = "internet-roadtrip/mod/look-out-the-window";

  const DEFAULT_FRONT_OVERLAY_IMAGE = {
    imageSrc: null
  };
  const DEFAULT_SIDE_OVERLAY_IMAGE = {
    imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
    transformOrigin: {
      x: "50%",
      y: "40%"
    }
  };
  const DEFAULT_BACK_OVERLAY_IMAGE = {
    imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/back%20window.png`,
    transformOrigin: {
      x: "50%",
      y: "20%"
    }
  };

  const Direction = Object.freeze({
    FRONT: 0,
    RIGHT: 1,
    BACK: 2,
    LEFT: 3
  });

  const state = {
    settings: {
      lookingDirection: Direction.FRONT,
      zoom: 1,
      showVehicleUi: true,
      alwaysShowGameUi: false,
      frontOverlay: DEFAULT_FRONT_OVERLAY_IMAGE,
      sideOverlay: DEFAULT_SIDE_OVERLAY_IMAGE,
      backOverlay: DEFAULT_BACK_OVERLAY_IMAGE
    },
    dom: {}
  };

  {
    // migrate locals storage data form versions <=1.12.0
    if (LEGACY_LOCAL_STORAGE_KEY in localStorage) {
      const localStorageSettings = JSON.parse(localStorage.getItem(LEGACY_LOCAL_STORAGE_KEY));
      await GM.setValues(localStorageSettings);
      localStorage.removeItem(LEGACY_LOCAL_STORAGE_KEY);
    }
  }

  {
    const storedSettings = await GM.getValues(Object.keys(state.settings))
    Object.assign(
      state.settings,
      storedSettings
    );
  }

  function setupDom() {
    injectStylesheet();
    preloadOverlayImages();

    const containerEl = document.querySelector('.container');
    state.dom.containerEl = containerEl;

    state.dom.panoIframeEls = Array.from(containerEl.querySelectorAll('.pano'));

    state.dom.windowEl = document.createElement('div');
    state.dom.windowEl.className = 'window';
    state.dom.panoIframeEls.at(-1).insertAdjacentElement('afterend', state.dom.windowEl);

    async function lookRight() {
      state.settings.lookingDirection = (state.settings.lookingDirection + 1) % 4;
      updateLookAt();
      await saveSettings();
    }

    async function lookLeft() {
      state.settings.lookingDirection = state.settings.lookingDirection - 1;
      if (state.settings.lookingDirection < 0) {
        state.settings.lookingDirection = 3;
      }
      updateLookAt();
      await saveSettings();
    }

    function chevronImage(rotation) {
      const imgEl = document.createElement('img');
      imgEl.src = '/sell-sell-sell/arrow.svg'; // yoink
      imgEl.style.width = `10px`;
      imgEl.style.aspectRatio = `1`;
      imgEl.style.filter = `invert(1)`;
      imgEl.style.rotate = `${rotation}deg`;
      return imgEl;
    }

    state.dom.lookLeftButtonEl = document.createElement('button');
    state.dom.lookLeftButtonEl.className = 'look-left-btn';
    state.dom.lookLeftButtonEl.appendChild(chevronImage(90));
    state.dom.lookLeftButtonEl.addEventListener('click', lookLeft);
    containerEl.appendChild(state.dom.lookLeftButtonEl);

    state.dom.lookRightButtonEl = document.createElement('button');
    state.dom.lookRightButtonEl.className = 'look-right-btn';
    state.dom.lookRightButtonEl.appendChild(chevronImage(-90));
    state.dom.lookRightButtonEl.addEventListener('click', lookRight);
    containerEl.appendChild(state.dom.lookRightButtonEl);

    window.addEventListener("keydown", async (event) => {
      if (event.target !== document.body) {
        return;
      }

      switch (event.key) {
        case "ArrowLeft": {
          await lookLeft();
          break;
        }
        case "ArrowRight": {
          await lookRight();
          break;
        }
      }
    });

    window.addEventListener("wheel", async (event) => {
      if (event.target !== document.documentElement) { // pointing at nothing but the backdrop
        return;
      }

      const scrollingForward = event.deltaY < 0;

      state.settings.zoom = Math.min(Math.max(1, state.settings.zoom * (scrollingForward ? 1.1 : 0.9)), 20);
      updateZoom();
      await saveSettings();
    })

    updateUiFromSettings();
    updateOverlays();
    updateLookAt();
    updateZoom();
  }

  function injectStylesheet() {
    const styleEl = document.createElement('style');
    styleEl.innerText = `
    body {
      & .look-right-btn, & .look-left-btn {
        position: fixed;
        bottom: 200px;
        transform: translateY(-50%);
        padding-block: 1.5rem;
        border: none;
        background-color: whitesmoke;
        cursor: pointer;
      }

      & .look-right-btn {
        right: 0;
        padding-inline: 0.35rem 0.125rem;
        border-radius: 15px 0 0 15px;
      }

      & .look-left-btn {
        left: 0;
        padding-inline: 0.125rem 0.25rem;
        border-radius: 0 15px 15px 0;
      }

      &:not(.look-out-the-window-always-show-game-ui):not([data-look-out-the-window-direction="${Direction.FRONT}"]) :is(.freshener-container, .wheel-container, .options) {
        display: none;
      }

      & .window {
        position: fixed;
        width: 100%;
        background-size: cover;
        height: 100%;
        background-position: center;
        pointer-events: none;
        display: none;
      }

      &[data-look-out-the-window-direction="${Direction.FRONT}"] .window {
        transform-origin: var(--look-out-the-window-front-overlay-transform-origin);
        background-image: var(--look-out-the-window-front-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.LEFT}"] .window,
      &[data-look-out-the-window-direction="${Direction.RIGHT}"] .window {
        transform-origin: var(--look-out-the-window-side-overlay-transform-origin);
        background-image: var(--look-out-the-window-side-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.RIGHT}"] .window {
        rotate: y 180deg;
      }
      &[data-look-out-the-window-direction="${Direction.BACK}"] .window {
        transform-origin: var(--look-out-the-window-back-overlay-transform-origin);
        background-image: var(--look-out-the-window-back-overlay-image-src);
      }

      &.look-out-the-window-show-vehicle-ui .window {
        display: initial;
      }

      & .pano, & window {
        transition: opacity 300ms linear, scale 100ms linear;
      }
    }
    `;
    document.head.appendChild(styleEl);
  }

  function preloadOverlayImages() {
    const configuredOverlayImagesSources = [state.settings.frontOverlay, state.settings.sideOverlay, state.settings.backOverlay]
      .map((overlay) => overlay?.imageSrc)
      .filter((imageSrc) => !!imageSrc);

    for (const imageSrc of configuredOverlayImagesSources) {
      const image = new Image();
      image.onload = () => {
        console.debug(`Successfully preloaded Look Out the Window image at "${imageSrc}"`);
      };
      image.onerror = (event) => {
        console.error(`Failed to preload Look Out the Window image at "${imageSrc}"`, event);
      };
      image.src = imageSrc;
    }
  }

  function patch(vue) {
    const calculateOverridenHeadingAngle = (baseHeading) =>
      (baseHeading + state.settings.lookingDirection * 90) % 360;

    function replaceHeadingInPanoUrl(urlStr, vanillaHeadingOverride = null) {
      if (!urlStr) {
        return urlStr;
      }

      const url = new URL(urlStr);

      const currentHeading = vanillaHeadingOverride ?? parseFloat(url.searchParams.get('heading'));
      if (isNaN(currentHeading)) {
        throw new Error("Invalid current heading", { currentHeading });
      }

      url.searchParams.set('heading', calculateOverridenHeadingAngle(currentHeading));

      return url.toString();
    }

    vue.state.getPanoUrl = new Proxy(vue.methods.getPanoUrl, {
      apply(ogGetPanoUrl, thisArg, args) {
        const urlStr = ogGetPanoUrl.apply(thisArg, args);
        return replaceHeadingInPanoUrl(urlStr);
      }
    });

    const panoEls = Object.keys(vue.$refs).filter((name) => name.startsWith('pano')).map((key) => vue.$refs[key]);

    let isVanillaTransitioning = false;
    {
      /**
       * For reference, this is what the vanilla code more-or-less does:
       *
       * ```js
       * function changeStop(..., newPano, newHeading, ...) {
       *    // ...
       *    this.currFrame = this.currFrame === 0 ? 1 : 0;
       *    this.currentPano = newPano;
       *    // ...
       *    setTimeout(() => {
       *      this.switchFrameOrder();
       *      this.currentHeading = newHeading;
       *      // ...
       *    }, someDelay));
       * }
       * ```
       *
       * Note the heading is set with a delay, after switchFrameOrder is called.
       */

      vue.state.changeStop = new Proxy(vue.methods.changeStop, {
        apply(ogChangeStop, thisArg, args) {
          isVanillaTransitioning = true;
          return ogChangeStop.apply(thisArg, args);
        }
      });

      function isCurrentFrameFacingTheCorrectDirection() {
        const currPanoSrc = panoEls[vue.state.currFrame]?.src;
        const currPanoUrl = currPanoSrc && new URL(currPanoSrc);
        if (!currPanoUrl) {
          return false;
        }

        const urlHeading = parseFloat(currPanoUrl.searchParams.get('heading'));
        if (isNaN(urlHeading)) {
          return false;
        }

        const correctHeading = calculateOverridenHeadingAngle(state.vue.data.currentHeading);

        return Math.abs(urlHeading - correctHeading) < 1e-3;
      }

      vue.state.switchFrameOrder = new Proxy(vue.methods.switchFrameOrder, {
        apply(ogSwitchFrameOrder, thisArg, args) {
          isVanillaTransitioning = false;

          requestIdleCallback(() => { // run after currentHeading is updated (see reference method implementation above)
            if (!isCurrentFrameFacingTheCorrectDirection()) {
              transitionPano(/* animate: */ true);
            }
          });

          return ogSwitchFrameOrder.apply(thisArg, args);
        }
      });
    }

    let modTransitionTimeout = null;
    function transitionPano(animate = true) {
      const now = Date.now();

      const currFrame = vue.state.currFrame;
      const nextFrame = (currFrame + 1) % panoEls.length;

      const activePanoEl = panoEls[currFrame];
      const transitionPanoEl = panoEls[nextFrame];

      if (isVanillaTransitioning) {
        // The page will do the transition for us
        clearTimeout(modTransitionTimeout);
        return;
      }

      const newPanoUrl = replaceHeadingInPanoUrl(activePanoEl.src, state.vue.data.currentHeading);

      if (animate) {
        if (modTransitionTimeout == null) {
          state.vue.state.currFrame = nextFrame;
          transitionPanoEl.src = newPanoUrl;
        } else {
          clearTimeout(modTransitionTimeout);
          activePanoEl.src = newPanoUrl;
        }

        modTransitionTimeout = setTimeout(() => {
          modTransitionTimeout = null;
          state.vue.methods.switchFrameOrder();
        }, 500);
      } else {
        activePanoEl.src = newPanoUrl;
      }
    };
    state.transitionPano = transitionPano;
  }

  function updateUiFromSettings() {
    document.body.classList.toggle('look-out-the-window-show-vehicle-ui', state.settings.showVehicleUi);
    document.body.classList.toggle('look-out-the-window-always-show-game-ui', state.settings.alwaysShowGameUi);
  }

  function updateOverlays() {
    const setCssVariable = (element, name, value) => value ? element.style.setProperty(`--${name}`, value) : element.style.removeProperty(`--${name}`);

    for (const overlayName of ['front', 'side', 'back']) {
      const overlay = state.settings[`${overlayName}Overlay`];
      const cssVariable = (name) => `look-out-the-window-${overlayName}-overlay-${name}`;

      setCssVariable(
        state.dom.windowEl, cssVariable('image-src'),
        `url("${overlay.imageSrc}")`
      );

      setCssVariable(
        state.dom.windowEl, cssVariable('transform-origin'),
        overlay.transformOrigin
          ? `${overlay.transformOrigin.x} ${overlay.transformOrigin.y}`
          : null
      );
    }
  }

  function updateLookAt(animate = true) {
    document.body.dataset.lookOutTheWindowDirection = state.settings.lookingDirection;

    state.transitionPano(animate);
  }

  function updateZoom() {
    for (const panoIframeEl of state.dom.panoIframeEls) {
      panoIframeEl.style.scale = (state.settings.zoom * 0.4 + 0.6 /* parallax */).toString();
    }
    state.dom.windowEl.style.scale = state.settings.zoom.toString();
  }

  async function saveSettings() {
    await GM.setValues(state.settings);
  }

  GM.registerMenuCommand('Toggle Vehicle UI', async () => {
    state.settings.showVehicleUi = !state.settings.showVehicleUi;
    updateUiFromSettings();
    await saveSettings();
  }, { id: 'look-out-the-window-toggle-vehicle-ui' });
  GM.registerMenuCommand('Toggle Always show Game UI', async () => {
    state.settings.alwaysShowGameUi = !state.settings.alwaysShowGameUi;
    updateUiFromSettings();
    await saveSettings();
  }, { id: 'look-out-the-window-toggle-always-show-game-ui' });

  state.vue = await IRF.vdom.container;

  patch(state.vue);
  setupDom();
  saveSettings();
})();