Adds the 5k radius to community maps and challenge pages
当前为
// ==UserScript==
// @name Geoguessr 5k radius displayer
// @description Adds the 5k radius to community maps and challenge pages
// @version 0.2.1
// @license MIT
// @author irrational
// @match https://www.geoguessr.com/*
// @require https://greasyfork.org/scripts/460322-geoguessr-styles-scan/code/Geoguessr%20Styles%20Scan.js?version=1151668
// @namespace https://greasyfork.org/users/1501600
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
const USERSCRIPT_RADIUS_BLOCK_CLASS = "___userscript-radius-block";
const I18N = {
'fivek_radius': {"en": "5k radius", "de": "5k-Radius",
"es": "radio de 5k", "fr": "rayon de 5k",
"it": "raggio di 5k", "nl": "5k straal",
"pt": "raio de 5k","sv": "5k radie",
"tr": "5k yarıçap", "ja": "5k半径",
"pl": "promień 5k", "ko": "5k 반경"},
'show_on_maps': {"en": "Show on map pages", "de": "Auf Kartenseiten anzeigen",
"es": "Mostrar en páginas de mapas", "fr": "Afficher sur les pages de cartes",
"it": "Mostra sulle pagine delle mappe", "nl": "Weergeven op kaartpagina's",
"pt": "Mostrar nas páginas de mapas", "sv": "Visa på kartsidor",
"tr": "Harita sayfalarında göster", "ja": "地図ページに表示",
"pl": "Pokaż na stronach map", "ko": "지도 페이지에 표시"},
'hide_on_maps': {"en": "Hide on map pages", "de": "Auf Kartenseiten ausblenden",
"es": "Ocultar en páginas de mapas", "fr": "Masquer sur les pages de cartes",
"it": "Nascondi sulle pagine delle mappe", "nl": "Verbergen op kaartpagina's",
"pt": "Ocultar nas páginas de mapas", "sv": "Dölj på kartsidor",
"tr": "Harita sayfalarında gizle", "ja": "地図ページで非表示",
"pl": "Ukryj na stronach map", "ko": "지도 페이지에서 숨기기"},
'show_on_challenges': {"en": "Show on challenge pages", "de": "Auf Herausforderungsseiten anzeigen",
"es": "Mostrar en páginas de desafíos", "fr": "Afficher sur les pages de défis",
"it": "Mostra sulle pagine delle sfide", "nl": "Weergeven op uitdagingpagina's",
"pt": "Mostrar nas páginas de desafios", "sv": "Visa på utmaningssidor",
"tr": "Meydan okuma sayfalarında göster", "ja": "チャレンジページに表示",
"pl": "Pokaż na stronach wyzwań", "ko": "도전 페이지에 표시"},
'hide_on_challenges': {"en": "Hide on challenge pages", "de": "Auf Herausforderungsseiten ausblenden",
"es": "Ocultar en páginas de desafíos", "fr": "Masquer sur les pages de défis",
"it": "Nascondi sulle pagine delle sfide", "nl": "Verbergen op uitdagingpagina's",
"pt": "Ocultar nas páginas de desafios", "sv": "Dölj på utmaningssidor",
"tr": "Meydan okuma sayfalarında gizle", "ja": "チャレンジページで非表示",
"pl": "Ukryj na stronach wyzwań", "ko": "도전 페이지에서 숨기기"}
}
const getLanguage = () => {
if (location.pathname.startsWith('/maps/') || location.pathname.startsWith('/challenges/')) return 'en';
return location.pathname.substring(1, 3);
}
const i18n = (key) => I18N[key][getLanguage()] || I18N[key].en;
/* Run only on maps and challenge pages. Because the Geoguessr frontend is a React application,
URL updates are not always registered (by Tampermonkey on Firefox at least) for the purpose of
finding out whether the script should be loaded at all. So unfortunately, we must @match on
the entire website.
Run only on community maps pages. Official map IDs are retrievable via the /api/maps/explorer
endpoint, but these IDs 404 on /api/maps/<id>. */
const checkURL = () => location.pathname.match(/^\/([a-z]{2}\/)?maps\/[0-9a-fA-F]{24}$/) ? 'map' :
location.pathname.match(/^\/([a-z]{2}\/)?challenge\//) ? 'challenge' : null;
const runOn = { map: GM_getValue('run_on_map', true),
challenge: GM_getValue('run_on_challenge', true) };
const menuId = {map: null, challenge: null};
const makeMenuHandler = (pageType, runOnPage, newText) => {
return (event) => {
GM_setValue('run_on_' + pageType, runOnPage);
runOn[pageType] = runOnPage;
GM_registerMenuCommand(newText,
makeMenuHandler(pageType, ! runOnPage,
i18n((runOnPage ? 'show' : 'hide') + `_on_${pageType}s`)),
{id: menuId[pageType]});
if (checkURL() == pageType) location.reload();
}
}
menuId.map =
GM_registerMenuCommand(i18n((runOn.map ? 'hide' : 'show') + '_on_maps'),
makeMenuHandler('map', ! runOn.map,
i18n((runOn.map ? 'show' : 'hide') + '_on_maps')));
menuId.challenge =
GM_registerMenuCommand(i18n((runOn.challenge ? 'hide' : 'show') + '_on_challenges'),
makeMenuHandler('challenge', ! runOn.challenge,
i18n((runOn.challenge ? 'show' : 'hide') + '_on_challenges')));
const fetchMap = async (mapId) => {
return fetch("https://www.geoguessr.com/api/maps/" + mapId)
.then(out => out.json())
.catch(err => { console.log("5k radius displayer in fetchMap():", err); return null; });
}
const fetchChallengeMap = async (challengeId) => {
return fetch("https://www.geoguessr.com/api/v3/challenges/" + challengeId)
.then(out => out.json())
.then(challenge => challenge.map)
.catch(err => { console.log("5k radius displayer in fetchChallenge():", err); return null; });
}
const fetchDistanceUnit = async () => {
return fetch("https://www.geoguessr.com/api/v3/profiles")
.then(out => out.json())
.then(profile => profile.distanceUnit == 1 ? 'yd' : 'm')
.catch(err => { console.log("5k radius displayer in fetchDistanceUnit():", err); return null; });
}
const createRadiusBlock = () => {
let radiusBlock = document.createElement('div');
radiusBlock.className = cn("community-map-stat_mapStat__") + " " + USERSCRIPT_RADIUS_BLOCK_CLASS;
let statIcon = document.createElement('div');
statIcon.className = cn("community-map-stat_icon__");
let statContent = document.createElement('div');
statContent.className = cn("community-map-stat_content__");
let statValue = document.createElement('div');
statValue.className = cn("community-map-stat_value__");
let statTitle = document.createElement('div');
statTitle.className = cn("community-map-stat_title__");
statContent.appendChild(statValue);
statContent.appendChild(statTitle);
radiusBlock.appendChild(statIcon);
radiusBlock.appendChild(statContent);
statIcon.innerHTML = String.fromCodePoint(0x1F4CD); // round pushpin emoji
statTitle.innerHTML = i18n('fivek_radius');
return radiusBlock;
}
const fillRadiusBlock = (radiusBlock, content, fontStyle = null) => {
let statValue = radiusBlock.querySelector("." + cn("community-map-stat_value__"));
statValue.innerHTML = content;
if (fontStyle) statValue.style.fontStyle = fontStyle;
}
const createChallengeRadiusBlock = () => {
let radiusBlock = document.createElement('li');
radiusBlock.className = cn('game-settings-list_setting__') + " " + USERSCRIPT_RADIUS_BLOCK_CLASS;
let settingIcon = document.createElement('div');
settingIcon.className = cn('game-settings-list_settingIcon__');
let settingLabel = document.createElement('div');
settingLabel.className = cn('game-settings-list_settingLabel__');
radiusBlock.appendChild(settingIcon);
radiusBlock.appendChild(settingLabel);
settingIcon.innerHTML = String.fromCodePoint(0x1F4CD); // round pushpin emoji
settingIcon.style.fontSize = 'calc(var(--setting-icon-size) * 0.75)';
settingLabel.style.textTransform = 'none';
return radiusBlock;
}
const formatRadius = (map, distanceUnit, language) => {
/* It doesn't sound like it, but maxErrorDistance is in effect faked by maps that set the 5k
radius manually. Thus, we can always use it to determine the 5k radius. */
let radius = Math.log(5000/4999.5) * map.maxErrorDistance / 10; // in m
radius = radius < 25 ? 25 : radius;
if (distanceUnit == 'yd') {
radius = Math.round(radius / 0.0254); // in inches, rounded to inches
const yd = Math.trunc(radius / 36);
const ft = Math.trunc((radius - 36 * yd) / 12);
const in_ = radius - 36 * yd - 12 * ft;
return in_ > 0 ? `${yd} yd ${ft}′ ${in_}″` :
ft > 0 ? `${yd} yd ${ft}′` :
`${yd} yd`;
} else {
radius = Math.round(radius * 100) / 100; // in m, rounded to cm
return new Intl.NumberFormat(language).format(radius) + " m";
}
};
var lastMapId = null;
var lastLanguage = null;
const runOnMapPage = () => {
let statsContainer = document.querySelector("." + cn("community-map-block_mapStatsContainer__"));
/* Before there is a stats container in the DOM, there is nothing to do. */
if (! statsContainer) return;
/* Multiple mutations may occur and trigger this function before we have created
the radius block, so acquire a lock and release it once we've created and filled
the block in order to avoid adding multiple. */
navigator.locks.request("userscript_map_radius_block", async (lock) => {
let mapId = location.pathname.split('/').pop();
let radiusBlock = document.querySelector("." + USERSCRIPT_RADIUS_BLOCK_CLASS);
// If we have a radius block, and the language setting changes, we need to recreate it.
let language = getLanguage();
if (radiusBlock && language != lastLanguage) radiusBlock.remove();
lastLanguage = language;
/* We don't want API requests on every mutation. However, React reuses document
elements, e.g. when a new map is selected from search results when a map page is
already open. So, upon mutation, check if the map ID has changed to see if new
API requests are worth it. */
if (radiusBlock && mapId == lastMapId) return;
lastMapId = mapId;
if (! radiusBlock) {
radiusBlock = createRadiusBlock();
statsContainer.appendChild(radiusBlock);
let mapInfoContainer = document.querySelector("." + cn("community-map-block_mapInfo__"));
mapInfoContainer.style.height = "18.125rem"; // current height + block height + gap = 14.625 + 3 + 0.5
}
fillRadiusBlock(radiusBlock, "\u2026"); // ellipsis
Promise.all([fetchDistanceUnit(), fetchMap(mapId)]).then(([distanceUnit, map]) => {
if (! (distanceUnit && map)) { // We probably were rate-limited.
fillRadiusBlock(radiusBlock, "\u274C", "normal"); // cross mark emoji
return;
}
const radius = formatRadius(map, distanceUnit, language);
fillRadiusBlock(radiusBlock, radius);
});
});
}
// Challenges have proper page loads, so this is sufficient.
var haveChallengeRadiusBlock = false;
const runOnChallengePage = () => {
let gameSettings = document.querySelector("." + cn('game-settings-list_settings__'));
if (!gameSettings) return;
let challengeId = location.pathname.split('/').pop();
navigator.locks.request("userscript_challenge_radius_block", async (lock) => {
if (haveChallengeRadiusBlock) return;
haveChallengeRadiusBlock = true;
const radiusBlock = createChallengeRadiusBlock();
Promise.all([fetchChallengeMap(challengeId), fetchDistanceUnit()]).then(([map, distanceUnit]) => {
if (! (map && distanceUnit)) return;
const radius = formatRadius(map, distanceUnit, getLanguage());
const label = radiusBlock.querySelector("." + cn('game-settings-list_settingLabel__'));
label.innerHTML = radius;
gameSettings.appendChild(radiusBlock);
});
});
}
const run = async (page) => {
if (page == 'map' && runOn.map) {
scanStyles().then(runOnMapPage)
} else if (page == 'challenge' && runOn.challenge) {
scanStyles().then(runOnChallengePage)
}
}
new MutationObserver((mutations) => {
run(checkURL());
}).observe(document.body, { subtree: true, childList: true });
/* Make sure to run at least once, in case the MutationObserver was created too late
(which tends to happen on challenge pages). */
run(checkURL());