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.21.3
// @author       netux
// @license      MIT
// @match        https://neal.fun/internet-roadtrip/
// @icon         https://cloudy.netux.site/neal_internet_roadtrip/Look%20Out%20the%20Window%20logo.png
// @grant        GM.setValues
// @grant        GM.getValues
// @grant        GM.registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-start
// @require      https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/[email protected]
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// @noframes
// ==/UserScript==

/* globals IRF, VM */

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

  // https://developers.google.com/maps/documentation/embed/embedding-map#streetview_mode
  const MIN_FOV = 10;
  const MAX_FOV = 100;

  const MIN_ZOOM = 1; // not to be confused with zeroeth zoom
  const MAX_ZOOM = 20;

  const MIN_ZOOM_SPEED = 1.001;
  const MAX_ZOOM_SPEED = 1.5;

  const MIN_ZEROETH_ZOOM_FOV = 90;
  const MIN_ZEROETH_ZOOM_ZOOM = 1;

  const MAX_ZEROETH_ZOOM_FOV = MAX_FOV;
  const MAX_ZEROETH_ZOOM_ZOOM = 1.48; // roughly visually equal to 1x at 90° FOV on a 16:9 display

  const calculateZeroethZoom = () => {
    const clampedFov = Math.max(MIN_ZEROETH_ZOOM_FOV, state.settings.fov);

    // Gradually set zeroeth zoom according to fov level
    // When fov = MIN_ZEROETH_ZOOM_FOV => zeroeth zoom = MIN_ZEROETH_ZOOM_ZOOM
    // When fov = MAX_ZEROETH_ZOOM_FOV => zeroeth zoom = MAX_ZEROETH_ZOOM_ZOOM
    // https://www.desmos.com/calculator/8dssjjiog1
    return Math.sqrt(
        Math.pow(
          (MAX_ZEROETH_ZOOM_ZOOM - MIN_ZEROETH_ZOOM_ZOOM) / (MAX_ZEROETH_ZOOM_FOV - MIN_ZEROETH_ZOOM_FOV),
          1 / Math.sqrt(2)
        ) *
        (clampedFov - MIN_ZEROETH_ZOOM_FOV) -
        (-MIN_ZEROETH_ZOOM_ZOOM)
    );
  };

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

  const DEFAULT_SETTINGS = {
    lookingDirection: Direction.FRONT,
    fov: 90,
    zoom: 1, // normalized [MIN_ZOOM, MAX_ZOOM]
    zoomSpeed: 1.1,
    zoomParallax: {
      multiplier: null, // null => multiplier = 1 - offset
      offset: 0.6
    },
    showVehicleUi: true,
    alwaysShowGameUi: false,
    frontOverlay: {
      imageSrc: null
    },
    backOverlay: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/back%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "20%"
      }
    },
    leftOverlay: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "40%"
      },
      flip: true
    },
    rightOverlay: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "40%"
      }
    }
  };

  const state = {
    settings: structuredClone(DEFAULT_SETTINGS),
    tabFields: {},
    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
    );
  }

  { // migrate from single side overlay config from versions <=1.16.0
    if (state.settings.sideOverlay) {
      state.settings.rightOverlay = state.settings.sideOverlay;
      state.settings.leftOverlay = { ... state.settings.sideOverlay, flip: true };
      delete state.settings.sideOverlay;
    }
  }

  // Set zoom level so, no matter the configured fov, it looks like 90°.
  state.settings.zoom = Math.max(state.settings.zoom, calculateZeroethZoom());

  const cssClass = (... names) => names.map((name) => `${MOD_PREFIX}${name}`).join(' ');

  function setupDom() {
    injectMainStylesheet();
    injectMinimapMarkerTurnStylesheet();
    preloadOverlayImages();

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

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

    state.dom.overlayImageEl = VM.hm('div', { className: cssClass('overlay__image') });
    state.dom.overlayEl = VM.hm('div', { className: cssClass('overlay') }, state.dom.overlayImageEl);
    state.dom.panoIframeEls.at(-1).insertAdjacentElement('afterend', state.dom.overlayEl);

    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();
    }

    const chevronImage = (rotation) => VM.hm('img', {
      src: '/sell-sell-sell/arrow.svg', // yoink
      style: `
        width: 10px;
        aspectRatio: 1;
        filter: invert(1);
        rotate: ${rotation}deg;
      `
    });

    state.dom.lookLeftButtonEl = VM.hm('button', { className: cssClass('look-left-btn') }, chevronImage(90));
    state.dom.lookLeftButtonEl.addEventListener('click', lookLeft);
    containerEl.appendChild(state.dom.lookLeftButtonEl);

    state.dom.lookRightButtonEl = VM.hm('button', { className: cssClass('look-right-btn') }, 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;

      const newZoom = state.settings.zoom * (scrollingForward ? state.settings.zoomSpeed : (1 / state.settings.zoomSpeed));

      state.settings.zoom = Math.min(Math.max(MIN_ZOOM, newZoom), MAX_ZOOM);
      updateZoom();
      await saveSettings();
    });

    createSettings();

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

  function injectMainStylesheet() {
    GM_addStyle(`
    body {
      & .${cssClass('look-right-btn')}, & .${cssClass('look-left-btn')} {
        position: fixed;
        bottom: 200px;
        transform: translateY(-50%);
        padding-block: 1.5rem;
        border: none;
        background-color: whitesmoke;
        cursor: pointer;
      }

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

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

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

      & .${cssClass('overlay')} {
        position: fixed;
        width: 100%;
        height: 100%;
        pointer-events: none;
        display: none;

        &.${cssClass('overlay--flipped')} {
          rotate: y 180deg;
        }

        & .${cssClass('overlay__image')} {
          position: absolute;
          top: 0%;
          left: 0%;
          width: 100%;
          height: 100%;
          background-size: cover;
          background-position: center;
        }
      }

      &[data-look-out-the-window-direction="${Direction.FRONT}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${MOD_PREFIX}front-overlay-transform-origin);
        background-image: var(--${MOD_PREFIX}front-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.LEFT}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${MOD_PREFIX}left-overlay-transform-origin);
        background-image: var(--${MOD_PREFIX}left-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.RIGHT}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${MOD_PREFIX}right-overlay-transform-origin);
        background-image: var(--${MOD_PREFIX}right-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.BACK}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${MOD_PREFIX}back-overlay-transform-origin);
        background-image: var(--${MOD_PREFIX}back-overlay-image-src);
      }

      &.${cssClass('show-vehicle-ui')} .${cssClass('overlay')} {
        display: initial;
      }

      & .pano, & .${cssClass('overlay')}.${cssClass('overlay__image')} {
        transition: opacity 300ms linear, scale 100ms linear;
      }
    }
    `);
  }

  async function injectMinimapMarkerTurnStylesheet() {
    let markerStyleImageUrlCss = 'none';

    const styleEl = GM_addStyle();

    const emptyStylesheet = () => {
      styleEl.textContent = '';
    };

    const rerenderStylesheet = () => {
      styleEl.textContent = `
        #mini-map {
          .marker {
            background-image: none !important;

            &::before {
              content: "";
              position: absolute;
              width: 100%;
              height: 100%;
              background: inherit;
              background-image: ${markerStyleImageUrlCss};
            }
          }
        }

        body {
          &[data-look-out-the-window-direction="${Direction.FRONT}"] #mini-map .marker::before {
            rotate: 0deg;
          }
          &[data-look-out-the-window-direction="${Direction.LEFT}"] #mini-map .marker::before {
            rotate: -90deg;
          }
          &[data-look-out-the-window-direction="${Direction.RIGHT}"] #mini-map .marker::before {
            rotate: 90deg;
          }
          &[data-look-out-the-window-direction="${Direction.BACK}"] #mini-map .marker::before {
            rotate: 180deg;
          }
        }
      `;
    };

    const markerEl = await (new Promise(async (resolve) => {
      const mapContainerEl = await IRF.dom.map;

      const getMarkerEl = () => mapContainerEl.querySelector('.marker');

      if (getMarkerEl()) {
        resolve(getMarkerEl());
        return;
      }

      new MutationObserver((_records, mutationObserver) => {
        const markerEl = getMarkerEl();

        if (markerEl) {
          mutationObserver.disconnect();
          resolve(markerEl);
        }
      }).observe(
        mapContainerEl,
        {
          childList: true,
          subtree: true
        }
      )
    }));

    const updateAndRerenderStylesheet = () => {
      emptyStylesheet();
      markerStyleImageUrlCss = markerEl.style.backgroundImage;
      rerenderStylesheet();
    };

    new MutationObserver(updateAndRerenderStylesheet).observe(
      markerEl,
      {
        attributes: true,
        attributeFilter: ['style']
      }
    );

    updateAndRerenderStylesheet();
  }

  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) {
      if (imageSrc.startsWith('data:')) {
        continue;
      }

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

  function createSettings() {
    const UNDO_ICON_SRC = 'https://www.svgrepo.com/show/511181/undo.svg';

    const settingsTab = IRF.ui.panel.createTabFor(
      {
        ... GM.info,
        script: {
          ... GM.info.script,
          name: GM.info.script.name.replace('Internet Roadtrip - ', '')
        }
      },
      {
        tabName: 'Look Out the Window',
        style: `
        .${cssClass('settings-tab-content')} {
          container-name: lotw-settings-tab-content;
          container-type: inline-size;

          & *, *::before, *::after {
            box-sizing: border-box;
          }

          & button {
            padding: 0.25rem;
            margin-left: 0.125rem;
            gap: 0.25rem;
            cursor: pointer;
            border: none;
            align-items: center;
            justify-content: center;
            background-color: white;
            display: inline-flex;

            & > img {
              width: 1rem;
              vertical-align: middle;
              user-select: none;
            }
          }

          & .${cssClass('field-group')} {
            margin-block: 1rem;
            gap: 0.25rem;
            display: flex;
            align-items: center;
            justify-content: space-between;

            & input:is(:not([type]), [type="text"], [type="number"]) {
              --padding-inline: 0.5rem;

              width: calc(100% - 2 * var(--padding-inline));
              min-height: 1.5rem;
              margin: 0;
              padding-inline: var(--padding-inline);
              color: white;
              background: transparent;
              border: 1px solid #848e95;
              font-size: 100%;
              border-radius: 5rem;
            }

            & input[type="range"] {
              width: 200px;
            }

            & .${cssClass('field-group__label-container')},
            & .${cssClass('field-group__input-container')} {
              display: flex;
              justify-content: space-between;
              flex-direction: row;
              flex-wrap: nowrap;
              align-items: center;
              gap: 1ch;
            }

            & .${cssClass('field-group__input-container')} {
              white-space: nowrap;
            }
          }

          & .${cssClass('overlay-settings-container')} {
            display: grid;
            gap: 0.5rem;
            grid-template: 1fr / repeat(4, 1fr);

            & .${cssClass('overlay-setting')} {
              position: relative;
              display: flex;
              flex-direction: column;
              background-color: rgba(255 255 255 / 10%);

              & .${cssClass('overlay-setting__header')} {
                padding: 0.25rem;
                background-color: rgba(255 255 255 / 10%);
                align-items: center;
                justify-content: space-between;
                display: flex;
              }

              & .${cssClass('overlay-preview')} {
                position: relative;
                height: fit-content;
                min-height: 100px;
                margin-block: auto;
                cursor: pointer;
                overflow: hidden;

                /* Checkerboard */
                background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAABtJREFUGFdj7Ovr+29oaMjAuG///v8Xzl9gAABLjgjpsdlMqQAAAABJRU5ErkJggg==");
                background-repeat: repeat;
                background-size: 10px;
                image-rendering: pixelated;

                & .${cssClass('overlay-preview__image')} {
                  image-rendering: revert;
                  width: 100%;
                  display: block;
                }

                & .${cssClass('overlay-preview__transform-origin')} {
                  position: absolute;
                  left: var(--transform-origin-x);
                  top: var(--transform-origin-y);
                  translate: -50% -50%;
                  zoom: 7;
                  stroke: #bb1313;
                  stroke-width: 0.3;
                  pointer-events: none;
                }

                & .${cssClass('overlay-preview__no-image-text')},
                & .${cssClass('overlay-preview__image-load-failed-text')} {
                  text-align: center;
                  white-space: pre-wrap;
                  pointer-events: none;
                  display: none;
                }

                & .${cssClass('overlay-preview__no-image-text')} {
                  color: grey;
                }

                & .${cssClass('overlay-preview__image-load-failed-text')} {
                  color: red;
                }

                &::after {
                  content: "";
                  position: absolute;
                  top: 0;
                  left: 0;
                  width: 100%;
                  height: 100%;
                  pointer-events: none;
                  background-color: transparent;
                  transition: background-color 0.15s linear;
                }
                &.${cssClass('overlay-preview--dropping-file')}::after {
                  background-color: rgb(32 213 32 / 27%);
                }
              }
              &.${cssClass('overlay-setting--no-transform-origin')} .${cssClass('overlay-preview')} {
                & .${cssClass('overlay-preview__transform-origin')} {
                  display: none;
                }
              }
              &.${cssClass('overlay-setting--no-image')} .${cssClass('overlay-preview')},
              &.${cssClass('overlay-setting--image-load-failed')} .${cssClass('overlay-preview')} {
                height: 100%;
                background-image: none;
                display: flex;

                &::after {
                  box-shadow: 0 0 7px black inset;
                }

                & .${cssClass('overlay-preview__image')},
                & .${cssClass('overlay-preview__transform-origin')} {
                  display: none;
                }
              }
              &.${cssClass('overlay-setting--no-image')} .${cssClass('overlay-preview')} .${cssClass('overlay-preview__no-image-text')} {
                margin: auto;
                display: revert;
              }
              &.${cssClass('overlay-setting--image-load-failed')} .${cssClass('overlay-preview')} .${cssClass('overlay-preview__image-load-failed-text')} {
                margin: auto;
                display: revert;
              }
              &.${cssClass('overlay-setting--image-flipped')} .${cssClass('overlay-preview')} {
                rotate: y 180deg;

                & .${cssClass('overlay-preview__no-image-text')},
                & .${cssClass('overlay-preview__image-load-failed-text')} {
                  /* Unflip the text */
                  rotate: y 180deg;
                }
              }

              & .${cssClass('overlay-image-actions')} {
                display: flex;

                & button {
                  width: 100%;
                }
              }

              & .${cssClass('overlay-fields')} {
                margin-top: 0.25rem;

                & .${cssClass('field-group')} {
                  margin: 0.25rem;
                }
              }

              &.${cssClass('overlay-setting--no-image')} .${cssClass('overlay-thing--only-show-with-image')},
              &:not(.${cssClass('overlay-setting--no-image')}) .${cssClass('overlay-thing--only-show-without-image')} {
                display: none;
              }
            }
          }

          @container lotw-settings-tab-content (width < 600px) {
            .${cssClass('overlay-settings-container')} {
              grid-template-columns: repeat(2, 1fr);
            }
          }

          & .${cssClass('info-icon')} {
            --icon-size: 1rem;
            --tip-height: 15px;

            position: relative;
            width: var(--icon-size);
            aspect-ratio: 1;
            margin-inline: 0.25rem;
            vertical-align: text-top;
            background-image: url("https://www.svgrepo.com/show/509372/info.svg");
            background-size: contain;
            background-position: center;
            background-repeat: no-repeat;
            display: inline-block;

            /* The background image is black. Invert it to match the color scheme of the IRF panel */
            filter: invert(1);
            &::before, &::after {
              /* But undo the inversion on the tooltip content */
              filter: invert(1);
            }

            &::before {
              content: "";
              position: absolute;
              height: var(--tip-height);
              aspect-ratio: 1.5;
              background-color: white;
              pointer-events: none;
              z-index: 1;
            }

            &::after {
              position: absolute;
              top: 0;
              left: 0;
              min-width: 200px;
              content: attr(data-tooltip);
              white-space: pre-wrap;
              padding: 0.5rem;
              border-radius: 0.5rem;
              color: black;
              font-size: 80%;
              background-color: white;
              pointer-events: none;
              z-index: 2;
            }

            &:not([data-tooltip-text-align]) {
              text-align: center;
            }
            &[data-tooltip-text-align] {
              text-align: attr(data-tooltip-text-align);
            }

            &:not([data-tooltip-prefer-direction]), &[data-tooltip-prefer-direction="up"] {
              &::before {
                top: 0;
                left: 50%;
                translate: -50% -100%;
                clip-path: polygon(0 0, 100% 0%, 50% 100%);
              }

              &::after {
                translate: -50% calc(-100% - var(--tip-height) + 1px);
              }
            }
            &[data-tooltip-prefer-direction="down"] {
              &::before {
                bottom: 0;
                left: 50%;
                translate: -50% 100%;
                clip-path: polygon(0 100%, 100% 100%, 50% 0);
              }

              &::after {
                translate: -50% calc(var(--icon-size) + var(--tip-height) - 1px);
              }
            }

            &::before, &::after {
              opacity: 0;
            }
            &:hover::before, &:hover::after {
              transition: opacity 0s;
              transition-delay: 0.5s;
              opacity: 1;
            }
          }
        }
        `,
        className: cssClass('settings-tab-content')
      }
    );


    class FieldGroup {
      /** @type {string} */
      id;
      /** @type {any} */
      defaultValue;
      /** @type {(() => boolean)?} */
      isDefaultValue;

      /** @type {HTMLDivElement} */
      fieldGroupEl;
      /** @type {HTMLElement} */
      inputEl;

      /** @type {HTMLSpanElement} */
      #inputValueEl;
      /** @type {HTMLButtonElement} */
      #revertToDefaultButtonEl;
      /** @type {(() => Promise<void>)} */
      #changeHandler;

      constructor(config, renderInput) {
        this.id = config.id;
        Object.defineProperty(this, 'defaultValue', Object.getOwnPropertyDescriptor(config, 'defaultValue')); // this.defaultValue = config.defaultValue;
        this.isDefaultValue = config.isDefaultValue;

        const labelContents = [];
        const inputContents = [];

        if (config.hasValueText != null) {
          this.#inputValueEl = VM.hm('span', {});
          inputContents.unshift(this.#inputValueEl);
        }

        this.#changeHandler = null;

        labelContents.push(
          VM.hm('label', { labelFor: config.id }, config.label)
        );

        if (config.helpTooltip) {
          const helpTooltipEl = VM.hm('div', {
            className: cssClass('info-icon'),
            'data-tooltip': config.helpTooltip.text
          });

          if (config.helpTooltip.preferDirection) {
            helpTooltipEl.dataset.tooltipPreferDirection = config.helpTooltip.preferDirection;
          }

          if (config.helpTooltip.textAlign) {
            helpTooltipEl.dataset.tooltipTextAlign = config.helpTooltip.textAlign;
          }

          labelContents.push(helpTooltipEl);
        }

        this.inputEl = renderInput({
          id: this.id,
          triggerUpdate: this.triggerUpdate.bind(this),
          onChange: this.onChange.bind(this)
        });
        inputContents.push(this.inputEl);

        if (config.hasRevertToDefaultButton) {
          this.#revertToDefaultButtonEl = VM.hm('button', {}, VM.h('img', { src: UNDO_ICON_SRC }));
          this.updateRevertToDefaultButtonVisibility();

          this.#revertToDefaultButtonEl.addEventListener('click', () => {
            this.#changeHandler?.(this.defaultValue);
          });

          labelContents.push(this.#revertToDefaultButtonEl);
        }

        this.fieldGroupEl = VM.hm('div', { className: cssClass('field-group') }, [
          labelContents.length === 1 ?
            labelContents[0]
            : VM.hm('div', { className: cssClass('field-group__label-container') }, labelContents),
          inputContents.length === 1 ?
            inputContents[0]
            : VM.hm('div', { className: cssClass('field-group__input-container') }, inputContents)
        ]);
      }

      onChange(handler) {
        this.#changeHandler = handler;
      }

      async triggerUpdate() {
        this.updateRevertToDefaultButtonVisibility();
        await saveSettings();
        updateUiFromSettings();
      }

      updateInputValue(value) {
        this.#changeHandler?.(value);
        updateUiFromSettings();
      }

      updateValueText(text) {
        if (this.#inputValueEl != null) {
          this.#inputValueEl.textContent = text;
        }
      }

      updateRevertToDefaultButtonVisibility() {
        if (this.#revertToDefaultButtonEl !== undefined && this.isDefaultValue != null) {
          this.#revertToDefaultButtonEl.style.display = this.isDefaultValue() ? 'none' : '';
        }
      }
    }

    state.tabFields.toggleVehicleOverlay = new FieldGroup(
      {
        id: `${MOD_PREFIX}toggle-vehicle-overlay`,
        label: 'Enable Vehicle Overlay',
        hasRevertToDefaultButton: true,
        defaultValue: DEFAULT_SETTINGS.showVehicleUi,
        isDefaultValue: () => state.settings.showVehicleUi === DEFAULT_SETTINGS.showVehicleUi,
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'checkbox',
          className: IRF.ui.panel.styles.toggle
        });
        inputEl.checked = state.settings.showVehicleUi;

        inputEl.addEventListener('change', async () => {
          state.settings.showVehicleUi = inputEl.checked;
          await triggerUpdate();
        });

        onChange(async (newValue) => {
          state.settings.showVehicleUi = newValue;
          inputEl.checked = newValue;
          await triggerUpdate();
        });

        return inputEl;
      }
    );

    state.tabFields.alwaysShowGameUi = new FieldGroup(
      {
        id: `${MOD_PREFIX}always-show-game-ui`,
        label: 'Always show Game UI',
        helpTooltip: {
          text: [
            `By default, the steering wheel, car hangables, and vote arrows are hidden when not looking straight ahead.`,
            `Enabling this option makes those elements always visible.`
          ].join('\n'),
          preferDirection: 'down'
        },
        hasRevertToDefaultButton: true,
        defaultValue: DEFAULT_SETTINGS.alwaysShowGameUi,
        isDefaultValue: () => state.settings.alwaysShowGameUi === DEFAULT_SETTINGS.alwaysShowGameUi,
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'checkbox',
          className: IRF.ui.panel.styles.toggle
        });
        inputEl.checked = state.settings.alwaysShowGameUi;

        inputEl.addEventListener('change', async () => {
          state.settings.alwaysShowGameUi = inputEl.checked;
          await triggerUpdate();
        });

        onChange(async (defaultValue) => {
          state.settings.alwaysShowGameUi = defaultValue;
          inputEl.checked = defaultValue;
          await triggerUpdate();
        });

        return inputEl;
      }
    );

    state.tabFields.fov = new FieldGroup(
      {
        id: `${MOD_PREFIX}fov`,
        label: 'FOV (changes after pano refresh)',
        hasRevertToDefaultButton: true,
        defaultValue: DEFAULT_SETTINGS.fov,
        isDefaultValue: () => state.settings.fov === DEFAULT_SETTINGS.fov,
        hasValueText: true
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'range',
          className: IRF.ui.panel.styles.slider,
          min: MIN_FOV,
          max: MAX_FOV
        });
        inputEl.value = state.settings.fov;

        inputEl.addEventListener('change', async () => {
          state.settings.fov = Number.parseFloat(inputEl.value);
          await triggerUpdate();
        });

        onChange(async (defaultValue) => {
          state.settings.fov = defaultValue;
          inputEl.value = defaultValue.toString();
          await triggerUpdate();
        });

        return inputEl;
      }
    );

    state.tabFields.zoom = new FieldGroup(
      {
        id: `${MOD_PREFIX}zoom`,
        label: 'Zoom',
        hasRevertToDefaultButton: true,
        get defaultValue() { return calculateZeroethZoom() },
        isDefaultValue: () => state.settings.zoom === calculateZeroethZoom(),
        hasValueText: true
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'range',
          className: IRF.ui.panel.styles.slider,
          min: MIN_ZOOM,
          max: MAX_ZOOM,
          step: 0.0005
        });
        inputEl.value = state.settings.zoom;

        inputEl.addEventListener('change', async () => {
          state.settings.zoom = Number.parseFloat(inputEl.value);
          await triggerUpdate();
          updateZoom();
        });

        onChange(async (defaultValue) => {
          const previousValue = state.settings.zoom;

          state.settings.zoom = defaultValue;
          inputEl.value = defaultValue.toString();

          await triggerUpdate();

          if (previousValue !== state.settings.zoom) {
            updateZoom();
          }
        });

        return inputEl;
      }
    );

    state.tabFields.zoomSpeed = new FieldGroup(
      {
        id: `${MOD_PREFIX}zoomSpeed`,
        label: 'Zoom Speed',
        hasRevertToDefaultButton: true,
        defaultValue: DEFAULT_SETTINGS.zoomSpeed,
        isDefaultValue: () => state.settings.zoomSpeed === DEFAULT_SETTINGS.zoomSpeed,
        hasValueText: true
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'range',
          className: IRF.ui.panel.styles.slider,
          min: 0,
          max: 1,
          step: 0.01
        });
        inputEl.value = (state.settings.zoomSpeed - MIN_ZOOM_SPEED) / (MAX_ZOOM_SPEED - MIN_ZOOM_SPEED); // normalize to [0, 1] range;

        inputEl.addEventListener('change', async () => {
          const inputValue = parseFloat(inputEl.value);
          state.settings.zoomSpeed = (inputValue * (MAX_ZOOM_SPEED - MIN_ZOOM_SPEED)) + MIN_ZOOM_SPEED; // expand to [MIN_ZOOM_SPEED, MAX_ZOOM_SPEED] range
          await triggerUpdate();
        });

        onChange(async (defaultValue) => {
          state.settings.zoomSpeed = defaultValue;
          inputEl.value = defaultValue.toString();
          await triggerUpdate();
        });

        return inputEl;
      }
    );

    state.tabFields.zoomParallaxStrength = new FieldGroup(
      {
        id: `${MOD_PREFIX}zoomParallaxStrength`,
        label: 'Zoom Parallax Strength',
        helpTooltip: {
          text: [
            `Controls how much the overlay "disconnects" from the streetview image when zooming in.`,
            `• A value of 0% makes both zoom at the same time.`,
            `• A value of 50% makes the streetview image zoom half as fast as the overlay.`,
            `• A value of 100% only makes the overlay zoom (essentially disabling zooming into the streetview image).`
          ].join('\n'),
          textAlign: 'left'
        },
        hasRevertToDefaultButton: true,
        defaultValue: DEFAULT_SETTINGS.zoomParallax.offset,
        isDefaultValue: () => state.settings.zoomParallax.offset === DEFAULT_SETTINGS.zoomParallax.offset,
        hasValueText: true
      },
      ({ id, triggerUpdate, onChange }) => {
        const inputEl = VM.hm('input', {
          id,
          type: 'range',
          className: IRF.ui.panel.styles.slider,
          min: 0,
          max: 1,
          step: 0.01
        });
        inputEl.value = state.settings.zoomParallax.offset;

        inputEl.addEventListener('input', async () => {
          const inputValue = parseFloat(inputEl.value);
          state.settings.zoomParallax.offset = inputValue;
          await triggerUpdate();
          updateZoom();
        });

        onChange(async (defaultValue) => {
          const previousValue = state.settings.zoomParallax.offset;

          state.settings.zoomParallax.offset = defaultValue;
          inputEl.value = defaultValue.toString();
          await triggerUpdate();

          if (previousValue !== state.settings.zoomParallax.offset) {
            updateZoom();
          }
        });

        return inputEl;
      }
    );

    for (const { fieldGroupEl } of Object.values(state.tabFields)) {
      settingsTab.container.append(fieldGroupEl);
    }

    // #region Overlay settings
    const overlaySettingsContainerGroupEl = VM.hm('div', { className: cssClass('overlay-settings-container') });

    const OVERLAY_SETTINGS_RENDER_CONFIG = [
      {
        fieldId: `${MOD_PREFIX}front-overlay`,
        label: 'Front Overlay',
        overlaySetting: state.settings.frontOverlay,
        defaultOverlaySetting: DEFAULT_SETTINGS.frontOverlay,
      },
      {
        fieldId: `${MOD_PREFIX}back-overlay`,
        label: 'Back Overlay',
        overlaySetting: state.settings.backOverlay,
        defaultOverlaySetting: DEFAULT_SETTINGS.backOverlay,
      },
      {
        fieldId: `${MOD_PREFIX}left-overlay`,
        label: 'Left Overlay',
        overlaySetting: state.settings.leftOverlay,
        defaultOverlaySetting: DEFAULT_SETTINGS.leftOverlay,
      },
      {
        fieldId: `${MOD_PREFIX}right-overlay`,
        label: 'Right Overlay',
        overlaySetting: state.settings.rightOverlay,
        defaultOverlaySetting: DEFAULT_SETTINGS.rightOverlay,
      },
    ];

    for (const {
      fieldId,
      label,
      overlaySetting,
      defaultOverlaySetting
    } of OVERLAY_SETTINGS_RENDER_CONFIG) {
      function handleFileUpload(file) {
        const fileReader = new window.FileReader();
        fileReader.onload = async (event) => {
          overlaySetting.imageSrc = event.target.result;
          await saveSettings();
          updateDom();
        };
        fileReader.readAsDataURL(file);
      }

      const previewImageEl = VM.hm('img', { className: cssClass('overlay-preview__image') });
      const transformOriginCrosshairEl = VM.hm('svg', {
        xmlns: 'http://www.w3.org/2000/svg',
        className: cssClass('overlay-preview__transform-origin'),
        width: 2,
        height: 2
      }, [
        VM.h('line', { x1: 1, y1: 0, x2: 1, y2: 2 }),
        VM.h('line', { x1: 0, y1: 1, x2: 2, y2: 1 })
      ]);

      const fileInputEl = VM.hm('input', { type: 'file' });
      fileInputEl.addEventListener('change', () => {
        const file = fileInputEl.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      const previewEl = VM.hm('div', { className: cssClass('overlay-preview') }, [
        previewImageEl,
        transformOriginCrosshairEl,
        VM.h('span', { className: cssClass('overlay-preview__no-image-text') }, `No image set for this overlay\nClick to select one or drag one in`),
        VM.h('span', { className: cssClass('overlay-preview__image-load-failed-text') }, `Failed to load image`),
      ]);

      previewEl.addEventListener('click', () => fileInputEl.click());

      previewEl.addEventListener('dragover', (event) => {
        event.preventDefault();

        const containsValidData = event.dataTransfer.types.includes("Files");
        event.dataTransfer.dropEffect = containsValidData ? "move" : "none";
        previewEl.classList.toggle(cssClass('overlay-preview--dropping-file'), containsValidData);
      });
      previewEl.addEventListener('dragleave', (event) => {
        previewEl.classList.toggle(cssClass('overlay-preview--dropping-file'), false);
      });
      previewEl.addEventListener('drop', (event) => {
        event.preventDefault();
        previewEl.classList.toggle(cssClass('overlay-preview--dropping-file'), false);

        const file = event.dataTransfer.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      const setImageFromUrlButtonEl = VM.hm('button', {}, [
        VM.h('img', { src: 'https://www.svgrepo.com/show/474041/edit.svg' }),
        'Set image URL'
      ]);
      setImageFromUrlButtonEl.addEventListener('click', async () => {
        const url = prompt([
          'Enter image URL. Ensure it ends in .png, .jpeg, etc.',
          'Do not use Discord CDN links, as they will eventually expire'
        ].join('\n'));

        if (!url) {
          return;
        }

        overlaySetting.imageSrc = url;
        await saveSettings();

        imageLoadFailed = false;
        updateDom();
      });

      const uploadImageButtonEl = VM.hm('button', { className: cssClass('overlay-thing--only-show-without-image') }, [
        VM.h('img', { src: 'https://www.svgrepo.com/show/491151/upload.svg' }),
        'Upload image'
      ]);
      uploadImageButtonEl.addEventListener('click', () => fileInputEl.click());

      const removeImageButtonEl = VM.hm('button', { className: cssClass('overlay-thing--only-show-with-image') }, [
        VM.h('img', { src: 'https://www.svgrepo.com/show/533007/trash.svg' }),
        'Remove image'
      ]);
      removeImageButtonEl.addEventListener('click', async () => {
        if (overlaySetting.imageSrc == null) {
          return;
        }

        if (!confirm(`Are you sure you want to delete this overlay's image?`)) {
          return;
        }

        overlaySetting.imageSrc = null;
        await saveSettings();

        imageLoadFailed = false;
        updateDom();
      });

      const revertToDefaultButtonEl = VM.hm('button', {}, [
        VM.h('img', { src: UNDO_ICON_SRC })
      ]);
      revertToDefaultButtonEl.addEventListener('click', async () => {
        if (!confirm("This will revert the overlay to its default image and settings. Are you sure you want to proceed?")) {
          return;
        }

        for (const key in overlaySetting) {
          delete overlaySetting[key];
        }
        Object.assign(overlaySetting, defaultOverlaySetting);

        await saveSettings();

        imageLoadFailed = false;
        updateDom();
      });

      const transformOriginXInputEl = VM.hm('input', { id: `${fieldId}-transform-origin-x` });
      transformOriginXInputEl.addEventListener('change', async () => {
        overlaySetting.transformOrigin ??= { x: '50%', y: '50%' };
        overlaySetting.transformOrigin.x = transformOriginXInputEl.value;
        await saveSettings();
        updateDom();
      });
      const transformOriginYInputEl = VM.hm('input', { id: `${fieldId}-transform-origin-Y` });
      transformOriginYInputEl.addEventListener('change', async () => {
        overlaySetting.transformOrigin ??= { x: '50%', y: '50%' };
        overlaySetting.transformOrigin.y = transformOriginYInputEl.value;
        await saveSettings();
        updateDom();
      });

      const flipToggleInputEl = VM.hm('input', { id: `${fieldId}-flip`, type: 'checkbox', className: IRF.ui.panel.styles.toggle });
      flipToggleInputEl.addEventListener('change', async () => {
        overlaySetting.flip = flipToggleInputEl.checked;
        await saveSettings();
        updateDom();
      });

      const transformOriginHelpTooltipEl = VM.hm('div', {
        className: cssClass('info-icon'),
        'data-tooltip': [
          'Adjusts the point at which the overlay image will be zoomed in.',
          'Valid values include percentages (%) and "top", "bottom", "left", "right", and "center".'
        ].join('\n')
      });

      const overlaySettingEl = VM.hm('div', { className: cssClass('overlay-setting') }, [
        VM.h('header', { className: cssClass('overlay-setting__header') }, [
          VM.h('label', { labelFor: fieldId }, label),
          VM.h('div', {}, [
            revertToDefaultButtonEl
          ])
        ]),
        previewEl,
        VM.h('div', { className: cssClass('overlay-image-actions') }, [
          setImageFromUrlButtonEl,
          uploadImageButtonEl,
          removeImageButtonEl,
        ]),
        VM.h('div', { className: cssClass('overlay-fields') }, [
          VM.h('div', { className: cssClass('field-group') }, [
            VM.h('label', { labelFor: `${fieldId}-flip` }, 'Flip'),
            flipToggleInputEl,
          ]),
          VM.h('div', { className: cssClass('field-group') }, [
            VM.h('label', { labelFor: `${fieldId}-transform-origin-x` }, ['Transform Origin X', transformOriginHelpTooltipEl.cloneNode()]),
            transformOriginXInputEl,
          ]),
          VM.h('div', { className: cssClass('field-group') }, [
            VM.h('label', { labelFor: `${fieldId}-transform-origin-y` }, ['Transform Origin Y', transformOriginHelpTooltipEl.cloneNode()]),
            transformOriginYInputEl,
          ]),
        ]),
      ]);
      overlaySettingsContainerGroupEl.appendChild(overlaySettingEl);

      overlaySettingEl.addEventListener('paste', (event) => {
        const file = event.clipboardData.files[0];
        if (!file) {
          return;
        }

        handleFileUpload(file);
      });

      let lastPreviewImageSrc = null;
      let imageLoadFailed = false;
      function updateDom() {
        if (overlaySetting.imageSrc && lastPreviewImageSrc !== overlaySetting.imageSrc) {
          previewImageEl.src = overlaySetting.imageSrc;
          lastPreviewImageSrc = overlaySetting.imageSrc;
        }
        overlaySettingEl.classList.toggle(cssClass('overlay-setting--no-image'), !overlaySetting.imageSrc);
        overlaySettingEl.classList.toggle(cssClass('overlay-setting--image-load-failed'), imageLoadFailed);

        flipToggleInputEl.checked = overlaySetting.flip ?? false;
        overlaySettingEl.classList.toggle(cssClass('overlay-setting--image-flipped'), overlaySetting.flip ?? false);

        transformOriginXInputEl.value = overlaySetting.transformOrigin?.x ?? '';
        transformOriginYInputEl.value = overlaySetting.transformOrigin?.y ?? '';
        if (overlaySetting.transformOrigin) {
          const normalizeTransformOriginValue = (value) => ({
            'center': '50%',
            'top': '0%',
            'left': '0%',
            'right': '100%',
            'bottom': '100%',
          }[value] || value);

          transformOriginCrosshairEl.style.setProperty('--transform-origin-x', normalizeTransformOriginValue(overlaySetting.transformOrigin.x));
          transformOriginCrosshairEl.style.setProperty('--transform-origin-y', normalizeTransformOriginValue(overlaySetting.transformOrigin.y));
        }
        overlaySettingEl.classList.toggle(cssClass('overlay-setting--no-transform-origin'), !overlaySetting.transformOrigin);

        updateOverlays();
      }

      previewImageEl.addEventListener('load', () => {
        imageLoadFailed = false;
        updateDom();
      });
      previewImageEl.addEventListener('error', () => {
        imageLoadFailed = true;
        updateDom();
      });

      updateDom();
    }

    settingsTab.container.append(overlaySettingsContainerGroupEl);
    // #endregion Overlay settings
  }

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

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

      const url = new URL(urlStr);

      if (vanillaHeadingOverride != null || url.searchParams.has('heading')) {
        const currentHeading = vanillaHeadingOverride ?? parseFloat(url.searchParams.get('heading'));
        if (!Number.isNaN(currentHeading)) {
          url.searchParams.set('heading', calculateOverridenHeadingAngle(currentHeading));
        }
      }

      url.searchParams.set('fov', state.settings.fov);

      return url.toString();
    }

    vue.state.getPanoUrl = new Proxy(vue.methods.getPanoUrl, {
      apply(ogGetPanoUrl, thisArg, args) {
        const urlStr = ogGetPanoUrl.apply(thisArg, args);
        return replaceParametersInPanoUrl(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()) {
              attemptManualPanoTransition(/* animate: */ true);
            }
          });

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

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

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

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

      if (!activePanoEl.src) {
        // The vanilla code hasn't set a src on the current pano iframe yet, meaning this ran too soon.
        // We'll let the vanilla code do the transition for us.
        clearTimeout(modTransitionTimeout);
        return;
      }

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

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

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

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

  function updateUiFromSettings() {
    document.body.classList.toggle(cssClass('show-vehicle-ui'), state.settings.showVehicleUi);
    document.body.classList.toggle(cssClass('always-show-game-ui'), state.settings.alwaysShowGameUi);

    state.tabFields.fov.updateValueText(`${state.settings.fov}°`);
    state.tabFields.zoom.updateValueText(`${((state.settings.zoom - (calculateZeroethZoom() - MIN_ZOOM)) * 100).toFixed(2)}%`);
    state.tabFields.zoomSpeed.updateValueText((Math.round(state.settings.zoomSpeed * 1000) / 1000).toString());
    state.tabFields.zoomParallaxStrength.updateValueText(`${(Math.floor(state.settings.zoomParallax.offset * 100))}%`);

    for (const field of Object.values(state.tabFields)) {
      field.updateRevertToDefaultButtonVisibility();
    }
  }

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

    function setOverlayCssVariables(overlayName, overlaySetting) {
      const cssVariable = (name) => `${MOD_PREFIX}${overlayName}-overlay-${name}`;

      setCssVariable(
        state.dom.overlayEl, cssVariable('image-src'),
        overlaySetting.imageSrc
          ? `url("${overlaySetting.imageSrc}")`
          : null
      );

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

    setOverlayCssVariables('front', state.settings.frontOverlay);
    setOverlayCssVariables('back', state.settings.backOverlay);
    setOverlayCssVariables('left', state.settings.leftOverlay);
    setOverlayCssVariables('right', state.settings.rightOverlay);


    const lookingDirectionOverlaySettings = {
      [Direction.FRONT]: state.settings.frontOverlay,
      [Direction.RIGHT]: state.settings.rightOverlay,
      [Direction.BACK]: state.settings.backOverlay,
      [Direction.LEFT]: state.settings.leftOverlay,
    }[state.settings.lookingDirection];
    state.dom.overlayEl.classList.toggle(cssClass('overlay--flipped'), lookingDirectionOverlaySettings?.flip ?? false);
  }

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

    updateOverlays();

    state.attemptManualPanoTransition(animate);
  }

  function updateZoom() {
    for (const panoIframeEl of state.dom.panoIframeEls) {
      const parallaxOffset = state.settings.zoomParallax.offset;
      const parallaxMultiplier = state.settings.zoomParallax.multiplier ?? (1 - state.settings.zoomParallax.offset);
      panoIframeEl.style.scale = (state.settings.zoom * parallaxMultiplier + parallaxOffset /* parallax */).toString();
    }
    state.dom.overlayImageEl.style.scale = state.settings.zoom.toString();

    state.tabFields.zoom.updateInputValue(state.settings.zoom);
  }

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

  state.vue = await IRF.vdom.container;

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

  if (typeof unsafeWindow !== undefined) {
    unsafeWindow.LOtWv1 = {
      attemptManualPanoTransition: () => state.attemptManualPanoTransition(),

      async setZoom(value) {
        state.settings.zoom = value;
        updateZoom();

        await saveSettings();
      },
      async setZoomSpeed(value) {
        state.settings.zoomSpeed = value;
        updateUiFromSettings();

        await saveSettings();
      },
      async setFov(value) {
        state.settings.fov = value;
        updateUiFromSettings();

        await saveSettings();
      },
      async setLookingDirection(value) {
        state.settings.lookingDirection = value;
        updateLookAt();

        await saveSettings();
      }
    };
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址