GeoDiff

ラウンド終了時に画面下部に正解地点およびGuess地点のストリートビューを表示します

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==UserScript==
// @name         GeoDiff
// @namespace    http://tampermonkey.net/
// @version      0.93
// @description  ラウンド終了時に画面下部に正解地点およびGuess地点のストリートビューを表示します
// @author       Daig_O
// @copyright    2026 Daig_O
// @license      MIT
// @match        https://www.geoguessr.com/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    // =============================================
    // 言語設定
    // =============================================
    const i18n = {
        ja: {
            toggleAutoShow: '自動表示を切り替え',
            toggleMinDist: '自動表示の最小距離を変更',
            toggleLayout: 'レイアウト反転を切り替え',
            toggleLanguage: 'Change Language',
            autoShowToggled: (state) => `自動表示を ${state ? 'ON' : 'OFF'} にしました。`,
            minDistPrompt: '何km以上離れていたら自動表示するか(0=常に表示)',
            minDistUpdated: (n) => `最小距離を ${n} km に設定しました。`,
            minDistInvalid: '正の数値を入力してください',
            layoutToggled: (state) => `レイアウト反転を ${state ? 'ON' : 'OFF'} にしました。`,
            languageChanged: (lang) => `言語を ${lang} に変更しました。`,
            mapTitle: 'Google マップで開く',
            correctLabel: '📍 正解地点',
            guessLabel: '🗺 Guess周辺',
            svNotFound: 'SV未検出',
            guessNoSV: '100km圏内にSV未検出',
            distanceSuffix: '先',
        },
        en: {
            toggleAutoShow: 'Toggle Auto-show',
            toggleMinDist: 'Change Min Distance',
            toggleLayout: 'Toggle Layout Reverse',
            toggleLanguage: 'Change Language',
            autoShowToggled: (state) => `Auto-show turned ${state ? 'ON' : 'OFF'}.`,
            minDistPrompt: 'Show auto-display if distance is greater than (km)? (0=always)',
            minDistUpdated: (n) => `Min distance set to ${n} km.`,
            minDistInvalid: 'Please enter a positive number',
            layoutToggled: (state) => `Layout reverse turned ${state ? 'ON' : 'OFF'}.`,
            languageChanged: (lang) => `Language changed to ${lang}.`,
            mapTitle: 'Open in Google Maps',
            correctLabel: '📍 Correct Location',
            guessLabel: '🗺 Guess Vicinity',
            svNotFound: 'No Street View',
            guessNoSV: 'No Street View within 100km',
            distanceSuffix: ' away',
        },
    };

    // =============================================
    // 設定
    // =============================================
    let CFG_AUTO_SHOW = GM_getValue('autoShow', false);
    let CFG_MIN_DIST_KM = GM_getValue('minDistKm', 0);
    let CFG_LAYOUT_REVERSED = GM_getValue('layoutReversed', false);
    let CFG_LANGUAGE = GM_getValue('language', 'ja');

    const t = (key, ...args) => {
        const msg = i18n[CFG_LANGUAGE]?.[key] ?? i18n['ja'][key];
        return typeof msg === 'function' ? msg(...args) : msg;
    };

    GM_registerMenuCommand(
        t('toggleAutoShow'),
        () => {
            CFG_AUTO_SHOW = !CFG_AUTO_SHOW;
            GM_setValue('autoShow', CFG_AUTO_SHOW);
            alert(t('autoShowToggled', CFG_AUTO_SHOW));
        }
    );

    GM_registerMenuCommand(
        t('toggleMinDist'),
        () => {
            const val = prompt(t('minDistPrompt'), CFG_MIN_DIST_KM);
            if (val === null) return;
            const n = parseFloat(val);
            if (isNaN(n) || n < 0) { alert(t('minDistInvalid')); return; }
            CFG_MIN_DIST_KM = n;
            GM_setValue('minDistKm', n);
            alert(t('minDistUpdated', n));
        }
    );

    GM_registerMenuCommand(
        t('toggleLayout'),
        () => {
            CFG_LAYOUT_REVERSED = !CFG_LAYOUT_REVERSED;
            GM_setValue('layoutReversed', CFG_LAYOUT_REVERSED);
            alert(t('layoutToggled', CFG_LAYOUT_REVERSED));
        }
    );

    GM_registerMenuCommand(
        t('toggleLanguage'),
        () => {
            CFG_LANGUAGE = CFG_LANGUAGE === 'ja' ? 'en' : 'ja';
            GM_setValue('language', CFG_LANGUAGE);
            alert(t('languageChanged', CFG_LANGUAGE));
        }
    );

    // =============================================
    // 状態管理
    // =============================================
    let isProcessing = false;
    let lastRoundIdx = -1;
    let lastGameId = null;
    let lastDistM = 0;
    let lastGuess = null;
    let lastRound = null;
    let panelVisible = false;
    let observerActive = false; //observer の二重起動防止フラグ

    const getGameId = () => {
        const m = location.pathname.match(/^\/(?:[a-z]{2}\/)?game\/([^/]+)/);
        return m ? m[1] : null;
    };
    const isGamePage = () => /^\/(?:[a-z]{2}\/)?game\/[^/]+/.test(location.pathname);

    const fetchGameData = async (gameId) => {
        const res = await fetch(`/api/v3/games/${gameId}`, { credentials: 'include' });
        if (!res.ok) throw new Error('API fetch failed');
        return res.json();
    };

    const decodePanoId = (hexStr) => {
        if (!hexStr) return null;
        if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) return hexStr;
        try {
            let decoded = '';
            for (let i = 0; i < hexStr.length; i += 2)
                decoded += String.fromCharCode(parseInt(hexStr.substr(i, 2), 16));
            return decoded;
        } catch (e) { return hexStr; }
    };

    const haversineM = (lat1, lng1, lat2, lng2) => {
        const R = 6371000;
        const dLat = (lat2 - lat1) * Math.PI / 180;
        const dLng = (lng2 - lng1) * Math.PI / 180;
        const a = Math.sin(dLat / 2) ** 2
            + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180)
            * Math.sin(dLng / 2) ** 2;
        return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    };

    const formatDist = (m) =>
        m < 1000 ? `${Math.round(m)}m` : `${(m / 1000).toFixed(1)}km`;

    // =============================================
    // description から州・地域名を抽出
    // =============================================
    const extractState = (description) => {
        if (!description) return null;
        const m = description.match(/State of (.+)/i);
        if (m) return m[1].trim();
        const parts = description.split(',');
        return parts[parts.length - 1].trim();
    };

    const buildLocText = (streakCode, description, distPrefix = null) => {
        const code = streakCode ? streakCode.toUpperCase() : '';
        const state = extractState(description);
        const place = state || code;
        const base = code ? `[${code}] ${place}` : place;
        return distPrefix ? `${base} · ${distPrefix}` : base;
    };

    // =============================================
    // StreetView API
    // =============================================
    let _svService = null;

    const getSVService = () => {
        if (_svService) return _svService;
        const dummy = document.createElement('div');
        dummy.setAttribute('style', 'display:none;width:1px;height:1px;position:absolute;');
        document.body.appendChild(dummy);
        new google.maps.Map(dummy, { center: { lat: 0, lng: 0 }, zoom: 8 });
        _svService = new google.maps.StreetViewService();
        return _svService;
    };

    const searchOnce = (lat, lng, radiusM) => new Promise((resolve) => {
        getSVService().getPanorama({
            location: { lat, lng },
            radius: radiusM,
            preference: google.maps.StreetViewPreference.NEAREST,
            sources: [
                google.maps.StreetViewSource.GOOGLE,
                google.maps.StreetViewSource.OUTDOOR
            ]
        }, (data, status) => {
            if (status === 'OK' && data) {
                resolve({
                    panoId: data.location.pano,
                    lat: data.location.latLng.lat(),
                    lng: data.location.latLng.lng(),
                    description: data.location.description || '',
                });
            } else {
                resolve(null);
            }
        });
    });

    const findNearestPano = async (lat, lng) => {
        const radii = [10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000];
        let best = null;
        for (const r of radii) {
            const result = await searchOnce(lat, lng, r);
            if (result) {
                const distM = haversineM(lat, lng, result.lat, result.lng);
                const candidate = { ...result, distM };
                if (!best || distM < best.distM) best = candidate;
                if (distM <= r) break;
            }
        }
        return best;
    };

    const getPanoInfo = (panoId) => new Promise((resolve) => {
        getSVService().getPanorama({ pano: panoId }, (data, status) => {
            if (status === 'OK' && data) {
                resolve({
                    panoId: data.location.pano,
                    lat: data.location.latLng.lat(),
                    lng: data.location.latLng.lng(),
                    description: data.location.description || '',
                });
            } else {
                resolve(null);
            }
        });
    });

    const renderSV = (containerDiv, panoId, heading = 0, pitch = 0) => {
        return new google.maps.StreetViewPanorama(containerDiv, {
            pano: panoId,
            pov: { heading, pitch },
            zoom: 1,
            addressControl: false,
            fullscreenControl: false,
            motionTracking: false,
            motionTrackingControl: false,
            showRoadLabels: false,
            linksControl: true,
        });
    };

    // =============================================
    // Google Maps リンクボタン
    // =============================================
    const makeMapLinkBtn = (url) => {
        const btn = document.createElement('a');
        btn.href = url;
        btn.target = '_blank';
        btn.rel = 'noopener noreferrer';
        btn.title = t('mapTitle');
        btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
            <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
        </svg>`;
        btn.setAttribute('style', [
            'display:inline-flex', 'align-items:center', 'justify-content:center',
            'width:20px', 'height:20px', 'border-radius:4px',
            'background:rgba(255,255,255,0.18)', 'color:#fff',
            'text-decoration:none', 'margin-left:5px', 'flex-shrink:0',
            'cursor:pointer', 'line-height:1', 'vertical-align:middle',
        ].join(';'));
        btn.addEventListener('mouseover', () => btn.style.background = 'rgba(255,255,255,0.38)');
        btn.addEventListener('mouseout', () => btn.style.background = 'rgba(255,255,255,0.18)');
        return btn;
    };

    // =============================================
    // SVブロック生成
    // =============================================
    const makeSVBlock = (label) => {
        const block = document.createElement('div');
        block.setAttribute('style', 'flex:1;display:flex;flex-direction:column;overflow:hidden;background:#111;min-width:0;');

        const header = document.createElement('div');
        header.setAttribute('style', [
            'padding:6px 10px',
            'background:rgba(0,0,0,0.75)',
            'color:#fff',
            'font-family:sans-serif',
            'flex-shrink:0',
            'display:flex',
            'align-items:center',
            'justify-content:space-between',
            'gap:8px',
            'min-height:20px',
        ].join(';'));

        const leftRow = document.createElement('div');
        leftRow.setAttribute('style', 'display:flex;align-items:center;flex-shrink:0;gap:5px;');

        const labelEl = document.createElement('span');
        labelEl.textContent = label;
        labelEl.setAttribute('style', 'font-weight:bold;font-size:12px;white-space:nowrap;');

        const mapLinkSlot = document.createElement('span');
        leftRow.appendChild(labelEl);
        leftRow.appendChild(mapLinkSlot);

        const rightRow = document.createElement('div');
        rightRow.setAttribute('style', [
            'font-size:11px', 'opacity:0.85', 'text-align:right',
            'min-width:0', 'flex:1',
            'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap',
        ].join(';'));

        header.appendChild(leftRow);
        header.appendChild(rightRow);

        const svDiv = document.createElement('div');
        svDiv.setAttribute('style', 'flex:1;min-height:0;');

        block.appendChild(header);
        block.appendChild(svDiv);

        return { block, svDiv, rightRow, mapLinkSlot };
    };

    // =============================================
    // ズームアウト
    // =============================================
    const zoomOutMap = (steps = 3) => {
        const mapEl = document.querySelector('[class*="coordinate-result-map"]');
        if (!mapEl) return;
        const target = Array.from(mapEl.querySelectorAll('button'))
            .find(b => (b.title + (b.getAttribute('aria-label') || '')).includes('Zoom out'));
        if (target) for (let i = 0; i < steps; i++) setTimeout(() => target.click(), i * 80);
    };

    // =============================================
    // トグルボタン
    // =============================================
    const injectToggleButton = () => {
        if (document.getElementById('gg-sv-toggle')) return;
        const nextBtn = document.querySelector('[data-qa="close-round-result"]');
        if (!nextBtn) return;

        const btn = document.createElement('button');
        btn.id = 'gg-sv-toggle';
        btn.textContent = '📺 SV';
        btn.setAttribute('style', [
            'margin-left:8px', 'padding:0 12px', 'height:40px',
            'border:none', 'border-radius:6px', 'background:rgba(255,255,255,0.15)',
            'color:#fff', 'font-size:13px', 'font-weight:bold',
            'cursor:pointer', 'flex-shrink:0', 'vertical-align:middle',
        ].join(';'));
        btn.addEventListener('mouseover', () => btn.style.background = panelVisible ? 'rgba(100,180,255,0.5)' : 'rgba(255,255,255,0.28)');
        btn.addEventListener('mouseout', () => btn.style.background = panelVisible ? 'rgba(100,180,255,0.35)' : 'rgba(255,255,255,0.15)');
        btn.addEventListener('click', async () => {
            if (!panelVisible) {
                panelVisible = true;
                btn.style.background = 'rgba(100,180,255,0.35)';
                if (lastGuess && lastRound) await buildPanel(lastGuess, lastRound);
            } else {
                panelVisible = false;
                btn.style.background = 'rgba(255,255,255,0.15)';
                hidePanel();
            }
        });
        nextBtn.insertAdjacentElement('afterend', btn);
    };

    const hidePanel = () => {
        document.getElementById('gg-sv-root')?.remove();
        const resultTop = document.querySelector('[data-qa="result-view-top"]');
        if (resultTop) resultTop.removeAttribute('style');
    };

    // =============================================
    // メインパネル構築
    // =============================================
    const buildPanel = async (guess, round) => {
        document.getElementById('gg-sv-root')?.remove();

        const resultTop = document.querySelector('[data-qa="result-view-top"]');
        const resultBottom = document.querySelector('[data-qa="result-view-bottom"]');
        if (!resultTop || !resultBottom) return;

        const totalH = window.innerHeight;
        const nextBandH = resultBottom.getBoundingClientRect().height;
        const mapH = Math.floor((totalH - nextBandH) * 0.5);
        const svH = totalH - nextBandH - mapH;

        resultTop.setAttribute('style', `height:${mapH}px;overflow:hidden;flex-shrink:0;`);

        const root = document.createElement('div');
        root.id = 'gg-sv-root';
        root.setAttribute('style', [
            `height:${svH}px`, 'display:flex', 'flex-direction:row',
            'overflow:hidden', 'background:#1a1a2e',
            'border-top:1px solid rgba(255,255,255,0.1)',
        ].join(';'));

        const { block: correctBlock, svDiv: correctSVDiv, rightRow: correctInfo, mapLinkSlot: correctSlot } =
            makeSVBlock(t('correctLabel'));
        const { block: guessBlock, svDiv: guessSVDiv, rightRow: guessInfo, mapLinkSlot: guessSlot } =
            makeSVBlock(t('guessLabel'));

        const divider = document.createElement('div');
        divider.setAttribute('style', 'width:2px;background:rgba(255,255,255,0.12);flex-shrink:0;');

        if (CFG_LAYOUT_REVERSED) {
            root.appendChild(guessBlock);
            root.appendChild(divider);
            root.appendChild(correctBlock);
        } else {
            root.appendChild(correctBlock);
            root.appendChild(divider);
            root.appendChild(guessBlock);
        }

        resultBottom.parentElement.insertBefore(root, resultBottom);
        setTimeout(() => zoomOutMap(3), 300);

        // ===== 正解地点 =====
        const decodedPanoId = decodePanoId(round.panoId);
        if (decodedPanoId) {
            const panoInfo = await getPanoInfo(decodedPanoId);
            renderSV(correctSVDiv, decodedPanoId, round.heading || 0, round.pitch || 0);
            correctInfo.textContent = buildLocText(
                round.streakLocationCode,
                panoInfo?.description || ''
            );
            correctSlot.appendChild(makeMapLinkBtn(makeMapsUrl(round.lat, round.lng, decodedPanoId)));
        } else {
            const nearest = await findNearestPano(round.lat, round.lng);
            if (nearest) {
                renderSV(correctSVDiv, nearest.panoId, round.heading || 0, round.pitch || 0);
                correctInfo.textContent = buildLocText(round.streakLocationCode, nearest.description);
                correctSlot.appendChild(makeMapLinkBtn(makeMapsUrl(nearest.lat, nearest.lng, nearest.panoId)));
            } else {
                correctInfo.textContent = round.streakLocationCode
                    ? `[${round.streakLocationCode.toUpperCase()}] ${t('svNotFound')}`
                    : t('svNotFound');
            }
        }

        // ===== Guess周辺 =====
        const guessNearest = await findNearestPano(guess.lat, guess.lng);
        if (guessNearest) {
            const distM = haversineM(guess.lat, guess.lng, guessNearest.lat, guessNearest.lng);
            guessInfo.textContent = buildLocText(
                guess.streakLocationCode || '',
                guessNearest.description,
                formatDist(distM) + t('distanceSuffix')
            );
            renderSV(guessSVDiv, guessNearest.panoId);
            guessSlot.appendChild(makeMapLinkBtn(makeMapsUrl(guessNearest.lat, guessNearest.lng, guessNearest.panoId)));
        } else {
            guessInfo.textContent = t('guessNoSV');
        }
    };

    // =============================================
    // Google Maps URL生成
    // =============================================
    const makeMapsUrl = (lat, lng, panoId) => panoId
        ? `https://www.google.com/maps/@${lat},${lng},3a,90y,0h,90t/data=!3m4!1e1!3m2!1s${encodeURIComponent(panoId)}!2e0`
        : `https://www.google.com/maps/@${lat},${lng},15z`;

    // =============================================
    // リセット・初期化
    // =============================================
    const resetState = () => {
        isProcessing = false;
        lastRoundIdx = -1;
        panelVisible = false;
        document.querySelector('[data-qa="result-view-top"]')?.removeAttribute('style');
        document.getElementById('gg-sv-root')?.remove();
        document.getElementById('gg-sv-toggle')?.remove();
        console.log('[GeoDiff] reset:', getGameId());
    };

    // initGame: /game/ ページに来たときに observer を確実に起動する関数
    const initGame = () => {
        const newId = getGameId();
        if (!isGamePage()) {
            // /game/ を離れたら observer を停める
            if (observerActive) {
                observer.disconnect();
                observerActive = false;
            }
            return;
        }
        if (newId !== lastGameId) {
            lastGameId = newId;
            resetState();
        }
        if (!observerActive) {
            observer.observe(document.body, { childList: true, subtree: true });
            observerActive = true;
            console.log('[GeoDiff] observer started for:', newId);
        }
    };

    // =============================================
    // ラウンド結果処理
    // =============================================
    const onRoundResult = async () => {
        if (isProcessing) return;
        if (!document.querySelector('[data-qa="result-view-top"]')) return;
        if (document.querySelector('[data-qa="final-result-score"]')) return;

        isProcessing = true;
        try {
            const data = await fetchGameData(getGameId());
            const guesses = data.player?.guesses ?? [];
            const roundIdx = guesses.length - 1;

            if (roundIdx < 0 || roundIdx === lastRoundIdx) return;
            lastRoundIdx = roundIdx;

            const guess = guesses[roundIdx];
            const round = data.rounds?.[roundIdx];
            if (!guess || !round) return;

            lastDistM = haversineM(guess.lat, guess.lng, round.lat, round.lng);
            lastGuess = guess;
            lastRound = round;

            const shouldAutoShow = CFG_AUTO_SHOW && (lastDistM / 1000 >= CFG_MIN_DIST_KM);

            // bserver を止めてパネル操作、終わったら再開(元のコードと同じ方針を維持)
            observer.disconnect();
            observerActive = false;

            if (shouldAutoShow) {
                panelVisible = true;
                await buildPanel(guess, round);
            } else {
                panelVisible = false;
                await new Promise(r => setTimeout(r, 200));
                injectToggleButton();
            }

            observer.observe(document.body, { childList: true, subtree: true });
            observerActive = true;
        } catch (e) {
            console.error('[GeoDiff]', e);
        } finally {
            isProcessing = false;
        }
    };

    // =============================================
    // URL変化検知(SPA対応)
    // =============================================
    const _origPushState = history.pushState.bind(history);
    const _origReplaceState = history.replaceState.bind(history);

    history.pushState = function (...args) {
        _origPushState(...args);
        setTimeout(initGame, 0); // setTimeout(,0) でURL確定後に実行
    };

    history.replaceState = function (...args) {
        _origReplaceState(...args);
        setTimeout(initGame, 0);
    };

    window.addEventListener('popstate', () => setTimeout(initGame, 0));

    // =============================================
    // MutationObserver
    // =============================================
    const observer = new MutationObserver(() => {
        if (!isGamePage()) return; // /game/ 以外では何もしない

        if (document.querySelector('[data-qa="final-result-score"]')) {
            if (document.getElementById('gg-sv-root') || document.getElementById('gg-sv-toggle')) resetState();
            return;
        }

        if (isProcessing) return;

        if (document.querySelector('[data-qa="result-view-top"]') &&
            !document.getElementById('gg-sv-root') &&
            !document.getElementById('gg-sv-toggle')) {
            onRoundResult();
            return;
        }

        if (!CFG_AUTO_SHOW &&
            document.querySelector('[data-qa="result-view-top"]') &&
            !document.getElementById('gg-sv-toggle') &&
            document.querySelector('[data-qa="close-round-result"]')) {
            injectToggleButton();
        }
    });

    // =============================================
    // 初回起動
    // document-start なので DOM がまだない場合がある。
    //          DOMContentLoaded 後に initGame を呼ぶ
    // =============================================
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initGame);
    } else {
        initGame(); // すでに DOM が存在する場合(リロード等)
    }

    console.log('[GeoDiff] v3.6 loaded');
})();