Geoguessr Replay Analyzer

analyze geoguessr replay data

  1. // ==UserScript==
  2. // @name Geoguessr Replay Analyzer
  3. // @namespace https://gf.qytechs.cn/users/1179204
  4. // @version 0.0.2
  5. // @description analyze geoguessr replay data
  6. // @author KaKa
  7. // @match https://www.geoguessr.com/duels/*
  8. // @match https://www.geoguessr.com/team-duels/*
  9. // @run-at document-end
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com
  11. // @license BSD
  12. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  13. // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js
  14. // @require https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.1.0/dist/chartjs-plugin-annotation.min.js
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. let replayData,playersList,selectedPlayer,rounds,currentGameId
  19.  
  20. async function getReplayer(gameId,round){
  21. let replayControls = document.querySelector('[class^="replay_main__"]');
  22. const keys = Object.keys(replayControls)
  23. const key = keys.find(key => key.startsWith("__reactProps"))
  24. const props = replayControls[key]
  25. playersList=props.children[4].props.players
  26. rounds=Math.max(...playersList.map(player => player.guesses?.length || 0));
  27. selectedPlayer=props.children[4].props.selectedPlayer
  28. currentGameId=gameId
  29. replayData=await fetchReplayData(currentGameId,selectedPlayer.playerId,round)
  30. }
  31.  
  32. async function fetchReplayData( gameId,userId,round) {
  33. const url = `https://game-server.geoguessr.com/api/replays/${userId}/${gameId}/${round}`;
  34. try {
  35. const response = await fetch(url,{method: "GET",credentials: "include"});
  36.  
  37. if (!response.ok) {
  38. console.error(`HTTP error! Status: ${response.status}`);
  39. return null
  40. }
  41. return await response.json();
  42.  
  43. } catch (error) {
  44. console.error('Error fetching replay data:', error);
  45. return null;
  46. }
  47. }
  48.  
  49. function parseUrl() {
  50. const url = window.location.href;
  51. const urlObj = new URL(url);
  52.  
  53. const pathSegments = urlObj.pathname.split('/');
  54. const gameId = pathSegments.length > 2 ? pathSegments[2] : null;
  55.  
  56. const round = urlObj.searchParams.get("round");
  57. return { gameId, round };
  58. }
  59.  
  60. async function downloadPanoramaImage(panoId, fileName, w, h, zoom,d) {
  61. return new Promise(async (resolve, reject) => {
  62. try {
  63. let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl;
  64. const tileWidth = 512;
  65. const tileHeight = 512;
  66.  
  67. let zoomTiles;
  68. imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoom}&nbt=0&fover=2`;
  69. zoomTiles = [2, 4, 8, 16, 32];
  70. tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoom - 1]);
  71. tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoom - 1] / 2);
  72.  
  73. const canvasWidth = tilesPerRow * tileWidth;
  74. const canvasHeight = tilesPerColumn * tileHeight;
  75. canvas = document.createElement('canvas');
  76. ctx = canvas.getContext('2d');
  77. canvas.width = canvasWidth;
  78. canvas.height = canvasHeight;
  79.  
  80. const loadTile = (x, y) => {
  81. return new Promise(async (resolveTile) => {
  82. let tile;
  83.  
  84. tileUrl = `${imageUrl}&x=${x}&y=${y}`;
  85.  
  86.  
  87. try {
  88. tile = await loadImage(tileUrl);
  89. ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight);
  90. resolveTile();
  91. } catch (error) {
  92. console.error(`Error loading tile at ${x},${y}:`, error);
  93. resolveTile();
  94. }
  95. });
  96. };
  97.  
  98. let tilePromises = [];
  99. for (let y = 0; y < tilesPerColumn; y++) {
  100. for (let x = 0; x < tilesPerRow; x++) {
  101. tilePromises.push(loadTile(x, y));
  102. }
  103. }
  104.  
  105. await Promise.all(tilePromises);
  106. if(d){
  107. resolve(canvas.toDataURL('image/jpeg'));}
  108. else{
  109. canvas.toBlob(blob => {
  110. const url = window.URL.createObjectURL(blob);
  111. const a = document.createElement('a');
  112. a.href = url;
  113. a.download = fileName;
  114. document.body.appendChild(a);
  115. a.click();
  116. document.body.removeChild(a);
  117. window.URL.revokeObjectURL(url);
  118. resolve();
  119. }, 'image/jpeg');}
  120. } catch (error) {
  121. Swal.fire({
  122. title: 'Error!',
  123. text: error.toString(),
  124. icon: 'error',
  125. backdrop: false
  126. });
  127. reject(error);
  128. }
  129. });
  130. }
  131.  
  132. async function loadImage(url) {
  133. return new Promise((resolve, reject) => {
  134. const img = new Image();
  135. img.crossOrigin = 'Anonymous';
  136. img.onload = () => resolve(img);
  137. img.onerror = () => reject(new Error(`Failed to load image from ${url}`));
  138. img.src = url;
  139. });
  140. }
  141.  
  142. async function searchGooglePano(t, e, z) {
  143. try {
  144. const u = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`;
  145. const r=50*(21-z)**2
  146. let payload = createPayload(t,e,r);
  147.  
  148. const response = await fetch(u, {
  149. method: "POST",
  150. headers: {
  151. "content-type": "application/json+protobuf",
  152. "x-user-agent": "grpc-web-javascript/0.1"
  153. },
  154. body: payload,
  155. mode: "cors",
  156. credentials: "omit"
  157. });
  158.  
  159. if (!response.ok) {
  160. throw new Error(`HTTP error! status: ${response.status}`);
  161. } else {
  162. const data = await response.json();
  163. if(t=='GetMetadata'){
  164. return {
  165. panoId: data[1][0][1][1],
  166. heading: data[1][0][5][0][1][2][0],
  167. worldHeight:data[1][0][2][2][0],
  168. worldWidth:data[1][0][2][2][1]
  169. };
  170. }
  171. return {
  172. panoId: data[1][1][1],
  173. heading: data[1][5][0][1][2][0]
  174. };
  175. }
  176. } catch (error) {
  177. console.error(`Failed to fetch metadata: ${error.message}`);
  178. }
  179. }
  180.  
  181. function createPayload(mode,coorData,r) {
  182. let payload;
  183. if(!r)r=50
  184. if (mode === 'GetMetadata') {
  185. payload = [["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],["en","US"],[[[2,coorData]]],[[1,2,3,4,8,6]]];
  186. }
  187. else if (mode === 'SingleImageSearch') {
  188. payload =[["apiv3"],
  189. [[null,null,coorData.lat,coorData.lng],r],
  190. [null,["en","US"],null,null,null,null,null,null,[2],null,[[[2,true,2],[10,true,2]]]], [[1,2,3,4,8,6]]]
  191. } else {
  192. throw new Error("Invalid mode!");
  193. }
  194. return JSON.stringify(payload);
  195. }
  196.  
  197. function analyze(round){
  198.  
  199. Swal.fire({
  200. title: 'Replay Analysis',
  201. html: `
  202. <div style="text-align: center; font-family: sans-serif;">
  203. <div style="margin-bottom: 10px;">
  204. <select id="roundSelect" style="background: #db173e; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;"></select>
  205. <select id="playerSelect" style="background: #007bff; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;"></select>
  206. <button id="toggleEventBtn" style="background: #28a745; color: white; font-size: 14px; padding: 8px 15px; border: 2px solid grey; border-radius: 6px; cursor: pointer; margin: 5px;">Event Analysis</button>
  207. <button id="toggleSVBtn" style="background: #ffc107; color: black; font-size: 14px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer;">StreetView Analysis</button>
  208. </div>
  209. <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>
  210. <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 20px; margin-top: 5px;">
  211. <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;">
  212. <p><strong>Event Density:</strong> <span id="eventDensity">Loading...</span></p>
  213. <p><strong>Avgerage Gap Time:</strong> <span id="AvgGapTime">Loading...</span></p>
  214. <p><strong>Pano Event Ratio:</strong> <span id="streetViewRatio">Loading...</span></p>
  215. <p><strong>First PanoZoom:</strong> <span id="firstPanoZoomTime">Loading...</span></p>
  216. <p><strong>Longest Single Gap:</strong> <span id="longestGapTime">Loading...</span></p>
  217. </div>
  218. <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;">
  219. <p><strong>Gap Count:</strong> <span id="stagnationCount">Loading...</span></p>
  220. <p><strong>Total Gap Time:</strong> <span id="stagnationTime">Loading...</span></p>
  221. <p><strong>Map Event Ratio:</strong> <span id="mapEventRatio">Loading...</span></p>
  222. <p><strong>First Map Zoom:</strong> <span id="firstMapZoomTime">Loading...</span></p>
  223. <p><strong>Pano POV Speed:</strong> <span id="avgPovSpeed">Loading...</span></p>
  224. </div>
  225. </div>
  226. </div>
  227. `,
  228. width: 800,
  229. showCloseButton: true,
  230. backdrop:null,
  231. didOpen: () => {
  232.  
  233. const canvas = document.getElementById('chartCanvas')
  234. const ctx = canvas.getContext('2d', {willReadFrequently: true })
  235.  
  236. const playerSelect = document.getElementById("playerSelect");
  237. const roundSelect = document.getElementById("roundSelect");
  238.  
  239. playersList.forEach(player => {
  240. let option = document.createElement("option");
  241. option.value = player.playerId;
  242. option.textContent = player.nick;
  243. playerSelect.appendChild(option);
  244. });
  245. if(selectedPlayer)playerSelect.value=selectedPlayer.playerId;
  246. if(rounds){
  247. for (let i = 1; i <= rounds; i++) {
  248. let option =document.createElement("option")
  249. option.value = i;
  250. option.textContent=`Round ${i}`
  251. roundSelect.appendChild(option);
  252. }
  253. }
  254. if(round)roundSelect.value=parseInt(round)
  255. const toggleSVBtn = document.getElementById('toggleSVBtn');
  256. const toggleEventBtn = document.getElementById('toggleEventBtn');
  257.  
  258. function updateChartData(data, playerName) {
  259. chart.resize()
  260. const interval = 1000;
  261. const eventTypes = [
  262. "PanoPov",
  263. "PanoZoom",
  264. "MapPosition",
  265. "MapZoom",
  266. "PinPosition",
  267. "MapDisplay",
  268. "PanoPosition",
  269. "GuessWithLatLng"
  270. ];
  271.  
  272. const keyEventTypes = ["PinPosition", "MapDisplay", "GuessWithLatLng", "PanoPosition","Timer"];
  273. const eventColors = {
  274. "MapZoom": "#0000FF",
  275. "MapPosition": "#FFA500",
  276. "PanoPov": "#00FF00",
  277. "GuessWithLatLng": "#FF0000",
  278. "PinPosition": "#00FFFF",
  279. "MapDisplay": "#800080",
  280. "PanoZoom": "#FF69B4",
  281. "PanoPosition": "#1E90FF"
  282. };
  283.  
  284. const eventBuckets = {};
  285. const allEventTimes = {};
  286.  
  287. eventTypes.forEach(eventType => {
  288. eventBuckets[eventType] = {};
  289.  
  290. });
  291. keyEventTypes.forEach(eventType => {
  292. allEventTimes[eventType] = [];
  293. });
  294.  
  295. data.forEach(event => {
  296. const eventTime = event.time;
  297. const relativeTime = eventTime - data[0].time;
  298. if(eventBuckets[event.type]){
  299. const bucket = Math.floor(relativeTime / interval);
  300.  
  301. if (!eventBuckets[event.type][bucket]) {
  302. eventBuckets[event.type][bucket] = 0;
  303. }
  304. eventBuckets[event.type][bucket]++;
  305. }
  306. if(allEventTimes[event.type]){
  307. allEventTimes[event.type].push(relativeTime); }
  308. });
  309.  
  310. const labels = [];
  311. const maxBucket = Math.max(
  312. ...Object.values(eventBuckets).flatMap(bucket => Object.keys(bucket).map(Number))
  313. );
  314.  
  315. for (let i = 0; i <= maxBucket; i++) {
  316. const relativeSeconds = (i * interval + interval / 2) / 1000; // 获取3秒区间的中点
  317. const minutes = Math.floor(relativeSeconds / 60);
  318. const seconds = Math.floor(relativeSeconds % 60);
  319. const formattedTime = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
  320. labels.push(formattedTime);
  321. }
  322.  
  323. const datasets = eventTypes.map(eventType => {
  324. const dataPoints = labels.map((label, index) => eventBuckets[eventType][index] || 0);
  325. return {
  326. label: eventType,
  327. data: dataPoints,
  328. fill: false,
  329. borderColor: eventColors[eventType],
  330. backgroundColor: eventColors[eventType],
  331. tension: 0.5,
  332. hidden: true
  333. };
  334. });
  335.  
  336. const totalEventsData = labels.map((label, index) => {
  337. let total = 0;
  338. eventTypes.forEach(eventType => {
  339. total += eventBuckets[eventType][index] || 0;
  340. });
  341. return total;
  342. });
  343.  
  344. datasets.push({
  345. label: 'Total Events',
  346. data: totalEventsData,
  347. fill: false,
  348. borderColor: 'rgba(0,0,0,0.6)',
  349. backgroundColor: 'rgba(0,0,0,0.6)',
  350. tension: 0.5
  351. });
  352.  
  353. const annotations = [];
  354.  
  355. Object.keys(allEventTimes).forEach(eventType => {
  356. allEventTimes[eventType].forEach(eventTime => {
  357. const xPosition = eventTime / 1000;
  358. annotations.push({
  359. type: 'line',
  360. xMin: xPosition,
  361. xMax: xPosition,
  362. borderColor: eventColors[eventType],
  363. borderWidth: 1.5,
  364. borderDash: [5, 5],
  365. });
  366. });
  367. });
  368.  
  369. chart.data.datasets = datasets;
  370. chart.data.labels = labels;
  371. chart.options.plugins.annotation.annotations = annotations;
  372. chart.update();}
  373.  
  374. const chart = new Chart(ctx, {
  375. type: 'line',
  376. data: {
  377. labels: [],
  378. datasets: []
  379. },
  380. options: {
  381. responsive: true,
  382. plugins: {
  383. legend: {
  384. display: true,
  385. labels: {
  386. boxWidth: 30,
  387. boxHeight: 15,
  388. padding: 30
  389. },
  390. position: 'top',
  391. align: 'center',
  392. labels: {
  393. usePointStyle: true,
  394. padding: 20,
  395. pointStyle: 'rectRounded'
  396. },
  397. },
  398. tooltip: { mode: 'index', intersect: false,enabled:false },
  399. annotation: {
  400. annotations: [],
  401. },
  402. },
  403. scales: {
  404. x: { title: { display: true } },
  405. y: { title: { display: true, text: 'Event Counts' }, beginAtZero: true }
  406. },
  407.  
  408. },
  409.  
  410. });
  411.  
  412. function updateEventAnalysisData(data) {
  413. const { eventDensity, stagnationTime, stagnationCount, AvgGapTime, streetViewRatio, mapEventRatio, firstMapZoomTime, firstPanoZoomTime,longestGapTime,avgPovSpeed} = updateEventAnalysis(data);
  414. document.getElementById('eventDensity').textContent = eventDensity.toFixed(2) + " times/s";
  415. document.getElementById('stagnationTime').textContent = stagnationTime.toFixed(2) + " s";
  416. document.getElementById('longestGapTime').textContent = longestGapTime.toFixed(2) + " s";
  417. document.getElementById('avgPovSpeed').textContent = avgPovSpeed.toFixed(2) + " °/s";
  418. document.getElementById('stagnationCount').textContent = stagnationCount;
  419. document.getElementById('AvgGapTime').textContent =!stagnationCount?'None': `${(parseFloat(stagnationTime/stagnationCount)).toFixed(2)}s`;
  420. document.getElementById('streetViewRatio').textContent = (streetViewRatio * 100).toFixed(2) + "%";
  421. document.getElementById('mapEventRatio').textContent = (mapEventRatio * 100).toFixed(2) + "%";
  422. document.getElementById('firstMapZoomTime').textContent = firstMapZoomTime === null ? "None" : "At " + firstMapZoomTime + " s";
  423. document.getElementById('firstPanoZoomTime').textContent = firstPanoZoomTime === null ? "None" : "At " + firstPanoZoomTime + " s";
  424. }
  425.  
  426. updateChartData(replayData);
  427. updateEventAnalysisData(replayData);
  428. playerSelect.onchange = async () => {
  429. canvas.style.pointerEvents = 'auto';
  430. try{
  431. replayData=await fetchReplayData(currentGameId,playerSelect.value,roundSelect.value)
  432. selectedPlayer=playersList.find(player=>player.playerId==playerSelect.value)
  433. }
  434. catch(e){
  435. console.error("Error fetching replay data")
  436. return
  437. }
  438. updateChartData(replayData);
  439. updateEventAnalysisData(replayData);
  440. };
  441.  
  442. roundSelect.onchange = async () => {
  443. canvas.style.pointerEvents = 'auto';
  444. try{
  445. replayData=await fetchReplayData(currentGameId,playerSelect.value,roundSelect.value)
  446. }
  447. catch(e){
  448. console.error("Error fetching replay data")
  449. return
  450. }
  451. updateChartData(replayData);
  452. updateEventAnalysisData(replayData);
  453. };
  454.  
  455. toggleEventBtn.addEventListener('click',()=>{
  456. toggleSVBtn.style.border='none'
  457. toggleEventBtn.style.border='2px solid grey'
  458. canvas.style.pointerEvents = 'auto';
  459. updateChartData(replayData);
  460. updateEventAnalysisData(replayData);
  461. })
  462. toggleSVBtn.addEventListener('click',async () => {
  463. toggleEventBtn.style.border='none'
  464. toggleSVBtn.style.border='2px solid grey'
  465. canvas.style.pointerEvents='none'
  466. var centerHeading;
  467. const panoIds = replayData
  468. .filter(item => item.type === 'PanoPosition' && item.payload?.panoId)
  469. .map(item => item.payload.panoId);
  470. if(panoIds.length>1){
  471. var panoId=panoIds[Math.floor(Math.random() * panoIds.length)]
  472. }
  473. else{
  474. panoId=panoIds[0]
  475. }
  476. const metaData = await searchGooglePano('GetMetadata',panoId );
  477.  
  478. var w = metaData.worldWidth;
  479. var h = metaData.worldHeight;
  480.  
  481. centerHeading = metaData.heading;
  482.  
  483.  
  484. try {
  485. const imageUrl = await downloadPanoramaImage(panoId, panoId, w, h, w==13312?5:3, true);
  486. const img = await loadImage(imageUrl);
  487. canvas.width = img.width;
  488. canvas.height = img.height;
  489. ctx.drawImage(img, 0, 0);
  490.  
  491. let lastPanoPov = { heading: 0, pitch: 0 };
  492. let stagnationPoints =[];
  493. const heatData = replayData.filter(event => ["PanoZoom", "PanoPov"].includes(event.type)).map((event, index, events) => {
  494. let heading, pitch, type;
  495. let time = event.time;
  496.  
  497. if (event.type === "PanoPov") {
  498. [heading, pitch] =[event.payload.heading,event.payload.pitch]
  499. lastPanoPov = { heading, pitch };
  500. type = "PanoPov";
  501. } else if (event.type === "PanoZoom") {
  502. heading = lastPanoPov.heading;
  503. pitch = lastPanoPov.pitch;
  504. type = "PanoZoom";
  505. }
  506.  
  507. if (index > 0) {
  508. const prevEvent = events[index - 1];
  509. const timeDiff = Math.abs(time - prevEvent.time);
  510. if (timeDiff > 3000) {
  511. stagnationPoints.push(index);
  512. }
  513. }
  514.  
  515. return { heading, pitch, type};
  516. });
  517.  
  518. drawHeatMapOnImage(canvas, heatData, centerHeading,stagnationPoints);
  519. } catch (error) {
  520. console.error('Error downloading panorama image:', error);
  521. }
  522.  
  523. })}
  524.  
  525. });
  526. }
  527.  
  528. function drawHeatMapOnImage(canvas, heatData, centerHeading,points) {
  529. const ctx = canvas.getContext('2d');
  530. heatData.forEach((point, index) => {
  531. let headingDifference = point.heading - centerHeading;
  532. if (headingDifference > 180) {
  533. headingDifference -= 360;
  534. } else if (headingDifference < -180) {
  535. headingDifference += 360;
  536. }
  537. const x = (headingDifference + 180) / 360 * canvas.width;
  538. const y = (90 - point.pitch) / 180 * canvas.height;
  539.  
  540. ctx.beginPath();
  541. if(canvas.width===13312) ctx.arc(x, y, (points.includes(index))?80:40, 0,2* Math.PI);
  542. else ctx.arc(x, y, (points.includes(index))?30:15, 0,2* Math.PI);
  543.  
  544. if (points.includes(index)) {
  545. ctx.fillStyle = 'yellow';
  546. } else if (point.type === "PanoZoom") {
  547. ctx.fillStyle = '#FF0000';
  548. } else if (point.type === "PanoPov") {
  549. ctx.fillStyle = '#00FF00';
  550. }
  551.  
  552. ctx.fill();
  553. });
  554. }
  555.  
  556. function updateEventAnalysis(data) {
  557. let totalEvents = 0;
  558. let totalTime = 0;
  559. let stagnationTime = 0;
  560. let stagnationCount = 0;
  561. let switchCount = 0;
  562. let streetViewEvents = 0;
  563. let mapEvents = 0;
  564. let lastEventTime = null;
  565. let longestGapTime = 0;
  566.  
  567. let totalHeadingDifference = 0;
  568. let totalTimeGap = 0;
  569.  
  570. let lastPanoPovEventTime = null;
  571. let lastHeading = null;
  572.  
  573. data.forEach(event => {
  574. const eventTime = event.time;
  575. const relativeTime = Math.floor((eventTime - data[0].time) / 1000);
  576.  
  577. totalEvents++;
  578. totalTime = relativeTime;
  579.  
  580. if (event.type.includes("Pano")) {
  581. streetViewEvents++;
  582. } else if (event.type.includes("Map")) {
  583. mapEvents++;
  584. }
  585.  
  586. if (lastEventTime !== null) {
  587. const timeGap = (eventTime - lastEventTime) / 1000;
  588.  
  589. if (timeGap >= 3) {
  590. if (timeGap > longestGapTime) longestGapTime = timeGap;
  591. stagnationTime += timeGap;
  592. stagnationCount++;
  593. }
  594. }
  595.  
  596. if (event.type === "PanoPov" && lastPanoPovEventTime !== null) {
  597. const headingDifference = Math.abs(event.payload.heading - lastHeading);
  598. const timeGap = (eventTime - lastPanoPovEventTime) / 1000;
  599.  
  600. totalHeadingDifference += headingDifference;
  601. totalTimeGap += timeGap;
  602. }
  603.  
  604. lastEventTime = eventTime;
  605.  
  606. if (event.type === "PanoPov") {
  607. lastPanoPovEventTime = eventTime;
  608. lastHeading =event.payload.heading;
  609. }
  610.  
  611. if (event.type === "Switch") {
  612. switchCount++;
  613. }
  614. });
  615.  
  616. const eventDensity = totalEvents / totalTime;
  617.  
  618. const streetViewRatio = streetViewEvents / totalEvents;
  619. const mapEventRatio = mapEvents / totalEvents;
  620.  
  621. let firstMapZoomTime = null;
  622. let firstMapZoomTime_ = null;
  623. let firstPanoZoomTime_ = null;
  624. let firstPanoZoomTime = null;
  625. data.forEach(event => {
  626. if (event.type === "MapZoom" && !firstMapZoomTime) {
  627. if (firstMapZoomTime_ === null) firstMapZoomTime_ = 1;
  628. else {
  629. firstMapZoomTime = Math.floor((event.time - data[0].time) / 1000);
  630. }
  631. }
  632. if (event.type === "PanoZoom" && !firstPanoZoomTime) {
  633. if (firstPanoZoomTime_ === null) firstPanoZoomTime_ = 1;
  634. else {
  635. firstPanoZoomTime = Math.floor((event.time - data[0].time) / 1000);
  636. }
  637. }
  638. });
  639.  
  640. let avgPovSpeed = 0;
  641. if (totalTimeGap > 0) {
  642. avgPovSpeed = totalHeadingDifference / totalTimeGap;
  643. }
  644.  
  645. return {
  646. eventDensity,
  647. stagnationTime,
  648. stagnationCount,
  649. streetViewRatio,
  650. mapEventRatio,
  651. firstPanoZoomTime,
  652. firstMapZoomTime,
  653. longestGapTime,
  654. avgPovSpeed
  655. };
  656. }
  657.  
  658.  
  659. let onKeyDown =async (e) => {
  660. if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
  661. return;
  662. }
  663. if (e.shiftKey&&(e.key === 'K' || e.key === 'k')){
  664. const {gameId, round}=parseUrl()
  665. await getReplayer(gameId,round)
  666. analyze(round)
  667. }
  668. }
  669.  
  670. document.addEventListener("keydown", onKeyDown);
  671. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址