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