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

QingJ © 2025

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