GeoDiff

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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