// ==UserScript==
// @name Google Street View Panorama Info
// @namespace https://gf.qytechs.cn/users/1340965-zecageo
// @version 1.17
// @description Displays the country name, coordinates, panoId and share link for a given Google Street View panorama
// @author ZecaGeo <[email protected]>
// @run-at document-end
// @match https://www.google.com/maps/*
// @match https://www.google.at/maps/*
// @match https://www.google.ca/maps/*
// @match https://www.google.de/maps/*
// @match https://www.google.fr/maps/*
// @match https://www.google.it/maps/*
// @match https://www.google.ru/maps/*
// @match https://www.google.co.uk/maps/*
// @match https://www.google.co.jp/maps/*
// @match https://www.google.com.br/maps/*
// @exclude https://ogs.google.com/
// @exclude https://accounts.google.com/
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com
// @connect nominatim.openstreetmap.org
// @license MIT
// @copyright 2025, zecageo
// ==/UserScript==
(function () {
'use strict';
let zgDebugMode, zgSVPanorama;
const init = () => {
zgDebugMode = new ZGDebug(config.debugMode);
ZGDOM.waitForElement('.pB8Nmf', updateTitleCard, '#titlecard');
}
const updateTitleCard = async (referenceElement) => {
const lastChild = referenceElement.lastElementChild;
zgSVPanorama = new ZGStreetViewPanorama();
referenceElement.insertBefore(
ZGDOM.cloneNode(await getCountry(zgSVPanorama.latitude, zgSVPanorama.longitude)),
lastChild
);
referenceElement.insertBefore(
ZGDOM.cloneNode(zgSVPanorama.latitude, true),
lastChild
);
referenceElement.insertBefore(
ZGDOM.cloneNode(zgSVPanorama.longitude, true),
lastChild
);
referenceElement.insertBefore(ZGDOM.cloneNode(zgSVPanorama.panoId, true), lastChild);
if (!referenceElement) {
zgDebugMode.error('Reference element not found.');
return;
}
const shareLinkNode = referenceElement.insertBefore(
ZGDOM.cloneNode('Click here to generate a share link', true),
lastChild
);
shareLinkNode.onclick = shareLinkOnClickHandler;
}
const shareLinkOnClickHandler = async (event) => {
await generatePanoramaShareLink(zgSVPanorama);
zgDebugMode.debug('zgSVPanorama:', zgSVPanorama);
event.target.innerText = zgSVPanorama.shareLink;
GM_setClipboard(zgSVPanorama.shareLink);
}
const getCountry = async (latitude, longitude) => {
const osmUrl = `https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&accept-language=en-US`;
const response = await ZGNetwork.promiseRequest(osmUrl);
const data = JSON.parse(response.responseText);
return data?.address?.country ?? 'Country not found';
}
const generatePanoramaShareLink = async () => {
const shareLinkRequestUrl = zgSVPanorama.generateShareLinkRequestUrl();
const response = await ZGNetwork.promiseRequest(shareLinkRequestUrl);
const rawText = response.responseText;
zgSVPanorama.shareLink = rawText.substring(7, rawText.length - 2);
}
/** Settings */
const config = {
get debugMode() {
return GM_getValue('zgDebugMode', false);
},
set debugMode(value) {
GM_setValue('zgDebugMode', value);
},
};
/** Google Street View Panorama */
class ZGStreetViewPanorama {
url;
latitude;
longitude;
panoId;
thumb;
fov;
zoom;
heading;
pitch;
shareLink;
constructor() {
this.#update();
}
#update() {
this.url = window.location.href;
const tempUrl = new URL(this.url);
const pathname = tempUrl.pathname;
let pathnameRegex = new RegExp(
/@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^h]+)h,([^t]+)t\/.*?!1s([^!]+)!2e/
);
let matches = pathnameRegex.exec(pathname);
if (matches && matches.length === 8) {
this.latitude = parseFloat(matches[1]);
this.longitude = parseFloat(matches[2]);
this.thumb = parseInt(matches[3]);
this.fov = parseFloat(matches[4]);
this.zoom = Math.log(180 / this.fov) / Math.log(2);
this.heading = parseFloat(matches[5]);
this.pitch = Math.round((parseFloat(matches[6]) - 90) * 100) / 100;
this.panoId = matches[7];
return;
}
// Sometimes the heading is missing in the URL, so ignore it in the regex expression
if(!matches) {
pathnameRegex = new RegExp(/@([^,]+),([^,]+),(\d+)a,([^y]+)y,([^t]+)t\/.*?!1s([^!]+)!2e/);
matches = pathnameRegex.exec(pathname);
if (matches && matches.length === 7) {
this.latitude = parseFloat(matches[1]);
this.longitude = parseFloat(matches[2]);
this.thumb = parseInt(matches[3]);
this.fov = parseFloat(matches[4]);
this.zoom = Math.log(180 / this.fov) / Math.log(2);
this.heading = 0; // Default heading
this.pitch = Math.round((parseFloat(matches[5]) - 90) * 100) / 100;
this.panoId = matches[6];
return;
}
}
console.error('Invalid Google Street View URL format.', this.url);
}
generateShareLinkRequestUrl = () => {
this.#update();
const encodedUrl = encodeURIComponent(this.url.replaceAll('!', '*21'));
return `https://www.google.com/maps/rpc/shorturl?pb=!1s${encodedUrl}!2m1!7e81!6b1`;
}
}
ZGStreetViewPanorama.prototype.toString = function () {
return `
Panorama: {
panoId: ${this.panoId},
latitude: ${this.latitude},
longitude: ${this.longitude},
thumb: ${this.thumb},
fov: ${this.fov},
zoom: ${this.zoom},
heading: ${this.heading},
pitch: ${this.pitch},
shareLink: ${this.shareLink}
}`;
};
/** DOM manipulation */
class ZGDOM {
static cloneNode(value, isClickable = false) {
let h2Element = document.createElement('h2');
h2Element.setAttribute('class', 'lsdM5 fontBodySmall');
let divElement = document.createElement('div');
divElement.appendChild(h2Element);
let node = divElement.cloneNode(true);
node.querySelector('h2').innerText = value;
if (isClickable) {
node.style.cursor = 'pointer';
node.onclick = () => GM_setClipboard(value);
}
return node;
}
static waitForElement = (selector, callback, targetNode) => {
new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const matchingElement = document.querySelector(selector);
if (matchingElement) {
observer.disconnect();
callback(matchingElement);
return;
}
}
}
}).observe(
targetNode ? document.querySelector(targetNode) : document.body,
{
childList: true,
subtree: true,
}
);
};
}
/** Networking */
class ZGNetwork {
static promiseRequest = (url, method = 'GET') => {
if (!url) return Promise.reject('URL is empty');
zgDebugMode.debug(
[
'---PROMISEREQUEST---',
'\tmethod: ' + method,
'\turl: ' + url,
'---PROMISEREQUEST---',
].join('\n')
);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: url,
onload: (result) => {
this.responseInfo(result);
if (result.status >= 200 && result.status < 300) {
resolve(result);
} else {
reject(result.responseText);
}
},
ontimeout: () => {
let l = new URL(url);
reject(
' timeout detected: "no answer from ' +
l.host +
' for ' +
l.timeout / 1000 +
's"'
);
},
onerror: (result) => {
this.responseInfo(result);
reject(
' error: ' + result.status + ', message: ' + result.statusText
);
},
});
});
}
static responseInfo = (response) => {
zgDebugMode.debug(
[
'',
'finalUrl: \t\t' + (response.finalUrl || '-'),
'status: \t\t' + (response.status || '-'),
'statusText: \t' + (response.statusText || '-'),
'readyState: \t' + (response.readyState || '-'),
'responseHeaders: ' +
(response.responseHeaders.replaceAll('\response\n', ';') || '-'),
'responseText: \t' + (response.responseText || '-'),
].join('\n')
);
}
}
/** Debugging */
class ZGDebug {
constructor(debugMode) {
this.debugMode = debugMode;
}
#debugLog = (level, ...msg) => {
if (this.debugMode) {
console[level](...msg);
}
};
debug = (...msg) => {
this.#debugLog('log', ...msg);
};
warn = (...msg) => {
this.#debugLog('warn', ...msg);
};
error = (...msg) => {
this.#debugLog('error', ...msg);
};
}
init();
})();