Skip to content

Instantly share code, notes, and snippets.

@netux
Last active May 16, 2025 09:53
Show Gist options
  • Save netux/8d5f4eca8b6f77044462771929fa5e34 to your computer and use it in GitHub Desktop.
Save netux/8d5f4eca8b6f77044462771929fa5e34 to your computer and use it in GitHub Desktop.
Internet Roadtrip - Look Out The Window v1

Look Out The Window v1 - Backseat/side window view for Internet Roadtrip

How to install

  1. Install a browser extension to install userscripts (GreaseMonkey, TamperMonkey, ViolentMonkey, etc.)

    ❗ If you are on a Chromium-based browser (like Google Chrome), you may need to enable Developer Mode. Without this, this userscript may not work! Other browsers, like Firefox, don't have this problem.

    Follow this guide to do learn how to enable Developer Mode.

  2. Click on the Raw below (not the one above this)

  3. Install userscript

  4. Reload Internet Roadtrip tab

// ==UserScript==
// @name Internet Roadtrip - Look Out The Window
// @namespace me.netux.site/user-scripts/internet-roadtrip/look-out-the-window-v1
// @version 1.8
// @author Netux
// @match https://neal.fun/internet-roadtrip/
// @icon https://www.google.com/s2/favicons?sz=64&domain=neal.fun
// @grant none
// @run-at document-end
// ==/UserScript==
(() => {
const STORAGE_KEY = "internet-roadtrip/mod/look-out-the-window";
const containerEl = document.querySelector('.container');
function findObjectsDeep(obj, compareFn) {
const objectsSeenSoFar = [];
function* find(obj, pathSoFar = '') {
if (typeof obj !== 'object' || obj == null) {
return;
}
if (objectsSeenSoFar.includes(obj)) {
return;
}
objectsSeenSoFar.push(obj);
for (const key in obj) {
const keyPath = (pathSoFar.length > 0 ? `${pathSoFar}.` : '') + key;
const payload = { key, value: obj[key], parent: obj, path: keyPath };
if (compareFn(payload)) {
yield {
... payload,
replace: (newValue) => {
obj[key] = newValue;
}
};
}
for (const result of find(obj[key], keyPath)) {
yield result;
}
}
}
return find(obj);
}
window.DEBUG__findObjectsDeep = (... args) => Array.from(findObjectsDeep(... args));
function findFirstObjectDeep(obj, compareFn) {
return findObjectsDeep(obj, compareFn).next().value;
}
const Direction = Object.freeze({
AHEAD: 0,
RIGHT: 1,
BACK: 2,
LEFT: 3
});
const state = {
lookingDirection: Direction.AHEAD,
zoom: 1,
dom: {}
};
if (STORAGE_KEY in localStorage) {
Object.assign(
state,
JSON.parse(localStorage.getItem(STORAGE_KEY))
);
}
function start(vue) {
state.vue = vue;
patch(vue);
setupDom();
}
function setupDom() {
injectStylesheet();
const containerEl = document.querySelector('.container');
state.dom.containerEl = containerEl;
state.dom.panoIframeEls = Array.from(containerEl.querySelectorAll('[id^="pano"]'));
state.dom.wheelEl = containerEl.querySelector('.wheel');
state.dom.optionsContainerEl = containerEl.querySelector('.options');
state.dom.windowEl = document.createElement('div');
state.dom.windowEl.className = 'window';
containerEl.insertBefore(state.dom.windowEl, containerEl.querySelector('iframe').nextSibling);
function lookRight() {
state.lookingDirection = (state.lookingDirection + 1) % 4;
updateLookAt();
storeSettings();
}
function lookLeft() {
state.lookingDirection = state.lookingDirection - 1;
if (state.lookingDirection < 0) {
state.lookingDirection = 3;
}
updateLookAt();
storeSettings();
}
function chevronImage(rotation) {
const imgEl = document.createElement('img');
imgEl.src = '/sell-sell-sell/arrow.svg'; // yoink
imgEl.style.width = `10px`;
imgEl.style.aspectRatio = `1`;
imgEl.style.filter = `invert(1)`;
imgEl.style.rotate = `${rotation}deg`;
return imgEl;
}
state.dom.lookLeftButtonEl = document.createElement('button');
state.dom.lookLeftButtonEl.className = 'look-left-btn';
state.dom.lookLeftButtonEl.appendChild(chevronImage(90));
state.dom.lookLeftButtonEl.addEventListener('click', lookLeft);
containerEl.appendChild(state.dom.lookLeftButtonEl);
state.dom.lookRightButtonEl = document.createElement('button');
state.dom.lookRightButtonEl.className = 'look-right-btn';
state.dom.lookRightButtonEl.appendChild(chevronImage(-90));
state.dom.lookRightButtonEl.addEventListener('click', lookRight);
containerEl.appendChild(state.dom.lookRightButtonEl);
window.addEventListener("keydown", (event) => {
switch (event.key) {
case "ArrowLeft": {
lookLeft();
break;
}
case "ArrowRight": {
lookRight();
break;
}
}
});
window.addEventListener("mousewheel", (event) => {
if (event.target !== document.documentElement) { // pointing at nothing but the backdrop
return;
}
const scrollingForward = event.deltaY < 0;
state.zoom = Math.min(Math.max(1, state.zoom * (scrollingForward ? 1.1 : 0.9)), 5);
updateZoom();
storeSettings();
})
updateLookAt();
updateZoom();
}
function injectStylesheet() {
const styleEl = document.createElement('style');
styleEl.innerText = `
.container {
& .look-right-btn, & .look-left-btn {
position: fixed;
top: 50%;
transform: translateY(-50%);
padding-block: 1.5rem;
border: none;
background-color: whitesmoke;
cursor: pointer;
}
& .look-right-btn {
right: 0;
padding-inline: 0.35rem 0.125rem;
border-radius: 15px 0 0 15px;
}
& .look-left-btn {
left: 0;
padding-inline: 0.125rem 0.25rem;
border-radius: 0 15px 15px 0;
}
&:not([data-looking-direction="0"]) :is(.wheel, .options) {
display: none;
}
& .window {
position: fixed;
width: 100%;
background-image: url("https://cloudy.netux.site/neal_internet_roadtrip/side window.png");
background-size: cover;
height: 100%;
background-position: center;
pointer-events: none;
&.window--flip {
rotate: y 180deg;
}
&.window--back {
transform-origin: center 20%;
background-image: url("https://cloudy.netux.site/neal_internet_roadtrip/back window.png");
}
}
& [id^="pano"], & window {
transition: scale 100ms linear;
}
}
`;
document.head.appendChild(styleEl);
}
function patch(vue) {
const currentHeadingFind = findFirstObjectDeep(vue, ({ key }) => key === 'currentHeading');
const getCurrentHeading = () => currentHeadingFind.parent.currentHeading;
const currFrameFind = findFirstObjectDeep(vue, ({ key }) => key === 'currFrame');
const getCurrFrame = () => currFrameFind.parent.currFrame;
function replaceHeadingInPanoUrl(urlStr, headingOverride) {
if (!urlStr) {
return urlStr;
}
headingOverride ??= getCurrentHeading();
const url = new URL(urlStr);
url.searchParams.set('heading', (headingOverride + state.lookingDirection * 90) % 360);
return url.toString();
}
for (const getPanoUrlFind of findObjectsDeep(vue, ({ key, value }) => key === 'getPanoUrl' && typeof value === 'function')) {
const ogGetPanoUrl = getPanoUrlFind.value;
getPanoUrlFind.replace(function() {
const urlStr = ogGetPanoUrl.apply(this, arguments);
return replaceHeadingInPanoUrl(urlStr, this.currentHeading);
});
}
state.markPanoUrlDirty = () => {
const panoEl = document.querySelector(`#pano${getCurrFrame()}`);
panoEl.src = replaceHeadingInPanoUrl(panoEl.src);
};
}
function updateLookAt() {
state.dom.containerEl.dataset.lookingDirection = state.lookingDirection;
const isLookingAhead = state.lookingDirection === Direction.AHEAD;
state.dom.windowEl.style.display = isLookingAhead ? 'none' : '';
if (!isLookingAhead) {
state.dom.windowEl.classList.toggle('window--flip', state.lookingDirection === Direction.LEFT);
state.dom.windowEl.classList.toggle('window--back', state.lookingDirection === Direction.BACK);
}
state.markPanoUrlDirty();
}
function updateZoom() {
for (const panoIframeEl of state.dom.panoIframeEls) {
panoIframeEl.style.scale = (state.zoom * 0.4 + 0.6 /* parallax */).toString();
}
state.dom.windowEl.style.scale = state.zoom.toString();
}
function storeSettings() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
lookingDirection: state.lookingDirection,
zoom: state.zoom
}));
}
const waitForVueInterval = setInterval(() => {
const vue = containerEl.__vue__;
if (!vue) {
return;
}
window.DEBUG__vue = vue;
clearInterval(waitForVueInterval);
start(vue);
}, 100);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment