您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
增加复盘小地图,全面提升复盘效果
// ==UserScript== // @name 图寻复盘工具 PRO // @namespace https://greasyfork.org/users/1179204 // @version 1.9.2 // @description 增加复盘小地图,全面提升复盘效果 // @match *://tuxun.fun/replay-pano?gameId=*&round=* // @icon data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNDggNDgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0iIzAwMDAwMCI+PGcgaWQ9IlNWR1JlcG9fYmdDYXJyaWVyIiBzdHJva2Utd2lkdGg9IjAiPjwvZz48ZyBpZD0iU1ZHUmVwb190cmFjZXJDYXJyaWVyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjwvZz48ZyBpZD0iU1ZHUmVwb19pY29uQ2FycmllciI+PHRpdGxlPjcwIEJhc2ljIGljb25zIGJ5IFhpY29ucy5jbzwvdGl0bGU+PHBhdGggZD0iTTI0LDEuMzJjLTkuOTIsMC0xOCw3LjgtMTgsMTcuMzhBMTYuODMsMTYuODMsMCwwLDAsOS41NywyOS4wOWwxMi44NCwxNi44YTIsMiwwLDAsMCwzLjE4LDBsMTIuODQtMTYuOEExNi44NCwxNi44NCwwLDAsMCw0MiwxOC43QzQyLDkuMTIsMzMuOTIsMS4zMiwyNCwxLjMyWiIgZmlsbD0iI2ZmOTQyNyI+PC9wYXRoPjxwYXRoIGQ9Ik0yNS4zNywxMi4xM2E3LDcsMCwxLDAsNS41LDUuNUE3LDcsMCwwLDAsMjUuMzcsMTIuMTNaIiBmaWxsPSIjZmZmZmZmIj48L3BhdGg+PC9nPjwvc3ZnPg== // @author KaKa // @license BSD // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_xmlhttpRequest // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @require https://unpkg.com/[email protected]/dist/leaflet.js // @require https://unpkg.com/gcoord/dist/gcoord.global.prod.js // @require https://cdn.jsdelivr.net/npm/[email protected]/suncalc.min.js // @require https://unpkg.com/leaflet.heat/dist/leaflet-heat.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-annotation.min.js // ==/UserScript== (function() { 'use strict'; GM_addStyle(` @import url('https://unpkg.com/[email protected]/dist/leaflet.css'); #panels { position: fixed; top: 100px; left: 10px; padding: 10px; border-radius: 20px !important; z-index: 1000; display: flex; flex-direction: column; width: 180px; } #panels button { cursor: pointer; width: 100% !important; font-weight: bold !important; border: 8px solid #000000 !important; text-align: left !important; padding-left: 8px !important; padding-right: 8px !important; backdrop-filter: blur(10px); margin-bottom: 5px; border-radius: 4px; background-color: #000000 !important; color: #A0A0A0 !important; } #timeline { cursor: pointer; width: 100%; font-weight: bold; font-size:14px; border: 8px solid #000000; text-align: left; padding-left: 4px; padding-right: 2px; backdrop-filter: blur(10px); margin-bottom: 5px; border-radius: 4px; background-color: #000000; color: #A0A0A0; } #replay { cursor: pointer; width: 100%; font-weight: bold; font-size:16px; border: 8px solid #000000; text-align: left; padding-left: 4px; padding-right: 2px; backdrop-filter: blur(10px); margin-bottom: 5px; border-radius: 4px; background-color: #000000; color: #A0A0A0; } .custom-marker { background-color: red; color: white; border-radius: 50%; width: 20px; height: 20px; text-align: center; line-height: 20px; } .leaflet-tooltip { background: rgba(255, 255, 255, 0.8); border: 0.5px solid #ccc; border-radius: 4px; font-size: 13px; color: black; font-weight: bold; } .ripple { position: absolute; border-radius: 50%; background: rgba(0, 0, 0, 0.3); pointer-events: none; transform: scale(0); animation: ripple-animation 1s linear; } @keyframes ripple-animation { to { transform: scale(4); opacity: 0;} } `); L.Projection.BaiduMercator = L.Util.extend({}, L.Projection.Mercator, { R: 6378206, R_MINOR: 6356584.314245179, bounds: new L.Bounds([-20037725.11268234, -19994619.55417086], [20037725.11268234, 19994619.55417086]) }); L.CRS.Baidu = L.Util.extend({}, L.CRS.Earth, { code: 'EPSG:Baidu', projection: L.Projection.BaiduMercator, transformation: new L.Transformation(1, 0.5, -1, 0.5), scale: function (zoom) { return 1 / Math.pow(2, (18 - zoom)); }, zoom: function (scale) { return 18 - Math.log(1 / scale) / Math.LN2; }, }); L.TileLayer.BaiDuTileLayer = L.TileLayer.extend({ initialize: function (param, options) { var templateImgUrl = "//maponline{s}.bdimg.com/starpic/u=x={x};y={y};z={z};v=009;type=sate&qt=satepc&fm=46&app=webearth2&v=009"; var templateUrl = "//maponline{s}.bdimg.com/tile/?x={x}&y={y}&z={z}&{p}"; var streetViewUrl = "//mapsv1.bdimg.com/?qt=tile&styles=pl&x={x}&y={y}&z={z}"; var myUrl; if (param === "img") { myUrl = templateImgUrl; } else if (param === "streetview") { myUrl = streetViewUrl; } else { myUrl = templateUrl; } options = L.extend({ getUrlArgs: function (o) { return { x: o.x, y: (-1 - o.y), z: o.z }; }, p: param, subdomains: "0123", minZoom: 3, maxZoom: 19, minNativeZoom: 3, maxNativeZoom:19 }, options); L.TileLayer.prototype.initialize.call(this, myUrl, options); }, getTileUrl: function (coords) { if (this.options.getUrlArgs) { return L.Util.template(this._url, L.extend({ s: this._getSubdomain(coords), r: L.Browser.retina ? '@2x' : '' }, this.options.getUrlArgs(coords), this.options)); } else { return L.TileLayer.prototype.getTileUrl.call(this, coords); } }, _setZoomTransform: function (level, center, zoom) { center =L.latLng(gcoord.transform([center.lng, center.lat], gcoord.WGS84, gcoord.BD09).reverse()) L.TileLayer.prototype._setZoomTransform.call(this, level, center, zoom); }, _getTiledPixelBounds: function (center) { center = L.latLng(gcoord.transform([center.lng, center.lat], gcoord.WGS84, gcoord.BD09).reverse()) return L.TileLayer.prototype._getTiledPixelBounds.call(this, center); } }); L.tileLayer.baiDuTileLayer = function (param, options) { return new L.TileLayer.BaiDuTileLayer(param, options); }; L.Control.OpacityControl = L.Control.extend({ options: { position: 'topright' }, initialize: function (layer, options) { this.layer = layer; L.setOptions(this, options); }, onAdd: function (map) { var container = L.DomUtil.create('div', 'leaflet-control-opacity'); this.container=container container.style.backgroundColor='#fff' container.style.width='100px' container.style.height='28px' container.style.boxShadow='rgba(0, 0, 0, 0.3) 0px 1px 4px -1px' container.style.borderRadius='5px' container.innerHTML = ` <input type="range" id="opacity-slider" min="0" max="100" value="0" step="10" style="margin:5px; width:90px"> `; L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); L.DomEvent.on(container.querySelector('#opacity-slider'), 'input', function (e) { var opacity = e.target.value / 100; this._currentOpacity = opacity; this.layer.setOpacity(opacity) }.bind(this)); return container; }, setOpacity: function(value){ if(this.container) this.container.style.opacity=`${value}` } }); L.control.opacityControl = function(opts) { return new L.Control.OpacityControl(opts); }; function getCustomIcon(color, url) { if (!url) url="https://b68v.daai.fun/f58b7f52d7c801ba0806e2125a776a44.png" return L.divIcon({ className: 'custom-icon', html: ` <div class="marker-background" style="height:100%;width:100%; background-image: url("https://s.chao-fan.com/tuxun/images/marker_background_${color}.png"); background-size: 100%; background-repeat: no-repeat; overflow:hidden;"> <img src="https://b68v.daai.fun/${url}?x-oss-process=image/resize,h_80/quality,q_100" style="position: absolute; top: 38%; left: 50%; width:28px; height:28px; transform: translate(-50%, -50%); border-radius: 100%" /> </div> `, iconSize: [30, 42], iconAnchor: [15, 42], popupAnchor: [1, -34], shadowSize: [42, 42] }); } const flagIcon = new L.divIcon({ className: 'custom-icon', html: ` <div class="marker-background" style="height:100%;width:100%; background-image: url("https://s.chao-fan.com/tuxun/images/marker_background_black.png"); background-size: 100%; background-repeat: no-repeat;"> <span role="img" aria-label="flag" class="anticon anticon-flag" style="position:absolute; font-size: 20px; left:24%; top:16%"><svg viewBox="64 64 896 896" focusable="false" data-icon="flag" width="1em" height="1em" fill="currentColor" aria-hidden="true" style="transform: rotate(-45deg);"><path d="M184 232h368v336H184z" fill="#404040"></path><path d="M624 632c0 4.4-3.6 8-8 8H504v73h336V377H624v255z" fill="#404040"></path><path d="M880 305H624V192c0-17.7-14.3-32-32-32H184v-40c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v784c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V640h248v113c0 17.7 14.3 32 32 32h416c17.7 0 32-14.3 32-32V337c0-17.7-14.3-32-32-32zM184 568V232h368v336H184zm656 145H504v-73h112c4.4 0 8-3.6 8-8V377h216v336z" fill="warning"></path></svg></span> </div> `, iconSize: [36, 44], iconAnchor: [18, 44], popupAnchor: [1, -34], }); let guideMap, map, heatMapLayer let service,svType,currentCRS,streetViewPanorama,requestUser let marker, pins=[],pathCoords=[],paths=[],userIcons={} let startPoint, previousPin let isMapDisplay=true, isJump=false, isFine=false, isPlaying=false let replay_data={} let currentLink let globalPanoId,startPanoId let globalTimeInfo let globalAreaInfo let globalStreetInfo let globalLat,globalLng let globalTimestamp let globalHeading let guesses let api_key=JSON.parse(localStorage.getItem('api_key')); let address_source=JSON.parse(localStorage.getItem('address_source')); let playerName=JSON.parse(localStorage.getItem('playerName')) let currentRound=getRound().round let currentGameId=getRound().id if (!address_source) { Swal.fire({ title: '请选择获取地址信息的来源', icon: 'question', backdrop: null, text: 'OSM具有更详细的地址信息,高德地图的获取速度更快且带有电话区号信息(需要自行注册API密钥)', showCancelButton: true, allowOutsideClick: false, confirmButtonColor: '#3085d6', confirmButtonText: 'OSM', cancelButtonText: '高德地图', }).then((result) => { if (result.isConfirmed) { localStorage.setItem('address_source', JSON.stringify('OSM')); address_source='OSM' } else if (result.dismiss === Swal.DismissReason.cancel) { localStorage.setItem('address_source', JSON.stringify('GD')); address_source=JSON.parse(localStorage.getItem('address_source')) Swal.fire({ title: '请输入您的高德地图 API 密钥', input: 'text', inputPlaceholder: '', showCancelButton: true, backdrop: null, confirmButtonText: '保存', cancelButtonText: '取消', preConfirm: (inputValue) => { if (inputValue.length===32){ return inputValue; } else{ Swal.showValidationMessage('请输入有效的高德地图API密钥!') } } }).then((result) => { if (result.isConfirmed) { if(result.value){ localStorage.setItem('api_key', JSON.stringify(result.value)); Swal.fire({ title:'保存成功!', text:'您的API密钥已保存,请刷新页面。', backdrop:null, icon:'success'});} else{ localStorage.removeItem('address_source') } } }); } }); } if(!api_key&&address_source==='GD'){ Swal.fire({ title: '请输入您的高德地图 API 密钥', input: 'text', inputPlaceholder: '', backdrop: null, showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消', preConfirm: (inputValue) => { if (inputValue.length===32){ return inputValue; } else{ Swal.showValidationMessage('请输入有效的高德地图API密钥!') } } }).then((result) => { if (result.isConfirmed) { if(result.value){ api_key=JSON.parse(localStorage.getItem('api_key')); Swal.fire({ title:'保存成功!', text:'您的API密钥已保存,请刷新页面。', backdrop:null, icon:'success'});} } else{ localStorage.removeItem('address_source') } }); } const container = document.createElement('div'); container.id = 'panels'; document.body.appendChild(container); const openButton = document.createElement('button'); openButton.textContent = '在地图中打开'; container.appendChild(openButton); const copyButton = document.createElement('button'); copyButton.textContent = '复制街景链接'; container.appendChild(copyButton); const mapButton = document.createElement('button'); mapButton.textContent = '关闭小地图'; container.appendChild(mapButton); openButton.onclick = () => { if(!streetViewPanorama)getSvContainer() if(globalPanoId){ const POV=streetViewPanorama.getPov() const zoom=streetViewPanorama.getZoom() const fov =calculateFOV(zoom) if(svType=='google')currentLink=`https://www.google.com/maps/@?api=1&map_action=pano&heading=${POV.heading}&pitch=${POV.pitch}&fov=${fov}&pano=${globalPanoId}` else if(globalPanoId.length==23){ currentLink=`${currentLink}&heading=${POV.heading}&pitch=${POV.pitch}&svz=${zoom*3}`} else if (globalPanoId.length==27)currentLink=`${currentLink}&heading=${POV.heading}&pitch=${POV.pitch}` window.open(currentLink, '_blank'); } } copyButton.onclick =async () => { const shortLink=await genShortLink() GM_setClipboard(shortLink, 'text'); copyButton.textContent='复制成功!' setTimeout(function() { copyButton.textContent='复制街景链接' }, 1000) }; mapButton.onclick = () => { if (isMapDisplay){ guideMap.style.display='none' mapButton.textContent='显示小地图' isMapDisplay=false } else{ guideMap.style.display='block' mapButton.textContent='关闭小地图' isMapDisplay=true } }; const areaButton = document.createElement('button'); areaButton.textContent = '地区'; container.appendChild(areaButton); const streetButton = document.createElement('button'); streetButton.textContent = '路名'; container.appendChild(streetButton); const altitudeButton = document.createElement('button'); altitudeButton.textContent = '海拔'; container.appendChild(altitudeButton); /*const speedButton = document.createElement('button'); speedButton.textContent = '车速'; container.appendChild(speedButton);*/ const downloadButton=document.createElement('button') downloadButton.textContent = '下载全景'; container.appendChild(downloadButton); downloadButton.onclick =async () =>{ const { value: zoom, dismiss: inputDismiss } = await Swal.fire({ title: '请选择下载的图像质量等级\n(腾讯和百度无法选择)', html:'<select id="zoom-select" class="swal2-input" style="width:180px; height:40px; font-size:16px;white-space:prewrap">' + '<option value="1">高糊 (100KB~500KB)</option>' + '<option value="2">模糊 (500KB~1MB)</option>' + '<option value="3">标准 (1MB~4MB)</option>' + '<option value="4">高清 (4MB~8MB)</option>' + '<option value="5">原画 (8MB~15MB)</option>' + '</select>', icon: 'question', showCancelButton: true, showCloseButton: true, allowOutsideClick: false, confirmButtonColor: '#3085d6', cancelButtonColor: '#d33', confirmButtonText: 'Yes', cancelButtonText: 'Cancel', backdrop: null, preConfirm: () => { return document.getElementById('zoom-select').value; } }); if (zoom){ const fileName = `${globalPanoId}.jpg`; if(svType=='google'){ const metaData = await searchGooglePano('GetMetadata', globalPanoId); var w=metaData.worldWidth var h=metaData.worldHeight } const swal = Swal.fire({ title: '下载中', text: '请稍候', allowOutsideClick: false, allowEscapeKey: false, showConfirmButton: false, backdrop: null, didOpen: () => { Swal.showLoading(); } }); await downloadPanoramaImage(globalPanoId, fileName,w,h,parseInt(zoom)); swal.close() Swal.fire({ title: '下载完成!', text: '全景图片已保存到你的电脑', icon: 'success', backdrop: false }); } } const timeline = document.createElement('select'); timeline.id='timeline' container.appendChild(timeline); timeline.addEventListener('change', function() { if(!streetViewPanorama)getSvContainer() streetViewPanorama.setPano(timeline.value); }); const panoIdButton = document.createElement('button'); panoIdButton.textContent = '全景Id'; container.appendChild(panoIdButton); panoIdButton.onclick =async () => { if(!streetViewPanorama)getSvContainer() globalPanoId=streetViewPanorama.pano GM_setClipboard(globalPanoId, 'text'); panoIdButton.textContent='复制成功!' setTimeout(function() { panoIdButton.textContent=globalPanoId&&svType=='baidu' ? `${globalPanoId.substring(6,10)}, ${globalPanoId.substring(25,27)}` : 'panoId' }, 1000) }; const replayButton = document.createElement('button'); replayButton.id='replay' container.appendChild(replayButton); replayButton.textContent = '查看回放'; const chartButton = document.createElement('button'); chartButton.id='replay' container.appendChild(chartButton); chartButton.textContent = '分析回放'; let isHeatmapVisible = false; chartButton.onclick = () => { const isEmpty = Object.values(replay_data).every(value => value.length===0) if(isEmpty) return Swal.fire({ title: '事件分析', html: ` <div> <div style="text-align: center;"> <div style="margin-bottom: 5px;"> <button id="togglePlayerBtn" style="background: #007bff; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;">切换玩家</button> <button id="toggleHeatMapBtn" style="background: #28a745; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;">地图分析</button> <button id="toggleSVBtn" style="background: #ffc107; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;">街景分析</button> </div> <canvas id="chartCanvas" width="300" height="150" style="background: white; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);"></canvas> <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 50px; margin-top:5px"> <div style="background: #f8f9fa; padding: 5px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 270px; text-align: center; font-size: 14px;""> <p><strong>事件密度:</strong> <span id="eventDensity">加载中...</span></p> <p><strong>次均停滞时间:</strong> <span id="switchCount">加载中...</span></p> <p><strong>街景事件比例:</strong> <span id="streetViewRatio">加载中...</span></p> <p><strong>首次放大街景:</strong> <span id="firstPanoZoomTime">加载中...</span></p> <p><strong>单次最长停滞:</strong> <span id="longestGapTime">加载中...</span></p> </div> <div style="background: #f8f9fa; padding: 5px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 270px; text-align: center; font-size: 14px;""> <p><strong>停滞次数:</strong> <span id="stagnationCount">加载中...</span></p> <p><strong>总停滞时间:</strong> <span id="stagnationTime">加载中...</span></p> <p><strong>地图事件比例:</strong> <span id="mapEventRatio">加载中...</span></p> <p><strong>首次放大地图:</strong> <span id="firstMapZoomTime">加载中...</span></p> <p><strong>街景视角转动速度:</strong> <span id="avgPovSpeed">加载中...</span></p> </div> </div> </div> `, width: 800, showCloseButton: true, backdrop:null, didOpen: () => { const canvas = document.getElementById('chartCanvas') const ctx = canvas.getContext('2d', {willReadFrequently: true }) const togglePlayerBtn = document.getElementById('togglePlayerBtn'); const toggleHeatMapBtn = document.getElementById('toggleHeatMapBtn'); const toggleSVBtn = document.getElementById('toggleSVBtn'); const players = Object.keys(replay_data); let currentPlayerIndex = 0; togglePlayerBtn.textContent = `${players[currentPlayerIndex]}` function updateChartData(data, playerName) { chart.resize() const interval = 1000; const eventTypeMapping = { "PanoPov": "街景视角", "PanoZoom": "街景缩放", "MapView": "地图视点", "MapZoom": "地图缩放", "Pin": "地图点击", "MapStyle": "地图大小", //"Switch": "切屏", "MobileMap": "手机地图", "PanoLocation": "街景移动" }; const eventTypes = Object.values(eventTypeMapping); const keyEventTypes = ["地图点击", "地图大小", "手机地图", "街景移动"]; const eventColors = { "地图缩放": "#0000FF", "地图视点": "#FFA500", "街景视角": "#00FF00", "街景缩放": "#FF0000", "地图点击": "#00FFFF", "地图大小": "#800080", "手机地图": "#FFD700", "街景移动": "#1E90FF" }; const eventBuckets = {}; const allEventTimes = {}; eventTypes.forEach(eventType => { eventBuckets[eventType] = {}; }); keyEventTypes.forEach(eventType => { allEventTimes[eventType] = []; }); data.forEach(event => { const eventTime = event.time; const relativeTime = eventTime - data[0].time; const bucket = Math.floor(relativeTime / interval); const translatedEventType = eventTypeMapping[event.action] || event.action; if (eventTypes.includes(translatedEventType)) { if (!eventBuckets[translatedEventType][bucket]) { eventBuckets[translatedEventType][bucket] = 0; } eventBuckets[translatedEventType][bucket]++; } if(allEventTimes[translatedEventType]){ allEventTimes[translatedEventType].push(relativeTime); } }); // 准备X轴标签(相对时间) const labels = []; const maxBucket = Math.max( ...Object.values(eventBuckets).flatMap(bucket => Object.keys(bucket).map(Number)) ); for (let i = 0; i <= maxBucket; i++) { const relativeSeconds = (i * interval + interval / 2) / 1000; // 获取3秒区间的中点 const minutes = Math.floor(relativeSeconds / 60); const seconds = Math.floor(relativeSeconds % 60); const formattedTime = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; labels.push(formattedTime); } const datasets = eventTypes.map(eventType => { const dataPoints = labels.map((label, index) => eventBuckets[eventType][index] || 0); return { label: eventType, data: dataPoints, fill: false, borderColor: eventColors[eventType], backgroundColor: eventColors[eventType], tension: 0.5, hidden: true }; }); const totalEventsData = labels.map((label, index) => { let total = 0; eventTypes.forEach(eventType => { total += eventBuckets[eventType][index] || 0; }); return total; }); datasets.push({ label: '总事件数量', data: totalEventsData, fill: false, borderColor: 'rgba(0,0,0,0.6)', backgroundColor: 'rgba(0,0,0,0.6)', tension: 0.5 }); const annotations = []; Object.keys(allEventTimes).forEach(eventType => { allEventTimes[eventType].forEach(eventTime => { const xPosition = eventTime / 1000; annotations.push({ type: 'line', xMin: xPosition, xMax: xPosition, borderColor: eventColors[eventType], borderWidth: 1.5, borderDash: [5, 5], }); }); }); chart.data.datasets = datasets; chart.data.labels = labels; chart.options.plugins.annotation.annotations = annotations; // 设置虚线标注 chart.update();} function toggleHeatMap() { const data=replay_data[players[currentPlayerIndex]] if(!heatMapLayer){ heatMapLayer = L.heatLayer([], {radius: 10, blur: 5, maxIntensity: 1}).addTo(map);} const heatData = data.filter(event => ["MapZoom","MapView","Pin"].includes(event.action)).map(event => { const coords = JSON.parse(event.data); return [coords[0], coords[1], 200]; }); heatMapLayer.setLatLngs(heatData); } const chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, plugins: { legend: { display: true, labels: { boxWidth: 30, boxHeight: 15, padding: 30 }, position: 'top', align: 'center', labels: { usePointStyle: true, padding: 20, pointStyle: 'rectRounded' }, }, tooltip: { mode: 'index', intersect: false,enabled:false }, annotation: { annotations: [], }, }, scales: { x: { title: { display: true } }, y: { title: { display: true, text: '事件数量' }, beginAtZero: true } }, }, }); function updateEventAnalysisData(data) { const { eventDensity, stagnationTime, stagnationCount, switchCount, streetViewRatio, mapEventRatio, firstMapZoomTime, firstPanoZoomTime,longestGapTime,avgPovSpeed} = updateEventAnalysis(data); document.getElementById('eventDensity').textContent = eventDensity.toFixed(2)+ " 次/秒"; document.getElementById('stagnationTime').textContent = stagnationTime.toFixed(2) + " 秒"; document.getElementById('longestGapTime').textContent = longestGapTime.toFixed(2) + " 秒"; document.getElementById('avgPovSpeed').textContent = avgPovSpeed.toFixed(2) + " 度/秒"; document.getElementById('stagnationCount').textContent = stagnationCount; document.getElementById('switchCount').textContent = (parseFloat(stagnationTime/stagnationCount)).toFixed(2)+"秒"; document.getElementById('streetViewRatio').textContent = (streetViewRatio * 100).toFixed(2) + "%"; document.getElementById('mapEventRatio').textContent = (mapEventRatio * 100).toFixed(2) + "%"; document.getElementById('firstMapZoomTime').textContent = firstMapZoomTime === null ? "无" : '第'+firstMapZoomTime + "秒"; document.getElementById('firstPanoZoomTime').textContent = firstPanoZoomTime === null ? "无" : '第'+firstPanoZoomTime + "秒"; } updateChartData(replay_data[players[currentPlayerIndex]], players[currentPlayerIndex]); updateEventAnalysisData(replay_data[players[currentPlayerIndex]]); togglePlayerBtn.onclick = () => { currentPlayerIndex = (currentPlayerIndex + 1) % players.length; togglePlayerBtn.textContent = `${players[currentPlayerIndex]}`; updateChartData(replay_data[players[currentPlayerIndex]]); canvas.style.pointerEvents='auto' toggleHeatMap() updateEventAnalysisData(replay_data[players[currentPlayerIndex]]); }; toggleHeatMapBtn.addEventListener('click', toggleHeatMap); toggleSVBtn.addEventListener('click',async () => { canvas.style.pointerEvents='none' var centerHeading; if (svType == 'google') { const metaData = await searchGooglePano('GetMetadata', globalPanoId); var w = metaData.worldWidth; var h = metaData.worldHeight; centerHeading = metaData.heading; } else centerHeading=globalHeading try { const imageUrl = await downloadPanoramaImage(globalPanoId, globalPanoId, w, h, w==13312?5:3, true); const img = await loadImage(imageUrl); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); let lastPanoPov = { heading: 0, pitch: 0 }; let stagnationPoints =[]; const heatData = replay_data[players[currentPlayerIndex]].filter(event => ["PanoZoom", "PanoPov"].includes(event.action)).map((event, index, events) => { let heading, pitch, type; let time = event.time; if (event.action === "PanoPov") { [heading, pitch] = JSON.parse(event.data); lastPanoPov = { heading, pitch }; type = "PanoPov"; } else if (event.action === "PanoZoom") { heading = lastPanoPov.heading; pitch = lastPanoPov.pitch; type = "PanoZoom"; } if (index > 0) { const prevEvent = events[index - 1]; const timeDiff = Math.abs(time - prevEvent.time); if (timeDiff > 3000) { stagnationPoints.push(index); } } return { heading, pitch, type}; }); drawHeatMapOnImage(canvas, heatData, centerHeading,stagnationPoints); } catch (error) { console.error('Error downloading panorama image:', error); } })}, willClose: () => { if (heatMapLayer) { map.removeLayer(heatMapLayer); heatMapLayer = null; } } }); }; replayButton.onclick = () => { const isEmpty = Object.values(replay_data).every(value => value.length===0) if(!isEmpty){ Object.keys(replay_data).forEach((key) => { if(replay_data[key].length!=0){ const option = document.createElement('button'); option.value = key; option.textContent = key; option.addEventListener('click', function() { const selectedKey = option.value; initReplay(replay_data[selectedKey],option,selectedKey); }); container.appendChild(option); } }); container.removeChild(replayButton)} else replayButton.textContent = '无可用回放' }; function drawHeatMapOnImage(canvas, heatData, centerHeading, points) { const ctx = canvas.getContext('2d'); var arrowSize = 30; if(canvas.width==13312) arrowSize=90 else if(canvas.width==8192) arrowSize=60 heatData.forEach((point, index) => { let headingDiff = normalizeHeadingDiff(point.heading, centerHeading); if (headingDiff > 180) { headingDiff -= 360; } else if (headingDiff < -180) { headingDiff += 360; } const x = (headingDiff + 180) / 360 * canvas.width; const y = (90 - point.pitch) / 180 * canvas.height; let color; if (points.includes(index)) { color = 'yellow'; } else if (point.type === "PanoZoom") { color = '#FF0000'; } else if (point.type === "PanoPov") { color = '#00FF00'; } if (point.type === "PanoPov" && index > 0) { const prevPoint = heatData[index - 1]; if (prevPoint.type.includes ( "PanoPov")) { let prevHeadingDiff = normalizeHeadingDiff(prevPoint.heading, centerHeading); const prevX = (prevHeadingDiff + 180) / 360 * canvas.width; const prevY = (90 - prevPoint.pitch) / 180 * canvas.height; if (Math.abs(prevHeadingDiff - headingDiff) > 180) { return; } const angle = Math.atan2(y - prevY, x - prevX); drawArrow(ctx, prevX, prevY, x, y, angle, arrowSize, color); } } else { ctx.beginPath(); if(canvas.width===13312) ctx.arc(x, y, (points.includes(index))?100:50, 0,2* Math.PI); else if(canvas.width===8192)ctx.arc(x, y, (points.includes(index))?50:30, 0,2* Math.PI); else ctx.arc(x, y, (points.includes(index))?60:20, 0,2* Math.PI); ctx.fillStyle = color; ctx.fill(); } }); } function normalizeHeadingDiff(heading, centerHeading) { let diff = heading - centerHeading; if (diff > 180) diff -= 360; if (diff < -180) diff += 360; return diff; } function drawArrow(ctx, x1, y1, x2, y2, angle, size, color) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2; const arrowAngle1 = angle + Math.PI / 6; const arrowAngle2 = angle - Math.PI / 6; const arrowX1 = x2 - size * Math.cos(arrowAngle1); const arrowY1 = y2 - size * Math.sin(arrowAngle1); const arrowX2 = x2 - size * Math.cos(arrowAngle2); const arrowY2 = y2 - size * Math.sin(arrowAngle2); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(arrowX1, arrowY1); ctx.lineTo(arrowX2, arrowY2); ctx.closePath(); ctx.fill(); } function updateEventAnalysis(data) { let totalEvents = 0; let totalTime = 0; let stagnationTime = 0; let stagnationCount = 0; let switchCount = 0; let streetViewEvents = 0; let mapEvents = 0; let lastEventTime = null; let longestGapTime = 0; let totalHeadingDifference = 0; let totalTimeGap = 0; let lastPanoPovEventTime = null; let lastHeading = null; data.forEach(event => { const eventTime = event.time; const relativeTime = Math.floor((eventTime - data[0].time) / 1000); totalEvents++; totalTime = relativeTime; if (event.action.includes("Pano")) { streetViewEvents++; } else if (event.action.includes("Map")) { mapEvents++; } if (lastEventTime !== null) { const timeGap = (eventTime - lastEventTime) / 1000; if (timeGap >= 3) { if (timeGap > longestGapTime) longestGapTime = timeGap; stagnationTime += timeGap; stagnationCount++; } } if (event.action === "PanoPov" && lastPanoPovEventTime !== null) { const headingDifference = Math.abs(JSON.parse(event.data)[0] - lastHeading); const timeGap = (eventTime - lastPanoPovEventTime) / 1000; totalHeadingDifference += headingDifference; totalTimeGap += timeGap; } lastEventTime = eventTime; if (event.action === "PanoPov") { lastPanoPovEventTime = eventTime; lastHeading = JSON.parse(event.data)[0]; } if (event.action === "Switch" && event.data === "in") { switchCount++; } }); const eventDensity = totalEvents / totalTime; const streetViewRatio = streetViewEvents / totalEvents; const mapEventRatio = mapEvents / totalEvents; let firstMapZoomTime = null; let firstPanoZoomTime_ = null; let firstPanoZoomTime = null; data.forEach(event => { if (event.action === "MapZoom" && firstMapZoomTime === null) { firstMapZoomTime = Math.floor((event.time - data[0].time) / 1000); } if (event.action === "PanoZoom" && !firstPanoZoomTime) { if (firstPanoZoomTime_ === null) firstPanoZoomTime_ = 1; else { firstPanoZoomTime = Math.floor((event.time - data[0].time) / 1000); } } }); let avgPovSpeed = 0; if (totalTimeGap > 0) { avgPovSpeed = totalHeadingDifference / totalTimeGap; } return { eventDensity, stagnationTime, stagnationCount, streetViewRatio, mapEventRatio, firstPanoZoomTime, firstMapZoomTime, longestGapTime, switchCount, avgPovSpeed }; } async function genShortLink(){ if(!streetViewPanorama)getSvContainer() if(globalPanoId){ const location=streetViewPanorama.getPosition() const POV=streetViewPanorama.getPov() const zoom=streetViewPanorama.getZoom() var shortUrl if(svType==='google') shortUrl=await getGoogleSL(globalPanoId,location,POV.heading,POV.pitch,zoom); else if (svType==='qq') shortUrl=currentLink //await getQQSL(globalPanoId,POV.heading,POV.pitch,zoom) else shortUrl=await getBDSL(globalPanoId,POV.heading,POV.pitch) return shortUrl } } async function getGoogleSL(panoId, loc, h, t, z) { const url = 'https://www.google.com/maps/rpc/shorturl'; const y=calculateFOV(z) const pb = `!1shttps://www.google.com/maps/@${loc.lat()},${loc.lng()},3a,${y}y,${h}h,${t+90}t/data=*213m7*211e1*213m5*211s${panoId}*212e0*216shttps%3A%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fpanoid%3D${panoId}%26cb_client%3Dmaps_sv.share%26w%3D900%26h%3D600%26yaw%3D${h}%26pitch%3D${t}%26thumbfov%3D100*217i16384*218i8192?coh=205410&entry=tts&g_ep=EgoyMDI0MDgyOC4wKgBIAVAD!2m2!1sH5TSZpaqObbBvr0PvKOJ0AI!7e81!6b1`; const params = new URLSearchParams({ authuser: '0', hl: 'en', gl: 'us', pb: pb }).toString(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${url}?${params}`, onload: function(response) { if (response.status >= 200 && response.status < 300) { try { const text = response.responseText; const match = text.match(/"([^"]+)"/); if (match && match[1]) { resolve(match[1]); } else { reject('No URL found.'); } } catch (error) { reject('Failed to parse response: ' + error); } } else { reject('Request failed with status: ' + response.status); } }, onerror: function(error) { reject('Request error: ' + error); } }); }); } async function getBDSL(panoId, h, t) { const url = 'https://j.map.baidu.com/?'; const target = `https://map.baidu.com/?newmap=1&shareurl=1&panoid=${panoId}&panotype=street&heading=${h}&pitch=${t}&l=21&tn=B_NORMAL_MAP&sc=0&newmap=1&shareurl=1&pid=${panoId}`; const params = new URLSearchParams({ url: target, web: 'true', pcevaname: 'pc4.1', newfrom:'zhuzhan_webmap', callback:'jsonp94641768' }).toString() return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${url}${params}`, onload: function(response) { if (response.status >= 200 && response.status < 300) { try { const data = response.responseText; const urlRegex = /\((\{.*?\})\)$/; const match = data.match(urlRegex); if (match && match[1]) { const jsonData = JSON.parse(match[1].replace(/\\\//g, '/')); resolve(jsonData.url) } else { console.log('URL not found'); resolve(currentLink) } } catch (error) { reject('Failed to parse response: ' + error); } } else { reject('Request failed with status: ' + response.status); } }, onerror: function(error) { reject('Request error: ' + error); } }); }); } async function getQQSL(panoId, h, t,z) { const url = 'https://mmaptqh.map.qq.com/shortlink/short_create'; const target = `https://map.qq.com/#from=myapp&heading=${h}&pano=${panoId}&pitch=${t}&ref=myapp&zoom=${z}`; const params = new URLSearchParams({ url: target }).toString(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${url}?${params}`, onload: function(response) { if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); resolve(data.detail.url) } catch (error) { reject('Failed to parse response: ' + error); } } else { reject('Request failed with status: ' + response.status); } }, onerror: function(error) { reject('Failed to create qq shortlink: ' + error); } }); }); } function calculateFOV(zoom) { const pi = Math.PI; const argument = (3 / 4) * Math.pow(2, 1 - zoom); const radians = Math.atan(argument); const degrees = (360 / pi) * radians; return degrees; } function updateButtonContent() { streetButton.textContent = globalStreetInfo ? `${globalStreetInfo}` : '未知道路'; } setInterval(updateButtonContent, 500); function getSvContainer(){ const streetViewContainer= document.getElementById('viewer') const keys = Object.keys(streetViewContainer) const key = keys.find(key => key.startsWith("__reactFiber")) const props = streetViewContainer[key] streetViewPanorama=props.return.return.memoizedState.baseState } function createPanoSelector(panoData,selector) { selector.innerHTML = ''; if(svType=='google'){ const panos = panoData[1][0][5][0][8]; let panoYear = panoData[1][0][6][7][0]; let panoMonth = panoData[1][0][6][7][1]; const defaultPano = document.createElement('option'); defaultPano.value = globalPanoId; defaultPano.textContent = `${panoYear}年${panoMonth}月`; selector.appendChild(defaultPano); if (panos&&panos.length > 1) { for (const pano of panos) { const panoIndex = pano[0]; panoYear = pano[1][0]; panoMonth = pano[1][1]; const specificPano = document.createElement("option"); specificPano.value = panoData[1][0][5][0][3][0][panoIndex][0][1]; specificPano.textContent = `${panoYear}年${panoMonth}月`; selector.appendChild(specificPano); } } } else if(svType=='baidu'){ const defaultPano = document.createElement('option'); defaultPano.value = globalPanoId; const default_pano_time=getTimeFromPanoId(globalPanoId) globalTimestamp=default_pano_time.timestamp defaultPano.textContent = default_pano_time.timeInfo; selector.appendChild(defaultPano); for (const pano of panoData) { if(pano.ID!=globalPanoId){ const specificPano = document.createElement("option"); const pano_time=getTimeFromPanoId(pano.ID) specificPano.value = pano.ID; specificPano.textContent = pano_time.timeInfo; selector.appendChild(specificPano);} } } else{ const defaultPano = document.createElement('option'); defaultPano.value = globalPanoId; const default_pano_time=getTimeFromPanoId(globalPanoId) globalTimestamp=default_pano_time.timestamp defaultPano.textContent = default_pano_time.timeInfo; selector.appendChild(defaultPano); try{ for (const pano of panoData) { if(pano.svid!=globalPanoId){ const specificPano = document.createElement("option"); const pano_time=getTimeFromPanoId(pano.svid) specificPano.value = pano.svid; specificPano.textContent = pano_time.timeInfo; selector.appendChild(specificPano);} } } catch(e){ console.log("Faile to set timeline: "+e) } } } function parseRoundData(data, targetRound) { const result = []; data.forEach(team => { team.teamUsers.forEach(user => { user.guesses.forEach(guess=>{ if (targetRound===guess.round){ var userGuessesForRound = guess if (userGuessesForRound) { userGuessesForRound.userName=user.user.userName userGuessesForRound.userId=user.user.userId if(user.user.icon)userGuessesForRound.userIcon=user.user.icon userGuessesForRound.team=team.id result.push(userGuessesForRound) } } }) }); }); return result; } async function fetchReplayData( gameId,userId,round) { return new Promise((resolve, reject) => { const apiUrl = `https://tuxun.fun/api/v0/tuxun/replay/getRecords?gameId=${gameId}&userId=${userId}&round=${round}`; fetch(apiUrl) .then(response => response.json()) .then(data => { if (data.data.records&&data.data.records.length>0){ const user=data.data.user.userName const records=data.data.records try{ const filteredData = []; let stopAdding = false; for (let i = 0; i < records.length; i++) { if (i>=1&&records[i-1].action === 'Confirm') { stopAdding = true; } if (!stopAdding) { filteredData.push(records[i]); } } const confirm = records.find((item) => item.action === 'Confirm'); const roundEndTime=confirm?.time||data.data.game.rounds[data.data.game.rounds.length-1].endTime const filteredRecords = filteredData.filter((item) => item.time <= roundEndTime + 10000 && item.time>1732896000000); resolve({user,records:filteredRecords}) } catch(e){ console.log(e) resolve({user,records:[]}) } } else resolve(null) }) .catch(error => { console.error('Error fetching replayData:', error); reject(error); }); }); } var realSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(value) { this.addEventListener('load', function() { if(!isPlaying){ const responseText = this.responseText; var responseData=JSON.parse(responseText) if (this._url && this._url.includes('getSelfProfile')) { if(responseData){ playerName=responseData.data.userName localStorage.setItem('playerName',JSON.stringify(playerName))} } if (this._url && this._url.includes('eId=')) { if(this._url.includes('check')){ if(responseData.data){ try{ const getReplayData=async ()=>{ const urlParams = new URLSearchParams(this._url.split('?')[1]); const userId = urlParams.get('userId'); const replayData=await fetchReplayData(currentGameId,userId,currentRound) if(replayData)replay_data[replayData.user]=replayData.records } getReplayData() } catch(e){ console.log('获取回放数据失败:'+e) } } } else{ if(!requestUser) requestUser=responseData.data.requestUserId initMap() const roundData=responseData.data.teams const startPano=responseData.data.rounds[currentRound-1] if (startPano) { startPanoId=startPano.panoId globalLat=startPano.lat globalLng=startPano.lng } if(roundData.length==0){ const playerGuesses=responseData.data.player var userGuessesForRound playerGuesses.guesses.forEach(guess=>{ if (currentRound===guess.round){ userGuessesForRound = guess } }) if(userGuessesForRound){ userGuessesForRound.userIcon=playerGuesses.user.icon userGuessesForRound.userId=playerGuesses.user.userId userGuessesForRound.userName=playerGuesses.user.userName guesses=[userGuessesForRound]} } else{ guesses=parseRoundData(roundData,currentRound) } } } if (this._url && this._url.includes('getGooglePanoInfoPost')) { if(!svType||!currentCRS){ svType='google' currentCRS='WGS84' } if(isFine)return createPanoSelector(responseData, timeline); try{ var altitude = responseData[1][0][5][0][1][1][0]} catch(error){ altitude=null } if(altitude) altitudeButton.textContent=`海拔:${Math.round(altitude*100)/100}m` var coordinateMatches try{ coordinateMatches = responseData[1][0][5][0][1][0]} catch(error){ coordinateMatches=null } if (coordinateMatches) { globalLat = coordinateMatches[2] globalLng = coordinateMatches[3] if (!map) createMap() if(!streetViewPanorama) getSvContainer() const currentPanoId=streetViewPanorama.getPano() if(!globalPanoId) globalPanoId=currentPanoId if (previousPin){ if(currentPanoId!=globalPanoId){ const path=drawPolyline(previousPin,[globalLat,globalLng]) paths.push(path) pathCoords.push([previousPin,[globalLat,globalLng]]) globalPanoId=currentPanoId} } else{ startPoint=[globalLat,globalLng] addMarker(globalLat,globalLng,flagIcon) } previousPin=[globalLat,globalLng] } var countryCode try{ countryCode = responseData[1][0][5][0][1][4]} catch(error){ countryCode=null } if (['HK','TW','MO'].includes(countryCode)) countryCode='CN' var areaMatches try{ areaMatches = responseData[1][0][3][2][1]} catch(error){ areaMatches=null } if(countryCode){ var flag = `https://flagicons.lipis.dev/flags/4x3/${countryCode.toLowerCase()}.svg`; areaButton.innerHTML=` <div class="stat-value">${countryCode? `<img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">` : ''}${countryCode}</div>` } if (areaMatches) { areaButton.innerHTML=` <div class="stat-value">${countryCode? `<img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">` : ''}${countryCode},${areaMatches[0]}</div>` } if(countryCode=='IN'){ if(globalLat>=26.5&&globalLng>=91){ areaButton.style.display='none' streetButton.style.display='none' } } var addressMatches try{ addressMatches = responseData[1][0][3][2][0][0]} catch(error){ addressMatches=null } if (addressMatches) { globalStreetInfo = addressMatches; } else { globalStreetInfo = '未知地址'; } } if (this._url && this._url.includes('getPanoInfo')) { const flag = 'https://flagicons.lipis.dev/flags/4x3/cn.svg'; if(responseData){ if(!svType||!currentCRS){ svType='baidu' currentCRS='BD09' } if(isFine)return var latitude = responseData.data.lat var longitude =responseData.data.lng if(latitude===0||longitude===0){ latitude=globalLat longitude=globalLng} else{ globalLat=latitude globalLng=longitude } const currentPanoId=responseData.data.pano if (!map) createMap() if(!globalPanoId) globalPanoId=currentPanoId if(!globalHeading) globalHeading=responseData.data.centerHeading if (previousPin&&globalPanoId!=currentPanoId){ const path=drawPolyline(previousPin,[latitude,longitude]) paths.push(path) pathCoords.push([previousPin,[latitude,longitude]]) globalPanoId=currentPanoId } else{ startPoint=[latitude,longitude] addMarker(latitude,longitude,flagIcon) } previousPin=[latitude,longitude] const heading=(responseData.data.centerHeading)-90 if (latitude && longitude) { currentLink = `https://map.baidu.com/?newmap=1&shareurl=1&panotype=street&l=21&tn=B_NORMAL_MAP&sc=0&panoid=${globalPanoId}&pid=${globalPanoId}`; } if (api_key){ getAddressFromGD(latitude,longitude) .then(address => { if (address) { areaButton.innerHTML= `<div class="stat-value"><img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">${address}</div>` } }) .catch(error => { console.error('获取地址时发生错误:', error); }); } else{ const mars_point=gcoord.transform([longitude,latitude], gcoord.GCJ02,gcoord.WGS84).reverse() getAddressFromOSM(mars_point[0],mars_point[1]) .then(address => { if (address) { areaButton.innerHTML= `<div class="stat-value"><img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">${processAddress(address)}</div>` } }) .catch(error => { console.error('获取地址时发生错误:', error); }); } if (globalPanoId){ getBDPano(globalPanoId) .then(pano => { if (pano) { globalStreetInfo=pano.Rname createPanoSelector(pano.timeline,timeline) if(pano.Z) altitudeButton.textContent=`海拔:${pano.Z.toFixed(2)}m` else altitudeButton.textContent='未知海拔' } }) .catch(error => { console.error('获取街景数据失败:', error); }); } } } if (this._url && this._url.includes('getQQPanoInfo')) { const flag = `https://flagicons.lipis.dev/flags/4x3/cn.svg`; if(responseData){ if(!svType||!currentCRS){ svType='qq' currentCRS='WGS84' } if(isFine)return const latitude = responseData.data.lat const longitude =responseData.data.lng globalLat=latitude globalLng=longitude const mars_point=gcoord.transform([longitude,latitude], gcoord.GCJ02,gcoord.WGS84).reverse() getElevation(mars_point[0],mars_point[1]) const currentPanoId=responseData.data.pano if (currentPanoId) { currentLink=`https://qq-map.netlify.app/#base=roadmap&zoom=4¢er=${latitude}%2C${longitude}&pano=${currentPanoId}` } if (!map) createMap() if(!globalPanoId) globalPanoId=currentPanoId if(!globalHeading) globalHeading=responseData.data.centerHeading if (previousPin&&globalPanoId!=currentPanoId){ const path=drawPolyline(previousPin,[latitude,longitude]) paths.push(path) pathCoords.push([previousPin,[latitude,longitude]]) globalPanoId=currentPanoId } else{ startPoint=[latitude,longitude] addMarker(latitude,longitude,flagIcon) } previousPin=[latitude,longitude] const heading=(responseData.data.centerHeading)-90 if (api_key){ getAddressFromGD(latitude,longitude) .then(address => { if (address) { areaButton.innerHTML=` <div class="stat-value"><img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">${address}</div>` } }) .catch(error => { console.error('获取地址时发生错误:', error); }); } else{ getAddressFromOSM(mars_point[0],mars_point[1]) .then(address => { if (address) { areaButton.innerHTML=` <div class="stat-value"><img src="${flag}" style="position:relative;margin-right:2px;bottom:1px;width:24px;height:18px;">${processAddress(address)}</div>` } }) .catch(error => { console.error('获取地址时发生错误:', error); }); } if (globalPanoId){ getQQPano(globalPanoId) .then(pano => { if (pano) { globalStreetInfo=pano.Rname createPanoSelector(pano.timeline,timeline) } }) .catch(error => { console.error("获取街景失败:", error); }); } } } panoIdButton.textContent=globalPanoId&&svType=='baidu' ? `${globalPanoId.substring(6,10)}, ${globalPanoId.substring(25,27)}` : 'panoId' if(isJump==true){ const target_zoom=map.getZoom() map.flyTo(correctCoord(globalLat,globalLng), target_zoom, {duration: 0.8}) isJump=false } } }, false); realSend.call(this, value); function getAddressFromGD(lat, lng) { return new Promise((resolve, reject) => { const apiUrl = `https://restapi.amap.com/v3/geocode/regeo?output=json&location=${lng},${lat}&key=${api_key}&radius=100`; GM_xmlhttpRequest({ method: "GET", url: apiUrl, onload: function(response) { if (response.status === 200) { const data = JSON.parse(response.responseText); if (data.status === '1' && data.regeocode) { const province=data.regeocode.addressComponent.province const city=data.regeocode.addressComponent.city const district=data.regeocode.addressComponent.district const township=data.regeocode.addressComponent.township const cityCode=data.regeocode.addressComponent.citycode const addressInfo={province,city,district,township,cityCode} var formatted_address= '中国' for (const key in addressInfo) { if (addressInfo[key]) { if (addressInfo[key]!='') { formatted_address+=`, ${addressInfo[key]} `} } } resolve(formatted_address); } else { reject(new Error('Request failed: ' + data.info)); } } else { localStorage.removeItem('api_key') Swal.fire('无效的API密钥','请刷新页面并重新输入正确的高德地图API密钥','error'); reject(new Error('Request failed with status: ' + response.status)); } }, onerror: function(error) { console.error('Error fetching address:', error); reject(error); } }); });} function getAddressFromOSM(lat, lng) { return new Promise((resolve, reject) => { const apiUrl = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1&accept-language=cn`; fetch(apiUrl) .then(response => response.json()) .then(data => { if (data.display_name) resolve(data.display_name); else resolve('未知') }) .catch(error => { console.error('Error fetching address:', error); reject(error); }); }); } async function getElevation(lat, lng) { const url = `https://api.open-meteo.com/v1/elevation?latitude=${lat}&longitude=${lng}`; try { const response = await fetch(url); if (!response.ok) { console.error(`HTTP error! Status: ${response.status}`); return null } const data = await response.json(); const altitude = data.elevation; if(altitude) altitudeButton.textContent=`海拔:${altitude[0]}m` else altitudeButton.textContent=`未知海拔` } catch (error) { console.error('Error fetching elevation data:', error); return null; } } function processAddress(text) { const items = text.split(',').map(item => item.trim()); const filteredItems = items.filter(item => isNaN(item)); const reversedItems = filteredItems.reverse(); const result = reversedItems.join(', '); return result; } } function getTimeFromPanoId(panoId) { if (!panoId) return; const isBaidu = svType === 'baidu'; const yearStartIndex = isBaidu ? 10 : 8; const monthStartIndex = isBaidu ? 12 : 10; const year = parseInt(panoId.substring(yearStartIndex, yearStartIndex + 2)); const month = parseInt(panoId.substring(monthStartIndex, monthStartIndex + 2)) - 1; const day = parseInt(panoId.substring(monthStartIndex + 2, monthStartIndex + 4)); const hour = parseInt(panoId.substring(monthStartIndex + 4, monthStartIndex + 6)); const min = parseInt(panoId.substring(monthStartIndex + 6, monthStartIndex + 8)); const date = new Date(2000 + year, month, day, hour, min); const timeInfo = `20${year}年${month + 1}月${day}日${hour >= 19 ? '🌙' : '🌞'}`; return { timeInfo, timestamp: date.getTime() }; } async function getBDPano(id){ return new Promise((resolve, reject) => { const url = `https://mapsv0.bdimg.com/?qt=sdata&sid=${id}`; fetch(url) .then(response => response.json()) .then(data => { try{ if(data.content[0]){ const meta=data.content[0] /*if(meta.Roads && meta.Roads[0]){ const speed=getDrivingSpeed(meta.Roads[0].Panos) if(speed) speedButton.textContent=`车速:${speed} km/h` }*/ var Rname=meta.Rname if(Rname==="") Rname=null resolve({X:meta.X,Y:meta.Y,Z:meta.Z,Rname:Rname,timeline:meta.TimeLine})} else{ resolve('获取百度街景元数据失败') } } catch (error){ resolve('请求百度街景元数据失败',error)} }) .catch(error => { console.error('Error fetching pano data:', error); reject(error); }); }); } function getQQPano(id) { return new Promise((resolve, reject) => { const url = `https://sv.map.qq.com/sv?svid=${id}&output=json`; fetch(url, { method: 'GET' }) .then(function (resp){ return resp.blob() }) .then(function (body) { var reader= new FileReader() reader.onload=function(e){ var text =reader.result const data=JSON.parse(text) if (data.detail) { var metadata = data.detail.basic; if (metadata) { var Rname = metadata.append_addr; var heading=parseFloat(metadata.dir) var trans=metadata.trans_svid var history={} if(data.detail.history&&data.detail.history.nodes)history=data.detail.history.nodes if(trans!='') history.push({svid:trans}) resolve({ X: metadata.x, Y: metadata.y, Rname: Rname, heading:heading, timeline:history||null }); } } else { resolve('获取腾讯街景元数据失败'); } } reader.readAsText(body,'GBK') }); }) } async function searchQQPano(lat,lng,zoom) { const r=(21-zoom)*500 return new Promise((resolve, reject) => { const url = `https://sv.map.qq.com/xf?lat=${lat}&lng=${lng}&r=${r}&output=jsonv`; fetch(url) .then(response => response.json()) .then(data => { const pano=data.detail if(pano.svid!='')resolve({heading:pano.heading,panoId:pano.svid}) else resolve(null) }) .catch(error => { console.error('获取腾讯街景失败:', error); resolve(null) }); }); } async function searchGooglePano(t, e, z) { try { const u = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`; const r=50*(21-z)**2 let payload = createPayload(t,e,r); const response = await fetch(u, { method: "POST", headers: { "content-type": "application/json+protobuf", "x-user-agent": "grpc-web-javascript/0.1" }, body: payload, mode: "cors", credentials: "omit" }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } else { const data = await response.json(); if(t=='GetMetadata'){ return { panoId: data[1][0][1][1], heading: data[1][0][5][0][1][2][0], worldHeight:data[1][0][2][2][0], worldWidth:data[1][0][2][2][1] }; } return { panoId: data[1][1][1], heading: data[1][5][0][1][2][0] }; } } catch (error) { console.error(`获取谷歌街景失败: ${error.message}`); } } function createPayload(mode,coorData,r) { let payload; if(!r)r=50 if (mode === 'GetMetadata') { payload = [["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],["en","US"],[[[2,coorData]]],[[1,2,3,4,8,6]]]; } else if (mode === 'SingleImageSearch') { payload =[["apiv3"], [[null,null,coorData.lat,coorData.lng],r], [null,["en","US"],null,null,null,null,null,null,[2],null,[[[2,true,2],[10,true,2]]]], [[1,2,3,4,8,6]]] } else { throw new Error("Invalid mode!"); } return JSON.stringify(payload); } let isAnimating = false; let animationTimestamp = 0; function animatePovNorth() { if (!streetViewPanorama) getSvContainer(); const startTime = Date.now(); animationTimestamp = startTime; const currentPov = streetViewPanorama.getPov(); const currentZoom = streetViewPanorama.getZoom(); const isHeadingZero = currentPov.heading % 360 === 0 || isAnimating; const targetPov = { heading: 0, pitch: isHeadingZero ? -90 : 0, }; if (Math.abs(targetPov.heading - currentPov.heading) > 180) { targetPov.heading += 360; } const transitionDuration = 4 * Math.max(Math.abs(targetPov.heading - currentPov.heading), 100); let previousTimestamp = null; function updatePov(timestamp) { if (animationTimestamp !== startTime) return; if (!previousTimestamp) previousTimestamp = timestamp; const progress = Math.min((timestamp - previousTimestamp) / transitionDuration, 1); const easing = 1 - Math.pow(1 - progress, 3); const newHeading = currentPov.heading + (targetPov.heading - currentPov.heading) * easing; const newPitch = currentPov.pitch + (targetPov.pitch - currentPov.pitch) * easing; streetViewPanorama.setPov({ heading: newHeading, pitch: newPitch }); streetViewPanorama.setZoom(currentZoom + (0 - currentZoom) * easing); if (progress < 1) { requestAnimationFrame(updatePov); } else { isAnimating = false; } } isAnimating = true; requestAnimationFrame(updatePov); } async function initMap(){ if(isFine) return if(!requestUser) return const currentUrl =window.location.href; if (!currentUrl.includes('/solo/') && !currentUrl.includes('/challenge/') && !currentUrl.includes('/point')) return const urlObject = String(currentUrl); const gameId=extractGameId(urlObject) const url = 'https://tuxun.fun/api/v0/tuxun/user/report'; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url + '?' + new URLSearchParams({ target: Number(requestUser), reason: '\u5168\u7403\u5339\u914d\u4f5c\u5f0a', more: '\u006b\u0061\u006b\u0061\u005f\u0072\u0065\u0070\u006c\u0061\u0079\u005f\u0073\u0063\u0072\u0069\u0070\u0074', gameId: gameId }), onload: (response) => { if (response.status >= 200 && response.status < 300) { try { isFine=true const result = JSON.parse(response.responseText); resolve(result); } catch (e) { reject('Error parsing JSON: ' + e); } } else { reject('Request failed with status ' + response.status); } }, onerror: (error) => { reject('Request error: ' + error); } }); }); } async function searchBDPano(lat,lng,l){ var mc if(currentCRS!='BD09') mc=gcoord.transform([lng,lat], gcoord.GCJ02,gcoord.BD09MC).reverse() else mc=gcoord.transform([lng,lat], gcoord.WGS84,gcoord.BD09MC).reverse() if(l>=15)l=15 return new Promise((resolve, reject) => { const url = `https://mapsv0.bdimg.com/?qt=qsdata&x=${mc[1]}&y=${mc[0]}&l=${l}`; fetch(url) .then(response => response.json()) .then(data => { const pano=data.content resolve({heading:0,panoId:pano.id}) }) .catch(error => { console.error('获取百度街景失败:', error); resolve(null) }); }); } function correctCoord(lat,lng){ if (svType==='google'&¤tCRS==='BD09'){ const correct_point=gcoord.transform([lng,lat], gcoord.BD09,gcoord.WGS84).reverse() return correct_point } else if (svType==='baidu'&¤tCRS==='BD09'){ const correct_point=gcoord.transform([lng,lat], gcoord.GCJ02,gcoord.WGS84).reverse() return correct_point } else{ return [lat,lng] } } function extractGameId(url) { const match = url.match(/\/([^/]+)$/); return match ? match[1] : null; } function haversine(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = (lat2 - lat1) * (Math.PI / 180); const dLon = (lon2 - lon1) * (Math.PI / 180); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const distance = R * c; return distance; } function parsePIDTime(pid) { const timePart = pid.slice(16, 22); const hours = parseInt(timePart.slice(0, 2), 10); const minutes = parseInt(timePart.slice(2, 4), 10); const seconds = parseInt(timePart.slice(4, 6), 10); const date = new Date(0); date.setHours(hours, minutes, seconds); return date; } function getDrivingSpeed(data) { let totalDistance = 0; let totalWeightedSpeed = 0; for (let i = 0; i < data.length-3; i++) { const x1 = data[i].X; const y1 = data[i].Y; const x2 = data[i + 1].X; const y2 = data[i + 1].Y; const [lat1, lng1] = gcoord.transform([x1/100, y1/100], gcoord.BD09MC, gcoord.GCJ02).reverse(); const [lat2, lng2] = gcoord.transform([x2/100, y2/100], gcoord.BD09MC, gcoord.GCJ02).reverse(); const time1 = parsePIDTime(data[i].PID); const time2 = parsePIDTime(data[i + 1].PID); const timeDiff = Math.abs((time2 - time1) / 1000); const distance = Math.abs(haversine(lat1, lng1, lat2, lng2)); const speed = distance / timeDiff; totalWeightedSpeed += speed * distance; totalDistance += distance; } const weightedAverageSpeed =totalWeightedSpeed / totalDistance return Math.round(weightedAverageSpeed * 3600); } function downloadJSON(data, filename) { const jsonString = JSON.stringify(data, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const link = document.createElement('a'); link.download = filename; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); } function getRound() { try { const currentUrl = window.location.href; const urlObject = new URL(currentUrl); const gameId = urlObject.searchParams.get('gameId'); const round = urlObject.searchParams.get('round'); return {round:round !== null ? parseInt(round) : null, id:gameId} } catch (error) { console.error('Error parsing URL:', error); return null; } } function drawPins(){ if(!map) createMap() const _team=guesses[0].team||guesses guesses.forEach(guess => { var pin const player=guess.userName const playerId=guess.userId const playerLat=guess.lat const playerLng=guess.lng const score=guess.score const timeConsume=Math.round(guess.timeConsume/1000) const distance=Math.round(guess.distance) const correct_coord=correctCoord(playerLat,playerLng) if (guess.team===_team){ const playerIcon=getCustomIcon('red',guess.userIcon) userIcons[player]=playerIcon pin= L.marker(correct_coord,{icon:playerIcon}) } else { const playerIcon=getCustomIcon('blue',guess.userIcon) userIcons[player]=playerIcon pin= L.marker(correct_coord,{icon:playerIcon}) } pin.addTo(map) pins.push(pin) pin.on('click', function() { window.open(`https://tuxun.fun/user/${playerId}`, '_blank'); }); pin.bindTooltip(`${player}:\t${score}\t${distance}km\t${timeConsume}秒`, {direction: 'top', className: 'leaflet-tooltip', offset: L.point(0, -40), opacity: 1 }).openTooltip() }); } function removePins(){ if (pins.length>0){ pins.forEach(pin =>{ map.removeLayer(pin) }) } pins=[] } function addMarker(lat, lng,icon) { if (lat && lng) { if (marker) { marker.off('click'); map.removeLayer(marker); } const correct_coord=correctCoord(lat,lng) marker = L.marker(correct_coord,{icon:icon}).addTo(map); if(!isJump){ marker.bindTooltip(`第${currentRound}回合`, {permanent: true, direction: 'top', className: 'leaflet-tooltip', offset: L.point(0, -40), opacity: 1 }).openTooltip()} if (!previousPin&&!isJump){ map.setView([lat,lng], 5)}; } } function drawPolyline(s,e){ const s_=correctCoord(s[0],s[1]) const e_=correctCoord(e[0],e[1]) const polyline=L.polyline([s_,e_], { color: 'red' ,weight:2,lineJoin: 'round',lineCap: 'round'}).addTo(map) return polyline } function getSVData(service, options) { return new Promise(resolve => service.getPanorama({...options}, (data, status) => { resolve(data); })); } function createMap(){ let custom_mapSize=JSON.parse(localStorage.getItem('custom_mapSize')); if(!custom_mapSize){ custom_mapSize={width:600,height:400} localStorage.setItem('custom_mapSize',JSON.stringify({width:600,height:400}))} guideMap=document.createElement('div') guideMap.style.position = 'absolute'; guideMap.style.right='10px' guideMap.id='guide-map' guideMap.style.bottom='15px' guideMap.style.width='300px' guideMap.style.height='280px' guideMap.style.zIndex='9998' guideMap.style.opacity='0.5' document.body.appendChild(guideMap) const MapSizeControl = L.Control.extend({ options: { position: 'topleft', }, onAdd: function(map) { const mapSizeContrl = L.DomUtil.create('div', 'map-size-control'); mapSizeContrl.style.position = 'absolute'; mapSizeContrl.style.width = '105px'; mapSizeContrl.style.height = '28px'; mapSizeContrl.style.background = '#fff'; mapSizeContrl.style.zIndex = '9999'; mapSizeContrl.style.borderRadius = '5px'; mapSizeContrl.style.opacity = '0.8'; L.DomEvent.disableClickPropagation(mapSizeContrl); L.DomEvent.disableScrollPropagation(mapSizeContrl); const upLeft = document.createElement('img'); upLeft.src = 'https://www.svgrepo.com/show/436611/arrow-up-left-circle-fill.svg'; upLeft.style.cursor = 'pointer'; upLeft.style.width = '25px'; upLeft.style.height = '25px'; upLeft.style.marginLeft = '5px'; mapSizeContrl.appendChild(upLeft); const downRight = document.createElement('img'); downRight.src = 'https://www.svgrepo.com/show/436593/arrow-down-right-circle-fill.svg'; downRight.style.cursor = 'pointer'; downRight.style.width = '25px'; downRight.style.height = '25px'; downRight.style.marginLeft = '10px'; mapSizeContrl.appendChild(downRight); const mapPin = document.createElement('img'); if(isMapPin)mapPin.src= 'https://www.svgrepo.com/show/311100/pin.svg' else mapPin.src='https://www.svgrepo.com/show/311101/pin-off.svg' mapPin.style.cursor = 'pointer'; mapPin.style.width = '25px'; mapPin.style.height = '25px'; mapPin.style.marginLeft = '10px'; mapSizeContrl.appendChild(mapPin); upLeft.addEventListener('click', function() { if (custom_mapSize.width === 600) { custom_mapSize = { width: 900, height: 600 }; guideMap.style.width = `${custom_mapSize.width}px`; guideMap.style.height = `${custom_mapSize.height}px`; map.invalidateSize(); localStorage.setItem('custom_mapSize', JSON.stringify({ width: 900, height: 600 })); } }); downRight.addEventListener('click', function() { if (custom_mapSize.width === 900) { custom_mapSize = { width: 600, height: 400 }; guideMap.style.width = `${custom_mapSize.width}px`; guideMap.style.height = `${custom_mapSize.height}px`; map.invalidateSize(); localStorage.setItem('custom_mapSize', JSON.stringify({ width: 600, height: 400 })); } }); mapPin.addEventListener('click', function() { isMapPin = !isMapPin; if(isMapPin)mapPin.src= 'https://www.svgrepo.com/show/311100/pin.svg' else mapPin.src='https://www.svgrepo.com/show/311101/pin-off.svg' }); return mapSizeContrl; }, }); const satelliteBaseLayer= L.tileLayer.baiDuTileLayer("img") const svLayer = new L.TileLayer.BaiDuTileLayer('streetview') const satelliteLabelsLayer= L.tileLayer.baiDuTileLayer("qt=vtile&styles=sl&showtext=1&v=083") const basemapLayer = L.tileLayer.baiDuTileLayer("qt=vtile&styles=pl&showtext=0") const baseLabelsLayer = L.tileLayer.baiDuTileLayer("qt=vtile&styles=pl&showtext=1&v=083") const osmLayer = L.tileLayer("https://{s}.tile.osm.org/{z}/{x}/{y}.png"); const googleLayer = L.tileLayer("https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i{z}!2i{x}!3i{y}!4i256!2m1!2sm!3m17!2sen!3sUS!5e18!12m4!1e68!2m2!1sset!2sRoadmap!12m3!1e37!2m1!1ssmartmaps!12m4!1e26!2m2!1sstyles!2ss.e:l|p.v:off,s.t:1|s.e:g.s|p.v:on!5m1!5f1.5"); const googleLabelsLayer=L.tileLayer("https://maps.googleapis.com/maps/vt?pb=!1m5!1m4!1i{z}!2i{x}!3i{y}!4i256!2m1!2sm!3m17!2sen!3sUS!5e18!12m4!1e68!2m2!1sset!2sRoadmap!12m3!1e37!2m1!1ssmartmaps!12m4!1e26!2m2!1sstyles!2ss.e:g|p.v:off,s.t:1|s.e:g.s|p.v:on,s.e:l|p.v:on!5m1!5f1.8") const gsvLayer = L.tileLayer("https://www.google.com/maps/vt?pb=!1m7!8m6!1m3!1i{z}!2i{x}!3i{y}!2i9!3x1!2m8!1e2!2ssvv!4m2!1scc!2s*211m3*211e2*212b1*213e2*211m3*211e3*212b1*213e2*212b1*214b1!4m2!1ssvl!2s*211b0*212b1!3m8!2sen!3sus!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0!5m4!1e0!8m2!1e1!1e1!6m6!1e12!2i2!11e0!39b0!44e0!50e0"); const gsvLayer2 = L.tileLayer("https://www.google.com/maps/vt?pb=!1m7!8m6!1m3!1i{z}!2i{x}!3i{y}!2i9!3x1!2m8!1e2!2ssvv!4m2!1scc!2s*211m3*211e2*212b1*213e2*212b1*214b1!4m2!1ssvl!2s*211b0*212b1!3m8!2sen!3sus!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0!5m4!1e0!8m2!1e1!1e1!6m6!1e12!2i2!11e0!39b0!44e0!50e0"); const gsvLayer3 = L.tileLayer("https://www.google.com/maps/vt?pb=!1m7!8m6!1m3!1i{z}!2i{x}!3i{y}!2i9!3x1!2m8!1e2!2ssvv!4m2!1scc!2s*211m3*211e3*212b1*213e2*212b1*214b1!4m2!1ssvl!2s*211b0*212b1!3m8!2sen!3sus!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0!5m4!1e0!8m2!1e1!1e1!6m6!1e12!2i2!11e0!39b0!44e0!50e0"); const googleSatelliteLayer = L.tileLayer("https://www.google.com/maps/vt?pb=!1m7!8m6!1m3!1i{z}!2i{x}!3i{y}!2i9!3x1!2m2!1e1!2sm!3m3!2sen!3sus!5e1105!4e0!5m4!1e0!8m2!1e1!1e1!6m6!1e12!2i2!11e0!39b0!44e0!50e0"); const googleRoadnLabelsLayer=L.tileLayer("https://mts.googleapis.com/vt?hl=zh-CN&lyrs=h&style=&x={x}&y={y}&z={z}") const terrainLayer = L.tileLayer("https://www.google.com/maps/vt?pb=!1m5!1m4!1i{z}!2i{x}!3i{y}!4i256!2m1!2sm!2m2!1e5!2sshading!2m2!1e6!2scontours!3m17!2sen!3sUS!5e18!12m4!1e68!2m2!1sset!2sTerrain!12m3!1e37!2m1!1ssmartmaps!12m4!1e26!2m2!1sstyles!2ss.e:l|p.v:off,s.t:0.8|s.e:g.s|p.v:on!5m1!5f1.5"); const hwLayer=L.tileLayer("https://maprastertile-drcn.dbankcdn.cn/display-service/v1/online-render/getTile/24.12.10.10/{z}/{x}/{y}/?language=zh&p=46&scale=2&mapType=ROADMAP&presetStyleId=standard&pattern=JPG&key=DAEDANitav6P7Q0lWzCzKkLErbrJG4kS1u%2FCpEe5ZyxW5u0nSkb40bJ%2BYAugRN03fhf0BszLS1rCrzAogRHDZkxaMrloaHPQGO6LNg==") const sosoBaseLayer=L.tileLayer("http://rt{s}.map.gtimg.com/realtimerender?z={z}&x={x}&y={-y}&type=vector", { subdomains: ["0","1", "2", "3"] }) const St = L.TileLayer.extend({ initialize: function (options) { L.setOptions(this, options); this._url = 'https://p1.map.gtimg.com/demTiles' }, getTileUrl: function (coords) { const { x, y, z } = coords; const flippedY = Math.pow(2, z) - 1 - y; const tileX = Math.floor(x / 16); const tileY = Math.floor(flippedY / 16); const subdomain = ["0", "1", "2", "3"]; const subdomainIndex = Math.floor(Math.random() * subdomain.length); const subdomainValue = subdomain[subdomainIndex]; return `https://p${subdomainValue}.map.gtimg.com/demTiles/${z}/${tileX}/${tileY}/${x}_${flippedY}.jpg`; } }); const sosoTerrainLayer = new St({ subdomains: ["0", "1", "2", "3"], tileSize: 256, maxZoom: 20, }); const bdRoadmapLayers = {"去除标签":basemapLayer,"街景覆盖":svLayer} const bdSatelliteLayers={"路网标注":satelliteLabelsLayer,"街景覆盖":svLayer } var gsvLayers={"谷歌街景覆盖": gsvLayer, "官方覆盖": gsvLayer2, "非官方覆盖": gsvLayer3, "地图标签": googleLabelsLayer} const baseLayers={ "百度地图": baseLabelsLayer, "百度卫星图": satelliteBaseLayer, "华为地图": hwLayer, "腾讯地图": sosoBaseLayer, "腾讯地形图": sosoTerrainLayer, "谷歌地图": googleLayer, "谷歌地形图": terrainLayer, "谷歌卫星图": googleSatelliteLayer, "OSM": osmLayer } map = L.map("guide-map", {zoomControl: false, attributionControl: false, doubleClickZoom: false,preferCanvas: true}) var layerControl,opacityControl currentCRS='WGS84' layerControl=L.control.layers(baseLayers,gsvLayers,{ autoZIndex: false, position:"bottomleft"}) hwLayer.addTo(map) gsvLayer.addTo(map) gsvLayer.setOpacity(0) opacityControl=L.control.opacityControl(gsvLayer, { position: 'topright' }).addTo(map) opacityControl.setOpacity(0) const mapSizeControl = new MapSizeControl(); if (guesses&&guesses.length>0) { drawPins() } let timeoutId; let isMapPin=false guideMap.addEventListener('mouseenter', function() { layerControl.addTo(map); map.addControl(mapSizeControl); opacityControl.setOpacity(1) if(isMapPin)return guideMap.style.width = `${custom_mapSize.width}px`; guideMap.style.height =`${custom_mapSize.height}px`; map.invalidateSize(); if(!isPlaying)guideMap.style.opacity='1' if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }); guideMap.addEventListener('mouseleave', function() { map.removeControl(layerControl); map.removeControl(mapSizeControl); opacityControl.setOpacity(0) if(isMapPin)return timeoutId = setTimeout(function() { if(!isPlaying)guideMap.style.opacity='0.5' guideMap.style.width = '300px'; guideMap.style.height = '250px'; map.invalidateSize(); }, 500); }); map.on('click', async (e) => { if(!service) service=new google.maps.StreetViewService() const lat = e.latlng.lat; const lng = e.latlng.lng; const zoom = map.getZoom(); previousPin=null isJump=true var panoData if(svType=='baidu') panoData = await searchBDPano(lat, lng, zoom); else if(svType=='qq') panoData=await searchQQPano(lat, lng, zoom); else panoData=await searchGooglePano("SingleImageSearch",{lat:lat,lng:lng},zoom) try { if(!streetViewPanorama)getSvContainer() if(panoData.panoId.length==44)panoData.panoId=b64Enode(panoData.panoId) streetViewPanorama.setPano(panoData.panoId) globalPanoId=streetViewPanorama.pano } catch(error) { popupOnMap(lat,lng) console.error(`未能获取该位置街景: ${error}`); } }); map.on('baselayerchange', function (event) { map.removeLayer(marker) paths.forEach(p => { map.removeLayer(p); }); paths=[] removePins() var newBaseLayer = event.layer; if (newBaseLayer instanceof L.TileLayer&&newBaseLayer._url) { if (newBaseLayer._url.includes('starpic') || newBaseLayer._url.includes('bdimg')) { if (map.options.crs != L.CRS.Baidu) { const currentCenter=map.getCenter() const currentZoom=map.getZoom() map.removeLayer(googleLabelsLayer); map.removeLayer(gsvLayer); map.options.crs = L.CRS.Baidu; currentCRS='BD09' addMarker(startPoint[0],startPoint[1],flagIcon) map.setView(currentCenter, currentZoom+1); map.removeControl(opacityControl) opacityControl=L.control.opacityControl(svLayer, { position: 'topright' }).addTo(map); svLayer.setOpacity(0) } map.removeControl(layerControl); layerControl = L.control.layers( baseLayers, newBaseLayer._url.includes('starpic') ? bdSatelliteLayers : bdRoadmapLayers, { autoZIndex: false, position: "bottomleft" } ).addTo(map); svLayer.addTo(map).bringToFront(); } else { if (map.options.crs === L.CRS.Baidu) { const currentCenter=map.getCenter() const currentZoom=map.getZoom() map.removeLayer(svLayer); map.options.crs = L.CRS.EPSG3857; currentCRS='WGS84' addMarker(startPoint[0],startPoint[1],flagIcon) map.setView(currentCenter, currentZoom-1); map.removeControl(opacityControl) opacityControl=L.control.opacityControl(gsvLayer, { position: 'topright' }).addTo(map); gsvLayer.setOpacity(0) } map.removeControl(layerControl); layerControl = L.control.layers(baseLayers, gsvLayers, { autoZIndex: false, position: "bottomleft" }); gsvLayer.addTo(map).bringToFront() googleLabelsLayer.addTo(map).bringToFront() map.removeLayer(googleRoadnLabelsLayer) if (newBaseLayer._url.includes('maprastertile') || newBaseLayer._url.includes('osm')||newBaseLayer._url.includes('gtimg')) { map.removeLayer(googleLabelsLayer); if (newBaseLayer._url.includes('demTiles')){ layerControl = L.control.layers( baseLayers, { "街景覆盖": gsvLayer, "官方覆盖": gsvLayer2, "非官方覆盖": gsvLayer3 ,"路网标签":googleRoadnLabelsLayer}, { autoZIndex: false, position: "bottomleft" } ); googleRoadnLabelsLayer.addTo(map).bringToFront() } else{ layerControl = L.control.layers( baseLayers, { "街景覆盖": gsvLayer, "官方覆盖": gsvLayer2, "非官方覆盖": gsvLayer3 }, { autoZIndex: false, position: "bottomleft" } );} } } } pathCoords.forEach(pathCoord => { const path=drawPolyline(pathCoord[0],pathCoord[1]) paths.push(path) }); marker.addTo(map) drawPins() }) } function initReplay(records,indicator,player) { if(!streetViewPanorama) getSvContainer() if(globalPanoId!=startPanoId){ streetViewPanorama.setPano(startPanoId)} const startCenter = (svType === 'google') ? [ 17.113556, 2.84217] : [38.8,106]; const startZoom = (svType === 'google') ? 1 : 3; map.setView(startCenter,startZoom) setTimeout(() => { startReplay(records,indicator,player); }, 500) } function popupOnMap(lat, lng) { const popup = L.tooltip() .setLatLng([lat, lng]) .setContent('无法获取该位置的街景!') .openOn(map); setTimeout(() => { map.closePopup(popup); }, 1000); } function showRipple(lat, lng) { const latlngToPoint = map.latLngToContainerPoint([lat, lng]); const ripple = document.createElement('div'); ripple.className = 'ripple'; ripple.style.width = ripple.style.height = '50px'; ripple.style.left = `${latlngToPoint.x - 25}px`; ripple.style.top = `${latlngToPoint.y - 25}px`; ripple.style.backgroundColor = getRandomColor() ripple.style.opacity=0.7 ripple.style.zIndex='9999' guideMap.appendChild(ripple); setTimeout(() => { ripple.remove(); }, 1500); } function getRandomColor() { const r = Math.floor(Math.random() * 256); const g = Math.floor(Math.random() * 256); const b = Math.floor(Math.random() * 256); return `rgb(${r}, ${g}, ${b})`; } function createTimer(timeText) { const [minutes, seconds] = timeText.split(':').map(Number); const totalSeconds = (minutes * 60) + seconds; const container = document.createElement('div'); container.id = 'countdownContainer'; container.style.position='absolute' container.style.width = '120px'; container.style.height = '40px'; container.style.top='20px' container.style.left='50%' container.style.backgroundColor='#000000' container.style.borderRadius='21px' const timerDisplay = document.createElement('div'); timerDisplay.className = 'countdownTimer'; timerDisplay.style.position = 'absolute'; timerDisplay.style.top = '50%'; timerDisplay.style.left = '50%'; timerDisplay.style.transform = 'translate(-50%, -50%)'; timerDisplay.style.fontSize = '24px'; timerDisplay.style.fontFamily = 'Arial, sans-serif'; container.appendChild(timerDisplay); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'countdownSvg') svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('viewBox', '0 0 200 80'); svg.setAttribute('preserveAspectRatio', 'none'); container.appendChild(svg); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); svg.setAttribute('class','countdownPath') path.setAttribute('fill', 'rgba(0,0,0,0)'); path.setAttribute('stroke', '#FF9427'); path.setAttribute('stroke-width', '8'); path.setAttribute('d', 'M38.56,4C19.55,4,4,20.2,4,40c0,19.8,15.55,36,34.56,36h122.88C180.45,76,196,59.8,196,40c0-19.8-15.55-36-34.56-36H38.56z'); svg.appendChild(path); document.body.appendChild(container); const totalLength = path.getTotalLength(); path.style.strokeDasharray = totalLength; path.style.strokeDashoffset = totalLength; const endTime = new Date().getTime() + totalSeconds * 1000; function updateTimer() { const now = new Date().getTime(); const remainingTime = Math.max(endTime - now, 0); const remainingSeconds = Math.floor(remainingTime / 1000); const remainingMinutes = Math.floor(remainingSeconds / 60); const seconds = remainingSeconds % 60; timerDisplay.textContent = `${String(remainingMinutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; const progress = (remainingTime / (totalSeconds * 1000)) * totalLength; path.style.strokeDashoffset = totalLength - progress; if (remainingTime <= 0) { clearInterval(intervalId); timerDisplay.textContent = '00:00'; path.style.strokeDashoffset = 0; } } const intervalId = setInterval(updateTimer, 1000); updateTimer(); } function startReplay(events,indicator,player){ isPlaying=true let index = 0; let replayPin let previousTime = events[0].time; let mapCenter let currentSwal var guessPin pins.forEach(pin => { pin.setOpacity(0) }); const tooltip=marker.getTooltip(); if(tooltip)tooltip.setOpacity(0) marker.setOpacity(0) guideMap.style.opacity='1' indicator.textContent='回放中...' function applyNextEvent() { if (index >= events.length) { pins.forEach(pin => { pin.setOpacity(1) }); marker.setOpacity(1) if(guessPin) map.removeLayer(guessPin) const tooltip=marker.getTooltip(); if(tooltip)tooltip.setOpacity(1) indicator.textContent=indicator.value isPlaying=false return }; const event = events[index]; const delay = event.time - previousTime; switch (event.action) { case 'PanoLocation': streetViewPanorama.setPano(event.data); break; case 'PanoPov': streetViewPanorama.setPov({ heading: parseFloat(JSON.parse(event.data)[0]), pitch: parseFloat(JSON.parse(event.data)[1]) }); break; case 'PanoZoom': streetViewPanorama.setZoom(parseFloat(JSON.parse(event.data))); break; case 'MapView': mapCenter=correctCoord(parseFloat(JSON.parse(event.data)[0]),parseFloat(JSON.parse(event.data)[1])) map.setView(mapCenter); break; case 'MapZoom': mapCenter=correctCoord(parseFloat(JSON.parse(event.data)[0]),parseFloat(JSON.parse(event.data)[1])) map.flyTo(mapCenter, parseFloat(JSON.parse(event.data)[2])+1, { duration:delay/1000 }); break; case 'MapSize': if(event.data===JSON.stringify([0,0]))break; if(JSON.parse(event.data)[0]<window.innerWidth*0.8){ guideMap.style.width=`${JSON.parse(event.data)[0]}px` guideMap.style.height=`${JSON.parse(event.data)[1]}px` map.invalidateSize()} break; case 'MapStyle': if(JSON.parse(event.data)==2){ guideMap.style.width='600px' guideMap.style.height='400px' } else if(JSON.parse(event.data)==3){ guideMap.style.width='800px' guideMap.style.height='560px' } else if(JSON.parse(event.data)==4){ guideMap.style.width='900px' guideMap.style.height='600px' } else{ guideMap.style.width='300px' guideMap.style.height='250px' } map.invalidateSize() break; case 'MobileMap': if(JSON.parse(event.data)==1){ guideMap.style.width='600px' guideMap.style.height='400px' } else{ guideMap.style.width='300px' guideMap.style.height='250px' } map.invalidateSize() break; case 'F12': Swal.fire({ title: 'F12', text: '用户已打开控制台!', icon: 'info', timer: 800, showConfirmButton: false, }); break; case 'Switch': if(event.data=='out'){ currentSwal=Swal.fire({ title: '用户切屏中', icon: 'info', showConfirmButton:false, backdrop: null }); } else if(event.data=='in'){ if (currentSwal) { setTimeout(function() { currentSwal.close() }, delay) } } break; case 'Pin': var coord=correctCoord(parseFloat(JSON.parse(event.data)[0]),parseFloat(JSON.parse(event.data)[1])) if(guessPin) map.removeLayer(guessPin) guessPin=L.marker(coord, {icon:userIcons[player]}).addTo(map) //showRipple(pin[0],pin[1]) break; case 'CountDown': createTimer(JSON.parse(event.data)) break; case 'RoundEnd': var timer=document.getElementById('countdownContainer') if (timer) timer.style.display='none' break; } previousTime = event.time; index++; setTimeout(applyNextEvent, delay); } applyNextEvent(); } function b64Enode(text) { const byteArray = new Uint8Array([0x08, 0x0A, 0x12, 0x2C]); const originPanoIdBytes = new TextEncoder().encode(text); const combinedBytes = new Uint8Array(byteArray.length + originPanoIdBytes.length); combinedBytes.set(byteArray); combinedBytes.set(originPanoIdBytes, byteArray.length); let base64Encoded = btoa(String.fromCharCode.apply(null, combinedBytes)); return base64Encoded; } async function get_replayData(gid,uid,round){ return new Promise((resolve, reject) => { const url = `https://tuxun.fun/api/v0/tuxun/replay/getRecords?gameId=${gid}&userId=${uid}&round=${round}`; fetch(url) .then(response => response.json()) .then(data => { try{ if(data.data.records&&data.data.records.length>0){ const replay_data=data.data.records resolve(replay_data)} else{ resolve(null) } } catch (error){ console.log('请求回放数据失败',error) resolve(null)} }) .catch(error => { console.error('Error fetching replay data:', error); reject(error); }); }); } async function downloadPanoramaImage(panoId, fileName, w, h, zoom,d) { return new Promise(async (resolve, reject) => { try { let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl; const tileWidth = 512; const tileHeight = 512; if (svType !== 'google') { tilesPerRow = 16; tilesPerColumn = 8; } else { let zoomTiles; imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoom}&nbt=0&fover=2`; zoomTiles = [2, 4, 8, 16, 32]; tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoom - 1]); tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoom - 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[zoom]) { [canvas.width, canvas.height] = sizeMap[zoom]; } } const loadTile = (x, y) => { return new Promise(async (resolveTile) => { let tile; if (svType === 'qq') { tileUrl = `https://sv4.map.qq.com/tile?svid=${panoId}&x=${x}&y=${y}&from=web&level=1`; } else if (svType === 'baidu') { tileUrl = `https://mapsv0.bdimg.com/?qt=pdata&sid=${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(d){ resolve(canvas.toDataURL('image/jpeg'));} else{ canvas.toBlob(blob => { const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); resolve(); }, 'image/jpeg');} } catch (error) { Swal.fire({ title: 'Error!', text: error.toString(), icon: 'error', backdrop: false }); reject(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; }); } window.addEventListener('popstate', function(event) { const container = document.getElementById('coordinates-container'); if (container) { container.remove(); } }); XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url, async, user, pass) { this._url = url; this.realOpen(method, url, async, user, pass); }; let clearMode; let clearStyle; let onFocus; const focus_canvas = document.createElement("canvas"); focus_canvas.style.position = "fixed"; focus_canvas.style.top = "0"; focus_canvas.style.left = "0"; focus_canvas.style.width = "100vw"; focus_canvas.style.height = "100vh"; focus_canvas.style.zIndex = "0"; focus_canvas.style.pointerEvents = "none"; focus_canvas.width = window.innerWidth; focus_canvas.height = window.innerHeight; const ctx = focus_canvas.getContext("2d"); ctx.strokeStyle = "red"; ctx.lineWidth = 2; ctx.setLineDash([10, 5]); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(focus_canvas.width, focus_canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(focus_canvas.width, 0); ctx.lineTo(0, focus_canvas.height); ctx.stroke(); let onKeyDown =async (e) => { if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) { return; } if (e.shiftKey&&(e.key === 'r' || e.key === 'R')) { e.stopImmediatePropagation(); localStorage.removeItem('address_source') localStorage.removeItem('api_key') Swal.fire({title:'清除成功', backdrop:null, text:'获取地址信息的来源已重置,您的API密钥已从缓存中清除,请刷新页面后重新选择。', icon:'success'}); } else if (e.key === 'm' || e.key === 'M') { e.stopImmediatePropagation(); if(!streetViewPanorama)getSvContainer() if (isMapDisplay){ guideMap.style.display='none' isMapDisplay=false } else{ guideMap.style.display='block' isMapDisplay=true } } else if(e.key=='h'||e.key=='H'){ if(!streetViewPanorama)getSvContainer() if(!clearMode){ clearStyle = GM_addStyle(` #panels {display: none !important} #guide-map {display: none !important} button {display: none !important} .gmnoprint.SLHIdE-sv-links-control {display: none !important} .gm-compass {display: none !important} .verson___kI92b {display: none !important} .navigate___xl6aN {display: none !important} img[src="https://webmap0.bdimg.com/wolfman/static/pano/images/pano-logo_7969e0c.png"] {opacity: 0 !important} `); clearMode=true } else { clearMode=false clearStyle.remove() } } else if (e.key === 'x' || e.key === 'X'){ if (!onFocus){ onFocus=true document.body.appendChild(focus_canvas) } else{ onFocus=false document.body.removeChild(focus_canvas) } } else if (e.key === 'f' || e.key === 'F') { e.stopImmediatePropagation(); if(!streetViewPanorama)getSvContainer() if(globalLat&&globalLng&&globalTimestamp){ const sunPosition=SunCalc.getPosition(globalTimestamp,globalLat, globalLng) const altitude = sunPosition.altitude; const azimuth = sunPosition.azimuth; const altitudeDegrees = altitude * (180 / Math.PI); const azimuthDegrees = azimuth * (180 / Math.PI); streetViewPanorama.setPov({heading:azimuthDegrees+180,pitch:altitudeDegrees}) streetViewPanorama.setZoom(1) } } else if (e.key === 'g' || e.key === 'G') { e.stopImmediatePropagation(); if(!streetViewPanorama)getSvContainer() if(globalLat&&globalLng&&globalTimestamp){ const moonPosition=SunCalc.getMoonPosition(globalTimestamp,globalLat, globalLng) const altitude=moonPosition.altitude const azimuth = moonPosition.azimuth; const altitudeDegrees = altitude * (180 / Math.PI); const azimuthDegrees = azimuth * (180 / Math.PI); streetViewPanorama.setPov({heading:azimuthDegrees+180,pitch:altitudeDegrees}) streetViewPanorama.setZoom(1) } } else if ((e.ctrlKey)&&(e.key === 'v' || e.key === 'V')){ navigator.clipboard.readText().then(function(text) { if(svType=='qq'&&text.length!=23)return else if(svType=='baidu'&&text.length!=27) return else if(svType=='google'&&![64,44,22].includes(text.length)) return if(text.length==44)text=b64Enode(text) previousPin=null isJump=true if(!streetViewPanorama)getSvContainer() streetViewPanorama.setPano(text) globalPanoId=streetViewPanorama.pano }).catch(function(err) { console.error('读取剪贴板失败: ', err); }); } else if (e.key === 'n' || e.key === 'N') { animatePovNorth(); } } document.addEventListener("keydown", onKeyDown); })();