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.17.1
// @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
// @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]
// ==/UserScript==

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

  const DEFAULT_OVERLAY_SETTINGS = {
    front: {
      imageSrc: null
    },
    back: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/back%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "20%"
      }
    },
    left: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "40%"
      },
      flip: true
    },
    right: {
      imageSrc: `https://cloudy.netux.site/neal_internet_roadtrip/side%20window.png`,
      transformOrigin: {
        x: "50%",
        y: "40%"
      }
    }
  }

  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_OVERLAY_SETTINGS.front,
      backOverlay: DEFAULT_OVERLAY_SETTINGS.back,
      leftOverlay: DEFAULT_OVERLAY_SETTINGS.left,
      rightOverlay: DEFAULT_OVERLAY_SETTINGS.right,
    },
    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;
    }
  }

  const cssClass = (names) => (Array.isArray(names) ? names : [names]).map((name) => `${CSS_PREFIX}${name}`).join(' ');

  function setupDom() {
    injectStylesheet();
    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;

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

    createSettings();

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

  function injectStylesheet() {
    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(--${CSS_PREFIX}front-overlay-transform-origin);
        background-image: var(--${CSS_PREFIX}front-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.LEFT}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${CSS_PREFIX}left-overlay-transform-origin);
        background-image: var(--${CSS_PREFIX}left-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.RIGHT}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${CSS_PREFIX}right-overlay-transform-origin);
        background-image: var(--${CSS_PREFIX}right-overlay-image-src);
      }
      &[data-look-out-the-window-direction="${Direction.BACK}"] .${cssClass('overlay__image')} {
        transform-origin: var(--${CSS_PREFIX}back-overlay-transform-origin);
        background-image: var(--${CSS_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;
      }
    }
    `);
  }

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

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

            & input[type="checkbox"] {
              vertical-align: middle;
            }

            &.${cssClass('field-group--plain-checkbox')} {
              justify-content: start;

              & > input[type="checkbox"] + label {
                width: 100%;
              }
            }
          }

          & .${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-setting__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('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;

                & .${cssClass('overlay-setting__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')} {
                display: none;
              }
            }
          }

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

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

            position: relative;
            width: 1rem;
            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;
              top: 0;
              left: 50%;
              translate: -50% -100%;
              background-color: white;
              clip-path: polygon(0 0, 100% 0%, 50% 100%);
              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;
              text-align: center;
              color: black;
              font-size: 80%;
              background-color: white;
              translate: -50% calc(-100% - var(--tip-height) + 1px);
              pointer-events: none;
              z-index: 2;
            }

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


    state.dom.toggleVehicleOverlayInputEl = VM.hm('input', {
      id: `${CSS_PREFIX}toggle-vehicle-overlay`,
      type: 'checkbox',
      className: IRF.ui.panel.styles.toggle
    });

    state.dom.toggleVehicleOverlayInputEl.addEventListener('change', async () => {
      state.settings.showVehicleUi = state.dom.toggleVehicleOverlayInputEl.checked;
      await saveSettings();
      updateUiFromSettings();
    });
    const showVehicleOverlayFieldGroupEl = VM.hm('div', { className: cssClass('field-group') }, [
      VM.hm('label', { labelFor: `${CSS_PREFIX}toggle-vehicle-overlay` }, 'Show Vehicle Overlay'),
      state.dom.toggleVehicleOverlayInputEl
    ]);


    state.dom.alwaysShowGameUIInputEl = VM.hm('input', {
      id: `${CSS_PREFIX}always-show-game-ui`,
      type: 'checkbox',
      className: IRF.ui.panel.styles.toggle
    });
    state.dom.alwaysShowGameUIInputEl.addEventListener('change', async () => {
      state.settings.alwaysShowGameUi = state.dom.alwaysShowGameUIInputEl.checked;
      await saveSettings();
      updateUiFromSettings();
    });
    const alwaysShowGameUiFieldGroupEl = VM.hm('div', { className: cssClass('field-group') }, [
      VM.hm('label', { labelFor: `${CSS_PREFIX}always-show-game-ui` }, 'Always show Game UI'),
      state.dom.alwaysShowGameUIInputEl
    ]);


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

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

    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', { className: cssClass('overlay-setting__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 removeImageButtonEl = VM.hm('button', { className: cssClass(['overlay-setting__button', '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', { className: cssClass('overlay-setting__button') }, [
        VM.h('img', { src: 'https://www.svgrepo.com/show/511181/undo.svg' })
      ]);
      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' });
      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,
          removeImageButtonEl,
        ]),
        VM.h('div', { className: cssClass('overlay-fields') }, [
          VM.h('div', { className: cssClass(['field-group', 'field-group--plain-checkbox']) }, [
            flipToggleInputEl,
            VM.h('label', { labelFor: `${fieldId}-flip` }, 'Flip'),
          ]),
          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(
      showVehicleOverlayFieldGroupEl,
      alwaysShowGameUiFieldGroupEl,
      overlaySettingsContainerGroupEl
    );
  }

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

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

      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()) {
              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 = replaceHeadingInPanoUrl(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() {
    state.dom.toggleVehicleOverlayInputEl.checked = state.settings.showVehicleUi;
    document.body.classList.toggle(cssClass('show-vehicle-ui'), state.settings.showVehicleUi);

    state.dom.alwaysShowGameUIInputEl.checked = state.settings.alwaysShowGameUi;
    document.body.classList.toggle(cssClass('always-show-game-ui'), state.settings.alwaysShowGameUi);
  }

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

    function setOverlayCssVariables(overlayName, overlaySetting) {
      const cssVariable = (name) => `${CSS_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) {
      panoIframeEl.style.scale = (state.settings.zoom * 0.4 + 0.6 /* parallax */).toString();
    }
    state.dom.overlayImageEl.style.scale = state.settings.zoom.toString();
  }

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

  state.vue = await IRF.vdom.container;

  patch(state.vue);
  setupDom();
  saveSettings();
})();
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

一年攒够 12 元