GeoIntel

location panel

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GeoIntel
// @namespace    http://tampermonkey.net/
// @version      2.7.4
// @description  location panel
// @author       maiousXO13
// @license      MIT
// @match        https://www.geoguessr.com/*
// @grant        GM_xmlhttpRequest
// @connect      us1.locationiq.com
// @connect      locationiq.com
// @connect      api.bigdatacloud.net
// @connect      api-bdc.io
// @connect      static-maps.yandex.ru
// @connect      flagcdn.com
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEYS = {
        autoGuessDelayMs: 'gi_auto_guess_delay_ms',
        autoGuessDeviationMs: 'gi_auto_guess_deviation_ms',
        guessOffsetBaseM: 'gi_guess_offset_base_m',
        guessOffsetDeviationM: 'gi_guess_offset_deviation_m',
        hotkeyScan: 'gi_hotkey_scan',
        hotkeyPlay: 'gi_hotkey_play',
        hotkeyCopy: 'gi_hotkey_copy',
        locationIqKeys: 'gi_locationiq_keys'
    };

    const MAPS_RPC_PATTERN = /google\.internal\.maps\.mapsjs\.v1\.MapsJsInternalService\/[A-Za-z]+\b/;
    const XHR_PATCH_FLAG = '__gi_xhr_intercept_patched__';
    const FETCH_PATCH_FLAG = '__gi_fetch_intercept_patched__';
    const DOM_CACHE_MS = 350;
    const COORD_DUPLICATE_WINDOW_MS = 2200;
    const STALE_ROUND_SIGNATURE_TTL_MS = 15000;
    const DEBUG = false;

    const CONFIG = {
        toggleKey: 'Insert',
        detectKey: 'q',
        playKey: 't',
        copyKey: 'y',
        mapZoomMin: 2,
        mapZoomMax: 18,
        defaultMapZoom: 11,
        autoPlayGuessBaseDelayMs: 20000,
        autoPlayGuessDeviationMs: 7000,
        autoPlayWorkerTickMs: 700,
        autoPlayPinAttempts: 18,
        autoPlayPinPauseMs: 220,
        autoPlayGuessAttempts: 16,
        autoPlayGuessPauseMs: 90,
        guessTargetAvoidWaterAttempts: 8,
        guessTargetProbePrecision: 2,
        guessTargetProbeCacheLimit: 320,
        guessOffsetBaseM: 500000,
        guessOffsetDeviationM: 200000,
        maxMarkDelayMs: 86400000,
        maxMarkDeviationMs: 86400000,
        maxGuessOffsetM: 10000000,
        maxGuessOffsetDeviationM: 10000000,
        autoDetectCooldownMs: 1200,
        mapLayers: [
            { label: 'Map', param: 'map', marker: 'pm2gnm' },
            { label: 'Sat', param: 'sat', marker: 'pm2orm' },
            { label: 'Hybrid', param: 'sat,skl', marker: 'pm2orm' }
        ],
        locationIqKeys: loadApiKeysSetting(STORAGE_KEYS.locationIqKeys)
    };

    const state = {
        panelVisible: true,
        mapZoom: CONFIG.defaultMapZoom,
        mapLayerIndex: 0,
        autoDetect: false,
        autoPlay: false,
        autoPlaySubmitGuess: false,
        autoGuessDelayMs: loadNumberSetting(STORAGE_KEYS.autoGuessDelayMs, CONFIG.autoPlayGuessBaseDelayMs),
        autoGuessDeviationMs: loadNumberSetting(STORAGE_KEYS.autoGuessDeviationMs, CONFIG.autoPlayGuessDeviationMs),
        guessOffsetBaseM: loadNumberSetting(STORAGE_KEYS.guessOffsetBaseM, CONFIG.guessOffsetBaseM),
        guessOffsetDeviationM: loadNumberSetting(STORAGE_KEYS.guessOffsetDeviationM, CONFIG.guessOffsetDeviationM),
        hotkeys: {
            scan: loadHotkeySetting(STORAGE_KEYS.hotkeyScan, CONFIG.detectKey),
            play: loadHotkeySetting(STORAGE_KEYS.hotkeyPlay, CONFIG.playKey),
            copy: loadHotkeySetting(STORAGE_KEYS.hotkeyCopy, CONFIG.copyKey)
        },
        detectInFlight: false,
        coordinates: null,
        locationData: null,
        apiKeyStartIndex: 0,
        lastDetectAt: 0,
        autoDetectTimer: null,
        autoPlayWorkerTimer: null,
        autoPlayInFlight: false,
        autoPlayHandledSignature: '',
        guessMapCache: [],
        guessMapCacheAt: 0,
        resizeTimer: null,
        lastCoordSignature: '',
        lastCoordAt: 0,
        pendingRoundCapture: false,
        pendingCoordSignature: '',
        pendingCoordAt: 0,
        guessLandProbeCache: new Map(),
        ui: null
    };

    init();

    function init() {
        interceptCoordinates();
        registerHotkeys();
        registerGuessSubmissionWatcher();
        window.addEventListener('resize', onResize, { passive: true });

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', createUI, { once: true });
        } else {
            createUI();
        }
    }

    function createUI() {
        if (state.ui || !document.body) {
            return;
        }

        injectStyles();

        const panel = document.createElement('section');
        panel.id = 'gi-shell';
        panel.innerHTML = `
            <header id="gi-hero">
                <div id="gi-title">GeoIntel</div>
            </header>

            <div id="gi-scan-row">
                <button type="button" id="gi-scan">SCAN</button>
            </div>

            <div id="gi-toggle-row">
                <button type="button" id="gi-auto" data-active="false">AutoScan Off</button>
                <button type="button" id="gi-autoplay" data-active="false">AutoPlay Off</button>
                <button type="button" id="gi-autoguess" data-active="false">AutoGuess Off</button>
            </div>

            <div id="gi-action-row">
                <button type="button" id="gi-play">Play</button>
                <button type="button" id="gi-layer">Layer</button>
                <button type="button" id="gi-copy">Copy</button>
            </div>

            <section id="gi-guess-controls">
                <label class="gi-guess-field">
                    <span>Mark Delay (s)</span>
                    <input type="number" min="0" max="86400" step="0.05" id="gi-guess-delay" />
                </label>
                <label class="gi-guess-field">
                    <span>Deviation (s)</span>
                    <input type="number" min="0" max="86400" step="0.05" id="gi-guess-deviation" />
                </label>
                <label class="gi-guess-field">
                    <span>Guess Offset (km)</span>
                    <input type="number" min="0" max="10000" step="0.05" id="gi-offset-base" />
                </label>
                <label class="gi-guess-field">
                    <span>Offset Deviation (km)</span>
                    <input type="number" min="0" max="10000" step="0.05" id="gi-offset-deviation" />
                </label>
                <div id="gi-guess-range"></div>
                <div id="gi-offset-range"></div>
            </section>

            <section id="gi-intel">
                <div class="gi-row">
                    <span class="gi-label">Country</span>
                    <span class="gi-value-wrap">
                        <img id="gi-flag" alt="flag" />
                        <span class="gi-value" id="gi-country">N/A</span>
                    </span>
                </div>
                <div class="gi-row">
                    <span class="gi-label">State</span>
                    <span class="gi-value" id="gi-state">N/A</span>
                </div>
                <div class="gi-row">
                    <span class="gi-label">City</span>
                    <span class="gi-value" id="gi-city">N/A</span>
                </div>
            </section>

            <section id="gi-map-stage">
                <img id="gi-map" alt="map" draggable="false" />
                <div id="gi-empty">Move once, then press Scan or Q.</div>
                <div id="gi-map-ui">
                    <div id="gi-coords">Coordinates: N/A</div>
                    <div id="gi-zoom-wrap">
                        <button type="button" id="gi-zin">+</button>
                        <div id="gi-zoom">Zoom 11</div>
                        <button type="button" id="gi-zout">-</button>
                    </div>
                </div>
            </section>

            <section id="gi-hotkeys">
                <label class="gi-hotkey-field">
                    <span>Scan</span>
                    <input type="text" id="gi-key-scan" maxlength="16" />
                </label>
                <label class="gi-hotkey-field">
                    <span>Play</span>
                    <input type="text" id="gi-key-play" maxlength="16" />
                </label>
                <label class="gi-hotkey-field">
                    <span>Copy</span>
                    <input type="text" id="gi-key-copy" maxlength="16" />
                </label>
            </section>
        `;

        document.body.appendChild(panel);

        state.ui = {
            panel,
            header: panel.querySelector('#gi-hero'),
            scan: panel.querySelector('#gi-scan'),
            auto: panel.querySelector('#gi-auto'),
            autoPlay: panel.querySelector('#gi-autoplay'),
            autoGuess: panel.querySelector('#gi-autoguess'),
            play: panel.querySelector('#gi-play'),
            layer: panel.querySelector('#gi-layer'),
            copy: panel.querySelector('#gi-copy'),
            guessDelay: panel.querySelector('#gi-guess-delay'),
            guessDeviation: panel.querySelector('#gi-guess-deviation'),
            guessRange: panel.querySelector('#gi-guess-range'),
            offsetBase: panel.querySelector('#gi-offset-base'),
            offsetDeviation: panel.querySelector('#gi-offset-deviation'),
            offsetRange: panel.querySelector('#gi-offset-range'),
            keyScan: panel.querySelector('#gi-key-scan'),
            keyPlay: panel.querySelector('#gi-key-play'),
            keyCopy: panel.querySelector('#gi-key-copy'),
            flag: panel.querySelector('#gi-flag'),
            country: panel.querySelector('#gi-country'),
            region: panel.querySelector('#gi-state'),
            city: panel.querySelector('#gi-city'),
            mapStage: panel.querySelector('#gi-map-stage'),
            map: panel.querySelector('#gi-map'),
            empty: panel.querySelector('#gi-empty'),
            coords: panel.querySelector('#gi-coords'),
            zin: panel.querySelector('#gi-zin'),
            zout: panel.querySelector('#gi-zout'),
            zoom: panel.querySelector('#gi-zoom')
        };

        bindUI();
        renderAll();
        flashMessage('Deck online', 'success');
    }

    function injectStyles() {
        if (document.getElementById('gi-style')) {
            return;
        }

        const style = document.createElement('style');
        style.id = 'gi-style';
        style.textContent = `
            #gi-shell {
                position: fixed;
                right: 12px;
                top: 12px;
                width: min(360px, calc(100vw - 16px));
                z-index: 2147483647;
                border-radius: 8px;
                border: 1px solid #444;
                background: #1b1b1b;
                color: #f3f3f3;
                font-family: Segoe UI, Tahoma, sans-serif;
                padding: 8px;
                overflow: hidden;
                user-select: none;
                box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
            }

            #gi-hero {
                display: flex;
                align-items: center;
                cursor: move;
                margin-bottom: 6px;
                padding: 0;
            }

            #gi-title {
                font-size: 14px;
                font-weight: 700;
                color: #fff;
            }

            #gi-scan-row {
                margin-bottom: 4px;
            }

            #gi-toggle-row,
            #gi-action-row {
                display: grid;
                grid-template-columns: repeat(3, minmax(0, 1fr));
                gap: 4px;
                margin-bottom: 6px;
            }

            #gi-scan-row button,
            #gi-toggle-row button,
            #gi-action-row button {
                min-height: 28px;
                border: 1px solid #555;
                border-radius: 4px;
                background: #2b2b2b;
                color: #fff;
                font-size: 11px;
                font-weight: 600;
                cursor: pointer;
                transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
            }

            #gi-scan-row button {
                width: 100%;
                min-height: 34px;
                font-size: 13px;
                font-weight: 800;
                letter-spacing: 0.4px;
            }

            #gi-scan-row button:hover,
            #gi-toggle-row button:hover,
            #gi-action-row button:hover {
                background: #343434;
            }

            #gi-scan-row button[disabled],
            #gi-toggle-row button[disabled],
            #gi-action-row button[disabled] {
                opacity: 0.6;
                cursor: default;
            }

            #gi-auto[data-active='true'] {
                border-color: #9acb6c;
            }

            #gi-autoplay[data-active='true'],
            #gi-autoguess[data-active='true'] {
                border-color: #7ad1ff;
            }

            #gi-scan-row button[data-pressed='true'],
            #gi-toggle-row button[data-pressed='true'],
            #gi-action-row button[data-pressed='true'] {
                transform: translateY(1px);
                filter: brightness(0.82);
            }

            #gi-guess-controls {
                display: grid;
                grid-template-columns: repeat(2, minmax(0, 1fr));
                gap: 4px;
                margin-bottom: 6px;
            }

            .gi-guess-field {
                display: grid;
                gap: 2px;
                font-size: 10px;
                color: #c9c9c9;
            }

            .gi-guess-field input {
                width: 100%;
                border: 1px solid #4a4a4a;
                border-radius: 4px;
                background: #252525;
                color: #f2f2f2;
                font-size: 11px;
                padding: 4px 5px;
                box-sizing: border-box;
            }

            #gi-guess-range,
            #gi-offset-range {
                grid-column: 1 / -1;
                font-size: 11px;
                color: #b9d9ff;
                border: 1px solid #3a4b5f;
                border-radius: 4px;
                background: #202834;
                padding: 4px 6px;
            }

            #gi-intel {
                display: grid;
                grid-template-columns: 1fr;
                gap: 3px;
                margin-bottom: 6px;
            }

            .gi-row {
                display: grid;
                grid-template-columns: 70px 1fr;
                align-items: center;
                gap: 6px;
                border: 1px solid #3e3e3e;
                border-radius: 4px;
                padding: 4px 6px;
                background: #252525;
            }

            .gi-label {
                font-size: 11px;
                color: #cfcfcf;
            }

            .gi-value {
                font-size: 12px;
                font-weight: 600;
                color: #fff;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }

            .gi-value-wrap {
                display: flex;
                align-items: center;
                gap: 6px;
                min-width: 0;
            }

            #gi-flag {
                width: 18px;
                height: 14px;
                border-radius: 2px;
                border: 1px solid #666;
                object-fit: cover;
                display: none;
                flex: 0 0 auto;
            }

            #gi-map-stage {
                position: relative;
                height: 170px;
                border-radius: 4px;
                border: 1px solid #3e3e3e;
                overflow: hidden;
                background: #202020;
            }

            #gi-map {
                width: 100%;
                height: 100%;
                object-fit: cover;
                display: none;
                pointer-events: none;
            }

            #gi-empty {
                position: absolute;
                inset: 0;
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 0 18px;
                text-align: center;
                font-size: 12px;
                font-weight: 500;
                color: #ddd;
                background: rgba(20, 20, 20, 0.88);
            }

            #gi-map-ui {
                position: absolute;
                inset: 0;
                display: flex;
                justify-content: space-between;
                align-items: flex-start;
                padding: 6px;
                pointer-events: none;
            }

            #gi-coords {
                max-width: 74%;
                border: 1px solid #4a4a4a;
                border-radius: 4px;
                background: rgba(30, 30, 30, 0.9);
                color: #e8e8e8;
                font-size: 11px;
                font-weight: 700;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                padding: 3px 6px;
                pointer-events: auto;
            }

            #gi-zoom-wrap {
                display: grid;
                gap: 4px;
                justify-items: center;
                pointer-events: auto;
            }

            #gi-zoom-wrap button {
                width: 24px;
                height: 24px;
                border: 1px solid #555;
                border-radius: 4px;
                background: #2c2c2c;
                color: #fff;
                font-size: 16px;
                font-weight: 700;
                line-height: 1;
                padding: 0;
                cursor: pointer;
            }

            #gi-zoom-wrap button:hover {
                background: #383838;
            }

            #gi-zoom {
                border: 1px solid #555;
                border-radius: 4px;
                background: #2a2a2a;
                color: #f1f1f1;
                font-size: 11px;
                padding: 2px 5px;
            }

            #gi-hotkeys {
                margin-top: 6px;
                display: grid;
                grid-template-columns: repeat(3, minmax(0, 1fr));
                gap: 4px;
            }

            .gi-hotkey-field {
                display: grid;
                gap: 2px;
                font-size: 10px;
                color: #c9c9c9;
            }

            .gi-hotkey-field input {
                width: 100%;
                border: 1px solid #4a4a4a;
                border-radius: 4px;
                background: #252525;
                color: #f2f2f2;
                font-size: 11px;
                padding: 4px 5px;
                box-sizing: border-box;
                text-transform: uppercase;
            }

            @media (max-width: 860px) {
                #gi-shell {
                    left: 8px;
                    right: 8px;
                    top: 8px;
                    width: auto;
                    padding: 8px;
                }

                #gi-map-stage {
                    height: 150px;
                }

                #gi-hotkeys {
                    grid-template-columns: 1fr;
                }
            }
        `;

        document.head.appendChild(style);
    }

    function bindUI() {
        const panelUi = state.ui;
        if (!panelUi) return;

        panelUi.scan.addEventListener('click', async () => {
            pulseActionButton(panelUi.scan);
            await detectLocation('manual');
        });

        panelUi.auto.addEventListener('click', () => {
            pulseActionButton(panelUi.auto);
            toggleAutoDetect();
        });

        panelUi.autoPlay.addEventListener('click', () => {
            pulseActionButton(panelUi.autoPlay);
            toggleAutoPlay();
        });

        panelUi.autoGuess.addEventListener('click', () => {
            pulseActionButton(panelUi.autoGuess);
            toggleAutoPlaySubmitGuess();
        });

        panelUi.play.addEventListener('click', async () => {
            await playOnce();
        });

        panelUi.layer.addEventListener('click', () => {
            pulseActionButton(panelUi.layer);
            cycleLayer();
        });

        panelUi.copy.addEventListener('click', async () => {
            await copyCoords();
        });

        panelUi.zin.addEventListener('click', () => changeZoom(1));
        panelUi.zout.addEventListener('click', () => changeZoom(-1));

        panelUi.mapStage.addEventListener('wheel', (event) => {
            event.preventDefault();
            changeZoom(event.deltaY < 0 ? 1 : -1);
        }, { passive: false });

        fixGuessTiming();
        fixOffsetNumbers();
        panelUi.guessDelay.value = secText(state.autoGuessDelayMs);
        panelUi.guessDeviation.value = secText(state.autoGuessDeviationMs);
        panelUi.offsetBase.value = kmText(state.guessOffsetBaseM);
        panelUi.offsetDeviation.value = kmText(state.guessOffsetDeviationM);
        panelUi.guessDelay.addEventListener('input', updateGuessTimingFromInputs);
        panelUi.guessDeviation.addEventListener('input', updateGuessTimingFromInputs);
        panelUi.offsetBase.addEventListener('input', updateGuessTimingFromInputs);
        panelUi.offsetDeviation.addEventListener('input', updateGuessTimingFromInputs);
        panelUi.guessDelay.addEventListener('change', updateGuessTimingFromInputs);
        panelUi.guessDeviation.addEventListener('change', updateGuessTimingFromInputs);
        panelUi.offsetBase.addEventListener('change', updateGuessTimingFromInputs);
        panelUi.offsetDeviation.addEventListener('change', updateGuessTimingFromInputs);
        panelUi.keyScan.value = hotkeyInputText(state.hotkeys.scan);
        panelUi.keyPlay.value = hotkeyInputText(state.hotkeys.play);
        panelUi.keyCopy.value = hotkeyInputText(state.hotkeys.copy);
        panelUi.keyScan.addEventListener('change', updateHotkeysFromInputs);
        panelUi.keyPlay.addEventListener('change', updateHotkeysFromInputs);
        panelUi.keyCopy.addEventListener('change', updateHotkeysFromInputs);

        enableDrag(panelUi.header, panelUi.panel);
    }

    function enableDrag(handle, panel) {
        let dragging = false;
        let mouseStartX = 0;
        let mouseStartY = 0;
        let panelStartX = 0;
        let panelStartY = 0;
        let pendingDx = 0;
        let pendingDy = 0;
        let dragFrame = 0;

        const applyDragPosition = () => {
            dragFrame = 0;
            const maxX = Math.max(0, window.innerWidth - panel.offsetWidth);
            const maxY = Math.max(0, window.innerHeight - panel.offsetHeight);
            panel.style.left = `${clamp(panelStartX + pendingDx, 0, maxX)}px`;
            panel.style.top = `${clamp(panelStartY + pendingDy, 0, maxY)}px`;
        };

        handle.addEventListener('mousedown', (event) => {
            const target = event.target instanceof Element ? event.target : null;
            if (event.button !== 0 || (target && target.closest('button'))) {
                return;
            }

            dragging = true;
            mouseStartX = event.clientX;
            mouseStartY = event.clientY;
            const rect = panel.getBoundingClientRect();
            panelStartX = rect.left;
            panelStartY = rect.top;
            pendingDx = 0;
            pendingDy = 0;

            // Fix the current visual position before switching from right-based to left-based layout
            panel.style.left = `${panelStartX}px`;
            panel.style.top = `${panelStartY}px`;
            panel.style.right = 'auto';
            panel.style.bottom = 'auto';
            panel.style.willChange = 'left, top';
            event.preventDefault();
        });

        document.addEventListener('mousemove', (event) => {
            if (!dragging) {
                return;
            }

            pendingDx = event.clientX - mouseStartX;
            pendingDy = event.clientY - mouseStartY;
            if (!dragFrame) {
                dragFrame = window.requestAnimationFrame(applyDragPosition);
            }
        });

        document.addEventListener('mouseup', () => {
            if (dragFrame) {
                window.cancelAnimationFrame(dragFrame);
                dragFrame = 0;
            }
            dragging = false;
            panel.style.willChange = 'auto';
        });
    }

    function registerHotkeys() {
        document.addEventListener('keydown', async (event) => {
            if (isEditable(event.target)) {
                return;
            }

            if (isHotkeyMatch(event, CONFIG.toggleKey)) {
                event.preventDefault();
                state.panelVisible = !state.panelVisible;
                renderVisibility();
                return;
            }

            if (isHotkeyMatch(event, state.hotkeys.scan)) {
                event.preventDefault();
                await detectLocation('hotkey');
                return;
            }

            if (isHotkeyMatch(event, state.hotkeys.play)) {
                event.preventDefault();
                await playOnce();
                return;
            }

            if (isHotkeyMatch(event, state.hotkeys.copy)) {
                event.preventDefault();
                await copyCoords();
            }
        }, true);
    }

    function registerGuessSubmissionWatcher() {
        document.addEventListener('click', (event) => {
            const target = event.target instanceof Element ? event.target : null;
            const button = target ? target.closest('button') : null;
            if (!isLikelyGuessButton(button)) {
                return;
            }
            markAwaitingNextRound();
        }, true);
    }

    function isEditable(target) {
        if (!target) {
            return false;
        }

        const tag = target.tagName;
        return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
    }

    function interceptCoordinates() {
        interceptXHR();
        interceptFetch();
    }

    function interceptXHR() {
        const currentOpen = XMLHttpRequest.prototype.open;
        if (typeof currentOpen !== 'function' || currentOpen[XHR_PATCH_FLAG]) {
            return;
        }

        const wrappedOpen = function (...args) {
            const method = String(args[0] || '').toUpperCase();
            const url = String(args[1] || '');

            if (method === 'POST' && isGeoGuessrMapRequest(url)) {
                this.addEventListener('load', () => {
                    try {
                        handleCoords(parseCoordinates(this.responseText || ''), 'xhr');
                    } catch {}
                });
            }

            return currentOpen.apply(this, args);
        };

        tagPatchedFn(wrappedOpen, XHR_PATCH_FLAG);
        XMLHttpRequest.prototype.open = wrappedOpen;
    }

    function interceptFetch() {
        if (typeof window.fetch !== 'function' || window.fetch[FETCH_PATCH_FLAG]) {
            return;
        }

        const originalFetch = window.fetch;
        const wrappedFetch = async function (...args) {
            const response = await originalFetch.apply(this, args);

            try {
                const url = getRequestUrl(args[0]);
                if (!isGeoGuessrMapRequest(url)) {
                    return response;
                }

                const text = await response.clone().text();
                handleCoords(parseCoordinates(text), 'fetch');
            } catch (_err) {
                dbg(`Fetch sniff skipped: ${_err?.message || 'parse error'}`);
            }

            return response;
        };

        tagPatchedFn(wrappedFetch, FETCH_PATCH_FLAG);
        window.fetch = wrappedFetch;
    }

    function getRequestUrl(input) {
        if (!input) {
            return '';
        }
        if (typeof input === 'string') {
            return input;
        }
        if (typeof input.url === 'string') {
            return input.url;
        }
        return '';
    }

    function isGeoGuessrMapRequest(url) {
        return MAPS_RPC_PATTERN.test(String(url || ''));
    }

    function parseCoordinates(responseText) {
        const text = String(responseText || '');
        if (!text) {
            return null;
        }

        const patterns = [
            /"lat"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"lng"\s*:\s*(-?\d+(?:\.\d+)?)/g,
            /\[\s*null\s*,\s*null\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]/g
        ];

        let picked = null;
        let pickedAt = -1;
        for (const pattern of patterns) {
            for (const match of text.matchAll(pattern)) {
                const lat = Number(match[1]);
                const lng = Number(match[2]);
                if (!isValidCoordinatePair(lat, lng)) {
                    continue;
                }

                const at = Number.isFinite(match.index) ? match.index : -1;
                if (at >= pickedAt) {
                    picked = { lat, lng };
                    pickedAt = at;
                }
            }
        }

        return picked;
    }

    function handleCoords(coordinates, source) {
        if (!coordinates) {
            return;
        }

        const signature = `${coordinates.lat.toFixed(5)},${coordinates.lng.toFixed(5)}`;
        const now = Date.now();
        if (state.pendingRoundCapture) {
            const pendingSignature = String(state.pendingCoordSignature || '');
            const pendingElapsed = now - state.pendingCoordAt;
            if (pendingSignature && signature === pendingSignature && pendingElapsed < STALE_ROUND_SIGNATURE_TTL_MS) {
                return;
            }
            state.pendingRoundCapture = false;
            state.pendingCoordSignature = '';
            state.pendingCoordAt = 0;
        }

        if (signature === state.lastCoordSignature && (now - state.lastCoordAt) < COORD_DUPLICATE_WINDOW_MS) {
            return;
        }

        state.lastCoordSignature = signature;
        state.lastCoordAt = now;
        state.coordinates = coordinates;
        state.locationData = null;
        clearMapCache();
        dbg(`Coords ${signature} from ${source}`);

        renderLocation();
        renderMap();
        flashMessage(`Captured ${coordsText(coordinates, 5)} from ${source}`, 'success');

        if (state.autoDetect) {
            scheduleAutoDetect();
        }
        if (state.autoPlay) {
            scheduleAutoPlay('capture');
        }
    }

    function scheduleAutoDetect() {
        if (!state.autoDetect || !state.coordinates) {
            return;
        }

        if (state.autoDetectTimer) {
            clearTimeout(state.autoDetectTimer);
        }

        const elapsed = Date.now() - state.lastDetectAt;
        const delay = Math.max(250, CONFIG.autoDetectCooldownMs - elapsed);
        state.autoDetectTimer = setTimeout(async () => {
            state.autoDetectTimer = null;
            await detectLocation('auto');
        }, delay);
    }

    function scheduleAutoPlay(trigger) {
        if (!state.autoPlay || !state.coordinates) {
            return;
        }

        if (trigger !== 'worker') {
            void runAutoPlayForCurrentCoordinates(trigger);
        }
        ensureAutoPlayWorker();
    }

    function ensureAutoPlayWorker() {
        if (state.autoPlayWorkerTimer) {
            return;
        }

        const tick = async () => {
            state.autoPlayWorkerTimer = null;
            if (!state.autoPlay) {
                return;
            }

            await runAutoPlayForCurrentCoordinates('worker');
            if (!state.autoPlay) {
                return;
            }

            state.autoPlayWorkerTimer = setTimeout(() => {
                void tick();
            }, CONFIG.autoPlayWorkerTickMs);
        };

        state.autoPlayWorkerTimer = setTimeout(() => {
            void tick();
        }, CONFIG.autoPlayWorkerTickMs);
    }

    function stopAutoPlayWorker() {
        if (!state.autoPlayWorkerTimer) {
            return;
        }

        clearTimeout(state.autoPlayWorkerTimer);
        state.autoPlayWorkerTimer = null;
    }

    async function runAutoPlayForCurrentCoordinates(trigger) {
        if (!state.autoPlay || !state.coordinates || state.autoPlayInFlight || state.pendingRoundCapture) {
            return;
        }

        const coords = { ...state.coordinates };
        const signature = coordinateSignature(coords, 6);
        const captureStamp = state.lastCoordAt;
        const roundKey = `${signature}|${captureStamp}`;
        if (roundKey === state.autoPlayHandledSignature) {
            return;
        }
        const isCaptureCurrent = () => {
            if (!state.autoPlay || state.pendingRoundCapture || state.lastCoordAt !== captureStamp) {
                return false;
            }
            const latest = state.coordinates;
            if (!latest) {
                return false;
            }
            return coordinateSignature(latest, 6) === signature;
        };

        state.autoPlayInFlight = true;

        if (trigger !== 'worker') {
            flashMessage(`Autoplay placing pin (${trigger})`, 'info');
        }

        try {
            const markDelayMs = pickGuessDelayMs();
            if (markDelayMs > 0) {
                flashMessage(`Mark delay ${secText(markDelayMs)}s`, 'info');
                await sleep(markDelayMs);
            }
            if (!isCaptureCurrent()) {
                return;
            }

            const guessTarget = await buildRandomGuessTarget(coords);
            if (!isCaptureCurrent()) {
                return;
            }
            if (guessTarget.offsetM > 0) {
                flashMessage(`Guess offset ${kmText(guessTarget.offsetM)}km`, 'info');
            }

            const placed = await placeGuessMarkerWithRetry(
                guessTarget.lat,
                guessTarget.lng,
                CONFIG.autoPlayPinAttempts,
                CONFIG.autoPlayPinPauseMs,
                isCaptureCurrent
            );

            if (!isCaptureCurrent()) {
                return;
            }

            if (!placed) {
                if (trigger !== 'worker') {
                    flashMessage('Autoplay waiting for map...', 'error');
                }
                return;
            }

            state.autoPlayHandledSignature = roundKey;
            flashMessage('Autoplay pin placed', 'success');

            if (!state.autoPlaySubmitGuess) {
                return;
            }

            const submitted = await clickGuessButtonWithRetry(
                CONFIG.autoPlayGuessAttempts,
                CONFIG.autoPlayGuessPauseMs,
                isCaptureCurrent
            );
            if (!isCaptureCurrent()) {
                return;
            }
            flashMessage(
                submitted ? 'Autoplay guess submitted' : 'Autoplay failed: guess button not found',
                submitted ? 'success' : 'error'
            );
        } finally {
            state.autoPlayInFlight = false;
        }
    }

    async function clickGuessButtonWithRetry(maxAttempts, pauseMs, continueFn) {
        for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
            if (typeof continueFn === 'function' && !continueFn()) {
                return false;
            }
            if (clickGuessButton()) {
                return true;
            }
            await sleep(pauseMs);
        }

        return false;
    }

    function coordinateSignature(coords, precision) {
        return `${coords.lat.toFixed(precision)},${coords.lng.toFixed(precision)}`;
    }

    async function detectLocation(trigger) {
        const point = state.coordinates;
        if (!point) {
            flashMessage('No coordinates captured yet', 'error');
            return;
        }

        if (state.detectInFlight) {
            flashMessage('Scan already in progress', 'info');
            return;
        }

        state.detectInFlight = true;
        state.lastDetectAt = Date.now();
        renderControls();

        const short = coordsText(point, 5);
        flashMessage(`Scanning ${short} (${trigger})`, 'info');

        try {
            const data = await reverseGeocode(point.lat, point.lng);
            if (!data) {
                flashMessage('Scan failed: geocoder unavailable', 'error');
                return;
            }

            state.locationData = data;
            renderLocation();

            const place = [data.city, data.state, data.country].filter(Boolean).join(', ') || 'Unknown';
            flashMessage(`Decoded: ${place}`, 'success');
        } finally {
            state.detectInFlight = false;
            renderControls();
        }
    }

    async function reverseGeocode(lat, lng) {
        if (!isValidCoordinatePair(lat, lng)) {
            return null;
        }

        const locationIqResult = await reverseGeocodeWithLocationIq(lat, lng);
        if (locationIqResult) {
            return locationIqResult;
        }

        dbg('Falling back to BigDataCloud geocoder');
        return reverseGeocodeWithBigDataCloud(lat, lng);
    }

    async function reverseGeocodeWithLocationIq(lat, lng) {
        const keys = CONFIG.locationIqKeys;
        if (!Array.isArray(keys) || !keys.length) {
            return null;
        }

        for (let offset = 0; offset < keys.length; offset += 1) {
            const index = (state.apiKeyStartIndex + offset) % keys.length;
            const apiKey = String(keys[index] || '').trim();
            if (!apiKey) {
                continue;
            }

            const url = `https://us1.locationiq.com/v1/reverse?key=${encodeURIComponent(apiKey)}&lat=${lat}&lon=${lng}&format=json&accept-language=en`;

            try {
                const response = await gmRequest({ method: 'GET', url });
                if (!response || response.status !== 200) {
                    continue;
                }

                const body = tryJson(response.responseText);
                const parsed = parseAddressPayload(body);
                if (!parsed) {
                    continue;
                }

                state.apiKeyStartIndex = index;
                return parsed;
            } catch (_err) {
                dbg(`LocationIQ key #${index + 1} failed`);
            }
        }

        return null;
    }

    async function reverseGeocodeWithBigDataCloud(lat, lng) {
        const params = new URLSearchParams({
            latitude: String(lat),
            longitude: String(lng),
            localityLanguage: 'en'
        });
        const url = `https://api.bigdatacloud.net/data/reverse-geocode-client?${params.toString()}`;

        try {
            const response = await gmRequest({ method: 'GET', url });
            if (!response || response.status !== 200) {
                return null;
            }

            const body = tryJson(response.responseText);
            return parseBigDataCloudPayload(body);
        } catch (_err) {
            return null;
        }
    }

    function parseAddressPayload(payload) {
        if (!payload || typeof payload !== 'object') {
            return null;
        }

        const address = (payload.address && typeof payload.address === 'object')
            ? payload.address
            : payload;
        const country = firstFilled([address.country, address.country_name]);
        const state = firstFilled([address.state, address.region, address.county]);
        const city = firstFilled([
            address.city,
            address.town,
            address.village,
            address.suburb,
            address.hamlet,
            address.municipality
        ]);

        if (!country && !state && !city) {
            return null;
        }

        return {
            country: country || '',
            countryCode: normCC(address.country_code || address.countryCode),
            state: state || '',
            city: city || '',
            _geoTokens: [
                payload.display_name,
                payload.name,
                payload.type,
                payload.class,
                address.ocean,
                address.sea,
                address.bay,
                address.strait,
                address.channel,
                address.river,
                address.water,
                address.waterway,
                address.lake
            ].map((value) => String(value || '').trim()).filter(Boolean).join(' ').toLowerCase()
        };
    }

    function parseBigDataCloudPayload(payload) {
        if (!payload || typeof payload !== 'object') {
            return null;
        }

        const admin = Array.isArray(payload.localityInfo?.administrative) ? payload.localityInfo.administrative : [];
        const informative = Array.isArray(payload.localityInfo?.informative) ? payload.localityInfo.informative : [];
        const country = firstFilled([payload.countryName, payload.country]);
        const state = firstFilled([
            payload.principalSubdivision,
            admin[1]?.name,
            admin[0]?.name
        ]);
        const city = firstFilled([
            payload.city,
            payload.locality,
            payload.town,
            payload.village,
            informative[0]?.name
        ]);

        if (!country && !state && !city) {
            return null;
        }

        return {
            country: country || '',
            countryCode: normCC(payload.countryCode),
            state: state || '',
            city: city || '',
            _geoTokens: [
                payload.locality,
                payload.city,
                payload.principalSubdivision,
                payload.countryName,
                ...informative.map((item) => item && item.name),
                ...informative.map((item) => item && item.description)
            ].map((value) => String(value || '').trim()).filter(Boolean).join(' ').toLowerCase()
        };
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            if (typeof GM_xmlhttpRequest !== 'function') {
                reject(new Error('GM_xmlhttpRequest unavailable'));
                return;
            }

            GM_xmlhttpRequest({
                method: options.method,
                url: options.url,
                onload: resolve,
                onerror: reject,
                ontimeout: reject
            });
        });
    }

    function toggleAutoDetect() {
        state.autoDetect = !state.autoDetect;
        renderControls();

        if (!state.autoDetect && state.autoDetectTimer) {
            clearTimeout(state.autoDetectTimer);
            state.autoDetectTimer = null;
        }
        if (state.autoDetect && state.coordinates) {
            scheduleAutoDetect();
        }

        flashMessage(`Auto ${state.autoDetect ? 'enabled' : 'disabled'}`, 'info');
    }

    function toggleAutoPlay() {
        state.autoPlay = !state.autoPlay;
        renderControls();

        if (!state.autoPlay) {
            stopAutoPlayWorker();
            state.autoPlayInFlight = false;
            flashMessage('Autoplay disabled', 'info');
            return;
        }

        state.autoPlayHandledSignature = '';
        ensureAutoPlayWorker();
        if (state.coordinates) {
            scheduleAutoPlay('toggle');
        }
        flashMessage('Autoplay enabled', 'info');
    }

    function toggleAutoPlaySubmitGuess() {
        state.autoPlaySubmitGuess = !state.autoPlaySubmitGuess;
        renderControls();
        if (state.autoPlay && state.coordinates) {
            state.autoPlayHandledSignature = '';
            scheduleAutoPlay('guess-toggle');
        }
        flashMessage(`AutoGuess ${state.autoPlaySubmitGuess ? 'enabled' : 'disabled'}`, 'info');
    }

    async function playOnce() {
        pulseActionButton(state.ui ? state.ui.play : null);

        const point = state.coordinates;
        if (!point) {
            flashMessage('No coordinates captured yet', 'error');
            return;
        }

        const coords = { ...point };
        const guessTarget = await buildRandomGuessTarget(coords);
        if (guessTarget.offsetM > 0) {
            flashMessage(`Guess offset ${kmText(guessTarget.offsetM)}km`, 'info');
        }
        const placed = await placeGuessMarkerWithRetry(
            guessTarget.lat,
            guessTarget.lng,
            CONFIG.autoPlayPinAttempts,
            CONFIG.autoPlayPinPauseMs
        );

        if (!placed) {
            flashMessage('Play failed: map is not ready', 'error');
            return;
        }

        state.autoPlayHandledSignature = `${coordinateSignature(coords, 6)}|${state.lastCoordAt}`;
        flashMessage('Point placed', 'success');
    }

    function updateGuessTimingFromInputs() {
        const ui = state.ui;
        if (!ui) return;

        const secField = (raw, fallbackMs, maxMs) => {
            const n = Number(raw);
            const ms = Number.isFinite(n) ? Math.round(n * 1000) : fallbackMs;
            return clamp(ms, 0, maxMs);
        };
        const kmField = (raw, fallbackM, maxM) => {
            const n = Number(raw);
            const m = Number.isFinite(n) ? Math.round(n * 1000) : fallbackM;
            return clamp(m, 0, maxM);
        };

        const delay = secField(ui.guessDelay.value, CONFIG.autoPlayGuessBaseDelayMs, CONFIG.maxMarkDelayMs);
        const deviation = secField(ui.guessDeviation.value, CONFIG.autoPlayGuessDeviationMs, CONFIG.maxMarkDeviationMs);
        const offsetBase = kmField(ui.offsetBase.value, CONFIG.guessOffsetBaseM, CONFIG.maxGuessOffsetM);
        const offsetDeviation = kmField(ui.offsetDeviation.value, CONFIG.guessOffsetDeviationM, CONFIG.maxGuessOffsetDeviationM);

        state.autoGuessDelayMs = delay;
        state.autoGuessDeviationMs = Math.min(deviation, delay);
        state.guessOffsetBaseM = offsetBase;
        state.guessOffsetDeviationM = Math.min(offsetDeviation, offsetBase);

        ui.guessDelay.value = secText(state.autoGuessDelayMs);
        ui.guessDeviation.value = secText(state.autoGuessDeviationMs);
        ui.offsetBase.value = kmText(state.guessOffsetBaseM);
        ui.offsetDeviation.value = kmText(state.guessOffsetDeviationM);

        saveNumberSetting(STORAGE_KEYS.autoGuessDelayMs, state.autoGuessDelayMs);
        saveNumberSetting(STORAGE_KEYS.autoGuessDeviationMs, state.autoGuessDeviationMs);
        saveNumberSetting(STORAGE_KEYS.guessOffsetBaseM, state.guessOffsetBaseM);
        saveNumberSetting(STORAGE_KEYS.guessOffsetDeviationM, state.guessOffsetDeviationM);

        renderControls();
    }

    function updateHotkeysFromInputs() {
        const ui = state.ui;
        if (!ui) return;

        state.hotkeys.scan = readHotkeyField(ui.keyScan.value, CONFIG.detectKey);
        state.hotkeys.play = readHotkeyField(ui.keyPlay.value, CONFIG.playKey);
        state.hotkeys.copy = readHotkeyField(ui.keyCopy.value, CONFIG.copyKey);

        ui.keyScan.value = hotkeyInputText(state.hotkeys.scan);
        ui.keyPlay.value = hotkeyInputText(state.hotkeys.play);
        ui.keyCopy.value = hotkeyInputText(state.hotkeys.copy);

        saveHotkeySetting(STORAGE_KEYS.hotkeyScan, state.hotkeys.scan);
        saveHotkeySetting(STORAGE_KEYS.hotkeyPlay, state.hotkeys.play);
        saveHotkeySetting(STORAGE_KEYS.hotkeyCopy, state.hotkeys.copy);
    }

    function fixGuessTiming() {
        state.autoGuessDelayMs = boundInt(state.autoGuessDelayMs, CONFIG.autoPlayGuessBaseDelayMs, 0, CONFIG.maxMarkDelayMs);
        state.autoGuessDeviationMs = boundInt(state.autoGuessDeviationMs, CONFIG.autoPlayGuessDeviationMs, 0, CONFIG.maxMarkDeviationMs);
        state.autoGuessDeviationMs = Math.min(state.autoGuessDeviationMs, state.autoGuessDelayMs);
    }

    function fixOffsetNumbers() {
        state.guessOffsetBaseM = boundInt(state.guessOffsetBaseM, CONFIG.guessOffsetBaseM, 0, CONFIG.maxGuessOffsetM);
        state.guessOffsetDeviationM = boundInt(state.guessOffsetDeviationM, CONFIG.guessOffsetDeviationM, 0, CONFIG.maxGuessOffsetDeviationM);
        state.guessOffsetDeviationM = Math.min(state.guessOffsetDeviationM, state.guessOffsetBaseM);
    }

    function pickGuessDelayMs() {
        fixGuessTiming();
        const base = state.autoGuessDelayMs;
        const deviation = state.autoGuessDeviationMs;

        const safeDeviation = Math.min(base, deviation);
        const min = Math.max(0, base - safeDeviation);
        const max = base + safeDeviation;
        return randBetween(min, max);
    }

    function pickGuessOffsetMeters() {
        fixOffsetNumbers();
        const base = state.guessOffsetBaseM;
        const deviation = state.guessOffsetDeviationM;

        const safeDeviation = Math.min(base, deviation);
        const min = Math.max(0, base - safeDeviation);
        const max = base + safeDeviation;
        return randBetween(min, max);
    }

    async function buildRandomGuessTarget(coords) {
        const attempts = clamp(
            Math.round(CONFIG.guessTargetAvoidWaterAttempts),
            1,
            30
        );

        const firstCandidate = buildOffsetGuessTarget(coords);
        if (firstCandidate.offsetM <= 0 || attempts <= 1) {
            return firstCandidate;
        }

        if (await isLikelyLandGuessTarget(firstCandidate)) {
            return firstCandidate;
        }

        for (let attempt = 1; attempt < attempts; attempt += 1) {
            const candidate = buildOffsetGuessTarget(coords);
            if (await isLikelyLandGuessTarget(candidate)) {
                return candidate;
            }
        }

        return firstCandidate;
    }

    function buildOffsetGuessTarget(coords) {
        const offsetM = pickGuessOffsetMeters();
        if (offsetM <= 0) {
            return { lat: coords.lat, lng: coords.lng, offsetM: 0 };
        }

        const bearingDeg = Math.random() * 360;
        const moved = destinationPoint(coords.lat, coords.lng, bearingDeg, offsetM / 1000);
        return {
            lat: moved.lat,
            lng: moved.lng,
            offsetM
        };
    }

    async function isLikelyLandGuessTarget(target) {
        if (!target || target.offsetM <= 0) {
            return true;
        }
        return isLikelyLandCoordinate(target.lat, target.lng);
    }

    async function isLikelyLandCoordinate(lat, lng) {
        if (!isValidCoordinatePair(lat, lng)) {
            return false;
        }

        const precision = clamp(Math.round(CONFIG.guessTargetProbePrecision), 1, 4);
        const key = `${lat.toFixed(precision)},${lng.toFixed(precision)}`;
        if (state.guessLandProbeCache.has(key)) {
            return state.guessLandProbeCache.get(key);
        }

        let isLand = false;
        try {
            const geo = await reverseGeocode(lat, lng);
            isLand = !isWaterLikeLocation(geo);
        } catch (_err) {
            isLand = false;
        }

        state.guessLandProbeCache.set(key, isLand);
        trimGuessProbeCache();
        return isLand;
    }

    function trimGuessProbeCache() {
        const limit = clamp(Math.round(CONFIG.guessTargetProbeCacheLimit), 40, 2000);
        while (state.guessLandProbeCache.size > limit) {
            const oldest = state.guessLandProbeCache.keys().next().value;
            if (!oldest) {
                break;
            }
            state.guessLandProbeCache.delete(oldest);
        }
    }

    function isWaterLikeLocation(data) {
        if (!data || typeof data !== 'object') {
            return true;
        }

        const summary = [
            data.country,
            data.state,
            data.city,
            data._geoTokens
        ].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean).join(' ');

        if (!summary) {
            return true;
        }

        const waterWords = [
            'ocean',
            'sea',
            'gulf',
            'bay',
            'strait',
            'channel',
            'lake',
            'lagoon',
            'reservoir',
            'river',
            'water',
            'harbor',
            'harbour',
            'fjord',
            'sound',
            'inlet'
        ];
        if (waterWords.some((word) => summary.includes(word))) {
            return true;
        }

        if (!data.state && !data.city) {
            return true;
        }

        return false;
    }

    function destinationPoint(lat, lng, bearingDeg, distanceKm) {
        const earthRadiusKm = 6371;
        const distanceRad = distanceKm / earthRadiusKm;
        const bearingRad = bearingDeg * (Math.PI / 180);
        const latRad = lat * (Math.PI / 180);
        const lngRad = lng * (Math.PI / 180);

        const newLatRad = Math.asin(
            (Math.sin(latRad) * Math.cos(distanceRad)) +
            (Math.cos(latRad) * Math.sin(distanceRad) * Math.cos(bearingRad))
        );

        const newLngRad = lngRad + Math.atan2(
            Math.sin(bearingRad) * Math.sin(distanceRad) * Math.cos(latRad),
            Math.cos(distanceRad) - (Math.sin(latRad) * Math.sin(newLatRad))
        );

        return {
            lat: newLatRad * (180 / Math.PI),
            lng: normalizeLongitude(newLngRad * (180 / Math.PI))
        };
    }

    function normalizeLongitude(lng) {
        let normalized = lng;
        while (normalized < -180) {
            normalized += 360;
        }
        while (normalized > 180) {
            normalized -= 360;
        }
        return normalized;
    }

    function cycleLayer() {
        state.mapLayerIndex = (state.mapLayerIndex + 1) % CONFIG.mapLayers.length;
        renderControls();
        renderMap();
        const layer = activeLayer();
        flashMessage(`Layer: ${layer.label}`, 'info');
    }

    async function copyCoords() {
        pulseActionButton(state.ui ? state.ui.copy : null);

        const point = state.coordinates;
        if (!point) {
            flashMessage('Nothing to copy yet', 'error');
            return;
        }

        const f6 = (n) => Number(n).toFixed(6);
        const text = `${f6(point.lat)}, ${f6(point.lng)}`;

        try {
            if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
                await navigator.clipboard.writeText(text);
            } else {
                fallbackCopy(text);
            }

            flashMessage('Coordinates copied', 'success');
        } catch (_err) {
            flashMessage('Clipboard access failed', 'error');
        }
    }

    function fallbackCopy(text) {
        const area = document.createElement('textarea');
        area.value = text;
        area.setAttribute('readonly', 'readonly');
        area.style.position = 'absolute';
        area.style.left = '-9999px';
        document.body.appendChild(area);
        area.select();
        document.execCommand('copy');
        area.remove();
    }

    function markAwaitingNextRound() {
        const point = state.coordinates;
        state.pendingRoundCapture = true;
        state.pendingCoordSignature = point ? coordinateSignature(point, 5) : String(state.lastCoordSignature || '');
        state.pendingCoordAt = Date.now();
        state.autoPlayHandledSignature = '';
    }

    async function placeGuessMarkerWithRetry(lat, lng, maxAttempts, pauseMs, continueFn) {
        for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
            if (typeof continueFn === 'function' && !continueFn()) {
                return false;
            }
            if (placeGuessMarker(lat, lng)) {
                return true;
            }
            await sleep(pauseMs);
        }
        return false;
    }

    function placeGuessMarker(lat, lng) {
        const mapCandidates = getGuessMapElements();
        if (!mapCandidates.length) {
            return false;
        }

        const guessWasReady = isGuessReadyToSubmit();

        for (const mapElement of mapCandidates) {
            const clickStores = getClickStoresNearElement(mapElement);
            for (const clickStore of clickStores) {
                const invoked = invokeGoogleMapClickStore(clickStore, lat, lng);
                if (!invoked) {
                    continue;
                }

                if (guessWasReady || isGuessReadyToSubmit()) {
                    return true;
                }
            }
        }

        return false;
    }

    function getGuessMapElements() {
        const nowMs = Date.now();
        if (state.guessMapCacheAt && (nowMs - state.guessMapCacheAt) <= DOM_CACHE_MS) {
            return state.guessMapCache.filter((node) => isElementVisible(node));
        }

        // Geoguessr UI classes change often
        const selectors = [
            '[class^="guess-map_canvas__"]',
            '[class*="guess-map_canvas__"]',
            '[class^="region-map_mapCanvas__"]',
            '[class*="region-map_mapCanvas__"]',
            '[data-qa="guess-map"]',
            '[data-qa*="guess-map"]',
            '[class*="guess-map"]',
            '[class*="region-map"] canvas',
            '[class*="guess-map"] canvas'
        ];

        const seen = new Set();
        const hits = [];
        const looksUsable = (el) => {
            const box = el.getBoundingClientRect();
            return box.width >= 40 && box.height >= 40;
        };
        for (const sel of selectors) {
            for (const node of document.querySelectorAll(sel)) {
                const el = (node instanceof HTMLCanvasElement || node instanceof HTMLImageElement) && node.parentElement
                    ? node.parentElement
                    : node;
                if (!(el instanceof HTMLElement) || seen.has(el)) {
                    continue;
                }
                if (!isElementVisible(el)) {
                    continue;
                }
                if (!looksUsable(el)) {
                    continue;
                }
                seen.add(el);
                hits.push(el);
            }
        }

        // Prefer map elements likely belonging to the active in-round mini-map
        const picked = hits.sort((a, b) => scoreGuessMapCandidate(b) - scoreGuessMapCandidate(a));
        state.guessMapCache = picked;
        state.guessMapCacheAt = nowMs;
        dbg(`Map candidates: ${picked.length}`);
        return picked;
    }

    function getClickStoresNearElement(element) {
        const stores = [];
        const seenStores = new Set();
        const nodesToCheck = [];
        let current = element;

        for (let index = 0; index < 6 && current; index += 1) {
            nodesToCheck.push(current);
            current = current.parentElement;
        }

        for (const node of nodesToCheck) {
            const directStore = node?.__e3_?.click;
            if (isClickStoreObject(directStore) && !seenStores.has(directStore)) {
                seenStores.add(directStore);
                stores.push(directStore);
            }

            const reactKeys = Object.keys(node).filter((key) =>
                key.startsWith('__reactFiber$') || key.startsWith('__reactProps$')
            );

            for (const reactKey of reactKeys) {
                const root = node[reactKey];
                for (const store of collectClickStores(root)) {
                    if (!seenStores.has(store)) {
                        seenStores.add(store);
                        stores.push(store);
                    }
                }
            }
        }

        return stores;
    }

    function collectClickStores(root) {
        if (!root || typeof root !== 'object') {
            return [];
        }

        const stores = [];
        const seenStores = new Set();
        const queue = [{ value: root, depth: 0 }];
        const seen = new Set();

        for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
            const { value, depth } = queue[queueIndex];
            if (!value || typeof value !== 'object' || seen.has(value)) {
                continue;
            }
            if (value instanceof Element || value === window || value === document) {
                continue;
            }
            if (depth > 6 || seen.size > 360) {
                continue;
            }

            seen.add(value);

            const fromMap = value?.map?.__e3_?.click;
            const fromDirect = value?.__e3_?.click;
            if (isClickStoreObject(fromMap) && !seenStores.has(fromMap)) {
                seenStores.add(fromMap);
                stores.push(fromMap);
            }
            if (isClickStoreObject(fromDirect) && !seenStores.has(fromDirect)) {
                seenStores.add(fromDirect);
                stores.push(fromDirect);
            }

            if (Array.isArray(value)) {
                for (const item of value) {
                    if (item && typeof item === 'object') {
                        queue.push({ value: item, depth: depth + 1 });
                    }
                }
                continue;
            }

            for (const key of Object.keys(value)) {
                if (key === 'ownerDocument' || key === 'parentElement' || key === 'parentNode') {
                    continue;
                }
                const next = value[key];
                if (next && typeof next === 'object') {
                    queue.push({ value: next, depth: depth + 1 });
                }
            }
        }

        return stores;
    }

    function isClickStoreObject(value) {
        return !!value && typeof value === 'object' && !Array.isArray(value);
    }

    function invokeGoogleMapClickStore(clickStore, lat, lng) {
        const eventLike = {
            latLng: {
                lat: () => lat,
                lng: () => lng
            }
        };

        let invoked = false;
        for (const key of Object.keys(clickStore)) {
            const branch = clickStore[key];
            if (!branch || typeof branch !== 'object') {
                continue;
            }

            for (const subKey of Object.keys(branch)) {
                if (typeof branch[subKey] !== 'function') {
                    continue;
                }

                try {
                    branch[subKey](eventLike);
                    invoked = true;
                } catch {}
            }
        }

        return invoked;
    }

    function clickGuessButton() {
        const button = findGuessButton(false);
        if (!button) {
            return false;
        }

        markAwaitingNextRound();
        button.click();
        return true;
    }

    function isGuessReadyToSubmit() {
        const button = findGuessButton(true);
        return !!button && !button.disabled;
    }

    function findGuessButton(allowDisabled) {
        const selectors = [
            '[data-qa="perform-guess"]',
            '[data-qa="guess-button"]',
            'button[data-qa*="guess"]',
            'button[class*="guess-actions_guess"]',
            'button[class*="guess-button"]'
        ];
        const pickBySelector = (selector) => Array.from(document.querySelectorAll(selector)).find((node) =>
            isGuessButtonCandidate(node, allowDisabled)
        );

        for (const selector of selectors) {
            const btn = pickBySelector(selector);
            if (btn) {
                return btn;
            }
        }

        return Array.from(document.querySelectorAll('button')).find((node) => {
            if (!isGuessButtonCandidate(node, allowDisabled)) {
                return false;
            }
            const label = String(node.textContent || '').trim().toLowerCase();
            return label === 'guess' || label.includes('make guess');
        }) || null;
    }

    function isLikelyGuessButton(node) {
        if (!(node instanceof HTMLButtonElement)) {
            return false;
        }
        if (!isGuessButtonCandidate(node, true)) {
            return false;
        }

        const dataQa = String(node.getAttribute('data-qa') || '').trim().toLowerCase();
        const className = String(node.className || '').trim().toLowerCase();
        const label = String(node.textContent || '').trim().toLowerCase();
        return dataQa.includes('guess') || className.includes('guess') || label === 'guess' || label.includes('make guess');
    }

    function isGuessButtonCandidate(node, allowDisabled) {
        if (!(node instanceof HTMLButtonElement)) {
            return false;
        }
        if (!allowDisabled && node.disabled) {
            return false;
        }
        if (!isElementVisible(node)) {
            return false;
        }

        const style = window.getComputedStyle(node);
        return style.pointerEvents !== 'none';
    }

    function scoreGuessMapCandidate(element) {
        const rect = element.getBoundingClientRect();
        const area = Math.max(0, rect.width * rect.height);
        const bottomRightBias = (rect.right > (window.innerWidth * 0.5) && rect.bottom > (window.innerHeight * 0.5)) ? 1000000 : 0;
        return area + bottomRightBias;
    }

    function isElementVisible(node) {
        if (!(node instanceof Element) || !node.isConnected) {
            return false;
        }

        const rect = node.getBoundingClientRect();
        if (rect.width < 2 || rect.height < 2) {
            return false;
        }

        if (rect.right < 0 || rect.bottom < 0 || rect.left > window.innerWidth || rect.top > window.innerHeight) {
            return false;
        }

        const style = window.getComputedStyle(node);
        if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
            return false;
        }

        return true;
    }

    function changeZoom(delta) {
        const next = clamp(state.mapZoom + delta, CONFIG.mapZoomMin, CONFIG.mapZoomMax);
        if (next === state.mapZoom) {
            return;
        }
        state.mapZoom = next;
        renderMap();
    }

    function renderAll() {
        renderVisibility();
        renderControls();
        renderLocation();
        renderMap();
    }

    function renderVisibility() {
        const uiState = state.ui;
        if (uiState) {
            uiState.panel.style.display = state.panelVisible ? 'block' : 'none';
        }
    }

    function renderControls() {
        const panelUi = state.ui;
        if (!panelUi) return;
        const mkRange = (min, max, fmt, unit) => `${fmt(min)}-${fmt(max)} ${unit}`;

        panelUi.layer.textContent = `Layer ${state.mapLayerIndex + 1}`;
        panelUi.auto.textContent = state.autoDetect ? 'AutoScan On' : 'AutoScan Off';
        panelUi.auto.dataset.active = state.autoDetect ? 'true' : 'false';
        panelUi.autoPlay.textContent = state.autoPlay ? 'AutoPlay On' : 'AutoPlay Off';
        panelUi.autoPlay.dataset.active = state.autoPlay ? 'true' : 'false';
        panelUi.autoGuess.textContent = state.autoPlaySubmitGuess ? 'AutoGuess On' : 'AutoGuess Off';
        panelUi.autoGuess.dataset.active = state.autoPlaySubmitGuess ? 'true' : 'false';
        const minDelay = Math.max(0, state.autoGuessDelayMs - state.autoGuessDeviationMs);
        const maxDelay = state.autoGuessDelayMs + state.autoGuessDeviationMs;
        panelUi.guessRange.textContent = `Mark delay range: ${mkRange(minDelay, maxDelay, secText, 's')}`;
        const minOffset = Math.max(0, state.guessOffsetBaseM - state.guessOffsetDeviationM);
        const maxOffset = state.guessOffsetBaseM + state.guessOffsetDeviationM;
        panelUi.offsetRange.textContent = `Guess offset range: ${mkRange(minOffset, maxOffset, kmText, 'km')}`;
        panelUi.scan.disabled = state.detectInFlight;
        panelUi.scan.textContent = state.detectInFlight ? 'SCANNING...' : 'SCAN';
    }

    function renderLocation() {
        const view = state.ui;
        if (!view) return;

        const pending = state.coordinates ? 'Scan needed' : 'N/A';
        const data = state.locationData;
        view.country.textContent = data && data.country ? data.country : pending;
        view.region.textContent = data && data.state ? data.state : pending;
        view.city.textContent = data && data.city ? data.city : pending;

        if (data && data.countryCode) {
            view.flag.src = `https://flagcdn.com/24x18/${data.countryCode}.png`;
            view.flag.style.display = 'block';
        } else {
            view.flag.style.display = 'none';
            view.flag.removeAttribute('src');
        }
    }

    function renderMap() {
        const ui = state.ui;
        if (!ui) return;

        ui.zoom.textContent = `Zoom ${state.mapZoom}`;

        const point = state.coordinates;
        if (!point) {
            ui.map.style.display = 'none';
            ui.empty.style.display = 'flex';
            ui.coords.textContent = 'Coordinates: N/A';
        } else {
            const { lat, lng } = point;
            const w = ui.mapStage.clientWidth || 520;
            const h = ui.mapStage.clientHeight || 292;
            ui.coords.textContent = `Coordinates: ${coordsText({ lat, lng }, 5)}`;
            ui.map.src = buildMapUrl(lat, lng, state.mapZoom, w, h);
            ui.map.style.display = 'block';
            ui.empty.style.display = 'none';
        }
    }

    function buildMapUrl(lat, lng, zoom, width, height) {
        const layer = activeLayer();
        const fx = lng.toFixed(6);
        const fy = lat.toFixed(6);
        const w = clamp(Math.round(width), 260, 650);
        const h = clamp(Math.round(height), 160, 360);
        return `https://static-maps.yandex.ru/1.x/?ll=${fx},${fy}&z=${zoom}&size=${w},${h}&l=${layer.param}&lang=en_US&pt=${fx},${fy},${layer.marker}`;
    }

    function flashMessage(text, tone) {
        if (tone === 'error') {
            console.warn(`[GeoIntel] ${text}`);
            return;
        }
        console.info(`[GeoIntel] ${text}`);
    }

    function pulseActionButton(button, durationMs) {
        if (!(button instanceof HTMLButtonElement)) {
            return;
        }

        if (button.__giFlashTimer) {
            clearTimeout(button.__giFlashTimer);
            button.__giFlashTimer = null;
        }

        button.dataset.pressed = 'true';
        const hold = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : 140;
        button.__giFlashTimer = setTimeout(() => {
            delete button.dataset.pressed;
            button.__giFlashTimer = null;
        }, hold);
    }

    function clearMapCache() {
        state.guessMapCache = [];
        state.guessMapCacheAt = 0;
    }

    function dbg(message) {
        if (!DEBUG) {
            return;
        }
        console.debug(`[GeoIntel:debug] ${message}`);
    }

    function tagPatchedFn(fn, patchFlag) {
        try {
            Object.defineProperty(fn, patchFlag, {
                value: true,
                configurable: false,
                enumerable: false,
                writable: false
            });
        } catch (_err) {
            try {
                fn[patchFlag] = true;
            } catch (_err2) {
                // Ignore inability to write marker
            }
        }
    }

    function activeLayer() {
        return CONFIG.mapLayers[state.mapLayerIndex] || CONFIG.mapLayers[0];
    }

    function coordsText(coords, p) {
        return `${coords.lat.toFixed(p)}, ${coords.lng.toFixed(p)}`;
    }

    function sleep(ms) {
        return new Promise((resolve) => {
            setTimeout(resolve, ms);
        });
    }

    function isValidCoordinatePair(lat, lng) {
        return Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
    }

    function tryJson(text) {
        try {
            return JSON.parse(String(text || '{}'));
        } catch (_err) {
            return null;
        }
    }

    function firstFilled(values) {
        for (const value of values) {
            const asString = String(value || '').trim();
            if (asString) {
                return asString;
            }
        }
        return '';
    }

    function normCC(value) {
        return String(value || '').trim().toLowerCase();
    }

    function loadApiKeysSetting(primaryKey) {
        try {
            const current = parseApiKeysFromStorage(window.localStorage.getItem(primaryKey));
            return current;
        } catch (_err) {
            dbg('Failed to read API keys from storage');
            return [];
        }
    }

    function parseApiKeysFromStorage(raw) {
        if (raw === null || raw === undefined) {
            return [];
        }

        let values = [];
        const text = String(raw).trim();
        if (!text) {
            return [];
        }

        if (text.startsWith('[')) {
            const parsed = tryJson(text);
            if (Array.isArray(parsed)) {
                values = parsed;
            }
        } else {
            values = text.split(/[,\n;]+/);
        }

        const result = [];
        const seen = new Set();
        for (const value of values) {
            const key = String(value || '').trim();
            if (!key || seen.has(key)) {
                continue;
            }
            seen.add(key);
            result.push(key);
        }

        return result;
    }

    function loadNumberSetting(key, fallback) {
        try {
            const raw = window.localStorage.getItem(key);
            if (raw === null || raw === '') {
                return fallback;
            }
            const parsed = Number(raw);
            return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
        } catch (_err) {
            return fallback;
        }
    }

    function loadHotkeySetting(key, fallback) {
        try {
            const raw = window.localStorage.getItem(key);
            return normalizeHotkey(raw || fallback, normalizeHotkey(fallback, ''));
        } catch (_err) {
            return normalizeHotkey(fallback, '');
        }
    }

    function saveNumberSetting(key, value) {
        try {
            window.localStorage.setItem(key, String(value));
        } catch {}
    }

    function saveHotkeySetting(key, hotkey) {
        try {
            window.localStorage.setItem(key, String(hotkey || ''));
        } catch (_err) {
            dbg(`Hotkey "${key}" not persisted`);
        }
    }

    function normalizeHotkey(value, fallback) {
        const raw = String(value || '').trim();
        if (!raw) {
            return fallback || '';
        }

        const lower = raw.toLowerCase();
        if (lower === ' ' || lower === 'spacebar' || lower === 'space') {
            return 'space';
        }
        if (lower === 'esc') {
            return 'escape';
        }
        if (lower === 'del') {
            return 'delete';
        }

        return lower;
    }

    function readHotkeyField(value, fallback) {
        return normalizeHotkey(value, normalizeHotkey(fallback, ''));
    }

    function hotkeyInputText(hotkey) {
        const normalized = normalizeHotkey(hotkey, '');
        if (!normalized) {
            return '';
        }
        if (normalized.length === 1) {
            return normalized.toUpperCase();
        }
        if (normalized === 'space') {
            return 'SPACE';
        }
        if (normalized.startsWith('arrow')) {
            return normalized.replace('arrow', 'ARROW ').toUpperCase();
        }
        return normalized.toUpperCase();
    }

    function normEventKey(eventKey) {
        return normalizeHotkey(eventKey, '');
    }

    function isHotkeyMatch(event, hotkey) {
        if (!hotkey || event.repeat || event.ctrlKey || event.altKey || event.metaKey) {
            return false;
        }
        return normEventKey(event.key) === normalizeHotkey(hotkey, '');
    }

    function boundInt(value, fallback, min, max) {
        const parsed = Number(value);
        if (!Number.isFinite(parsed)) {
            return clamp(Math.round(fallback), min, max);
        }
        return clamp(Math.round(parsed), min, max);
    }

    function secText(ms) {
        const seconds = Number(ms) / 1000;
        if (!Number.isFinite(seconds)) {
            return '0';
        }
        return seconds.toFixed(2).replace(/\.?0+$/, '');
    }

    function kmText(meters) {
        const km = Number(meters) / 1000;
        if (!Number.isFinite(km)) {
            return '0';
        }
        return km.toFixed(2).replace(/\.?0+$/, '');
    }

    function randBetween(min, max) {
        const low = Math.ceil(Math.min(min, max));
        const high = Math.floor(Math.max(min, max));
        if (low >= high) {
            return low;
        }
        return low + Math.floor(Math.random() * ((high - low) + 1));
    }

    function clamp(value, min, max) {
        return Math.max(min, Math.min(max, value));
    }

    function onResize() {
        if (state.resizeTimer) {
            clearTimeout(state.resizeTimer);
        }

        state.resizeTimer = setTimeout(() => {
            state.resizeTimer = null;
            clearMapCache();
            renderMap();
        }, 120);
    }
})();