// ==UserScript==
// @name Internet Roadtrip - Time Travel
// @description See the current neal.fun/internet-roadtrip panorama in another time
// @namespace me.netux.site/user-scripts/internet-roadtrip/time-travel
// @version 0.3.0
// @author Netux
// @license MIT
// @icon https://neal.fun/favicons/internet-roadtrip.png
// @match https://neal.fun/internet-roadtrip/*
// @grant GM.xmlHttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==
(async () => {
const MOD_NAME = GM.info.script.name.replace('Internet Roadtrip - ', '');
const MOD_DOM_SAFE_PREFIX = 'time-travel';
const cssClass = (... names) => names.map((name) => `${MOD_DOM_SAFE_PREFIX}-${name}`).join(' ');
GM.fetch = function(details) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
...details,
onload: (response) => resolve(response),
onerror: (err) => reject(err),
});
});
}
class HTMLYearMonthInputElement extends HTMLElement {
#year = 0;
#month = 1;
#setYearValue;
#setMonthValue;
get value() {
return [this.#year, this.#month];
}
set value(value) {
const [yearStr, monthStr] = Array.isArray(value)
? value.slice(0, 2)
: String(value).split('-', 2);
this.year = yearStr;
this.month = monthStr;
}
get year() {
return this.#year;
}
set year(newYear) {
newYear = Number.parseInt(String(newYear));
if (Number.isNaN(newYear)) {
return;
}
this.#year = newYear;
this.#setYearValue?.(newYear);
}
get month() {
return this.#month;
}
set month(newMonth) {
newMonth = Number.parseInt(String(newMonth));
if (Number.isNaN(newMonth)) {
return;
}
this.#month = newMonth;
this.#setMonthValue?.(newMonth);
}
constructor() {
super();
}
#createDatePartElement({
defaultValue,
minValue = 0,
maxValue,
onChange = (() => {})
}) {
let isFocused = false;
let value = defaultValue;
const length = maxValue.toString().length;
const el = document.createElement('div');
el.setAttribute('tabindex', '0');
el.classList.add('part');
const clampValue = () => {
value = Math.min(Math.max(minValue, value), maxValue);
};
const rerender = () => {
el.textContent = value.toString().padStart(length, '0');
};
let wasJustFocused = false;
el.addEventListener('focus', (event) => {
wasJustFocused = true;
});
el.addEventListener('blur', (event) => {
wasJustFocused = false;
});
el.addEventListener('keydown', (event) => {
if (!(['ArrowUp', 'ArrowDown'].includes(event.key))) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.key === 'ArrowUp') {
value = (value + 1) % (maxValue + 1);
} else if (event.key === 'ArrowDown') {
value = value - 1;
if (value < minValue) {
value = (maxValue + 1) + value;
}
}
clampValue();
rerender();
onChange(value);
}, { capture: true });
el.addEventListener('keypress', (event) => {
if (!(/^[0-9]$/.test(event.key))) {
return;
}
event.preventDefault();
const currentValueLength = value.toString().length;
if (wasJustFocused || currentValueLength === length) {
value = 0;
wasJustFocused = false;
}
value = (value * 10) + Number.parseInt(event.key, 10);
clampValue();
rerender();
onChange(value);
});
el.addEventListener('keydown', (event) => {
if (event.key !== 'Backspace') {
return;
}
event.preventDefault();
value = Math.floor(value / 10);
clampValue();
rerender();
onChange(value);
});
el.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
value = minValue > 0 ? minValue : 0;
clampValue();
rerender();
onChange(value);
});
rerender();
return {
el,
setValue: (newValue) => {
value = newValue;
clampValue();
rerender();
onChange(value);
}
};
}
connectedCallback() {
if (this.shadowRoot) {
return;
}
const shadowRoot = this.attachShadow({ mode: 'open' });
const styleEl = document.createElement('style');
styleEl.textContent = `
:host {
display: inline-block;
padding: 1px 2px;
color: fieldtext;
background-color: field;
text-transform: none;
text-shadow: none;
text-align: start;
cursor: cursor;
border: 2px inset light-dark(rgb(118, 118, 118), rgb(133, 133, 133));
}
.separator {
pointer-events: none;
}
.part {
display: inline-block;
user-select: none;
&:is(:focus, :focus-visible) {
outline: none;
background-color: var(--selected-background-color, highlight);
color: var(--selected-text-color, highlighttext);
}
}
`;
const { el: yearEl, setValue: setYearValue } = this.#createDatePartElement({
defaultValue: this.#year,
minValue: 0,
maxValue: 9999,
onChange: (newYear) => {
this.#year = newYear;
this.dispatchEvent(new Event('change'));
}
});
this.#setYearValue = setYearValue;
yearEl.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
event.stopPropagation();
monthEl.focus();
}
});
const { el: monthEl, setValue: setMonthValue } = this.#createDatePartElement({
defaultValue: this.#month,
minValue: 1,
maxValue: 12,
onChange: (newMonth) => {
this.#month = newMonth;
this.dispatchEvent(new Event('change'));
}
});
this.#setMonthValue = setMonthValue;
monthEl.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
event.stopPropagation();
yearEl.focus();
}
});
const separatorEl = document.createElement('span');
separatorEl.classList.add('separator');
separatorEl.textContent = '-';
shadowRoot.append(
styleEl,
yearEl,
separatorEl,
monthEl
);
}
}
window.customElements.define(`${MOD_DOM_SAFE_PREFIX}-year-month-input`, HTMLYearMonthInputElement);
const containerVDOM = await IRF.vdom.container;
const panoThumbnailSrc = (pano, heading) => `https://streetviewpixels-pa.googleapis.com/v1/thumbnail?cb_client=maps_sv.tactile&w=156&h=100&pitch=0&panoid=${pano}&yaw=${heading}`;
// Doesn't catch all, but it does catch most.
const isPanoDefinitelyUgc = (pano) => pano.startsWith('CAoS');
async function fetchAlternativePanoDates(pano) {
const responseJson = await GM.fetch({
url: `https://www.google.com/maps/photometa/v1?authuser=0&hl=en&gl=ar&pb=!1m4!1smaps_sv.tactile!11m2!2m1!1b1!2m2!1sen!2sar!3m3!1m2!1e2!2s${pano}!4m61!1e1!1e2!1e3!1e4!1e5!1e6!1e8!1e12!1e17!2m1!1e1!4m1!1i48!5m1!1e1!5m1!1e2!6m1!1e1!6m1!1e2!9m36!1m3!1e2!2b1!3e2!1m3!1e2!2b0!3e3!1m3!1e3!2b1!3e2!1m3!1e3!2b0!3e3!1m3!1e8!2b0!3e3!1m3!1e1!2b0!3e3!1m3!1e4!2b0!3e3!1m3!1e10!2b1!3e2!1m3!1e10!2b0!3e3!11m2!3m1!4b1`,
headers: {
'content-type': 'application/json',
},
}).then((res) => JSON.parse(res.response.substring(`)]}'\n`.length)));
const thisPanoDate = {
pano,
date: (() => {
const [year, month] = responseJson?.[1]?.[0]?.[6]?.[7] ?? [];
return { year, month };
})()
};
const otherPanoDates = responseJson?.[1]?.[0]?.[5]?.[0]?.[8]?.map((panoDate) => {
const [index, [year, month] = []] = panoDate;
const panoRef = responseJson[1][0][5][0][3][0][index];
const [_unk, pano] = panoRef[0];
return {
pano,
date: { year, month }
};
}) ?? [];
const panoDates = [
thisPanoDate,
... otherPanoDates
];
// Sort newest first
panoDates.sort(({ date: dateA }, { date: dateB }) => {
if (!dateA) {
return -1;
} else if (!dateB) {
return 1;
}
let rank = dateB.year - dateA.year;
if (rank !== 0) {
return rank;
}
rank = dateB.month - dateA.month;
return rank;
});
return panoDates;
}
const settings = new Proxy({
year: 9999,
month: 12
}, {
get(target, propertyName, _receiver) {
return GM_getValue(propertyName, target[propertyName]);
},
set(_target, propertyName, value, _receiver) {
GM_setValue(propertyName, value);
return value;
}
});
GM_addStyle(`
.place {
.road {
width: fit-content;
margin-inline: auto;
display: block;
}
.${cssClass('pano-year')} {
background-color: pink;
}
}
`);
let panoYearEl;
function setPanoYearText(text) {
if (!panoYearEl) {
containerVDOM.state.el$;
const roadEl = document.querySelector('.place .road');
panoYearEl = roadEl.cloneNode();
panoYearEl.classList.add(cssClass('pano-year'));
roadEl.parentElement.appendChild(panoYearEl);
}
if (text == null) {
panoYearEl.style.display = 'none';
} else {
panoYearEl.textContent = text;
panoYearEl.style.display = '';
}
}
let yearMonthInputEl;
let alternativeDatesContainerEl;
function clearAlternativeDatesInIrfTab() {
while (alternativeDatesContainerEl.firstChild) {
alternativeDatesContainerEl.firstChild.remove();
}
}
function renderAlternativeDatesInIrfTab(panoDates, chosenPano) {
clearAlternativeDatesInIrfTab();
for (const { pano, date } of panoDates) {
const thumbnailEl = document.createElement('img');
thumbnailEl.classList.add(cssClass('alternative-date__thumbnail'));
thumbnailEl.src = panoThumbnailSrc(pano, containerVDOM.state.currentHeading);
const infoEl = document.createElement('div');
infoEl.classList.add(cssClass('alternative-date__info'));
infoEl.textContent = `${date.year.toString().padStart(4, '0')}-${date.month.toString().padStart(2, '0')}`;
const alternativeDateEl = document.createElement('div');
alternativeDateEl.classList.add(cssClass('alternative-date'));
if (pano === chosenPano) {
alternativeDateEl.classList.add(cssClass('alternative-date--chosen'));
}
alternativeDateEl.addEventListener('click', () => {
settings.year = date.year;
settings.month = date.month;
if (yearMonthInputEl != null) {
yearMonthInputEl.value = [date.year, date.month];
}
});
alternativeDateEl.append(
thumbnailEl,
infoEl
);
alternativeDatesContainerEl.append(alternativeDateEl);
}
}
{
const tab = IRF.ui.panel.createTabFor(
{
... GM.info,
script: {
... GM.info.script,
name: MOD_NAME,
icon: null
}
},
{
tabName: MOD_NAME,
style: `
.${cssClass('tab-content')} {
& *, *::before, *::after {
box-sizing: border-box;
}
& .${cssClass('field-group')} {
margin-block: 1rem;
gap: 0.25rem;
display: flex;
align-items: center;
justify-content: space-between;
& label > small {
color: lightgray;
display: block;
}
& input:is(:not([type]), [type="text"], [type="number"]),
& ${MOD_DOM_SAFE_PREFIX}-year-month-input {
--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;
}
& .${cssClass('field-group__label-container')},
& .${cssClass('field-group__input-container')} {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 1ch;
}
& .${cssClass('field-group__input-container')} {
justify-content: end;
white-space: nowrap;
}
}
& .${cssClass('alternative-dates-container')} {
display: grid;
grid-template-columns: repeat(3, 1fr);
& .${cssClass('alternative-date')} {
margin: 0.25rem;
border: 3px dashed white;
display: flex;
flex-direction: column;
cursor: pointer;
&.${cssClass('alternative-date--chosen')} {
border-style: solid;
}
& .${cssClass('alternative-date__thumbnail')} {
aspect-ratio: 156 / 100;
}
& .${cssClass('alternative-date__info')} {
text-align: center;
}
}
}
& 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;
&:hover {
background-color: #F5F5F5;
}
& > img {
width: 1rem;
vertical-align: middle;
user-select: none;
}
}
}
`,
className: cssClass('tab-content')
}
);
function makeFieldGroup({ id, label, labelSubtext = null }, renderInput) {
const fieldGroupEl = document.createElement('div');
fieldGroupEl.className = cssClass('field-group');
const labelContainerEl = document.createElement('div');
labelContainerEl.className = cssClass('field-group__label-container');
fieldGroupEl.append(labelContainerEl);
const labelEl = document.createElement('label');
labelEl.textContent = label;
labelContainerEl.append(labelEl);
if (labelSubtext != null) {
const labelSubtextEl = document.createElement('small');
labelSubtextEl.textContent = labelSubtext;
labelEl.append(labelSubtextEl);
}
const inputContainerEl = document.createElement('div');
inputContainerEl.className = cssClass('field-group__input-container');
fieldGroupEl.append(inputContainerEl);
const renderInputOutput = renderInput({ id });
inputContainerEl.append(... (Array.isArray(renderInputOutput) ? renderInputOutput : [renderInputOutput]));
return {
fieldGroupEl,
renderInputOutput
};
}
const {
fieldGroupEl: dateFieldGroupEl,
renderInputOutput: [_alwaysLatestButtonEl, _alwaysEarliestButtonEl, dateInputEl]
} = makeFieldGroup(
{
id: `${MOD_DOM_SAFE_PREFIX}date`,
label: 'Date'
},
({ id }) => {
const yearMonthInputEl = new HTMLYearMonthInputElement();
yearMonthInputEl.style.width = 'fit-content';
yearMonthInputEl.year = settings.year;
yearMonthInputEl.month = settings.month;
yearMonthInputEl.addEventListener('change', () => {
const [year, month] = yearMonthInputEl.value;
settings.year = year;
settings.month = month;
});
const alwaysLatestButtonEl = document.createElement('button');
alwaysLatestButtonEl.textContent = 'Always latest';
alwaysLatestButtonEl.addEventListener('click', () => {
settings.year = 9999;
settings.month = 12;
yearMonthInputEl.value = [settings.year, settings.month];
});
const alwaysEarliestButtonEl = document.createElement('button');
alwaysEarliestButtonEl.textContent = 'Always earliest';
alwaysEarliestButtonEl.addEventListener('click', () => {
settings.year = 0;
settings.month = 1;
yearMonthInputEl.value = [settings.year, settings.month];
});
return [
alwaysLatestButtonEl,
alwaysEarliestButtonEl,
yearMonthInputEl
];
}
);
yearMonthInputEl = dateInputEl;
alternativeDatesContainerEl = document.createElement('div');
alternativeDatesContainerEl.classList.add(cssClass('alternative-dates-container'));
tab.container.append(
document.createTextNode(`The mod will try to find the panorama closest to this date. This will apply after the next panorama change.`),
dateFieldGroupEl,
alternativeDatesContainerEl
);
}
let lastStopNum = null;
containerVDOM.state.changeStop = new Proxy(containerVDOM.state.changeStop, {
apply(ogChangeStop, thisArg, args) {
const runOriginal = () => ogChangeStop.apply(thisArg, args);
// During stop changes, this function runs twice. Once with this flag unset, and another with this flag set.
// We just care about the first case.
if (containerVDOM.state.isChangingStop) {
return runOriginal();
}
const stopNum = args[0];
if (lastStopNum === stopNum) {
return runOriginal();
}
lastStopNum = stopNum;
const pano = args[2];
const currentDate = new Date();
const doAttemptToOverwritePano = !isPanoDefinitelyUgc(pano) && settings.year <= currentDate.getFullYear();
if (!isPanoDefinitelyUgc(pano)) {
fetchAlternativePanoDates(pano)
.then((panoDates) => {
console.debug(`[${MOD_NAME}] Alternative pano dates:`, panoDates);
const closestPanoToDesiredDate = panoDates.reduce((closestSoFar, current) => {
const yearDiff = Math.abs(settings.year - current.date.year);
const monthDiff = Math.abs(settings.month - current.date.month);
if (
!closestSoFar ||
yearDiff < closestSoFar.yearDiff ||
(yearDiff === closestSoFar.yearDiff && monthDiff < closestSoFar.monthDiff)
) {
return {
panoDate: current,
yearDiff,
monthDiff
};
}
return closestSoFar;
}, null)?.panoDate;
if (doAttemptToOverwritePano) {
args[2] = closestPanoToDesiredDate.pano;
runOriginal();
}
setPanoYearText(`${closestPanoToDesiredDate.date.year}-${closestPanoToDesiredDate.date.month}`);
renderAlternativeDatesInIrfTab(panoDates, closestPanoToDesiredDate.pano);
})
.catch((error) => {
console.error(`[${MOD_NAME}] Could not fetch alternative pano dates:`, error);
if (doAttemptToOverwritePano) {
runOriginal();
}
clearAlternativeDatesInIrfTab();
setPanoYearText(null);
});
} else {
clearAlternativeDatesInIrfTab();
setPanoYearText(null);
}
if (!doAttemptToOverwritePano) {
runOriginal();
}
}
});
})();