Pano Downloader

download panoramas from google

  1. // ==UserScript==
  2. // @name Pano Downloader
  3. // @namespace https://greasyfork.org/users/1179204
  4. // @version 1.4.8
  5. // @description download panoramas from google
  6. // @author KaKa
  7. // @match https://map-making.app/maps/*
  8. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  9. // @license MIT
  10. // @icon https://www.svgrepo.com/show/502638/download-photo.svg
  11. // ==/UserScript==
  12. (function() {
  13. 'use strict';
  14. let zoomLevel
  15. const OPENMAP_TILE_HOST1="https://storage.nambox.com/"
  16. const OPENMAP_TILE_HOST2="https://hn.storage.weodata.vn/"
  17. const KAKAO_TILE_URL = "https://map0.daumcdn.net/map_roadview";
  18. const tileImagePathCache = /* @__PURE__ */ new Map();
  19.  
  20. function getSelection() {
  21. const activeSelections = unsafeWindow.editor.selections
  22.  
  23. return activeSelections.flatMap(selection => selection.locations)
  24. }
  25.  
  26. function stripPanoId(pano,prefix) {
  27. return pano.replace(prefix, "");
  28. }
  29.  
  30. const HOST_CACHE={}
  31. async function checkHostUrl(url) {
  32. try {
  33. const response = await fetch(url, {
  34. method: 'GET',
  35. timeout: 3000
  36. });
  37. return response.status === 200;
  38. } catch (error) {
  39. return false;
  40. }
  41. }
  42.  
  43. async function getOpenmapThumbUrl(panoId) {
  44. if(HOST_CACHE[panoId])return HOST_CACHE[panoId]
  45. const paramUrl = `streetview-cdn/derivates/${panoId.slice(0, 2)}/${panoId.slice(2, 4)}/${panoId.slice(4, 6)}/${panoId.slice(6, 8)}/${panoId.slice(9)}/sd.jpg`;
  46. const urlTemplate = await checkHostUrl(`${OPENMAP_TILE_HOST1}${paramUrl}`) ? `${OPENMAP_TILE_HOST1}${paramUrl}` : `${OPENMAP_TILE_HOST2}${paramUrl}`;
  47. const url = new URL(urlTemplate);
  48. HOST_CACHE[panoId]=url.href
  49. return url.href;
  50. }
  51. async function getOpenmapTileUrl(panoId, x, y) {
  52. if(HOST_CACHE[panoId])return HOST_CACHE[panoId]
  53. const paramUrl = `streetview-cdn/derivates/${panoId.slice(0, 2)}/${panoId.slice(2, 4)}/${panoId.slice(4, 6)}/${panoId.slice(6, 8)}/${panoId.slice(9)}/tiles/${x}_${y}.jpg`;
  54. const urlTemplate = await checkHostUrl(`${OPENMAP_TILE_HOST1}${paramUrl}`) ? `${OPENMAP_TILE_HOST1}${paramUrl}` : `${OPENMAP_TILE_HOST2}${paramUrl}`;
  55. const url = new URL(urlTemplate);
  56. HOST_CACHE[panoId]=url.href
  57. return url.href;
  58. }
  59.  
  60. async function get_yandex_pano(id) {
  61. try {
  62.  
  63. const response = await fetch(`https://api-maps.yandex.com/services/panoramas/1.x?l=stv&lang=en_US&origin=userAction&provider=streetview&oid=${stripPanoId(id,"YANDEX:")}`);
  64.  
  65. if (!response.ok) {
  66. console.error(`Error fetching imageKey: HTTP ${response.status}`);
  67. return null;
  68. }
  69.  
  70. const data = await response.json();
  71. if (data) {
  72. return {imageId:data.data.Data.Images.imageId,
  73. width:data.data.Data.Images.Zooms.length===4 ?data.data.Data.Images.Zooms[0].width:data.data.Data.Images.Zooms[1].width,
  74. height:data.data.Data.Images.Zooms.length===4 ?data.data.Data.Images.Zooms[0].height:data.data.Data.Images.Zooms[1].height,
  75. zoomLevels:data.data.Data.Images.Zooms.length};
  76. } else {
  77. console.error('Error fetching imageKey: Data format invalid.');
  78. return null;
  79. }
  80. } catch (error) {
  81. console.error('Error fetching imageKey:', error.message);
  82. return null;
  83. }
  84. }
  85. async function get_kakao_pano(id) {
  86. try {
  87.  
  88. const response = await fetch(`https://rv.map.kakao.com/roadview-search/v2/node/${id}?SERVICE=glpano`);
  89.  
  90. if (!response.ok) {
  91. console.error(`Error fetching imageKey: HTTP ${response.status}`);
  92. return null;
  93. }
  94.  
  95. const data = await response.json();
  96. if (data) {
  97. const kakao = data.street_view.street;
  98. return kakao.img_path
  99. } else {
  100. console.error('Error fetching imageKey: Data format invalid.');
  101. return null;
  102. }
  103. } catch (error) {
  104. console.error('Error fetching imageKey:', error.message);
  105. return null;
  106. }
  107. }
  108. const GOOGLE_TO_KAKAO_ZOOM = [0, 0, 1, 1, 2];
  109. const KAKAO_HORZ_TILES = [1, 8, 16];
  110. function getTileIndex(zoom, x, y) {
  111. const w = KAKAO_HORZ_TILES[zoom];
  112. return (y + 1) * w + (x - w) + 1;
  113. }
  114. function getTileImageName(imagePath) {
  115. const i = imagePath.lastIndexOf("/");
  116. return imagePath.slice(i + 1);
  117. }
  118. function buildTileUrl(imagePath, zoom, x, y) {
  119. zoom = GOOGLE_TO_KAKAO_ZOOM[zoom];
  120. const tileIndex = getTileIndex(zoom, x, y).toString().padStart(zoom + 1, "0");
  121. if (zoom === 1) {
  122. return `${KAKAO_TILE_URL}${imagePath}/${getTileImageName(imagePath)}_${tileIndex}.jpg`;
  123. }
  124. if (zoom === 2) {
  125. return `${KAKAO_TILE_URL}${imagePath}_HD1/${getTileImageName(imagePath)}_HD1_${tileIndex}.jpg`;
  126. }
  127. return `${KAKAO_TILE_URL}${imagePath}.jpg`;
  128. }
  129.  
  130. async function getTileUrlAsync(pano, zoom, x, y) {
  131. if(tileImagePathCache.get(stripPanoId(pano,"KAKAO")))return buildTileUrl(tileImagePathCache.get(stripPanoId(pano,"KAKAO")),zoom,x,y)
  132. const img_path = await get_kakao_pano(pano)
  133. if (!img_path) {
  134. throw new Error(`Could not find Kakao image path for pano "${pano}"`);
  135. }
  136. tileImagePathCache.set(stripPanoId(pano,"KAKAO"), img_path);
  137. return buildTileUrl(img_path, zoom, x, y);
  138. }
  139.  
  140. async function runScript() {
  141.  
  142. const { value: option,dismiss: inputDismiss } = await Swal.fire({
  143. title: 'Select Panoramas',
  144. 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.',
  145. icon: 'question',
  146. showCancelButton: true,
  147. showCloseButton:true,
  148. allowOutsideClick: false,
  149. confirmButtonColor: '#3085d6',
  150. cancelButtonColor: '#d33',
  151. confirmButtonText: 'Yes',
  152. cancelButtonText: 'Cancel'
  153. });
  154.  
  155.  
  156. if (option) {
  157.  
  158. const selectedLocs=getSelection()
  159. if(selectedLocs.length>0) {
  160. const { value: options, dismiss: inputDismiss } = await Swal.fire({
  161. title: 'Download Options',
  162. html:
  163. '<select id="zoom-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
  164. '<option value="1">1 (100KB~500KB)</option>' +
  165. '<option value="2">2 (500KB~1MB)</option>' +
  166. '<option value="3">3 (1MB~4MB)</option>' +
  167. '<option value="4">4 (4MB~8MB)</option>' +
  168. '<option value="5">5 (8MB~24MB)</option>' +
  169. '</select>'+
  170. '<select id="img-select" class="swal2-input" style="width:180px; height:40px; margin:5px; font-size:16px;white-space:prewrap">' +
  171. '<option value="1">Equirectangular</option>' +
  172. '<option value="2">Perspective</option>' +
  173. '<option value="3">Thumbnail</option>' +
  174. '</select>',
  175. icon: 'question',
  176. showCancelButton: true,
  177. showCloseButton: true,
  178. allowOutsideClick: false,
  179. confirmButtonColor: '#3085d6',
  180. cancelButtonColor: '#d33',
  181. confirmButtonText: 'Yes',
  182. cancelButtonText: 'Cancel',
  183. preConfirm: () => {
  184. return [document.getElementById('zoom-select').value,document.getElementById('img-select').value];
  185. }
  186. });
  187. if (options){
  188. zoomLevel=parseInt(options[0])
  189. processData(selectedLocs,parseInt(options[1]))
  190. }}
  191. }
  192.  
  193. else if(inputDismiss==='cancel'){
  194.  
  195. const input = document.createElement('input');
  196. input.type = 'file';
  197. input.style.display = 'none'
  198. document.body.appendChild(input);
  199.  
  200. const data = await new Promise((resolve) => {
  201. input.addEventListener('change', async () => {
  202. const file = input.files[0];
  203. const reader = new FileReader();
  204.  
  205. reader.onload = (event) => {
  206. try {
  207. const result = JSON.parse(event.target.result);
  208. resolve(result);
  209.  
  210. document.body.removeChild(input);
  211. } catch (error) {
  212. Swal.fire('Error Parsing JSON Data!', 'The input JSON data is invalid or incorrectly formatted.','error');
  213. }
  214. };
  215.  
  216. reader.readAsText(file);
  217. });
  218.  
  219.  
  220. input.click();
  221. });
  222. }
  223. function generatePerspective(canvas, FOV, THETA, PHI, outputWidth, outputHeight) {
  224. const perspectiveCanvas = document.createElement('canvas');
  225. perspectiveCanvas.width = outputWidth;
  226. perspectiveCanvas.height = outputHeight;
  227. const perspectiveCtx = perspectiveCanvas.getContext('2d');
  228.  
  229. const f = 0.5 * outputWidth / Math.tan((FOV / 2) * (Math.PI / 180));
  230. const cx = outputWidth / 2;
  231. const cy = outputHeight / 2;
  232.  
  233. var inputWidth = canvas.width;
  234. var inputHeight = canvas.height;
  235. const inputCtx = canvas.getContext('2d');
  236. const inputImageData = inputCtx.getImageData(0, 0, inputWidth, inputHeight);
  237.  
  238. const outputImageData = perspectiveCtx.createImageData(outputWidth, outputHeight);
  239. const outputData = outputImageData.data;
  240.  
  241. const R1 = rotationMatrix([0, 1, 0], THETA);
  242. const rotatedXAxis = applyRotation(R1, [1, 0, 0]);
  243. const R2 = rotationMatrix(rotatedXAxis, PHI);
  244. const R = multiplyMatrices(R2, R1);
  245.  
  246. for (let y = 0; y < outputHeight; y++) {
  247. for (let x = 0; x < outputWidth; x++) {
  248. const nx = (x - cx) / f;
  249. const ny = (y - cy) / f;
  250. const nz = 1;
  251.  
  252. const [rx, ry, rz] = applyRotation(R, [nx, ny, nz]);
  253.  
  254. const lon = Math.atan2(rx, rz);
  255. const lat = Math.asin(ry / Math.sqrt(rx * rx + ry * ry + rz * rz));
  256.  
  257. const u = Math.floor(((lon / (2 * Math.PI)) + 0.5) * inputWidth);
  258. const v = Math.floor(((lat / Math.PI) + 0.5) * inputHeight);
  259.  
  260. if (u >= 0 && u < inputWidth && v >= 0 && v < inputHeight) {
  261. const srcOffset = (v * inputWidth + u) * 4;
  262. const destOffset = (y * outputWidth + x) * 4;
  263.  
  264. outputData[destOffset] = inputImageData.data[srcOffset]; // Red
  265. outputData[destOffset + 1] = inputImageData.data[srcOffset + 1]; // Green
  266. outputData[destOffset + 2] = inputImageData.data[srcOffset + 2]; // Blue
  267. outputData[destOffset + 3] = 255; // Alpha
  268. }
  269. }
  270. }
  271.  
  272. perspectiveCtx.putImageData(outputImageData, 0, 0);
  273. return perspectiveCanvas;
  274. }
  275.  
  276. function rotationMatrix(axis, angle) {
  277. const rad = angle * (Math.PI / 180);
  278. const c = Math.cos(rad);
  279. const s = Math.sin(rad);
  280. const t = 1 - c;
  281. const [x, y, z] = axis;
  282.  
  283. return [
  284. [t*x*x + c, t*x*y - s*z, t*x*z + s*y],
  285. [t*x*y + s*z, t*y*y + c, t*y*z - s*x],
  286. [t*x*z - s*y, t*y*z + s*x, t*z*z + c]
  287. ];
  288. }
  289.  
  290. function applyRotation(matrix, vector) {
  291. return [
  292. matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
  293. matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
  294. matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2]
  295. ];
  296. }
  297.  
  298. function multiplyMatrices(A, B) {
  299. const result = Array(3).fill(null).map(() => Array(3).fill(0));
  300. for (let i = 0; i < 3; i++) {
  301. for (let j = 0; j < 3; j++) {
  302. for (let k = 0; k < 3; k++) {
  303. result[i][j] += A[i][k] * B[k][j];
  304. }
  305. }
  306. }
  307. return result;
  308. }
  309. async function downloadPanoramaImage(panoId, fileName,w,h,th,ch,tp,mode) {
  310. return new Promise(async (resolve, reject) => {
  311. try {
  312. let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl, imageKey, zoomLevels;
  313. var [tileWidth ,tileHeight]= [512, 512];
  314.  
  315. if (panoId.includes('BAIDU')||panoId.includes('TENCENT')) {
  316. tilesPerRow = 16;
  317. tilesPerColumn = 8;
  318. }
  319. else if(panoId.includes('KAKAO:')){
  320. if(zoomLevel ==5) {
  321. tilesPerRow = 16;
  322. tilesPerColumn = 8;
  323. }
  324. else if (zoomLevel>=3){
  325. zoomLevel=4
  326. tilesPerRow = 8;
  327. tilesPerColumn = 4;
  328. }
  329. else{
  330. zoomLevel=3
  331. tilesPerRow = 8;
  332. tilesPerColumn = 4;
  333. }
  334. }
  335.  
  336. else if (panoId.includes('OPENMAP')){
  337. tilesPerRow = 8;
  338. tilesPerColumn = 4;
  339. tileWidth=720
  340. tileHeight=720
  341. }
  342. else if (panoId.includes('YANDEX')){
  343. const metadata=await get_yandex_pano(panoId)
  344. imageKey=metadata.imageId
  345. /*if (metadata.width<=7000){
  346. tilesPerRow=22
  347. tilesPerColumn = 8;
  348. }
  349. else if(metadata.width<=9000) {
  350. tilesPerRow=28
  351. tilesPerColumn = 14;
  352. }
  353. else{
  354. tilesPerRow=28
  355. tilesPerColumn = 10;
  356. }*/
  357. tileWidth=256
  358. tileHeight=256
  359. zoomLevels=metadata.zoomLevels
  360. tilesPerRow=Math.ceil(metadata.width / tileWidth)
  361. tilesPerColumn = Math.ceil(metadata.height / tileHeight);
  362. }
  363. else {
  364. let zoomTiles;
  365.  
  366. imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoomLevel}&nbt=0&fover=2`;
  367. zoomTiles = [2, 4, 8, 16, 32];
  368. tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoomLevel - 1]);
  369. tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoomLevel - 1] / 2);
  370.  
  371. }
  372.  
  373. canvas = document.createElement('canvas');
  374. ctx = canvas.getContext('2d');
  375.  
  376. canvas.width = tilesPerRow * tileWidth;
  377. canvas.height = tilesPerColumn * tileHeight;
  378. if (w === 13312) {
  379. const sizeMap = {
  380. 4: [6656, 3328],
  381. 3: [3328, 1664],
  382. 2: [1664, 832],
  383. 1: [832, 416]
  384. };
  385. if (sizeMap[zoomLevel]) {
  386. [canvas.width, canvas.height] = sizeMap[zoomLevel];
  387. }
  388. }
  389. if (panoId.includes('YANDEX')&&mode==2){
  390. canvas.height=3584
  391. }
  392. const loadTile = (x, y) => {
  393. return new Promise(async (resolveTile) => {
  394. let tile;
  395. if (panoId.includes('TENCENT'))tileUrl = `https://sv4.map.qq.com/tile?svid=${stripPanoId(panoId,"TENCENT:")}&x=${x}&y=${y}&from=web&level=1`;
  396.  
  397. else if (panoId.includes('BAIDU'))tileUrl = `https://mapsv0.bdimg.com/?qt=pdata&sid=${stripPanoId(panoId,"BAIDU")}&pos=${y}_${x}&z=5`;
  398.  
  399. else if (panoId.includes('YANDEX'))tileUrl = `https://pano.maps.yandex.net/${imageKey}/${zoomLevels==4?0:1}.${x}.${y}`;
  400.  
  401. else if (panoId.includes('OPENMAP'))tileUrl =await getOpenmapTileUrl(stripPanoId(panoId,"OEPNMAP"),x,y)
  402.  
  403. else if (panoId.includes('KAKAO:')) tileUrl= await getTileUrlAsync(stripPanoId(panoId,"KAKAO:"),zoomLevel-1,x,y)
  404.  
  405. else tileUrl = `${imageUrl}&x=${x}&y=${y}`;
  406.  
  407. try {
  408. tile = await loadImage(tileUrl);
  409. ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
  410. resolveTile();
  411. } catch (error) {
  412. console.error(`Error loading tile at ${x},${y}:`, error);
  413. resolveTile();
  414. }
  415. });
  416. };
  417. let tilePromises = [];
  418. for (let y = 0; y < tilesPerColumn; y++) {
  419. for (let x = 0; x < tilesPerRow; x++) {
  420. tilePromises.push(loadTile(x, y));
  421. }
  422. }
  423.  
  424. await Promise.all(tilePromises);
  425.  
  426. if(mode!=1){
  427. var targetTheta
  428. if(th||th==0) targetTheta=(th-ch)
  429. else targetTheta=0
  430. const perspectiveCanvas = generatePerspective(canvas, 125,targetTheta,tp,1920, 1080)
  431. perspectiveCanvas.toBlob(blob => {
  432. const url = window.URL.createObjectURL(blob);
  433. const a = document.createElement('a');
  434. a.href = url;
  435. a.download = fileName+'.png';
  436. document.body.appendChild(a);
  437. a.click();
  438. document.body.removeChild(a);
  439. window.URL.revokeObjectURL(url);
  440. resolve();
  441. }, 'image/png');}
  442. else{
  443. canvas.toBlob(blob => {
  444. const url = window.URL.createObjectURL(blob);
  445. const a = document.createElement('a');
  446. a.href = url;
  447. a.download = fileName+'.jpg';
  448. document.body.appendChild(a);
  449. a.click(); document.body.removeChild(a);
  450. window.URL.revokeObjectURL(url);
  451. resolve();
  452. }, 'image/jpeg');
  453. }
  454. } catch (error) {
  455. Swal.fire('Error!', error.toString(),'error');
  456. reject(error);
  457. }
  458. });
  459. }
  460. async function downloadPanoThumbnail(panoId, fileName, h,p) {
  461. var 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`;
  462. if(panoId.includes('BAIDU')) url=`https://mapsv0.bdimg.com/?qt=pr3d&fovy=125&quality=100&panoid=${stripPanoId(panoId,"BAIDU:")}&heading=${h}&pitch=${p}&width=1024&height=768`
  463. else if (panoId.includes('OPENMAP')) url= await getOpenmapThumbUrl(stripPanoId(panoId,"OPENMAP:"))
  464. else if (panoId.includes('TENCENT')) url=`https://sv0.map.qq.com/thumb?from=web&level=2&svid=${stripPanoId(panoId,"TENCENT:")}`
  465. try {
  466. const response = await fetch(url);
  467. if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  468.  
  469. const blob = await response.blob();
  470. const a = document.createElement("a");
  471. a.href = URL.createObjectURL(blob);
  472. a.download = `${fileName}.png`;
  473. document.body.appendChild(a);
  474. a.click();
  475. document.body.removeChild(a);
  476.  
  477. console.log(`Failed to download: ${fileName}.png`);
  478. } catch (error) {
  479. console.error(`Failed to download (${fileName}):`, error);
  480. }
  481. }
  482. async function loadImage(url) {
  483. return new Promise((resolve, reject) => {
  484. const img = new Image();
  485. img.crossOrigin = 'Anonymous';
  486. img.onload = () => resolve(img);
  487. img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
  488. img.src = url;
  489. });
  490. }
  491.  
  492. var CHUNK_SIZE = Math.round(20/zoomLevel);
  493.  
  494. var promises = [];
  495.  
  496. async function processChunk(chunk,mode) {
  497. var service = new google.maps.StreetViewService();
  498. var promises = chunk.map(async coord => {
  499. let panoId = coord.panoId;
  500. const th=coord.heading
  501. const tp=coord.pitch
  502. let latLng = {lat: coord.location.lat, lng: coord.location.lng};
  503. let svData;
  504.  
  505. if ((panoId || latLng)) {
  506. svData = await getSVData(service, panoId ? {pano: panoId} : {location: latLng, radius: 5});
  507. }
  508.  
  509. if (svData.tiles&&svData.tiles.worldSize) {
  510. const w=svData.tiles.worldSize.width
  511. const h=svData.tiles.worldSize.height
  512. const ch=svData.tiles.centerHeading
  513. const fileName = panoId;
  514. if(mode==3) await downloadPanoThumbnail(panoId,fileName,th,tp)
  515. else await downloadPanoramaImage(panoId, fileName,w,h,th,ch,tp,mode);
  516. }
  517.  
  518. });
  519.  
  520. await Promise.all(promises);
  521. }
  522.  
  523. function getSVData(service, options) {
  524. return new Promise(resolve => service.getPanorama({...options}, (data, status) => {
  525. resolve(data);
  526. }));
  527. }
  528.  
  529. async function processData(panos,mode) {
  530. try {
  531. let processedChunks = 0;
  532. const swal = Swal.fire({
  533. title: 'Downloading',
  534. text: 'Please wait...',
  535. allowOutsideClick: false,
  536. allowEscapeKey: false,
  537. showConfirmButton: false,
  538. didOpen: () => {
  539. Swal.showLoading();
  540. }
  541. });
  542. for (let i = 0; i < panos.length; i += 5) {
  543. let chunk = panos.slice(i, i + 5);
  544. await processChunk(chunk,mode);
  545. processedChunks++;
  546. const progress = Math.min((processedChunks /panos.length) * 100, 100);
  547. Swal.update({
  548. html: `<div>${progress.toFixed(2)}% completed</div>
  549. <div class="swal2-progress">
  550. <div class="swal2-progress-bar" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" style="width: ${progress}%;">
  551. </div>
  552. </div>`
  553. });
  554. }
  555. swal.close();
  556. Swal.fire('Success!','Download completed', 'success');
  557. } catch (error) {
  558. swal.close();
  559. Swal.fire('Error!',"Failed to download due to:"+error.toString(),'error');
  560. }
  561. }
  562. }
  563.  
  564. var downloadButton=document.createElement('button');
  565. downloadButton.textContent='Download Panos'
  566. downloadButton.addEventListener('click', runScript);
  567. downloadButton.style.width='160px'
  568. downloadButton.style.position = 'fixed';
  569. downloadButton.style.right = '150px';
  570. downloadButton.style.bottom = '15px';
  571. downloadButton.style.borderRadius = "18px";
  572. downloadButton.style.padding = "5px 10px";
  573. downloadButton.style.border = "none";
  574. downloadButton.style.color = "white";
  575. downloadButton.style.cursor = "pointer";
  576. downloadButton.style.backgroundColor = "#4CAF50";
  577. document.body.appendChild(downloadButton);
  578.  
  579. })();