GeoIntel

location panel

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
    }
})();