// ==UserScript==
// @name Pano Downloader
// @namespace https://greasyfork.org/users/1179204
// @version 1.4.4
// @description download panoramas from google
// @author KaKa
// @match https://map-making.app/maps/*
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// @license MIT
// @icon https://www.svgrepo.com/show/502638/download-photo.svg
// ==/UserScript==
(function() {
'use strict';
let zoomLevel
function getSelection() {
const activeSelections = unsafeWindow.editor.selections
return activeSelections.flatMap(selection => selection.locations)
}
function stripBaidu(pano) {
return pano.replace("BAIDU:", "");
}
function stripTencent(pano) {
return pano.replace("TENCENT:", "");
}
async function runScript() {
const { value: option,dismiss: inputDismiss } = await Swal.fire({
title: 'Select Panoramas',
text: 'Do you want to input the panoId from your selections on map-making? If you click "Cancel", you will need to upload a JSON file.',
icon: 'question',
showCancelButton: true,
showCloseButton:true,
allowOutsideClick: false,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes',
cancelButtonText: 'Cancel'
});
if (option) {
const selectedLocs=getSelection()
if(selectedLocs.length>0) {
const { value: options, dismiss: inputDismiss } = await Swal.fire({
title: 'Download Options',
html:
'<select id="zoom-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
'<option value="1">1 (100KB~500KB)</option>' +
'<option value="2">2 (500KB~1MB)</option>' +
'<option value="3">3 (1MB~4MB)</option>' +
'<option value="4">4 (4MB~8MB)</option>' +
'<option value="5">5 (8MB~24MB)</option>' +
'</select>'+
'<select id="img-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
'<option value="1">Equirectangular</option>' +
'<option value="2">Perspective</option>' +
'<option value="3">Thumbnail</option>' +
'</select>',
icon: 'question',
showCancelButton: true,
showCloseButton: true,
allowOutsideClick: false,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes',
cancelButtonText: 'Cancel',
preConfirm: () => {
return [document.getElementById('zoom-select').value,document.getElementById('img-select').value];
}
});
if (options){
zoomLevel=parseInt(options[0])
processData(selectedLocs,parseInt(options[1]))
}}
}
else if(inputDismiss==='cancel'){
const input = document.createElement('input');
input.type = 'file';
input.style.display = 'none'
document.body.appendChild(input);
const data = await new Promise((resolve) => {
input.addEventListener('change', async () => {
const file = input.files[0];
const reader = new FileReader();
reader.onload = (event) => {
try {
const result = JSON.parse(event.target.result);
resolve(result);
document.body.removeChild(input);
} catch (error) {
Swal.fire('Error Parsing JSON Data!', 'The input JSON data is invalid or incorrectly formatted.','error');
}
};
reader.readAsText(file);
});
input.click();
});
}
function generatePerspective(canvas, FOV, THETA, PHI, outputWidth, outputHeight) {
const perspectiveCanvas = document.createElement('canvas');
perspectiveCanvas.width = outputWidth;
perspectiveCanvas.height = outputHeight;
const perspectiveCtx = perspectiveCanvas.getContext('2d');
const f = 0.5 * outputWidth / Math.tan((FOV / 2) * (Math.PI / 180));
const cx = outputWidth / 2;
const cy = outputHeight / 2;
var inputWidth = canvas.width;
var inputHeight = canvas.height;
const inputCtx = canvas.getContext('2d');
const inputImageData = inputCtx.getImageData(0, 0, inputWidth, inputHeight);
const outputImageData = perspectiveCtx.createImageData(outputWidth, outputHeight);
const outputData = outputImageData.data;
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
const nx = (x - cx) / f;
const ny = (y - cy) / f;
const nz = 1;
const cosTheta = Math.cos(THETA);
const sinTheta = Math.sin(THETA);
const cosPhi = Math.cos(PHI);
const sinPhi = Math.sin(PHI);
const rx = cosTheta * nx - sinTheta * nz;
const ry = -sinPhi * (sinTheta * nx + cosTheta * nz) + cosPhi * ny;
const rz = cosPhi * (sinTheta * nx + cosTheta * nz) + sinPhi * ny;
const lon = Math.atan2(rx, rz);
const lat = Math.asin(ry / Math.sqrt(rx * rx + ry * ry + rz * rz));
const u = Math.floor(((lon / (2 * Math.PI)) + 0.5) * inputWidth);
const v = Math.floor(((lat / Math.PI) + 0.5) * inputHeight);
if (u >= 0 && u < inputWidth && v >= 0 && v < inputHeight) {
const srcOffset = (v * inputWidth + u) * 4;
const destOffset = (y * outputWidth + x) * 4;
outputData[destOffset] = inputImageData.data[srcOffset]; // Red
outputData[destOffset + 1] = inputImageData.data[srcOffset + 1]; // Green
outputData[destOffset + 2] = inputImageData.data[srcOffset + 2]; // Blue
outputData[destOffset + 3] = 255; // Alpha
}
}
}
perspectiveCtx.putImageData(outputImageData, 0, 0);
return perspectiveCanvas;
}
async function downloadPanoramaImage(panoId, fileName,w,h,th,ch,mode) {
return new Promise(async (resolve, reject) => {
try {
let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl;
const tileWidth = 512;
const tileHeight = 512;
if (panoId.includes('BAIDU')||panoId.includes('TENCENT')) {
tilesPerRow = 16;
tilesPerColumn = 8;
} else {
let zoomTiles;
imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoomLevel}&nbt=0&fover=2`;
zoomTiles = [2, 4, 8, 16, 32];
tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoomLevel - 1]);
tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoomLevel - 1] / 2);
}
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d');
canvas.width = tilesPerRow * tileWidth;
canvas.height = tilesPerColumn * tileHeight;
if (w === 13312) {
const sizeMap = {
4: [6656, 3328],
3: [3328, 1664],
2: [1664, 832],
1: [832, 416]
};
if (sizeMap[zoomLevel]) {
[canvas.width, canvas.height] = sizeMap[zoomLevel];
}
}
const loadTile = (x, y) => {
return new Promise(async (resolveTile) => {
let tile;
if (panoId.includes('TENCENT')) {
tileUrl = `https://sv4.map.qq.com/tile?svid=${stripTencent(panoId)}&x=${x}&y=${y}&from=web&level=1`;
} else if (panoId.includes('BAIDU')) {
tileUrl = `https://mapsv0.bdimg.com/?qt=pdata&sid=${stripBaidu(panoId)}&pos=${y}_${x}&z=5`;
} else {
tileUrl = `${imageUrl}&x=${x}&y=${y}`;
}
try {
tile = await loadImage(tileUrl);
ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
resolveTile();
} catch (error) {
console.error(`Error loading tile at ${x},${y}:`, error);
resolveTile();
}
});
};
let tilePromises = [];
for (let y = 0; y < tilesPerColumn; y++) {
for (let x = 0; x < tilesPerRow; x++) {
tilePromises.push(loadTile(x, y));
}
}
await Promise.all(tilePromises);
if(mode!=1){
var targetTheta
if(th) targetTheta=(ch-th) * (Math.PI / 180)
else if(th==0) targetTheta=(ch-th) * (Math.PI / 180)
else targetTheta=0
const perspectiveCanvas = generatePerspective(canvas, 125,targetTheta,0,1920, 1080)
perspectiveCanvas.toBlob(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName+'.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
resolve();
}, 'image/png');}
else{
canvas.toBlob(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName+'.jpg';
document.body.appendChild(a);
a.click(); document.body.removeChild(a);
window.URL.revokeObjectURL(url);
resolve();
}, 'image/jpg');
}
} catch (error) {
Swal.fire('Error!', error.toString(),'error');
reject(error);
}
});
}
async function downloadPanoThumbnail(panoId, fileName, h,p) {
const url = `https://streetviewpixels-pa.googleapis.com/v1/thumbnail?panoid=${panoId}&cb_client=maps_sv.tactile.gps&yaw=${h}&pitch=${p}&thumbfov=120&width=1024&height=512`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
const blob = await response.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${fileName}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
console.log(`Failed to download: ${fileName}.png`);
} catch (error) {
console.error(`Failed to download (${fileName}):`, error);
}
}
async function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
img.src = url;
});
}
var CHUNK_SIZE = Math.round(20/zoomLevel);
var promises = [];
async function processChunk(chunk,mode) {
var service = new google.maps.StreetViewService();
var promises = chunk.map(async coord => {
let panoId = coord.panoId;
const th=coord.heading
const tp=coord.pitch
let latLng = {lat: coord.location.lat, lng: coord.location.lng};
let svData;
if ((panoId || latLng)) {
svData = await getSVData(service, panoId ? {pano: panoId} : {location: latLng, radius: 5});
}
if (svData.tiles&&svData.tiles.worldSize) {
const w=svData.tiles.worldSize.width
const h=svData.tiles.worldSize.height
const ch=svData.tiles.centerHeading
const fileName = panoId;
if(mode==3) await downloadPanoThumbnail(panoId,fileName,th,tp)
else await downloadPanoramaImage(panoId, fileName,w,h,th,ch,mode);
}
});
await Promise.all(promises);
}
function getSVData(service, options) {
return new Promise(resolve => service.getPanorama({...options}, (data, status) => {
resolve(data);
}));
}
async function processData(panos,mode) {
try {
let processedChunks = 0;
const swal = Swal.fire({
title: 'Downloading',
text: 'Please wait...',
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
for (let i = 0; i < panos.length; i += 5) {
let chunk = panos.slice(i, i + 5);
await processChunk(chunk,mode);
processedChunks++;
const progress = Math.min((processedChunks /panos.length) * 100, 100);
Swal.update({
html: `<div>${progress.toFixed(2)}% completed</div>
<div class="swal2-progress">
<div class="swal2-progress-bar" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" style="width: ${progress}%;">
</div>
</div>`
});
}
swal.close();
Swal.fire('Success!','Download completed', 'success');
} catch (error) {
swal.close();
Swal.fire('Error!',"Failed to download due to:"+error.toString(),'error');
}
}
}
var downloadButton=document.createElement('button');
downloadButton.textContent='Download Panos'
downloadButton.addEventListener('click', runScript);
downloadButton.style.width='160px'
downloadButton.style.position = 'fixed';
downloadButton.style.right = '150px';
downloadButton.style.bottom = '15px';
downloadButton.style.borderRadius = "18px";
downloadButton.style.padding = "5px 10px";
downloadButton.style.border = "none";
downloadButton.style.color = "white";
downloadButton.style.cursor = "pointer";
downloadButton.style.backgroundColor = "#4CAF50";
document.body.appendChild(downloadButton);
})();